test(512): flush LibrariesScreen scan-poll chain deterministically instead of waitFor timeouts
PR Gates / Docs update reminder (pull_request) Successful in 18s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 18s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 8s
PR Gates / decisions lifecycle (pull_request) Successful in 19s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m57s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 20m49s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m9s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

The "stops scan polling and refreshes sources once when scans complete" test
fired the poll tick via runPollTick but discarded the promise loadScanStatuses
returns, so the progress-clear state update and the fire-and-forget
loadSources() refetch it triggers landed on real microtasks AFTER act() had
resolved. The test out-waited that race with two waitFor({ timeout: 5000 })
calls, which were marginal on a starved CI VM (timed out on PR #509/run 918);
prior timeout bumps (#447) were diminishing whack-a-mole.

Drive the chain to completion deterministically instead of out-waiting it:
runPollTick now awaits the promise the handler returns (settling the
progress-clear) and then yields to a single macrotask (setTimeout 0) to drain
the microtask queue — including the fire-and-forget loadSources() refetch —
all INSIDE act(async () => ...). The mock fetches resolve synchronously on
microtasks and setInterval is the only mocked timer, so one macrotask turn
completes both chains with no wall-clock delay. The two waitFor calls are
replaced by synchronous assertions (progress label gone; exactly one extra
/api/v1/media-sources fetch), and the 20s test-level budget is dropped.

Verified: LibrariesScreen suite green 15/15 consecutive runs; npm run lint and
npm run build clean. No production code changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Fixes #512
This commit is contained in:
2026-07-22 17:02:14 +02:00
co-authored by Claude Opus 4.8
parent 732322dc84
commit b5596f55f3
+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({