import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { test as base, expect, type Locator, type Page, type TestInfo } from "@playwright/test"; import { env, RUN_DIR } from "./env.ts"; import { Recorder } from "./recorder.ts"; export type Box = { x: number; y: number; width: number; height: number }; // `ignore` holds regions, in the still's own pixels, that comparisons skip. export type Shot = { name: string; file: string; caption: string; ignore?: Box[] }; export type FlowMeta = { slug: string; title: string; file: string; status: TestInfo["status"]; // Spooled frames waiting for the end-of-run encode; `video` replaces it once encoded. frames?: string; video?: string; shots: Shot[]; }; // Pages a signed-in flow must never land on: each means the session or the account setup is wrong. const GATES: [RegExp, string][] = [ [/^\/auth\//, "the sign-in page: the saved session was refused. Delete qa/.artifacts/auth and run again"], [/^\/onboarding/, "onboarding: the account has not finished setup. Delete qa/.artifacts/auth so global setup completes it"], [/^\/select-org/, "the workspace picker: the account belongs to several workspaces; open the one the flow needs first"], ]; // Dev-only chrome that would otherwise sit in every frame (the React Query devtools button). const HIDE_DEV_CHROME = `.tsqd-parent-container, .tsqd-open-btn-container { display: none !important; }`; export class Proof { readonly shots: Shot[] = []; readonly dir: string; private readonly page: Page; constructor(page: Page, dir: string) { this.page = page; this.dir = dir; } // A title card in the video, so a reviewer knows what the next few seconds show. async chapter(title: string, description?: string): Promise { await this.page.screencast.showChapter(title, { description, duration: 1600 }); } // A named full-resolution still. Keep names stable: follow-up comments diff stills by name. // `ignore` names content that changes on its own (relative times, live counters): the still is // published as is, and those regions are skipped when it is compared with the previous publish. async shot(name: string, opts: { caption?: string; target?: Locator; ignore?: Locator[] } = {}): Promise { if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) throw new Error(`shot name "${name}" must be kebab-case`); if (this.shots.some((s) => s.name === name)) throw new Error(`shot "${name}" taken twice in one flow`); const file = join(this.dir, "shots", `${name}.png`); const origin = opts.target ? await opts.target.boundingBox() : { x: 0, y: 0 }; const ignore: Box[] = []; for (const locator of opts.ignore ?? []) { for (const el of await locator.all()) { const b = await el.boundingBox(); if (b && origin) ignore.push({ x: Math.floor(b.x - origin.x), y: Math.floor(b.y - origin.y), width: Math.ceil(b.width) + 1, height: Math.ceil(b.height) + 1 }); } } const options = { path: file, animations: "disabled" as const, caret: "hide" as const }; await this.page.screencast.hideOverlays(); try { if (opts.target) await opts.target.screenshot(options); else await this.page.screenshot(options); } finally { await this.page.screencast.showOverlays(); } this.shots.push({ name, file, caption: opts.caption ?? name, ignore: ignore.length ? ignore : undefined }); } // Lets a result sit on screen long enough to be read in the video. async dwell(ms = 1200): Promise { await this.page.waitForTimeout(ms); } } export function slugOf(info: TestInfo): string { return info.titlePath .slice(1) .join(" ") .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, ""); } export type Seed = "rich" | "sandbox"; // The seed each stack mode loads; a stack the harness did not start ("external") is trusted as is. const SEED_OF_MODE: Record = { lite: "rich", full: "rich", sandbox: "sandbox" }; export const test = base.extend<{ proof: Proof; signedIn: boolean; seed: Seed }>({ // `test.use({ signedIn: false })` for a flow that starts on the sign-in pages. signedIn: [true, { option: true }], // `test.use({ seed: "sandbox" })` for a flow written against the Sunrise Labs sandbox data. seed: ["rich", { option: true }], storageState: async ({ signedIn, storageState }, use) => { await use(signedIn ? storageState : { cookies: [], origins: [] }); }, proof: [ async ({ page, signedIn, seed }, use, info) => { const loaded = SEED_OF_MODE[env.mode]; const fix = seed === "sandbox" ? "pnpm stack reset-data sandbox" : "pnpm stack reset-data lite"; if (loaded !== undefined && loaded !== seed) { const why = `written for the ${seed} seed, but the stack has the ${loaded} seed (${fix})`; console.log(`skipped "${info.title}": ${why}`); info.skip(true, why); } const slug = slugOf(info); const dir = join(RUN_DIR, slug); mkdirSync(join(dir, "shots"), { recursive: true }); await page.addInitScript((css) => { const add = () => document.head.appendChild(Object.assign(document.createElement("style"), { textContent: css })); if (document.head) add(); else document.addEventListener("DOMContentLoaded", add); }, HIDE_DEV_CHROME); const frames = join(dir, "frames"); const recorder = new Recorder(page, frames); await recorder.start(); await page.screencast.showActions({ cursor: "pointer", duration: 700, fontSize: 20, position: "top-right" }); const proof = new Proof(page, dir); await use(proof); const captured = await recorder.stop(); // A failed flow's video is never published, so its frames are not worth encoding unless asked for. const keep = captured && (info.status === "passed" || process.env.QA_VIDEO_ON_FAIL === "1"); if (!keep) rmSync(frames, { recursive: true, force: true }); const meta: FlowMeta = { slug, title: info.title, file: info.file, status: info.status, frames: keep ? frames : undefined, shots: proof.shots, }; writeFileSync(join(dir, "meta.json"), JSON.stringify(meta, null, 2)); const path = new URL(page.url()).pathname; const gate = signedIn ? GATES.find(([re]) => re.test(path)) : undefined; if (gate) throw new Error(`the dashboard sent this flow to ${gate[1]}.`); }, { auto: true }, ], }); export { expect };