1import {2 ConvergenceScenarioError,3 type OfflineTestClient,4 type OfflineTestFixture,5 runConcurrentOfflineConvergence,6} from "./convergence.ts";7import type { ValueFreeState } from "./safety.ts";89function assert(condition: unknown, message: string): asserts condition {10 if (!condition) throw new Error(message);11}1213class FakeClient implements OfflineTestClient {14 offline = false;15 readonly events: string[] = [];1617 constructor(readonly name: string) {}1819 goOffline(): Promise<void> {20 this.offline = true;21 this.events.push("offline");22 return Promise.resolve();23 }2425 goOnline(): Promise<void> {26 this.offline = false;27 this.events.push("online");28 return Promise.resolve();29 }30}3132class FakeFixture implements OfflineTestFixture<FakeClient> {33 readonly first = new FakeClient("first");34 readonly second = new FakeClient("second");35 readonly clients = [this.first, this.second] as const;36 captures: Array<{ label: string; states?: ValueFreeState[] }> = [];3738 async goOffline(): Promise<void> {39 await Promise.all(this.clients.map((client) => client.goOffline()));40 }4142 async goOnline(): Promise<void> {43 await Promise.all(this.clients.map((client) => client.goOnline()));44 }4546 async captureFailure(47 label: string,48 snapshot?: (client: FakeClient) => Promise<ValueFreeState>,49 ): Promise<void> {50 this.captures.push({51 label,52 states: snapshot ? await Promise.all(this.clients.map(snapshot)) : undefined,53 });54 }55}5657Deno.test("offline convergence starts edits concurrently and leaves both clients online", async () => {58 const fixture = new FakeFixture();59 const entered: string[] = [];60 let release!: () => void;61 const bothEntered = new Promise<void>((resolve) => (release = resolve));6263 await runConcurrentOfflineConvergence(fixture, {64 edits: ["alpha", "beta"],65 ready: () => Promise.resolve(),66 async apply(client, edit) {67 assert(client.offline, `${client.name} edit ran online`);68 entered.push(`${client.name}:${edit}`);69 if (entered.length === 2) release();70 await bothEntered;71 },72 locallyApplied: () => Promise.resolve(),73 whilePending(current) {74 assert(current.clients.every((client) => client.offline), "pending hook ran online");75 return Promise.resolve();76 },77 converged(current) {78 assert(current.clients.every((client) => !client.offline), "convergence ran offline");79 return Promise.resolve();80 },81 });8283 assert(entered.join(",") === "first:alpha,second:beta", `edits were not paired: ${entered}`);84 assert(fixture.clients.every((client) => !client.offline), "clients were left offline");85 assert(fixture.captures.length === 0, "successful scenario captured failure artifacts");86});8788Deno.test("offline convergence captures the failing stage and restores connectivity", async () => {89 const fixture = new FakeFixture();90 try {91 await runConcurrentOfflineConvergence(fixture, {92 edits: [1, 2],93 ready: () => Promise.resolve(),94 apply: () => Promise.reject(new Error("edit failed")),95 locallyApplied: () => Promise.resolve(),96 converged: () => Promise.resolve(),97 snapshot: (client) => Promise.resolve({ offline: client.offline }),98 });99 throw new Error("expected scenario failure");100 } catch (error) {101 assert(error instanceof ConvergenceScenarioError, `unexpected error: ${error}`);102 assert(error.stage === "concurrent edits", `unexpected stage: ${error.stage}`);103 }104 assert(fixture.clients.every((client) => !client.offline), "failure left clients offline");105 assert(fixture.captures.length === 1, "failure artifacts were not requested once");106 assert(107 fixture.captures[0].label.includes("concurrent edits"),108 `failure label lost the stage: ${fixture.captures[0].label}`,109 );110});111112Deno.test("offline convergence surfaces a failed artifact capture on the scenario error", async () => {113 class CaptureFailingFixture extends FakeFixture {114 override captureFailure(): Promise<void> {115 return Promise.reject(new TypeError("state contains a string value"));116 }117 }118 const fixture = new CaptureFailingFixture();119 const editFailure = new Error("edit failed");120 try {121 await runConcurrentOfflineConvergence(fixture, {122 edits: [1, 2],123 ready: () => Promise.resolve(),124 apply: () => Promise.reject(editFailure),125 locallyApplied: () => Promise.resolve(),126 converged: () => Promise.resolve(),127 });128 throw new Error("expected scenario failure");129 } catch (error) {130 assert(error instanceof ConvergenceScenarioError, `unexpected error: ${error}`);131 assert(error.cause === editFailure, "scenario failure was displaced from the cause");132 assert(133 error.captureError instanceof TypeError &&134 error.captureError.message.includes("string value"),135 `capture failure was not surfaced: ${error.captureError}`,136 );137 assert(138 error.message.includes("failure-artifact capture also failed"),139 `message hides the capture failure: ${error.message}`,140 );141 }142});143144Deno.test("offline convergence bounds a never-settling app predicate", async () => {145 const fixture = new FakeFixture();146 try {147 await runConcurrentOfflineConvergence(fixture, {148 edits: [1, 2],149 timeoutMs: 10,150 ready: () => Promise.resolve(),151 apply: () => Promise.resolve(),152 locallyApplied: () => Promise.resolve(),153 converged: () => new Promise(() => undefined),154 });155 throw new Error("expected convergence deadline failure");156 } catch (error) {157 assert(error instanceof ConvergenceScenarioError, `unexpected error: ${error}`);158 assert(error.stage === "convergence", `unexpected stage: ${error.stage}`);159 assert(160 error.cause instanceof Error && error.cause.message.includes("10ms deadline"),161 `deadline cause was not retained: ${error.cause}`,162 );163 }164 assert(fixture.clients.every((client) => !client.offline), "deadline left clients offline");165});