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 "npm:playwright@1.61.1";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";1920type MemoryStorageState = Awaited<ReturnType<BrowserContext["storageState"]>>;21/**22 * Playwright context options with `storageState` forbidden, so identity never23 * leaves memory via an on-disk state file.24 */25export type SafeContextOptions = Omit<BrowserContextOptions, "storageState"> & {26 /** Identity state is managed in memory by the fixture. File paths are forbidden. */27 storageState?: never;28};2930/** Identifies which of the fixture's two clients a value belongs to. */31export type ClientName = "first" | "second";3233/** A redacted console, page-error, or failed-request record captured from a client. */34export interface BrowserDiagnostic {35 /** Which of the two clients produced this diagnostic. */36 readonly client: ClientName;37 /** What surfaced it: a console message, an uncaught page error, or a failed request. */38 readonly kind: "console" | "page-error" | "request-failed";39 /** Console severity (e.g. `"error"`, `"warning"`), when applicable. */40 readonly level?: string;41 /** The redacted message text. */42 readonly message: string;43 /** The associated URL, for `request-failed` records. */44 readonly url?: string;45}4647/**48 * How the two clients obtain identity: `shared` clones the primary's prepared49 * state (in memory) into the second client, `isolated` prepares each client on50 * its own.51 */52export type IdentityOptions =53 | {54 readonly mode: "shared";55 /** Runs before tracing starts. The resulting state is cloned only in memory. */56 readonly preparePrimary: (client: BrowserTestClient) => Promise<void>;57 }58 | {59 readonly mode: "isolated";60 /** Runs independently for each client before tracing starts. */61 readonly prepare?: (client: BrowserTestClient) => Promise<void>;62 };6364/** Configures where and how redacted failure artifacts are written on capture. */65export interface FailureArtifactOptions {66 /** Directory the redacted artifacts are written into on capture. */67 readonly directory: string;68 /** Literal credential values to remove from retained diagnostics. */69 readonly secretValues?: readonly string[];70 /** Locators containing private UI values that screenshots should mask. */71 readonly mask?: (client: BrowserTestClient) => readonly Locator[];72}7374/** Options for constructing a {@link TwoClientFixture} via {@link createTwoClientFixture}. */75export interface TwoClientFixtureOptions {76 /** The origin both clients open and enroll identity against. */77 readonly baseURL: string;78 /** How the two clients obtain identity (`shared` or `isolated`). */79 readonly identity: IdentityOptions;80 /** An existing browser to reuse; when omitted the fixture launches and owns one. */81 readonly browser?: Browser;82 /** Extra context options (a disk `storageState` is forbidden to keep identity in memory). */83 readonly context?: SafeContextOptions;84 /** Where and how to write redacted artifacts if the test fails. */85 readonly artifacts?: FailureArtifactOptions;86 /** Defaults to true. Tracing begins only after identity preparation. */87 readonly traceOnFailure?: boolean;88}8990/** Re-exported so callers can catch a missing-Chromium launch failure. */91export { BrowserUnavailableError };9293function validateContextOptions(options: SafeContextOptions | undefined): void {94 if (options && "storageState" in options) {95 throw new TypeError(96 "context.storageState is not accepted; use an identity mode so state stays in memory",97 );98 }99}100101/**102 * One browser client (context + page) whose identity and IndexedDB stay in103 * memory. Records redacted diagnostics and can go offline/online, restart, and104 * capture a sanitized trace.105 */106export class BrowserTestClient {107 readonly #diagnostics: BrowserDiagnostic[] = [];108 #context: BrowserContext;109 #page: Page;110 #offline = false;111 #recording = false;112 #closed = false;113114 /** Constructed by {@link createTwoClientFixture}; not intended for direct use. */115 constructor(116 readonly name: ClientName,117 context: BrowserContext,118 page: Page,119 readonly baseURL: string,120 private readonly createContext: (state?: MemoryStorageState) => Promise<BrowserContext>,121 private readonly redact: (value: string) => string,122 private readonly traceOnFailure: boolean,123 ) {124 this.#context = context;125 this.#page = page;126 }127128 /** The client's current Playwright browser context. */129 get context(): BrowserContext {130 return this.#context;131 }132133 /** The client's current Playwright page. */134 get page(): Page {135 return this.#page;136 }137138 /** Whether the client's network is currently forced offline. */139 get offline(): boolean {140 return this.#offline;141 }142143 /** Redacted diagnostics collected from the client's pages so far. */144 get diagnostics(): readonly BrowserDiagnostic[] {145 return this.#diagnostics;146 }147148 /** Attach diagnostic listeners and, if enabled, begin failure tracing. */149 async startRecording(): Promise<void> {150 if (this.#recording || this.#closed) return;151 this.#attachDiagnostics(this.#page);152 if (this.traceOnFailure) {153 await this.#context.tracing.start({ screenshots: false, snapshots: false, sources: false });154 }155 this.#recording = true;156 }157158 /** Force the client's network offline. */159 async goOffline(): Promise<void> {160 await this.#context.setOffline(true);161 this.#offline = true;162 }163164 /** Restore the client's network to online. */165 async goOnline(): Promise<void> {166 await this.#context.setOffline(false);167 this.#offline = false;168 }169170 /** Reload the current page without replacing its browser-side client state. */171 async reloadPage(): Promise<Page> {172 await this.#page.reload({ waitUntil: "domcontentloaded" });173 return this.#page;174 }175176 /** Close and recreate the page inside the same identity/context. */177 async restartPage(): Promise<Page> {178 const previous = this.#page;179 await previous.close();180 this.#page = await this.#context.newPage();181 this.#attachDiagnostics(this.#page);182 await this.#page.goto(this.baseURL, { waitUntil: "domcontentloaded" });183 return this.#page;184 }185186 /**187 * Restart the whole context, preserving identity and IndexedDB only in memory.188 * A new context starts online because service-worker/cache state is not part of189 * Playwright storageState; use restartPage for an offline PWA restart.190 */191 async restartClient(options: { preserveIdentity?: boolean } = {}): Promise<Page> {192 const preserveIdentity = options.preserveIdentity ?? true;193 let state: MemoryStorageState | undefined;194 if (preserveIdentity) {195 state = await this.#context.storageState({ indexedDB: true });196 }197 await this.#discardTrace();198 await this.#context.close();199 // createContext clones defensively; cloning again here doubled the work.200 this.#context = await this.createContext(state);201 state = undefined;202 this.#page = await this.#context.newPage();203 this.#offline = false;204 this.#recording = false;205 await this.startRecording();206 await this.#page.goto(this.baseURL, { waitUntil: "domcontentloaded" });207 return this.#page;208 }209210 /**211 * Stop tracing and write a sanitized trace archive to `path`. Returns false212 * when tracing was not active; removes the archive if sanitization fails.213 */214 async captureTrace(path: string): Promise<boolean> {215 if (!this.#recording || !this.traceOnFailure) return false;216 await this.#context.tracing.stop({ path });217 this.#recording = false;218 try {219 await sanitizeTraceArchive(path, this.redact);220 } catch (error) {221 await Deno.remove(path).catch(() => undefined);222 throw error;223 }224 return true;225 }226227 /** Discard any pending trace and close the client's context. Idempotent. */228 async close(): Promise<void> {229 if (this.#closed) return;230 this.#closed = true;231 await this.#discardTrace();232 await this.#context.close();233 }234235 #attachDiagnostics(page: Page): void {236 page.on("console", (message: ConsoleMessage) => {237 this.#diagnostics.push({238 client: this.name,239 kind: "console",240 level: message.type(),241 message: this.redact(message.text()),242 url: this.redact(message.location().url),243 });244 });245 page.on("pageerror", (error: Error) => {246 this.#diagnostics.push({247 client: this.name,248 kind: "page-error",249 message: this.redact(`${error.name}: ${error.message}`),250 });251 });252 page.on("requestfailed", (request: Request) => {253 this.#diagnostics.push({254 client: this.name,255 kind: "request-failed",256 message: this.redact(request.failure()?.errorText ?? "request failed"),257 url: this.redact(request.url()),258 });259 });260 }261262 async #discardTrace(): Promise<void> {263 if (!this.#recording || !this.traceOnFailure) return;264 await this.#context.tracing.stop().catch(() => undefined);265 this.#recording = false;266 }267}268269/** The directory and file paths produced by a failure capture. */270export interface FailureArtifacts {271 /** The directory the artifacts were written into. */272 readonly directory: string;273 /** Absolute paths of every file written (screenshots, traces, diagnostics). */274 readonly files: readonly string[];275}276277/**278 * Coordinates a pair of {@link BrowserTestClient}s sharing one browser, with279 * helpers to take both clients offline/online and to capture redacted,280 * value-free failure artifacts.281 */282export class TwoClientFixture {283 /** The fixture's two clients, ordered `[first, second]`. */284 readonly clients: readonly [BrowserTestClient, BrowserTestClient];285 #closed = false;286287 /** Constructed by {@link createTwoClientFixture}; not intended for direct use. */288 constructor(289 readonly browser: Browser,290 readonly ownsBrowser: boolean,291 first: BrowserTestClient,292 second: BrowserTestClient,293 private readonly artifacts: FailureArtifactOptions | undefined,294 ) {295 this.clients = [first, second];296 }297298 /** The first client. */299 get first(): BrowserTestClient {300 return this.clients[0];301 }302303 /** The second client. */304 get second(): BrowserTestClient {305 return this.clients[1];306 }307308 /** Take both clients offline. */309 async goOffline(): Promise<void> {310 await Promise.all(this.clients.map((client) => client.goOffline()));311 }312313 /** Restore both clients to online. */314 async goOnline(): Promise<void> {315 await Promise.all(this.clients.map((client) => client.goOnline()));316 }317318 /**319 * Write redacted screenshots, traces, diagnostics, and (optionally) a320 * value-free state-shape snapshot under a label-named directory. Returns321 * undefined when no artifact directory was configured.322 */323 async captureFailure(324 label: string,325 snapshot?: (client: BrowserTestClient) => Promise<ValueFreeState>,326 ): Promise<FailureArtifacts | undefined> {327 if (!this.artifacts) return undefined;328 const directory = `${this.artifacts.directory}/${artifactName(label)}`;329 await Deno.mkdir(directory, { recursive: true });330 const files: string[] = [];331332 for (const client of this.clients) {333 const screenshot = `${directory}/${client.name}.png`;334 await client.page.screenshot({335 path: screenshot,336 fullPage: true,337 mask: this.artifacts.mask ? [...this.artifacts.mask(client)] : undefined,338 });339 files.push(screenshot);340341 const trace = `${directory}/${client.name}.trace.zip`;342 if (await client.captureTrace(trace)) files.push(trace);343 }344345 const diagnostics = `${directory}/diagnostics.json`;346 await Deno.writeTextFile(347 diagnostics,348 `${JSON.stringify(this.clients.flatMap((client) => client.diagnostics), null, 2)}\n`,349 );350 files.push(diagnostics);351352 if (snapshot) {353 const states: Record<ClientName, ValueFreeState> = {354 first: await snapshot(this.first),355 second: await snapshot(this.second),356 };357 assertValueFreeState(states);358 const statePath = `${directory}/state-shape.json`;359 await Deno.writeTextFile(statePath, `${JSON.stringify(states, null, 2)}\n`);360 files.push(statePath);361 }362 return { directory, files };363 }364365 /**366 * Close both clients and, when the fixture owns it, the browser. Idempotent;367 * rethrows the first client close failure.368 */369 async close(): Promise<void> {370 if (this.#closed) return;371 this.#closed = true;372 const results = await Promise.allSettled(this.clients.map((client) => client.close()));373 if (this.ownsBrowser) await this.browser.close();374 const rejected = results.find((result) => result.status === "rejected");375 if (rejected?.status === "rejected") throw rejected.reason;376 }377}378379/**380 * Launch (or reuse) a browser and build a {@link TwoClientFixture}: opens both381 * clients at `baseURL`, applies the chosen identity mode with state kept in382 * memory, and starts recording. Cleans up on any setup failure.383 *384 * @example385 * ```ts386 * const fixture = await createTwoClientFixture({387 * baseURL,388 * identity: { mode: "shared", preparePrimary: (client) => ready(client) },389 * artifacts: { directory: "test-results" },390 * });391 * try {392 * await fixture.goOffline();393 * // ...apply edits on fixture.first and fixture.second, then converge...394 * } finally {395 * await fixture.close();396 * }397 * ```398 *399 * @param options The base URL, identity mode, and optional browser, context, and artifact settings.400 * @returns A ready fixture whose two clients are open at `baseURL` with identity applied.401 */402export async function createTwoClientFixture(403 options: TwoClientFixtureOptions,404): Promise<TwoClientFixture> {405 validateContextOptions(options.context);406 let browser = options.browser;407 const ownsBrowser = !browser;408 if (!browser) {409 try {410 browser = await chromium.launch({ headless: true });411 } catch (error) {412 rethrowBrowserLaunchError(error);413 }414 }415416 const contextOptions = { ...options.context, baseURL: options.baseURL };417 const createContext = (state?: MemoryStorageState) =>418 browser.newContext({419 ...contextOptions,420 storageState: state ? structuredClone(state) : undefined,421 });422 const redact = (value: string) =>423 redactDiagnosticText(value, options.artifacts?.secretValues ?? []);424 const traceOnFailure = options.traceOnFailure ?? true;425 let first: BrowserTestClient | undefined;426 let second: BrowserTestClient | undefined;427428 try {429 const firstContext = await createContext();430 const firstPage = await firstContext.newPage();431 first = new BrowserTestClient(432 "first",433 firstContext,434 firstPage,435 options.baseURL,436 createContext,437 redact,438 traceOnFailure,439 );440 await firstPage.goto(options.baseURL, { waitUntil: "domcontentloaded" });441442 let sharedState: MemoryStorageState | undefined;443 if (options.identity.mode === "shared") {444 await options.identity.preparePrimary(first);445 sharedState = await firstContext.storageState({ indexedDB: true });446 } else if (options.identity.prepare) {447 await options.identity.prepare(first);448 }449450 const secondContext = await createContext(sharedState);451 sharedState = undefined;452 const secondPage = await secondContext.newPage();453 second = new BrowserTestClient(454 "second",455 secondContext,456 secondPage,457 options.baseURL,458 createContext,459 redact,460 traceOnFailure,461 );462 await secondPage.goto(options.baseURL, { waitUntil: "domcontentloaded" });463 if (options.identity.mode === "isolated" && options.identity.prepare) {464 await options.identity.prepare(second);465 }466467 await Promise.all([first.startRecording(), second.startRecording()]);468 return new TwoClientFixture(browser, ownsBrowser, first, second, options.artifacts);469 } catch (error) {470 await Promise.allSettled([first?.close(), second?.close()].filter(Boolean));471 if (ownsBrowser) await browser.close().catch(() => undefined);472 throw error;473 }474}