test(512): flush LibrariesScreen scan-poll chain deterministically instead of waitFor timeouts #558

Merged
timothy merged 1 commits from issue-512-deterministic-libraries-test into main 2026-07-22 18:02:25 +02:00
+28 -29
View File
@@ -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<void> {
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<void> {
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({