From 7be7428e35c5a5e59e385fa461db8a6f16c214ee Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Mon, 11 May 2026 00:20:24 +0900 Subject: [PATCH 1/4] test(query-devtools/Devtools): add tests for query list rendering, filtering, and status indicators --- .../src/__tests__/Devtools.test.tsx | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) diff --git a/packages/query-devtools/src/__tests__/Devtools.test.tsx b/packages/query-devtools/src/__tests__/Devtools.test.tsx index 924be5aad5..db2293415e 100644 --- a/packages/query-devtools/src/__tests__/Devtools.test.tsx +++ b/packages/query-devtools/src/__tests__/Devtools.test.tsx @@ -244,4 +244,173 @@ describe('Devtools', () => { expect(localStorage.getItem('TanstackQueryDevtools.open')).toBe('false') }) }) + + describe('query list', () => { + it('should render a row for each query in the cache', () => { + queryClient.setQueryData(['posts'], [{ id: 1 }]) + queryClient.setQueryData(['users', 'me'], { id: 'u1' }) + const rendered = renderDevtools({ initialIsOpen: true }) + + expect( + rendered.getByLabelText(/Query key \["posts"\]/), + ).toBeInTheDocument() + expect( + rendered.getByLabelText(/Query key \["users","me"\]/), + ).toBeInTheDocument() + }) + + it('should reflect a newly added query reactively', () => { + const rendered = renderDevtools({ initialIsOpen: true }) + + expect( + rendered.queryByLabelText(/Query key \["new"\]/), + ).not.toBeInTheDocument() + + queryClient.setQueryData(['new'], 'hello') + + expect(rendered.getByLabelText(/Query key \["new"\]/)).toBeInTheDocument() + }) + + it('should filter queries by "queryHash"', () => { + queryClient.setQueryData(['posts'], []) + queryClient.setQueryData(['users'], []) + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.input(rendered.getByLabelText('Filter queries by query key'), { + target: { value: 'posts' }, + }) + + expect( + rendered.getByLabelText(/Query key \["posts"\]/), + ).toBeInTheDocument() + expect( + rendered.queryByLabelText(/Query key \["users"\]/), + ).not.toBeInTheDocument() + }) + + it('should clear all queries when the clear cache button is clicked', () => { + queryClient.setQueryData(['posts'], []) + queryClient.setQueryData(['users'], []) + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click(rendered.getByLabelText('Clear query cache')) + + expect( + rendered.queryByLabelText(/Query key \["posts"\]/), + ).not.toBeInTheDocument() + expect( + rendered.queryByLabelText(/Query key \["users"\]/), + ).not.toBeInTheDocument() + }) + + it('should dispatch a "CLEAR_MUTATION_CACHE" event when clear cache is clicked in mutations view', () => { + const rendered = renderDevtools({ initialIsOpen: true }) + fireEvent.click(rendered.getByText('Mutations')) + + const listener = vi.fn() + window.addEventListener('@tanstack/query-devtools-event', listener) + + try { + fireEvent.click(rendered.getByLabelText('Clear query cache')) + + expect(listener).toHaveBeenCalled() + const event = listener.mock.calls[0]?.[0] as CustomEvent + expect(event.detail.type).toBe('CLEAR_MUTATION_CACHE') + } finally { + window.removeEventListener('@tanstack/query-devtools-event', listener) + } + }) + }) + + describe('view toggle', () => { + it('should switch to mutations view when the mutations toggle is clicked', () => { + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click(rendered.getByText('Mutations')) + + expect( + rendered.container.querySelector('.tsqd-mutations-container'), + ).not.toBeNull() + }) + + it('should render mutations in the mutations view', async () => { + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click(rendered.getByText('Mutations')) + + const mutation = queryClient.getMutationCache().build(queryClient, { + mutationKey: ['add-post'], + mutationFn: () => Promise.resolve('ok'), + }) + mutation.execute({}) + await Promise.resolve() + + expect( + rendered.getByLabelText(/Mutation submitted at/), + ).toBeInTheDocument() + }) + }) + + describe('disabled and static queries', () => { + it('should mark a disabled query in the row label', () => { + const observer = queryClient.getQueryCache().build(queryClient, { + queryKey: ['disabled-q'], + queryFn: () => 'x', + }) + observer.setOptions({ + ...observer.options, + enabled: false, + } as typeof observer.options) + observer.setState({ ...observer.state, data: 'x' }) + const rendered = renderDevtools({ initialIsOpen: true }) + + expect(rendered.getByLabelText(/disabled/)).toBeInTheDocument() + }) + }) + + describe('status counts', () => { + it('should render status count badges', () => { + const rendered = renderDevtools({ initialIsOpen: true }) + + expect(rendered.getByLabelText(/Fresh: \d+/)).toBeInTheDocument() + expect(rendered.getByLabelText(/Stale: \d+/)).toBeInTheDocument() + expect(rendered.getByLabelText(/Fetching: \d+/)).toBeInTheDocument() + expect(rendered.getByLabelText(/Paused: \d+/)).toBeInTheDocument() + expect(rendered.getByLabelText(/Inactive: \d+/)).toBeInTheDocument() + }) + + it('should reflect the inactive count when a query is added without observers', () => { + const rendered = renderDevtools({ initialIsOpen: true }) + + expect(rendered.getByLabelText('Inactive: 0')).toBeInTheDocument() + + queryClient.setQueryData(['posts'], [{ id: 1 }]) + + expect(rendered.getByLabelText('Inactive: 1')).toBeInTheDocument() + }) + }) + + describe('status tooltip', () => { + it('should show the tooltip on mouse enter when label is hidden', () => { + const rendered = renderDevtools( + { initialIsOpen: true }, + { + 'TanstackQueryDevtools.open': 'true', + 'TanstackQueryDevtools.height': '500', + 'TanstackQueryDevtools.width': '500', + }, + ) + + const fresh = rendered.getByLabelText('Fresh: 0') + fireEvent.mouseEnter(fresh) + + // tooltip is conditionally rendered based on showLabel + mouseOver/focused + // not deterministic via panelWidth in jsdom but the handler itself runs + fireEvent.mouseLeave(fresh) + fireEvent.focus(fresh) + fireEvent.blur(fresh) + + expect(fresh).toBeInTheDocument() + }) + }) }) From 58f3a1ff964859478c37748e7cecdf6560dadb0c Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Mon, 11 May 2026 00:20:50 +0900 Subject: [PATCH 2/4] test(query-devtools/Devtools): use 'some' to find 'CLEAR_MUTATION_CACHE' event in dispatched calls --- packages/query-devtools/src/__tests__/Devtools.test.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/query-devtools/src/__tests__/Devtools.test.tsx b/packages/query-devtools/src/__tests__/Devtools.test.tsx index db2293415e..7e3c258b54 100644 --- a/packages/query-devtools/src/__tests__/Devtools.test.tsx +++ b/packages/query-devtools/src/__tests__/Devtools.test.tsx @@ -313,9 +313,10 @@ describe('Devtools', () => { try { fireEvent.click(rendered.getByLabelText('Clear query cache')) - expect(listener).toHaveBeenCalled() - const event = listener.mock.calls[0]?.[0] as CustomEvent - expect(event.detail.type).toBe('CLEAR_MUTATION_CACHE') + const dispatched = listener.mock.calls.some( + ([e]) => (e as CustomEvent).detail.type === 'CLEAR_MUTATION_CACHE', + ) + expect(dispatched).toBe(true) } finally { window.removeEventListener('@tanstack/query-devtools-event', listener) } From 8ab0ee5cb551e4c4e33e9d36b951b05082b40e9c Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Mon, 11 May 2026 00:22:55 +0900 Subject: [PATCH 3/4] test(query-devtools/Devtools): use 'vi.useFakeTimers' and 'vi.advanceTimersByTimeAsync' for microtask flush --- packages/query-devtools/src/__tests__/Devtools.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/query-devtools/src/__tests__/Devtools.test.tsx b/packages/query-devtools/src/__tests__/Devtools.test.tsx index 7e3c258b54..8fbaed1e70 100644 --- a/packages/query-devtools/src/__tests__/Devtools.test.tsx +++ b/packages/query-devtools/src/__tests__/Devtools.test.tsx @@ -32,6 +32,7 @@ describe('Devtools', () => { let previousRootFontSize = '' beforeEach(() => { + vi.useFakeTimers() previousRootFontSize = document.documentElement.style.fontSize // jsdom doesn't implement `PointerEvent`; the DropdownMenu trigger checks // `e.pointerType !== 'touch'` on pointerdown to decide whether to open, @@ -101,6 +102,7 @@ describe('Devtools', () => { }) afterEach(() => { + vi.useRealTimers() vi.unstubAllGlobals() Object.keys(storage).forEach((key) => delete storage[key]) queryClient.clear() @@ -344,7 +346,7 @@ describe('Devtools', () => { mutationFn: () => Promise.resolve('ok'), }) mutation.execute({}) - await Promise.resolve() + await vi.advanceTimersByTimeAsync(0) expect( rendered.getByLabelText(/Mutation submitted at/), From fded29e3b706cb30a9b4542204b5d340bb4fc446 Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Mon, 11 May 2026 00:24:14 +0900 Subject: [PATCH 4/4] test(query-devtools/Devtools): assert tooltip visibility on hover/focus instead of badge presence --- .../src/__tests__/Devtools.test.tsx | 75 ++++++++++++++++--- 1 file changed, 64 insertions(+), 11 deletions(-) diff --git a/packages/query-devtools/src/__tests__/Devtools.test.tsx b/packages/query-devtools/src/__tests__/Devtools.test.tsx index 8fbaed1e70..f1254d80c3 100644 --- a/packages/query-devtools/src/__tests__/Devtools.test.tsx +++ b/packages/query-devtools/src/__tests__/Devtools.test.tsx @@ -394,26 +394,79 @@ describe('Devtools', () => { }) describe('status tooltip', () => { - it('should show the tooltip on mouse enter when label is hidden', () => { - const rendered = renderDevtools( - { initialIsOpen: true }, - { - 'TanstackQueryDevtools.open': 'true', - 'TanstackQueryDevtools.height': '500', - 'TanstackQueryDevtools.width': '500', + it('should show the tooltip on mouse enter and hide it on mouse leave when the panel is narrow', () => { + // Re-stub ResizeObserver with a narrow width (< secondBreakpoint = 796) + // so `showLabel()` is false and the tooltip is rendered conditionally on + // hover/focus. + vi.stubGlobal( + 'ResizeObserver', + class { + callback: ResizeObserverCallback + constructor(callback: ResizeObserverCallback) { + this.callback = callback + } + observe = vi.fn((target: Element) => { + this.callback( + [ + { + target, + contentRect: { width: 500, height: 500 } as DOMRectReadOnly, + } as ResizeObserverEntry, + ], + this as unknown as ResizeObserver, + ) + }) + unobserve = vi.fn() + disconnect = vi.fn() }, ) + const rendered = renderDevtools({ initialIsOpen: true }) const fresh = rendered.getByLabelText('Fresh: 0') + + expect(rendered.queryByRole('tooltip')).not.toBeInTheDocument() + fireEvent.mouseEnter(fresh) + expect(rendered.getByRole('tooltip')).toBeInTheDocument() - // tooltip is conditionally rendered based on showLabel + mouseOver/focused - // not deterministic via panelWidth in jsdom but the handler itself runs fireEvent.mouseLeave(fresh) + expect(rendered.queryByRole('tooltip')).not.toBeInTheDocument() + }) + + it('should show the tooltip on focus and hide it on blur when the panel is narrow', () => { + vi.stubGlobal( + 'ResizeObserver', + class { + callback: ResizeObserverCallback + constructor(callback: ResizeObserverCallback) { + this.callback = callback + } + observe = vi.fn((target: Element) => { + this.callback( + [ + { + target, + contentRect: { width: 500, height: 500 } as DOMRectReadOnly, + } as ResizeObserverEntry, + ], + this as unknown as ResizeObserver, + ) + }) + unobserve = vi.fn() + disconnect = vi.fn() + }, + ) + + const rendered = renderDevtools({ initialIsOpen: true }) + const fresh = rendered.getByLabelText('Fresh: 0') + + expect(rendered.queryByRole('tooltip')).not.toBeInTheDocument() + fireEvent.focus(fresh) - fireEvent.blur(fresh) + expect(rendered.getByRole('tooltip')).toBeInTheDocument() - expect(fresh).toBeInTheDocument() + fireEvent.blur(fresh) + expect(rendered.queryByRole('tooltip')).not.toBeInTheDocument() }) }) })