test: remove timing races from screen-sync and route transition tests (#138) #166

Merged
codex-bot merged 1 commits from fix/138-flaky-unit-tests into develop 2026-11-07 18:44:44 +03:00
2 changed files with 29 additions and 6 deletions
@@ -10,6 +10,9 @@ import { server } from "@/test/server";
import { useScreenSync } from "../index"; import { useScreenSync } from "../index";
const TRANSPORT_ATTEMPTS = 3;
const TRANSPORT_RECOVERY_MS = 3000;
function auth() { function auth() {
configureAuthClient({ configureAuthClient({
accessToken: () => "synthetic", accessToken: () => "synthetic",
@@ -28,11 +31,13 @@ describe("read-only screen synchronization", () => {
gets += 1; gets += 1;
syncModes.push(new URL(request.url).searchParams.get("sync")); syncModes.push(new URL(request.url).searchParams.get("sync"));
return gets === 1 // The transport retries a transient read twice (1 s, 2 s) on its own.
return gets <= TRANSPORT_ATTEMPTS
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource([])); : HttpResponse.json(resource([]));
}), }),
); );
vi.useFakeTimers();
const { result } = renderHook( const { result } = renderHook(
() => { () => {
@@ -46,12 +51,26 @@ describe("read-only screen synchronization", () => {
{ wrapper: createQueryWrapper().wrapper }, { wrapper: createQueryWrapper().wrapper },
); );
await waitFor(() => expect(gets).toBe(1)); try {
await act(async () => result.current.sync.retry()); // Exhaust the transport back-off so the query settles in error.
await waitFor(() => expect(result.current.accounts.data).toEqual([])); await vi.waitFor(async () => {
await act(() => vi.advanceTimersByTimeAsync(TRANSPORT_RECOVERY_MS));
expect(result.current.sync.transportError).toBe(true);
});
expect(gets).toBe(TRANSPORT_ATTEMPTS);
expect(gets).toBe(2); await act(async () => result.current.sync.retry());
expect(syncModes).toEqual([null, null]); await vi.waitFor(async () => {
await act(() => vi.advanceTimersByTimeAsync(0));
expect(result.current.accounts.data).toEqual([]);
});
}
finally {
vi.useRealTimers();
}
expect(gets).toBe(TRANSPORT_ATTEMPTS + 1);
expect(syncModes).toEqual(Array.from({ length: gets }, () => null));
}); });
it("refreshes the currently open resources together", async () => { it("refreshes the currently open resources together", async () => {
+4
View File
@@ -1,5 +1,6 @@
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { cleanup, configure } from "@testing-library/react"; import { cleanup, configure } from "@testing-library/react";
import { MotionGlobalConfig } from "motion/react";
import { afterAll, afterEach, beforeAll, vi } from "vitest"; import { afterAll, afterEach, beforeAll, vi } from "vitest";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
@@ -10,6 +11,9 @@ import { server } from "./server";
// Allow async UI updates on the slower CI runner; local waits stay unchanged. // Allow async UI updates on the slower CI runner; local waits stay unchanged.
if (process.env.CI) configure({ asyncUtilTimeout: 5000 }); if (process.env.CI) configure({ asyncUtilTimeout: 5000 });
// Route fades start at opacity 0; under load toBeVisible can catch that frame.
MotionGlobalConfig.skipAnimations = true;
beforeAll(() => server.listen({ onUnhandledRequest: "error" })); beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => { afterEach(() => {
cleanup(); cleanup();