From 98834a78370af4ee67aeb436efb6227ba4e2143b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B1=E3=82=A4=E3=83=A9?= Date: Fri, 23 Jan 2026 11:28:59 -0700 Subject: [PATCH] chore: continue vitest test migrations (#21639) --- site/jest.config.ts | 9 +- site/src/api/{api.jest.ts => api.test.ts} | 64 ++++---- .../{debounce.jest.ts => debounce.test.ts} | 49 +++--- site/src/hooks/debounce.ts | 12 +- ...ta.jest.ts => useEmbeddedMetadata.test.ts} | 2 +- ...uery.jest.ts => usePaginatedQuery.test.ts} | 149 +++++++++--------- ...Key.jest.ts => useSearchParamsKey.test.ts} | 0 7 files changed, 140 insertions(+), 145 deletions(-) rename site/src/api/{api.jest.ts => api.test.ts} (80%) rename site/src/hooks/{debounce.jest.ts => debounce.test.ts} (85%) rename site/src/hooks/{useEmbeddedMetadata.jest.ts => useEmbeddedMetadata.test.ts} (99%) rename site/src/hooks/{usePaginatedQuery.jest.ts => usePaginatedQuery.test.ts} (74%) rename site/src/hooks/{useSearchParamsKey.jest.ts => useSearchParamsKey.test.ts} (100%) diff --git a/site/jest.config.ts b/site/jest.config.ts index 5ee9ec7ebd..79a0558c3e 100644 --- a/site/jest.config.ts +++ b/site/jest.config.ts @@ -32,14 +32,7 @@ module.exports = { customExportConditions: [""], }, testRegex: "(/__tests__/.*|(\\.|/)(jest))\\.tsx?$", - testPathIgnorePatterns: [ - "/node_modules/", - "/e2e/", - // TODO: This test is timing out after upgrade a few Jest dependencies - // and I was not able to figure out why. When running it specifically, I - // can see many act warnings that may can help us to find the issue. - "/usePaginatedQuery.test.ts", - ], + testPathIgnorePatterns: ["/node_modules/", "/e2e/"], transformIgnorePatterns: [], moduleDirectories: ["node_modules", "/src"], moduleNameMapper: { diff --git a/site/src/api/api.jest.ts b/site/src/api/api.test.ts similarity index 80% rename from site/src/api/api.jest.ts rename to site/src/api/api.test.ts index 8c4c8556d4..c753261997 100644 --- a/site/src/api/api.jest.ts +++ b/site/src/api/api.test.ts @@ -21,9 +21,9 @@ describe("api.ts", () => { session_token: "abc_123_test", }; - jest - .spyOn(axiosInstance, "post") - .mockResolvedValueOnce({ data: loginResponse }); + vi.spyOn(axiosInstance, "post").mockResolvedValueOnce({ + data: loginResponse, + }); // when const result = await API.login("test", "123"); @@ -41,7 +41,7 @@ describe("api.ts", () => { message: "Validation failed", errors: [{ field: "email", code: "email" }], }; - const axiosMockPost = jest.fn().mockImplementationOnce(() => { + const axiosMockPost = vi.fn().mockImplementationOnce(() => { return Promise.reject(expectedError); }); axiosInstance.post = axiosMockPost; @@ -57,7 +57,7 @@ describe("api.ts", () => { describe("logout", () => { it("should return without erroring", async () => { // given - const axiosMockPost = jest.fn().mockImplementationOnce(() => { + const axiosMockPost = vi.fn().mockImplementationOnce(() => { return Promise.resolve(); }); axiosInstance.post = axiosMockPost; @@ -76,7 +76,7 @@ describe("api.ts", () => { const expectedError = { message: "Failed to logout.", }; - const axiosMockPost = jest.fn().mockImplementationOnce(() => { + const axiosMockPost = vi.fn().mockImplementationOnce(() => { return Promise.reject(expectedError); }); @@ -96,7 +96,7 @@ describe("api.ts", () => { const apiKeyResponse: TypesGen.GenerateAPIKeyResponse = { key: "abc_123_test", }; - const axiosMockPost = jest.fn().mockImplementationOnce(() => { + const axiosMockPost = vi.fn().mockImplementationOnce(() => { return Promise.resolve({ data: apiKeyResponse }); }); @@ -117,7 +117,7 @@ describe("api.ts", () => { const expectedError = { message: "No Cookie!", }; - const axiosMockPost = jest.fn().mockImplementationOnce(() => { + const axiosMockPost = vi.fn().mockImplementationOnce(() => { return Promise.reject(expectedError); }); @@ -175,16 +175,16 @@ describe("api.ts", () => { describe("update", () => { describe("given a running workspace", () => { it("stops with current version before starting with the latest version", async () => { - jest.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({ + vi.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({ ...MockWorkspaceBuild, transition: "stop", }); - jest.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({ + vi.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({ ...MockWorkspaceBuild, template_version_id: MockTemplateVersion2.id, transition: "start", }); - jest.spyOn(API, "getTemplate").mockResolvedValueOnce({ + vi.spyOn(API, "getTemplate").mockResolvedValueOnce({ ...MockTemplate, active_version_id: MockTemplateVersion2.id, }); @@ -201,17 +201,15 @@ describe("api.ts", () => { }); it("fails when having missing parameters", async () => { - jest - .spyOn(API, "postWorkspaceBuild") - .mockResolvedValue(MockWorkspaceBuild); - jest.spyOn(API, "getTemplate").mockResolvedValue(MockTemplate); - jest.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValue([]); - jest - .spyOn(API, "getTemplateVersionRichParameters") - .mockResolvedValue([ - MockTemplateVersionParameter1, - { ...MockTemplateVersionParameter2, mutable: false }, - ]); + vi.spyOn(API, "postWorkspaceBuild").mockResolvedValue( + MockWorkspaceBuild, + ); + vi.spyOn(API, "getTemplate").mockResolvedValue(MockTemplate); + vi.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValue([]); + vi.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValue([ + MockTemplateVersionParameter1, + { ...MockTemplateVersionParameter2, mutable: false }, + ]); let error = new Error(); try { @@ -229,20 +227,20 @@ describe("api.ts", () => { }); it("creates a build with no parameters if it is already filled", async () => { - jest.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({ + vi.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({ ...MockWorkspaceBuild, transition: "stop", }); - jest.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({ + vi.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({ ...MockWorkspaceBuild, template_version_id: MockTemplateVersion2.id, transition: "start", }); - jest.spyOn(API, "getTemplate").mockResolvedValueOnce(MockTemplate); - jest - .spyOn(API, "getWorkspaceBuildParameters") - .mockResolvedValue([MockWorkspaceBuildParameter1]); - jest.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValue([ + vi.spyOn(API, "getTemplate").mockResolvedValueOnce(MockTemplate); + vi.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValue([ + MockWorkspaceBuildParameter1, + ]); + vi.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValue([ { ...MockTemplateVersionParameter1, required: true, @@ -263,10 +261,10 @@ describe("api.ts", () => { }); describe("given a stopped workspace", () => { it("creates a build with start and the latest template", async () => { - jest - .spyOn(API, "postWorkspaceBuild") - .mockResolvedValueOnce(MockWorkspaceBuild); - jest.spyOn(API, "getTemplate").mockResolvedValueOnce({ + vi.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce( + MockWorkspaceBuild, + ); + vi.spyOn(API, "getTemplate").mockResolvedValueOnce({ ...MockTemplate, active_version_id: MockTemplateVersion2.id, }); diff --git a/site/src/hooks/debounce.jest.ts b/site/src/hooks/debounce.test.ts similarity index 85% rename from site/src/hooks/debounce.jest.ts rename to site/src/hooks/debounce.test.ts index 6de4a261f3..37dc01e5cb 100644 --- a/site/src/hooks/debounce.jest.ts +++ b/site/src/hooks/debounce.test.ts @@ -1,14 +1,14 @@ -import { renderHook, waitFor } from "@testing-library/react"; +import { act, renderHook } from "@testing-library/react"; import { useDebouncedFunction, useDebouncedValue } from "./debounce"; beforeAll(() => { - jest.useFakeTimers(); - jest.spyOn(global, "setTimeout"); + vi.useFakeTimers(); + vi.spyOn(global, "setTimeout"); }); afterAll(() => { - jest.useRealTimers(); - jest.clearAllMocks(); + vi.useRealTimers(); + vi.clearAllMocks(); }); describe(useDebouncedValue.name, () => { @@ -62,7 +62,7 @@ describe(useDebouncedValue.name, () => { }, i * 100); } - await jest.advanceTimersByTimeAsync(time - 100); + await vi.advanceTimersByTimeAsync(time - 100); expect(result.current).toEqual(0); }); @@ -74,8 +74,13 @@ describe(useDebouncedValue.name, () => { expect(result.current).toEqual(false); rerender({ value: !initialValue, time }); - await jest.runAllTimersAsync(); - await waitFor(() => expect(result.current).toEqual(true)); + + // Waiting until the debounce time has elapsed will trigger a state update. + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(result.current).toEqual(true); }); // Very important that we not do any async logic for this test @@ -122,7 +127,7 @@ describe(`${useDebouncedFunction.name}`, () => { -42, ]; - const dummyFunction = jest.fn(); + const dummyFunction = vi.fn(); for (const input of invalidInputs) { expect(() => { renderDebouncedFunction(dummyFunction, input); @@ -136,12 +141,12 @@ describe(`${useDebouncedFunction.name}`, () => { describe("hook", () => { it("Should provide stable function references across re-renders", () => { const time = 5000; - const { result, rerender } = renderDebouncedFunction(jest.fn(), time); + const { result, rerender } = renderDebouncedFunction(vi.fn(), time); const { debounced: oldDebounced, cancelDebounce: oldCancel } = result.current; - rerender({ callback: jest.fn(), time }); + rerender({ callback: vi.fn(), time }); const { debounced: newDebounced, cancelDebounce: newCancel } = result.current; @@ -151,43 +156,43 @@ describe(`${useDebouncedFunction.name}`, () => { it("Resets any pending debounces if the timer argument changes", async () => { const time = 5000; - const mockCallback = jest.fn(); + const mockCallback = vi.fn(); const { result, rerender } = renderDebouncedFunction(mockCallback, time); result.current.debounced(); rerender({ callback: mockCallback, time: time + 1 }); - await jest.runAllTimersAsync(); + await vi.runAllTimersAsync(); expect(mockCallback).not.toBeCalled(); }); }); describe("debounced function", () => { it("Resolve the debounce after specified milliseconds pass with no other calls", async () => { - const mockCallback = jest.fn(); + const mockCallback = vi.fn(); const { result } = renderDebouncedFunction(mockCallback, 100); result.current.debounced(); - await jest.runOnlyPendingTimersAsync(); + await vi.runOnlyPendingTimersAsync(); expect(mockCallback).toBeCalledTimes(1); }); it("Always uses the most recent callback argument passed in (even if it switches while a debounce is queued)", async () => { - const mockCallback1 = jest.fn(); - const mockCallback2 = jest.fn(); + const mockCallback1 = vi.fn(); + const mockCallback2 = vi.fn(); const time = 500; const { result, rerender } = renderDebouncedFunction(mockCallback1, time); result.current.debounced(); rerender({ callback: mockCallback2, time }); - await jest.runAllTimersAsync(); + await vi.runAllTimersAsync(); expect(mockCallback1).not.toBeCalled(); expect(mockCallback2).toBeCalledTimes(1); }); it("Should reset the debounce timer with repeated calls to the method", async () => { - const mockCallback = jest.fn(); + const mockCallback = vi.fn(); const { result } = renderDebouncedFunction(mockCallback, 2000); for (let i = 0; i < 10; i++) { @@ -196,20 +201,20 @@ describe(`${useDebouncedFunction.name}`, () => { }, i * 100); } - await jest.runAllTimersAsync(); + await vi.runAllTimersAsync(); expect(mockCallback).toBeCalledTimes(1); }); }); describe("cancelDebounce function", () => { it("Should be able to cancel a pending debounce", async () => { - const mockCallback = jest.fn(); + const mockCallback = vi.fn(); const { result } = renderDebouncedFunction(mockCallback, 2000); result.current.debounced(); result.current.cancelDebounce(); - await jest.runAllTimersAsync(); + await vi.runAllTimersAsync(); expect(mockCallback).not.toBeCalled(); }); }); diff --git a/site/src/hooks/debounce.ts b/site/src/hooks/debounce.ts index 0ed3d960d0..e6a23bcdd3 100644 --- a/site/src/hooks/debounce.ts +++ b/site/src/hooks/debounce.ts @@ -43,10 +43,12 @@ export function useDebouncedFunction< ); } - const timeoutIdRef = useRef(undefined); + const timeoutIdRef = useRef | undefined>( + undefined, + ); const cancelDebounce = useCallback(() => { if (timeoutIdRef.current !== undefined) { - window.clearTimeout(timeoutIdRef.current); + clearTimeout(timeoutIdRef.current); } timeoutIdRef.current = undefined; @@ -70,7 +72,7 @@ export function useDebouncedFunction< (...args: Args): void => { cancelDebounce(); - timeoutIdRef.current = window.setTimeout( + timeoutIdRef.current = setTimeout( () => void callbackRef.current(...args), debounceTimeRef.current, ); @@ -105,10 +107,10 @@ export function useDebouncedValue(value: T, debounceTimeoutMs: number): T { return; } - const timeoutId = window.setTimeout(() => { + const timeoutId = setTimeout(() => { setDebouncedValue(value); }, debounceTimeoutMs); - return () => window.clearTimeout(timeoutId); + return () => clearTimeout(timeoutId); }, [value, debounceTimeoutMs]); return debouncedValue; diff --git a/site/src/hooks/useEmbeddedMetadata.jest.ts b/site/src/hooks/useEmbeddedMetadata.test.ts similarity index 99% rename from site/src/hooks/useEmbeddedMetadata.jest.ts rename to site/src/hooks/useEmbeddedMetadata.test.ts index 60002381ac..f8dcdf17eb 100644 --- a/site/src/hooks/useEmbeddedMetadata.jest.ts +++ b/site/src/hooks/useEmbeddedMetadata.test.ts @@ -190,7 +190,7 @@ describe(useEmbeddedMetadata.name, () => { const cleanupTags = seedInitialMetadata(key); const { result: reactResult, manager } = renderMetadataHook(key); - const nonReactSubscriber = jest.fn(); + const nonReactSubscriber = vi.fn(); manager.subscribe(nonReactSubscriber); const expectedUpdate1: RuntimeHtmlMetadata = { diff --git a/site/src/hooks/usePaginatedQuery.jest.ts b/site/src/hooks/usePaginatedQuery.test.ts similarity index 74% rename from site/src/hooks/usePaginatedQuery.jest.ts rename to site/src/hooks/usePaginatedQuery.test.ts index 3811cd134c..0d620451d2 100644 --- a/site/src/hooks/usePaginatedQuery.jest.ts +++ b/site/src/hooks/usePaginatedQuery.test.ts @@ -1,9 +1,3 @@ -// TODO: This test is timing out after upgrade a few Jest dependencies -// and I was not able to figure out why. When running it specifically, I -// can see many act warnings that may can help us to find the issue. -// (Note: This comment was originally written by Bruno, and was relocated by -// me. If you go poking at `git blame`, disabling these tests was not my idea. - import { renderHookWithAuth } from "testHelpers/hooks"; import { waitFor } from "@testing-library/react"; import { @@ -12,13 +6,8 @@ import { usePaginatedQuery, } from "./usePaginatedQuery"; -beforeAll(() => { - jest.useFakeTimers(); -}); - -afterAll(() => { - jest.useRealTimers(); - jest.clearAllMocks(); +afterEach(() => { + vi.clearAllMocks(); }); function render< @@ -39,16 +28,12 @@ function render< }); } -/** - * There are a lot of test cases in this file. Scoping mocking to inner describe - * function calls to limit the cognitive load of maintaining all this stuff - */ -describe.skip(usePaginatedQuery.name, () => { +describe(usePaginatedQuery.name, () => { describe("queryPayload method", () => { - const mockQueryFn = jest.fn(() => Promise.resolve({ count: 0 })); + const mockQueryFn = vi.fn(() => Promise.resolve({ count: 0 })); it("Passes along an undefined payload if queryPayload is not used", async () => { - const mockQueryKey = jest.fn(() => ["mockQuery"]); + const mockQueryKey = vi.fn(() => ["mockQuery"]); await render({ queryKey: mockQueryKey, @@ -64,7 +49,7 @@ describe.skip(usePaginatedQuery.name, () => { }); it("Passes along type-safe payload if queryPayload is provided", async () => { - const mockQueryKey = jest.fn(({ payload }) => { + const mockQueryKey = vi.fn(({ payload }) => { return ["mockQuery", payload]; }); @@ -85,8 +70,8 @@ describe.skip(usePaginatedQuery.name, () => { }); describe("Querying for current page", () => { - const mockQueryKey = jest.fn(() => ["mock"]); - const mockQueryFn = jest.fn(() => Promise.resolve({ count: 50 })); + const mockQueryKey = vi.fn(() => ["mock"]); + const mockQueryFn = vi.fn(() => Promise.resolve({ count: 50 })); it("Parses page number if it exists in URL params", async () => { const pageNumbers = [1, 2, 7, 39, 743]; @@ -113,7 +98,7 @@ describe.skip(usePaginatedQuery.name, () => { }); describe("Prefetching", () => { - const mockQueryKey = jest.fn(({ pageNumber }) => ["query", pageNumber]); + const mockQueryKey = vi.fn(({ pageNumber }) => ["query", pageNumber]); type Context = { pageNumber: number; limit: number }; const mockQueryFnImplementation = ({ pageNumber, limit }: Context) => { @@ -134,7 +119,7 @@ describe.skip(usePaginatedQuery.name, () => { ) => { // Have to reinitialize mock function every call to avoid false positives // from shared mutable tracking state - const mockQueryFn = jest.fn(mockQueryFnImplementation); + const mockQueryFn = vi.fn(mockQueryFnImplementation); const { result } = await render( { queryKey: mockQueryKey, queryFn: mockQueryFn }, `/?page=${startingPage}`, @@ -143,16 +128,12 @@ describe.skip(usePaginatedQuery.name, () => { const pageMatcher = expect.objectContaining({ pageNumber: targetPage }); if (shouldMatch) { await waitFor(() => expect(result.current.totalRecords).toBeDefined()); - await waitFor(() => expect(mockQueryFn).toBeCalledWith(pageMatcher)); + await waitFor(() => + expect(mockQueryFn).toHaveBeenCalledWith(pageMatcher), + ); } else { - // Can't use waitFor to test this, because the expect call will - // immediately succeed for the not case, even though queryFn needs to be - // called async via React Query - setTimeout(() => { - expect(mockQueryFn).not.toBeCalledWith(pageMatcher); - }, 1000); - - jest.runAllTimers(); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(mockQueryFn).not.toHaveBeenCalledWith(pageMatcher); } }; @@ -175,7 +156,7 @@ describe.skip(usePaginatedQuery.name, () => { it("Reuses the same queryKey and queryFn methods for the current page and all prefetching (on a given render)", async () => { const startPage = 2; - const mockQueryFn = jest.fn(mockQueryFnImplementation); + const mockQueryFn = vi.fn(mockQueryFnImplementation); await render( { queryKey: mockQueryKey, queryFn: mockQueryFn }, @@ -183,26 +164,34 @@ describe.skip(usePaginatedQuery.name, () => { ); const currentMatcher = expect.objectContaining({ pageNumber: startPage }); - expect(mockQueryKey).toBeCalledWith(currentMatcher); - expect(mockQueryFn).toBeCalledWith(currentMatcher); + expect(mockQueryKey).toHaveBeenCalledWith(currentMatcher); + expect(mockQueryFn).toHaveBeenCalledWith(currentMatcher); const prevPageMatcher = expect.objectContaining({ pageNumber: startPage - 1, }); - await waitFor(() => expect(mockQueryKey).toBeCalledWith(prevPageMatcher)); - await waitFor(() => expect(mockQueryFn).toBeCalledWith(prevPageMatcher)); + await waitFor(() => + expect(mockQueryKey).toHaveBeenCalledWith(prevPageMatcher), + ); + await waitFor(() => + expect(mockQueryFn).toHaveBeenCalledWith(prevPageMatcher), + ); const nextPageMatcher = expect.objectContaining({ pageNumber: startPage + 1, }); - await waitFor(() => expect(mockQueryKey).toBeCalledWith(nextPageMatcher)); - await waitFor(() => expect(mockQueryFn).toBeCalledWith(nextPageMatcher)); + await waitFor(() => + expect(mockQueryKey).toHaveBeenCalledWith(nextPageMatcher), + ); + await waitFor(() => + expect(mockQueryFn).toHaveBeenCalledWith(nextPageMatcher), + ); }); }); describe("Safety nets/redirects for invalid pages", () => { - const mockQueryKey = jest.fn(() => ["mock"]); - const mockQueryFn = jest.fn(({ pageNumber, limit }) => + const mockQueryKey = vi.fn(() => ["mock"]); + const mockQueryFn = vi.fn(({ pageNumber, limit }) => Promise.resolve({ data: new Array(limit).fill(pageNumber), count: 100, @@ -244,7 +233,7 @@ describe.skip(usePaginatedQuery.name, () => { page: "1000", }); - const onInvalidPageChange = jest.fn(); + const onInvalidPageChange = vi.fn(); await render({ onInvalidPageChange, queryKey: mockQueryKey, @@ -270,8 +259,8 @@ describe.skip(usePaginatedQuery.name, () => { }); describe("Passing in searchParams property", () => { - const mockQueryKey = jest.fn(() => ["mock"]); - const mockQueryFn = jest.fn(({ pageNumber, limit }) => + const mockQueryKey = vi.fn(() => ["mock"]); + const mockQueryFn = vi.fn(({ pageNumber, limit }) => Promise.resolve({ data: new Array(limit).fill(pageNumber), count: 100, @@ -309,25 +298,19 @@ describe.skip(usePaginatedQuery.name, () => { }); }); -describe.skip(`${usePaginatedQuery.name} - Returned properties`, () => { +describe(`${usePaginatedQuery.name} - Returned properties`, () => { describe("Page change methods", () => { - const mockQueryKey = jest.fn(() => ["mock"]); + const mockQueryKey = vi.fn(() => ["mock"]); - const mockQueryFn = jest.fn(({ pageNumber, limit }) => { - type Data = PaginatedData & { data: readonly number[] }; - - return new Promise((resolve) => { - setTimeout(() => { - resolve({ - data: new Array(limit).fill(pageNumber), - count: 100, - }); - }, 10_000); + const mockQueryFn = vi.fn(({ pageNumber, limit }) => { + return Promise.resolve({ + data: new Array(limit).fill(pageNumber), + count: 100, }); }); test("goToFirstPage always succeeds regardless of fetch status", async () => { - const queryFns = [mockQueryFn, jest.fn(() => Promise.reject("Too bad"))]; + const queryFns = [mockQueryFn, vi.fn(() => Promise.reject("Too bad"))]; for (const queryFn of queryFns) { const { result, unmount } = await render( @@ -351,14 +334,22 @@ describe.skip(`${usePaginatedQuery.name} - Returned properties`, () => { "/?page=1", ); - expect(result.current.hasNextPage).toBe(false); - result.current.goToNextPage(); - expect(result.current.currentPage).toBe(1); + // Wait for the query to complete and check we have next pages + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.hasNextPage).toBe(true); - await jest.runAllTimersAsync(); - await waitFor(() => expect(result.current.hasNextPage).toBe(true)); + // Can go to next page when hasNextPage is true result.current.goToNextPage(); await waitFor(() => expect(result.current.currentPage).toBe(2)); + + // Navigate to last page (page 4, since we have 100 items with 25 per page) + result.current.onPageChange(4); + await waitFor(() => expect(result.current.currentPage).toBe(4)); + + // Now hasNextPage should be false and goToNextPage should not change the page + expect(result.current.hasNextPage).toBe(false); + result.current.goToNextPage(); + expect(result.current.currentPage).toBe(4); }); test("goToPreviousPage works only if hasPreviousPage is true", async () => { @@ -370,14 +361,22 @@ describe.skip(`${usePaginatedQuery.name} - Returned properties`, () => { "/?page=3", ); - expect(result.current.hasPreviousPage).toBe(false); - result.current.goToPreviousPage(); - expect(result.current.currentPage).toBe(3); + // Wait for the query to complete and check we have previous pages + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.hasPreviousPage).toBe(true); - await jest.runAllTimersAsync(); - await waitFor(() => expect(result.current.hasPreviousPage).toBe(true)); + // Can go to previous page when hasPreviousPage is true result.current.goToPreviousPage(); await waitFor(() => expect(result.current.currentPage).toBe(2)); + + // Navigate to first page + result.current.goToFirstPage(); + await waitFor(() => expect(result.current.currentPage).toBe(1)); + + // Now hasPreviousPage should be false and goToPreviousPage should not change the page + expect(result.current.hasPreviousPage).toBe(false); + result.current.goToPreviousPage(); + expect(result.current.currentPage).toBe(1); }); test("onPageChange accounts for floats and truncates numeric values before navigating", async () => { @@ -386,7 +385,7 @@ describe.skip(`${usePaginatedQuery.name} - Returned properties`, () => { queryFn: mockQueryFn, }); - await jest.runAllTimersAsync(); + // Wait for the initial query to complete await waitFor(() => expect(result.current.isSuccess).toBe(true)); result.current.onPageChange(2.5); @@ -399,18 +398,16 @@ describe.skip(`${usePaginatedQuery.name} - Returned properties`, () => { queryFn: mockQueryFn, }); - await jest.runAllTimersAsync(); + // Wait for the initial query to complete await waitFor(() => expect(result.current.isSuccess).toBe(true)); result.current.onPageChange(Number.NaN); result.current.onPageChange(Number.POSITIVE_INFINITY); result.current.onPageChange(Number.NEGATIVE_INFINITY); - setTimeout(() => { - expect(result.current.currentPage).toBe(1); - }, 1000); - - jest.runAllTimers(); + // Give it a moment to ensure no navigation happens + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(result.current.currentPage).toBe(1); }); }); }); diff --git a/site/src/hooks/useSearchParamsKey.jest.ts b/site/src/hooks/useSearchParamsKey.test.ts similarity index 100% rename from site/src/hooks/useSearchParamsKey.jest.ts rename to site/src/hooks/useSearchParamsKey.test.ts