clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

fix(testing): bound convergence scenarios

Dami committed d215191
commit
d215191b960f75c9f9414afff5834d00a218ab2e
parent
f5cfbd38f71887d00f5c604e5ac38b2ff72f633e
author
Dami <dami.etoile@gmail.com>
2 files changed, +80 -18
Mpackage/testing/convergence.ts75+++++++++++++++++++++++-------
Mpackage/testing/convergence_test.ts23+++++++++
M package/testing/convergence.ts +57 -18
@@ -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 );
M package/testing/convergence_test.ts +23 -0