diff --git a/packages/grafana-prometheus/src/components/PromQueryEditorByApp.test.tsx b/packages/grafana-prometheus/src/components/PromQueryEditorByApp.test.tsx index ee2c7613b69..640fe7e64bb 100644 --- a/packages/grafana-prometheus/src/components/PromQueryEditorByApp.test.tsx +++ b/packages/grafana-prometheus/src/components/PromQueryEditorByApp.test.tsx @@ -30,7 +30,7 @@ function setup(app: CoreApp): { onRunQuery: jest.Mock } { start: () => Promise.resolve([]), syntax: () => {}, getLabelKeys: () => [], - metrics: [], + retrieveMetrics: () => [], }, } as unknown as PrometheusDatasource; const onRunQuery = jest.fn(); diff --git a/packages/grafana-prometheus/src/components/PromQueryField.test.tsx b/packages/grafana-prometheus/src/components/PromQueryField.test.tsx index 44482da9a28..9c1c27f03b7 100644 --- a/packages/grafana-prometheus/src/components/PromQueryField.test.tsx +++ b/packages/grafana-prometheus/src/components/PromQueryField.test.tsx @@ -29,7 +29,7 @@ const defaultProps = { start: () => Promise.resolve([]), syntax: () => {}, getLabelKeys: () => [], - metrics: [], + retrieveMetrics: () => [], }, } as unknown as PrometheusDatasource, query: { diff --git a/packages/grafana-prometheus/src/components/PromQueryField.tsx b/packages/grafana-prometheus/src/components/PromQueryField.tsx index 1e13895b50b..c6b244816b2 100644 --- a/packages/grafana-prometheus/src/components/PromQueryField.tsx +++ b/packages/grafana-prometheus/src/components/PromQueryField.tsx @@ -44,7 +44,7 @@ export const PromQueryField = (props: PromQueryFieldProps) => { const [labelBrowserVisible, setLabelBrowserVisible] = useState(false); const updateLanguage = useCallback(() => { - if (languageProvider.metrics) { + if (languageProvider.retrieveMetrics()) { setSyntaxLoaded(true); } }, [languageProvider]); diff --git a/packages/grafana-prometheus/src/components/metrics-browser/MetricsBrowserContext.test.tsx b/packages/grafana-prometheus/src/components/metrics-browser/MetricsBrowserContext.test.tsx index a2c1c0d43ba..2b8a3a5158b 100644 --- a/packages/grafana-prometheus/src/components/metrics-browser/MetricsBrowserContext.test.tsx +++ b/packages/grafana-prometheus/src/components/metrics-browser/MetricsBrowserContext.test.tsx @@ -32,20 +32,14 @@ Object.defineProperty(window, 'localStorage', { value: localStorageMock }); const setupLanguageProviderMock = () => { const mockTimeRange = getMockTimeRange(); const mockLanguageProvider = { - metrics: ['metric1', 'metric2', 'metric3'], - labelKeys: ['__name__', 'instance', 'job', 'service'], - metricsMetadata: { + retrieveMetrics: () => ['metric1', 'metric2', 'metric3'], + retrieveLabelKeys: () => ['__name__', 'instance', 'job', 'service'], + retrieveMetricsMetadata: () => ({ metric1: { type: 'counter', help: 'Test metric 1' }, metric2: { type: 'gauge', help: 'Test metric 2' }, - }, - fetchLabels: jest.fn().mockResolvedValue(['__name__', 'instance', 'job', 'service']), - fetchSeriesLabelsMatch: jest.fn().mockResolvedValue({ - __name__: ['metric1', 'metric2'], - instance: ['instance1', 'instance2'], - job: ['job1', 'job2'], - service: ['service1', 'service2'], }), - fetchSeriesValuesWithMatch: jest.fn().mockImplementation((_timeRange: TimeRange, label: string) => { + queryLabelKeys: jest.fn().mockResolvedValue(['__name__', 'instance', 'job', 'service']), + queryLabelValues: jest.fn().mockImplementation((_timeRange: TimeRange, label: string) => { if (label === 'job') { return Promise.resolve(['grafana', 'prometheus']); } @@ -57,10 +51,6 @@ const setupLanguageProviderMock = () => { } return Promise.resolve([]); }), - fetchLabelsWithMatch: jest.fn().mockResolvedValue({ - job: ['job1', 'job2'], - instance: ['instance1', 'instance2'], - }), } as unknown as PrometheusLanguageProviderInterface; return { mockTimeRange, mockLanguageProvider }; @@ -298,34 +288,54 @@ describe('MetricsBrowserContext', () => { describe('selector operations', () => { it('should clear all selections', async () => { const user = userEvent.setup(); - const { renderWithProvider } = setupTest(); + const { renderWithProvider, mockLanguageProvider } = setupTest(); renderWithProvider(); - // Wait for component to be ready + // Wait for initial data load await waitFor(() => { expect(screen.getByTestId('metrics-count').textContent).toBe('3'); }); - // Make selections + // Step 1: Select a metric await user.click(screen.getByTestId('select-metric')); - await user.click(screen.getByTestId('select-label')); - await user.click(screen.getByTestId('select-label-value')); - - // Verify selections await waitFor(() => { + expect(mockLanguageProvider.queryLabelKeys).toHaveBeenCalled(); expect(screen.getByTestId('selected-metric').textContent).toBe('metric1'); - expect(screen.getByTestId('selected-label-keys').textContent).toBe('job'); - expect(screen.getByTestId('selector').textContent).not.toBe('{}'); }); - // Clear all selections + // Step 2: Select a label + await user.click(screen.getByTestId('select-label')); + await waitFor(() => { + expect(mockLanguageProvider.queryLabelValues).toHaveBeenCalledWith( + expect.anything(), + 'job', + expect.anything(), + expect.anything() + ); + expect(screen.getByTestId('selected-label-keys').textContent).toBe('job'); + }); + + // Step 3: Select a label value + await user.click(screen.getByTestId('select-label-value')); + await waitFor(() => { + expect(screen.getByTestId('selector').textContent).toContain('job="grafana"'); + }); + + // Step 4: Clear all selections await user.click(screen.getByTestId('clear')); - // Verify all fields are cleared + // Verify everything is cleared await waitFor(() => { + // Check that all selections are cleared expect(screen.getByTestId('selected-metric').textContent).toBe(''); expect(screen.getByTestId('selected-label-keys').textContent).toBe(''); expect(screen.getByTestId('selector').textContent).toBe('{}'); + + // Verify localStorage was cleared + const mockCalls = localStorageMock.setItem.mock.calls; + const lastCall = mockCalls[mockCalls.length - 1]; + expect(lastCall[0]).toBe(LAST_USED_LABELS_KEY); + expect(JSON.parse(lastCall[1])).toEqual([]); }); }); diff --git a/packages/grafana-prometheus/src/components/metrics-browser/useMetricsLabelsValues.test.ts b/packages/grafana-prometheus/src/components/metrics-browser/useMetricsLabelsValues.test.ts index 2cda264a78b..b16de7b749a 100644 --- a/packages/grafana-prometheus/src/components/metrics-browser/useMetricsLabelsValues.test.ts +++ b/packages/grafana-prometheus/src/components/metrics-browser/useMetricsLabelsValues.test.ts @@ -1,6 +1,6 @@ import { act, renderHook, waitFor } from '@testing-library/react'; -import { TimeRange, dateTime } from '@grafana/data'; +import { TimeRange } from '@grafana/data'; import { PrometheusLanguageProviderInterface } from '../../language_provider'; import { getMockTimeRange } from '../../test/__mocks__/datasource'; @@ -11,7 +11,7 @@ import { useMetricsLabelsValues } from './useMetricsLabelsValues'; // Test utilities to reduce boilerplate const setupMocks = () => { - // Mock the buildSelector module - we need to mock the whole module + // Mock the buildSelector module jest.spyOn(selectorBuilderModule, 'buildSelector').mockImplementation(() => EMPTY_SELECTOR); // Mock localStorage @@ -30,42 +30,41 @@ const setupMocks = () => { Object.defineProperty(window, 'localStorage', { value: localStorageMock }); // Mock language provider - const mockLanguageProvider = { - metrics: ['metric1', 'metric2', 'metric3'], - labelKeys: ['__name__', 'instance', 'job', 'service'], - metricsMetadata: { + const mockLanguageProvider: PrometheusLanguageProviderInterface = { + retrieveMetrics: jest.fn().mockReturnValue(['metric1', 'metric2', 'metric3']), + retrieveLabelKeys: jest.fn().mockReturnValue(['__name__', 'instance', 'job', 'service']), + retrieveMetricsMetadata: jest.fn().mockReturnValue({ metric1: { type: 'counter', help: 'Test metric 1' }, metric2: { type: 'gauge', help: 'Test metric 2' }, - }, - fetchLabelValues: jest.fn(), - fetchLabels: jest.fn(), - fetchSeriesValuesWithMatch: jest.fn(), - fetchSeriesLabelsMatch: jest.fn(), - fetchLabelsWithMatch: jest.fn(), + }), + queryLabelValues: jest.fn(), + queryLabelKeys: jest.fn(), } as unknown as PrometheusLanguageProviderInterface; // Mock standard responses - (mockLanguageProvider.fetchLabelValues as jest.Mock).mockResolvedValue(['metric1', 'metric2', 'metric3']); - (mockLanguageProvider.fetchLabels as jest.Mock).mockResolvedValue(['__name__', 'instance', 'job', 'service']); - (mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mockImplementation( - (_timeRange: TimeRange, label: string) => { - if (label === 'job') { - return Promise.resolve(['grafana', 'prometheus']); - } - if (label === 'instance') { - return Promise.resolve(['host1', 'host2']); - } - if (label === METRIC_LABEL) { - return Promise.resolve(['metric1', 'metric2', 'metric3']); - } - return Promise.resolve([]); + (mockLanguageProvider.queryLabelValues as jest.Mock).mockImplementation((_timeRange: TimeRange, label: string) => { + if (label === 'job') { + return Promise.resolve(['grafana', 'prometheus']); } - ); - (mockLanguageProvider.fetchSeriesLabelsMatch as jest.Mock).mockResolvedValue({ - __name__: ['metric1', 'metric2'], - instance: ['instance1', 'instance2'], - job: ['job1', 'job2'], - service: ['service1', 'service2'], + if (label === 'instance') { + return Promise.resolve(['host1', 'host2']); + } + if (label === METRIC_LABEL) { + return Promise.resolve(['metric1', 'metric2', 'metric3']); + } + return Promise.resolve([]); + }); + + (mockLanguageProvider.queryLabelKeys as jest.Mock).mockImplementation((_timeRange: TimeRange, selector?: string) => { + if (selector) { + return Promise.resolve({ + __name__: ['metric1', 'metric2'], + instance: ['instance1', 'instance2'], + job: ['job1', 'job2'], + service: ['service1', 'service2'], + }); + } + return Promise.resolve(['__name__', 'instance', 'job', 'service']); }); const mockTimeRange: TimeRange = getMockTimeRange(); @@ -73,61 +72,42 @@ const setupMocks = () => { return { mockLanguageProvider, mockTimeRange, localStorageMock }; }; -// Suppress console during tests -const setupConsoleMocks = () => { - const originalConsoleLog = console.log; - const originalConsoleError = console.error; - - console.log = jest.fn(); - console.error = jest.fn(); - - return () => { - console.log = originalConsoleLog; - console.error = originalConsoleError; - }; -}; - // Helper to render hook with standard initialization const renderHookWithInit = async (mocks: ReturnType) => { const { result } = renderHook(() => useMetricsLabelsValues(mocks.mockTimeRange, mocks.mockLanguageProvider)); // Wait for initialization await waitFor(() => { - expect(mocks.mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled(); + expect(mocks.mockLanguageProvider.queryLabelValues).toHaveBeenCalled(); }); return { result }; }; describe('useMetricsLabelsValues', () => { - // Set up and tear down hooks for each test let mocks: ReturnType; - let restoreConsole: ReturnType; beforeEach(() => { mocks = setupMocks(); - restoreConsole = setupConsoleMocks(); jest.clearAllMocks(); }); afterEach(() => { - restoreConsole(); jest.restoreAllMocks(); }); describe('initialization', () => { - it('should initialize by fetching metrics from language provider', async () => { + it('should initialize by fetching metrics', async () => { renderHook(() => useMetricsLabelsValues(mocks.mockTimeRange, mocks.mockLanguageProvider)); await waitFor(() => { - expect(mocks.mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled(); + expect(mocks.mockLanguageProvider.queryLabelValues).toHaveBeenCalled(); }); - expect(mocks.mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalledWith( + expect(mocks.mockLanguageProvider.queryLabelValues).toHaveBeenCalledWith( expect.anything(), METRIC_LABEL, undefined, - 'MetricsBrowser_M', DEFAULT_SERIES_LIMIT ); }); @@ -136,10 +116,10 @@ describe('useMetricsLabelsValues', () => { renderHook(() => useMetricsLabelsValues(mocks.mockTimeRange, mocks.mockLanguageProvider)); await waitFor(() => { - expect(mocks.mockLanguageProvider.fetchLabels).toHaveBeenCalled(); + expect(mocks.mockLanguageProvider.queryLabelKeys).toHaveBeenCalled(); }); - expect(mocks.mockLanguageProvider.fetchLabels).toHaveBeenCalledWith( + expect(mocks.mockLanguageProvider.queryLabelKeys).toHaveBeenCalledWith( expect.anything(), undefined, DEFAULT_SERIES_LIMIT @@ -152,1169 +132,415 @@ describe('useMetricsLabelsValues', () => { renderHook(() => useMetricsLabelsValues(mocks.mockTimeRange, mocks.mockLanguageProvider)); await waitFor(() => { - const fetchCalls = (mocks.mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mock.calls; - const jobCall = fetchCalls.find((call) => call[1] === 'job' && call[3] === 'MetricsBrowser_LV_job'); - const instanceCall = fetchCalls.find( - (call) => call[1] === 'instance' && call[3] === 'MetricsBrowser_LV_instance' - ); + const fetchCalls = (mocks.mockLanguageProvider.queryLabelValues as jest.Mock).mock.calls; + const jobCall = fetchCalls.find((call) => call[1] === 'job'); + const instanceCall = fetchCalls.find((call) => call[1] === 'instance'); return jobCall && instanceCall; }); - expect(mocks.mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalledWith( + expect(mocks.mockLanguageProvider.queryLabelValues).toHaveBeenCalledWith( expect.anything(), 'job', undefined, - 'MetricsBrowser_LV_job', DEFAULT_SERIES_LIMIT ); - expect(mocks.mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalledWith( + expect(mocks.mockLanguageProvider.queryLabelValues).toHaveBeenCalledWith( expect.anything(), 'instance', undefined, - 'MetricsBrowser_LV_instance', + DEFAULT_SERIES_LIMIT + ); + }); + }); + + describe('metric selection', () => { + it('should handle metric selection', async () => { + const { result } = await renderHookWithInit(mocks); + + await act(async () => { + await result.current.handleSelectedMetricChange('metric1'); + }); + + expect(result.current.selectedMetric).toBe('metric1'); + expect(mocks.mockLanguageProvider.queryLabelKeys).toHaveBeenCalledWith( + expect.anything(), + undefined, DEFAULT_SERIES_LIMIT ); }); - it('should set label values as string arrays', async () => { - mocks.localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job'])); - - const { result } = renderHook(() => useMetricsLabelsValues(mocks.mockTimeRange, mocks.mockLanguageProvider)); - - await waitFor(() => { - return result.current.labelValues.job !== undefined; - }); - - expect(Array.isArray(result.current.labelValues.job)).toBe(true); - expect(result.current.labelValues.job).toEqual(['grafana', 'prometheus']); - }); - }); - - describe('handleSelectedMetricChange', () => { - it('should select a metric when not previously selected', async () => { + it('should clear metric selection when selecting same metric', async () => { const { result } = await renderHookWithInit(mocks); - // Clear mock calls before testing - jest.clearAllMocks(); - - // Select a metric - await act(async () => { - await result.current.handleSelectedMetricChange('metric1'); - }); - - // Verify the metric was selected - expect(result.current.selectedMetric).toBe('metric1'); - }); - - it('should deselect a metric when the same metric is selected again', async () => { - const { result } = await renderHookWithInit(mocks); - - // First select a metric await act(async () => { await result.current.handleSelectedMetricChange('metric1'); }); expect(result.current.selectedMetric).toBe('metric1'); - // Clear mock calls - jest.clearAllMocks(); - - // Deselect by selecting the same metric await act(async () => { await result.current.handleSelectedMetricChange('metric1'); }); - // Verify the metric was deselected expect(result.current.selectedMetric).toBe(''); }); - it('should update label keys and values when a metric is selected', async () => { - // Mock fetchSeriesLabelsMatch to return specific labels - (mocks.mockLanguageProvider.fetchSeriesLabelsMatch as jest.Mock).mockResolvedValue({ - __name__: ['metric1'], - job: ['job1', 'job2'], - instance: ['instance1', 'instance2'], - service: ['service1', 'service2'], - }); - - // Start with some selected label keys - mocks.localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job', 'instance', 'service'])); - + it('should update label keys when metric is selected', async () => { const { result } = await renderHookWithInit(mocks); - await waitFor(() => { - return result.current.selectedLabelKeys.length === 3; - }); - - // Clear mock calls - jest.clearAllMocks(); - - // Select a metric await act(async () => { await result.current.handleSelectedMetricChange('metric1'); }); - // Verify that the label keys were updated - expect(result.current.labelKeys).toContain('job'); - expect(result.current.labelKeys).toContain('instance'); - expect(result.current.labelKeys).toContain('service'); - - // Verify that selected label keys were filtered correctly - expect(result.current.selectedLabelKeys).toContain('job'); - expect(result.current.selectedLabelKeys).toContain('instance'); - expect(result.current.selectedLabelKeys).toContain('service'); - - // Verify that label values were fetched - expect(mocks.mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled(); + expect(mocks.mockLanguageProvider.queryLabelKeys).toHaveBeenCalled(); + expect(result.current.labelKeys).toEqual(['__name__', 'instance', 'job', 'service']); }); }); - describe('handleSelectedLabelKeyChange', () => { - it('should add a label key when it is not already selected', async () => { + describe('label key selection', () => { + it('should handle label key selection', async () => { const { result } = await renderHookWithInit(mocks); - // Clear mock calls - jest.clearAllMocks(); - - // Since we have no selected metric, the buildSelector will return EMPTY_SELECTOR - jest.spyOn(selectorBuilderModule, 'buildSelector').mockReturnValue(EMPTY_SELECTOR); - - // Add a new label key - await act(async () => { - await result.current.handleSelectedLabelKeyChange('service'); - }); - - // Wait for the label key to be added to selectedLabelKeys - await waitFor(() => { - expect(result.current.selectedLabelKeys).toContain('service'); - }); - - // Verify that buildSelector was called - expect(selectorBuilderModule.buildSelector).toHaveBeenCalled(); - - // Verify that fetchSeriesValuesWithMatch was called correctly - expect(mocks.mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalledWith( - expect.anything(), - 'service', - undefined, // Since selector is EMPTY_SELECTOR, it should be converted to undefined - 'MetricsBrowser_LV_service', - DEFAULT_SERIES_LIMIT - ); - - // Verify localStorage was updated - expect(mocks.localStorageMock.setItem).toHaveBeenCalled(); - }); - - it('should remove a label key when it is already selected', async () => { - // Setup with a selected label key - mocks.localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job'])); - - const { result } = await renderHookWithInit(mocks); - - // Wait for job to be in the selected keys - await waitFor(() => { - expect(result.current.selectedLabelKeys).toContain('job'); - }); - - // Mock localStorage.setItem to verify it's called correctly - mocks.localStorageMock.setItem.mockClear(); - - // Remove the label key await act(async () => { await result.current.handleSelectedLabelKeyChange('job'); }); - // Verify the label key was removed - expect(result.current.selectedLabelKeys).not.toContain('job'); - - // Verify label values were removed - expect(result.current.labelValues).not.toHaveProperty('job'); - - // Verify localStorage was updated - expect(mocks.localStorageMock.setItem).toHaveBeenCalled(); - }); - - it('should handle labelKey changes when a metric is selected', async () => { - const { result } = await renderHookWithInit(mocks); - - // Select a metric first - await act(async () => { - await result.current.handleSelectedMetricChange('metric1'); - }); - - // Clear mock calls - jest.clearAllMocks(); - - // Mock the buildSelector to return a non-empty selector - jest.spyOn(selectorBuilderModule, 'buildSelector').mockReturnValue('metric1{instance="host1"}'); - - // Add a label key - await act(async () => { - await result.current.handleSelectedLabelKeyChange('service'); - }); - - // Verify buildSelector was called with the selected metric and label values - expect(selectorBuilderModule.buildSelector).toHaveBeenCalled(); - - // Verify fetchSeriesValuesWithMatch was called with the correct selector - expect(mocks.mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalledWith( + expect(result.current.selectedLabelKeys).toContain('job'); + expect(mocks.mockLanguageProvider.queryLabelValues).toHaveBeenCalledWith( expect.anything(), - 'service', - 'metric1{instance="host1"}', // The selector returned by our mock - 'MetricsBrowser_LV_service', + 'job', + undefined, DEFAULT_SERIES_LIMIT ); }); - }); - - describe('handleSelectedLabelValueChange', () => { - it('should add a label value when isSelected is true', async () => { - // Start with selected label keys but no selected values - mocks.localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job'])); + it('should handle label key deselection', async () => { const { result } = await renderHookWithInit(mocks); - // Clear mock calls - jest.clearAllMocks(); - - // Select a label value await act(async () => { - await result.current.handleSelectedLabelValueChange('job', 'grafana', true); + await result.current.handleSelectedLabelKeyChange('job'); }); - // Verify the value was added to selectedLabelValues - expect(result.current.selectedLabelValues.job).toContain('grafana'); + expect(result.current.selectedLabelKeys).toContain('job'); - // Verify buildSelector was called to create a selector with the selected value - expect(selectorBuilderModule.buildSelector).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ job: ['grafana'] }) - ); + await act(async () => { + await result.current.handleSelectedLabelKeyChange('job'); + }); + + expect(result.current.selectedLabelKeys).not.toContain('job'); + expect(result.current.labelValues['job']).toBeUndefined(); }); - it('should remove a label value when isSelected is false', async () => { - // Setup initial state with selected label key and value - mocks.localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job'])); - + it('should save selected label keys to localStorage', async () => { const { result } = await renderHookWithInit(mocks); - // First select a value await act(async () => { + await result.current.handleSelectedLabelKeyChange('job'); + }); + + expect(mocks.localStorageMock.setItem).toHaveBeenCalledWith(LAST_USED_LABELS_KEY, JSON.stringify(['job'])); + }); + + it('should fetch label values when label key is selected', async () => { + const { result } = await renderHookWithInit(mocks); + + await act(async () => { + await result.current.handleSelectedLabelKeyChange('job'); + }); + + expect(mocks.mockLanguageProvider.queryLabelValues).toHaveBeenCalledWith( + expect.anything(), + 'job', + undefined, + DEFAULT_SERIES_LIMIT + ); + expect(result.current.labelValues['job']).toEqual(['grafana', 'prometheus']); + }); + }); + + describe('label value selection', () => { + it('should handle label value selection', async () => { + const { result } = await renderHookWithInit(mocks); + + await act(async () => { + await result.current.handleSelectedLabelKeyChange('job'); await result.current.handleSelectedLabelValueChange('job', 'grafana', true); }); - // Verify initial selection - expect(result.current.selectedLabelValues.job).toContain('grafana'); + expect(result.current.selectedLabelValues['job']).toContain('grafana'); + expect(mocks.mockLanguageProvider.queryLabelValues).toHaveBeenCalled(); + }); - // Clear mock calls - jest.clearAllMocks(); + it('should handle label value deselection', async () => { + const { result } = await renderHookWithInit(mocks); + + await act(async () => { + await result.current.handleSelectedLabelKeyChange('job'); + await result.current.handleSelectedLabelValueChange('job', 'grafana', true); + }); + + expect(result.current.selectedLabelValues['job']).toContain('grafana'); - // Now deselect the value await act(async () => { await result.current.handleSelectedLabelValueChange('job', 'grafana', false); }); - // Verify that 'job' key is no longer in selectedLabelValues - expect(Object.keys(result.current.selectedLabelValues)).not.toContain('job'); + expect(result.current.selectedLabelValues['job']).toBeUndefined(); }); - it('should preserve values for the last selected label key', async () => { - // Mock with specific return values to test value merging - (mocks.mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mockImplementation( - (_timeRange: TimeRange, label: string) => { - if (label === 'job') { - return Promise.resolve(['grafana']); - } - if (label === 'instance') { - return Promise.resolve(['host1', 'host2']); - } - if (label === METRIC_LABEL) { - return Promise.resolve(['metric1', 'metric2']); - } - return Promise.resolve([]); - } - ); - - // Start with selected label keys - mocks.localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job', 'instance'])); - + it('should update metrics when label value is selected', async () => { const { result } = await renderHookWithInit(mocks); - // Initialize job values with a larger set - const initialJobValues = ['grafana', 'prometheus', 'additional_value']; + // Clear previous calls from initialization + (mocks.mockLanguageProvider.queryLabelValues as jest.Mock).mockClear(); - // Select a value for job (should set job as last selected) await act(async () => { + await result.current.handleSelectedLabelKeyChange('job'); await result.current.handleSelectedLabelValueChange('job', 'grafana', true); }); - // Mock a more extensive set of label values for job - (mocks.mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mockImplementation( - (_timeRange: TimeRange, label: string) => { - if (label === 'job') { - return Promise.resolve(initialJobValues); - } - if (label === 'instance') { - return Promise.resolve(['host1', 'host2']); - } - if (label === METRIC_LABEL) { - return Promise.resolve(['metric1', 'metric2']); - } - return Promise.resolve([]); - } - ); + // Get all calls to queryLabelValues after our actions + const calls = (mocks.mockLanguageProvider.queryLabelValues as jest.Mock).mock.calls; - // Select a value for instance to trigger job values refetch - await act(async () => { - await result.current.handleSelectedLabelValueChange('instance', 'host1', true); - }); - - // Select job value again to make it the last selected - await act(async () => { - await result.current.handleSelectedLabelValueChange('job', 'prometheus', true); - }); - - // Verify that job values contain all the values - expect(result.current.labelValues.job).toContain('grafana'); - expect(result.current.labelValues.job).toContain('prometheus'); - expect(result.current.labelValues.job).toContain('additional_value'); + // Find the call that fetches metrics (__name__) + const metricsCall = calls.find((call) => call[1] === METRIC_LABEL); + expect(metricsCall).toBeTruthy(); + expect(metricsCall![1]).toBe(METRIC_LABEL); + expect(metricsCall![3]).toBe(DEFAULT_SERIES_LIMIT); }); - it('should handle errors during label values fetching', async () => { - // Mock fetchSeriesValuesWithMatch to throw an error for specific labels - (mocks.mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mockImplementation( - (_timeRange: TimeRange, label: string, _selector: string, debugName: string) => { - if (label === METRIC_LABEL) { - return Promise.resolve(['metric1', 'metric2']); - } - if (label === 'job' && debugName === 'MetricsBrowser_LV_job') { - return Promise.reject(new Error('Test error')); - } - if (label === 'instance') { - return Promise.resolve(['host1', 'host2']); - } - return Promise.resolve([]); - } - ); - - // Start with selected label keys - mocks.localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job', 'instance'])); - + it('should update other label values when a value is selected', async () => { const { result } = await renderHookWithInit(mocks); - // Wait for the error to be logged - await waitFor(() => { - return result.current.err.includes('Error fetching label values'); + // Clear previous calls from initialization + (mocks.mockLanguageProvider.queryLabelValues as jest.Mock).mockClear(); + + await act(async () => { + await result.current.handleSelectedLabelKeyChange('job'); + await result.current.handleSelectedLabelKeyChange('instance'); + await result.current.handleSelectedLabelValueChange('job', 'grafana', true); }); - // Wait for initialization to complete so we can verify the result - await waitFor(() => { - return result.current.labelValues.instance !== undefined; - }); + // Get all calls to queryLabelValues after our actions + const calls = (mocks.mockLanguageProvider.queryLabelValues as jest.Mock).mock.calls; - // Verify that instance values were still fetched successfully - expect(result.current.labelValues).toHaveProperty('instance'); - - // Verify job is not in labelValues since its fetch failed - expect(result.current.labelValues).not.toHaveProperty('job'); + // Find the call that fetches metrics (__name__) + const metricsCall = calls.find((call) => call[1] === 'instance'); + expect(metricsCall).toBeTruthy(); + expect(metricsCall![1]).toBe('instance'); + expect(metricsCall![3]).toBe(DEFAULT_SERIES_LIMIT); }); }); - describe('handleValidation', () => { - it('should validate a selector against the language provider', async () => { - // Mock fetchSeriesLabelsMatch to return valid results - mocks.mockLanguageProvider.fetchSeriesLabelsMatch = jest.fn().mockResolvedValue({ - job: ['grafana', 'prometheus'], - instance: ['instance1', 'instance2'], - }); - + describe('validation', () => { + it('should validate selector', async () => { const { result } = await renderHookWithInit(mocks); - // Set up initial state await act(async () => { await result.current.handleSelectedMetricChange('metric1'); - }); - - // Call validation - await act(async () => { + await result.current.handleSelectedLabelKeyChange('job'); + await result.current.handleSelectedLabelValueChange('job', 'grafana', true); await result.current.handleValidation(); }); - // Verify API was called with the correct selector - expect(mocks.mockLanguageProvider.fetchSeriesLabelsMatch).toHaveBeenCalled(); - - // Verify validationStatus was updated expect(result.current.validationStatus).toContain('Selector is valid'); + expect(mocks.mockLanguageProvider.queryLabelKeys).toHaveBeenCalled(); }); - it('should handle errors during validation', async () => { - // Mock fetchSeriesLabelsMatch to throw an error - mocks.mockLanguageProvider.fetchSeriesLabelsMatch = jest.fn().mockRejectedValue(new Error('Validation error')); - + it('should handle validation errors', async () => { const { result } = await renderHookWithInit(mocks); - // Call validation + (mocks.mockLanguageProvider.queryLabelKeys as jest.Mock).mockRejectedValueOnce(new Error('Test error')); + await act(async () => { await result.current.handleValidation(); }); - // Verify error was handled - checking for the correct error message format - expect(result.current.err).toContain('Validation failed'); + expect(result.current.err).toContain('Test error'); expect(result.current.validationStatus).toBe(''); }); }); - describe('handleClear', () => { - it('should reset state and localStorage when called', async () => { - // Setup initial state with selections - mocks.localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job', 'instance'])); - + describe('clear functionality', () => { + it('should clear all selections', async () => { const { result } = await renderHookWithInit(mocks); - // Select a metric and label value await act(async () => { await result.current.handleSelectedMetricChange('metric1'); + await result.current.handleSelectedLabelKeyChange('job'); await result.current.handleSelectedLabelValueChange('job', 'grafana', true); }); - // Verify we have state to clear - expect(result.current.selectedMetric).toBe('metric1'); - expect(result.current.selectedLabelValues).toHaveProperty('job'); - - // Clear mock calls - jest.clearAllMocks(); - - // Call clear await act(async () => { - result.current.handleClear(); + await result.current.handleClear(); }); - // Verify state was reset expect(result.current.selectedMetric).toBe(''); expect(result.current.selectedLabelKeys).toEqual([]); expect(result.current.selectedLabelValues).toEqual({}); expect(result.current.err).toBe(''); expect(result.current.status).toBe('Ready'); expect(result.current.validationStatus).toBe(''); - - // Verify localStorage was cleared expect(mocks.localStorageMock.setItem).toHaveBeenCalledWith(LAST_USED_LABELS_KEY, '[]'); - - // Verify initialize was called - expect(mocks.mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled(); }); }); - describe('helper functions', () => { - describe('buildSafeSelector', () => { - it('should convert EMPTY_SELECTOR to undefined', async () => { - jest.spyOn(selectorBuilderModule, 'buildSelector').mockReturnValue(EMPTY_SELECTOR); + describe('error handling', () => { + it('should handle errors during metric fetch', async () => { + (mocks.mockLanguageProvider.queryLabelValues as jest.Mock).mockRejectedValueOnce(new Error('Metric fetch error')); - const { result } = await renderHookWithInit(mocks); - - // Access the helper function - const buildSafeSelector = result.current.buildSafeSelector; - - expect(buildSafeSelector('metric1', {})).toBeUndefined(); - }); - - it('should return the selector value when not empty', async () => { - const expectedSelector = 'metric1{job="prometheus"}'; - jest.spyOn(selectorBuilderModule, 'buildSelector').mockReturnValue(expectedSelector); - - const { result } = await renderHookWithInit(mocks); - - const buildSafeSelector = result.current.buildSafeSelector; - - expect(buildSafeSelector('metric1', { job: ['prometheus'] })).toBe(expectedSelector); - }); - }); - - describe('loadSelectedLabelsFromStorage', () => { - it('should filter labels against available labels', async () => { - mocks.localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job', 'instance', 'unavailable'])); - - const { result } = await renderHookWithInit(mocks); - - const loadSelectedLabelsFromStorage = result.current.loadSelectedLabelsFromStorage; - - const availableLabels = ['job', 'instance', 'pod']; - expect(loadSelectedLabelsFromStorage(availableLabels)).toEqual(['job', 'instance']); - }); - - it('should handle empty localStorage', async () => { - mocks.localStorageMock.clear(); - - const { result } = await renderHookWithInit(mocks); - - const loadSelectedLabelsFromStorage = result.current.loadSelectedLabelsFromStorage; - - expect(loadSelectedLabelsFromStorage(['job', 'instance'])).toEqual([]); - }); - }); - }); - - describe('seriesLimit handling', () => { - it('should refetch data when seriesLimit changes', async () => { const { result } = await renderHookWithInit(mocks); - // Clear mock calls - jest.clearAllMocks(); - - // Change series limit - await act(async () => { - result.current.setSeriesLimit(1000 as unknown as typeof DEFAULT_SERIES_LIMIT); - }); - - // Wait for debounce to finish - await new Promise((resolve) => setTimeout(resolve, 400)); - - // Verify data was refetched with new limit - await waitFor(() => { - const matchCalls = (mocks.mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mock.calls; - // Find calls with the new limit - const callWithNewLimit = matchCalls.find((call) => call[4] === 1000); - expect(callWithNewLimit).toBeTruthy(); - }); + expect(result.current.err).toContain('Metric fetch error'); }); - it('should use DEFAULT_SERIES_LIMIT when seriesLimit is empty', async () => { + it('should handle errors during label value fetch', async () => { const { result } = await renderHookWithInit(mocks); - // Clear mock calls - jest.clearAllMocks(); - - // Set series limit to empty string - await act(async () => { - result.current.setSeriesLimit('' as unknown as typeof DEFAULT_SERIES_LIMIT); - }); - - // Wait for debounce to finish - await new Promise((resolve) => setTimeout(resolve, 400)); - - // Verify data was refetched with the default limit - await waitFor(() => { - const matchCalls = (mocks.mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mock.calls; - const callsWithDefaultLimit = matchCalls.filter((call) => call[4] === DEFAULT_SERIES_LIMIT); - expect(callsWithDefaultLimit.length).toBeGreaterThan(0); - }); - }); - }); - - describe('timeRange handling', () => { - it('should not update timeRangeRef for small time changes', async () => { - // Create base time range - const baseTimeRange = getMockTimeRange(); - - // Time ranges with small differences (< 5 seconds) - const initialTimeRange = { - ...baseTimeRange, - from: baseTimeRange.from, - to: baseTimeRange.to, - }; - - const smallChangeTimeRange = { - ...baseTimeRange, - from: dateTime(baseTimeRange.from.valueOf() + 2000), // +2 seconds - to: dateTime(baseTimeRange.to.valueOf() + 2000), - }; - - // Render with initial time range - const { rerender } = renderHook((props) => useMetricsLabelsValues(props.timeRange, props.languageProvider), { - initialProps: { - timeRange: initialTimeRange, - languageProvider: mocks.mockLanguageProvider, - }, - }); - - await waitFor(() => { - expect(mocks.mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled(); - }); - - jest.clearAllMocks(); - - // Rerender with small time change - rerender({ - timeRange: smallChangeTimeRange, - languageProvider: mocks.mockLanguageProvider, - }); - - // Wait a bit to ensure no additional API calls - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Verify no API calls were made after rerender with small time change - expect(mocks.mockLanguageProvider.fetchSeriesValuesWithMatch).not.toHaveBeenCalled(); - }); - - it('should update timeRangeRef for significant time changes', async () => { - // Create time ranges with significant differences (≥ 5 seconds) - const baseTimeRange = getMockTimeRange(); - - const initialTimeRange = { - ...baseTimeRange, - from: baseTimeRange.from, - to: baseTimeRange.to, - }; - - const significantChangeTimeRange = { - ...baseTimeRange, - from: dateTime(baseTimeRange.from.valueOf() + 10000), // +10 seconds - to: dateTime(baseTimeRange.to.valueOf() + 10000), - }; - - // Mock the initialize method to be called when timeRangeRef is updated - const mockInitialize = jest.fn(); - - // Render with initial time range - const { rerender } = renderHook( - (props) => { - const hook = useMetricsLabelsValues(props.timeRange, props.languageProvider); - // Spy on the initialize method indirectly by monitoring metric fetches - if (props.timeRange === significantChangeTimeRange) { - mockInitialize(); - } - return hook; - }, - { - initialProps: { - timeRange: initialTimeRange, - languageProvider: mocks.mockLanguageProvider, - }, - } + (mocks.mockLanguageProvider.queryLabelValues as jest.Mock).mockRejectedValueOnce( + new Error('Label value fetch error') ); - await waitFor(() => { - expect(mocks.mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled(); - }); - - jest.clearAllMocks(); - - // Rerender with significant time change - rerender({ - timeRange: significantChangeTimeRange, - languageProvider: mocks.mockLanguageProvider, - }); - - // Verify timeRangeRef was updated - expect(mockInitialize).toHaveBeenCalled(); - }); - }); - - describe('testing with invalid values or special characters', () => { - it('should handle metric names with special characters', async () => { - // Mock fetchSeriesValuesWithMatch to return metrics with special characters - (mocks.mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mockImplementation( - (_timeRange: TimeRange, label: string) => { - if (label === METRIC_LABEL) { - return Promise.resolve(['metric-with-dash', 'metric.with.dots', 'metric{with}brackets']); - } - return Promise.resolve([]); - } - ); - - const { result } = await renderHookWithInit(mocks); - - // Verify that metrics with special characters are returned - expect(result.current.metrics.map((m) => m.name)).toContain('metric-with-dash'); - expect(result.current.metrics.map((m) => m.name)).toContain('metric.with.dots'); - expect(result.current.metrics.map((m) => m.name)).toContain('metric{with}brackets'); - - // Try to select a metric with special characters - await act(async () => { - await result.current.handleSelectedMetricChange('metric{with}brackets'); - }); - - // Verify the selection was successful - expect(result.current.selectedMetric).toBe('metric{with}brackets'); - }); - - it('should handle label values with special characters', async () => { - // Mock fetchSeriesValuesWithMatch to return label values with special characters - (mocks.mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mockImplementation( - (_timeRange: TimeRange, label: string) => { - if (label === 'job') { - return Promise.resolve(['name:with:colons', 'name/with/slashes', 'name=with=equals']); - } - if (label === METRIC_LABEL) { - return Promise.resolve(['metric1', 'metric2']); - } - return Promise.resolve([]); - } - ); - - // Set up selected label keys - mocks.localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job'])); - - const { result } = await renderHookWithInit(mocks); - - // Verify special character label values are loaded - await waitFor(() => { - expect(result.current.labelValues.job).toContain('name:with:colons'); - expect(result.current.labelValues.job).toContain('name/with/slashes'); - expect(result.current.labelValues.job).toContain('name=with=equals'); - }); - - // Test selecting a label value with special characters - await act(async () => { - await result.current.handleSelectedLabelKeyChange('job'); - await result.current.handleSelectedLabelValueChange('job', 'name:with:colons', true); - }); - - // Verify the selection was made - expect(result.current.selectedLabelValues.job).toContain('name:with:colons'); - }); - - it('should handle empty strings in API responses', async () => { - // Mock API to return some empty strings - (mocks.mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mockImplementation( - (_timeRange: TimeRange, label: string) => { - if (label === 'job') { - return Promise.resolve(['valid-job', '', 'another-job']); - } - if (label === METRIC_LABEL) { - return Promise.resolve(['metric1', 'metric2']); - } - return Promise.resolve([]); - } - ); - - // Set up selected label keys - mocks.localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job'])); - - const { result } = await renderHookWithInit(mocks); - - // Verify empty string is included in values - await waitFor(() => { - expect(result.current.labelValues.job).toContain(''); - }); - - // Test selecting an empty string as a value - await act(async () => { - await result.current.handleSelectedLabelKeyChange('job'); - await result.current.handleSelectedLabelValueChange('job', '', true); - }); - - // Verify the empty string was selected - expect(result.current.selectedLabelValues.job).toContain(''); - }); - - it('should handle extremely long label values', async () => { - // Create a very long label value - const longValue = 'x'.repeat(5000); - - // Mock API to return a very long label value - (mocks.mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mockImplementation( - (_timeRange: TimeRange, label: string) => { - if (label === 'job') { - return Promise.resolve(['normal-value', longValue]); - } - if (label === METRIC_LABEL) { - return Promise.resolve(['metric1', 'metric2']); - } - return Promise.resolve([]); - } - ); - - // Set up selected label keys - mocks.localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job'])); - - const { result } = await renderHookWithInit(mocks); - - // First ensure job is selected as a label key await act(async () => { await result.current.handleSelectedLabelKeyChange('job'); }); - // Wait for label values to load - await waitFor(() => { - expect(result.current.labelValues.job).toBeDefined(); - }); - - // Verify long value is included - expect(result.current.labelValues.job).toContain(longValue); - - // Test selecting the long value - await act(async () => { - await result.current.handleSelectedLabelValueChange('job', longValue, true); - }); - - // Verify the long value was selected - expect(result.current.selectedLabelValues.job).toContain(longValue); - }); - }); - - describe('debouncing functionality', () => { - beforeEach(() => { - jest.useFakeTimers(); + expect(result.current.err).toContain('Label value fetch error'); }); - afterEach(() => { - jest.useRealTimers(); - }); - - it('should debounce seriesLimit changes', async () => { + it('should clear error state when new operation succeeds', async () => { const { result } = await renderHookWithInit(mocks); - // Clear mocks to track new calls - jest.clearAllMocks(); + // Mock first call to fail + (mocks.mockLanguageProvider.queryLabelValues as jest.Mock) + .mockRejectedValueOnce(new Error('Test error')) + // Mock subsequent calls to succeed + .mockResolvedValue(['value1', 'value2']); - // Change seriesLimit multiple times rapidly - act(() => { - result.current.setSeriesLimit(100 as unknown as typeof DEFAULT_SERIES_LIMIT); - result.current.setSeriesLimit(200 as unknown as typeof DEFAULT_SERIES_LIMIT); - result.current.setSeriesLimit(300 as unknown as typeof DEFAULT_SERIES_LIMIT); - }); - - // Verify no fetch calls yet (before debounce timer completes) - expect(mocks.mockLanguageProvider.fetchSeriesValuesWithMatch).not.toHaveBeenCalled(); - - // Fast-forward debounce time - act(() => { - jest.advanceTimersByTime(400); // Slightly more than the 300ms debounce time - }); - - // Wait for the state update to propagate - await waitFor(() => { - expect(mocks.mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled(); - }); - - // Verify we're using the last value set (300) - const fetchCalls = (mocks.mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mock.calls; - const callWithFinalLimit = fetchCalls.find((call) => call[4] === 300); - expect(callWithFinalLimit).toBeTruthy(); - }); - - it('should not fetch multiple times for the same seriesLimit value', async () => { - const { result } = await renderHookWithInit(mocks); - - // Clear mocks to track new calls - jest.clearAllMocks(); - - // Set the same value multiple times - act(() => { - result.current.setSeriesLimit(100 as unknown as typeof DEFAULT_SERIES_LIMIT); - result.current.setSeriesLimit(100 as unknown as typeof DEFAULT_SERIES_LIMIT); - result.current.setSeriesLimit(100 as unknown as typeof DEFAULT_SERIES_LIMIT); - }); - - // Fast-forward debounce time - act(() => { - jest.advanceTimersByTime(400); - }); - - // Wait for any async operations to complete - await waitFor(() => { - const fetchCalls = (mocks.mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mock.calls; - return fetchCalls.length > 0; - }); - - // Clear mocks again - jest.clearAllMocks(); - - // Set the same value again - act(() => { - result.current.setSeriesLimit(100 as unknown as typeof DEFAULT_SERIES_LIMIT); - }); - - // Fast-forward debounce time - act(() => { - jest.advanceTimersByTime(400); - }); - - // Should not fetch again since the value hasn't changed - expect(mocks.mockLanguageProvider.fetchSeriesValuesWithMatch).not.toHaveBeenCalled(); - }); - }); - - describe('complete user workflows', () => { - it('should handle a full selection -> validation -> clear workflow', async () => { - const { result } = await renderHookWithInit(mocks); - - // 1. Select a metric - await act(async () => { - await result.current.handleSelectedMetricChange('metric1'); - }); - expect(result.current.selectedMetric).toBe('metric1'); - - // 2. Add a label key - await act(async () => { - await result.current.handleSelectedLabelKeyChange('job'); - }); - expect(result.current.selectedLabelKeys).toContain('job'); - - // 3. Select a label value - await act(async () => { - await result.current.handleSelectedLabelValueChange('job', 'grafana', true); - }); - expect(result.current.selectedLabelValues.job).toContain('grafana'); - - // 4. Add another label key - await act(async () => { - await result.current.handleSelectedLabelKeyChange('instance'); - }); - expect(result.current.selectedLabelKeys).toContain('instance'); - - // 5. Select a value for the second label - await act(async () => { - await result.current.handleSelectedLabelValueChange('instance', 'host1', true); - }); - expect(result.current.selectedLabelValues.instance).toContain('host1'); - - // 6. Validate the selection - // Mock the validation response - (mocks.mockLanguageProvider.fetchSeriesLabelsMatch as jest.Mock).mockResolvedValue({ - job: ['grafana'], - instance: ['host1'], - }); - - await act(async () => { - await result.current.handleValidation(); - }); - expect(result.current.validationStatus).toContain('Selector is valid'); - - // 7. Clear everything - await act(async () => { - result.current.handleClear(); - }); - - // 8. Verify everything was cleared - expect(result.current.selectedMetric).toBe(''); - expect(result.current.selectedLabelKeys).toEqual([]); - expect(result.current.selectedLabelValues).toEqual({}); - expect(result.current.validationStatus).toBe(''); - }); - - it('should handle a workflow with deselections and reselections', async () => { - const { result } = await renderHookWithInit(mocks); - - // 1. Select a metric - await act(async () => { - await result.current.handleSelectedMetricChange('metric1'); - }); - expect(result.current.selectedMetric).toBe('metric1'); - - // 2. Add label keys await act(async () => { await result.current.handleSelectedLabelKeyChange('job'); }); - // Wait for job to be in the selected keys - await waitFor(() => expect(result.current.selectedLabelKeys).toContain('job')); - - // Wait for job values to be loaded - await waitFor(() => result.current.labelValues.job && result.current.labelValues.job.length > 0); + expect(result.current.err).toContain('Test error'); await act(async () => { await result.current.handleSelectedLabelKeyChange('instance'); }); - // Wait for instance to be in the selected keys - await waitFor(() => expect(result.current.selectedLabelKeys).toContain('instance')); + // The error should be cleared by the successful operation + expect(result.current.err).toBe(''); + }); + }); - // Wait for instance values to be loaded - await waitFor(() => result.current.labelValues.instance && result.current.labelValues.instance.length > 0); + describe('series limit', () => { + it('should use default series limit during initialization', async () => { + renderHook(() => useMetricsLabelsValues(mocks.mockTimeRange, mocks.mockLanguageProvider)); + + await waitFor(() => { + expect(mocks.mockLanguageProvider.queryLabelValues).toHaveBeenCalledWith( + expect.anything(), + METRIC_LABEL, + undefined, + DEFAULT_SERIES_LIMIT + ); + }); + }); + + it('should use updated series limit for subsequent requests', async () => { + const { result } = await renderHookWithInit(mocks); + const newLimit = '500'; + + // Clear previous calls from initialization + (mocks.mockLanguageProvider.queryLabelValues as jest.Mock).mockClear(); - // 3. Select values await act(async () => { + result.current.setSeriesLimit(newLimit); + // Wait for debounce inside act + await new Promise((resolve) => setTimeout(resolve, 300)); + }); + + // Wait for the API call to happen + await waitFor(() => { + expect(mocks.mockLanguageProvider.queryLabelValues).toHaveBeenCalled(); + }); + + // Verify that at least one call used the new limit + const calls = (mocks.mockLanguageProvider.queryLabelValues as jest.Mock).mock.calls; + const callWithNewLimit = calls.find((call) => call[3] === newLimit); + expect(callWithNewLimit).toBeTruthy(); + }); + + it('should use 0 when series limit is set to 0', async () => { + const { result } = await renderHookWithInit(mocks); + + // Clear previous calls from initialization + (mocks.mockLanguageProvider.queryLabelValues as jest.Mock).mockClear(); + + await act(async () => { + result.current.setSeriesLimit('0'); + // Wait for debounce inside act + await new Promise((resolve) => setTimeout(resolve, 300)); + }); + + // Wait for the API call to happen + await waitFor(() => { + expect(mocks.mockLanguageProvider.queryLabelValues).toHaveBeenCalled(); + }); + + // Verify that at least one call used 0 as the limit + const calls = (mocks.mockLanguageProvider.queryLabelValues as jest.Mock).mock.calls; + const callWithZeroLimit = calls.find((call) => call[3] === '0'); + expect(callWithZeroLimit).toBeTruthy(); + }); + }); + + describe('cleanup', () => { + it('should clean up localStorage when clearing selections', async () => { + const { result } = await renderHookWithInit(mocks); + + // Setup some selections + await act(async () => { + await result.current.handleSelectedMetricChange('metric1'); + await result.current.handleSelectedLabelKeyChange('job'); await result.current.handleSelectedLabelValueChange('job', 'grafana', true); }); - // Wait for job value to be selected - await waitFor( - () => result.current.selectedLabelValues.job && result.current.selectedLabelValues.job.includes('grafana') - ); + // Clear mock to check for new calls + (mocks.localStorageMock.setItem as jest.Mock).mockClear(); + // Clear selections await act(async () => { - await result.current.handleSelectedLabelValueChange('instance', 'host1', true); + await result.current.handleClear(); }); - // Wait for instance value to be selected - await waitFor( - () => - result.current.selectedLabelValues.instance && result.current.selectedLabelValues.instance.includes('host1') - ); - - // 4. Deselect a value - await act(async () => result.current.handleSelectedLabelValueChange('job', 'grafana', false)); - - // Wait for job to be removed from selectedLabelValues - await waitFor( - () => !result.current.selectedLabelValues.job || result.current.selectedLabelValues.job.length === 0 - ); - - expect(Object.keys(result.current.selectedLabelValues)).not.toContain('job'); - - // 5. Select a different value - await act(async () => { - await result.current.handleSelectedLabelValueChange('job', 'prometheus', true); - }); - - // Wait for new job value to be selected - await waitFor( - () => result.current.selectedLabelValues.job && result.current.selectedLabelValues.job.includes('prometheus') - ); - - expect(result.current.selectedLabelValues.job).toContain('prometheus'); - - // 6. Change metric selection - await act(async () => { - await result.current.handleSelectedMetricChange('metric2'); - }); - expect(result.current.selectedMetric).toBe('metric2'); - - // 7. Remove a label key - await act(async () => { - await result.current.handleSelectedLabelKeyChange('instance'); - }); - - // Wait for instance to be removed from selectedLabelKeys - await waitFor(() => !result.current.selectedLabelKeys.includes('instance')); - - expect(result.current.selectedLabelKeys).not.toContain('instance'); - - // 8. Validate - (mocks.mockLanguageProvider.fetchSeriesLabelsMatch as jest.Mock).mockResolvedValue({ - job: ['prometheus'], - }); - - await act(async () => { - await result.current.handleValidation(); - }); - expect(result.current.validationStatus).toContain('Selector is valid'); + // Should clear localStorage + expect(mocks.localStorageMock.setItem).toHaveBeenCalledWith(LAST_USED_LABELS_KEY, '[]'); }); - }); - describe('state maintenance through complex interactions', () => { - it('should handle a complex workflow with changing label sets', async () => { - const { result } = await renderHookWithInit(mocks); + it('should clean up state when unmounting', async () => { + const { result, unmount } = renderHook(() => + useMetricsLabelsValues(mocks.mockTimeRange, mocks.mockLanguageProvider) + ); - // 1. Select a metric + await waitFor(() => { + expect(mocks.mockLanguageProvider.queryLabelValues).toHaveBeenCalled(); + }); + + // Setup some selections await act(async () => { await result.current.handleSelectedMetricChange('metric1'); - }); - - // 2. Add a label key and select a value - await act(async () => { await result.current.handleSelectedLabelKeyChange('job'); - }); - - // Wait for job to be in selectedLabelKeys - await waitFor(() => { - expect(result.current.selectedLabelKeys).toContain('job'); - }); - - // Wait for job values to load - await waitFor(() => { - return result.current.labelValues.job && result.current.labelValues.job.length > 0; - }); - - // Select a job value - await act(async () => { await result.current.handleSelectedLabelValueChange('job', 'grafana', true); }); - // Wait for job value to be selected - await waitFor(() => { - return result.current.selectedLabelValues.job && result.current.selectedLabelValues.job.includes('grafana'); - }); + // Unmount the component + unmount(); - // 3. Now mock that selecting a certain job value changes the available instance values - (mocks.mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mockImplementation( - (_timeRange: TimeRange, label: string) => { - if (label === 'instance' && result.current.selectedLabelValues.job?.includes('grafana')) { - return Promise.resolve(['grafana-host1', 'grafana-host2']); - } else if (label === 'instance') { - return Promise.resolve(['host1', 'host2']); - } - if (label === 'job') { - return Promise.resolve(['grafana', 'prometheus']); - } - if (label === METRIC_LABEL) { - return Promise.resolve(['metric1', 'metric2', 'metric3']); - } - return Promise.resolve([]); - } - ); - - // 4. Add instance label - await act(async () => { - await result.current.handleSelectedLabelKeyChange('instance'); - }); - - // Wait for instance to be in selectedLabelKeys - await waitFor(() => { - expect(result.current.selectedLabelKeys).toContain('instance'); - }); - - // Wait for instance values to load - await waitFor(() => { - return result.current.labelValues.instance && result.current.labelValues.instance.length > 0; - }); - - // 5. Select a grafana-specific instance - await act(async () => { - await result.current.handleSelectedLabelValueChange('instance', 'grafana-host1', true); - }); - - // Wait for instance value to be selected - await waitFor(() => { - return ( - result.current.selectedLabelValues.instance && - result.current.selectedLabelValues.instance.includes('grafana-host1') - ); - }); - - // Verify our current state for debugging - expect(result.current.selectedLabelValues.job).toBeDefined(); - expect(result.current.selectedLabelValues.job).toContain('grafana'); - expect(result.current.selectedLabelValues.instance).toBeDefined(); - expect(result.current.selectedLabelValues.instance).toContain('grafana-host1'); - - // 6. First select a second job value to make sure the job array stays when we remove a value - await act(async () => { - await result.current.handleSelectedLabelValueChange('job', 'prometheus', true); - }); - - // Wait for both job values to be selected - await waitFor(() => { - return ( - result.current.selectedLabelValues.job && - result.current.selectedLabelValues.job.includes('grafana') && - result.current.selectedLabelValues.job.includes('prometheus') - ); - }); - - // Now deselect grafana - await act(async () => { - await result.current.handleSelectedLabelValueChange('job', 'grafana', false); - }); - - // Wait for grafana to be removed from job values but prometheus to remain - await waitFor(() => { - return ( - result.current.selectedLabelValues.job && - !result.current.selectedLabelValues.job.includes('grafana') && - result.current.selectedLabelValues.job.includes('prometheus') - ); - }); - - // Wait for instance values to update - await waitFor(() => { - return ( - result.current.labelValues.instance && - result.current.labelValues.instance.includes('host1') && - !result.current.labelValues.instance.includes('grafana-host1') - ); - }); - - // 7. Verify instance value was removed since it's no longer valid with the new job - expect(result.current.selectedLabelValues.instance || []).not.toContain('grafana-host1'); - - // 8. Verify the instance options have changed - expect(result.current.labelValues.instance).toContain('host1'); - expect(result.current.labelValues.instance).toContain('host2'); - expect(result.current.labelValues.instance).not.toContain('grafana-host1'); + // Verify state is cleaned up + expect(result.current.selectedMetric).toBe('metric1'); // State should be preserved until unmount + expect(result.current.selectedLabelKeys).toEqual(['job']); // State should be preserved until unmount + expect(result.current.selectedLabelValues).toHaveProperty('job'); // State should be preserved until unmount }); }); }); diff --git a/packages/grafana-prometheus/src/components/metrics-browser/useMetricsLabelsValues.ts b/packages/grafana-prometheus/src/components/metrics-browser/useMetricsLabelsValues.ts index 8b8d2a29c3d..0ae3255f194 100644 --- a/packages/grafana-prometheus/src/components/metrics-browser/useMetricsLabelsValues.ts +++ b/packages/grafana-prometheus/src/components/metrics-browser/useMetricsLabelsValues.ts @@ -41,7 +41,7 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P } }, [timeRange]); - //Handler for error processing - logs the error and updates UI state + // Handler for error processing - logs the error and updates UI state const handleError = useCallback((e: unknown, msg: string) => { if (e instanceof Error) { setErr(`${msg}: ${e.message}`); @@ -54,10 +54,10 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P // Get metadata details for a metric if available const getMetricDetails = useCallback( (metricName: string) => { - const meta = languageProvider.metricsMetadata; + const meta = languageProvider.retrieveMetricsMetadata(); return meta && meta[metricName] ? `(${meta[metricName].type}) ${meta[metricName].help}` : undefined; }, - [languageProvider.metricsMetadata] + [languageProvider] ); // Builds a safe selector string from metric name and label values @@ -89,11 +89,10 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P const fetchMetrics = useCallback( async (safeSelector?: string) => { try { - const fetchedMetrics = await languageProvider.fetchSeriesValuesWithMatch( + const fetchedMetrics = await languageProvider.queryLabelValues( timeRangeRef.current, METRIC_LABEL, safeSelector, - 'MetricsBrowser_M', effectiveLimit ); return fetchedMetrics.map((m) => ({ @@ -114,11 +113,9 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P async (safeSelector?: string) => { try { if (safeSelector) { - return Object.keys( - await languageProvider.fetchSeriesLabelsMatch(timeRangeRef.current, safeSelector, effectiveLimit) - ); + return Object.keys(await languageProvider.queryLabelKeys(timeRangeRef.current, safeSelector, effectiveLimit)); } else { - return (await languageProvider.fetchLabels(timeRangeRef.current, undefined, effectiveLimit)) || []; + return (await languageProvider.queryLabelKeys(timeRangeRef.current, undefined, effectiveLimit)) || []; } } catch (e) { handleError(e, 'Error fetching labels'); @@ -135,17 +132,18 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P const newSelectedLabelValues: Record = {}; for (const lk of labelKeys) { try { - const values = await languageProvider.fetchSeriesValuesWithMatch( + const values = await languageProvider.queryLabelValues( timeRangeRef.current, lk, safeSelector, - `MetricsBrowser_LV_${lk}`, effectiveLimit ); transformedLabelValues[lk] = values; if (selectedLabelValues[lk]) { newSelectedLabelValues[lk] = [...selectedLabelValues[lk]]; } + + setErr(''); } catch (e) { handleError(e, 'Error fetching label values'); } @@ -294,11 +292,10 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P if (selectedLabelKeys.length !== 0) { for (const lk of selectedLabelKeys) { try { - const fetchedLabelValues = await languageProvider.fetchSeriesValuesWithMatch( + const fetchedLabelValues = await languageProvider.queryLabelValues( timeRange, lk, safeSelector, - `MetricsBrowser_LV_${lk}`, effectiveLimit ); @@ -314,6 +311,8 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P fetchedLabelValues.includes(item) ); } + + setErr(''); } catch (e: unknown) { handleError(e, 'Error fetching label values'); } @@ -352,7 +351,7 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P setErr(''); try { - const results = await languageProvider.fetchSeriesLabelsMatch(timeRangeRef.current, selector, effectiveLimit); + const results = await languageProvider.queryLabelKeys(timeRangeRef.current, selector, effectiveLimit); setValidationStatus(`Selector is valid (${Object.keys(results).length} labels found)`); } catch (e) { handleError(e, 'Validation failed'); diff --git a/packages/grafana-prometheus/src/components/useMetricsState.test.ts b/packages/grafana-prometheus/src/components/useMetricsState.test.ts index 05808e35aea..c632935a287 100644 --- a/packages/grafana-prometheus/src/components/useMetricsState.test.ts +++ b/packages/grafana-prometheus/src/components/useMetricsState.test.ts @@ -8,7 +8,7 @@ import { useMetricsState } from './useMetricsState'; // Mock implementations const createMockLanguageProvider = (metrics: string[] = []): PrometheusLanguageProviderInterface => ({ - metrics, + retrieveMetrics: () => metrics, }) as unknown as PrometheusLanguageProviderInterface; const createMockDatasource = (lookupsDisabled = false): PrometheusDatasource => diff --git a/packages/grafana-prometheus/src/components/useMetricsState.ts b/packages/grafana-prometheus/src/components/useMetricsState.ts index 86ea0cced30..3ae3fb9aa6f 100644 --- a/packages/grafana-prometheus/src/components/useMetricsState.ts +++ b/packages/grafana-prometheus/src/components/useMetricsState.ts @@ -25,7 +25,7 @@ export function useMetricsState( syntaxLoaded: boolean ) { return useMemo(() => { - const hasMetrics = languageProvider.metrics.length > 0; + const hasMetrics = languageProvider.retrieveMetrics().length > 0; const chooserText = getChooserText(datasource.lookupsDisabled, syntaxLoaded, hasMetrics); const buttonDisabled = !(syntaxLoaded && hasMetrics); @@ -34,5 +34,5 @@ export function useMetricsState( chooserText, buttonDisabled, }; - }, [languageProvider.metrics, datasource.lookupsDisabled, syntaxLoaded]); + }, [languageProvider, datasource.lookupsDisabled, syntaxLoaded]); } diff --git a/packages/grafana-prometheus/src/language_provider.mock.ts b/packages/grafana-prometheus/src/language_provider.mock.ts index 2ea779fad26..8c331ce01a0 100644 --- a/packages/grafana-prometheus/src/language_provider.mock.ts +++ b/packages/grafana-prometheus/src/language_provider.mock.ts @@ -1,12 +1,15 @@ // Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/language_provider.mock.ts export class EmptyLanguageProviderMock { metrics = []; + constructor() {} + start() { return new Promise((resolve) => { resolve(''); }); } + getLabelKeys = jest.fn().mockReturnValue([]); getLabelValues = jest.fn().mockReturnValue([]); getSeries = jest.fn().mockReturnValue({ __name__: [] }); @@ -17,4 +20,5 @@ export class EmptyLanguageProviderMock { fetchLabelsWithMatch = jest.fn().mockReturnValue([]); fetchLabels = jest.fn(); loadMetricsMetadata = jest.fn(); + retrieveMetrics = jest.fn().mockReturnValue(['metric']); }