diff --git a/web/src/screens/LibrariesScreen.test.tsx b/web/src/screens/LibrariesScreen.test.tsx index 8218d50f5..ec16ccdfa 100644 --- a/web/src/screens/LibrariesScreen.test.tsx +++ b/web/src/screens/LibrariesScreen.test.tsx @@ -98,9 +98,28 @@ function fetchCount(handle: MockHandle, path: string): number { return handle.requests.filter((r) => r.url === path).length; } +// Drain the entire microtask queue — including chains scheduled *while* it drains — by yielding to a +// single macrotask. A setTimeout(0) callback runs only after every currently-pending microtask (and +// anything they transitively schedule) has settled. The mock fetches all resolve synchronously on +// microtasks (no real network, and setInterval is the only mocked timer — setTimeout stays real), so +// one macrotask turn deterministically completes both the scan-status poll chain and the +// fire-and-forget loadSources() refetch it triggers. No wall-clock delay is being waited out here. +function flushMicrotasks(): Promise { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +// Fire the poll interval's registered handler(s). The handler is loadScanStatuses (libraries.ts), +// which RETURNS the scan-status poll promise; awaiting it settles the progress-clear state update. +// On completion that handler ALSO kicks off a fire-and-forget loadSources() refetch (not part of the +// returned promise), so a microtask flush after the await drains that too. Doing both INSIDE +// act(async () => ...) means every resulting React state update is flushed before act resolves, so +// callers can assert synchronously right after `await runPollTick(...)` with no waitFor race (#512). async function runPollTick(intervalHandlers: Array<() => void>): Promise { await act(async () => { - intervalHandlers.forEach((handler) => handler()); + await Promise.all(intervalHandlers.map((handler) => handler())); + await flushMicrotasks(); }); } @@ -277,34 +296,14 @@ describe('LibrariesScreen', () => { const sourceFetchesBeforeCompletion = fetchCount(handle, '/api/v1/media-sources'); await runPollTick(intervalHandlers); - // loadScanStatuses (the interval handler) kicks off its own Promise.all(...).then(...) chain - // (libraries.ts) rather than being itself async/awaitable, so `handler()` returns void - the - // state update that clears the progress bar and the follow-up loadSources() refetch both land a - // few microtask ticks after runPollTick's `act(async () => ...)` callback has already resolved. - // Asserting synchronously right after the tick races that chain (#447); waitFor lets both - // settle before asserting, without weakening either check. - // waitFor's 1000ms default is ample locally (this file runs in ~1s) but marginal on the CI VM, - // where the SPA suite runs ~8x slower under load — observed failing there on a types-only diff - // that cannot affect this screen. The per-waitFor timeout must stay under the test-level budget - // below, or the test dies on vitest's timeout with no assertion diff instead of waitFor's. - await waitFor( - () => { - expect(screen.queryByText('75%')).not.toBeInTheDocument(); - }, - { timeout: 5000 } - ); - await waitFor( - () => { - expect(fetchCount(handle, '/api/v1/media-sources')).toBe(sourceFetchesBeforeCompletion + 1); - }, - { timeout: 5000 } - ); - // Generous test timeout: vitest defaults to 5s for the whole test, which the two waits above - // would otherwise share with render + findByText + runPollTick. Budgeted so the waits cannot - // exhaust it: 2 x 5s of waits plus a CI-slowed prelude still leaves headroom at 20s, so a - // failure is reported by waitFor (with an assertion diff) rather than by vitest's generic - // timeout. Same reason as TrashScreen.test.tsx (run 686) and CollectionsScreen.test.tsx. - }, 20000); + // runPollTick awaits the loadScanStatuses poll promise AND drains the microtask queue (its + // fire-and-forget loadSources() refetch), all inside act() — so both the progress-clear state + // update and the single source refetch have landed by the time it resolves. That makes these + // observations deterministic and lets us assert synchronously, replacing the two timeout-based + // waitFor calls that were marginal on a starved CI VM (#512, superseding the #447 timeout bumps). + expect(screen.queryByText('75%')).not.toBeInTheDocument(); + expect(fetchCount(handle, '/api/v1/media-sources')).toBe(sourceFetchesBeforeCompletion + 1); + }); it('reconciles a 409 "already scanning" against scan-status without an error toast (#232)', async () => { mockApi({