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

@@ -207,7 +207,7 @@ describe('hooks.server proxy', () => {
// 0.87.9 the upstream fetch kept running until the proxy wall-clock
// timeout fired.
it('chains an already-aborted client signal into the upstream signal immediately', async () => {
// Capture the signal the proxy hands fetch; resolve when called.
// Capture the signal the proxy hands fetch.
let upstream: AbortSignal | undefined;
fetchSpy.mockImplementation(async (_url, init) => {
upstream = (init as RequestInit).signal ?? undefined;
@@ -225,6 +225,26 @@ describe('hooks.server proxy', () => {
expect(upstream?.aborted).toBe(true);
});
it('returns 502 when fetch itself rejects with AbortError on an already-aborted signal', async () => {
// Production Node fetch rejects synchronously with AbortError if
// the signal is already aborted on entry — the prior test mocks
// fetch to resolve 200, which leaves the rejection-path
// observation uncovered. Mirror Node's real behaviour and
// confirm the handler maps the rejection to 502 / upstream_unavailable
// (same envelope as any other network failure).
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
fetchSpy.mockRejectedValueOnce(new DOMException('aborted', 'AbortError'));
const resolve = vi.fn();
const ctrl = new AbortController();
ctrl.abort();
const event = makeEventWithSignal('/api/v1/health', ctrl.signal);
const resp = await handle({ event, resolve });
expect(resp.status).toBe(502);
const body = await resp.json();
expect(body.error.code).toBe('upstream_unavailable');
expect(errSpy).toHaveBeenCalled();
});
it('chains a mid-flight client-disconnect into the upstream signal', async () => {
// The mid-flight shape: request.signal is live when the handler
// starts, then aborts while the upstream fetch is in progress.
@@ -232,13 +252,13 @@ describe('hooks.server proxy', () => {
let upstream: AbortSignal | undefined;
fetchSpy.mockImplementation(async (_url, init) => {
upstream = (init as RequestInit).signal ?? undefined;
// Let the handler's chaining listener attach, then trip the
// client signal — the upstream signal should reflect.
// Let the handler's chaining listener attach (it runs on
// the next microtask after `handle()` calls fetch), THEN
// trip the client signal. `AbortSignal#dispatchEvent` is
// synchronous, so the listener runs in-line — no further
// tick needed; we observe `upstream.aborted` immediately.
await Promise.resolve();
clientCtrl.abort();
// Tick once so the listener observes the abort before we
// check.
await Promise.resolve();
return new Response('[]', { status: 200 });
});
const resolve = vi.fn();