From 2e0eb40aec398324f93e538bd6919ea693be3e56 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 17:40:10 +0200 Subject: [PATCH] test(debounce): fix flaky onExit-cancels-debounce test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test raced a real 150 ms setTimeout: fill('Walter') started the debounce, then focus + keyboard(Escape) had to complete before 150 ms elapsed. Under CI load the Playwright CDP round-trips exceeded 150 ms, letting the debounce fire first. Fix: install vi.useFakeTimers() after the stable-state setup (so vi.waitFor()'s real-timer polling still works), freeze the Walter debounce, let Escape trigger onExit/cancel, then advance fake time with vi.advanceTimersByTimeAsync() — no real-wall-clock race. Co-Authored-By: Claude Sonnet 4.6 --- .../PersonMentionEditor.svelte.spec.ts | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts index 3b58a62f..a0486e6d 100644 --- a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts +++ b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts @@ -409,19 +409,24 @@ describe('PersonMentionEditor — onExit cancels pending debounce', () => { await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); const fetchesBeforeEscape = fetchMock.mock.calls.length; - // Trigger a new debounced search (queues runSearch after 150 ms), then - // immediately Escape *while focus is back in the editor* so Tiptap's - // suggestion-plugin Escape handler fires onExit before the debounce. - // Without onExit cancelling the pending debounce, runSearch executes - // against the now-unmounted dropdown's state. - await page.getByRole('searchbox').fill('Walter'); - // Focus the editor so the Escape lands on Tiptap's suggestion handler. - (page.getByRole('textbox').element() as HTMLElement).focus(); - await userEvent.keyboard('{Escape}'); - - // Wait past the debounce window. If onExit did not cancel the pending - // debounce, a fetch with q=Walter would still fire here. - await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); + // Freeze setTimeout so the 150 ms debounce cannot fire before Escape + // triggers onExit. We install fake timers only now — after the setup + // above — so that vi.waitFor()'s real-timer polling still worked. + vi.useFakeTimers(); + try { + // fill() dispatches the input event synchronously via CDP; by the + // time the await resolves, onSearch('Walter') has run and the fake + // debounce timer is set. + await page.getByRole('searchbox').fill('Walter'); + // Focus the editor so the Escape lands on Tiptap's suggestion handler. + (page.getByRole('textbox').element() as HTMLElement).focus(); + await userEvent.keyboard('{Escape}'); + // onExit has now called debouncedSearch.cancel(). Advance past the + // debounce window — the cancelled timer must not fire. + await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS); + } finally { + vi.useRealTimers(); + } const newFetches = fetchMock.mock.calls.slice(fetchesBeforeEscape); const walterFetches = newFetches.filter(