test(frontend): extract CancellableLoader; pin debounce-race invariants in unit tests (0.87.23)

0.87.16 followup. Extract the four debounce-race invariants
(predecessor abort, late-result suppression, AbortError swallow,
ownership-aware finally) from `loadCoverage` into a reusable
`CancellableLoader` helper at $lib/util/cancellable, with 9 unit
tests covering each. Page rewrite uses the helper.

Also corrects: hooks.server.test.ts (added Node-fetch rejection
shape for already-aborted entry; removed redundant tick); admin.test.ts
(AbortError-propagation now uses pre-aborted signal + mockRejectedValueOnce
mirroring real Node fetch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-23 21:03:12 +02:00
parent 2ee77bc867
commit 4e14ed09ef
8 changed files with 342 additions and 58 deletions

View File

@@ -731,28 +731,19 @@ describe('admin crawler api client', () => {
});
it('getAnalysisMangaCoverage propagates an abort rejection', async () => {
// Mid-flight abort: caller flips the AbortController, the fetch
// promise rejects, and the api client lets the rejection
// surface to its caller (the debounce-race handler treats
// AbortError as silent cancellation rather than an error toast).
// Production Node fetch rejects synchronously with AbortError
// when called with an already-aborted signal. Mirror that
// (instead of wiring our own listener and asserting the
// rejection we self-fire) so the test exercises the contract
// the consumer relies on — the CancellableLoader at
// $lib/util/cancellable.ts treats AbortError as `null` and
// skips the state update.
const ctrl = new AbortController();
fetchSpy.mockImplementationOnce(
(_url, init) =>
new Promise((_resolve, reject) => {
const signal = (init as RequestInit).signal;
signal?.addEventListener(
'abort',
() => reject(new DOMException('aborted', 'AbortError')),
{ once: true }
);
})
);
const inflight = getAnalysisMangaCoverage(
{ limit: 25 },
{ signal: ctrl.signal }
);
ctrl.abort();
await expect(inflight).rejects.toMatchObject({ name: 'AbortError' });
fetchSpy.mockRejectedValueOnce(new DOMException('aborted', 'AbortError'));
await expect(
getAnalysisMangaCoverage({ limit: 25 }, { signal: ctrl.signal })
).rejects.toMatchObject({ name: 'AbortError' });
});
it('getAnalysisChapterCoverage unwraps items', async () => {