clone

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

feat(testing): add local-first browser fixtures

Dami committed cd3481c
commit
cd3481ce06804d9402fe58bb9d42b93a6c877533
parent
9cbfebaed289e73ecea1833aeeb0daf629e394a2
author
Dami <dami.etoile@gmail.com>
11 files changed, +1169 -0
Apackage/testing/browser_error.ts20++
Apackage/testing/convergence.ts87+++++++
Apackage/testing/convergence_test.ts110+++++++++
Apackage/testing/fixture.ts386++++++++++++++++++++++++++++++
Apackage/testing/fixture_integration.ts171+++++++++++++
Apackage/testing/mod.ts22++
Apackage/testing/readiness.ts40+++
Apackage/testing/readiness_test.ts48++++
Apackage/testing/safety.ts73++++++
Apackage/testing/safety_test.ts77++++++
Apackage/testing/trace.ts135++++++++++
A package/testing/browser_error.ts +20 -0
A package/testing/convergence.ts +87 -0
@@ -0,0 +1,87 @@
1+import type { BrowserTestClient } from "./fixture.ts";2+import type { ValueFreeState } from "./safety.ts";3+4+export interface OfflineTestClient {5+ readonly offline: boolean;6+ goOffline(): Promise<void>;7+ goOnline(): Promise<void>;8+}9+10+export interface OfflineTestFixture<Client extends OfflineTestClient> {11+ readonly clients: readonly [Client, Client];12+ readonly first: Client;13+ readonly second: Client;14+ goOffline(): Promise<void>;15+ goOnline(): Promise<void>;16+ captureFailure(17+ label: string,18+ snapshot?: (client: Client) => Promise<ValueFreeState>,19+ ): Promise<unknown>;20+}21+22+export interface ConcurrentOfflineScenario<23+ Edit,24+ Client extends OfflineTestClient = BrowserTestClient,25+> {26+ readonly edits: readonly [Edit, Edit];27+ /** App-owned, deterministic readiness assertion (locator/waitForFunction/etc.). */28+ readonly ready: (client: Client) => Promise<void>;29+ readonly apply: (client: Client, edit: Edit) => Promise<void>;30+ readonly locallyApplied: (client: Client, edit: Edit) => Promise<void>;31+ /** Use this hook for page/client restart while offline work is pending. */32+ readonly whilePending?: (fixture: OfflineTestFixture<Client>) => Promise<void>;33+ /** Must resolve only when both app views have converged, or reject on timeout. */34+ readonly converged: (fixture: OfflineTestFixture<Client>) => Promise<void>;35+ readonly snapshot?: (client: Client) => Promise<ValueFreeState>;36+ readonly failureLabel?: string;37+}38+39+export class ConvergenceScenarioError extends Error {40+ override readonly name = "ConvergenceScenarioError";41+42+ constructor(readonly stage: string, options?: ErrorOptions) {43+ super(`Concurrent offline convergence failed during ${stage}`, options);44+ }45+}46+47+/**48+ * Coordinates the transport lifecycle while the app owns edits, assertions and49+ * conflict semantics. Both offline edits are started in the same microtask.50+ */51+export async function runConcurrentOfflineConvergence<Edit, Client extends OfflineTestClient>(52+ fixture: OfflineTestFixture<Client>,53+ scenario: ConcurrentOfflineScenario<Edit, Client>,54+): Promise<void> {55+ let stage = "readiness";56+ try {57+ await Promise.all(fixture.clients.map((client) => scenario.ready(client)));58+ stage = "going offline";59+ await fixture.goOffline();60+ stage = "concurrent edits";61+ await Promise.all([62+ scenario.apply(fixture.first, scenario.edits[0]),63+ scenario.apply(fixture.second, scenario.edits[1]),64+ ]);65+ stage = "local persistence";66+ await Promise.all([67+ scenario.locallyApplied(fixture.first, scenario.edits[0]),68+ scenario.locallyApplied(fixture.second, scenario.edits[1]),69+ ]);70+ stage = "pending-work hook";71+ await scenario.whilePending?.(fixture);72+ stage = "reconnection";73+ await fixture.goOnline();74+ stage = "convergence";75+ await scenario.converged(fixture);76+ } catch (error) {77+ await fixture.captureFailure(78+ scenario.failureLabel ?? `offline-convergence-${stage}`,79+ scenario.snapshot,80+ ).catch(() => undefined);81+ throw new ConvergenceScenarioError(stage, { cause: error });82+ } finally {83+ await Promise.allSettled(84+ fixture.clients.filter((client) => client.offline).map((client) => client.goOnline()),85+ );86+ }87+}
A package/testing/convergence_test.ts +110 -0
@@ -0,0 +1,110 @@
1+import {2+ ConvergenceScenarioError,3+ type OfflineTestClient,4+ type OfflineTestFixture,5+ runConcurrentOfflineConvergence,6+} from "./convergence.ts";7+import type { ValueFreeState } from "./safety.ts";8+9+function assert(condition: unknown, message: string): asserts condition {10+ if (!condition) throw new Error(message);11+}12+13+class FakeClient implements OfflineTestClient {14+ offline = false;15+ readonly events: string[] = [];16+17+ constructor(readonly name: string) {}18+19+ goOffline(): Promise<void> {20+ this.offline = true;21+ this.events.push("offline");22+ return Promise.resolve();23+ }24+25+ goOnline(): Promise<void> {26+ this.offline = false;27+ this.events.push("online");28+ return Promise.resolve();29+ }30+}31+32+class 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[] }> = [];37+38+ async goOffline(): Promise<void> {39+ await Promise.all(this.clients.map((client) => client.goOffline()));40+ }41+42+ async goOnline(): Promise<void> {43+ await Promise.all(this.clients.map((client) => client.goOnline()));44+ }45+46+ 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+}56+57+Deno.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));62+63+ 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+ });82+83+ 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+});87+88+Deno.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+});
A package/testing/fixture.ts +386 -0
@@ -0,0 +1,386 @@
1+import {2+ type Browser,3+ type BrowserContext,4+ type BrowserContextOptions,5+ chromium,6+ type ConsoleMessage,7+ type Locator,8+ type Page,9+ type Request,10+} from "npm:playwright@1.61.1";11+import { BrowserUnavailableError, rethrowBrowserLaunchError } from "./browser_error.ts";12+import {13+ artifactName,14+ assertValueFreeState,15+ redactDiagnosticText,16+ type ValueFreeState,17+} from "./safety.ts";18+import { sanitizeTraceArchive } from "./trace.ts";19+20+type MemoryStorageState = Awaited<ReturnType<BrowserContext["storageState"]>>;21+export type SafeContextOptions = Omit<BrowserContextOptions, "storageState"> & {22+ /** Identity state is managed in memory by the fixture. File paths are forbidden. */23+ storageState?: never;24+};25+26+export type ClientName = "first" | "second";27+28+export interface BrowserDiagnostic {29+ readonly client: ClientName;30+ readonly kind: "console" | "page-error" | "request-failed";31+ readonly level?: string;32+ readonly message: string;33+ readonly url?: string;34+}35+36+export type IdentityOptions =37+ | {38+ readonly mode: "shared";39+ /** Runs before tracing starts. The resulting state is cloned only in memory. */40+ readonly preparePrimary: (client: BrowserTestClient) => Promise<void>;41+ }42+ | {43+ readonly mode: "isolated";44+ /** Runs independently for each client before tracing starts. */45+ readonly prepare?: (client: BrowserTestClient) => Promise<void>;46+ };47+48+export interface FailureArtifactOptions {49+ readonly directory: string;50+ /** Literal credential values to remove from retained diagnostics. */51+ readonly secretValues?: readonly string[];52+ /** Locators containing private UI values that screenshots should mask. */53+ readonly mask?: (client: BrowserTestClient) => readonly Locator[];54+}55+56+export interface TwoClientFixtureOptions {57+ readonly baseURL: string;58+ readonly identity: IdentityOptions;59+ readonly browser?: Browser;60+ readonly context?: SafeContextOptions;61+ readonly artifacts?: FailureArtifactOptions;62+ /** Defaults to true. Tracing begins only after identity preparation. */63+ readonly traceOnFailure?: boolean;64+}65+66+export { BrowserUnavailableError };67+68+function validateContextOptions(options: SafeContextOptions | undefined): void {69+ if (options && "storageState" in options) {70+ throw new TypeError(71+ "context.storageState is not accepted; use an identity mode so state stays in memory",72+ );73+ }74+}75+76+export class BrowserTestClient {77+ readonly #diagnostics: BrowserDiagnostic[] = [];78+ #context: BrowserContext;79+ #page: Page;80+ #offline = false;81+ #recording = false;82+ #closed = false;83+84+ constructor(85+ readonly name: ClientName,86+ context: BrowserContext,87+ page: Page,88+ readonly baseURL: string,89+ private readonly createContext: (state?: MemoryStorageState) => Promise<BrowserContext>,90+ private readonly redact: (value: string) => string,91+ private readonly traceOnFailure: boolean,92+ ) {93+ this.#context = context;94+ this.#page = page;95+ }96+97+ get context(): BrowserContext {98+ return this.#context;99+ }100+101+ get page(): Page {102+ return this.#page;103+ }104+105+ get offline(): boolean {106+ return this.#offline;107+ }108+109+ get diagnostics(): readonly BrowserDiagnostic[] {110+ return this.#diagnostics;111+ }112+113+ async startRecording(): Promise<void> {114+ if (this.#recording || this.#closed) return;115+ this.#attachDiagnostics(this.#page);116+ if (this.traceOnFailure) {117+ await this.#context.tracing.start({ screenshots: false, snapshots: false, sources: false });118+ }119+ this.#recording = true;120+ }121+122+ async goOffline(): Promise<void> {123+ await this.#context.setOffline(true);124+ this.#offline = true;125+ }126+127+ async goOnline(): Promise<void> {128+ await this.#context.setOffline(false);129+ this.#offline = false;130+ }131+132+ /** Reload the current page without replacing its browser-side client state. */133+ async reloadPage(): Promise<Page> {134+ await this.#page.reload({ waitUntil: "domcontentloaded" });135+ return this.#page;136+ }137+138+ /** Close and recreate the page inside the same identity/context. */139+ async restartPage(): Promise<Page> {140+ const previous = this.#page;141+ await previous.close();142+ this.#page = await this.#context.newPage();143+ this.#attachDiagnostics(this.#page);144+ await this.#page.goto(this.baseURL, { waitUntil: "domcontentloaded" });145+ return this.#page;146+ }147+148+ /**149+ * Restart the whole context, preserving identity and IndexedDB only in memory.150+ * A new context starts online because service-worker/cache state is not part of151+ * Playwright storageState; use restartPage for an offline PWA restart.152+ */153+ async restartClient(options: { preserveIdentity?: boolean } = {}): Promise<Page> {154+ const preserveIdentity = options.preserveIdentity ?? true;155+ let state: MemoryStorageState | undefined;156+ if (preserveIdentity) {157+ state = await this.#context.storageState({ indexedDB: true });158+ }159+ await this.#discardTrace();160+ await this.#context.close();161+ this.#context = await this.createContext(state ? structuredClone(state) : undefined);162+ state = undefined;163+ this.#page = await this.#context.newPage();164+ this.#offline = false;165+ this.#recording = false;166+ await this.startRecording();167+ await this.#page.goto(this.baseURL, { waitUntil: "domcontentloaded" });168+ return this.#page;169+ }170+171+ async captureTrace(path: string): Promise<boolean> {172+ if (!this.#recording || !this.traceOnFailure) return false;173+ await this.#context.tracing.stop({ path });174+ this.#recording = false;175+ try {176+ await sanitizeTraceArchive(path, this.redact);177+ } catch (error) {178+ await Deno.remove(path).catch(() => undefined);179+ throw error;180+ }181+ return true;182+ }183+184+ async close(): Promise<void> {185+ if (this.#closed) return;186+ this.#closed = true;187+ await this.#discardTrace();188+ await this.#context.close();189+ }190+191+ #attachDiagnostics(page: Page): void {192+ page.on("console", (message: ConsoleMessage) => {193+ this.#diagnostics.push({194+ client: this.name,195+ kind: "console",196+ level: message.type(),197+ message: this.redact(message.text()),198+ url: this.redact(message.location().url),199+ });200+ });201+ page.on("pageerror", (error: Error) => {202+ this.#diagnostics.push({203+ client: this.name,204+ kind: "page-error",205+ message: this.redact(`${error.name}: ${error.message}`),206+ });207+ });208+ page.on("requestfailed", (request: Request) => {209+ this.#diagnostics.push({210+ client: this.name,211+ kind: "request-failed",212+ message: this.redact(request.failure()?.errorText ?? "request failed"),213+ url: this.redact(request.url()),214+ });215+ });216+ }217+218+ async #discardTrace(): Promise<void> {219+ if (!this.#recording || !this.traceOnFailure) return;220+ await this.#context.tracing.stop().catch(() => undefined);221+ this.#recording = false;222+ }223+}224+225+export interface FailureArtifacts {226+ readonly directory: string;227+ readonly files: readonly string[];228+}229+230+export class TwoClientFixture {231+ readonly clients: readonly [BrowserTestClient, BrowserTestClient];232+ #closed = false;233+234+ constructor(235+ readonly browser: Browser,236+ readonly ownsBrowser: boolean,237+ first: BrowserTestClient,238+ second: BrowserTestClient,239+ private readonly artifacts: FailureArtifactOptions | undefined,240+ ) {241+ this.clients = [first, second];242+ }243+244+ get first(): BrowserTestClient {245+ return this.clients[0];246+ }247+248+ get second(): BrowserTestClient {249+ return this.clients[1];250+ }251+252+ async goOffline(): Promise<void> {253+ await Promise.all(this.clients.map((client) => client.goOffline()));254+ }255+256+ async goOnline(): Promise<void> {257+ await Promise.all(this.clients.map((client) => client.goOnline()));258+ }259+260+ async captureFailure(261+ label: string,262+ snapshot?: (client: BrowserTestClient) => Promise<ValueFreeState>,263+ ): Promise<FailureArtifacts | undefined> {264+ if (!this.artifacts) return undefined;265+ const directory = `${this.artifacts.directory}/${artifactName(label)}`;266+ await Deno.mkdir(directory, { recursive: true });267+ const files: string[] = [];268+269+ for (const client of this.clients) {270+ const screenshot = `${directory}/${client.name}.png`;271+ await client.page.screenshot({272+ path: screenshot,273+ fullPage: true,274+ mask: this.artifacts.mask ? [...this.artifacts.mask(client)] : undefined,275+ });276+ files.push(screenshot);277+278+ const trace = `${directory}/${client.name}.trace.zip`;279+ if (await client.captureTrace(trace)) files.push(trace);280+ }281+282+ const diagnostics = `${directory}/diagnostics.json`;283+ await Deno.writeTextFile(284+ diagnostics,285+ `${JSON.stringify(this.clients.flatMap((client) => client.diagnostics), null, 2)}\n`,286+ );287+ files.push(diagnostics);288+289+ if (snapshot) {290+ const states: Record<ClientName, ValueFreeState> = {291+ first: await snapshot(this.first),292+ second: await snapshot(this.second),293+ };294+ assertValueFreeState(states);295+ const statePath = `${directory}/state-shape.json`;296+ await Deno.writeTextFile(statePath, `${JSON.stringify(states, null, 2)}\n`);297+ files.push(statePath);298+ }299+ return { directory, files };300+ }301+302+ async close(): Promise<void> {303+ if (this.#closed) return;304+ this.#closed = true;305+ const results = await Promise.allSettled(this.clients.map((client) => client.close()));306+ if (this.ownsBrowser) await this.browser.close();307+ const rejected = results.find((result) => result.status === "rejected");308+ if (rejected?.status === "rejected") throw rejected.reason;309+ }310+}311+312+export async function createTwoClientFixture(313+ options: TwoClientFixtureOptions,314+): Promise<TwoClientFixture> {315+ validateContextOptions(options.context);316+ let browser = options.browser;317+ const ownsBrowser = !browser;318+ if (!browser) {319+ try {320+ browser = await chromium.launch({ headless: true });321+ } catch (error) {322+ rethrowBrowserLaunchError(error);323+ }324+ }325+326+ const contextOptions = { ...options.context, baseURL: options.baseURL };327+ const createContext = (state?: MemoryStorageState) =>328+ browser.newContext({329+ ...contextOptions,330+ storageState: state ? structuredClone(state) : undefined,331+ });332+ const redact = (value: string) =>333+ redactDiagnosticText(value, options.artifacts?.secretValues ?? []);334+ const traceOnFailure = options.traceOnFailure ?? true;335+ let first: BrowserTestClient | undefined;336+ let second: BrowserTestClient | undefined;337+338+ try {339+ const firstContext = await createContext();340+ const firstPage = await firstContext.newPage();341+ first = new BrowserTestClient(342+ "first",343+ firstContext,344+ firstPage,345+ options.baseURL,346+ createContext,347+ redact,348+ traceOnFailure,349+ );350+ await firstPage.goto(options.baseURL, { waitUntil: "domcontentloaded" });351+352+ let sharedState: MemoryStorageState | undefined;353+ if (options.identity.mode === "shared") {354+ await options.identity.preparePrimary(first);355+ sharedState = await firstContext.storageState({ indexedDB: true });356+ } else if (options.identity.prepare) {357+ await options.identity.prepare(first);358+ }359+360+ const secondContext = await createContext(361+ sharedState ? structuredClone(sharedState) : undefined,362+ );363+ sharedState = undefined;364+ const secondPage = await secondContext.newPage();365+ second = new BrowserTestClient(366+ "second",367+ secondContext,368+ secondPage,369+ options.baseURL,370+ createContext,371+ redact,372+ traceOnFailure,373+ );374+ await secondPage.goto(options.baseURL, { waitUntil: "domcontentloaded" });375+ if (options.identity.mode === "isolated" && options.identity.prepare) {376+ await options.identity.prepare(second);377+ }378+379+ await Promise.all([first.startRecording(), second.startRecording()]);380+ return new TwoClientFixture(browser, ownsBrowser, first, second, options.artifacts);381+ } catch (error) {382+ await Promise.allSettled([first?.close(), second?.close()].filter(Boolean));383+ if (ownsBrowser) await browser.close().catch(() => undefined);384+ throw error;385+ }386+}
A package/testing/fixture_integration.ts +171 -0
@@ -0,0 +1,171 @@
1+import {2+ BrowserUnavailableError,3+ createTwoClientFixture,4+ type TwoClientFixture,5+} from "./fixture.ts";6+import { waitForReady } from "./readiness.ts";7+import { readTraceArchive } from "./trace.ts";8+9+function assert(condition: unknown, message: string): asserts condition {10+ if (!condition) throw new Error(message);11+}12+13+Deno.test("real Chromium fixture isolates or shares identity in memory and captures safe failures", async () => {14+ const artifacts = await Deno.makeTempDir({ dir: "." });15+ const server = Deno.serve(16+ { hostname: "127.0.0.1", port: 0, onListen() {} },17+ () =>18+ new Response(19+ `<!doctype html>20+ <html><body data-ready="yes"><main>fixture</main></body></html>`,21+ { headers: { "content-type": "text/html; charset=utf-8" } },22+ ),23+ );24+ const address = server.addr as Deno.NetAddr;25+ const baseURL = `http://127.0.0.1:${address.port}/`;26+ let shared: TwoClientFixture | undefined;27+ let isolated: TwoClientFixture | undefined;28+29+ try {30+ try {31+ shared = await createTwoClientFixture({32+ baseURL,33+ identity: {34+ mode: "shared",35+ async preparePrimary(client) {36+ await client.page.evaluate(() => localStorage.setItem("identity", "shared-person"));37+ },38+ },39+ artifacts: { directory: artifacts, secretValues: ["literal-secret"] },40+ });41+ } catch (error) {42+ if (error instanceof BrowserUnavailableError) return;43+ throw error;44+ }45+46+ await Promise.all(47+ shared.clients.map((client) =>48+ waitForReady(49+ client.page,50+ (value) => document.body.dataset.ready === value,51+ "yes",52+ { description: `${client.name} boot` },53+ )54+ ),55+ );56+ const sharedIdentities = await Promise.all(57+ shared.clients.map((client) => client.page.evaluate(() => localStorage.getItem("identity"))),58+ );59+ assert(60+ sharedIdentities.join(",") === "shared-person,shared-person",61+ `shared state was not cloned: ${sharedIdentities}`,62+ );63+64+ await shared.first.reloadPage();65+ assert(66+ await shared.first.page.evaluate(() => localStorage.getItem("identity")) === "shared-person",67+ "page reload lost identity",68+ );69+70+ await shared.goOffline();71+ await Promise.all(72+ shared.clients.map((client) => client.page.waitForFunction(() => navigator.onLine === false)),73+ );74+ await shared.goOnline();75+ await Promise.all(76+ shared.clients.map((client) => client.page.waitForFunction(() => navigator.onLine === true)),77+ );78+79+ await shared.first.restartPage();80+ assert(81+ await shared.first.page.evaluate(() => localStorage.getItem("identity")) === "shared-person",82+ "page restart lost identity",83+ );84+ await shared.second.restartClient();85+ assert(86+ await shared.second.page.evaluate(() => localStorage.getItem("identity")) === "shared-person",87+ "client restart lost in-memory identity",88+ );89+90+ isolated = await createTwoClientFixture({91+ baseURL,92+ browser: shared.browser,93+ identity: {94+ mode: "isolated",95+ async prepare(client) {96+ await client.page.evaluate(97+ (identity) => localStorage.setItem("identity", identity),98+ `${client.name}-person`,99+ );100+ },101+ },102+ traceOnFailure: false,103+ });104+ const isolatedIdentities = await Promise.all(105+ isolated.clients.map((client) =>106+ client.page.evaluate(() => localStorage.getItem("identity"))107+ ),108+ );109+ assert(110+ isolatedIdentities.join(",") === "first-person,second-person",111+ `isolated identities crossed: ${isolatedIdentities}`,112+ );113+ await isolated.close();114+ isolated = undefined;115+116+ const consoleEvent = shared.first.page.waitForEvent("console", {117+ predicate: (message) => message.type() === "error",118+ });119+ await shared.first.page.evaluate(() => console.error("token=literal-secret"));120+ await consoleEvent;121+ const pageError = shared.first.page.waitForEvent("pageerror");122+ await shared.first.page.evaluate(() => {123+ queueMicrotask(() => {124+ throw new Error("secret=literal-secret");125+ });126+ });127+ await pageError;128+ const requestFailed = shared.first.page.waitForEvent("requestfailed");129+ await shared.first.page.evaluate(() =>130+ fetch("http://127.0.0.1:1/private?token=literal-secret").catch(() => undefined)131+ );132+ await requestFailed;133+134+ const captured = await shared.captureFailure(135+ "integration failure",136+ (client) => Promise.resolve({ online: !client.offline, itemCount: 0 }),137+ );138+ assert(captured, "failure artifacts were not created");139+ assert(captured.files.some((path) => path.endsWith(".png")), "screenshots were omitted");140+ assert(141+ captured.files.filter((path) => path.endsWith(".trace.zip")).length === 2,142+ "one trace per client was not retained",143+ );144+ assert(145+ captured.files.every((path) => !path.toLowerCase().includes("storage")),146+ `storage state leaked into artifact names: ${captured.files}`,147+ );148+ const diagnostics = await Deno.readTextFile(`${captured.directory}/diagnostics.json`);149+ assert(!diagnostics.includes("literal-secret"), diagnostics);150+ assert(diagnostics.includes("[redacted]"), diagnostics);151+ for (const tracePath of captured.files.filter((path) => path.endsWith(".trace.zip"))) {152+ const entries = await readTraceArchive(tracePath);153+ for (const bytes of Object.values(entries)) {154+ assert(155+ !new TextDecoder().decode(bytes).includes("literal-secret"),156+ `${tracePath} retained a secret`,157+ );158+ }159+ }160+161+ const browser = shared.browser;162+ await shared.close();163+ shared = undefined;164+ assert(!browser.isConnected(), "owned Chromium process remained connected after close");165+ } finally {166+ await isolated?.close().catch(() => undefined);167+ await shared?.close().catch(() => undefined);168+ await server.shutdown().catch(() => undefined);169+ await Deno.remove(artifacts, { recursive: true });170+ }171+});
A package/testing/mod.ts +22 -0
A package/testing/readiness.ts +40 -0
A package/testing/readiness_test.ts +48 -0
@@ -0,0 +1,48 @@
1+import type { Page } from "npm:playwright@1.61.1";2+import { ReadinessError, waitForReady } from "./readiness.ts";3+4+function assert(condition: unknown, message: string): asserts condition {5+ if (!condition) throw new Error(message);6+}7+8+Deno.test("waitForReady delegates polling and timeout to Playwright", async () => {9+ let call: unknown[] | undefined;10+ const page = {11+ waitForFunction(...args: unknown[]) {12+ call = args;13+ return Promise.resolve({});14+ },15+ } as unknown as Page;16+ const predicate = (expected: string) => document.body.dataset.ready === expected;17+18+ await waitForReady(page, predicate, "yes", {19+ description: "reference app boot",20+ timeoutMs: 321,21+ polling: 17,22+ });23+24+ assert(call?.[0] === predicate, "predicate was replaced");25+ assert(call?.[1] === "yes", "predicate argument was replaced");26+ assert(27+ JSON.stringify(call?.[2]) === JSON.stringify({ polling: 17, timeout: 321 }),28+ `unexpected Playwright options: ${JSON.stringify(call?.[2])}`,29+ );30+});31+32+Deno.test("waitForReady adds the app condition to timeout failures", async () => {33+ const cause = new Error("playwright timeout");34+ const page = {35+ waitForFunction() {36+ return Promise.reject(cause);37+ },38+ } as unknown as Page;39+40+ try {41+ await waitForReady(page, () => false, undefined, { description: "sync indicator" });42+ throw new Error("expected readiness to fail");43+ } catch (error) {44+ assert(error instanceof ReadinessError, `unexpected error: ${error}`);45+ assert(error.message.includes("sync indicator"), "condition was missing from the error");46+ assert(error.cause === cause, "Playwright failure was not retained as the cause");47+ }48+});
A package/testing/safety.ts +73 -0
@@ -0,0 +1,73 @@
1+export type ValueFreeState =2+ | null3+ | boolean4+ | number5+ | { readonly [key: string]: ValueFreeState }6+ | readonly ValueFreeState[];7+8+const JSON_SECRET_ASSIGNMENT =9+ /(\"(?:password|passwd|secret|token|authorization|cookie|credential|session)\"\s*:\s*)\"(?:\\.|[^\"])*\"/gi;10+const SECRET_ASSIGNMENT =11+ /\b(password|passwd|secret|token|authorization|cookie|credential|session)\b\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;"']+)/gi;12+const BEARER_VALUE = /\bBearer\s+[A-Za-z0-9._~+/=-]+/gi;13+const URL_VALUE = /https?:\/\/[^\s"'<>]+/gi;14+15+function safeUrl(value: string): string {16+ try {17+ const url = new URL(value);18+ url.username = "";19+ url.password = "";20+ url.search = "";21+ url.hash = "";22+ return url.toString();23+ } catch {24+ return value;25+ }26+}27+28+/** Redacts common credential forms before diagnostics are retained in memory. */29+export function redactDiagnosticText(30+ value: string,31+ secretValues: readonly string[] = [],32+): string {33+ let redacted = value34+ .replaceAll(URL_VALUE, (url) => safeUrl(url))35+ .replaceAll(JSON_SECRET_ASSIGNMENT, '$1"[redacted]"')36+ .replaceAll(BEARER_VALUE, "Bearer [redacted]")37+ .replaceAll(SECRET_ASSIGNMENT, (_match, key: string) => `${key}=[redacted]`);38+ for (const secret of secretValues) {39+ if (secret.length > 0) redacted = redacted.replaceAll(secret, "[redacted]");40+ }41+ return redacted;42+}43+44+/**45+ * State artifacts intentionally reject strings and non-finite numbers. Callers46+ * can record shape, counts and booleans, but not user values or credentials.47+ */48+export function assertValueFreeState(49+ value: unknown,50+ path = "state",51+): asserts value is ValueFreeState {52+ if (value === null || typeof value === "boolean") return;53+ if (typeof value === "number" && Number.isFinite(value)) return;54+ if (typeof value === "string") {55+ throw new TypeError(`${path} contains a string value; state artifacts must be value-free`);56+ }57+ if (Array.isArray(value)) {58+ value.forEach((entry, index) => assertValueFreeState(entry, `${path}[${index}]`));59+ return;60+ }61+ if (typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {62+ for (const [key, entry] of Object.entries(value)) {63+ assertValueFreeState(entry, `${path}.${key}`);64+ }65+ return;66+ }67+ throw new TypeError(`${path} contains an unsupported value`);68+}69+70+export function artifactName(value: string): string {71+ const safe = value.toLowerCase().replaceAll(/[^a-z0-9._-]+/g, "-").replaceAll(/^-+|-+$/g, "");72+ return safe || "failure";73+}
A package/testing/safety_test.ts +77 -0
@@ -0,0 +1,77 @@
1+import { BrowserUnavailableError, rethrowBrowserLaunchError } from "./browser_error.ts";2+import { assertValueFreeState, redactDiagnosticText } from "./safety.ts";3+import { readTraceArchive, sanitizeTraceArchive, writeTraceArchive } from "./trace.ts";4+5+function assert(condition: unknown, message: string): asserts condition {6+ if (!condition) throw new Error(message);7+}8+9+Deno.test("value-free snapshots retain shape and reject values", () => {10+ assertValueFreeState({ online: false, itemCount: 2, clients: [true, null] });11+ for (12+ const invalid of [13+ { title: "private value" },14+ { value: Number.NaN },15+ new Date(),16+ ]17+ ) {18+ let rejected = false;19+ try {20+ assertValueFreeState(invalid);21+ } catch {22+ rejected = true;23+ }24+ assert(rejected, `unsafe state was accepted: ${String(invalid)}`);25+ }26+});27+28+Deno.test("missing Chromium reports the exact install command", () => {29+ const cause = new Error("browser executable doesn't exist");30+ try {31+ rethrowBrowserLaunchError(cause);32+ } catch (error) {33+ assert(error instanceof BrowserUnavailableError, `unexpected error: ${error}`);34+ assert(35+ error.message.includes("deno run -A npm:playwright@1.61.1 install chromium"),36+ error.message,37+ );38+ assert(error.cause === cause, "browser launch failure was not retained");39+ return;40+ }41+ throw new Error("missing browser error was not thrown");42+});43+44+Deno.test("diagnostic redaction strips URL, JSON and literal credentials", () => {45+ const input =46+ 'request https://user:pass@example.test/path?token=query#fragment {"authorization":"Bearer auth-value"} token=plain literal-value';47+ const output = redactDiagnosticText(input, ["literal-value"]);48+ assert(!output.includes("user:pass"), output);49+ assert(!output.includes("query"), output);50+ assert(!output.includes("fragment"), output);51+ assert(!output.includes("auth-value"), output);52+ assert(!output.includes("plain"), output);53+ assert(!output.includes("literal-value"), output);54+ assert(output.includes("https://example.test/path"), output);55+});56+57+Deno.test("trace archives are sanitized without losing their structure", async () => {58+ const directory = await Deno.makeTempDir({ dir: "." });59+ const path = `${directory}/trace.zip`;60+ try {61+ const trace =62+ '{"type":"console","text":"token=trace-secret","authorization":"Bearer auth-secret"}\n';63+ await Deno.writeFile(64+ path,65+ writeTraceArchive({ "trace.trace": new TextEncoder().encode(trace) }),66+ );67+ await sanitizeTraceArchive(path, (value) => redactDiagnosticText(value, ["trace-secret"]));68+ const entries = await readTraceArchive(path);69+ const result = new TextDecoder().decode(entries["trace.trace"]);70+ assert(!result.includes("trace-secret"), result);71+ assert(!result.includes("auth-secret"), result);72+ assert(result.includes("[redacted]"), result);73+ JSON.parse(result);74+ } finally {75+ await Deno.remove(directory, { recursive: true });76+ }77+});
A package/testing/trace.ts +135 -0
@@ -0,0 +1,135 @@
1+const LOCAL_FILE = 0x04034b50;2+const CENTRAL_FILE = 0x02014b50;3+const END_OF_CENTRAL_DIRECTORY = 0x06054b50;4+const UTF8 = new TextEncoder();5+const TEXT = new TextDecoder();6+7+function concatenate(parts: readonly Uint8Array[]): Uint8Array {8+ const length = parts.reduce((total, part) => total + part.length, 0);9+ const result = new Uint8Array(length);10+ let offset = 0;11+ for (const part of parts) {12+ result.set(part, offset);13+ offset += part.length;14+ }15+ return result;16+}17+18+function header(length: number): { bytes: Uint8Array; view: DataView } {19+ const bytes = new Uint8Array(length);20+ return { bytes, view: new DataView(bytes.buffer) };21+}22+23+function crc32(bytes: Uint8Array): number {24+ let crc = 0xffffffff;25+ for (const byte of bytes) {26+ crc ^= byte;27+ for (let bit = 0; bit < 8; bit++) {28+ crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);29+ }30+ }31+ return (crc ^ 0xffffffff) >>> 0;32+}33+34+function endOffset(bytes: Uint8Array): number {35+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);36+ for (let offset = bytes.length - 22; offset >= Math.max(0, bytes.length - 65_557); offset--) {37+ if (view.getUint32(offset, true) === END_OF_CENTRAL_DIRECTORY) return offset;38+ }39+ throw new Error("Playwright trace is not a ZIP archive");40+}41+42+async function inflateRaw(bytes: Uint8Array): Promise<Uint8Array> {43+ const input = new Blob([bytes.slice()]).stream();44+ const output = input.pipeThrough(new DecompressionStream("deflate-raw"));45+ return new Uint8Array(await new Response(output).arrayBuffer());46+}47+48+export async function readTraceArchive(path: string): Promise<Record<string, Uint8Array>> {49+ const bytes = await Deno.readFile(path);50+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);51+ const end = endOffset(bytes);52+ const count = view.getUint16(end + 10, true);53+ let centralOffset = view.getUint32(end + 16, true);54+ const entries: Record<string, Uint8Array> = {};55+56+ for (let index = 0; index < count; index++) {57+ if (view.getUint32(centralOffset, true) !== CENTRAL_FILE) {58+ throw new Error("Playwright trace has an invalid central directory");59+ }60+ const flags = view.getUint16(centralOffset + 8, true);61+ if (flags & 1) throw new Error("Encrypted trace entries cannot be sanitized");62+ const method = view.getUint16(centralOffset + 10, true);63+ const compressedSize = view.getUint32(centralOffset + 20, true);64+ const nameLength = view.getUint16(centralOffset + 28, true);65+ const extraLength = view.getUint16(centralOffset + 30, true);66+ const commentLength = view.getUint16(centralOffset + 32, true);67+ const localOffset = view.getUint32(centralOffset + 42, true);68+ const name = TEXT.decode(bytes.subarray(centralOffset + 46, centralOffset + 46 + nameLength));69+ if (view.getUint32(localOffset, true) !== LOCAL_FILE) {70+ throw new Error(`Playwright trace entry ${name} has an invalid local header`);71+ }72+ const localNameLength = view.getUint16(localOffset + 26, true);73+ const localExtraLength = view.getUint16(localOffset + 28, true);74+ const dataOffset = localOffset + 30 + localNameLength + localExtraLength;75+ const compressed = bytes.subarray(dataOffset, dataOffset + compressedSize);76+ if (method === 0) entries[name] = compressed.slice();77+ else if (method === 8) entries[name] = await inflateRaw(compressed);78+ else throw new Error(`Playwright trace entry ${name} uses unsupported compression ${method}`);79+ centralOffset += 46 + nameLength + extraLength + commentLength;80+ }81+ return entries;82+}83+84+export function writeTraceArchive(entries: Readonly<Record<string, Uint8Array>>): Uint8Array {85+ const localParts: Uint8Array[] = [];86+ const centralParts: Uint8Array[] = [];87+ let localOffset = 0;88+89+ for (const [name, contents] of Object.entries(entries)) {90+ const nameBytes = UTF8.encode(name);91+ const checksum = crc32(contents);92+ const local = header(30);93+ local.view.setUint32(0, LOCAL_FILE, true);94+ local.view.setUint16(4, 20, true);95+ local.view.setUint32(14, checksum, true);96+ local.view.setUint32(18, contents.length, true);97+ local.view.setUint32(22, contents.length, true);98+ local.view.setUint16(26, nameBytes.length, true);99+ localParts.push(local.bytes, nameBytes, contents);100+101+ const central = header(46);102+ central.view.setUint32(0, CENTRAL_FILE, true);103+ central.view.setUint16(4, 20, true);104+ central.view.setUint16(6, 20, true);105+ central.view.setUint32(16, checksum, true);106+ central.view.setUint32(20, contents.length, true);107+ central.view.setUint32(24, contents.length, true);108+ central.view.setUint16(28, nameBytes.length, true);109+ central.view.setUint32(42, localOffset, true);110+ centralParts.push(central.bytes, nameBytes);111+ localOffset += local.bytes.length + nameBytes.length + contents.length;112+ }113+114+ const central = concatenate(centralParts);115+ const end = header(22);116+ end.view.setUint32(0, END_OF_CENTRAL_DIRECTORY, true);117+ end.view.setUint16(8, Object.keys(entries).length, true);118+ end.view.setUint16(10, Object.keys(entries).length, true);119+ end.view.setUint32(12, central.length, true);120+ end.view.setUint32(16, localOffset, true);121+ return concatenate([...localParts, central, end.bytes]);122+}123+124+/** Sanitize every text entry in the intentionally snapshot-free trace archive. */125+export async function sanitizeTraceArchive(126+ path: string,127+ redact: (value: string) => string,128+): Promise<void> {129+ const entries = await readTraceArchive(path);130+ const sanitized: Record<string, Uint8Array> = {};131+ for (const [name, bytes] of Object.entries(entries)) {132+ sanitized[name] = UTF8.encode(redact(TEXT.decode(bytes)));133+ }134+ await Deno.writeFile(path, writeTraceArchive(sanitized));135+}