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
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:
@@ -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