test(512): flush LibrariesScreen scan-poll chain deterministically instead of waitFor timeouts #558
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user