// Copyright 2022 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import {ReactNode, act} from 'react'; // eslint-disable-next-line import/named import {QueryClient, QueryClientProvider} from '@tanstack/react-query'; // eslint-disable-next-line import/named import {renderHook, waitFor} from '@testing-library/react'; // eslint-disable-next-line import/no-unresolved import {NuqsTestingAdapter} from 'nuqs/adapters/testing'; import {beforeEach, describe, expect, it, vi} from 'vitest'; import {useQueryState} from './useQueryState'; const mockNavigateTo = vi.fn(); // Mock useSumBy with stateful behavior using React's useState vi.mock('../useSumBy', async () => { const actual = await vi.importActual('../useSumBy'); const react = await import('react'); return { ...actual, useSumBy: ( _queryClient: any, _profileType: any, _timeRange: any, _draftProfileType: any, _draftTimeRange: any, defaultValue: any ) => { const [draftSumBy, setDraftSumBy] = react.useState(defaultValue); const [sumBy, setSumBy] = react.useState(defaultValue); return { sumBy, setSumBy, isLoading: false, draftSumBy, setDraftSumBy, isDraftSumByLoading: false, }; }, }; }); // Track profile types loading state for tests let mockProfileTypesLoading = false; let mockProfileTypesData: | { types: Array<{ name: string; sampleType: string; sampleUnit: string; periodType: string; periodUnit: string; delta: boolean; }>; } | undefined; // Mock useProfileTypes to control loading state in tests vi.mock('../ProfileSelector', async () => { const actual = await vi.importActual('../ProfileSelector'); return { ...actual, useProfileTypes: () => ({ loading: mockProfileTypesLoading, data: mockProfileTypesData, error: null, }), }; }); // Helper to set profile types loading state for tests const setProfileTypesLoading = (loading: boolean): void => { mockProfileTypesLoading = loading; }; const setProfileTypesData = (data: typeof mockProfileTypesData): void => { mockProfileTypesData = data; }; // Helper to create wrapper with NuqsTestingAdapter const createWrapper = ( _paramPreferences = {}, searchParams: string | Record = {} ): (({children}: {children: ReactNode}) => JSX.Element) => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }); const Wrapper = ({children}: {children: ReactNode}): JSX.Element => ( {children} ); Wrapper.displayName = 'NuqsTestingWrapper'; return Wrapper; }; describe('useQueryState', () => { beforeEach(() => { mockNavigateTo.mockClear(); // Reset profile types mock state setProfileTypesLoading(false); setProfileTypesData(undefined); }); describe('Basic functionality', () => { it('should initialize with default values', () => { const {result} = renderHook( () => useQueryState({ defaultExpression: 'process_cpu:cpu:nanoseconds:cpu:nanoseconds{}', defaultTimeSelection: 'relative:hour|1', defaultFrom: 1000, defaultTo: 2000, }), {wrapper: createWrapper()} ); const {querySelection} = result.current; expect(querySelection.expression).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds{}'); expect(querySelection.timeSelection).toBe('relative:hour|1'); // From/to should be calculated from the range expect(querySelection.from).toBeDefined(); expect(querySelection.to).toBeDefined(); }); it('should handle suffix for comparison mode', () => { const {result} = renderHook(() => useQueryState({suffix: '_a'}), { wrapper: createWrapper( {}, '?expression_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&from_a=1000&to_a=2000' ), }); const {querySelection} = result.current; expect(querySelection.expression).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}'); expect(querySelection.from).toBe(1000); expect(querySelection.to).toBe(2000); }); }); describe('Individual setters', () => { it('should update expression and handle delta profiles', async () => { const {result} = renderHook( () => useQueryState({ defaultFrom: 1000, defaultTo: 2000, }), {wrapper: createWrapper()} ); act(() => { result.current.setDraftExpression('memory:alloc_objects:count:space:bytes:delta{}'); }); // Draft should be updated but not committed expect(result.current.draftSelection.expression).toBe( 'memory:alloc_objects:count:space:bytes:delta{}' ); // Delta profile should auto-calculate merge params in draft expect(result.current.draftSelection.mergeFrom).toBe('1000000000'); expect(result.current.draftSelection.mergeTo).toBe('2000000000'); act(() => { result.current.commitDraft(); }); await waitFor(() => { expect(result.current.querySelection.expression).toBe( 'memory:alloc_objects:count:space:bytes:delta{}' ); expect(result.current.querySelection.mergeFrom).toBe('1000000000'); expect(result.current.querySelection.mergeTo).toBe('2000000000'); }); }); it('should update time range', async () => { const {result} = renderHook(() => useQueryState(), {wrapper: createWrapper()}); act(() => { result.current.setDraftTimeRange(3000, 4000, 'relative:minute|5'); }); // Draft should be updated expect(result.current.draftSelection.from).toBe(3000); expect(result.current.draftSelection.to).toBe(4000); expect(result.current.draftSelection.timeSelection).toBe('relative:minute|5'); act(() => { result.current.commitDraft(); }); await waitFor(() => { expect(String(result.current.querySelection.from)).toBe('3000'); expect(String(result.current.querySelection.to)).toBe('4000'); expect(result.current.querySelection.timeSelection).toBe('relative:minute|5'); }); }); it('should update sumBy', async () => { const {result} = renderHook(() => useQueryState(), {wrapper: createWrapper()}); // sumBy only applies to delta profiles, so we need to set one first act(() => { result.current.setDraftExpression('process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}'); result.current.setDraftSumBy(['namespace', 'container']); }); // Draft should be updated expect(result.current.draftSelection.sumBy).toEqual(['namespace', 'container']); act(() => { result.current.commitDraft(); }); await waitFor(() => { // sumBy is managed by the mocked useSumBy hook; verify it was set in draft expect(result.current.draftSelection.sumBy).toEqual(['namespace', 'container']); }); }); it('should auto-calculate merge range for delta profiles', async () => { const {result} = renderHook(() => useQueryState(), {wrapper: createWrapper()}); // Set a delta profile expression act(() => { result.current.setDraftExpression('memory:alloc_space:bytes:space:bytes:delta{}'); result.current.setDraftTimeRange(5000, 6000, 'relative:minute|5'); }); // Merge range should be auto-calculated in draft expect(result.current.draftSelection.mergeFrom).toBe('5000000000'); expect(result.current.draftSelection.mergeTo).toBe('6000000000'); act(() => { result.current.commitDraft(); }); await waitFor(() => { expect(result.current.querySelection.mergeFrom).toBe('5000000000'); expect(result.current.querySelection.mergeTo).toBe('6000000000'); }); }); }); describe('Batch updates', () => { it('should batch multiple updates into single navigation', async () => { const {result} = renderHook(() => useQueryState(), {wrapper: createWrapper()}); act(() => { // Update multiple draft values (using delta profile since sumBy only applies to delta) result.current.setDraftExpression('memory:alloc_space:bytes:space:bytes:delta{}'); result.current.setDraftTimeRange(7000, 8000, 'relative:minute|30'); result.current.setDraftSumBy(['pod', 'node']); }); // All drafts should be updated expect(result.current.draftSelection.expression).toBe( 'memory:alloc_space:bytes:space:bytes:delta{}' ); expect(result.current.draftSelection.from).toBe(7000); expect(result.current.draftSelection.to).toBe(8000); expect(result.current.draftSelection.sumBy).toEqual(['pod', 'node']); act(() => { result.current.commitDraft(); }); await waitFor(() => { // Verify all state values are correct after the batch expect(result.current.querySelection.expression).toBe( 'memory:alloc_space:bytes:space:bytes:delta{}' ); expect(String(result.current.querySelection.from)).toBe('7000'); expect(String(result.current.querySelection.to)).toBe('8000'); expect(result.current.querySelection.timeSelection).toBe('relative:minute|30'); // sumBy is managed by the mocked useSumBy hook; verify it was set in draft expect(result.current.draftSelection.sumBy).toEqual(['pod', 'node']); }); }); it('should handle partial updates', async () => { const {result} = renderHook(() => useQueryState(), { wrapper: createWrapper( {}, '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&from=1000&to=2000&time_selection=relative:hour|1' ), }); act(() => { // Only update expression, other values should remain result.current.setDraftExpression('memory:inuse_space:bytes:space:bytes{}'); }); expect(result.current.draftSelection.expression).toBe( 'memory:inuse_space:bytes:space:bytes{}' ); // Other values should be from URL expect(result.current.draftSelection.from).toBe(1000); expect(result.current.draftSelection.to).toBe(2000); act(() => { result.current.commitDraft(); }); await waitFor(() => { expect(result.current.querySelection.expression).toBe( 'memory:inuse_space:bytes:space:bytes{}' ); expect(String(result.current.querySelection.from)).toBe('1000'); expect(String(result.current.querySelection.to)).toBe('2000'); expect(result.current.querySelection.timeSelection).toBe('relative:hour|1'); }); }); it('should auto-calculate merge params for delta profiles in batch update', async () => { const {result} = renderHook(() => useQueryState(), {wrapper: createWrapper()}); act(() => { result.current.setDraftExpression('memory:alloc_space:bytes:space:bytes:delta{}'); result.current.setDraftTimeRange(9000, 10000, 'relative:minute|5'); }); // Merge params should be auto-calculated in draft expect(result.current.draftSelection.mergeFrom).toBe('9000000000'); expect(result.current.draftSelection.mergeTo).toBe('10000000000'); act(() => { result.current.commitDraft(); }); await waitFor(() => { expect(result.current.querySelection.expression).toBe( 'memory:alloc_space:bytes:space:bytes:delta{}' ); expect(result.current.querySelection.mergeFrom).toBe('9000000000'); expect(result.current.querySelection.mergeTo).toBe('10000000000'); }); }); }); describe('Helper functions', () => { it('should set profile name correctly', async () => { const {result} = renderHook(() => useQueryState(), { wrapper: createWrapper( {}, '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{job="parca"}' ), }); act(() => { result.current.setDraftProfileName('memory:inuse_space:bytes:space:bytes'); }); // Draft should be updated expect(result.current.draftSelection.expression).toBe( 'memory:inuse_space:bytes:space:bytes{job="parca"}' ); act(() => { result.current.commitDraft(); }); await waitFor(() => { expect(result.current.querySelection.expression).toBe( 'memory:inuse_space:bytes:space:bytes{job="parca"}' ); }); }); it('should set matchers correctly using draft', async () => { const {result} = renderHook(() => useQueryState(), { wrapper: createWrapper( {}, '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}' ), }); act(() => { result.current.setDraftMatchers('namespace="default",pod="my-pod"'); }); // Draft should be updated but not URL yet expect(result.current.draftSelection.expression).toBe( 'process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{namespace="default",pod="my-pod"}' ); // Commit the draft act(() => { result.current.commitDraft(); }); await waitFor(() => { expect(result.current.querySelection.expression).toBe( 'process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{namespace="default",pod="my-pod"}' ); }); }); }); describe('Comparison mode', () => { it('should handle _a suffix correctly', async () => { const {result} = renderHook(() => useQueryState({suffix: '_a'}), {wrapper: createWrapper()}); // Update draft state act(() => { result.current.setDraftExpression('process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}'); result.current.setDraftTimeRange(1111, 2222, 'relative:hour|1'); result.current.setDraftSumBy(['label_a']); }); // Commit draft act(() => { result.current.commitDraft(); }); await waitFor(() => { expect(result.current.querySelection.expression).toBe( 'process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}' ); expect(String(result.current.querySelection.from)).toBe('1111'); expect(String(result.current.querySelection.to)).toBe('2222'); // sumBy is managed by the mocked useSumBy hook; verify it was set in draft expect(result.current.draftSelection.sumBy).toEqual(['label_a']); }); }); it('should handle _b suffix correctly', async () => { const {result} = renderHook(() => useQueryState({suffix: '_b'}), {wrapper: createWrapper()}); // Update draft state (using delta profile since sumBy only applies to delta) act(() => { result.current.setDraftExpression('memory:alloc_space:bytes:space:bytes:delta{}'); result.current.setDraftTimeRange(3333, 4444, 'relative:hour|2'); result.current.setDraftSumBy(['label_b']); }); // Commit draft act(() => { result.current.commitDraft(); }); await waitFor(() => { expect(result.current.querySelection.expression).toBe( 'memory:alloc_space:bytes:space:bytes:delta{}' ); expect(String(result.current.querySelection.from)).toBe('3333'); expect(String(result.current.querySelection.to)).toBe('4444'); // sumBy is managed by the mocked useSumBy hook; verify it was set in draft expect(result.current.draftSelection.sumBy).toEqual(['label_b']); }); }); }); describe('Draft state pattern', () => { it('should not update URL until commit', async () => { const {result} = renderHook(() => useQueryState(), {wrapper: createWrapper()}); // Make multiple draft changes (using delta profile since sumBy only applies to delta) act(() => { result.current.setDraftExpression('memory:alloc_space:bytes:space:bytes:delta{}'); result.current.setDraftTimeRange(5000, 6000, 'relative:hour|3'); result.current.setDraftSumBy(['namespace', 'pod']); }); // Commit all changes at once act(() => { result.current.commitDraft(); }); // Verify all state values are correct await waitFor(() => { expect(result.current.querySelection.expression).toBe( 'memory:alloc_space:bytes:space:bytes:delta{}' ); expect(String(result.current.querySelection.from)).toBe('5000'); expect(String(result.current.querySelection.to)).toBe('6000'); // sumBy is managed by the mocked useSumBy hook; verify it was set in draft expect(result.current.draftSelection.sumBy).toEqual(['namespace', 'pod']); }); }); it('should handle draft profile name changes', () => { const {result} = renderHook(() => useQueryState(), { wrapper: createWrapper( {}, '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{job="test"}' ), }); // Change profile name in draft act(() => { result.current.setDraftProfileName('memory:inuse_space:bytes:space:bytes'); }); // Draft should be updated expect(result.current.draftSelection.expression).toBe( 'memory:inuse_space:bytes:space:bytes{job="test"}' ); }); }); describe('Edge cases', () => { it('should handle invalid expression gracefully and log warning', () => { const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const {result} = renderHook( () => useQueryState({ defaultExpression: 'invalid{{}expression', }), {wrapper: createWrapper()} ); // Should not throw error - invalid expressions are caught and logged expect(() => result.current.querySelection).not.toThrow(); // Should fall back to empty expression expect(result.current.querySelection.expression).toBe('invalid{{}expression'); // Should log a warning about the parse failure expect(consoleSpy).toHaveBeenCalledWith( 'Failed to parse expression', expect.objectContaining({ expression: 'invalid{{}expression', }) ); consoleSpy.mockRestore(); }); it('should handle empty expression gracefully', () => { const {result} = renderHook( () => useQueryState({ defaultExpression: '', }), {wrapper: createWrapper()} ); // Should not throw error with empty expression expect(() => result.current.querySelection).not.toThrow(); expect(result.current.querySelection.expression).toBe(''); }); it('should clear merge params for non-delta profiles', async () => { const {result} = renderHook(() => useQueryState(), { wrapper: createWrapper( {}, '?expression=memory:alloc_objects:count:space:bytes:delta{}&merge_from=1000000000&merge_to=2000000000' ), }); // Switch to non-delta profile (without :delta suffix) using draft act(() => { result.current.setDraftExpression('memory:inuse_space:bytes:space:bytes{}'); }); // Commit the draft act(() => { result.current.commitDraft(); }); await waitFor(() => { expect(result.current.querySelection.expression).toBe( 'memory:inuse_space:bytes:space:bytes{}' ); // Merge params should not be set for non-delta profiles expect(result.current.querySelection.mergeFrom).toBeUndefined(); expect(result.current.querySelection.mergeTo).toBeUndefined(); }); }); it('should preserve other URL parameters when updating', async () => { const {result} = renderHook(() => useQueryState(), { wrapper: createWrapper( {}, '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&other_param=value&unrelated=test' ), }); // Update draft and commit act(() => { result.current.setDraftExpression('memory:inuse_space:bytes:space:bytes{}'); }); act(() => { result.current.commitDraft(); }); await waitFor(() => { expect(result.current.querySelection.expression).toBe( 'memory:inuse_space:bytes:space:bytes{}' ); }); }); }); describe('Commit with refreshed time range (time range re-evaluation)', () => { it('should use refreshed time range values instead of draft state when provided', async () => { const {result} = renderHook(() => useQueryState(), { wrapper: createWrapper( {}, '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds{}&from=1000&to=2000&time_selection=relative:minute|15' ), }); // Draft state has original values expect(result.current.draftSelection.from).toBe(1000); expect(result.current.draftSelection.to).toBe(2000); expect(result.current.draftSelection.timeSelection).toBe('relative:minute|15'); // Commit with refreshed time range (simulating re-evaluated time range) act(() => { result.current.commitDraft({ from: 5000, to: 6000, timeSelection: 'relative:minute|15', }); }); await waitFor(() => { // Should use refreshed time range values, not draft values expect(String(result.current.querySelection.from)).toBe('5000'); expect(String(result.current.querySelection.to)).toBe('6000'); expect(result.current.querySelection.timeSelection).toBe('relative:minute|15'); }); }); it('should update draft state with refreshed time range after commit', async () => { const {result} = renderHook( () => useQueryState({ defaultExpression: 'process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}', defaultFrom: 1000, defaultTo: 2000, defaultTimeSelection: 'relative:minute|5', }), {wrapper: createWrapper()} ); // Commit with refreshed time values act(() => { result.current.commitDraft({ from: 3000, to: 4000, timeSelection: 'relative:minute|5', }); }); await waitFor(() => { expect(String(result.current.querySelection.from)).toBe('3000'); expect(String(result.current.querySelection.to)).toBe('4000'); }); // Draft state should be updated with the refreshed time range expect(result.current.draftSelection.from).toBe(3000); expect(result.current.draftSelection.to).toBe(4000); }); it('should trigger navigation even when expression unchanged (time re-evaluation)', async () => { const {result} = renderHook(() => useQueryState(), { wrapper: createWrapper( {}, '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds{}&from=1000&to=2000&time_selection=relative:minute|5' ), }); // First commit with new time values act(() => { result.current.commitDraft({ from: 5000, to: 6000, timeSelection: 'relative:minute|5', }); }); await waitFor(() => { expect(String(result.current.querySelection.from)).toBe('5000'); expect(String(result.current.querySelection.to)).toBe('6000'); }); // Second commit with different time values (simulating clicking Search again) act(() => { result.current.commitDraft({ from: 7000, to: 8000, timeSelection: 'relative:minute|5', }); }); await waitFor(() => { expect(String(result.current.querySelection.from)).toBe('7000'); expect(String(result.current.querySelection.to)).toBe('8000'); }); }); it('should auto-calculate merge params for delta profiles when using refreshed time range', async () => { const {result} = renderHook( () => useQueryState({ defaultExpression: 'process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}', defaultFrom: 1000, defaultTo: 2000, }), {wrapper: createWrapper()} ); // Commit with refreshed time range for delta profile act(() => { result.current.commitDraft({ from: 5000, to: 6000, timeSelection: 'relative:minute|5', }); }); await waitFor(() => { // Verify merge params are calculated from refreshed time range expect(result.current.querySelection.mergeFrom).toBe('5000000000'); // 5000ms * 1_000_000 expect(result.current.querySelection.mergeTo).toBe('6000000000'); // 6000ms * 1_000_000 }); }); it('should use draft values when refreshedTimeRange is not provided', async () => { const {result} = renderHook(() => useQueryState(), { wrapper: createWrapper( {}, '?expression=memory:inuse_space:bytes:space:bytes{}&from=1000&to=2000&time_selection=relative:hour|1' ), }); // Change draft values act(() => { result.current.setDraftTimeRange(3000, 4000, 'relative:minute|30'); }); // Commit without refreshedTimeRange - should use draft values act(() => { result.current.commitDraft(); }); await waitFor(() => { // Should use updated draft values expect(String(result.current.querySelection.from)).toBe('3000'); expect(String(result.current.querySelection.to)).toBe('4000'); expect(result.current.querySelection.timeSelection).toBe('relative:minute|30'); }); }); }); describe('State persistence after page reload', () => { it('should retain committed values after page reload simulation', async () => { // Initial state (using delta profile since sumBy only applies to delta) const {result: result1, unmount} = renderHook(() => useQueryState(), { wrapper: createWrapper( {}, '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&from=1000&to=2000' ), }); // User makes changes to draft (using delta profile since sumBy only applies to delta) act(() => { result1.current.setDraftExpression('memory:alloc_space:bytes:space:bytes:delta{}'); result1.current.setDraftTimeRange(5000, 6000, 'relative:minute|15'); result1.current.setDraftSumBy(['namespace', 'pod']); }); // User clicks Search to commit act(() => { result1.current.commitDraft(); }); await waitFor(() => { expect(result1.current.querySelection.expression).toBe( 'memory:alloc_space:bytes:space:bytes:delta{}' ); }); // Build the query string from the committed state const queryString = new URLSearchParams({ expression: String(result1.current.querySelection.expression), from: String(result1.current.querySelection.from), to: String(result1.current.querySelection.to), time_selection: String(result1.current.querySelection.timeSelection), sum_by: (result1.current.querySelection.sumBy ?? []).join(','), }).toString(); // Unmount the old hook instance unmount(); // Clear navigation mock to verify no new navigation on reload mockNavigateTo.mockClear(); // Create new hook instance (simulating page reload) with the committed search params const {result: result2} = renderHook(() => useQueryState(), { wrapper: createWrapper({}, `?${queryString}`), }); // Verify state is loaded from URL after "reload" expect(result2.current.querySelection.expression).toBe( 'memory:alloc_space:bytes:space:bytes:delta{}' ); expect(result2.current.querySelection.from).toBe(5000); expect(result2.current.querySelection.to).toBe(6000); expect(result2.current.querySelection.timeSelection).toBe('relative:minute|15'); // Draft should be synced with URL state on page load expect(result2.current.draftSelection.expression).toBe( 'memory:alloc_space:bytes:space:bytes:delta{}' ); expect(result2.current.draftSelection.from).toBe(5000); expect(result2.current.draftSelection.to).toBe(6000); }); it('should preserve delta profile merge params after reload', async () => { // Initial state with delta profile const {result: result1, unmount} = renderHook(() => useQueryState(), { wrapper: createWrapper( {}, '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&from=1000&to=2000' ), }); // Commit with time override act(() => { result1.current.commitDraft({ from: 5000, to: 6000, timeSelection: 'relative:minute|5', }); }); await waitFor(() => { expect(result1.current.querySelection.mergeFrom).toBe('5000000000'); expect(result1.current.querySelection.mergeTo).toBe('6000000000'); }); // Simulate page reload with all params including merge params const queryString = new URLSearchParams({ expression: String(result1.current.querySelection.expression), from: String(result1.current.querySelection.from), to: String(result1.current.querySelection.to), time_selection: String(result1.current.querySelection.timeSelection), merge_from: String(result1.current.querySelection.mergeFrom), merge_to: String(result1.current.querySelection.mergeTo), }).toString(); unmount(); mockNavigateTo.mockClear(); // Create new hook instance const {result: result2} = renderHook(() => useQueryState(), { wrapper: createWrapper({}, `?${queryString}`), }); // Verify merge params are preserved expect(result2.current.querySelection.mergeFrom).toBe('5000000000'); expect(result2.current.querySelection.mergeTo).toBe('6000000000'); // Draft should also have merge params expect(result2.current.draftSelection.mergeFrom).toBe('5000000000'); expect(result2.current.draftSelection.mergeTo).toBe('6000000000'); }); }); describe('ProfileSelection state management', () => { it('should initialize with null ProfileSelection when no URL params exist', () => { const {result} = renderHook(() => useQueryState({suffix: '_a'}), {wrapper: createWrapper()}); expect(result.current.profileSelection).toBeNull(); }); it('should compute ProfileSelection from URL params', () => { // Set URL with ProfileSelection params - using valid profile type const {result} = renderHook(() => useQueryState({suffix: '_a'}), { wrapper: createWrapper( {}, '?merge_from_a=1234567890&merge_to_a=9876543210&selection_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{pod="test"}' ), }); const {profileSelection} = result.current; expect(profileSelection).not.toBeNull(); // Test using the interface methods expect(profileSelection?.Type()).toBe('merge'); expect(profileSelection?.ProfileName()).toBe( 'process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta' ); // Test HistoryParams which should return merge params const historyParams = profileSelection?.HistoryParams(); expect(historyParams?.merge_from).toBe('1234567890'); expect(historyParams?.merge_to).toBe('9876543210'); expect(historyParams?.selection).toBe( 'process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{pod="test"}' ); }); it('should auto-commit ProfileSelection to URL when setProfileSelection called', async () => { const {result} = renderHook(() => useQueryState({suffix: '_a'}), {wrapper: createWrapper()}); const mergeFrom = BigInt(5000000000); const mergeTo = BigInt(6000000000); // Create a mock Query object - in real code, this would be Query.parse() const mockQuery = { toString: () => 'memory:inuse_space:bytes:space:bytes{namespace="default"}', profileType: () => ({delta: false}), } as any; act(() => { result.current.setProfileSelection(mergeFrom, mergeTo, mockQuery); }); await waitFor(() => { const {profileSelection} = result.current; expect(profileSelection).not.toBeNull(); const historyParams = profileSelection?.HistoryParams(); expect(historyParams?.selection).toBe( 'memory:inuse_space:bytes:space:bytes{namespace="default"}' ); expect(historyParams?.merge_from).toBe('5000000000'); expect(historyParams?.merge_to).toBe('6000000000'); }); }); it('should use correct suffix for ProfileSelection in comparison mode', async () => { const {result: resultB} = renderHook(() => useQueryState({suffix: '_b'}), { wrapper: createWrapper(), }); const mergeFrom = BigInt(7000000000); const mergeTo = BigInt(8000000000); const mockQuery = { toString: () => 'process_cpu:cpu:nanoseconds:cpu:nanoseconds{job="test"}', profileType: () => ({delta: false}), } as any; act(() => { resultB.current.setProfileSelection(mergeFrom, mergeTo, mockQuery); }); await waitFor(() => { const {profileSelection} = resultB.current; expect(profileSelection).not.toBeNull(); const historyParams = profileSelection?.HistoryParams(); expect(historyParams?.selection).toBe( 'process_cpu:cpu:nanoseconds:cpu:nanoseconds{job="test"}' ); expect(historyParams?.merge_from).toBe('7000000000'); expect(historyParams?.merge_to).toBe('8000000000'); }); }); it('should clear ProfileSelection when commitDraft is called', async () => { // Start with a ProfileSelection in URL - using valid profile type const {result} = renderHook(() => useQueryState({suffix: '_a'}), { wrapper: createWrapper( {}, '?expression_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds{}&merge_from_a=1000000000&merge_to_a=2000000000&selection_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds{pod="test"}' ), }); // Verify ProfileSelection exists expect(result.current.profileSelection).not.toBeNull(); // Make a change to trigger commit act(() => { result.current.setDraftExpression('memory:inuse_space:bytes:space:bytes{}'); }); // Commit the draft (this should clear ProfileSelection as per design decision 4.B) act(() => { result.current.commitDraft(); }); await waitFor(() => { // ProfileSelection should be cleared expect(result.current.profileSelection).toBeNull(); // But QuerySelection params should still be present expect(result.current.querySelection.expression).toBe( 'memory:inuse_space:bytes:space:bytes{}' ); }); }); it('should handle ProfileSelection with delta profiles correctly', () => { const {result} = renderHook(() => useQueryState({suffix: '_a'}), { wrapper: createWrapper( {}, '?merge_from_a=1000000000&merge_to_a=2000000000&selection_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{node="worker"}' ), }); const {profileSelection} = result.current; expect(profileSelection).not.toBeNull(); // Test that ProfileSelection recognizes delta profile type expect(profileSelection?.ProfileName()).toBe( 'process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta' ); // Test HistoryParams const historyParams = profileSelection?.HistoryParams(); expect(historyParams?.merge_from).toBe('1000000000'); expect(historyParams?.merge_to).toBe('2000000000'); }); it('should persist ProfileSelection across page reloads', async () => { // Initial state - user clicks on metrics graph point const {result: result1, unmount} = renderHook(() => useQueryState({suffix: '_a'}), { wrapper: createWrapper(), }); const mergeFrom = BigInt(3000000000); const mergeTo = BigInt(4000000000); const mockQuery = { toString: () => 'memory:alloc_objects:count:space:bytes{pod="test"}', profileType: () => ({delta: false}), } as any; // Set ProfileSelection act(() => { result1.current.setProfileSelection(mergeFrom, mergeTo, mockQuery); }); await waitFor(() => { expect(result1.current.profileSelection).not.toBeNull(); }); // Get the committed state values to build reload URL const historyParams = result1.current.profileSelection?.HistoryParams(); const selectionA = historyParams?.selection ?? ''; const mergeFromA = historyParams?.merge_from ?? ''; const mergeToA = historyParams?.merge_to ?? ''; unmount(); mockNavigateTo.mockClear(); // Create new hook instance (simulating page reload) with the committed search params const {result: result2} = renderHook(() => useQueryState({suffix: '_a'}), { wrapper: createWrapper( {}, // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `?selection_a=${encodeURIComponent(selectionA)}&merge_from_a=${ mergeFromA as string }&merge_to_a=${mergeToA as string}` ), }); // Verify ProfileSelection is loaded from URL after reload const profileSelection = result2.current.profileSelection; expect(profileSelection).not.toBeNull(); // Use interface methods to test expect(profileSelection?.Type()).toBe('merge'); const reloadedHistoryParams = profileSelection?.HistoryParams(); expect(reloadedHistoryParams?.merge_from).toBe('3000000000'); expect(reloadedHistoryParams?.merge_to).toBe('4000000000'); expect(reloadedHistoryParams?.selection).toBe( 'memory:alloc_objects:count:space:bytes{pod="test"}' ); }); it('should handle independent ProfileSelection for both sides in comparison mode', async () => { // Test component using both hooks with the same URLStateProvider (real-world scenario) // eslint-disable-next-line @typescript-eslint/explicit-function-return-type const TestComponent = () => { const stateA = useQueryState({suffix: '_a'}); const stateB = useQueryState({suffix: '_b'}); return {stateA, stateB}; }; const {result} = renderHook(() => TestComponent(), { wrapper: createWrapper(), }); const mockQueryA = { toString: () => 'process_cpu:cpu:nanoseconds:cpu:nanoseconds{pod="app-a"}', profileType: () => ({delta: false}), } as any; const mockQueryB = { toString: () => 'process_cpu:cpu:nanoseconds:cpu:nanoseconds{pod="app-b"}', profileType: () => ({delta: false}), } as any; // Set ProfileSelection for side A act(() => { result.current.stateA.setProfileSelection( BigInt(1000000000), BigInt(2000000000), mockQueryA ); }); await waitFor(() => { expect(result.current.stateA.profileSelection).not.toBeNull(); }); // Set ProfileSelection for side B act(() => { result.current.stateB.setProfileSelection( BigInt(3000000000), BigInt(4000000000), mockQueryB ); }); // Verify both ProfileSelections exist await waitFor(() => { expect(result.current.stateA.profileSelection).not.toBeNull(); expect(result.current.stateB.profileSelection).not.toBeNull(); }); }); it('should return null ProfileSelection when only partial params exist', () => { // Missing selection param const {result} = renderHook(() => useQueryState({suffix: '_a'}), { wrapper: createWrapper({}, '?merge_from_a=1000000000&merge_to_a=2000000000'), }); expect(result.current.profileSelection).toBeNull(); }); it('should handle ProfileSelection with complex query expressions', async () => { const {result} = renderHook(() => useQueryState({suffix: '_a'}), {wrapper: createWrapper()}); const mockQuery = { toString: () => 'memory:alloc_objects:count:space:bytes:delta{namespace="default",pod="app-1",container="main"}', profileType: () => ({delta: true}), } as any; act(() => { result.current.setProfileSelection(BigInt(5000000000), BigInt(6000000000), mockQuery); }); await waitFor(() => { const {profileSelection} = result.current; expect(profileSelection).not.toBeNull(); const historyParams = profileSelection?.HistoryParams(); // The expression gets re-serialized through Query.parse which adds spaces after commas expect(historyParams?.selection).toBe( 'memory:alloc_objects:count:space:bytes:delta{namespace="default", pod="app-1", container="main"}' ); }); }); it('should batch ProfileSelection update with other URL state changes', async () => { const {result} = renderHook(() => useQueryState({suffix: '_a'}), {wrapper: createWrapper()}); const mockQuery = { toString: () => 'process_cpu:cpu:nanoseconds:cpu:nanoseconds{job="test"}', profileType: () => ({delta: false}), } as any; // The batchUpdates is used internally by setProfileSelection act(() => { result.current.setProfileSelection(BigInt(1000000000), BigInt(2000000000), mockQuery); }); await waitFor(() => { const {profileSelection} = result.current; expect(profileSelection).not.toBeNull(); const historyParams = profileSelection?.HistoryParams(); expect(historyParams?.selection).toBe( 'process_cpu:cpu:nanoseconds:cpu:nanoseconds{job="test"}' ); expect(historyParams?.merge_from).toBe('1000000000'); expect(historyParams?.merge_to).toBe('2000000000'); }); }); it('should preserve other URL params when setting ProfileSelection', async () => { const {result} = renderHook(() => useQueryState({suffix: '_a'}), { wrapper: createWrapper( {}, '?expression_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds{}&other_param=value&unrelated=test' ), }); const mockQuery = { toString: () => 'process_cpu:cpu:nanoseconds:cpu:nanoseconds{pod="test"}', profileType: () => ({delta: false}), } as any; act(() => { result.current.setProfileSelection(BigInt(1000000000), BigInt(2000000000), mockQuery); }); await waitFor(() => { // ProfileSelection params should be set const {profileSelection} = result.current; expect(profileSelection).not.toBeNull(); const historyParams = profileSelection?.HistoryParams(); expect(historyParams?.selection).toBe( 'process_cpu:cpu:nanoseconds:cpu:nanoseconds{pod="test"}' ); // Expression should still be present expect(result.current.querySelection.expression).toBe( 'process_cpu:cpu:nanoseconds:cpu:nanoseconds{}' ); }); }); }); });