From dc2dd2bfcfb52b599091ef7e02b5ee8a4571233d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 5 Sep 2026 00:57:03 -0400 Subject: [PATCH] fix(web): remove the wall-clock race in the import-JSON debounce guard test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ServerImportJsonModal` > "guards against a live edit made before the debounce re-validates" opened its window on the real clock: it pasted invalid JSON and clicked Add Server, relying on less than VALIDATE_DEBOUNCE_MS (300ms) of wall time elapsing in between. When more did, the debounce landed first, `canAdd` went false, and the click hit a disabled button — a no-op that sets no submit error, so the `findByText(/Fix the validation errors/)` timed out. The first assertion (`onAddServer` not called) still passed, which is why the failure read as mysterious rather than as a disabled button. Run the test on fake timers end to end instead. The first validation is landed explicitly with `advanceTimersByTimeAsync(VALIDATE_DEBOUNCE_MS)` — imported from the hook, so the test cannot drift from the value it depends on — and after the second paste the timers are simply not advanced. The pending re-validation therefore cannot land at all, the window stays open by construction, and the final assertion becomes a synchronous `getByText`. Verified with a throwaway probe holding both shapes side by side with a deterministic 400ms stall injected between the paste and the click: the old shape fails exactly as reported, the new one passes. No timeout was widened and no production code changed. Closes #2250 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VQgwZ1kGzhkdkMJ81JVg42 Signed-off-by: cliffhall --- .../ServerImportJsonModal.test.tsx | 57 ++++++++++++------- 1 file changed, 36 insertions(+), 21 deletions(-) diff --git a/clients/web/src/components/groups/ServerImportJsonModal/ServerImportJsonModal.test.tsx b/clients/web/src/components/groups/ServerImportJsonModal/ServerImportJsonModal.test.tsx index 36b389b08..bc7396880 100644 --- a/clients/web/src/components/groups/ServerImportJsonModal/ServerImportJsonModal.test.tsx +++ b/clients/web/src/components/groups/ServerImportJsonModal/ServerImportJsonModal.test.tsx @@ -1,12 +1,14 @@ import { describe, it, expect, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import { + act, renderWithMantine, screen, fireEvent, waitFor, } from "../../../test/renderWithMantine"; import { setAceText } from "../../../test/aceEditor"; +import { VALIDATE_DEBOUNCE_MS } from "../../../hooks/useServerJsonImport"; import { ServerImportJsonModal } from "./ServerImportJsonModal"; const npmJson = JSON.stringify({ @@ -221,28 +223,41 @@ describe("ServerImportJsonModal", () => { expect(screen.getByRole("button", { name: "Add Server" })).toBeDisabled(); }); + // The window this exercises is the one between an edit and the debounce that + // re-disables the button, so the whole test runs on fake timers: the pending + // re-validation then cannot land unless this test advances it, and the window + // stops depending on how long the machine takes to get from the paste to the + // click. On real timers a loaded box could spend more than + // VALIDATE_DEBOUNCE_MS there, re-disable the button, and turn the click into a + // no-op that sets no submit error at all (#2250). it("guards against a live edit made before the debounce re-validates", async () => { - const onAddServer = vi.fn(); - renderWithMantine( - , - ); - await pasteJson(npmJson); - await waitFor(() => - expect(screen.getByRole("button", { name: "Add Server" })).toBeEnabled(), - ); - // Replace with invalid content; the button hasn't re-disabled yet (the - // debounce is still pending), so clicking exercises the submit-time guard. - await pasteJson("{not json"); - fireEvent.click(screen.getByRole("button", { name: "Add Server" })); - expect(onAddServer).not.toHaveBeenCalled(); - expect( - await screen.findByText(/Fix the validation errors/), - ).toBeInTheDocument(); + vi.useFakeTimers(); + try { + const onAddServer = vi.fn(); + renderWithMantine( + , + ); + await pasteJson(npmJson); + // Let the first validation land, so the button is enabled to click. + await act(async () => { + await vi.advanceTimersByTimeAsync(VALIDATE_DEBOUNCE_MS); + }); + expect(screen.getByRole("button", { name: "Add Server" })).toBeEnabled(); + // Replace with invalid content and do *not* advance: the debounce stays + // pending, the button stays enabled, and clicking exercises the + // submit-time guard that re-parses the live text. + await pasteJson("{not json"); + fireEvent.click(screen.getByRole("button", { name: "Add Server" })); + expect(onAddServer).not.toHaveBeenCalled(); + expect(screen.getByText(/Fix the validation errors/)).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } }); it("loads server.json from a chosen file", async () => {