- commit
- d215191b960f75c9f9414afff5834d00a218ab2e
- parent
- f5cfbd38f71887d00f5c604e5ac38b2ff72f633e
- author
- Dami <dami.etoile@gmail.com>
| M | package/ | 75 | +++++++++++++++++++++++------- |
| M | package/ | 23 | +++++++++ |
@@ -24,14 +24,22 @@ export interface ConcurrentOfflineScenario<
2424 Client extends OfflineTestClient = BrowserTestClient,2525 > {2626 readonly edits: readonly [Edit, Edit];27+ /** Overall defensive deadline. Defaults to 60 seconds. */28+ readonly timeoutMs?: number;2729 /** App-owned, deterministic readiness assertion (locator/waitForFunction/etc.). */28- readonly ready: (client: Client) => Promise<void>;29- readonly apply: (client: Client, edit: Edit) => Promise<void>;30- readonly locallyApplied: (client: Client, edit: Edit) => Promise<void>;30+ readonly ready: (client: Client, signal: AbortSignal) => Promise<void>;31+ readonly apply: (client: Client, edit: Edit, signal: AbortSignal) => Promise<void>;32+ readonly locallyApplied: (client: Client, edit: Edit, signal: AbortSignal) => Promise<void>;3133 /** Use this hook for page/client restart while offline work is pending. */32- readonly whilePending?: (fixture: OfflineTestFixture<Client>) => Promise<void>;34+ readonly whilePending?: (35+ fixture: OfflineTestFixture<Client>,36+ signal: AbortSignal,37+ ) => Promise<void>;3338 /** Must resolve only when both app views have converged, or reject on timeout. */34- readonly converged: (fixture: OfflineTestFixture<Client>) => Promise<void>;39+ readonly converged: (40+ fixture: OfflineTestFixture<Client>,41+ signal: AbortSignal,42+ ) => Promise<void>;3543 readonly snapshot?: (client: Client) => Promise<ValueFreeState>;3644 readonly failureLabel?: string;3745 }@@ -52,27 +60,57 @@ export async function runConcurrentOfflineConvergence<Edit, Client extends Offli
5260 fixture: OfflineTestFixture<Client>,5361 scenario: ConcurrentOfflineScenario<Edit, Client>,5462 ): Promise<void> {63+ const timeoutMs = scenario.timeoutMs ?? 60_000;64+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {65+ throw new TypeError("convergence timeoutMs must be a positive finite number");66+ }67+ const controller = new AbortController();68+ const timeout = setTimeout(69+ () => controller.abort(new Error(`convergence exceeded its ${timeoutMs}ms deadline`)),70+ timeoutMs,71+ );72+ const withinDeadline = async <Result>(operation: Promise<Result>): Promise<Result> => {73+ if (controller.signal.aborted) throw controller.signal.reason;74+ let onAbort!: () => void;75+ const aborted = new Promise<never>((_resolve, reject) => {76+ onAbort = () => reject(controller.signal.reason);77+ controller.signal.addEventListener("abort", onAbort, { once: true });78+ });79+ try {80+ return await Promise.race([operation, aborted]);81+ } finally {82+ controller.signal.removeEventListener("abort", onAbort);83+ }84+ };5585 let stage = "readiness";5686 try {57- await Promise.all(fixture.clients.map((client) => scenario.ready(client)));87+ await withinDeadline(88+ Promise.all(fixture.clients.map((client) => scenario.ready(client, controller.signal))),89+ );5890 stage = "going offline";59- await fixture.goOffline();91+ await withinDeadline(fixture.goOffline());6092 stage = "concurrent edits";61- await Promise.all([62- scenario.apply(fixture.first, scenario.edits[0]),63- scenario.apply(fixture.second, scenario.edits[1]),64- ]);93+ await withinDeadline(94+ Promise.all([95+ scenario.apply(fixture.first, scenario.edits[0], controller.signal),96+ scenario.apply(fixture.second, scenario.edits[1], controller.signal),97+ ]),98+ );6599 stage = "local persistence";66- await Promise.all([67- scenario.locallyApplied(fixture.first, scenario.edits[0]),68- scenario.locallyApplied(fixture.second, scenario.edits[1]),69- ]);100+ await withinDeadline(101+ Promise.all([102+ scenario.locallyApplied(fixture.first, scenario.edits[0], controller.signal),103+ scenario.locallyApplied(fixture.second, scenario.edits[1], controller.signal),104+ ]),105+ );70106 stage = "pending-work hook";71- await scenario.whilePending?.(fixture);107+ if (scenario.whilePending) {108+ await withinDeadline(scenario.whilePending(fixture, controller.signal));109+ }72110 stage = "reconnection";73- await fixture.goOnline();111+ await withinDeadline(fixture.goOnline());74112 stage = "convergence";75- await scenario.converged(fixture);113+ await withinDeadline(scenario.converged(fixture, controller.signal));76114 } catch (error) {77115 await fixture.captureFailure(78116 scenario.failureLabel ?? `offline-convergence-${stage}`,@@ -80,6 +118,7 @@ export async function runConcurrentOfflineConvergence<Edit, Client extends Offli
80118 ).catch(() => undefined);81119 throw new ConvergenceScenarioError(stage, { cause: error });82120 } finally {121+ clearTimeout(timeout);83122 await Promise.allSettled(84123 fixture.clients.filter((client) => client.offline).map((client) => client.goOnline()),85124 );@@ -108,3 +108,26 @@ Deno.test("offline convergence captures the failing stage and restores connectiv
108108 `failure label lost the stage: ${fixture.captures[0].label}`,109109 );110110 });111+112+Deno.test("offline convergence bounds a never-settling app predicate", async () => {113+ const fixture = new FakeFixture();114+ try {115+ await runConcurrentOfflineConvergence(fixture, {116+ edits: [1, 2],117+ timeoutMs: 10,118+ ready: () => Promise.resolve(),119+ apply: () => Promise.resolve(),120+ locallyApplied: () => Promise.resolve(),121+ converged: () => new Promise(() => undefined),122+ });123+ throw new Error("expected convergence deadline failure");124+ } catch (error) {125+ assert(error instanceof ConvergenceScenarioError, `unexpected error: ${error}`);126+ assert(error.stage === "convergence", `unexpected stage: ${error.stage}`);127+ assert(128+ error.cause instanceof Error && error.cause.message.includes("10ms deadline"),129+ `deadline cause was not retained: ${error.cause}`,130+ );131+ }132+ assert(fixture.clients.every((client) => !client.offline), "deadline left clients offline");133+});