fix(web): remove the wall-clock race in the import-JSON debounce guard test

`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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQgwZ1kGzhkdkMJ81JVg42
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-05 00:57:03 -04:00
commit dc2dd2bfcf

View file

@ -1,12 +1,14 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { import {
act,
renderWithMantine, renderWithMantine,
screen, screen,
fireEvent, fireEvent,
waitFor, waitFor,
} from "../../../test/renderWithMantine"; } from "../../../test/renderWithMantine";
import { setAceText } from "../../../test/aceEditor"; import { setAceText } from "../../../test/aceEditor";
import { VALIDATE_DEBOUNCE_MS } from "../../../hooks/useServerJsonImport";
import { ServerImportJsonModal } from "./ServerImportJsonModal"; import { ServerImportJsonModal } from "./ServerImportJsonModal";
const npmJson = JSON.stringify({ const npmJson = JSON.stringify({
@ -221,7 +223,16 @@ describe("ServerImportJsonModal", () => {
expect(screen.getByRole("button", { name: "Add Server" })).toBeDisabled(); 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 () => { it("guards against a live edit made before the debounce re-validates", async () => {
vi.useFakeTimers();
try {
const onAddServer = vi.fn(); const onAddServer = vi.fn();
renderWithMantine( renderWithMantine(
<ServerImportJsonModal <ServerImportJsonModal
@ -232,17 +243,21 @@ describe("ServerImportJsonModal", () => {
/>, />,
); );
await pasteJson(npmJson); await pasteJson(npmJson);
await waitFor(() => // Let the first validation land, so the button is enabled to click.
expect(screen.getByRole("button", { name: "Add Server" })).toBeEnabled(), await act(async () => {
); await vi.advanceTimersByTimeAsync(VALIDATE_DEBOUNCE_MS);
// Replace with invalid content; the button hasn't re-disabled yet (the });
// debounce is still pending), so clicking exercises the submit-time guard. 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"); await pasteJson("{not json");
fireEvent.click(screen.getByRole("button", { name: "Add Server" })); fireEvent.click(screen.getByRole("button", { name: "Add Server" }));
expect(onAddServer).not.toHaveBeenCalled(); expect(onAddServer).not.toHaveBeenCalled();
expect( expect(screen.getByText(/Fix the validation errors/)).toBeInTheDocument();
await screen.findByText(/Fix the validation errors/), } finally {
).toBeInTheDocument(); vi.useRealTimers();
}
}); });
it("loads server.json from a chosen file", async () => { it("loads server.json from a chosen file", async () => {