clone

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

feat/141-effect-library
lofi / package / testing / scenario / browser.ts
browser.ts 6.2K
1// The fixture module value-loads Playwright, which touches system APIs at2// import time; it is loaded lazily inside the run path so declaring (and3// skipping) a browser scenario never pays that cost.4import type { Page } from "playwright";5import type {6 BrowserTestClient,7 FailureArtifactOptions,8 IdentityOptions,9 TwoClientFixture,10} from "../fixture.ts";11import type { ValueFreeState } from "../safety.ts";12import { ScenarioError, type ScenarioStage } from "./errors.ts";13import { registerConvergeSource, type ScenarioPeerControls } from "./peer.ts";1415/**16 * A browser scenario peer: the scenario controls over a real browser client.17 * There is no typed `db` here — the app's data lives inside the page, so18 * edits go through the page and state is observed through the scenario's19 * value-free snapshot hook.20 */21export interface BrowserScenarioPeer extends ScenarioPeerControls {22 /** The underlying browser client, for artifacts and diagnostics. */23 readonly client: BrowserTestClient;24 /** The peer's page, for page-mediated edits and assertions. */25 readonly page: Page;26}2728/** The peers a browser scenario body receives. */29export interface BrowserScenarioContext {30 /** The first peer — the primary identity when identity mode is `shared`. */31 readonly alice: BrowserScenarioPeer;32 /** The second peer. */33 readonly bob: BrowserScenarioPeer;34}3536/** Configuration for a browser scenario. */37export interface BrowserScenarioOptions {38 /**39 * The served app under test. When `undefined` the scenario logs a skip40 * notice and passes, so browser scenarios stay opt-in — the conventional41 * source is the `LOFI_E2E_BASE_URL` environment variable.42 */43 baseURL: string | undefined;44 /** Identity mode for the two clients, forwarded to the two-client fixture. */45 identity: IdentityOptions;46 /** App-owned readiness assertion, awaited on both peers before the body runs. */47 ready: (peer: BrowserScenarioPeer) => Promise<void>;48 /**49 * App-owned, value-free view of the peer's state — counts and booleans,50 * never user data. Convergence and settling compare these snapshots, and a51 * failure's artifacts include them.52 */53 snapshot: (peer: BrowserScenarioPeer) => Promise<ValueFreeState>;54 /** Failure artifact capture options, forwarded to the two-client fixture. */55 artifacts?: FailureArtifactOptions;56 /** Overall defensive deadline for the whole scenario. Defaults to 120 seconds. */57 timeoutMs?: number;58}5960const SNAPSHOT_POLL_MS = 250;6162function makePeer(63 name: string,64 client: BrowserTestClient,65 snapshot: (peer: BrowserScenarioPeer) => Promise<ValueFreeState>,66): BrowserScenarioPeer {67 const peer: BrowserScenarioPeer = {68 name,69 client,70 get page() {71 return client.page;72 },73 get isOffline() {74 return client.offline;75 },76 async offline() {77 await client.goOffline();78 },79 async online() {80 await client.goOnline();81 },82 async settle(options) {83 const timeoutMs = options?.timeoutMs ?? 20_000;84 const deadline = Date.now() + timeoutMs;85 let previous: string | undefined;86 while (Date.now() < deadline) {87 const current = JSON.stringify(await snapshot(peer));88 if (previous !== undefined && current === previous) return;89 previous = current;90 await new Promise((resolve) => setTimeout(resolve, SNAPSHOT_POLL_MS));91 }92 throw new ScenarioError(93 "convergence",94 `${name}'s snapshot did not stabilize within ${timeoutMs}ms`,95 { peer: name },96 );97 },98 };99 registerConvergeSource(peer, { kind: "snapshot", snapshot: () => snapshot(peer) });100 return peer;101}102103/**104 * The browser scenario core: boots the two-client fixture against the served105 * app, readies both peers, runs the body, and captures value-free failure106 * artifacts before tearing down. Skips (resolving cleanly, with a notice)107 * when no `baseURL` is configured or no browser is installed.108 */109export async function runBrowserScenario(110 options: BrowserScenarioOptions,111 body: (context: BrowserScenarioContext) => Promise<void>,112): Promise<void> {113 if (!options.baseURL) {114 console.log("skipping browser scenario; set LOFI_E2E_BASE_URL to a served app to run it");115 return;116 }117 const timeoutMs = options.timeoutMs ?? 120_000;118 if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {119 throw new TypeError("scenario timeoutMs must be a positive finite number");120 }121122 const { BrowserUnavailableError, createTwoClientFixture } = await import("../fixture.ts");123 let fixture: TwoClientFixture;124 try {125 fixture = await createTwoClientFixture({126 baseURL: options.baseURL,127 identity: options.identity,128 artifacts: options.artifacts,129 });130 } catch (error) {131 if (error instanceof BrowserUnavailableError) {132 console.log(error.message);133 return;134 }135 throw new ScenarioError("boot", "browser fixture creation failed", { cause: error });136 }137138 const alice = makePeer("alice", fixture.first, options.snapshot);139 const bob = makePeer("bob", fixture.second, options.snapshot);140 const peers = [alice, bob];141 const deadline = setTimeout(() => {142 // The deadline is defensive: close() interrupts a hung page wait.143 void fixture.close().catch(() => undefined);144 }, timeoutMs);145146 let stage: ScenarioStage = "peers";147 try {148 await Promise.all(peers.map((peer) => options.ready(peer)));149 stage = "body";150 await body({ alice, bob });151 } catch (error) {152 if (error instanceof ScenarioError) throw error;153 let captureError: unknown;154 try {155 await fixture.captureFailure(156 `scenario-${stage}`,157 (client) => options.snapshot(client === fixture.first ? alice : bob),158 );159 } catch (failure) {160 captureError = failure ?? new Error("failure-artifact capture rejected without a reason");161 }162 const message = error instanceof Error ? error.message : String(error);163 throw new ScenarioError(stage, message, {164 cause: error,165 details: captureError !== undefined166 ? "failure-artifact capture also failed (see cause chain)"167 : undefined,168 });169 } finally {170 clearTimeout(deadline);171 await Promise.allSettled(172 peers.filter((peer) => peer.isOffline).map((peer) => peer.online()),173 );174 await fixture.close();175 }176}