clone

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

main
lofi / package / testing / scenario / run.ts
run.ts 3.5K
1import type { CompiledPermissions } from "jazz-tools";2import { ScenarioError, type ScenarioStage } from "./errors.ts";3import { createHeadlessScenarioHarness, type ScenarioApp } from "./headless.ts";4import type { ScenarioPeer } from "./peer.ts";56/** The app under test plus the scenario's own settings. */7export interface ScenarioConfig<A extends ScenarioApp> {8 /** The app object, as returned by `s.defineApp`. */9 app: A;10 /** The compiled permission bundle, as returned by `s.definePermissions`. */11 permissions: CompiledPermissions;12 /** Overall defensive deadline for the whole scenario. Defaults to 60 seconds. */13 timeoutMs?: number;14}1516/** The peers a scenario body receives, plus a factory for additional ones. */17export interface ScenarioContext<A> {18 /** The first peer, online and synced at scenario start. */19 readonly alice: ScenarioPeer<A>;20 /** The second peer, online and synced at scenario start. */21 readonly bob: ScenarioPeer<A>;22 /**23 * Boot another named peer — e.g. a fresh reader that joins after the edits24 * to observe the server-canonical state.25 */26 addPeer(name: string): Promise<ScenarioPeer<A>>;27}2829/**30 * The headless scenario core: boots a local sync server, deploys the app,31 * creates the alice and bob peers, runs the body, and tears everything down32 * with bounded waits. Thrown failures are wrapped in {@link ScenarioError}33 * naming the lifecycle stage that failed.34 */35export async function runHeadlessScenario<A extends ScenarioApp>(36 config: ScenarioConfig<A>,37 body: (context: ScenarioContext<A>) => Promise<void>,38): Promise<void> {39 const timeoutMs = config.timeoutMs ?? 60_000;40 if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {41 throw new TypeError("scenario timeoutMs must be a positive finite number");42 }43 const controller = new AbortController();44 const timeout = setTimeout(45 () => controller.abort(new Error(`scenario exceeded its ${timeoutMs}ms deadline`)),46 timeoutMs,47 );48 const withinDeadline = async <Result>(operation: Promise<Result>): Promise<Result> => {49 if (controller.signal.aborted) throw controller.signal.reason;50 let onAbort!: () => void;51 const aborted = new Promise<never>((_resolve, reject) => {52 onAbort = () => reject(controller.signal.reason);53 controller.signal.addEventListener("abort", onAbort, { once: true });54 });55 try {56 return await Promise.race([operation, aborted]);57 } finally {58 controller.signal.removeEventListener("abort", onAbort);59 }60 };6162 let stage: ScenarioStage = "boot";63 const peers: ScenarioPeer<A>[] = [];64 let harness: Awaited<ReturnType<typeof createHeadlessScenarioHarness<A>>> | undefined;65 try {66 harness = await withinDeadline(67 createHeadlessScenarioHarness(config.app, config.permissions),68 );69 stage = "peers";70 const booted = harness;71 const addPeer = async (name: string): Promise<ScenarioPeer<A>> => {72 const peer = await withinDeadline(booted.peer(name));73 peers.push(peer);74 return peer;75 };76 const alice = await addPeer("alice");77 const bob = await addPeer("bob");78 stage = "body";79 await withinDeadline(body({ alice, bob, addPeer }));80 } catch (error) {81 if (error instanceof ScenarioError) throw error;82 const message = error instanceof Error ? error.message : String(error);83 throw new ScenarioError(stage, message, { cause: error });84 } finally {85 clearTimeout(timeout);86 await Promise.allSettled(87 peers.filter((peer) => peer.isOffline).map((peer) => peer.online()),88 );89 if (harness !== undefined) await harness.stop();90 }91}