clone

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

feat/184-task-notice-migration
lofi / package / testing / fixture.ts
fixture.ts 16.1K
1import {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 "playwright";11import { BrowserUnavailableError, rethrowBrowserLaunchError } from "./browser_error.ts";12import {13 artifactName,14 assertValueFreeState,15 redactDiagnosticText,16 type ValueFreeState,17} from "./safety.ts";18import { sanitizeTraceArchive } from "./trace.ts";1920/**21 * Playwright storage state held only in memory — cookies plus per-origin22 * `localStorage` and IndexedDB — used to clone a prepared identity into a23 * fresh browser context without an on-disk state file.24 */25export type MemoryStorageState = Awaited<ReturnType<BrowserContext["storageState"]>>;26/**27 * Playwright context options with `storageState` forbidden, so identity never28 * leaves memory via an on-disk state file.29 */30export type SafeContextOptions = Omit<BrowserContextOptions, "storageState"> & {31 /** Identity state is managed in memory by the fixture. File paths are forbidden. */32 storageState?: never;33};3435/** Identifies which of the fixture's two clients a value belongs to. */36export type ClientName = "first" | "second";3738/** A redacted console, page-error, or failed-request record captured from a client. */39export interface BrowserDiagnostic {40 /** Which of the two clients produced this diagnostic. */41 readonly client: ClientName;42 /** What surfaced it: a console message, an uncaught page error, or a failed request. */43 readonly kind: "console" | "page-error" | "request-failed";44 /** Console severity (e.g. `"error"`, `"warning"`), when applicable. */45 readonly level?: string;46 /** The redacted message text. */47 readonly message: string;48 /** The associated URL, for `request-failed` records. */49 readonly url?: string;50}5152/**53 * How the two clients obtain identity: `shared` clones the primary's prepared54 * state (in memory) into the second client, `isolated` prepares each client on55 * its own.56 */57export type IdentityOptions =58 | {59 readonly mode: "shared";60 /** Runs before tracing starts. The resulting state is cloned only in memory. */61 readonly preparePrimary: (client: BrowserTestClient) => Promise<void>;62 }63 | {64 readonly mode: "isolated";65 /** Runs independently for each client before tracing starts. */66 readonly prepare?: (client: BrowserTestClient) => Promise<void>;67 };6869/** Configures where and how redacted failure artifacts are written on capture. */70export interface FailureArtifactOptions {71 /** Directory the redacted artifacts are written into on capture. */72 readonly directory: string;73 /** Literal credential values to remove from retained diagnostics. */74 readonly secretValues?: readonly string[];75 /** Locators containing private UI values that screenshots should mask. */76 readonly mask?: (client: BrowserTestClient) => readonly Locator[];77}7879/** Options for constructing a {@link TwoClientFixture} via {@link createTwoClientFixture}. */80export interface TwoClientFixtureOptions {81 /** The origin both clients open and enroll identity against. */82 readonly baseURL: string;83 /** How the two clients obtain identity (`shared` or `isolated`). */84 readonly identity: IdentityOptions;85 /** An existing browser to reuse; when omitted the fixture launches and owns one. */86 readonly browser?: Browser;87 /** Extra context options (a disk `storageState` is forbidden to keep identity in memory). */88 readonly context?: SafeContextOptions;89 /** Where and how to write redacted artifacts if the test fails. */90 readonly artifacts?: FailureArtifactOptions;91 /** Defaults to true. Tracing begins only after identity preparation. */92 readonly traceOnFailure?: boolean;93}9495/** Re-exported so callers can catch a missing-Chromium launch failure. */96export { BrowserUnavailableError };9798function validateContextOptions(options: SafeContextOptions | undefined): void {99 if (options && "storageState" in options) {100 throw new TypeError(101 "context.storageState is not accepted; use an identity mode so state stays in memory",102 );103 }104}105106/**107 * One browser client (context + page) whose identity and IndexedDB stay in108 * memory. Records redacted diagnostics and can go offline/online, restart, and109 * capture a sanitized trace.110 */111export class BrowserTestClient {112 readonly #diagnostics: BrowserDiagnostic[] = [];113 #context: BrowserContext;114 #page: Page;115 #offline = false;116 #recording = false;117 #closed = false;118119 /** Constructed by {@link createTwoClientFixture}; not intended for direct use. */120 constructor(121 readonly name: ClientName,122 context: BrowserContext,123 page: Page,124 readonly baseURL: string,125 private readonly createContext: (state?: MemoryStorageState) => Promise<BrowserContext>,126 private readonly redact: (value: string) => string,127 private readonly traceOnFailure: boolean,128 ) {129 this.#context = context;130 this.#page = page;131 }132133 /** The client's current Playwright browser context. */134 get context(): BrowserContext {135 return this.#context;136 }137138 /** The client's current Playwright page. */139 get page(): Page {140 return this.#page;141 }142143 /** Whether the client's network is currently forced offline. */144 get offline(): boolean {145 return this.#offline;146 }147148 /** Redacted diagnostics collected from the client's pages so far. */149 get diagnostics(): readonly BrowserDiagnostic[] {150 return this.#diagnostics;151 }152153 /** Attach diagnostic listeners and, if enabled, begin failure tracing. */154 async startRecording(): Promise<void> {155 if (this.#recording || this.#closed) return;156 this.#attachDiagnostics(this.#page);157 if (this.traceOnFailure) {158 await this.#context.tracing.start({ screenshots: false, snapshots: false, sources: false });159 }160 this.#recording = true;161 }162163 /** Force the client's network offline. */164 async goOffline(): Promise<void> {165 await this.#context.setOffline(true);166 this.#offline = true;167 }168169 /** Restore the client's network to online. */170 async goOnline(): Promise<void> {171 await this.#context.setOffline(false);172 this.#offline = false;173 }174175 /** Reload the current page without replacing its browser-side client state. */176 async reloadPage(): Promise<Page> {177 await this.#page.reload({ waitUntil: "domcontentloaded" });178 return this.#page;179 }180181 /** Close and recreate the page inside the same identity/context. */182 async restartPage(): Promise<Page> {183 const previous = this.#page;184 await previous.close();185 this.#page = await this.#context.newPage();186 this.#attachDiagnostics(this.#page);187 await this.#page.goto(this.baseURL, { waitUntil: "domcontentloaded" });188 return this.#page;189 }190191 /**192 * Restart the whole context, preserving identity and IndexedDB only in memory.193 * A new context starts online because service-worker/cache state is not part of194 * Playwright storageState; use restartPage for an offline PWA restart.195 */196 async restartClient(options: { preserveIdentity?: boolean } = {}): Promise<Page> {197 const preserveIdentity = options.preserveIdentity ?? true;198 let state: MemoryStorageState | undefined;199 if (preserveIdentity) {200 state = await this.#context.storageState({ indexedDB: true });201 }202 await this.#discardTrace();203 await this.#context.close();204 // createContext clones defensively; cloning again here doubled the work.205 this.#context = await this.createContext(state);206 state = undefined;207 this.#page = await this.#context.newPage();208 this.#offline = false;209 this.#recording = false;210 await this.startRecording();211 await this.#page.goto(this.baseURL, { waitUntil: "domcontentloaded" });212 return this.#page;213 }214215 /**216 * Stop tracing and write a sanitized trace archive to `path`. Returns false217 * when tracing was not active; removes the archive if sanitization fails.218 */219 async captureTrace(path: string): Promise<boolean> {220 if (!this.#recording || !this.traceOnFailure) return false;221 await this.#context.tracing.stop({ path });222 this.#recording = false;223 try {224 await sanitizeTraceArchive(path, this.redact);225 } catch (error) {226 await Deno.remove(path).catch(() => undefined);227 throw error;228 }229 return true;230 }231232 /** Discard any pending trace and close the client's context. Idempotent. */233 async close(): Promise<void> {234 if (this.#closed) return;235 this.#closed = true;236 await this.#discardTrace();237 await this.#context.close();238 }239240 #attachDiagnostics(page: Page): void {241 page.on("console", (message: ConsoleMessage) => {242 this.#diagnostics.push({243 client: this.name,244 kind: "console",245 level: message.type(),246 message: this.redact(message.text()),247 url: this.redact(message.location().url),248 });249 });250 page.on("pageerror", (error: Error) => {251 this.#diagnostics.push({252 client: this.name,253 kind: "page-error",254 message: this.redact(`${error.name}: ${error.message}`),255 });256 });257 page.on("requestfailed", (request: Request) => {258 this.#diagnostics.push({259 client: this.name,260 kind: "request-failed",261 message: this.redact(request.failure()?.errorText ?? "request failed"),262 url: this.redact(request.url()),263 });264 });265 }266267 async #discardTrace(): Promise<void> {268 if (!this.#recording || !this.traceOnFailure) return;269 await this.#context.tracing.stop().catch(() => undefined);270 this.#recording = false;271 }272}273274/** The directory and file paths produced by a failure capture. */275export interface FailureArtifacts {276 /** The directory the artifacts were written into. */277 readonly directory: string;278 /** Absolute paths of every file written (screenshots, traces, diagnostics). */279 readonly files: readonly string[];280}281282/**283 * Coordinates a pair of {@link BrowserTestClient}s sharing one browser, with284 * helpers to take both clients offline/online and to capture redacted,285 * value-free failure artifacts.286 */287export class TwoClientFixture {288 /** The fixture's two clients, ordered `[first, second]`. */289 readonly clients: readonly [BrowserTestClient, BrowserTestClient];290 #closed = false;291292 /** Constructed by {@link createTwoClientFixture}; not intended for direct use. */293 constructor(294 readonly browser: Browser,295 readonly ownsBrowser: boolean,296 first: BrowserTestClient,297 second: BrowserTestClient,298 private readonly artifacts: FailureArtifactOptions | undefined,299 ) {300 this.clients = [first, second];301 }302303 /** The first client. */304 get first(): BrowserTestClient {305 return this.clients[0];306 }307308 /** The second client. */309 get second(): BrowserTestClient {310 return this.clients[1];311 }312313 /** Take both clients offline. */314 async goOffline(): Promise<void> {315 await Promise.all(this.clients.map((client) => client.goOffline()));316 }317318 /** Restore both clients to online. */319 async goOnline(): Promise<void> {320 await Promise.all(this.clients.map((client) => client.goOnline()));321 }322323 /**324 * Write redacted screenshots, traces, diagnostics, and (optionally) a325 * value-free state-shape snapshot under a label-named directory. Returns326 * undefined when no artifact directory was configured.327 */328 async captureFailure(329 label: string,330 snapshot?: (client: BrowserTestClient) => Promise<ValueFreeState>,331 ): Promise<FailureArtifacts | undefined> {332 if (!this.artifacts) return undefined;333 const directory = `${this.artifacts.directory}/${artifactName(label)}`;334 await Deno.mkdir(directory, { recursive: true });335 const files: string[] = [];336337 for (const client of this.clients) {338 const screenshot = `${directory}/${client.name}.png`;339 await client.page.screenshot({340 path: screenshot,341 fullPage: true,342 mask: this.artifacts.mask ? [...this.artifacts.mask(client)] : undefined,343 });344 files.push(screenshot);345346 const trace = `${directory}/${client.name}.trace.zip`;347 if (await client.captureTrace(trace)) files.push(trace);348 }349350 const diagnostics = `${directory}/diagnostics.json`;351 await Deno.writeTextFile(352 diagnostics,353 `${JSON.stringify(this.clients.flatMap((client) => client.diagnostics), null, 2)}\n`,354 );355 files.push(diagnostics);356357 if (snapshot) {358 const states: Record<ClientName, ValueFreeState> = {359 first: await snapshot(this.first),360 second: await snapshot(this.second),361 };362 assertValueFreeState(states);363 const statePath = `${directory}/state-shape.json`;364 await Deno.writeTextFile(statePath, `${JSON.stringify(states, null, 2)}\n`);365 files.push(statePath);366 }367 return { directory, files };368 }369370 /**371 * Close both clients and, when the fixture owns it, the browser. Idempotent;372 * rethrows the first client close failure.373 */374 async close(): Promise<void> {375 if (this.#closed) return;376 this.#closed = true;377 const results = await Promise.allSettled(this.clients.map((client) => client.close()));378 if (this.ownsBrowser) await this.browser.close();379 const rejected = results.find((result) => result.status === "rejected");380 if (rejected?.status === "rejected") throw rejected.reason;381 }382}383384/**385 * Launch (or reuse) a browser and build a {@link TwoClientFixture}: opens both386 * clients at `baseURL`, applies the chosen identity mode with state kept in387 * memory, and starts recording. Cleans up on any setup failure.388 *389 * @example390 * ```ts391 * const fixture = await createTwoClientFixture({392 * baseURL,393 * identity: { mode: "shared", preparePrimary: (client) => ready(client) },394 * artifacts: { directory: "test-results" },395 * });396 * try {397 * await fixture.goOffline();398 * // ...apply edits on fixture.first and fixture.second, then converge...399 * } finally {400 * await fixture.close();401 * }402 * ```403 *404 * @param options The base URL, identity mode, and optional browser, context, and artifact settings.405 * @returns A ready fixture whose two clients are open at `baseURL` with identity applied.406 */407export async function createTwoClientFixture(408 options: TwoClientFixtureOptions,409): Promise<TwoClientFixture> {410 validateContextOptions(options.context);411 let browser = options.browser;412 const ownsBrowser = !browser;413 if (!browser) {414 try {415 browser = await chromium.launch({ headless: true });416 } catch (error) {417 rethrowBrowserLaunchError(error);418 }419 }420421 const contextOptions = { ...options.context, baseURL: options.baseURL };422 const createContext = (state?: MemoryStorageState) =>423 browser.newContext({424 ...contextOptions,425 storageState: state ? structuredClone(state) : undefined,426 });427 const redact = (value: string) =>428 redactDiagnosticText(value, options.artifacts?.secretValues ?? []);429 const traceOnFailure = options.traceOnFailure ?? true;430 let first: BrowserTestClient | undefined;431 let second: BrowserTestClient | undefined;432433 try {434 const firstContext = await createContext();435 const firstPage = await firstContext.newPage();436 first = new BrowserTestClient(437 "first",438 firstContext,439 firstPage,440 options.baseURL,441 createContext,442 redact,443 traceOnFailure,444 );445 await firstPage.goto(options.baseURL, { waitUntil: "domcontentloaded" });446447 let sharedState: MemoryStorageState | undefined;448 if (options.identity.mode === "shared") {449 await options.identity.preparePrimary(first);450 sharedState = await firstContext.storageState({ indexedDB: true });451 } else if (options.identity.prepare) {452 await options.identity.prepare(first);453 }454455 const secondContext = await createContext(sharedState);456 sharedState = undefined;457 const secondPage = await secondContext.newPage();458 second = new BrowserTestClient(459 "second",460 secondContext,461 secondPage,462 options.baseURL,463 createContext,464 redact,465 traceOnFailure,466 );467 await secondPage.goto(options.baseURL, { waitUntil: "domcontentloaded" });468 if (options.identity.mode === "isolated" && options.identity.prepare) {469 await options.identity.prepare(second);470 }471472 await Promise.all([first.startRecording(), second.startRecording()]);473 return new TwoClientFixture(browser, ownsBrowser, first, second, options.artifacts);474 } catch (error) {475 await Promise.allSettled([first?.close(), second?.close()].filter(Boolean));476 if (ownsBrowser) await browser.close().catch(() => undefined);477 throw error;478 }479}