From dd7812063c94c411d07f88a40978300659a1de31 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 1 Jul 2026 21:22:04 -0400 Subject: [PATCH] test(web): make the debounce-cancellation test load-bearing (review round 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "cancels a pending debounce timer when the input is re-focused" test passed trivially after the delay:null change: synchronous typing meant the assertion ran at t≈0, before the 300ms debounce could ever fire, so it passed whether or not handleFocus's clearTimeout worked. Rewrote it with fake timers so the debounce window elapses deterministically (advance the clock by 400ms directly). The interaction is driven with fireEvent rather than userEvent because userEvent's async internals deadlock under vitest fake timers with the Mantine/happy-dom stack. Verified load-bearing: removing the clearTimeout in handleFocus makes it fail (expected 1 to be 0), and it passes once restored. Also tightened the sibling-context comment (with delay:null the sibling is reliably "es"); the tolerant /^es?$/ regex is left as-is. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX --- .../PromptArgumentsForm.test.tsx | 101 +++++++++++------- 1 file changed, 63 insertions(+), 38 deletions(-) diff --git a/clients/web/src/components/groups/PromptArgumentsForm/PromptArgumentsForm.test.tsx b/clients/web/src/components/groups/PromptArgumentsForm/PromptArgumentsForm.test.tsx index 88871639..f00afc6c 100644 --- a/clients/web/src/components/groups/PromptArgumentsForm/PromptArgumentsForm.test.tsx +++ b/clients/web/src/components/groups/PromptArgumentsForm/PromptArgumentsForm.test.tsx @@ -6,6 +6,8 @@ import { renderWithMantine, screen, waitFor, + fireEvent, + act, } from "../../../test/renderWithMantine"; import { PromptArgumentsForm } from "./PromptArgumentsForm"; @@ -363,8 +365,9 @@ describe("PromptArgumentsForm", () => { ); // Poll until the debounced "text" call fires, then assert it captured a - // non-empty sibling (read at fire time). The exact string may be "e" or - // "es" depending on where the fire lands relative to the keystrokes. + // non-empty sibling (read at fire time, not the empty value present when + // the call was scheduled). With delay:null the sibling is fully typed + // ("es") before the debounce fires; the /^es?$/ regex stays tolerant. await waitFor(() => { const textCalls = onCompleteArgument.mock.calls.filter( ([n]) => n === "text", @@ -504,45 +507,67 @@ describe("PromptArgumentsForm", () => { }); it("cancels a pending debounce timer when the input is re-focused", async () => { - const user = userEvent.setup({ delay: null }); - const onCompleteArgument = vi - .fn< - ( - argName: string, - value: string, - context: Record, - ) => Promise - >() - .mockResolvedValue([]); + // This is the one completion test that must let the 300ms debounce + // window fully elapse to be meaningful — asserting at t≈0 (as the + // sibling negative tests do) would pass whether or not handleFocus's + // clearTimeout actually cancels the pending timer. Fake timers make that + // window deterministic: we advance the clock directly. The interaction + // is driven with fireEvent rather than userEvent, because userEvent's + // async internals deadlock under vitest fake timers with this + // Mantine/happy-dom stack. This test is load-bearing: if the + // clearTimeout in handleFocus were removed, the debounce would fire + // during the advance and the final assertion would fail (verified). + vi.useFakeTimers(); + try { + const onCompleteArgument = vi + .fn< + ( + argName: string, + value: string, + context: Record, + ) => Promise + >() + .mockResolvedValue([]); - renderWithMantine( - , - ); + renderWithMantine( + , + ); - const textInput = screen.getByRole("textbox", { name: /^text/ }); - const siblingInput = screen.getByRole("textbox", { - name: /targetLanguage/, - }); + const textInput = screen.getByRole("textbox", { name: /^text/ }); + const siblingInput = screen.getByRole("textbox", { + name: /targetLanguage/, + }); - // Type into text → schedules a debounce timer. Immediately move focus - // away and back (synchronous under delay: null, so well within the 300ms - // window), so handleFocus sees the pending timer for "text" and clears - // it. Clearing is synchronous, so the keystroke completion can never - // fire afterward regardless of machine speed. - await user.type(textInput, "h"); - await user.click(siblingInput); - await user.click(textInput); - onCompleteArgument.mockClear(); - const textCalls = onCompleteArgument.mock.calls.filter( - ([n]) => n === "text", - ); - // The cancelled debounce produced no post-clear "text" completion. - expect(textCalls.length).toBe(0); + // Type into text → schedules the 300ms debounce timer for "text". + fireEvent.focus(textInput); + fireEvent.change(textInput, { target: { value: "h" } }); + // Move focus to the sibling and back — the re-focus makes + // handleFocus("text") see the pending debounce timer and clear it. + fireEvent.focus(siblingInput); + fireEvent.focus(textInput); + + // Drop the focus-fire calls made above; only a *debounce* fire after + // this point would indicate the cancellation failed. + onCompleteArgument.mockClear(); + + // Elapse well past the debounce window. The cancelled timer must not + // fire a stale keystroke completion. + await act(async () => { + await vi.advanceTimersByTimeAsync(400); + }); + + const textCalls = onCompleteArgument.mock.calls.filter( + ([n]) => n === "text", + ); + expect(textCalls.length).toBe(0); + } finally { + vi.useRealTimers(); + } }); it("does not call onCompleteArgument when completions are unsupported", async () => {