1import type { BrowserTestClient } from "./fixture.ts";2import type { ValueFreeState } from "./safety.ts";34/** Minimal client contract the convergence runner needs: offline state and toggles. */5export interface OfflineTestClient {6 /** Whether the client's network is currently forced offline. */7 readonly offline: boolean;8 /** Force this client's network offline. */9 goOffline(): Promise<void>;10 /** Restore this client's network. */11 goOnline(): Promise<void>;12}1314/** Minimal two-client fixture contract required to drive an offline scenario. */15export interface OfflineTestFixture<Client extends OfflineTestClient> {16 /** Both clients, ordered `[first, second]`. */17 readonly clients: readonly [Client, Client];18 /** The first client. */19 readonly first: Client;20 /** The second client. */21 readonly second: Client;22 /** Take both clients offline. */23 goOffline(): Promise<void>;24 /** Bring both clients back online. */25 goOnline(): Promise<void>;26 /**27 * Capture a redacted failure artifact, optionally including a value-free28 * per-client snapshot. Returns an opaque handle describing what was written.29 */30 captureFailure(31 label: string,32 snapshot?: (client: Client) => Promise<ValueFreeState>,33 ): Promise<unknown>;34}3536/**37 * App-owned definition of a concurrent-offline convergence test: the two edits38 * plus the hooks that assert readiness, apply and locally verify each edit, and39 * confirm convergence after reconnection.40 */41export interface ConcurrentOfflineScenario<42 Edit,43 Client extends OfflineTestClient = BrowserTestClient,44> {45 /** The two concurrent edits, one applied on each client while offline. */46 readonly edits: readonly [Edit, Edit];47 /** Overall defensive deadline. Defaults to 60 seconds. */48 readonly timeoutMs?: number;49 /** App-owned, deterministic readiness assertion (locator/waitForFunction/etc.). */50 readonly ready: (client: Client, signal: AbortSignal) => Promise<void>;51 /** Apply one client's edit while it is offline. */52 readonly apply: (client: Client, edit: Edit, signal: AbortSignal) => Promise<void>;53 /** Assert the edit is visible locally on its own client before reconnection. */54 readonly locallyApplied: (client: Client, edit: Edit, signal: AbortSignal) => Promise<void>;55 /** Use this hook for page/client restart while offline work is pending. */56 readonly whilePending?: (57 fixture: OfflineTestFixture<Client>,58 signal: AbortSignal,59 ) => Promise<void>;60 /** Must resolve only when both app views have converged, or reject on timeout. */61 readonly converged: (62 fixture: OfflineTestFixture<Client>,63 signal: AbortSignal,64 ) => Promise<void>;65 /** Optional value-free snapshot taken per client when a failure is captured. */66 readonly snapshot?: (client: Client) => Promise<ValueFreeState>;67 /** Label for any captured failure artifact. Defaults to a generic name. */68 readonly failureLabel?: string;69}7071/** Thrown when a convergence scenario fails, naming the stage that failed via {@link stage}. */72export class ConvergenceScenarioError extends Error {73 /** Always `"ConvergenceScenarioError"`. */74 override readonly name = "ConvergenceScenarioError";7576 /**77 * Builds the error, recording which lifecycle stage was in progress.78 * @param stage the lifecycle stage that was in progress when the scenario failed79 * @param options standard error options, e.g. a `cause` from the failing hook80 */81 constructor(readonly stage: string, options?: ErrorOptions) {82 super(`Concurrent offline convergence failed during ${stage}`, options);83 }84}8586/**87 * Coordinates the transport lifecycle while the app owns edits, assertions and88 * conflict semantics. Both offline edits are started in the same microtask.89 */90export async function runConcurrentOfflineConvergence<Edit, Client extends OfflineTestClient>(91 fixture: OfflineTestFixture<Client>,92 scenario: ConcurrentOfflineScenario<Edit, Client>,93): Promise<void> {94 const timeoutMs = scenario.timeoutMs ?? 60_000;95 if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {96 throw new TypeError("convergence timeoutMs must be a positive finite number");97 }98 const controller = new AbortController();99 const timeout = setTimeout(100 () => controller.abort(new Error(`convergence exceeded its ${timeoutMs}ms deadline`)),101 timeoutMs,102 );103 const withinDeadline = async <Result>(operation: Promise<Result>): Promise<Result> => {104 if (controller.signal.aborted) throw controller.signal.reason;105 let onAbort!: () => void;106 const aborted = new Promise<never>((_resolve, reject) => {107 onAbort = () => reject(controller.signal.reason);108 controller.signal.addEventListener("abort", onAbort, { once: true });109 });110 try {111 return await Promise.race([operation, aborted]);112 } finally {113 controller.signal.removeEventListener("abort", onAbort);114 }115 };116 let stage = "readiness";117 try {118 await withinDeadline(119 Promise.all(fixture.clients.map((client) => scenario.ready(client, controller.signal))),120 );121 stage = "going offline";122 await withinDeadline(fixture.goOffline());123 stage = "concurrent edits";124 await withinDeadline(125 Promise.all([126 scenario.apply(fixture.first, scenario.edits[0], controller.signal),127 scenario.apply(fixture.second, scenario.edits[1], controller.signal),128 ]),129 );130 stage = "local persistence";131 await withinDeadline(132 Promise.all([133 scenario.locallyApplied(fixture.first, scenario.edits[0], controller.signal),134 scenario.locallyApplied(fixture.second, scenario.edits[1], controller.signal),135 ]),136 );137 stage = "pending-work hook";138 if (scenario.whilePending) {139 await withinDeadline(scenario.whilePending(fixture, controller.signal));140 }141 stage = "reconnection";142 await withinDeadline(fixture.goOnline());143 stage = "convergence";144 await withinDeadline(scenario.converged(fixture, controller.signal));145 } catch (error) {146 await fixture.captureFailure(147 scenario.failureLabel ?? `offline-convergence-${stage}`,148 scenario.snapshot,149 ).catch(() => undefined);150 throw new ConvergenceScenarioError(stage, { cause: error });151 } finally {152 clearTimeout(timeout);153 await Promise.allSettled(154 fixture.clients.filter((client) => client.offline).map((client) => client.goOnline()),155 );156 }157}