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/** The lifecycle stages the convergence runner moves through, in order. */72export type ConvergenceStage =73 | "readiness"74 | "going offline"75 | "concurrent edits"76 | "local persistence"77 | "pending-work hook"78 | "reconnection"79 | "convergence";8081/** Options for {@link ConvergenceScenarioError}, extending `ErrorOptions` with the capture outcome. */82export interface ConvergenceScenarioErrorOptions extends ErrorOptions {83 /** The failure raised while capturing artifacts, when capture itself failed. */84 captureError?: unknown;85}8687/** Thrown when a convergence scenario fails, naming the stage that failed via {@link stage}. */88export class ConvergenceScenarioError extends Error {89 /** Always `"ConvergenceScenarioError"`. */90 override readonly name = "ConvergenceScenarioError";9192 /**93 * The failure raised by the fixture's `captureFailure` while this error was94 * being produced — e.g. a `snapshot` callback that violates the value-free95 * rule — or `undefined` when the artifacts were captured. The scenario96 * failure itself stays in {@link Error.cause}.97 */98 readonly captureError?: unknown;99100 /**101 * Builds the error, recording which lifecycle stage was in progress.102 * @param stage the lifecycle stage that was in progress when the scenario failed103 * @param options standard error options, e.g. a `cause` from the failing hook,104 * plus the artifact-capture failure when capture also failed105 */106 constructor(readonly stage: ConvergenceStage, options?: ConvergenceScenarioErrorOptions) {107 const captureNote = options?.captureError !== undefined108 ? "; failure-artifact capture also failed (see captureError)"109 : "";110 super(`Concurrent offline convergence failed during ${stage}${captureNote}`, options);111 this.captureError = options?.captureError;112 }113}114115/**116 * Coordinates the transport lifecycle while the app owns edits, assertions and117 * conflict semantics. Both offline edits are started in the same microtask.118 */119export async function runConcurrentOfflineConvergence<Edit, Client extends OfflineTestClient>(120 fixture: OfflineTestFixture<Client>,121 scenario: ConcurrentOfflineScenario<Edit, Client>,122): Promise<void> {123 const timeoutMs = scenario.timeoutMs ?? 60_000;124 if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {125 throw new TypeError("convergence timeoutMs must be a positive finite number");126 }127 const controller = new AbortController();128 const timeout = setTimeout(129 () => controller.abort(new Error(`convergence exceeded its ${timeoutMs}ms deadline`)),130 timeoutMs,131 );132 const withinDeadline = async <Result>(operation: Promise<Result>): Promise<Result> => {133 if (controller.signal.aborted) throw controller.signal.reason;134 let onAbort!: () => void;135 const aborted = new Promise<never>((_resolve, reject) => {136 onAbort = () => reject(controller.signal.reason);137 controller.signal.addEventListener("abort", onAbort, { once: true });138 });139 try {140 return await Promise.race([operation, aborted]);141 } finally {142 controller.signal.removeEventListener("abort", onAbort);143 }144 };145 let stage: ConvergenceStage = "readiness";146 try {147 await withinDeadline(148 Promise.all(fixture.clients.map((client) => scenario.ready(client, controller.signal))),149 );150 stage = "going offline";151 await withinDeadline(fixture.goOffline());152 stage = "concurrent edits";153 await withinDeadline(154 Promise.all([155 scenario.apply(fixture.first, scenario.edits[0], controller.signal),156 scenario.apply(fixture.second, scenario.edits[1], controller.signal),157 ]),158 );159 stage = "local persistence";160 await withinDeadline(161 Promise.all([162 scenario.locallyApplied(fixture.first, scenario.edits[0], controller.signal),163 scenario.locallyApplied(fixture.second, scenario.edits[1], controller.signal),164 ]),165 );166 stage = "pending-work hook";167 if (scenario.whilePending) {168 await withinDeadline(scenario.whilePending(fixture, controller.signal));169 }170 stage = "reconnection";171 await withinDeadline(fixture.goOnline());172 stage = "convergence";173 await withinDeadline(scenario.converged(fixture, controller.signal));174 } catch (error) {175 // A capture failure never masks the scenario failure: the scenario error176 // stays the thrown error, and the capture failure rides along on it.177 let captureError: unknown;178 try {179 await fixture.captureFailure(180 scenario.failureLabel ?? `offline-convergence-${stage}`,181 scenario.snapshot,182 );183 } catch (failure) {184 captureError = failure ?? new Error("failure-artifact capture rejected without a reason");185 }186 throw new ConvergenceScenarioError(stage, { cause: error, captureError });187 } finally {188 clearTimeout(timeout);189 await Promise.allSettled(190 fixture.clients.filter((client) => client.offline).map((client) => client.goOnline()),191 );192 }193}