diff --git a/packages/manager/.changeset/pr-11464-upcoming-features-1735566884863.md b/packages/manager/.changeset/pr-11464-upcoming-features-1735566884863.md
new file mode 100644
index 00000000000..ff755a9d6f2
--- /dev/null
+++ b/packages/manager/.changeset/pr-11464-upcoming-features-1735566884863.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Upcoming Features
+---
+
+Exhaustive unit tests for CloudPulse widgets ([#11464](https://github.com/linode/manager/pull/11464))
diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts
new file mode 100644
index 00000000000..0d1985230b3
--- /dev/null
+++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts
@@ -0,0 +1,237 @@
+import { formatPercentage } from 'src/utilities/statMetrics';
+
+import {
+ generateGraphData,
+ generateMaxUnit,
+ getDimensionName,
+ getLabelName,
+ mapResourceIdToName,
+} from './CloudPulseWidgetUtils';
+
+import type { CloudPulseMetricsResponse } from '@linode/api-v4';
+import type { MetricsDisplayRow } from 'src/components/LineGraph/MetricsDisplay';
+
+describe('generateMaxUnit method', () => {
+ it('returns the appropriate unit for mixed data values', () => {
+ const legendRowsData: MetricsDisplayRow[] = [
+ {
+ data: { average: 1500, last: 1600, length: 3, max: 2000, total: 4500 },
+ format: formatPercentage,
+ legendColor: '#000',
+ legendTitle: 'linode-1',
+ },
+ {
+ data: {
+ average: 2000000,
+ last: 2100000,
+ length: 3,
+ max: 2500000,
+ total: 6000000,
+ },
+ format: formatPercentage,
+ legendColor: '#000',
+ legendTitle: 'linode-2',
+ },
+ ];
+
+ const result = generateMaxUnit(legendRowsData, 'Bytes');
+ expect(result).toBe('MB');
+ });
+
+ it('returns correct unit for empty array', () => {
+ const legendRowsData: MetricsDisplayRow[] = [];
+ const result = generateMaxUnit(legendRowsData, 'Bytes');
+ expect(result).toBe('B');
+ });
+
+ it('returns correct unit when max is zero', () => {
+ const legendRowsData: MetricsDisplayRow[] = [
+ {
+ data: { average: 0, last: 0, length: 3, max: 0, total: 0 },
+ format: formatPercentage,
+ legendColor: '#000',
+ legendTitle: 'linode-1',
+ },
+ ];
+ const result = generateMaxUnit(legendRowsData, 'Bytes');
+ expect(result).toBe('B');
+ });
+});
+
+describe('getLabelName method', () => {
+ const baseProps = {
+ flags: {
+ aclpResourceTypeMap: [
+ { dimensionKey: 'resource_id', serviceType: 'linode' },
+ ],
+ },
+ label: 'CPU Usage',
+ metric: { resource_id: '123' },
+ resources: [{ id: '123', label: 'linode-1' }],
+ serviceType: 'linode',
+ unit: '%',
+ };
+
+ it('returns resource label when all data is valid', () => {
+ const result = getLabelName(baseProps);
+ expect(result).toBe('linode-1');
+ });
+
+ it('returns resource_id when resource is not found in resources array', () => {
+ const props = {
+ ...baseProps,
+ metric: { resource_id: '999' },
+ };
+ const result = getLabelName(props);
+ expect(result).toBe('999');
+ });
+
+ it('returns empty string when resource_id is empty', () => {
+ const props = {
+ ...baseProps,
+ metric: { resource_id: '' },
+ };
+ const result = getLabelName(props);
+ expect(result).toBe('');
+ });
+});
+
+it('test generateGraphData with metrics data', () => {
+ const mockMetricsResponse: CloudPulseMetricsResponse = {
+ data: {
+ result: [
+ {
+ metric: { resource_id: '1' },
+ values: [[1234567890, '50']],
+ },
+ ],
+ result_type: 'matrix',
+ },
+ isPartial: false,
+ stats: {
+ series_fetched: 1,
+ },
+ status: 'success',
+ };
+
+ const result = generateGraphData({
+ flags: {
+ aclpResourceTypeMap: [
+ { dimensionKey: 'resource_id', serviceType: 'linode' },
+ ],
+ },
+ label: 'Graph',
+ metricsList: mockMetricsResponse,
+ resources: [{ id: '1', label: 'linode-1' }],
+ serviceType: 'linode',
+ status: 'success',
+ unit: '%',
+ });
+
+ expect(result.areas[0].dataKey).toBe('linode-1');
+ expect(result.dimensions).toEqual([
+ {
+ 'linode-1': 50,
+ timestamp: 1234567890000,
+ },
+ ]);
+
+ expect(result.legendRowsData[0].data).toEqual({
+ average: 50,
+ last: 50,
+ length: 1,
+ max: 50,
+ total: 50,
+ });
+ expect(result.legendRowsData[0].format).toBeDefined();
+ expect(result.legendRowsData[0].legendTitle).toBe('linode-1');
+ expect(result.unit).toBe('%');
+});
+
+describe('getDimensionName method', () => {
+ const baseProps = {
+ flag: { dimensionKey: 'resource_id', serviceType: 'linode' },
+ metric: { resource_id: '123' },
+ resources: [{ id: '123', label: 'linode-1' }],
+ };
+
+ it('returns resource label when all data is valid', () => {
+ const result = getDimensionName(baseProps);
+ expect(result).toBe('linode-1');
+ });
+
+ it('returns resource_id when flag is undefined', () => {
+ const props = {
+ ...baseProps,
+ flag: undefined,
+ };
+ const result = getDimensionName(props);
+ expect(result).toBe('123');
+ });
+
+ it('returns empty string when metric is empty', () => {
+ const props = {
+ ...baseProps,
+ metric: {},
+ };
+ const result = getDimensionName(props);
+ expect(result).toBe('');
+ });
+
+ it('returns value directly when key does not match dimensionKey', () => {
+ const props = {
+ ...baseProps,
+ metric: { other_key: '456' },
+ };
+ const result = getDimensionName(props);
+ expect(result).toBe('456');
+ });
+
+ it('joins multiple metric values with underscore', () => {
+ const props = {
+ ...baseProps,
+ metric: { other_key: 'test', resource_id: '123' },
+ };
+ const result = getDimensionName(props);
+ expect(result).toBe('test_linode-1');
+ });
+
+ it('handles empty metric values by filtering them out', () => {
+ const props = {
+ ...baseProps,
+ metric: { other_key: '', resource_id: '123' },
+ };
+ const result = getDimensionName(props);
+ expect(result).toBe('linode-1');
+ });
+
+ it('returns resource_id directly when resources array is empty', () => {
+ const props = {
+ ...baseProps,
+ resources: [],
+ };
+ const result = getDimensionName(props);
+ expect(result).toBe('123');
+ });
+
+ it('returns empty string when both resource_id is empty and flag is undefined', () => {
+ const props = {
+ ...baseProps,
+ flag: undefined,
+ metric: { resource_id: '' },
+ };
+ const result = getDimensionName(props);
+ expect(result).toBe('');
+ });
+});
+
+it('test mapResourceIdToName method', () => {
+ const resources = [
+ { id: '123', label: 'linode-1' },
+ { id: '456', label: 'inode-2' },
+ ];
+
+ expect(mapResourceIdToName('123', resources)).toBe('linode-1');
+ expect(mapResourceIdToName('999', resources)).toBe('999');
+ expect(mapResourceIdToName(undefined, resources)).toBe('');
+});
diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts
index f4066808b3d..ae540c93146 100644
--- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts
+++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts
@@ -267,7 +267,7 @@ export const generateGraphData = (props: GraphDataOptionsProps): GraphData => {
* @param unit base unit of the values
* @returns maximum possible rolled up unit based on the unit
*/
-const generateMaxUnit = (legendRowsData: MetricsDisplayRow[], unit: string) => {
+export const generateMaxUnit = (legendRowsData: MetricsDisplayRow[], unit: string) => {
const maxValue = Math.max(
0,
...legendRowsData?.map((row) => row?.data.max ?? 0)
@@ -307,7 +307,7 @@ export const getCloudPulseMetricRequest = (
*
* @returns generated label name for graph dimension
*/
-const getLabelName = (props: LabelNameOptionsProps): string => {
+export const getLabelName = (props: LabelNameOptionsProps): string => {
const { flags, label, metric, resources, serviceType, unit } = props;
// aggregated metric, where metric keys will be 0
if (!Object.keys(metric).length) {
diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.test.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.test.tsx
new file mode 100644
index 00000000000..716883ed945
--- /dev/null
+++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.test.tsx
@@ -0,0 +1,196 @@
+import userEvent from '@testing-library/user-event';
+import React from 'react';
+
+import {
+ cloudPulseMetricsResponseDataFactory,
+ widgetFactory,
+} from 'src/factories';
+import * as CloudPulseWidgetUtils from 'src/features/CloudPulse/Utils/CloudPulseWidgetUtils';
+import { formatPercentage } from 'src/utilities/statMetrics';
+import { renderWithTheme } from 'src/utilities/testHelpers';
+
+import { CloudPulseWidget } from './CloudPulseWidget';
+
+import type { CloudPulseWidgetProperties } from './CloudPulseWidget';
+
+const props: CloudPulseWidgetProperties = {
+ additionalFilters: [],
+ ariaLabel: 'CPU Utilization',
+ availableMetrics: {
+ available_aggregate_functions: ['min', 'max', 'avg'],
+ dimensions: [],
+ label: 'CPU utilization',
+ metric: 'system_cpu_utilization_percent',
+ metric_type: 'gauge',
+ scrape_interval: '2m',
+ unit: 'percent',
+ },
+ duration: { unit: 'min', value: 30 },
+ isJweTokenFetching: false,
+ resourceIds: ['1', '2'],
+ resources: [
+ {
+ id: '1',
+ label: 'test-1',
+ },
+ {
+ id: '2',
+ label: 'test-2',
+ },
+ ],
+ savePref: true,
+ serviceType: 'linode',
+ unit: '%',
+ widget: widgetFactory.build({
+ label: 'CPU Utilization',
+ }),
+};
+
+const queryMocks = vi.hoisted(() => ({
+ useCloudPulseMetricsQuery: vi.fn().mockReturnValue({}),
+}));
+
+vi.mock('src/queries/cloudpulse/metrics', async () => {
+ const actual = await vi.importActual('src/queries/cloudpulse/metrics');
+ return {
+ ...actual,
+ useCloudPulseMetricsQuery: queryMocks.useCloudPulseMetricsQuery,
+ };
+});
+
+queryMocks.useCloudPulseMetricsQuery.mockReturnValue({
+ data: cloudPulseMetricsResponseDataFactory.build(),
+ isError: false,
+ isLoading: false,
+ status: 'success',
+});
+
+const mockMetrics = {
+ average: 5.5,
+ last: 7.75,
+ length: 3,
+ max: 10,
+ total: 40,
+};
+const graphData = {
+ areas: [
+ {
+ color: '#1CB35C',
+ dataKey: 'test-1',
+ },
+ ],
+ dimensions: [],
+ legendRowsData: [
+ {
+ data: mockMetrics,
+ format: formatPercentage,
+ handleLegendClick: vi.fn(),
+ legendColor: 'blue',
+ legendTitle: 'Test',
+ },
+ ],
+ unit: '%',
+};
+vi.spyOn(CloudPulseWidgetUtils, 'generateGraphData').mockReturnValue(graphData);
+
+class ResizeObserver {
+ disconnect() {}
+ observe() {}
+ unobserve() {}
+}
+
+const mockUpdatePreferences = vi.fn();
+vi.mock('../Utils/UserPreference', () => ({
+ useAclpPreference: () => ({
+ updateWidgetPreference: mockUpdatePreferences,
+ }),
+}));
+
+describe('Cloud pulse widgets', () => {
+ window.ResizeObserver = ResizeObserver;
+
+ it('should render widget with all required components', () => {
+ const { container, getByTestId, getByText } = renderWithTheme(
+
+ );
+
+ // Verify widget title and unit
+ expect(getByText('CPU Utilization (%)')).toBeInTheDocument();
+
+ // Verify interval select
+ expect(getByTestId('Data aggregation interval')).toBeInTheDocument();
+
+ // Verify aggregate function select
+ expect(getByTestId('Aggregation function')).toBeInTheDocument();
+
+ // Verify zoom icon
+ expect(getByTestId('zoom-in')).toBeInTheDocument();
+
+ // Verify graph component
+ expect(
+ container.querySelector('.recharts-responsive-container')
+ ).toBeInTheDocument();
+ });
+
+ it('should show error state when metrics fetch fails', () => {
+ queryMocks.useCloudPulseMetricsQuery.mockReturnValue({
+ data: undefined,
+ error: [{ reason: 'test reason' }],
+ isLoading: false,
+ status: 'error',
+ });
+
+ const { getByText } = renderWithTheme();
+ expect(getByText('test reason')).toBeInTheDocument();
+ });
+
+ it('should update preferences for zoom toggle', async () => {
+ const { getByTestId } = renderWithTheme();
+ const zoomButton = getByTestId('zoom-in');
+ await userEvent.click(zoomButton);
+ expect(mockUpdatePreferences).toHaveBeenCalledWith('CPU Utilization', {
+ size: 6,
+ });
+ });
+
+ it('should update preferences for aggregation function select', async () => {
+ const { getByRole } = renderWithTheme();
+
+ await userEvent.click(
+ getByRole('combobox', { name: 'Select an Aggregate Function' })
+ );
+
+ await userEvent.click(getByRole('option', { name: 'Max' }));
+
+ expect(mockUpdatePreferences).toHaveBeenCalledWith('CPU Utilization', {
+ aggregateFunction: 'max',
+ });
+
+ expect(queryMocks.useCloudPulseMetricsQuery).toHaveBeenCalledWith(
+ 'linode',
+ expect.objectContaining({
+ aggregate_function: 'max',
+ }),
+ expect.any(Object)
+ );
+ });
+
+ it('should update preferences for interval select', async () => {
+ const { getByRole } = renderWithTheme();
+ await userEvent.click(
+ getByRole('combobox', { name: 'Select an Interval' })
+ );
+ await userEvent.click(getByRole('option', { name: '5 min' }));
+
+ expect(mockUpdatePreferences).toHaveBeenCalledWith('CPU Utilization', {
+ timeGranularity: { unit: 'min', value: 5 },
+ });
+ expect(queryMocks.useCloudPulseMetricsQuery).toHaveBeenCalledWith(
+ 'linode',
+ expect.objectContaining({
+ time_granularity: { unit: 'min', value: 5 }
+ }),
+ expect.any(Object)
+ );
+ });
+});
diff --git a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.test.tsx b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.test.tsx
new file mode 100644
index 00000000000..0e5cd7ab381
--- /dev/null
+++ b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.test.tsx
@@ -0,0 +1,74 @@
+import React from 'react';
+
+import { renderWithTheme } from 'src/utilities/testHelpers';
+
+import { CloudPulseLineGraph } from './CloudPulseLineGraph';
+
+const mockData = {
+ areas: [
+ {
+ color: 'theme.color.green',
+ dataKey: 'system_cpu_utilization_percent',
+ },
+ ],
+ ariaLabel: 'CPU Utilization',
+ data: [
+ { system_cpu_utilization_percent: 10, timestamp: 1672531200000 },
+ { system_cpu_utilization_percent: 20, timestamp: 1672617600000 },
+ { system_cpu_utilization_percent: 30, timestamp: 1672704000000 },
+ ],
+ error: undefined,
+ loading: false,
+ timezone: 'UTC',
+ unit: '%',
+ xAxis: {
+ tickFormat: 'HH:mm',
+ tickGap: 50,
+ },
+};
+
+class ResizeObserver {
+ disconnect() {}
+ observe() {}
+ unobserve() {}
+}
+
+describe('CloudPulseLineGraph', () => {
+ window.ResizeObserver = ResizeObserver;
+
+ it('should render AreaChart when data is provided', () => {
+ const { container, getByRole } = renderWithTheme(
+
+ );
+ const table = getByRole('table');
+
+ expect(
+ container.querySelector('.recharts-responsive-container')
+ ).toBeInTheDocument();
+ expect(table).toHaveAttribute(
+ 'summary',
+ 'This table contains the data for the CPU Utilization (system_cpu_utilization_percent)'
+ );
+ });
+
+ it('should show error state', () => {
+ const { getByText } = renderWithTheme(
+
+ );
+
+ expect(getByText('Test error')).toBeInTheDocument();
+ });
+
+ it('should show no data message when data array is empty', () => {
+ const emptyData = {
+ ...mockData,
+ data: [],
+ };
+
+ const { getByText } = renderWithTheme(
+
+ );
+
+ expect(getByText('No data to display')).toBeInTheDocument();
+ });
+});