diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dceb5ee..19f0761 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,8 +19,11 @@ jobs: - run: pnpm typecheck - run: pnpm build - run: pnpm exec publint + - run: sudo apt-get update && sudo apt-get install -y ffmpeg - run: pnpm exec playwright install chromium --with-deps - run: pnpm test + - run: pnpm exec pkg-pr-new publish --packageManager=pnpm + if: github.event_name == 'pull_request' - uses: actions/upload-artifact@v5 if: failure() with: diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b3f1d11 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,11 @@ +# middlewright Agent Notes + +## Plugin Boundaries + +When plugins interact, preserve caller ergonomics and plugin ownership. Expose shared cross-cutting facts through neutral middleware context rather than coupling one plugin to another plugin's feature. + +For example, `spinnerWaiter` should own spinner-specific waiting and errors, while `videoMode` should own video highlighting, dead-air metadata, and ffmpeg output. If both need timing information, add or use neutral middleware context such as `ActionContext.timing`; do not make `spinnerWaiter` know about `videoMode.deadAir`. + +### Writing tests + +Guidance for *end-users* to write tests is in [./writing-middlewright-tests.md](./writing-middlewright-tests.md). In many cases we should follow this guidance too, but we may additionally want to validate the assumptions we ask end-users to make, so deviate from that guidance consciously, but not arbitrarily or unthinkingly. diff --git a/README.md b/README.md index 43832f1..4138c9f 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,11 @@ Ships with five plugins: | [`spinnerWaiter`](#spinnerwaiter) | If the app is visibly loading, wait longer for elements. If it isn't, fail fast. | [source](./src/plugins/spinner-waiter.ts) | | [`hydrationWaiter`](#hydrationwaiter) | Don't interact with the app until it's hydrated. | [source](./src/plugins/hydration-waiter.ts) | | [`uiErrorReporter`](#uierrorreporter) | When an action fails, append any visible error toasts to the error message. | [source](./src/plugins/ui-error-reporter.ts) | -| [`videoMode`](#videomode) | Highlight elements and pause before actions, so recorded videos are watchable. | [source](./src/plugins/video-mode.ts) | +| [`videoMode`](#videomode) | Record action/dead-air facts and render watchable annotated videos after the run. | [source](./src/plugins/video-mode.ts) | | [`llmRecover`](#llmrecover) | When an action fails, ask an LLM to write and run recovery code. Marks the test as soft-failed so nothing silently passes. | [source](./src/plugins/llm-recover.ts) | +When Playwright is launched with `--debug`, it sets `PWDEBUG=1`; the bundled plugins treat that as a hard debug-mode no-op. They still return plugin objects so your fixture can stay unchanged, but they do not wrap locator actions, wait for app state, recover failures, or write video artifacts. + `spinnerWaiter` is the best one. It makes your test pass fast, fail fast, and it incentivises agents to *improve* the product when tests fail, instead of bumping timeouts which makes tests worse and lets your product get away with bad UX. ## ⚠️ This is a hack @@ -130,18 +132,52 @@ uiErrorReporter({ selector: '[data-type="error"]' }); ### videoMode -For producing demo/debugging videos people can actually follow: outlines the element in gold, pauses before each action, and pauses after the test so the video doesn't cut off abruptly. Enable it conditionally (e.g. `!!process.env.VIDEO_MODE && videoMode()`) together with Playwright's `video: "on"` and a generous `actionTimeout`. +For producing demo/debugging videos people can actually follow: marks pre-action waiting as dead air, records action bounding boxes, and renders highlights/final holds into the video after the test run. Enable it conditionally (e.g. `!!process.env.VIDEO_MODE && videoMode()`) together with Playwright's `video: "on"` and a generous `actionTimeout`. + +```ts +await using page = await addPlugins({ + page: basePage, + testInfo, + plugins: [ + videoMode({ + highlight: { mode: "pointer", duration: 1000 }, + finalHold: 3000, + deadAirThreshold: 300, + skipMethods: ["waitFor"], + skipStackFrames: ["test-helpers.ts"], // don't annotate internal login/setup helpers + }), + ], +}); + +// Use page.videoMode for invisible setup/bookkeeping that should be marked as +// dead air instead of highlighted in video mode. +await page.videoMode.deadAir(async () => { + await page.goto("/login"); + await page.locator("#email").fill("demo@example.com"); +}); + +const videoPaths = page.videoMode.outputPaths(); +const videoMetadata = await page.videoMode.metadata(); +console.log(videoPaths.rendered); +console.log(videoMetadata.highlights.length); + +page.videoMode.setStartTime(); +await page.locator("#important-flow").click(); +page.videoMode.setEndTime(); +``` + +When Playwright video recording is enabled, `videoMode` saves `video-raw.webm`, uses `ffmpeg` to write `video-rendered.webm`, writes a sibling `video-mode.html` frame-stepper for inspecting both videos, and attaches all of them with `video-mode.json` to the test report. The frame-stepper stores its active video and frame in the URL, so links like `video-mode.html?active=rendered&frame=28` reopen the same frame. If `ffmpeg` or `ffprobe` is missing, the render step fails plainly so you know to install ffmpeg. + +`video-mode.json` records raw dead-air spans and highlight rectangles. `deadAirThreshold` is applied only when writing the rendered video: dead-air spans longer than the threshold are sped up so they render within that duration. Spans at or below the threshold are left at normal speed. `highlight` duration and `finalHold` are also applied at render time, so they do not slow down the browser test. `highlight: true` is equivalent to the default pointer mode, `{ mode: "pointer", duration: 1000 }`. For outline boxes, use a simple solid CSS-style string: ```ts videoMode({ - pauseBefore: 1000, - pauseAfterTest: 3000, - highlightStyle: "3px solid gold", - skipMethods: ["waitFor"], - skipStackFrames: ["test-helpers.ts"], // don't slow down internal login/setup helpers + highlight: { mode: "outline", style: "1px solid yellow" }, }); ``` +Put `spinnerWaiter` before `videoMode` when you use both. Spinner-waiter still owns spinner-specific waiting and errors, while video-mode records the preceding middleware wait as dead air and records the action target immediately before the action. + ### llmRecover The most fun one, and the most dangerous one. When an action fails, it captures a screenshot, the accessibility snapshot, the page HTML and the error, asks Claude to respond with a JavaScript recovery function, and `eval`s it with `{ page, locator, error }` in scope. Up to `maxAttempts` tries, with attempt history fed back to the model. @@ -216,9 +252,9 @@ export default defineConfig({ ## Writing your own plugin -**Writing your own plugins is the intended way to use this package.** The bundled five exist because they were useful for one particular app; your app has its own loading conventions, error surfaces, and flake patterns. Each bundled plugin is one small self-contained file — use them as inspiration: [spinner-waiter](./src/plugins/spinner-waiter.ts) (conditional waiting + error enrichment + runtime settings via `AsyncLocalStorage`), [hydration-waiter](./src/plugins/hydration-waiter.ts) (the simplest one — start here), [ui-error-reporter](./src/plugins/ui-error-reporter.ts) (catch/enrich/rethrow), [video-mode](./src/plugins/video-mode.ts) (page mutation around actions + lifecycle hooks), [llm-recover](./src/plugins/llm-recover.ts) (recovery loops, artifacts, soft assertions). The source also ships inside the npm package, so it's right there in `node_modules/middlewright/src`. +**Writing your own plugins is the intended way to use this package.** The bundled five exist because they were useful for one particular app; your app has its own loading conventions, error surfaces, and flake patterns. Each bundled plugin is one small self-contained file — use them as inspiration: [spinner-waiter](./src/plugins/spinner-waiter.ts) (conditional waiting + error enrichment + runtime settings via `AsyncLocalStorage`), [hydration-waiter](./src/plugins/hydration-waiter.ts) (the simplest one — start here), [ui-error-reporter](./src/plugins/ui-error-reporter.ts) (catch/enrich/rethrow), [video-mode](./src/plugins/video-mode.ts) (video annotations/artifacts + lifecycle hooks), [llm-recover](./src/plugins/llm-recover.ts) (recovery loops, artifacts, soft assertions). The source also ships inside the npm package, so it's right there in `node_modules/middlewright/src`. -A plugin is a name plus optional `middleware` and `testLifecycle` hooks: +A plugin is a name plus optional `middleware`, `testLifecycle`, and `pageExtension` hooks: ```ts import type { Plugin } from "middlewright"; @@ -249,9 +285,35 @@ export const slowActionLogger = (thresholdMs = 2000): Plugin => ({ }); ``` +Use `pageExtension` for explicit controls tests can call through the page returned from `addPlugins`: + +```ts +export const debugTools = (): Plugin<{ + debugTools: { + title(): string; + }; +}> => ({ + name: "debug-tools", + pageExtension: ({ testInfo }) => ({ + debugTools: { + title: () => testInfo.title, + }, + }), +}); + +await using page = await addPlugins({ + page: basePage, + testInfo, + plugins: [debugTools()], +}); + +expect(page.debugTools.title()).toBe(testInfo.title); +``` + Notes for plugin authors: - Middleware runs in registration order; the first plugin in the array is outermost. Error-enriching plugins (like `uiErrorReporter`) should generally be registered *before* the plugins whose errors they enrich, and recovery plugins (like `llmRecover`) first of all, so they see fully-enriched errors. +- Keep page extensions namespaced (`page.videoMode`, `page.debugTools`) so plugin controls do not collide with Playwright's own `Page` methods or other plugins. - Inside middleware, use the `_original` methods (`locator.waitFor_original(...)` etc. — see the `LocatorWithOriginal` type) when you need to perform locator actions *without* re-entering the middleware chain. - `adjustError(error, infoLines, filterFile?)` appends colored info lines to an error message and optionally scrubs your plugin's frames from the stack trace. diff --git a/package.json b/package.json index 86f2759..7175b0c 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "devDependencies": { "@playwright/test": "^1.57.0", "@types/node": "^24.10.9", + "pkg-pr-new": "^0.0.75", "publint": "^0.3.14", "typescript": "^5.9.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dcb582f..d7581f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,9 @@ importers: '@types/node': specifier: ^24.10.9 version: 24.13.2 + pkg-pr-new: + specifier: ^0.0.75 + version: 0.0.75 publint: specifier: ^0.3.14 version: 0.3.21 @@ -69,6 +72,10 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + pkg-pr-new@0.0.75: + resolution: {integrity: sha512-u9mdErTewKSMsr+ceCt8VcNuNP0ro5AXiPXhUVApuEyqr2Zlvt+DdCFBcm+yGWN8mhOdZJ27meIDbnoZgfzpOw==} + hasBin: true + playwright-core@1.60.0: resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} engines: {node: '>=18'} @@ -121,6 +128,8 @@ snapshots: picocolors@1.1.1: {} + pkg-pr-new@0.0.75: {} + playwright-core@1.60.0: {} playwright@1.60.0: diff --git a/spec/debug-mode.spec.ts b/spec/debug-mode.spec.ts new file mode 100644 index 0000000..c5dbf58 --- /dev/null +++ b/spec/debug-mode.spec.ts @@ -0,0 +1,96 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { expect, test } from "@playwright/test"; +import { + addPlugins, + hydrationWaiter, + llmRecover, + spinnerWaiter, + uiErrorReporter, + videoMode, +} from "../src/index.ts"; + +test("action middleware plugins are inert when PWDEBUG is set", async ({ page }, testInfo) => { + using _debug = withPwdebug(); + let recoveryCalls = 0; + + await using plugged = await addPlugins({ + page, + testInfo, + plugins: [ + llmRecover({ + requestRecoveryCode: async () => { + recoveryCalls += 1; + return null; + }, + }), + hydrationWaiter({ timeout: 200 }), + uiErrorReporter(), + spinnerWaiter({ spinnerTimeout: 3001 }), + ], + }); + await plugged.setContent(` +
hydrating forever
+
Exploded visibly
+ +
Loading...
+ `); + + const start = Date.now(); + const error = await plugged + .getByRole("button", { name: "Submit approval" }) + .click({ timeout: 100 }) + .catch((e: Error) => e); + + expect(Date.now() - start).toBeLessThan(500); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("Timeout 100ms exceeded"); + expect((error as Error).message).not.toContain("Error UI visible"); + expect((error as Error).message).not.toContain("If this is a slow operation"); + expect(recoveryCalls).toBe(0); +}); + +test("videoMode controls are inert when PWDEBUG is set", async ({ page }, testInfo) => { + using _debug = withPwdebug(); + + const plugged = await addPlugins({ + page, + testInfo, + plugins: [videoMode({ finalHold: 50, highlight: { mode: "pointer", duration: 20 } })], + }); + await plugged.setContent(``); + + plugged.videoMode.setStartTime(); + await plugged.videoMode.deadAir(async () => { + await plugged.waitForTimeout(20); + }); + await plugged.getByRole("button", { name: "press" }).click(); + plugged.videoMode.setEndTime(); + + await expect(plugged.videoMode.metadata()).resolves.toMatchObject({ + deadAir: [], + highlights: [], + outputs: {}, + sourceRange: {}, + }); + expect(plugged.videoMode.getVideoTimestamp()).toBe(0); + + await plugged[Symbol.asyncDispose](); + expect(existsSync(join(testInfo.outputDir, "video-mode.json"))).toBe(false); +}); + +function withPwdebug() { + const previous = process.env.PWDEBUG; + process.env.PWDEBUG = "1"; + + return { + [Symbol.dispose]: () => { + if (previous === undefined) { + delete process.env.PWDEBUG; + return; + } + + process.env.PWDEBUG = previous; + }, + }; +} diff --git a/spec/plugin-system.spec.ts b/spec/plugin-system.spec.ts index ac8747c..e7235fb 100644 --- a/spec/plugin-system.spec.ts +++ b/spec/plugin-system.spec.ts @@ -31,6 +31,33 @@ test("middleware wraps actions in registration order", async ({ page }, testInfo ]); }); +test("middleware can pass adjusted action args to later middleware", async ({ page }, testInfo) => { + let innerArgs: unknown[] = []; + await using plugged = await addPlugins({ + page, + testInfo, + plugins: [ + { + name: "rewrite-fill", + middleware: async (_ctx, next) => next(["rewritten"]), + }, + { + name: "inner-spy", + middleware: async (ctx, next) => { + innerArgs = ctx.args; + return next(); + }, + }, + ], + }); + await plugged.setContent(``); + + await plugged.locator("#name").fill("original"); + + expect(innerArgs).toEqual(["rewritten"]); + expect(await plugged.locator("#name").inputValue()).toBe("rewritten"); +}); + test("falsy entries in the plugins array are skipped", async ({ page }, testInfo) => { const calls: string[] = []; await using plugged = await addPlugins({ @@ -72,6 +99,69 @@ test("middleware receives testInfo", async ({ page }, testInfo) => { expect(seenTitle).toBe("middleware receives testInfo"); }); +test("middleware receives action timing", async ({ page }, testInfo) => { + let seenTiming: any; + await using plugged = await addPlugins({ + page, + testInfo, + plugins: [ + { + name: "timing-spy", + middleware: async (ctx, next) => { + seenTiming = ctx.timing; + return next(); + }, + }, + ], + }); + await plugged.setContent(``); + + await plugged.locator("button").click(); + + expect(seenTiming).toMatchObject({ + actionStartedAt: expect.any(Number), + attachedAt: expect.any(Number), + attachedAtStart: true, + middlewares: [ + expect.objectContaining({ + endedAt: expect.any(Number), + name: "timing-spy", + startedAt: expect.any(Number), + }), + ], + }); +}); + +test("plugins can expose typed controls on the plugged page", async ({ page }, testInfo) => { + const helper = { + name: "page-helper", + pageExtension: ({ page, testInfo }) => ({ + pageHelper: { + renderMessage: async (message: string) => { + await page.setContent(`
${message}
`); + }, + title: () => testInfo.title, + }, + }), + } satisfies Plugin<{ + pageHelper: { + renderMessage(message: string): Promise; + title(): string; + }; + }>; + + await using plugged = await addPlugins({ + page, + testInfo, + plugins: [helper], + }); + + await plugged.pageHelper.renderMessage("hello from a page extension"); + + await expect(plugged.locator("main")).toContainText("hello from a page extension"); + expect(plugged.pageHelper.title()).toBe("plugins can expose typed controls on the plugged page"); +}); + test("pages without plugins fall through to the original behavior", async ({ page, context, diff --git a/spec/spinner-waiter.spec.ts b/spec/spinner-waiter.spec.ts index 6833297..269f713 100644 --- a/spec/spinner-waiter.spec.ts +++ b/spec/spinner-waiter.spec.ts @@ -1,5 +1,5 @@ import { test as base, expect } from "@playwright/test"; -import { addPlugins, spinnerWaiter } from "../src/index.ts"; +import { addPlugins, spinnerWaiter, type Plugin } from "../src/index.ts"; const test = base.extend<{ slowMutationTimeout: number }>({ page: async ({ page }, use, testInfo) => { @@ -30,6 +30,27 @@ test("slow button succeeds when there's a spinner", async ({ page }) => { await page.getByText("work done").waitFor(); }); +test("visible disabled button succeeds when there's a spinner", async ({ page }) => { + await page.setContent(` + +
Processing approval...
+
+ + `); + + await page.getByRole("button", { name: "Submit approval" }).click(); + + await expect(page.locator("#result")).toContainText("approval submitted"); +}); + test("slow button fails without spinner waiter", async ({ page }) => { spinnerWaiter.settings.enterWith({ disabled: true }); await page.getByText("start work").click(); @@ -76,6 +97,36 @@ test("fails before a late spinner can make the no-spinner hint misleading", asyn expect(await page.locator('[aria-label="Loading"]').isVisible()).toBe(false); }); +base("no-spinner fast fail still runs later middleware", async ({ page }, testInfo) => { + const calls: string[] = []; + const afterSpinner: Plugin = { + name: "after-spinner", + middleware: async (_ctx, next) => { + calls.push("before"); + try { + return await next(); + } finally { + calls.push("after"); + } + }, + }; + await using plugged = await addPlugins({ + page, + testInfo, + plugins: [spinnerWaiter(), afterSpinner], + }); + await plugged.setContent(``); + + const error = await plugged + .getByRole("button", { name: "Submit approval" }) + .click() + .catch((e: Error) => e); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toMatch(/If this is a slow operation/); + expect(calls).toEqual(["before", "after"]); +}); + test("slow button fails when spinner times out", async ({ page }) => { await page.evaluate(() => Object.assign(window, { slowMutationTimeout: 6000 })); spinnerWaiter.settings.enterWith({ spinnerTimeout: 3001 }); diff --git a/spec/video-mode-ffmpeg.spec.ts b/spec/video-mode-ffmpeg.spec.ts new file mode 100644 index 0000000..09314cc --- /dev/null +++ b/spec/video-mode-ffmpeg.spec.ts @@ -0,0 +1,1316 @@ +import { execFile as execFileCallback } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFile, stat } from "node:fs/promises"; +import { extname } from "node:path"; +import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import { test, expect } from "@playwright/test"; +import { addPlugins, spinnerWaiter, videoMode } from "../src/index.ts"; +import type { Plugin } from "../src/index.ts"; + +const execFile = promisify(execFileCallback); + +test.use({ video: "on" }); + +test("writes a rendered video with dead air sped up and highlights added in post", async ({ + page, +}, testInfo) => { + await using plugged = await addPlugins({ + page, + testInfo, + plugins: [ + spinnerWaiter(), + videoMode({ + deadAirThreshold: 300, + finalHold: 700, + highlight: { mode: "pointer", duration: 1000 }, + }), + ], + }); + await plugged.setViewportSize({ width: 800, height: 600 }); + await plugged.setContent(` +
+
Loading...
+
+ + + + +
+
+
+ + `); + + await plugged.getByText("Start import").click(); + await plugged.getByText("Review records").click(); + await plugged.getByText("Approve import").click(); + await plugged.videoMode.deadAir(async () => { + await new Promise((resolve) => setTimeout(resolve, 1700)); + }); + await plugged.getByText("Download receipt").click(); + + await plugged.getByText("Receipt ready").waitFor(); + await expect(plugged.getByText("Receipt ready")).toContainText("Receipt ready"); +}); + +test("writes video-mode artifact files and report player", async ({ page }, testInfo) => { + const deadAirThresholdMs = 300; + const finalHoldMs = 500; + const highlightDurationMs = 600; + const video = videoMode({ + deadAirThreshold: deadAirThresholdMs, + finalHold: finalHoldMs, + highlight: { mode: "pointer", duration: highlightDurationMs }, + }); + { + await using plugged = await addPlugins({ + page, + testInfo, + plugins: [video], + }); + await plugged.setContent(` + +
+ + `); + + await plugged.videoMode.deadAir(async () => { + await new Promise((resolve) => setTimeout(resolve, 1200)); + }); + await plugged.locator("#save").click(); + await expect(plugged.locator("#status")).toContainText("saved"); + } + + const paths = video.outputPaths(); + const metadata = await video.metadata(); + expect(metadata).toMatchObject({ + outputs: { + player: "video-mode.html", + rendered: "video-rendered.webm", + raw: "video-raw.webm", + }, + }); + expect(metadata.deadAir.some((span) => span.end - span.start >= 1000)).toBe(true); + expect(metadata.highlights.length).toBeGreaterThanOrEqual(1); + + const rawStats = await stat(paths.raw); + const renderedStats = await stat(paths.rendered); + const playerStats = await stat(paths.player); + const reportPlayerStats = await stat(paths.reportPlayer); + + expect(rawStats.size).toBeGreaterThan(0); + expect(renderedStats.size).toBeGreaterThan(0); + expect(playerStats.size).toBeGreaterThan(0); + expect(reportPlayerStats.size).toBeGreaterThan(0); + await expect(readFile(paths.player, "utf8")).resolves.toContain('src="video-rendered.webm"'); + await expect(readFile(paths.player, "utf8")).resolves.toContain('src="video-raw.webm"'); + await expect(readFile(paths.player, "utf8")).resolves.toContain("
"); + await expect(readFile(paths.player, "utf8")).resolves.toContain( + 'data-active-key="rendered"', + ); + await expect(readFile(paths.player, "utf8")).resolves.toContain('data-active-key="raw"'); + await expect(readFile(paths.reportPlayer, "utf8")).resolves.toContain( + `src="${await playwrightReportAttachmentName(paths.rendered)}"`, + ); + await expect(readFile(paths.reportPlayer, "utf8")).resolves.toContain( + `src="${await playwrightReportAttachmentName(paths.raw)}"`, + ); + + const rawDuration = await videoDurationMs(paths.raw); + const renderedDuration = await videoDurationMs(paths.rendered); + const expectedRenderedDuration = + rawDuration - + compressedDeadAirSavings(metadata.deadAir, deadAirThresholdMs) + + metadata.highlights.reduce( + (duration: number, highlight: { end: number; start: number }) => + duration + highlight.end - highlight.start, + 0, + ) + + finalHoldMs; + + expect(renderedDuration).toBeLessThan(rawDuration + metadata.highlights.length * highlightDurationMs); + expect(Math.abs(renderedDuration - expectedRenderedDuration)).toBeLessThan(1500); + + const playerUrl = new URL(pathToFileURL(paths.player).href); + playerUrl.searchParams.set("active", "rendered"); + playerUrl.searchParams.set("frame", "2"); + const playerPage = await page.context().newPage(); + await playerPage.goto(playerUrl.href); + await expect(playerPage.locator("#active")).toHaveText("Rendered video"); + await expect(playerPage.locator("#frame")).toHaveText("2"); + await playerPage.keyboard.press("ArrowRight"); + await expect(playerPage.locator("#frame")).toHaveText("3"); + expect(new URL(playerPage.url()).searchParams.get("active")).toBe("rendered"); + expect(new URL(playerPage.url()).searchParams.get("frame")).toBe("3"); + await playerPage.close(); +}); + +test("keeps the page open for later afterTest hooks", async ({ page }, testInfo) => { + const afterTestEvents: string[] = []; + const afterVideoMode = { + name: "after-video-mode", + testLifecycle: (emitter) => { + return emitter.on("afterTest", async ({ page }) => { + afterTestEvents.push(page.isClosed() ? "closed" : "open"); + await expect(page.locator("#after-test-hook-target")).toContainText("ready"); + }); + }, + } satisfies Plugin; + const video = videoMode({ + finalHold: 0, + highlight: false, + }); + + { + await using plugged = await addPlugins({ + page, + testInfo, + plugins: [video, afterVideoMode], + }); + await plugged.setContent(`
ready
`); + } + + expect(afterTestEvents).toEqual(["open"]); +}); + +test("speeds dead air up instead of cutting through it", async ({ page }, testInfo) => { + const deadAirThresholdMs = 500; + const video = videoMode({ + deadAirThreshold: deadAirThresholdMs, + finalHold: 0, + highlight: { mode: "pointer", duration: 0 }, + }); + { + await using plugged = await addPlugins({ + page, + testInfo, + plugins: [video], + }); + await plugged.setViewportSize({ width: 400, height: 300 }); + await plugged.setContent(` + +
+ `); + + await plugged.videoMode.deadAir(async () => { + await plugged.evaluate(() => { + const box = document.querySelector("#progress") as HTMLElement; + const startedAt = performance.now(); + const duration = 1600; + const update = () => { + const progress = Math.min(1, (performance.now() - startedAt) / duration); + const red = Math.round(255 * (1 - progress)); + const blue = Math.round(255 * progress); + box.style.background = `rgb(${red}, 0, ${blue})`; + if (progress < 1) requestAnimationFrame(update); + }; + update(); + }); + await plugged.waitForTimeout(1600); + }); + } + + const metadata = await video.metadata(); + const paths = video.outputPaths(); + const [span] = metadata.deadAir.filter((candidate) => candidate.end - candidate.start >= 1400); + expect(span).toMatchObject({ + end: expect.any(Number), + start: expect.any(Number), + }); + + const sourceMidpoint = span.start + Math.round((span.end - span.start) / 2); + const renderedMidpoint = renderedTimestampForSourceTimestamp( + sourceMidpoint, + metadata.deadAir, + deadAirThresholdMs, + ); + const frame = await videoFrame(paths.rendered, renderedMidpoint); + const middleColor = averagePixel(frame, { x: 200, y: 150 }); + + expect(middleColor.red).toBeGreaterThan(80); + expect(middleColor.blue).toBeGreaterThan(80); +}); + +test("renders only the selected video source range", async ({ page }, testInfo) => { + const video = videoMode({ + finalHold: 0, + highlight: { mode: "pointer", duration: 0 }, + }); + { + await using plugged = await addPlugins({ + page, + testInfo, + plugins: [video], + }); + await plugged.setContent(` +
source range
+ `); + + await plugged.waitForTimeout(700); + plugged.videoMode.setStartTime(); + await plugged.waitForTimeout(1100); + plugged.videoMode.setEndTime(); + await plugged.waitForTimeout(700); + } + + const metadata = await video.metadata(); + expect(metadata.sourceRange).toMatchObject({ + end: expect.any(Number), + start: expect.any(Number), + }); + + const paths = video.outputPaths(); + const rawDuration = await videoDurationMs(paths.raw); + const renderedDuration = await videoDurationMs(paths.rendered); + const expectedRenderedDuration = metadata.sourceRange.end! - metadata.sourceRange.start!; + + expect(rawDuration).toBeGreaterThan(expectedRenderedDuration + 500); + expect(Math.abs(renderedDuration - expectedRenderedDuration)).toBeLessThan(700); +}); + +test("skips rendering an empty selected video source range", async ({ page }, testInfo) => { + const video = videoMode({ + finalHold: 0, + highlight: false, + }); + const originalWarn = console.warn; + const warnings: string[] = []; + console.warn = (...args) => { + warnings.push(args.map(String).join(" ")); + originalWarn(...args); + }; + + try { + { + await using plugged = await addPlugins({ + page, + testInfo, + plugins: [video], + }); + await plugged.setContent(` +
empty source range
+ `); + + plugged.videoMode.setStartTime(0); + plugged.videoMode.setEndTime(0); + await plugged.waitForTimeout(100); + } + + const metadata = await video.metadata(); + const paths = video.outputPaths(); + + expect(metadata).toMatchObject({ + outputs: { + player: "video-mode.html", + raw: "video-raw.webm", + }, + sourceRange: { + end: 0, + start: 0, + }, + }); + expect(metadata.outputs.rendered).toBeUndefined(); + expect(warnings).toEqual([ + expect.stringContaining("videoMode source range is empty: start 0ms must be before end 0ms"), + ]); + await expect(stat(paths.raw)).resolves.toMatchObject({ size: expect.any(Number) }); + await expect(stat(paths.rendered)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(readFile(paths.player, "utf8")).resolves.toContain('src="video-raw.webm"'); + await expect(readFile(paths.player, "utf8")).resolves.not.toContain('src="video-rendered.webm"'); + } finally { + console.warn = originalWarn; + } +}); + +test("renders calibrated highlight boxes on a paused pre-click frame", async ({ page }, testInfo) => { + const highlightDurationMs = 900; + const video = videoMode({ + finalHold: 0, + highlight: { mode: "outline", duration: highlightDurationMs, style: "8px solid yellow" }, + }); + { + await using plugged = await addPlugins({ + page, + testInfo, + plugins: [video], + }); + await plugged.setViewportSize({ width: 800, height: 600 }); + await plugged.setContent(` + +
+ `); + + await plugged.locator("#target").click(); + await expect(plugged.locator("#target")).toHaveCSS("background-color", "rgb(255, 0, 0)"); + await page.waitForTimeout(300); + } + + const paths = video.outputPaths(); + const metadata = await video.metadata(); + const [highlight] = metadata.highlights; + expect(highlight).toMatchObject({ + color: "yellow", + rect: { + height: 90, + width: 160, + x: 120, + y: 80, + }, + thickness: 8, + viewport: { + height: 600, + width: 800, + }, + }); + + const renderedPath = paths.rendered; + const pauseFrame = await videoFrame( + renderedPath, + highlight.start + Math.round(highlightDurationMs / 2), + ); + const afterClickFrame = await videoFrame( + renderedPath, + highlight.start + highlightDurationMs + 250, + ); + const expectedScale = Math.min( + pauseFrame.width / highlight.viewport.width, + pauseFrame.height / highlight.viewport.height, + ); + const expectedBox = { + height: Math.round(highlight.rect.height * expectedScale), + width: Math.round(highlight.rect.width * expectedScale), + x: Math.round(highlight.rect.x * expectedScale), + y: Math.round(highlight.rect.y * expectedScale), + }; + const yellowBox = yellowBoundingBox(pauseFrame); + + expect(yellowBox).toMatchObject({ + height: expect.closeTo(expectedBox.height, 4), + width: expect.closeTo(expectedBox.width, 4), + x: expect.closeTo(expectedBox.x, 3), + y: expect.closeTo(expectedBox.y, 3), + }); + + const pauseCenter = averagePixel(pauseFrame, centerOf(expectedBox)); + const afterClickCenter = averagePixel(afterClickFrame, centerOf(expectedBox)); + + expect(pauseCenter).toMatchObject({ + blue: expect.any(Number), + green: expect.any(Number), + red: expect.any(Number), + }); + expect(pauseCenter.blue).toBeGreaterThan(pauseCenter.red + 80); + expect(afterClickCenter.red).toBeGreaterThan(afterClickCenter.blue + 80); +}); + +test("hides the pointer cursor after the last highlighted action", async ({ page }, testInfo) => { + const highlightDurationMs = 700; + const video = videoMode({ + finalHold: 900, + highlight: { mode: "pointer", duration: highlightDurationMs }, + }); + { + await using plugged = await addPlugins({ + page, + testInfo, + plugins: [video], + }); + await plugged.setViewportSize({ width: 800, height: 600 }); + await plugged.setContent(` + +
+ `); + + await plugged.locator("#target").click(); + await expect(plugged.locator("#target")).toHaveClass("clicked"); + await page.waitForTimeout(300); + } + + const paths = video.outputPaths(); + const metadata = await video.metadata(); + const [highlight] = metadata.highlights; + expect(highlight).toBeDefined(); + + const renderedPath = paths.rendered; + const highlightStart = renderedHighlightStartWithoutDeadAir(highlight, metadata.highlights); + const clickHoldFrame = await videoFrame( + renderedPath, + highlightStart + highlightDurationMs - 100, + ); + const pointerTailFrame = await videoFrame( + renderedPath, + highlightStart + highlightDurationMs + 100, + ); + const finalHoldFrame = await videoFrame(renderedPath, (await videoDurationMs(renderedPath)) - 100); + const scale = Math.min( + clickHoldFrame.width / highlight.viewport.width, + clickHoldFrame.height / highlight.viewport.height, + ); + const targetBox = { + height: Math.round(highlight.rect.height * scale), + width: Math.round(highlight.rect.width * scale), + x: Math.round(highlight.rect.x * scale), + y: Math.round(highlight.rect.y * scale), + }; + + expect(cursorPixelCount(clickHoldFrame, targetBox)).toBeGreaterThan(40); + expect(cursorPixelCount(pointerTailFrame, targetBox)).toBeGreaterThan(40); + expect(cursorPixelCount(finalHoldFrame, targetBox)).toBeLessThan(10); +}); + +test("moves the pointer toward the first click after a waitFor", async ({ page }, testInfo) => { + const highlightDurationMs = 700; + const video = videoMode({ + finalHold: 0, + highlight: { mode: "pointer", duration: highlightDurationMs }, + }); + { + await using plugged = await addPlugins({ + page, + testInfo, + plugins: [video], + }); + await plugged.setViewportSize({ width: 800, height: 600 }); + await plugged.setContent(` + +
+ + + `); + + await plugged.locator("#ready").waitFor(); + await plugged.waitForTimeout(900); + await plugged.locator("#run").click(); + await expect(plugged.locator("body")).toHaveAttribute("data-clicked", "true"); + await page.waitForTimeout(200); + } + + const paths = video.outputPaths(); + const metadata = await video.metadata(); + const [highlight] = metadata.highlights; + expect(highlight).toBeDefined(); + + const renderedPath = paths.rendered; + const preClickFrame = await videoFrame(renderedPath, Math.max(100, highlight.start - 650)); + const clickHoldFrame = await videoFrame( + renderedPath, + highlight.start + highlightDurationMs - 100, + ); + const scale = Math.min( + preClickFrame.width / highlight.viewport.width, + preClickFrame.height / highlight.viewport.height, + ); + const runBox = { + height: Math.round(highlight.rect.height * scale), + width: Math.round(highlight.rect.width * scale), + x: Math.round(highlight.rect.x * scale), + y: Math.round(highlight.rect.y * scale), + }; + const fullFrame = { + height: preClickFrame.height, + width: preClickFrame.width, + x: 0, + y: 0, + }; + + expect(cursorPixelCount(preClickFrame, fullFrame)).toBeGreaterThan(20); + expect(cursorPixelCount(preClickFrame, runBox)).toBeLessThan(10); + expect(cursorPixelCount(clickHoldFrame, runBox)).toBeGreaterThan(40); +}); + +test("uses a normal pointer tail after text cursor holds", async ({ + page, +}, testInfo) => { + const highlightDurationMs = 1000; + const video = videoMode({ + finalHold: 0, + highlight: { mode: "pointer", duration: highlightDurationMs }, + }); + { + await using plugged = await addPlugins({ + page, + testInfo, + plugins: [video], + }); + await plugged.setViewportSize({ width: 800, height: 600 }); + await plugged.setContent(` + + + + `); + + await plugged.locator("#name").fill("Ada"); + await expect(plugged.locator("#name")).toHaveValue("Ada"); + await plugged.locator("#notes").type("notes"); + await expect(plugged.locator("#notes")).toHaveValue("notes"); + await page.waitForTimeout(200); + } + + const paths = video.outputPaths(); + const metadata = await video.metadata(); + const fillHighlight = metadata.highlights.find((highlight) => highlight.method === "fill")!; + const typeHighlight = metadata.highlights.find((highlight) => highlight.method === "type")!; + expect(fillHighlight).toBeDefined(); + expect(typeHighlight).toBeDefined(); + + const renderedPath = paths.rendered; + const fillStart = renderedHighlightStartWithoutDeadAir(fillHighlight, metadata.highlights); + const typeStart = renderedHighlightStartWithoutDeadAir(typeHighlight, metadata.highlights); + const fillTextFrame = await videoFrame(renderedPath, fillStart + 700); + const fillPointerTailFrame = await videoFrame(renderedPath, fillStart + highlightDurationMs - 100); + const typeTextFrame = await videoFrame(renderedPath, typeStart + 700); + const typePointerTailFrame = await videoFrame(renderedPath, typeStart + highlightDurationMs - 100); + const scale = Math.min( + fillTextFrame.width / fillHighlight.viewport.width, + fillTextFrame.height / fillHighlight.viewport.height, + ); + const fillBox = { + height: Math.round(fillHighlight.rect.height * scale), + width: Math.round(fillHighlight.rect.width * scale), + x: Math.round(fillHighlight.rect.x * scale), + y: Math.round(fillHighlight.rect.y * scale), + }; + const typeBox = { + height: Math.round(typeHighlight.rect.height * scale), + width: Math.round(typeHighlight.rect.width * scale), + x: Math.round(typeHighlight.rect.x * scale), + y: Math.round(typeHighlight.rect.y * scale), + }; + + expect(textCursorPixelCount(fillTextFrame, fillBox)).toBeGreaterThan(35); + expect(pointerTailPixelCount(fillTextFrame, fillBox)).toBeLessThan(10); + expect(textCursorTopCapPixelCount(fillPointerTailFrame, fillBox)).toBeLessThan(3); + expect(pointerTailPixelCount(fillPointerTailFrame, fillBox)).toBeGreaterThan(20); + expect(textCursorPixelCount(typeTextFrame, typeBox)).toBeGreaterThan(35); + expect(pointerTailPixelCount(typeTextFrame, typeBox)).toBeLessThan(10); + expect(textCursorTopCapPixelCount(typePointerTailFrame, typeBox)).toBeLessThan(3); + expect(pointerTailPixelCount(typePointerTailFrame, typeBox)).toBeGreaterThan(20); +}); + +test("does not replay action frames when a hold overlaps the next highlight", async ({ + page, +}, testInfo) => { + const highlightDurationMs = 1000; + const video = videoMode({ + finalHold: 0, + highlight: { mode: "pointer", duration: highlightDurationMs }, + }); + { + await using plugged = await addPlugins({ + page, + testInfo, + plugins: [video], + }); + await plugged.setViewportSize({ width: 800, height: 600 }); + await plugged.setContent(` + + + + + `); + + await plugged.locator("#name").fill("Ada"); + await expect(plugged.locator("body")).toHaveAttribute("data-transient-seen", "true"); + await expect(plugged.locator("body")).toHaveAttribute("data-phase", "stable"); + await plugged.locator("#run").click(); + await expect(plugged.locator("body")).toHaveAttribute("data-clicked", "true"); + await plugged.waitForTimeout(100); + } + + const paths = video.outputPaths(); + const metadata = await video.metadata(); + const fillHighlight = metadata.highlights.find((highlight) => highlight.method === "fill")!; + const clickHighlight = metadata.highlights.find((highlight) => highlight.method === "click")!; + expect(fillHighlight).toBeDefined(); + expect(clickHighlight).toBeDefined(); + expect(fillHighlight.end).toBeGreaterThan(clickHighlight.start); + + const fillStart = renderedHighlightStartWithoutDeadAir(fillHighlight, metadata.highlights); + const afterFillHoldFrame = await videoFrame( + paths.rendered, + fillStart + highlightDurationMs + 80, + ); + const center = averagePixel(afterFillHoldFrame, { + x: Math.round(afterFillHoldFrame.width / 2), + y: Math.round(afterFillHoldFrame.height / 2), + }); + + expect(center).toMatchObject({ + blue: expect.any(Number), + green: expect.any(Number), + red: expect.any(Number), + }); + expect(center.green).toBeGreaterThan(center.red + 80); +}); + +test("does not linger on the unhighlighted post-wait state before a following highlight", async ({ + page, +}, testInfo) => { + const video = videoMode({ + deadAirThreshold: 300, + finalHold: 0, + highlight: { mode: "outline", duration: 600, style: "10px solid yellow" }, + }); + { + await using plugged = await addPlugins({ + page, + testInfo, + plugins: [ + spinnerWaiter({ + spinnerSelectors: ['[data-spinner="true"]'], + spinnerTimeout: 6000, + }), + video, + ], + }); + await plugged.setViewportSize({ width: 800, height: 600 }); + await plugged.setContent(` + + + +
+
+ + `); + + await plugged.locator("#start").click(); + await plugged.locator("#next").click(); + await expect(plugged.locator("#done")).toContainText("done"); + await page.waitForTimeout(300); + } + + const paths = video.outputPaths(); + const metadata = await video.metadata(); + const nextHighlight = metadata.highlights.find((highlight) => highlight.rect.x > 300)!; + expect(nextHighlight).toBeDefined(); + + const renderedPath = paths.rendered; + const frames = await videoFrames(renderedPath); + const scale = Math.min( + frames[0].width / nextHighlight.viewport.width, + frames[0].height / nextHighlight.viewport.height, + ); + const nextBox = { + height: Math.round(nextHighlight.rect.height * scale), + width: Math.round(nextHighlight.rect.width * scale), + x: Math.round(nextHighlight.rect.x * scale), + y: Math.round(nextHighlight.rect.y * scale), + }; + const samples = frames.map((frame, index) => ({ + highlighted: hasYellow(frame, nextBox), + index, + ready: hasGreen(frame, inset(nextBox, 14)), + })); + const highlightStartFrame = samples.findIndex((sample) => sample.highlighted); + expect(highlightStartFrame).toBeGreaterThan(0); + + const unhighlightedReadyFrames = samples + .slice(Math.max(0, highlightStartFrame - 8), highlightStartFrame) + .filter((sample) => sample.ready && !sample.highlighted) + .map((sample) => sample.index); + + expect(unhighlightedReadyFrames.length).toBeLessThanOrEqual(2); + for (const index of unhighlightedReadyFrames) { + expect(index).toBeGreaterThanOrEqual(highlightStartFrame - 2); + } +}); + +const videoDurationMs = async (path: string) => { + const { stdout } = await execFile( + "ffprobe", + ["-v", "error", "-show_entries", "format=duration", "-of", "default=nokey=1:noprint_wrappers=1", path], + { maxBuffer: 1024 * 1024 }, + ); + + return Math.round(Number(stdout.trim()) * 1000); +}; + +type VideoFrame = { + data: Buffer; + height: number; + width: number; +}; + +type VideoSpan = { + end: number; + start: number; +}; + +const playwrightReportAttachmentName = async (path: string) => { + const data = await readFile(path); + return `${createHash("sha1").update(data).digest("hex")}${extname(path)}`; +}; + +const compressedDeadAirSavings = (deadAir: VideoSpan[], thresholdMs: number) => { + return deadAir.reduce((savedDuration, span) => { + const duration = span.end - span.start; + return savedDuration + Math.max(0, duration - thresholdMs); + }, 0); +}; + +const renderedTimestampForSourceTimestamp = ( + sourceTimestamp: number, + deadAir: VideoSpan[], + thresholdMs: number, +) => { + return sourceTimestamp - deadAir.reduce((savedDuration, span) => { + if (sourceTimestamp <= span.start) { + return savedDuration; + } + + const duration = span.end - span.start; + if (duration <= thresholdMs) { + return savedDuration; + } + + const sourceDurationInSpan = Math.min(sourceTimestamp, span.end) - span.start; + const renderedDurationInSpan = sourceDurationInSpan * (thresholdMs / duration); + return savedDuration + sourceDurationInSpan - renderedDurationInSpan; + }, 0); +}; + +const renderedHighlightStartWithoutDeadAir = ( + highlight: { start: number }, + highlights: { end: number; start: number }[], +) => { + return ( + highlight.start + + highlights + .filter((candidate) => candidate.start < highlight.start) + .reduce((duration, candidate) => duration + candidate.end - candidate.start, 0) + ); +}; + +const videoInfo = async (path: string) => { + const { stdout } = await execFile( + "ffprobe", + [ + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=width,height", + "-of", + "json", + path, + ], + { maxBuffer: 1024 * 1024 }, + ); + const payload = JSON.parse(stdout); + const [stream] = payload.streams; + + return { + height: Number(stream.height), + width: Number(stream.width), + }; +}; + +const videoFrame = async (path: string, timestampMs: number): Promise => { + const info = await videoInfo(path); + const { stdout } = await execFile( + "ffmpeg", + [ + "-hide_banner", + "-loglevel", + "error", + "-ss", + String(Math.max(0, timestampMs) / 1000), + "-i", + path, + "-frames:v", + "1", + "-f", + "rawvideo", + "-pix_fmt", + "rgb24", + "pipe:1", + ], + { + encoding: "buffer", + maxBuffer: info.width * info.height * 3 + 1024, + }, + ); + + return { + data: stdout as Buffer, + height: info.height, + width: info.width, + }; +}; + +const videoFrames = async (path: string): Promise => { + const info = await videoInfo(path); + const { stdout } = await execFile( + "ffmpeg", + [ + "-hide_banner", + "-loglevel", + "error", + "-i", + path, + "-vf", + "fps=25", + "-f", + "rawvideo", + "-pix_fmt", + "rgb24", + "pipe:1", + ], + { + encoding: "buffer", + maxBuffer: 128 * 1024 * 1024, + }, + ); + const frameSize = info.width * info.height * 3; + const frames: VideoFrame[] = []; + + for (let offset = 0; offset + frameSize <= stdout.length; offset += frameSize) { + frames.push({ + data: (stdout as Buffer).subarray(offset, offset + frameSize), + height: info.height, + width: info.width, + }); + } + + return frames; +}; + +const yellowBoundingBox = (frame: VideoFrame) => { + let minX = frame.width; + let minY = frame.height; + let maxX = -1; + let maxY = -1; + + for (let y = 0; y < frame.height; y += 1) { + for (let x = 0; x < frame.width; x += 1) { + const offset = (y * frame.width + x) * 3; + const red = frame.data[offset]; + const green = frame.data[offset + 1]; + const blue = frame.data[offset + 2]; + + if (red > 180 && green > 160 && blue < 100) { + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + } + + expect(maxX).toBeGreaterThanOrEqual(0); + + return { + height: maxY - minY + 1, + width: maxX - minX + 1, + x: minX, + y: minY, + }; +}; + +const centerOf = (rect: { height: number; width: number; x: number; y: number }) => ({ + x: rect.x + Math.round(rect.width / 2), + y: rect.y + Math.round(rect.height / 2), +}); + +const averagePixel = (frame: VideoFrame, point: { x: number; y: number }) => { + const radius = 3; + let red = 0; + let green = 0; + let blue = 0; + let count = 0; + + for (let y = point.y - radius; y <= point.y + radius; y += 1) { + for (let x = point.x - radius; x <= point.x + radius; x += 1) { + if (x < 0 || y < 0 || x >= frame.width || y >= frame.height) { + continue; + } + + const offset = (y * frame.width + x) * 3; + red += frame.data[offset]; + green += frame.data[offset + 1]; + blue += frame.data[offset + 2]; + count += 1; + } + } + + return { + blue: Math.round(blue / count), + green: Math.round(green / count), + red: Math.round(red / count), + }; +}; + +const inset = (rect: { height: number; width: number; x: number; y: number }, amount: number) => ({ + height: Math.max(1, rect.height - amount * 2), + width: Math.max(1, rect.width - amount * 2), + x: rect.x + amount, + y: rect.y + amount, +}); + +const hasYellow = ( + frame: VideoFrame, + rect: { height: number; width: number; x: number; y: number }, +) => { + return countPixels(frame, rect, ({ blue, green, red }) => red > 180 && green > 160 && blue < 100) > 20; +}; + +const hasGreen = ( + frame: VideoFrame, + rect: { height: number; width: number; x: number; y: number }, +) => { + return countPixels(frame, rect, ({ blue, green, red }) => green > 120 && red < 80 && blue < 80) > 200; +}; + +const cursorPixelCount = ( + frame: VideoFrame, + rect: { height: number; width: number; x: number; y: number }, +) => { + return countPixels(frame, inset(rect, 12), ({ blue, green, red }) => { + const nearlyWhite = red > 230 && green > 230 && blue > 230; + const nearlyBlack = red < 35 && green < 35 && blue < 35; + return nearlyWhite || nearlyBlack; + }); +}; + +const textCursorPixelCount = ( + frame: VideoFrame, + rect: { height: number; width: number; x: number; y: number }, +) => { + const center = centerOf(rect); + + return countPixels( + frame, + { + height: 36, + width: 10, + x: center.x - 5, + y: center.y - 18, + }, + ({ blue, green, red }) => { + const nearlyWhite = red > 230 && green > 230 && blue > 230; + const nearlyBlack = red < 35 && green < 35 && blue < 35; + return nearlyWhite || nearlyBlack; + }, + ); +}; + +const textCursorTopCapPixelCount = ( + frame: VideoFrame, + rect: { height: number; width: number; x: number; y: number }, +) => { + const center = centerOf(rect); + + return blackOrWhitePixelCount(frame, { + height: 6, + width: 20, + x: center.x - 10, + y: center.y - 14, + }); +}; + +const pointerTailPixelCount = ( + frame: VideoFrame, + rect: { height: number; width: number; x: number; y: number }, +) => { + const center = centerOf(rect); + + return blackOrWhitePixelCount(frame, { + height: 36, + width: 6, + x: center.x + 8, + y: center.y - 18, + }); +}; + +const blackOrWhitePixelCount = ( + frame: VideoFrame, + rect: { height: number; width: number; x: number; y: number }, +) => { + return countPixels(frame, rect, ({ blue, green, red }) => { + const nearlyWhite = red > 230 && green > 230 && blue > 230; + const nearlyBlack = red < 35 && green < 35 && blue < 35; + return nearlyWhite || nearlyBlack; + }); +}; + +const countPixels = ( + frame: VideoFrame, + rect: { height: number; width: number; x: number; y: number }, + predicate: (pixel: { blue: number; green: number; red: number }) => boolean, +) => { + let count = 0; + const startX = Math.max(0, rect.x); + const endX = Math.min(frame.width, rect.x + rect.width); + const startY = Math.max(0, rect.y); + const endY = Math.min(frame.height, rect.y + rect.height); + + for (let y = startY; y < endY; y += 1) { + for (let x = startX; x < endX; x += 1) { + const offset = (y * frame.width + x) * 3; + if ( + predicate({ + blue: frame.data[offset + 2], + green: frame.data[offset + 1], + red: frame.data[offset], + }) + ) { + count += 1; + } + } + } + + return count; +}; diff --git a/spec/video-mode.spec.ts b/spec/video-mode.spec.ts index 740c21d..229141a 100644 --- a/spec/video-mode.spec.ts +++ b/spec/video-mode.spec.ts @@ -1,13 +1,14 @@ +import { join } from "node:path"; import { test, expect } from "@playwright/test"; import { addPlugins, videoMode } from "../src/index.ts"; -test("highlights the element while the action runs, then cleans up", async ({ +test("records highlight metadata without mutating element styles", async ({ page, }, testInfo) => { await using plugged = await addPlugins({ page, testInfo, - plugins: [videoMode({ pauseBefore: 300, pauseAfterTest: 50 })], + plugins: [videoMode({ finalHold: 50, highlight: { mode: "pointer", duration: 300 } })], }); await plugged.setContent(` @@ -21,25 +22,291 @@ test("highlights the element while the action runs, then cleans up", async ({ `); + const start = Date.now(); await plugged.locator("#btn").click(); - await expect(plugged.locator("#result")).toContainText("outline: 3px solid gold"); - // Cleanup is fire-and-forget, so poll until the highlight is gone - await expect - .poll(() => plugged.locator("#btn").getAttribute("style")) - .not.toContain("gold"); + expect(Date.now() - start).toBeLessThan(1000); + await expect(plugged.locator("#result")).toContainText("(no style)"); + await expect(plugged.videoMode.metadata()).resolves.toMatchObject({ + highlights: expect.arrayContaining([ + expect.objectContaining({ + color: "gold", + end: expect.any(Number), + rect: expect.objectContaining({ + height: expect.any(Number), + width: expect.any(Number), + x: expect.any(Number), + y: expect.any(Number), + }), + start: expect.any(Number), + thickness: 3, + viewport: expect.objectContaining({ + height: expect.any(Number), + width: expect.any(Number), + }), + }), + ]), + }); }); -test("skipped methods are not highlighted or slowed down", async ({ page }, testInfo) => { +test("skipped methods are not highlighted", async ({ page }, testInfo) => { + const video = videoMode({ + finalHold: 50, + highlight: { mode: "pointer", duration: 5000 }, + skipMethods: ["click"], + }); await using plugged = await addPlugins({ page, testInfo, - plugins: [videoMode({ pauseBefore: 5000, pauseAfterTest: 50, skipMethods: ["click"] })], + plugins: [video], }); - await plugged.setContent(``); + await plugged.setContent(` + + `); const start = Date.now(); await plugged.locator("#btn").click(); - // A 5s pauseBefore would blow way past this if click weren't skipped expect(Date.now() - start).toBeLessThan(2000); + const metadata = await video.metadata(); + expect(metadata.deadAir.some((span) => span.end - span.start >= 100)).toBe(true); + expect(metadata.highlights).toEqual([]); +}); + +test("marks pre-action waits for attachment as dead air", async ({ page }, testInfo) => { + const video = videoMode({ finalHold: 50, highlight: { mode: "pointer", duration: 20 } }); + await using plugged = await addPlugins({ + page, + testInfo, + plugins: [video], + }); + await plugged.setContent(` +
+ + `); + + await plugged.locator("#late").click(); + + await expect(plugged.locator("#result")).toContainText("clicked"); + const metadata = await video.metadata(); + expect(metadata.deadAir).toContainEqual( + expect.objectContaining({ + end: expect.any(Number), + start: expect.any(Number), + }), + ); + expect(metadata.deadAir.some((span) => span.end - span.start >= 100)).toBe(true); +}); + +test("pre-action attached waits honor action timeout", async ({ page }, testInfo) => { + await using plugged = await addPlugins({ + page, + testInfo, + plugins: [videoMode({ finalHold: 50, highlight: { mode: "pointer", duration: 20 } })], + }); + await plugged.setContent(` + + `); + + const start = Date.now(); + const error = await plugged.locator("#late").click({ timeout: 100 }).catch((e: Error) => e); + + expect(Date.now() - start).toBeLessThan(250); + expect(error).toBeInstanceOf(Error); + expect(String(error)).toContain("Timeout 100ms exceeded"); +}); + +test("marks explicit attached waitFor calls as dead air", async ({ page }, testInfo) => { + const video = videoMode({ finalHold: 50, highlight: { mode: "pointer", duration: 20 } }); + await using plugged = await addPlugins({ + page, + testInfo, + plugins: [video], + }); + await plugged.setContent(` + + `); + + await plugged.locator("#late").waitFor({ state: "attached" }); + + expect((await video.metadata()).deadAir.some((span) => span.end - span.start >= 100)).toBe(true); +}); + +test("marks default visible waitFor calls as dead air", async ({ page }, testInfo) => { + const video = videoMode({ finalHold: 50, highlight: { mode: "pointer", duration: 20 } }); + await using plugged = await addPlugins({ + page, + testInfo, + plugins: [video], + }); + await plugged.setContent(` + + + `); + + await plugged.locator("#ready").waitFor(); + + expect((await video.metadata()).deadAir.some((span) => span.end - span.start >= 100)).toBe(true); +}); + +test("marks explicit visible waitFor calls as dead air", async ({ page }, testInfo) => { + const video = videoMode({ finalHold: 50, highlight: { mode: "pointer", duration: 20 } }); + await using plugged = await addPlugins({ + page, + testInfo, + plugins: [video], + }); + await plugged.setContent(` + + `); + + await plugged.locator("#late").waitFor({ state: "visible" }); + + expect((await video.metadata()).deadAir.some((span) => span.end - span.start >= 100)).toBe(true); +}); + +test("marks attached actionability waits as dead air", async ({ page }, testInfo) => { + const video = videoMode({ finalHold: 50, highlight: { mode: "pointer", duration: 20 } }); + await using plugged = await addPlugins({ + page, + testInfo, + plugins: [video], + }); + await plugged.setContent(` + +
+ + `); + + await plugged.locator("#ready").click(); + + await expect(plugged.locator("#result")).toContainText("clicked"); + expect((await video.metadata()).deadAir.some((span) => span.end - span.start >= 100)).toBe(true); +}); + +test("sets video source range from current timestamps", async ({ page }, testInfo) => { + const video = videoMode({ finalHold: 50, highlight: { mode: "pointer", duration: 20 } }); + await using plugged = await addPlugins({ + page, + testInfo, + plugins: [video], + }); + + const startBefore = plugged.videoMode.getVideoTimestamp(); + plugged.videoMode.setStartTime(); + const startAfter = plugged.videoMode.getVideoTimestamp(); + await plugged.waitForTimeout(20); + const endBefore = plugged.videoMode.getVideoTimestamp(); + plugged.videoMode.setEndTime(); + const endAfter = plugged.videoMode.getVideoTimestamp(); + + const metadata = await plugged.videoMode.metadata(); + expect(metadata).toMatchObject({ + sourceRange: { + end: expect.any(Number), + start: expect.any(Number), + }, + }); + expect(metadata.sourceRange.start).toBeGreaterThanOrEqual(startBefore); + expect(metadata.sourceRange.start).toBeLessThanOrEqual(startAfter); + expect(metadata.sourceRange.end).toBeGreaterThanOrEqual(endBefore); + expect(metadata.sourceRange.end).toBeLessThanOrEqual(endAfter); +}); + +test("deadAir runs actions without video highlighting and records metadata", async ({ + page, +}, testInfo) => { + const video = videoMode({ finalHold: 50, highlight: { mode: "pointer", duration: 5000 } }); + { + await using plugged = await addPlugins({ + page, + testInfo, + plugins: [video], + }); + + await plugged.setContent(` + +
+ + `); + + const start = Date.now(); + const videoTimestamp = plugged.videoMode.getVideoTimestamp(); + await plugged.videoMode.deadAir(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + await plugged.locator("#btn").click(); + }); + + expect(Date.now() - start).toBeLessThan(2000); + expect(plugged.videoMode.getVideoTimestamp()).toBeGreaterThanOrEqual(videoTimestamp); + await expect(plugged.locator("#result")).toContainText("(no style)"); + await expect(plugged.videoMode.metadata()).resolves.toMatchObject({ + outputs: {}, + schemaVersion: 1, + timebase: "ms", + }); + expect((await plugged.videoMode.metadata()).deadAir).toContainEqual( + expect.objectContaining({ end: expect.any(Number), start: expect.any(Number) }), + ); + expect((await plugged.videoMode.metadata()).highlights).toEqual([]); + } + + const paths = video.outputPaths(); + expect(paths.metadata).toBe(join(testInfo.outputDir, "video-mode.json")); + expect(paths.player).toBe(join(testInfo.outputDir, "video-mode.html")); + expect(paths.raw).toBe(join(testInfo.outputDir, "video-raw.webm")); + expect(paths.rendered).toBe(join(testInfo.outputDir, "video-rendered.webm")); + expect(paths.reportPlayer).toBe(join(testInfo.outputDir, "video-mode-report.html")); + + const metadata = await video.metadata(); + expect(metadata).toMatchObject({ + highlights: [], + outputs: {}, + schemaVersion: 1, + timebase: "ms", + }); + expect(metadata.deadAir).toContainEqual( + expect.objectContaining({ end: expect.any(Number), start: expect.any(Number) }), + ); + expect(metadata.deadAir[0].end).toBeGreaterThan(metadata.deadAir[0].start); }); diff --git a/src/index.ts b/src/index.ts index 884601f..7b88cf4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,8 +4,11 @@ export { oneArgMethods, overrideableMethods, type Plugin, + type PageExtensionContext, type ActionContext, type ActionMiddleware, + type ActionMiddlewareTiming, + type ActionTiming, type NextFn, type TestLifecycleEvents, type LocatorWithOriginal, diff --git a/src/plugin-system.ts b/src/plugin-system.ts index 3cc6ccc..2f14da7 100644 --- a/src/plugin-system.ts +++ b/src/plugin-system.ts @@ -71,10 +71,24 @@ export type ActionContext = { args: unknown[]; page: Page; testInfo: TestInfo; + timing: ActionTiming; +}; + +export type ActionMiddlewareTiming = { + name: string; + startedAt: number; + endedAt?: number; +}; + +export type ActionTiming = { + actionStartedAt: number; + attachedAt?: number; + attachedAtStart: boolean; + middlewares: ActionMiddlewareTiming[]; }; /** Function that calls the next middleware or the original action */ -export type NextFn = () => Promise; +export type NextFn = (args?: unknown[]) => Promise; /** Middleware function - wraps an action, must call next() */ export type ActionMiddleware = (ctx: ActionContext, next: NextFn) => Promise; @@ -82,26 +96,62 @@ export type ActionMiddleware = (ctx: ActionContext, next: NextFn) => Promise = { name: string; /** Middleware to wrap locator actions. Called in registration order. */ middleware?: ActionMiddleware; /** Subscribe to test lifecycle events */ testLifecycle?: (emitter: Emittery) => void | (() => void); + /** Add explicit test controls to the page returned from addPlugins. */ + pageExtension?: (ctx: PageExtensionContext) => PageExtension; }; const PLUGIN_STATE = Symbol("playwrightPluginState"); type PluginState = { - actionMiddlewares: ActionMiddleware[]; + actionMiddlewares: RegisteredActionMiddleware[]; lifecycleEmitter: Emittery; lifecycleCleanups: (() => void)[]; testInfo: TestInfo; }; -type PageWithPlugins = Page & { +type RegisteredActionMiddleware = { + name: string; + middleware: ActionMiddleware; +}; + +type MaybePlugin = Plugin | false | null | undefined; + +type PluginPageExtension = T extends Plugin ? PageExtension : {}; + +type PluginPageExtensionForEntry = [Extract>] extends [never] + ? {} + : [Exclude>] extends [never] + ? PluginPageExtension + : Partial>; + +type UnionToIntersection = (T extends unknown ? (value: T) => void : never) extends ( + value: infer Intersection, +) => void + ? Intersection + : never; + +type PageExtensions = UnionToIntersection< + { + [Index in keyof T]: PluginPageExtensionForEntry; + }[number] +> & + object; + +type PageWithPlugins = Page & PageExtension & { [PLUGIN_STATE]: PluginState; [Symbol.asyncDispose]: () => Promise; }; @@ -130,12 +180,12 @@ const getPluginState = (page: Page): PluginState | undefined => { * }); * ``` */ -export const addPlugins = async (params: { +export const addPlugins = async (params: { page: Page; testInfo: TestInfo; - plugins: (Plugin | false | null | undefined)[]; + plugins: Plugins; boxedStackPrefixes?: (defaults: string[]) => string[]; -}): Promise => { +}): Promise>> => { const { page, testInfo, plugins, boxedStackPrefixes } = params; // Patch Locator prototype once globally patchLocatorPrototype(page, boxedStackPrefixes); @@ -153,7 +203,10 @@ export const addPlugins = async (params: { if (!plugin) continue; if (plugin.middleware) { - state.actionMiddlewares.push(plugin.middleware); + state.actionMiddlewares.push({ + middleware: plugin.middleware, + name: plugin.name, + }); } if (plugin.testLifecycle) { @@ -162,15 +215,23 @@ export const addPlugins = async (params: { } } - const pageWithPlugins = page as PageWithPlugins; + const pageWithPlugins = page as PageWithPlugins>; pageWithPlugins[PLUGIN_STATE] = state; + for (const plugin of plugins) { + if (!plugin) continue; + if (!plugin.pageExtension) continue; + + Object.assign(pageWithPlugins, plugin.pageExtension({ page, testInfo })); + } + // Emit beforeTest await state.lifecycleEmitter.emitSerial("beforeTest", { page, testInfo }); // Add async dispose pageWithPlugins[Symbol.asyncDispose] = async () => { await state.lifecycleEmitter.emitSerial("afterTest", { page, testInfo }); + await state.lifecycleEmitter.emitSerial("afterTestFinalize", { page, testInfo }); state.lifecycleCleanups.forEach((cleanup) => cleanup()); }; @@ -214,6 +275,52 @@ const loadSetBoxedStackPrefixes = (corePkg: string): ((prefixes: string[]) => vo return null; }; +const locatorIsAttached = async (locator: Locator) => { + try { + return (await locator.count()) > 0; + } catch { + return false; + } +}; + +const observeAttachedAt = ( + locator: Locator, + timing: ActionTiming, + pollIntervalMs = 50, +) => { + let stopped = false; + let timeout: ReturnType | undefined; + + const poll = async () => { + if (stopped || timing.attachedAt !== undefined) { + return; + } + + const attached = await locatorIsAttached(locator); + if (stopped || timing.attachedAt !== undefined) { + return; + } + + if (attached) { + timing.attachedAt = performance.now(); + return; + } + + timeout = setTimeout(() => { + void poll(); + }, pollIntervalMs); + }; + + void poll(); + + return () => { + stopped = true; + if (timeout) { + clearTimeout(timeout); + } + }; +}; + /** Patch Locator prototype to run middleware. Safe to call multiple times. */ const patchLocatorPrototype = ( page: Page, @@ -254,13 +361,25 @@ const patchLocatorPrototype = ( this: LocatorWithOriginal, ...args: unknown[] ): Promise { - const callOriginal = () => (this[`${method}_original`] as Function)(...args); + let currentArgs = args; + const callOriginal = () => (this[`${method}_original`] as Function)(...currentArgs); // Pages that never had plugins added (e.g. a second page in the same // worker) fall through to the original implementation. const state = getPluginState(this.page()); if (!state) return callOriginal(); const actionMiddlewares = state.actionMiddlewares; + const actionStartedAt = performance.now(); + const attachedAtStart = await locatorIsAttached(this); + const timing: ActionTiming = { + actionStartedAt, + attachedAt: attachedAtStart ? actionStartedAt : undefined, + attachedAtStart, + middlewares: [], + }; + const stopObservingAttached = attachedAtStart + ? () => {} + : observeAttachedAt(this, timing); const ctx: ActionContext = { locator: this, @@ -268,19 +387,38 @@ const patchLocatorPrototype = ( args, page: this.page(), testInfo: state.testInfo, + timing, }; // Build middleware chain - each middleware calls next() to continue let index = 0; - const next: NextFn = async () => { + const next: NextFn = async (nextArgs) => { + if (nextArgs) { + currentArgs = nextArgs; + ctx.args = nextArgs; + } + if (index < actionMiddlewares.length) { - const middleware = actionMiddlewares[index++]; - return middleware(ctx, next); + const { middleware, name } = actionMiddlewares[index++]; + const middlewareTiming: ActionMiddlewareTiming = { + name, + startedAt: performance.now(), + }; + timing.middlewares.push(middlewareTiming); + try { + return await middleware(ctx, next); + } finally { + middlewareTiming.endedAt = performance.now(); + } } return callOriginal(); }; - return next(); + try { + return await next(); + } finally { + stopObservingAttached(); + } }; Object.defineProperty(locatorPrototype, method, { value }); diff --git a/src/plugins/hydration-waiter.ts b/src/plugins/hydration-waiter.ts index e8266cf..f0af0e0 100644 --- a/src/plugins/hydration-waiter.ts +++ b/src/plugins/hydration-waiter.ts @@ -25,6 +25,10 @@ export type HydrationWaiterOptions = { * only runs client-side. */ export const hydrationWaiter = (options: HydrationWaiterOptions = {}): Plugin => { + if (process.env.PWDEBUG) { + return { name: "hydration-waiter" }; + } + const selector = options.selector || '[data-hydrated="false"]'; const timeout = options.timeout || 10_000; diff --git a/src/plugins/index.ts b/src/plugins/index.ts index 6ab1545..6e9c773 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -1,5 +1,19 @@ export { hydrationWaiter, type HydrationWaiterOptions } from "./hydration-waiter.ts"; -export { videoMode, type VideoModeOptions } from "./video-mode.ts"; +export { + videoMode, + type VideoModeControls, + type VideoModeHighlight, + type VideoModeMetadata, + type VideoModeOptions, + type VideoModeOutputPaths, + type VideoModeOutputs, + type VideoModePageExtension, + type VideoModeRect, + type VideoModePlugin, + type VideoModeSourceRange, + type VideoModeSpan, + type VideoModeViewport, +} from "./video-mode.ts"; export { spinnerWaiter, type SpinnerWaiterOptions, defaultSelectors } from "./spinner-waiter.ts"; export { uiErrorReporter, type UIErrorReporterOptions } from "./ui-error-reporter.ts"; export { diff --git a/src/plugins/llm-recover.ts b/src/plugins/llm-recover.ts index 5908638..14d6c4e 100644 --- a/src/plugins/llm-recover.ts +++ b/src/plugins/llm-recover.ts @@ -69,6 +69,10 @@ export type RequestRecoveryCodeFn = ( const pagesInRecovery = new WeakSet(); export const llmRecover = (options: LlmRecoverOptions = {}): Plugin => { + if (process.env.PWDEBUG) { + return { name: "llm-recover" }; + } + const expect = options.expect || playwrightExpect; const maxAttempts = options.maxAttempts || 3; const requestRecovery = options.requestRecoveryCode || createAnthropicProvider(options); diff --git a/src/plugins/spinner-waiter.ts b/src/plugins/spinner-waiter.ts index 7817d1f..e0f6379 100644 --- a/src/plugins/spinner-waiter.ts +++ b/src/plugins/spinner-waiter.ts @@ -32,6 +32,15 @@ const defaultSelectors = [ ]; const oneArgMethodNames = new Set(oneArgMethods); +const enabledActionMethods = new Set([ + "clear", + "click", + "dblclick", + "fill", + "focus", + "press", + "type", +]); const defaults: Required = { spinnerSelectors: defaultSelectors, @@ -68,6 +77,10 @@ const suggestSpinnerMessage = (spinnerLocator: Locator) => [ */ export const spinnerWaiter = Object.assign( (options: SpinnerWaiterOptions = {}): Plugin => { + if (process.env.PWDEBUG) { + return { name: "spinner-waiter" }; + } + return { name: "spinner-waiter", @@ -78,10 +91,10 @@ export const spinnerWaiter = Object.assign( const start = Date.now(); settings.log(`${locator}.${method}(...) starting`); - // Quick check if element is already visible - const elementVisible = await waitForVisible(locator, { timeout: 1000 }); - if (elementVisible) { - settings.log(`${locator} already visible, proceeding`); + // Quick check if element is already ready for the attempted action. + const elementReady = await waitForReady(locator, method, { timeout: 1000 }); + if (elementReady) { + settings.log(`${locator} already ready, proceeding`); return next(); } @@ -92,9 +105,9 @@ export const spinnerWaiter = Object.assign( if (!spinnerVisible) { // No spinner - call action, suggest adding one if it fails - settings.log(`${locator} not visible, no spinner, failing fast`); + settings.log(`${locator} not ready, no spinner, failing fast`); try { - return await callOriginalWithTimeout(locator, method, args, 1); + return await next(withTimeoutOption(method, args, 1)); } catch (error) { adjustError(error as Error, suggestSpinnerMessage(spinnerLocator), "spinner-waiter.ts"); throw error; @@ -107,18 +120,18 @@ export const spinnerWaiter = Object.assign( // Spinner is visible — wait for the element, but bail early if the spinner // disappears (the loading operation finished without producing the expected element). - const waitResult = await waitForVisibleWhileSpinning(locator, spinnerLocator, { + const waitResult = await waitForReadyWhileSpinning(locator, method, spinnerLocator, { timeout: settings.spinnerTimeout - 2000, }); if (waitResult === "appeared") { - settings.log(`${locator} appeared after waiting`); + settings.log(`${locator} became ready after waiting`); return next(); } if (waitResult === "spinner-gone") { settings.log( - `Spinner disappeared but element not visible — loading finished without expected result`, + `Spinner disappeared but element not ready — loading finished without expected result`, ); } else { settings.log(`Spinner still visible after ${settings.spinnerTimeout}ms, UI likely stuck`); @@ -130,7 +143,7 @@ export const spinnerWaiter = Object.assign( } catch (error) { const message = waitResult === "spinner-gone" - ? `Loading finished (spinner disappeared after ${Date.now() - start}ms) but the expected element never appeared.` + ? `Loading finished (spinner disappeared after ${Date.now() - start}ms) but the expected element was not ready.` : `Spinner was still visible after ${settings.spinnerTimeout}ms, the UI is likely stuck.`; adjustError(error as Error, [message], "spinner-waiter.ts"); throw error; @@ -146,24 +159,23 @@ export const spinnerWaiter = Object.assign( }, ); -async function waitForVisible(locator: Locator, { timeout = 1000 } = {}) { - const start = Date.now(); - while (Date.now() - start < timeout) { - if (await locator.isVisible()) return true; - await new Promise((resolve) => setTimeout(resolve, 100)); - } - return await locator.isVisible(); +async function locatorIsReady(locator: Locator, method: ActionContext["method"]) { + if (!(await locator.isVisible())) return false; + if (!enabledActionMethods.has(method)) return true; + return await locator.isEnabled(); } -async function callOriginalWithTimeout( - locator: LocatorWithOriginal, +async function waitForReady( + locator: Locator, method: ActionContext["method"], - args: unknown[], - timeout: number, + { timeout = 1000 } = {}, ) { - return await (locator[`${method}_original`] as Function)( - ...withTimeoutOption(method, args, timeout), - ); + const start = Date.now(); + while (Date.now() - start < timeout) { + if (await locatorIsReady(locator, method)) return true; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return await locatorIsReady(locator, method); } function withTimeoutOption(method: ActionContext["method"], args: unknown[], timeout: number) { @@ -183,12 +195,13 @@ function isOptionsObject(value: unknown): value is Record { } /** - * Wait for `target` to become visible, but bail early if `spinner` disappears. - * Returns "appeared" if target showed up, "spinner-gone" if loading finished + * Wait for `target` to become ready, but bail early if `spinner` disappears. + * Returns "appeared" if target became ready, "spinner-gone" if loading finished * without the target, or "timeout" if spinner was still visible at deadline. */ -async function waitForVisibleWhileSpinning( +async function waitForReadyWhileSpinning( target: Locator, + method: ActionContext["method"], spinner: Locator, { timeout = 1000 } = {}, ): Promise<"appeared" | "spinner-gone" | "timeout"> { @@ -196,7 +209,7 @@ async function waitForVisibleWhileSpinning( // Give the spinner a grace period before checking — it may flicker during transitions const spinnerGracePeriodMs = 3000; while (Date.now() - start < timeout) { - if (await target.isVisible()) return "appeared"; + if (await locatorIsReady(target, method)) return "appeared"; const elapsed = Date.now() - start; if (elapsed > spinnerGracePeriodMs && !(await spinner.isVisible())) return "spinner-gone"; await new Promise((resolve) => setTimeout(resolve, 250)); diff --git a/src/plugins/ui-error-reporter.ts b/src/plugins/ui-error-reporter.ts index 4749e6c..bd3bfd2 100644 --- a/src/plugins/ui-error-reporter.ts +++ b/src/plugins/ui-error-reporter.ts @@ -20,6 +20,10 @@ export type UIErrorReporterOptions = { * their text to the error message for easier debugging. */ export const uiErrorReporter = (options: UIErrorReporterOptions = {}): Plugin => { + if (process.env.PWDEBUG) { + return { name: "ui-error-reporter" }; + } + const selector = options.selector || '[data-type="error"]'; return { diff --git a/src/plugins/video-mode.ts b/src/plugins/video-mode.ts index af2fde6..7704394 100644 --- a/src/plugins/video-mode.ts +++ b/src/plugins/video-mode.ts @@ -1,21 +1,151 @@ /** - * video-mode: slow down and highlight actions so recorded videos are watchable. + * video-mode: record action timings and render watchable videos after the run. * * Extracted from the iterate monorepo's internal Playwright test * infrastructure (github.com/iterate/iterate, private). Modification from the * original: the hardcoded skip for iterate's test-helpers file is now the * `skipStackFrames` option. */ -import type { Locator } from "@playwright/test"; -import type { Plugin, OverrideableMethod } from "../plugin-system.ts"; +import { execFile as execFileCallback } from "node:child_process"; +import { createHash } from "node:crypto"; +import { copyFile, mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { extname, join } from "node:path"; +import { promisify } from "node:util"; +import type { Locator, TestInfo } from "@playwright/test"; +import type { ActionTiming, Plugin, OverrideableMethod } from "../plugin-system.ts"; + +const execFile = promisify(execFileCallback); +const VIDEO_MODE_METADATA_FILE = "video-mode.json"; +const VIDEO_MODE_PLAYER_FILE = "video-mode.html"; +const VIDEO_MODE_RAW_FILE = "video-raw.webm"; +const VIDEO_MODE_RENDERED_FILE = "video-rendered.webm"; +const VIDEO_MODE_REPORT_PLAYER_FILE = "video-mode-report.html"; +const VIDEO_MODE_POINTER_FILE = "video-mode-pointer.png"; +const VIDEO_MODE_CLICK_POINTER_FILE = "video-mode-click-pointer.png"; +const VIDEO_MODE_TEXT_POINTER_FILE = "video-mode-text-pointer.png"; +// Pointer assets adapted from Pictogrammers Material Design Icons: +// cursor-default.svg, cursor-pointer.svg, and cursor-text.svg. +// Source: https://github.com/Templarian/MaterialDesign +// Icons are distributed under the Pictogrammers Free License / Apache 2.0. +const VIDEO_MODE_POINTER_PNG = + "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAIjElEQVR4nORbe2hWZRj/udWqpZIYRqam4g0rUdOgBC+gYlZITEUyUCGRRBFSLBOhP0RErZwUM2luFkbZhUmu2baGogvZxVvtD6PbmmJpttzcpnObvb9zzuN5vuP3+d2/7ywf+HG+857znXOe5zz39z0ZuMMpA3c43YXEUo8gYzfgY8pE/ESmHzAY7/y+4mx7qOO+pXhNgMzlGTQaVBrUG1QYjDG4G7aGZTr3yYAPhRHPA/G/Xxq8GORYm8ErBgcMOgw6HXTBNgnfmEWsJkDmpxtskoERI0bg0qVLssu3n+P8roaPzSBWEyBD42Rn/vz5OHPmDEpKSixBKFpn8IHBfQb3wBaMr0wiFgGIg+snA0OGDLG2s2bNQnV1NaZOnarPpyZ8Y/CIwb1wfUMGAp1lWigeDQj64L1790Z5eTkWL16shxkhymA7R2pDFnwihHg0IORDZ2ZmoqCgANu3b9fDDxsUG8yErQkihEykUQjxhMGwD7xq1SqUlZWhV69eMpRt8LHBcrh+QYfKlAshHhOIiKZPn46qqioMGDBA3/Mtg/cMKJm0OseU1AKjRo3CyZMnMXHiRD08F3Ye8RBc56jNISVCSFkx1LdvXxw9ehTz5s3Tw08ZfGvA2EkhiEmkzDkm1Qd4KSsrC/v27cPGjRv18KOwhTAZgUJIiXNMSzm8fv16FBUVaefY2+Bzg4VIsRDS1g+YM2cODh8+jH79buZTZPZdA6oHo4U4RzGJpDjHtDZExo0bhxMnTmD06NF6eKnBJwZ9ERghkuIc094R6t+/v5U+UyMUTTUoMWCOrZOmhDtHX7TEsrOzLZ+wbt06PTzcoNTgaSQxffZVT3DTpk1WlFBEM/jKgLFThOA1ibjId01R5gnMHJVzJMPvG7yJQOeYkPTZl11hZow1NTXe3sIq2HUE+48UArUh7vTZt23xgQMHora2FjNmzNDD7EKxohyEBDlHX88L9OzZEwcPHsTKlSv1MGMmnSM7UnELwfcTIxkZGdixYwd2796th+kgvjZ4HoERImrn2G1mhpYsWYJDhw7p9JlvP9/gNQTvOUYkhG41NTZlyhQcP34cQ4cOlSE+/+uwG6+sJ6J2jt1ubnDYsGFWhJg0aZIeZhpZBLvtpmuIsOlzPAJI2+RGnz59rN5CiMYrM0jdfb6tELr17DAbr1u3btVD/Q0KDe5HYIRIigb4gtasWYPi4mLtHEfB7jwzawwbHf4X6wNmz56NhQsX6iHmCBSAhMiQkSHR6wNSTm1tbVbDtaKiQg+3wGb+ukE73AnaLu//4xFAynv4HR0dOH36tNU/EHDfQ60GtXBDIp3hNQQ6w5sOPB4BpCQKsGNUWFhoVYjHjh2L5C+5Bk2w1V6rftAX5nsTGD9+fCSn/WPwI+xJ2J8QxToE3wuAfYELFy7oIar4Lwa/Gvzs/OYKFTItNi/gfgdcgdxCvo8CK1as0LsNBq8abDH4zKDKgNK5ClswRIsDrlKh7VMIIVenpHRiJBaiAFSMHwg74yNDZEwYb4Zt9wLutzjH2xEiApB8rwFMexctWqSHXoDNPN8u33Kzg8sG/zpbrlQTAYgGBPUJ3SIRWr16td5lKTgS9pulAMio9+1TAGSeQtI+IKECiDgMNjQ0YNq0aVabi7+jpcGDB2PBggV6iNUfGaMQxAzE9lsRyLy2/1so6Rpw5MgRTJgwwWpmnD17Fhs2bEAstHbtWr3LeXb6AzJHJsN5/5AvK6kC2LVrFyZPnhwQxvbs2YNz584hWuI0mqdBOh9ukkMGyax3PWLINy+UlCjQ2dmJ5cuXY9myZUGP5+bmIhZi5aeIFd+DCL7EJuLFmAn3AY2NjdaymLy8PD3MFZQHZGfnzp1oampCtDRz5kydGZLpl2Hn+7rsjWqOIB4BnJcfLEpIdXV1N+1d0Q8G62EnLszY0NzcbJlHLOTRAi7TlYkSb0M0IiHEkszwBpT2kwbfyyAblRcvXrSYU8Q+3Rdw7fNZg8U8wEVTsUQEEqMJHapDHxm8Azv+8+YMje3OPbvCXSuWtcIUGoXAAoQx+XEOUvXb29vlHIYiGno53HBFMG9/ziCLJsAG55gxYxAtcR0iJ0wc4gpUapd4fvH+YZkXZqIlMk/B0e7YeyOjOkhT5T80+Mt5GMZjEQAfip7xJZ5I5k+dOoVoiU0QTqkrmm1AldBacB1JigI3FKjWbxg8A5cx2vsfcLO0FrjpKj1fgVyIzYzS0lJES3v37tW7kvjcjVu/UQj7gmN1ghJjRb3/hr0snowzDW1GINPEZWf7O2zfYNG2bdsQKeXn52PkyJFYunSpHq6DzbQwLsxHRLFWdNoM2H5m/y0bbj+e16WAKByagBQlXc7/noC9NM4idn3Gjh0b9Eatra0W41u2bNGOT4haxhSRTRAphijkKwisA0JSIr4ZEnMQjZBK7arzgG1wc3MRAh3oBIPBvAAjR05OTsBF+fHF5s2bMXfuXOzfv9+bN/A6dLBvG/zpXPua5z63rQGEYtUAibOiCVQ/73cAIhD9yQycc6gpnOv/VC5YX1+PQYMGWVsulQmRJ9CffGfAEMCcQoohvnFdDbYiTB9AMxIr6S6rdzLSm5/Lm5BzmbgwgnAq6zFejK2v4cOHo7KyMti9qNpcGEHmybDWNHG2UgYH6wPclol4yPt5nNf5iPTFTEQA1Bb6Dc5wcsF0qN4kqyYyTqlIfz+YmUk5LCYn54atCRLV1urh2Qrd8GxFSGRYtIC1PSf4eqr//QY7UtTANR+dUInqC9rUb10Kh02GUj65AddviBaQcWaUOc4xhrXzcM1I1/uSVImz09AaEnFbPB0C8GoBwyc1oRfcWV2Z0dWtbi+zsg2WAkdcDqdjXkBnkdLZ1ZGjXT2XVn3N/HW4jIuT1YxH3K5L18SIzht01OC+zOYCrgYItJrHxbjQfwAAAP//th2pdAAAAAZJREFUAwBUhOKkqcK2WAAAAABJRU5ErkJggg=="; +const VIDEO_MODE_POINTER_SOURCE_SIZE = 64; +const VIDEO_MODE_POINTER_SIZE = 28; +const VIDEO_MODE_POINTER_HOTSPOT = { x: 18, y: 6 }; +const VIDEO_MODE_CLICK_POINTER_PNG = + "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAG+ElEQVR4nOxbXWxURRT+Si3+kj4oFCIvVFMIxsbwQFqopBiMPkDUkNCECDUqtjYmmPBA5MX4F436UKNNScUEo+iD2phIm6jRhhgKpdhAlRqolESwFOhDta1IkeL57tzZzs7eu7vd3VkuKV/y5e7M/dmZ756ZOfece2dhhmMWZjhuCIAZjpuQXxRYWxtXfeYN+RKAHV4kfFT4gPBuYZ+wR9gm/Beq45OIF8G5GAVwj0Lhi8LXhbcE7B8QPgUlxhWfphBORXAtAK/fKnw8jWOfFX4lvCz8D4lCOIFLAXjtZ4Qf6ora2lpUV1djwYIF6Ovrw65du7ytj1FhtfAP4SVMCeFUBFcCFPjsF5ayYs+ePdi4cWPCgWvXrkVbW5suNgvfEl6EmhcmEC+Ck4a6ui47/jsL5eXlOHr0aOCBx48fx5IlS3TxV2GNcEz4D5QQFIHDwYkVuPIDKMAjurBixYrQAxcvXow5c+bo4n3CEuFtwpuFRVCTqLOh6kIAbf736Iri4uKkJ8ydO9c8916o1WI21DJd6LfTiQguLSDTBvOuz/bJ3846T7hwhLLpPME2FWHq7hfm4JqhcPkskGmD2SbdcX33U7nQGcOVK5xNQ3mu7rzmdTUEsoV5t5276lEVwCTgUAyXAuSqwU6t4EZECDMcNwTADEcUVwEiL0sg4dICMn105UPUIigBZgUwp8JE0QKe97eMCn0sbISKDplBkZzFCyMxB8ybNy+omvGA54Q/C1/lYZh6QjRd5awsohC5hzbdh4UVrKiqqsKaNWtCTygpKcHIyAgWLlyI+fPnY2JiAuPj43o3O7xcWCe8U/gbVKSowPrPjBCJp8F169ahvb0dHR0dOHDgAM6cOYOmpiZPEAO3Cl8QMrb2tpA7s7aISC6DRUVFaGhowMDAAHbu3GkLwXBZg7BX+C6yFCKKq0AMFKKurg6nT59Gc3NzkEXUQ0Wem4SViI8jpiXEdeMI1dfXe0K0tLTYQhC1wh+Fn0LNE2lbxHXnCW7ZssUTYvfu3SgtLbV3PyZsF96FNKPK2QpQkKLsDMwynTx50hNi6dKl5q5y4ZNQQ4QiJI0sT0cA7Z7anpltZnlzYwkKcezYMWzYsMGsfkJ4B6bC66GB1XQF4InbhJ8IfxB2CD8QPi28H0plM4rrNJkRhE2bNplFJlduR3CCJa5dqVxhHsysxU/CMmvfKuM301j02LqE3f7vvM4v1jDg3WfnmVK7jPiMcxySCcDOM2f1DRI7b4N/9qDPa4LR0VGzyOcGmj/zijrDZA7P2BKdTACaENO2y3UFZ+D169djbGwMXV1d6OzsxP79+xEFnDhxwiyegzJ7M8ESOAmGCcAD3xdW6YrGxkZs3bo1dgCF0Ojp6fFc2MOHD+PQoUNmzt+DZZ5OwCyzgbNIM7cQJIA2k9gY53pbU1ODMCxbtsyjBh9kKER3d7eX+d28eTNcw7KAs/425URcEFJHxTjDt+jKsrIy7Nu3z3taiyIqKytx8OBBXXwHaqX6Szjik+8c8KUL/cKFh7CZmiLQrdyuK6jwypUrMTQ0hCiCvoABNlIHTJK+YpNsqaIIHwlf0hV8OouiCMPDw/YqMAjVcc3QCFKqtZonfCZ8RVdEUQRr/A9DhdPYcf2mmR1OiyFMgKvGBehEUIQ39MlREyFgAtTtD7KCOAQJoA9k5zlh0Jng5PG58DVEUIQAASaRhQDAlIKmAAzSfQn1xmekRLAEMMd/0MuWaQ0BfaAeAhSA/j6Xki+ELyNCIvT395tFbQFXED4PxJDMAsKsgCJ8DSWCt55qES5cuIBrgVOnTpnFP5FoAZNh56aygFQi7IAhAsPf+RaB0SFjCWRbziH4zk/bDwBSi8CHpR3+n3ljMd8iWON/CPEdz9oPANITYTuukQghK8AVJL56H4h0gxZhIuiJ8VsoS4g1iiKcP38ertHb22sWzfEfZAUJmE7UJpUIjMZu0wdThNWrVzu1BI791tZWs+oXJB//GQ0BE6lE+A6GCIwLVFRU4MiRI8g19u7d613bsDIOSf6RNv20PrrINHBpRogZU2DYiSEohsYYj3tI+J55AoMiIVngaWNwcNAe+wS/Nfge6kboR+C/oYThs4EWI6EjmSKZCAynrRK+KSyGe9BDpYOmLZGdZyxg1K8zvzmIQ67S4+bQ0OCMzBD6IqjIsouXMRgEYIK0E6qTTJuzw+OY+uqEnmxg54lcxO5tS2Ag0hwOOj7PyDKFsAOUmbSBnRtAsLs+6lN/dWJ+dpOAXNwVrayeGO19elLi3aIwdpIi05swaVzfnIzNT21Sfm+UK7O0RTCXnUmjkZyMzKxtpgJcDbn2RcR/cBU48ZnI5bg0RTB/6yWJDbTT1pkOA3s51kvyJX9r3/1QC/gfAAD//6xCl+IAAAAGSURBVAMApSDEOHMObm0AAAAASUVORK5CYII="; +const VIDEO_MODE_CLICK_POINTER_SOURCE_SIZE = 64; +const VIDEO_MODE_CLICK_POINTER_SIZE = 28; +const VIDEO_MODE_CLICK_POINTER_HOTSPOT = { x: 28, y: 7 }; +const VIDEO_MODE_TEXT_POINTER_PNG = + "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFGklEQVR4nOxbS0scWRT+Wk3PTDIOzEJm48LxgathZJwHoszoZsBNEERBFDdCIBBXLsSliIu48Se4EcFlNro0voKBCHkICcFHC+4kkMTEmLajuafqnsqt6mr7VnnLB1UfHOrR99y696tzH1R/pwQxRwlijjJEh1SBcxWn0PMv5HOWvxZSMA+qs0XYDXntjbJ3wp7K81PP8XdhPyv1qDgW9kiWPfX4hibCdARQo18Lq9UoOy3svvQZEdaj4fNS2J/CTjxGCEWCyQiguv4Q9iSAz3t5/EnfBf8Jey4spxiRECoSTEZASq2vvLwcjY2NeYU2Nzext7fHl66OV1ZWorY2P3jW19dxcHDgVC3sprDP8pneIREIpgjgSHLGe0VFBRYWFnwLT09PY2RkxCGCOj4xMYGeHv9RUFNToxJwSxo9izr9BVcoArSGVF9fH7LZLAYGBqzrsbGxgp33wY+wCaDOqsOA2xCIhMgI2N/fR1tbm6tAVVWVda+/vx/d3d04OjpCOp1GV1eX9fvU1BQWFxexu7vr8qO6FKSFfS8sC3ulKZEWuPPcaBOgekqF/QV7qToTzc3NWFlZcd1ramrC2toaNHAP9iT4ThqNjU+wCeGhoA3TO8Fnwj4WK7S6uorx8XHnenR0VLfz1NEMAgy3YohiJ/ibsL/xbbb+AXbYUrj+L+wfKrS8vOw4LC0tqf6PhT2EPbmpMzyN80153zvxXYmNEDWCGvUW9jAgAnjC+g42AR8gCdjZ2XEcM5mMWs883B3lzhIBtBs8gr0EZuHeA4SC6QjgZYkaRw0tlfd4wvrABXO5XKE6yJ87m5XHnOfeIWwSjpEfKYFgigB+OL8paiSv03Sels8qOj/IOvhN05hX3/axtM/yd7rPERIKUQwBaigvSXR+Qz4nDAGHcL9tNRKOlXuXvhNk8BAgMBnU+FLoE0D+/JY/SR/1bXst9C4QMB8BDG4YHSkaaDgQCVnNepgEKs9EMAEcaae4YqsAgRvD6/SJ5zynWc8J3DM/H71v+1ydJ0T1RUhtWEoxncaqnWMi/N68EVzEN8Ewn7D8OnnucPdDlN8EVRhttEnE/qtwQgBijoQAxBwJAYg5EgIQcyQEIOZICEDMkRCAmCMhADFHQgBijoQAxBwJAYg5EgIQcyRyeVwMolClG8FF5Auo/w47zysrKzvLr5Cx7Eb9B/lciIKAQvkCpBS7w4Xq6uoch/r6emxvb/Nlh6yDBJAkqmKJDIkkSF2p/k2uiqVDIYp8AdL01xcr2NnZ6Zx3dHRgfn6eL5ul+eGVMJKgX+98gdbW1jwleUtLi6Ug1cC/wl7guuYLNDQ0YHJy0jqfm5tDKpVCe3u7pR0eHBzExsZGno8nX4ByDIzlC5gUS5ORCtQSS1dXV2Nra6ugw8zMDHp7e63z2dlZSz1eCJQvoMwRpKtfhz0n0PxACjIWTAbWC5rcB2gLmClhYnh42LkeGhqy7mmCtMckvyXJPAsw1RUnEEzL5SkCLB28ZsqMC5opM3dhZ51RvtGBNI6CwLrhS8kXUHAojzcD+BjNFzC9DHK+wC2NsovCHsAm7zbsbLBiMJ4vYDoCaEz+Av98gVJZjt56Bv66v1/xLSGK62XQJEcy+jewN0g0BNQIuPQhQI2m3R91mPIEvPkC6mSlqstVErgePqqTNCtHabxTlPH4Zy1xqNyBi8wX4Ajgcn67OQLripkI1YfF1yyjvzb5ApzddRYBagQwCV4CVCW5kXyBrwAAAP//qVxqLgAAAAZJREFUAwCpkwfomMTHxQAAAABJRU5ErkJggg=="; +const VIDEO_MODE_TEXT_POINTER_SOURCE_SIZE = 64; +const VIDEO_MODE_TEXT_POINTER_SIZE = 28; +const VIDEO_MODE_TEXT_POINTER_HOTSPOT = { x: 32, y: 32 }; + +export type VideoModeSpan = { + start: number; + end: number; +}; + +export type VideoModeOutputs = { + player?: string; + raw?: string; + rendered?: string; +}; + +export type VideoModeOutputPaths = { + metadata: string; + player: string; + raw: string; + rendered: string; + reportPlayer: string; +}; + +export type VideoModeSourceRange = { + start?: number; + end?: number; +}; + +export type VideoModeRect = { + x: number; + y: number; + width: number; + height: number; +}; + +export type VideoModeViewport = { + width: number; + height: number; +}; + +export type VideoModeHighlight = VideoModeSpan & { + actionEnd?: number; + color: string; + image?: string; + method?: OverrideableMethod; + rect: VideoModeRect; + thickness: number; + viewport: VideoModeViewport; +}; + +export type VideoModeMetadata = { + schemaVersion: 1; + timebase: "ms"; + deadAir: VideoModeSpan[]; + highlights: VideoModeHighlight[]; + outputs: VideoModeOutputs; + sourceRange: VideoModeSourceRange; +}; + +export type VideoModeControls = { + /** + * Run invisible video bookkeeping and write the elapsed span as dead air. + */ + deadAir(action: () => Promise): Promise; + /** Milliseconds since video-mode started recording metadata for this test. */ + getVideoTimestamp(): number; + /** Parsed video-mode metadata JSON after the test, or the current in-memory snapshot during the test. */ + metadata(): Promise; + /** Absolute artifact paths for the current test's video-mode outputs. */ + outputPaths(): VideoModeOutputPaths; + /** Render the video from this source timestamp, in ms. Defaults to the current video timestamp. */ + setStartTime(ms?: number): void; + /** Render the video until this source timestamp, in ms. Defaults to the current video timestamp. */ + setEndTime(ms?: number): void; +}; + +export type VideoModePageExtension = { + videoMode: VideoModeControls; +}; + +export type VideoModePlugin = Plugin & VideoModeControls; + +export type VideoModeOutlineHighlightStyle = `${number}px solid ${string}`; + +export type VideoModeHighlightOptions = + | boolean + | { + /** Highlight duration in the rendered video (ms). Default: 1000 */ + duration?: number; + mode: "pointer"; + } + | { + /** Highlight duration in the rendered video (ms). Default: 1000 */ + duration?: number; + mode: "outline"; + /** Outline style for the rendered video. Default: '3px solid gold' */ + style?: VideoModeOutlineHighlightStyle; + }; export type VideoModeOptions = { - /** Pause duration before action (ms). Default: 1000 */ - pauseBefore?: number; - /** Pause duration after test (ms). Default: 3000 */ - pauseAfterTest?: number; - /** Highlight style. Default: '3px solid gold' */ - highlightStyle?: string; + /** + * Render action annotations. `true` uses pointer mode with default options. + * Default: true + */ + highlight?: VideoModeHighlightOptions; + /** Final hold duration in the rendered video (ms). Default: 3000 */ + finalHold?: number; /** Methods to skip highlighting. Default: ['waitFor'] */ skipMethods?: OverrideableMethod[]; /** @@ -24,58 +154,1740 @@ export type VideoModeOptions = { * flows that shouldn't be slowed down. Default: [] */ skipStackFrames?: string[]; + /** + * Maximum rendered duration for each dead-air span. Longer spans are sped up + * so they fit within this duration. + */ + deadAirThreshold?: number; }; -/** Highlight element, pause, return disposable that unhighlights */ -const setupHighlight = async (locator: Locator, style: string, pauseMs: number) => { +type VideoModeState = { + deadAirDepth: number; + deadAirSpans: VideoModeSpan[]; + highlights: VideoModeHighlight[]; + highlightImageIndex: number; + outputs: VideoModeOutputs; + sourceRange: VideoModeSourceRange; + startedAt?: number; +}; + +type RenderVideoSegment = { + start: number; + end: number; + speed: number; +}; + +type VideoInfo = { + width: number; + height: number; + durationMs: number; +}; + +type VideoFilter = { + outputLabel: string; + value: string; +}; + +type VideoPiece = { + end: number; + highlight?: VideoModeHighlight; + speed: number; + start: number; +}; + +type RenderedVideoPiece = VideoPiece & { + outputEnd: number; + outputStart: number; +}; + +type CursorWaypoint = { + at: number; + x: number; + y: number; +}; + +type CursorTarget = { + method?: OverrideableMethod; + outputEnd: number; + outputStart: number; + point: { x: number; y: number }; +}; + +type PlannedCursorTarget = CursorTarget & { + arriveAt: number; +}; + +type CursorPlan = { + targets: PlannedCursorTarget[]; + waypoints: CursorWaypoint[]; +}; + +const CURSOR_MOVEMENT_MIN_MS = 200; +const CURSOR_MOVEMENT_MAX_MS = 1000; +const CURSOR_MOVEMENT_MIN_IDEAL_MS = 300; +const CURSOR_MOVEMENT_MAX_IDEAL_MS = 700; +const CURSOR_MOVEMENT_SPEED_PX_PER_SECOND = 600; +const CURSOR_REST_BEFORE_ACTION_MS = 200; +const CURSOR_TARGET_HOLD_IDEAL_MS = 1000; +const TEXT_CURSOR_HOLD_IDEAL_MS = 800; +const TEXT_CURSOR_POINTER_TAIL_MS = 200; + +type HighlightInput = { + durationMs: number; + image: string; + inputIndex: number; + path: string; +}; + +type PointerInput = { + hotspot: { x: number; y: number }; + inputIndex: number; + path: string; + size: number; + sourceSize: number; +}; + +type ResolvedVideoModeHighlight = + | { + color: string; + durationMs: number; + mode: "outline"; + thickness: number; + } + | { + color: string; + durationMs: number; + mode: "pointer"; + thickness: number; + } + | { + mode: "off"; + }; + +const resolveDeadAirThreshold = (thresholdMs: number | undefined) => { + if (thresholdMs === undefined) { + return undefined; + } + + if (!Number.isFinite(thresholdMs) || thresholdMs < 0) { + throw new Error("videoMode deadAirThreshold must be a non-negative number"); + } + + return thresholdMs; +}; + +const resolveNonNegativeNumber = (options: { + defaultValue: number; + name: string; + value: number | undefined; +}) => { + const value = options.value === undefined ? options.defaultValue : options.value; + + if (!Number.isFinite(value) || value < 0) { + throw new Error(`${options.name} must be a non-negative number`); + } + + return value; +}; + +const parseOutlineHighlightStyle = (style: VideoModeOutlineHighlightStyle) => { + const match = /^([0-9]+(?:\.[0-9]+)?)px solid (.+)$/.exec(style.trim()); + + if (!match) { + throw new Error("videoMode highlight.style must look like '1px solid yellow'"); + } + + const thickness = Number(match[1]); + const color = match[2].trim(); + + if (!Number.isFinite(thickness) || thickness < 0 || color.length === 0) { + throw new Error("videoMode highlight.style must look like '1px solid yellow'"); + } + + return { color, thickness }; +}; + +const resolveVideoModeHighlight = (options: VideoModeOptions): ResolvedVideoModeHighlight => { + const rawHighlight = options.highlight === undefined ? true : options.highlight; + + if (rawHighlight === false) { + return { mode: "off" }; + } + + if (rawHighlight === true) { + return { + color: "gold", + durationMs: resolveNonNegativeNumber({ + defaultValue: 1000, + name: "videoMode highlight.duration", + value: undefined, + }), + mode: "pointer", + thickness: 3, + }; + } + + if (!rawHighlight || typeof rawHighlight !== "object" || !("mode" in rawHighlight)) { + throw new Error("videoMode highlight must be true, false, or a highlight options object"); + } + + const durationMs = resolveNonNegativeNumber({ + defaultValue: 1000, + name: "videoMode highlight.duration", + value: rawHighlight.duration, + }); + + if (rawHighlight.mode === "pointer") { + return { + color: "gold", + durationMs, + mode: "pointer", + thickness: 3, + }; + } + + if (rawHighlight.mode === "outline") { + const parsed = parseOutlineHighlightStyle(rawHighlight.style || "3px solid gold"); + + return { + color: parsed.color, + durationMs, + mode: "outline", + thickness: parsed.thickness, + }; + } + + throw new Error("videoMode highlight.mode must be 'pointer' or 'outline'"); +}; + +const resolveVideoTimestamp = (name: string, ms: number) => { + if (!Number.isFinite(ms) || ms < 0) { + throw new Error(`videoMode.${name}() requires a non-negative timestamp`); + } + + return Math.round(ms); +}; + +const normalizeSourceRange = (sourceRange: VideoModeSourceRange): VideoModeSourceRange => { + const normalized: VideoModeSourceRange = {}; + + if (sourceRange.start !== undefined) { + normalized.start = Math.round(sourceRange.start); + } + + if (sourceRange.end !== undefined) { + normalized.end = Math.round(sourceRange.end); + } + + return normalized; +}; + +const sourceRangeIsSet = (sourceRange: VideoModeSourceRange) => { + return sourceRange.start !== undefined || sourceRange.end !== undefined; +}; + +const metadataFor = (state: VideoModeState): VideoModeMetadata => { + return { + deadAir: mergeVideoSpans(state.deadAirSpans), + highlights: normalizeVideoHighlights(state.highlights), + outputs: state.outputs, + schemaVersion: 1, + sourceRange: normalizeSourceRange(state.sourceRange), + timebase: "ms", + }; +}; + +const videoModeOutputPaths = (testInfo: TestInfo): VideoModeOutputPaths => { + return { + metadata: join(testInfo.outputDir, VIDEO_MODE_METADATA_FILE), + player: join(testInfo.outputDir, VIDEO_MODE_PLAYER_FILE), + raw: join(testInfo.outputDir, VIDEO_MODE_RAW_FILE), + rendered: join(testInfo.outputDir, VIDEO_MODE_RENDERED_FILE), + reportPlayer: join(testInfo.outputDir, VIDEO_MODE_REPORT_PLAYER_FILE), + }; +}; + +const readVideoModeMetadata = async ( + path: string, + fallback: () => VideoModeMetadata, +): Promise => { try { - await locator.evaluate((el, s) => { - const prev = el.getAttribute("style") || ""; - el.setAttribute("data-video-prev-style", prev); - el.setAttribute( - "style", - `${prev}; outline: ${s} !important; outline-offset: 2px !important;`, - ); - }, style); + return JSON.parse(await readFile(path, "utf8")) as VideoModeMetadata; + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + return fallback(); + } + + throw error; + } +}; + +const recordHighlight = async (options: { + color: string; + durationMs: number; + locator: Locator; + method: OverrideableMethod; + state: VideoModeState; + testInfo: TestInfo; + thickness: number; +}) => { + if (options.state.startedAt === undefined || options.durationMs <= 0) { + return; + } + + if (!(await locatorIsAttached(options.locator))) { + return; + } + + try { + const snapshot = await options.locator.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + rect: { + height: rect.height, + width: rect.width, + x: rect.left, + y: rect.top, + }, + viewport: { + height: window.innerHeight, + width: window.innerWidth, + }, + }; + }); + + if ( + snapshot.rect.width <= 0 || + snapshot.rect.height <= 0 || + snapshot.viewport.width <= 0 || + snapshot.viewport.height <= 0 + ) { + return; + } + + const image = `video-mode-highlight-${options.state.highlightImageIndex}.png`; + options.state.highlightImageIndex += 1; + const imagePath = join(options.testInfo.outputDir, image); + await mkdir(options.testInfo.outputDir, { recursive: true }); + await options.locator.page().screenshot({ path: imagePath, scale: "css" }); + + const start = Math.round(performance.now() - options.state.startedAt); + const highlight: VideoModeHighlight = { + color: options.color, + end: start + Math.round(options.durationMs), + image, + method: options.method, + rect: snapshot.rect, + start, + thickness: options.thickness, + viewport: snapshot.viewport, + }; + options.state.highlights.push(highlight); + return highlight; } catch { - // Element may not be ready yet, ignore + // Element may disappear between the actionability wait and the snapshot. + } +}; + +const recordDeadAirSpan = (state: VideoModeState, span: VideoModeSpan) => { + const start = Math.round(span.start); + const end = Math.round(span.end); + + if (end > start) { + state.deadAirSpans.push({ end, start }); + } +}; + +const recordDeadAir = async ( + state: VideoModeState, + action: () => Promise, +) => { + if (state.startedAt === undefined || state.deadAirDepth > 0) { + return await action(); + } + + const start = performance.now() - state.startedAt; + state.deadAirDepth += 1; + + try { + return await action(); + } finally { + state.deadAirDepth -= 1; + const end = performance.now() - state.startedAt; + recordDeadAirSpan(state, { + end: Math.round(end), + start: Math.round(start), + }); } - await new Promise((resolve) => setTimeout(resolve, pauseMs)); +}; + +const mergeVideoSpans = (spans: VideoModeSpan[]) => { + const sorted = spans + .map((span) => ({ + end: Math.round(span.end), + start: Math.round(span.start), + })) + .filter((span) => span.end > span.start) + .sort((left, right) => left.start - right.start || left.end - right.end); + + const merged: VideoModeSpan[] = []; + + for (const span of sorted) { + const previous = merged[merged.length - 1]; + + if (previous && span.start <= previous.end) { + previous.end = Math.max(previous.end, span.end); + continue; + } + + merged.push(span); + } + + return merged; +}; + +const normalizeVideoHighlights = (highlights: VideoModeHighlight[]) => { + return highlights + .map((highlight) => ({ + ...highlight, + actionEnd: + highlight.actionEnd === undefined + ? undefined + : Math.max(Math.round(highlight.actionEnd), Math.round(highlight.start)), + end: Math.round(highlight.end), + rect: { + height: Math.round(highlight.rect.height), + width: Math.round(highlight.rect.width), + x: Math.round(highlight.rect.x), + y: Math.round(highlight.rect.y), + }, + start: Math.round(highlight.start), + thickness: Math.round(highlight.thickness), + viewport: { + height: Math.round(highlight.viewport.height), + width: Math.round(highlight.viewport.width), + }, + })) + .filter((highlight) => highlight.end > highlight.start) + .filter((highlight) => highlight.rect.width > 0 && highlight.rect.height > 0) + .filter((highlight) => highlight.viewport.width > 0 && highlight.viewport.height > 0) + .sort((left, right) => left.start - right.start || left.end - right.end); +}; + +const formatSeconds = (ms: number) => { + const value = (ms / 1000).toFixed(3).replace(/\.?0+$/, ""); + return value || "0"; +}; + +const formatFilterNumber = (value: number) => { + return Number(value.toFixed(6)).toString(); +}; + +const clipVideoSpan = (span: VideoModeSpan, range: VideoModeSpan): VideoModeSpan | undefined => { + const start = Math.max(range.start, Math.min(Math.round(span.start), range.end)); + const end = Math.max(range.start, Math.min(Math.round(span.end), range.end)); + + if (end <= start) { + return undefined; + } + + return { end, start }; +}; + +const videoSpansOverlap = (left: VideoModeSpan, right: VideoModeSpan) => { + return left.start < right.end && right.start < left.end; +}; + +const deadAirSpeed = (span: VideoModeSpan, thresholdMs: number) => { + const duration = span.end - span.start; + + if (duration <= thresholdMs) { + return 1; + } + + if (thresholdMs === 0) { + return Infinity; + } + + return duration / thresholdMs; +}; + +const locatorIsAttached = async (locator: Locator) => { + try { + return (await locator.count()) > 0; + } catch { + return false; + } +}; + +const recordAttachedWaitFromTiming = ( + state: VideoModeState, + timing: { actionStartedAt: number; attachedAt?: number; attachedAtStart: boolean }, +) => { + if (state.startedAt === undefined || timing.attachedAtStart) { + return; + } + + if (timing.attachedAt === undefined) { + return; + } + + const start = Math.round(timing.actionStartedAt - state.startedAt); + const end = Math.round(timing.attachedAt - state.startedAt); + + recordDeadAirSpan(state, { end, start }); +}; + +const recordActionElapsedDeadAirFromTiming = ( + state: VideoModeState, + timing: Pick, + options: { minimumMs: number }, +) => { + if (state.startedAt === undefined) { + return; + } + + const start = Math.round(timing.actionStartedAt - state.startedAt); + const end = Math.round(performance.now() - state.startedAt); + + if (end - start < options.minimumMs) { + return; + } + + recordDeadAirSpan(state, { end, start }); +}; + +const recordMiddlewareWaitBeforeVideoMode = ( + state: VideoModeState, + timing: ActionTiming, +) => { + if (state.startedAt === undefined) { + return; + } + + const currentMiddleware = [...timing.middlewares] + .reverse() + .find((middleware) => middleware.name === "video-mode" && middleware.endedAt === undefined); + + if (!currentMiddleware) { + return; + } + + const start = Math.round(timing.actionStartedAt - state.startedAt); + const end = Math.round(currentMiddleware.startedAt - state.startedAt); + + recordDeadAirSpan(state, { end, start }); +}; + +const renderVideoSegments = (options: { + deadAir: VideoModeSpan[]; + finalEnd: number; + start: number; + thresholdMs?: number; +}): RenderVideoSegment[] => { + const finalEnd = Math.max(0, Math.round(options.finalEnd)); + const start = Math.max(0, Math.min(Math.round(options.start), finalEnd)); + + if (finalEnd <= start) { + return []; + } + + if (options.thresholdMs === undefined) { + return [{ end: finalEnd, speed: 1, start }]; + } + + const thresholdMs = options.thresholdMs; + const deadAir = mergeVideoSpans( + options.deadAir + .map((span) => clipVideoSpan(span, { end: finalEnd, start })) + .filter((span): span is VideoModeSpan => Boolean(span)), + ); + const boundaries = new Set([start, finalEnd]); + + for (const span of deadAir) { + boundaries.add(span.start); + boundaries.add(span.end); + } + + const sortedBoundaries = [...boundaries].sort((left, right) => left - right); + const segments: RenderVideoSegment[] = []; + + for (let index = 0; index < sortedBoundaries.length - 1; index += 1) { + const start = sortedBoundaries[index]; + const end = sortedBoundaries[index + 1]; + + if (end <= start) { + continue; + } + + const deadAirSpan = deadAir.find((span) => videoSpansOverlap(span, { end, start })); + const speed = deadAirSpan ? deadAirSpeed(deadAirSpan, thresholdMs) : 1; + + if (!Number.isFinite(speed)) { + continue; + } + + const previous = segments[segments.length - 1]; + + if (previous && previous.end === start && previous.speed === speed) { + previous.end = end; + continue; + } + + segments.push({ end, speed, start }); + } + + return segments; +}; + +const videoPieces = (options: { + highlights: VideoModeHighlight[]; + segments: RenderVideoSegment[]; +}): VideoPiece[] => { + const pieces: VideoPiece[] = []; + const frameMs = 50; + + for (const segment of options.segments) { + let cursor = segment.start; + const highlights = options.highlights.filter( + (highlight) => highlight.start >= segment.start && highlight.start < segment.end, + ); + + for (let index = 0; index < highlights.length; index += 1) { + const highlight = highlights[index]; + const nextHighlight = highlights[index + 1]; + + if (highlight.start > cursor) { + pieces.push({ end: highlight.start, speed: segment.speed, start: cursor }); + } + + let frameStart = Math.max(segment.start, highlight.start - frameMs); + let frameEnd = highlight.start; + + if (frameEnd <= frameStart) { + frameStart = highlight.start; + frameEnd = Math.min(segment.end, frameStart + frameMs); + } + + if (frameEnd > frameStart) { + pieces.push({ end: frameEnd, highlight, speed: segment.speed, start: frameStart }); + } + + let nextCursor = Math.max(highlight.start, highlight.actionEnd || highlight.start); + + if (nextHighlight && highlight.end > nextHighlight.start) { + nextCursor = Math.max(nextCursor, nextHighlight.start); + } + + cursor = Math.min(segment.end, nextCursor); + } + + if (segment.end > cursor) { + pieces.push({ end: segment.end, speed: segment.speed, start: cursor }); + } + } + + return pieces.filter((piece) => piece.end > piece.start); +}; + +const scaleHighlight = (highlight: VideoModeHighlight, video: { width: number; height: number }) => { + const scale = Math.min( + video.width / highlight.viewport.width, + video.height / highlight.viewport.height, + ); + const x = Math.max(0, Math.round(highlight.rect.x * scale)); + const y = Math.max(0, Math.round(highlight.rect.y * scale)); + const width = Math.max(1, Math.round(highlight.rect.width * scale)); + const height = Math.max(1, Math.round(highlight.rect.height * scale)); return { - [Symbol.dispose]: () => { - // Fire-and-forget cleanup - don't wait for it - locator - .evaluate((el) => { - const prev = el.getAttribute("data-video-prev-style"); - if (typeof prev === "string") { - el.setAttribute("style", prev); - el.removeAttribute("data-video-prev-style"); - } - }) - .catch(() => { - // Element may be gone or not actionable, ignore - }); - }, + height: Math.min(height, Math.max(1, video.height - y)), + width: Math.min(width, Math.max(1, video.width - x)), + x, + y, + }; +}; + +const scaledViewportSize = ( + viewport: VideoModeViewport, + video: { width: number; height: number }, +) => { + const scale = Math.min(video.width / viewport.width, video.height / viewport.height); + + return { + height: Math.max(1, Math.round(viewport.height * scale)), + width: Math.max(1, Math.round(viewport.width * scale)), + }; +}; + +const drawboxFilter = (highlight: VideoModeHighlight, video: { width: number; height: number }) => { + const rect = scaleHighlight(highlight, video); + return [ + `drawbox=x=${rect.x}`, + `y=${rect.y}`, + `w=${rect.width}`, + `h=${rect.height}`, + `color=${highlight.color}`, + `t=${Math.max(1, Math.round(highlight.thickness))}`, + ].join(":"); +}; + +const renderedPieceDuration = (piece: VideoPiece) => { + const sourceDuration = (piece.end - piece.start) / piece.speed; + + if (!piece.highlight) { + return sourceDuration; + } + + const highlightDuration = piece.highlight.end - piece.highlight.start; + + if (piece.highlight.image) { + return highlightDuration; + } + + return Math.max(sourceDuration, highlightDuration); +}; + +const renderedVideoPieces = (pieces: VideoPiece[]) => { + let cursor = 0; + const rendered: RenderedVideoPiece[] = []; + + for (const piece of pieces) { + const duration = renderedPieceDuration(piece); + rendered.push({ + ...piece, + outputEnd: cursor + duration, + outputStart: cursor, + }); + cursor += duration; + } + + return rendered; +}; + +const highlightCursorPoint = ( + highlight: VideoModeHighlight, + video: { width: number; height: number }, +) => { + const rect = scaleHighlight(highlight, video); + + return { + x: Math.max(0, Math.min(video.width - 1, rect.x + rect.width / 2)), + y: Math.max(0, Math.min(video.height - 1, rect.y + rect.height / 2)), + }; +}; + +const pushCursorWaypoint = (waypoints: CursorWaypoint[], waypoint: CursorWaypoint) => { + const rounded = { + at: Math.round(waypoint.at), + x: Math.round(waypoint.x), + y: Math.round(waypoint.y), + }; + const previous = waypoints[waypoints.length - 1]; + + if (previous && previous.at === rounded.at) { + previous.x = rounded.x; + previous.y = rounded.y; + return; + } + + waypoints.push(rounded); +}; + +const cursorTargets = (options: { + highlights: VideoModeHighlight[]; + pieces: RenderedVideoPiece[]; + video: { width: number; height: number }; +}) => { + const targets: CursorTarget[] = []; + + for (const highlight of options.highlights) { + const piece = options.pieces.find((candidate) => candidate.highlight === highlight); + + if (!piece) { + continue; + } + + targets.push({ + method: highlight.method, + outputEnd: piece.outputEnd, + outputStart: piece.outputStart, + point: highlightCursorPoint(highlight, options.video), + }); + } + + return targets; +}; + +const cursorMovementIdealDuration = (options: { + currentPoint: { x: number; y: number }; + target: CursorTarget; +}) => { + const distance = Math.hypot( + options.target.point.x - options.currentPoint.x, + options.target.point.y - options.currentPoint.y, + ); + const duration = (distance / CURSOR_MOVEMENT_SPEED_PX_PER_SECOND) * 1000; + + return Math.max( + CURSOR_MOVEMENT_MIN_IDEAL_MS, + Math.min(CURSOR_MOVEMENT_MAX_IDEAL_MS, duration), + ); +}; + +const cursorArrivalDeadline = (options: { + currentPoint: { x: number; y: number }; + earliestStart: number; + target: CursorTarget; +}) => { + const target = options.target; + const latestArrivalWithMinimumRest = Math.max( + target.outputStart, + target.outputEnd - CURSOR_REST_BEFORE_ACTION_MS, + ); + const idealHoldArrival = Math.max( + target.outputStart, + target.outputEnd - CURSOR_TARGET_HOLD_IDEAL_MS, + ); + const readableMovementArrival = + options.earliestStart + cursorMovementIdealDuration(options); + + return Math.min( + latestArrivalWithMinimumRest, + Math.max(idealHoldArrival, readableMovementArrival), + ); +}; + +const cursorMovementTiming = (options: { + currentPoint: { x: number; y: number }; + earliestStart: number; + target: CursorTarget; +}) => { + const deadline = cursorArrivalDeadline(options); + const available = Math.max(0, deadline - options.earliestStart); + const idealDuration = Math.min(cursorMovementIdealDuration(options), CURSOR_MOVEMENT_MAX_MS); + const duration = + available < CURSOR_MOVEMENT_MIN_MS ? available : Math.min(idealDuration, available); + const startAt = Math.max(options.earliestStart, deadline - duration); + + return { + arriveAt: startAt + duration, + startAt, }; }; /** - * Highlights elements before actions and pauses for video recording. - * Also pauses after tests complete for better video endings. + * Plan cursor motion backwards from each action's click/commit moment. + * + * The cursor starts in the center and switches to a target-specific shape only + * after arriving. Movement aims for a readable distance-based speed, clamped to + * a short 300-700ms range, while still compressing toward 200ms when the + * existing video timeline has no room. The arrival point preserves an ideal 1s + * target hold when possible and otherwise gives the hand/text cursor most of + * the configured highlight hold without extending product interaction time. */ -export const videoMode = (options: VideoModeOptions = {}): Plugin => { - const pauseBefore = options.pauseBefore || 1000; - const pauseAfterTest = options.pauseAfterTest || 3000; - const highlightStyle = options.highlightStyle || "3px solid gold"; +const cursorPlan = ( + targets: CursorTarget[], + video: { width: number; height: number }, +): CursorPlan => { + const waypoints: CursorWaypoint[] = []; + const plannedTargets: PlannedCursorTarget[] = []; + let currentPoint = { + x: video.width / 2, + y: video.height / 2, + }; + let earliestStart = 0; + + if (targets.length === 0) { + return { targets: plannedTargets, waypoints }; + } + + pushCursorWaypoint(waypoints, { + at: 0, + x: currentPoint.x, + y: currentPoint.y, + }); + + for (let index = 0; index < targets.length; index += 1) { + const target = targets[index]; + const nextTarget = targets[index + 1]; + const movement = cursorMovementTiming({ currentPoint, earliestStart, target }); + const holdEnd = nextTarget + ? Math.min(target.outputEnd, nextTarget.outputStart) + : target.outputEnd; + + pushCursorWaypoint(waypoints, { + at: movement.startAt, + x: currentPoint.x, + y: currentPoint.y, + }); + pushCursorWaypoint(waypoints, { + at: movement.arriveAt, + x: target.point.x, + y: target.point.y, + }); + pushCursorWaypoint(waypoints, { + at: Math.max(holdEnd, movement.arriveAt), + x: target.point.x, + y: target.point.y, + }); + plannedTargets.push({ + ...target, + arriveAt: movement.arriveAt, + }); + + currentPoint = target.point; + earliestStart = Math.max(earliestStart, target.outputEnd); + } + + return { targets: plannedTargets, waypoints }; +}; + +const methodCursorSpans = (targets: PlannedCursorTarget[], methods: OverrideableMethod[]) => { + return targets + .filter((target) => target.method !== undefined && methods.includes(target.method)) + .map((target) => ({ + end: target.outputEnd, + start: target.arriveAt, + })) + .filter((span) => span.end > span.start); +}; + +const textCursorSpans = (targets: PlannedCursorTarget[]) => { + return targets + .filter((target) => target.method === "fill" || target.method === "type") + .map((target) => ({ + end: Math.min( + target.arriveAt + TEXT_CURSOR_HOLD_IDEAL_MS, + Math.max(target.arriveAt, target.outputEnd - TEXT_CURSOR_POINTER_TAIL_MS), + ), + start: target.arriveAt, + })) + .filter((span) => span.end > span.start); +}; + +const cursorActivitySpan = ( + targets: CursorTarget[], + waypoints: CursorWaypoint[], +): VideoModeSpan | undefined => { + if (targets.length === 0 || waypoints.length === 0) { + return undefined; + } + + const lastTarget = targets[targets.length - 1]; + const tailDuration = Math.max(0, lastTarget.outputEnd - lastTarget.outputStart); + + return { + end: lastTarget.outputEnd + tailDuration, + start: waypoints[0].at, + }; +}; + +const cursorExpression = (waypoints: CursorWaypoint[], property: "x" | "y") => { + let expression = formatFilterNumber(waypoints[waypoints.length - 1][property]); + + for (let index = waypoints.length - 2; index >= 0; index -= 1) { + const from = waypoints[index]; + const to = waypoints[index + 1]; + + if (to.at <= from.at) { + continue; + } + + const start = formatSeconds(from.at); + const end = formatSeconds(to.at); + const progress = `((t-${start})/(${end}-${start}))`; + const eased = `((${progress})*(${progress})*(3-2*(${progress})))`; + const delta = to[property] - from[property]; + const value = + delta === 0 + ? formatFilterNumber(from[property]) + : `(${formatFilterNumber(from[property])}+(${formatFilterNumber(delta)})*${eased})`; + + expression = `if(between(t\\,${start}\\,${end})\\,${value}\\,${expression})`; + } + + return expression; +}; + +const cursorOverlayFilters = (options: { + enable: string; + inputLabel: string; + outputLabel: string; + pointerInput: PointerInput; + waypoints: CursorWaypoint[]; +}) => { + if (options.waypoints.length === 0) { + return `[${options.inputLabel}]null[${options.outputLabel}]`; + } + + const pointerScale = options.pointerInput.size / options.pointerInput.sourceSize; + const x = `(${cursorExpression(options.waypoints, "x")})-${formatFilterNumber( + options.pointerInput.hotspot.x * pointerScale, + )}`; + const y = `(${cursorExpression(options.waypoints, "y")})-${formatFilterNumber( + options.pointerInput.hotspot.y * pointerScale, + )}`; + + return [ + `[${options.pointerInput.inputIndex}:v]scale=w=${options.pointerInput.size}:h=${options.pointerInput.size},format=rgba[pointercursor]`, + [ + `[${options.inputLabel}][pointercursor]overlay=x='${x}'`, + `y='${y}'`, + "eval=frame", + "eof_action=pass", + `enable='${options.enable}'[${options.outputLabel}]`, + ].join(":"), + ].join(";"); +}; + +const videoSpanExpression = (spans: VideoModeSpan[]) => { + return spans + .map((span) => `between(t\\,${formatSeconds(span.start)}\\,${formatSeconds(span.end)})`) + .join("+"); +}; + +const renderedVideoFilter = (options: { + clickPointerInput?: PointerInput; + cursorPointerInput?: PointerInput; + finalHoldMs: number; + highlightMode: "outline" | "pointer"; + highlightInputs: HighlightInput[]; + highlights: VideoModeHighlight[]; + segments: RenderVideoSegment[]; + textPointerInput?: PointerInput; + video: { width: number; height: number }; +}): VideoFilter | undefined => { + const highlightInputByImage = new Map( + options.highlightInputs.map((input) => [input.image, input]), + ); + const pieces = videoPieces({ + highlights: options.highlights, + segments: options.segments, + }); + const renderedPieces = renderedVideoPieces(pieces); + const targets = cursorTargets({ + highlights: options.highlights, + pieces: renderedPieces, + video: options.video, + }); + const plan = cursorPlan(targets, options.video); + const clickSpans = methodCursorSpans(plan.targets, ["click"]); + const textSpans = textCursorSpans(plan.targets); + const activitySpan = cursorActivitySpan(targets, plan.waypoints); + const clickSpanExpression = videoSpanExpression(clickSpans); + const textSpanExpression = videoSpanExpression(textSpans); + + if (pieces.length === 0) { + return undefined; + } + + const filters: string[] = []; + const labels: string[] = []; + + for (let index = 0; index < pieces.length; index += 1) { + const piece = pieces[index]; + const label = `render${index}`; + labels.push(`[${label}]`); + + const operations: string[] = []; + + if (piece.highlight?.image && highlightInputByImage.has(piece.highlight.image)) { + const input = highlightInputByImage.get(piece.highlight.image)!; + const scaledViewport = scaledViewportSize(piece.highlight.viewport, options.video); + operations.push( + `[${input.inputIndex}:v]scale=w=${scaledViewport.width}:h=${scaledViewport.height}`, + ); + operations.push( + `pad=w=${options.video.width}:h=${options.video.height}:x=0:y=0:color=gray`, + ); + if (options.highlightMode === "outline") { + operations.push(drawboxFilter(piece.highlight, options.video)); + } + operations.push( + `trim=start=0:end=${formatSeconds(piece.highlight.end - piece.highlight.start)}`, + ); + operations.push("setpts=PTS-STARTPTS"); + } else { + operations.push( + `[0:v]trim=start=${formatSeconds(piece.start)}:end=${formatSeconds(piece.end)}`, + ); + operations.push(`setpts=(PTS-STARTPTS)/${formatFilterNumber(piece.speed)}`); + } + + if (piece.highlight && !piece.highlight.image) { + const sourceDuration = (piece.end - piece.start) / piece.speed; + if (options.highlightMode === "outline") { + operations.push(drawboxFilter(piece.highlight, options.video)); + } + operations.push( + `tpad=stop_mode=clone:stop_duration=${formatSeconds( + Math.max(0, piece.highlight.end - piece.highlight.start - sourceDuration), + )}`, + ); + } + + filters.push(`${operations.join(",")}[${label}]`); + } + + const concatLabel = labels.length === 1 ? labels[0].slice(1, -1) : "renderconcat"; + + if (labels.length > 1) { + filters.push(`${labels.join("")}concat=n=${labels.length}:v=1:a=0[${concatLabel}]`); + } + + const finalHoldMs = Math.max(0, Math.round(options.finalHoldMs)); + const outputLabel = finalHoldMs > 0 ? "renderout" : concatLabel; + + if (finalHoldMs > 0) { + filters.push( + `[${concatLabel}]tpad=stop_mode=clone:stop_duration=${formatSeconds(finalHoldMs)}[${outputLabel}]`, + ); + } + + if ( + options.highlightMode === "pointer" && + options.cursorPointerInput && + plan.waypoints.length > 0 && + activitySpan + ) { + const cursorOutputLabel = "renderpointer"; + const cursorActivityExpression = `between(t\\,${formatSeconds(activitySpan.start)}\\,${formatSeconds(activitySpan.end)})`; + const specialCursorExpression = [clickSpanExpression, textSpanExpression] + .filter(Boolean) + .join("+"); + const cursorEnable = specialCursorExpression + ? `${cursorActivityExpression}*not(${specialCursorExpression})` + : cursorActivityExpression; + filters.push( + cursorOverlayFilters({ + enable: cursorEnable, + inputLabel: outputLabel, + outputLabel: cursorOutputLabel, + pointerInput: options.cursorPointerInput, + waypoints: plan.waypoints, + }), + ); + + let pointerOutputLabel = cursorOutputLabel; + + if (options.textPointerInput && textSpanExpression) { + const textPointerOutputLabel = "rendertextpointer"; + filters.push( + cursorOverlayFilters({ + enable: textSpanExpression, + inputLabel: pointerOutputLabel, + outputLabel: textPointerOutputLabel, + pointerInput: options.textPointerInput, + waypoints: plan.waypoints, + }), + ); + pointerOutputLabel = textPointerOutputLabel; + } + + if (options.clickPointerInput && clickSpanExpression) { + const clickPointerOutputLabel = "renderclickpointer"; + filters.push( + cursorOverlayFilters({ + enable: clickSpanExpression, + inputLabel: pointerOutputLabel, + outputLabel: clickPointerOutputLabel, + pointerInput: options.clickPointerInput, + waypoints: plan.waypoints, + }), + ); + + return { + outputLabel: clickPointerOutputLabel, + value: filters.join(";"), + }; + } + + return { + outputLabel: pointerOutputLabel, + value: filters.join(";"), + }; + } + + return { + outputLabel, + value: filters.join(";"), + }; +}; + +const videoInfo = async (path: string): Promise => { + const { stdout } = await execFile( + "ffprobe", + [ + "-v", + "error", + "-show_entries", + "format=duration:stream=width,height", + "-of", + "json", + path, + ], + { maxBuffer: 1024 * 1024 }, + ); + const payload = JSON.parse(stdout); + const seconds = Number(payload.format?.duration); + const stream = payload.streams?.find((candidate: any) => candidate.width && candidate.height); + + if (!Number.isFinite(seconds) || seconds <= 0 || !stream) { + throw new Error(`Could not read video duration from ffprobe output: ${stdout}`); + } + + return { + durationMs: Math.round(seconds * 1000), + height: Number(stream.height), + width: Number(stream.width), + }; +}; + +const waitForNonEmptyFile = async (path: string, timeoutMs = 5000) => { + const start = Date.now(); + let lastError: unknown; + + while (Date.now() - start < timeoutMs) { + try { + const stats = await stat(path); + if (stats.size > 0) { + return; + } + lastError = new Error(`File exists but is empty: ${path}`); + } catch (error) { + lastError = error; + } + + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + throw lastError instanceof Error + ? lastError + : new Error(`Timed out waiting for non-empty file: ${path}`); +}; + +const escapeHtml = (value: string) => { + const entities: Record = { + "&": "&", + '"': """, + "'": "'", + "<": "<", + ">": ">", + }; + + return value.replace(/[&"'<>]/g, (character) => entities[character]); +}; + +const videoElementHtml = (options: { + activeKey: "raw" | "rendered"; + label: string; + source: string; +}) => { + const activeKey = escapeHtml(options.activeKey); + const label = escapeHtml(options.label); + const source = escapeHtml(options.source); + + return ` +
+
${label}
+ +
`; +}; + +const playwrightReportAttachmentName = async (path: string) => { + const data = await readFile(path); + return `${createHash("sha1").update(data).digest("hex")}${extname(path)}`; +}; + +const videoModePlayerHtml = (options: { raw: string; rendered?: string }) => { + const primary = options.rendered || options.raw; + const primaryLabel = options.rendered ? "Rendered video" : "Raw video"; + const primaryActiveKey = options.rendered ? "rendered" : "raw"; + const rawDetails = options.rendered + ? ` +
+ Raw video + ${videoElementHtml({ activeKey: "raw", label: "Raw video", source: options.raw })} +
` + : ""; + + return ` + + + + + video-mode player + + + +
+
video-mode player
+ + + +
+
+
+ ${videoElementHtml({ activeKey: primaryActiveKey, label: primaryLabel, source: primary })} + ${rawDetails} +
+ +
+ + + +`; +}; + +const renderVideo = async (options: { + finalHoldMs: number; + highlightMode: "outline" | "pointer"; + highlights: VideoModeHighlight[]; + inputPath: string; + outputDir: string; + outputPath: string; + deadAir: VideoModeSpan[]; + sourceRange: VideoModeSourceRange; + thresholdMs: number | undefined; +}) => { + const info = await videoInfo(options.inputPath); + const sourceRangeStart = options.sourceRange.start === undefined ? 0 : options.sourceRange.start; + const sourceRangeEnd = + options.sourceRange.end === undefined ? info.durationMs : options.sourceRange.end; + const rangeStart = Math.max(0, Math.min(Math.round(sourceRangeStart), info.durationMs)); + const rangeEnd = Math.max( + 0, + Math.min(Math.round(sourceRangeEnd), info.durationMs), + ); + + if (rangeEnd <= rangeStart) { + console.warn( + `videoMode source range is empty: start ${rangeStart}ms must be before end ${rangeEnd}ms`, + ); + return false; + } + + const highlightInputs = options.highlights + .filter((highlight) => highlight.image) + .map((highlight, index) => ({ + durationMs: highlight.end - highlight.start, + image: highlight.image!, + inputIndex: index + 1, + path: join(options.outputDir, highlight.image!), + })); + const shouldRenderPointer = options.highlightMode === "pointer" && options.highlights.length > 0; + const cursorPointerInput: PointerInput | undefined = shouldRenderPointer + ? { + hotspot: VIDEO_MODE_POINTER_HOTSPOT, + inputIndex: highlightInputs.length + 1, + path: join(options.outputDir, VIDEO_MODE_POINTER_FILE), + size: VIDEO_MODE_POINTER_SIZE, + sourceSize: VIDEO_MODE_POINTER_SOURCE_SIZE, + } + : undefined; + const clickPointerInput: PointerInput | undefined = shouldRenderPointer + ? { + hotspot: VIDEO_MODE_CLICK_POINTER_HOTSPOT, + inputIndex: highlightInputs.length + 2, + path: join(options.outputDir, VIDEO_MODE_CLICK_POINTER_FILE), + size: VIDEO_MODE_CLICK_POINTER_SIZE, + sourceSize: VIDEO_MODE_CLICK_POINTER_SOURCE_SIZE, + } + : undefined; + const textPointerInput: PointerInput | undefined = shouldRenderPointer + ? { + hotspot: VIDEO_MODE_TEXT_POINTER_HOTSPOT, + inputIndex: highlightInputs.length + 3, + path: join(options.outputDir, VIDEO_MODE_TEXT_POINTER_FILE), + size: VIDEO_MODE_TEXT_POINTER_SIZE, + sourceSize: VIDEO_MODE_TEXT_POINTER_SOURCE_SIZE, + } + : undefined; + if (cursorPointerInput) { + await writeFile(cursorPointerInput.path, Buffer.from(VIDEO_MODE_POINTER_PNG, "base64")); + } + if (clickPointerInput) { + await writeFile(clickPointerInput.path, Buffer.from(VIDEO_MODE_CLICK_POINTER_PNG, "base64")); + } + if (textPointerInput) { + await writeFile(textPointerInput.path, Buffer.from(VIDEO_MODE_TEXT_POINTER_PNG, "base64")); + } + const segments = renderVideoSegments({ + deadAir: options.deadAir, + finalEnd: rangeEnd, + start: rangeStart, + thresholdMs: options.thresholdMs, + }); + const filter = renderedVideoFilter({ + clickPointerInput, + cursorPointerInput, + finalHoldMs: options.finalHoldMs, + highlightMode: options.highlightMode, + highlightInputs, + highlights: options.highlights, + segments, + textPointerInput, + video: info, + }); + + if (!filter) { + return false; + } + + await execFile( + "ffmpeg", + [ + "-hide_banner", + "-loglevel", + "error", + "-y", + "-i", + options.inputPath, + ...highlightInputs.flatMap((input) => [ + "-loop", + "1", + "-t", + formatSeconds(input.durationMs), + "-i", + input.path, + ]), + ...(cursorPointerInput ? ["-loop", "1", "-i", cursorPointerInput.path] : []), + ...(clickPointerInput ? ["-loop", "1", "-i", clickPointerInput.path] : []), + ...(textPointerInput ? ["-loop", "1", "-i", textPointerInput.path] : []), + "-filter_complex", + filter.value, + "-map", + `[${filter.outputLabel}]`, + "-an", + options.outputPath, + ], + { maxBuffer: 10 * 1024 * 1024 }, + ); + + return true; +}; + +/** Records video-mode facts and renders annotations into the recorded video. */ +export const videoMode = (options: VideoModeOptions = {}): VideoModePlugin => { + if (process.env.PWDEBUG) { + let testInfoForOutputPaths: TestInfo | undefined; + const controls: VideoModeControls = { + deadAir: async (action) => { + return await action(); + }, + getVideoTimestamp: () => 0, + metadata: async () => ({ + deadAir: [], + highlights: [], + outputs: {}, + schemaVersion: 1, + sourceRange: {}, + timebase: "ms", + }), + outputPaths: () => { + if (!testInfoForOutputPaths) { + throw new Error("videoMode.outputPaths() is only available after addPlugins registers videoMode"); + } + + return videoModeOutputPaths(testInfoForOutputPaths); + }, + setEndTime: () => {}, + setStartTime: () => {}, + }; + + return { + ...controls, + name: "video-mode", + pageExtension: ({ testInfo }) => { + testInfoForOutputPaths = testInfo; + return { videoMode: controls }; + }, + }; + } + + const finalHold = resolveNonNegativeNumber({ + defaultValue: 3000, + name: "videoMode finalHold", + value: options.finalHold, + }); + const highlight = resolveVideoModeHighlight(options); const skipMethods = options.skipMethods || ["waitFor"]; const skipStackFrames = options.skipStackFrames || []; + const deadAirThreshold = resolveDeadAirThreshold(options.deadAirThreshold); + const state: VideoModeState = { + deadAirDepth: 0, + deadAirSpans: [], + highlightImageIndex: 0, + highlights: [], + outputs: {}, + sourceRange: {}, + startedAt: performance.now(), + }; + let testInfoForOutputPaths: TestInfo | undefined; + const getVideoTimestamp = () => { + const now = performance.now(); + return Math.round(now - (state.startedAt || now)); + }; + const controls: VideoModeControls = { + deadAir: async (action) => { + return await recordDeadAir(state, action); + }, + getVideoTimestamp, + metadata: async () => { + if (!testInfoForOutputPaths) { + return metadataFor(state); + } + + return await readVideoModeMetadata(videoModeOutputPaths(testInfoForOutputPaths).metadata, () => + metadataFor(state), + ); + }, + outputPaths: () => { + if (!testInfoForOutputPaths) { + throw new Error("videoMode.outputPaths() is only available after addPlugins registers videoMode"); + } + + return videoModeOutputPaths(testInfoForOutputPaths); + }, + setEndTime: (ms = getVideoTimestamp()) => { + state.sourceRange.end = resolveVideoTimestamp("setEndTime", ms); + }, + setStartTime: (ms = getVideoTimestamp()) => { + state.sourceRange.start = resolveVideoTimestamp("setStartTime", ms); + }, + }; return { + ...controls, name: "video-mode", + pageExtension: ({ testInfo }) => { + testInfoForOutputPaths = testInfo; + return { videoMode: controls }; + }, - middleware: async ({ locator, method }, next) => { - if (skipMethods.includes(method)) return next(); + middleware: async ({ locator, method, testInfo, timing }, next) => { + if (state.deadAirDepth > 0) return next(); // Skip if called from internal helpers (navigation, login flows etc) if (skipStackFrames.length > 0) { @@ -83,15 +1895,176 @@ export const videoMode = (options: VideoModeOptions = {}): Plugin => { if (skipStackFrames.some((frame) => stack.includes(frame))) return next(); } - using _ = await setupHighlight(locator, highlightStyle, pauseBefore); - return await next(); + if (method === "waitFor") { + try { + return await next(); + } finally { + recordActionElapsedDeadAirFromTiming(state, timing, { minimumMs: 0 }); + } + } + + recordMiddlewareWaitBeforeVideoMode(state, timing); + + if (skipMethods.includes(method)) { + try { + return await next(); + } finally { + if (timing.attachedAtStart) { + recordActionElapsedDeadAirFromTiming(state, timing, { minimumMs: 50 }); + } + recordAttachedWaitFromTiming(state, timing); + } + } + + const recordedHighlight = + highlight.mode === "off" + ? undefined + : await recordHighlight({ + color: highlight.color, + durationMs: highlight.durationMs, + locator, + method, + state, + testInfo, + thickness: highlight.thickness, + }); + + try { + return await next(); + } finally { + if (recordedHighlight && state.startedAt !== undefined) { + recordedHighlight.actionEnd = Math.max( + recordedHighlight.start, + Math.round(performance.now() - state.startedAt), + ); + } + if ( + !recordedHighlight && + (timing.attachedAtStart || timing.attachedAt === undefined) + ) { + recordActionElapsedDeadAirFromTiming(state, timing, { minimumMs: 50 }); + } + recordAttachedWaitFromTiming(state, timing); + } }, testLifecycle: (emitter) => { - return emitter.on("afterTest", async ({ testInfo }) => { - await new Promise((resolve) => setTimeout(resolve, pauseAfterTest)); - console.log(`video will be written to ${testInfo.outputDir}/video.webm`); + const offBeforeTest = emitter.on("beforeTest", () => { + state.deadAirDepth = 0; + state.deadAirSpans = []; + state.highlightImageIndex = 0; + state.highlights = []; + state.outputs = {}; + state.sourceRange = {}; + if (state.startedAt) { + recordDeadAirSpan(state, { + end: Math.round(performance.now() - state.startedAt), + start: 0, + }); + } else { + state.startedAt = performance.now(); + } }); + + const offAfterTestFinalize = emitter.on("afterTestFinalize", async ({ page, testInfo }) => { + const metadataBeforeVideo = metadataFor(state); + const deadAir = metadataBeforeVideo.deadAir; + const highlights = metadataBeforeVideo.highlights; + const sourceRange = metadataBeforeVideo.sourceRange; + const video = page.video(); + + if (video) { + const paths = videoModeOutputPaths(testInfo); + await mkdir(testInfo.outputDir, { recursive: true }); + + if (!page.isClosed()) { + await page.close({ runBeforeUnload: false }); + } + + const recordedVideoPath = await video.path(); + await waitForNonEmptyFile(recordedVideoPath); + await copyFile(recordedVideoPath, paths.raw); + state.outputs.raw = VIDEO_MODE_RAW_FILE; + await testInfo.attach("video-raw", { + contentType: "video/webm", + path: paths.raw, + }); + + if ( + highlights.length > 0 || + deadAirThreshold !== undefined || + finalHold > 0 || + sourceRangeIsSet(sourceRange) + ) { + const wroteRenderedVideo = await renderVideo({ + deadAir, + finalHoldMs: finalHold, + highlightMode: highlight.mode === "pointer" ? "pointer" : "outline", + highlights, + inputPath: paths.raw, + outputDir: testInfo.outputDir, + outputPath: paths.rendered, + sourceRange, + thresholdMs: deadAirThreshold, + }); + + if (wroteRenderedVideo) { + state.outputs.rendered = VIDEO_MODE_RENDERED_FILE; + await testInfo.attach("video-rendered", { + contentType: "video/webm", + path: paths.rendered, + }); + } + } + + state.outputs.player = VIDEO_MODE_PLAYER_FILE; + await writeFile( + paths.player, + videoModePlayerHtml({ + raw: state.outputs.raw, + rendered: state.outputs.rendered, + }), + ); + + const reportPlayerHtml = videoModePlayerHtml({ + raw: await playwrightReportAttachmentName(paths.raw), + rendered: state.outputs.rendered + ? await playwrightReportAttachmentName(paths.rendered) + : undefined, + }); + await writeFile(paths.reportPlayer, reportPlayerHtml); + await testInfo.attach("video-mode-player", { + contentType: "text/html", + path: paths.reportPlayer, + }); + } + + const metadata = metadataFor(state); + if ( + metadata.deadAir.length > 0 || + metadata.highlights.length > 0 || + metadata.outputs.player || + metadata.outputs.raw || + metadata.outputs.rendered || + sourceRangeIsSet(metadata.sourceRange) + ) { + const path = videoModeOutputPaths(testInfo).metadata; + await mkdir(testInfo.outputDir, { recursive: true }); + await writeFile(path, `${JSON.stringify(metadata, null, 2)}\n`); + await testInfo.attach("video-mode", { + contentType: "application/json", + path, + }); + } + + state.startedAt = undefined; + console.log(`video-mode metadata written to ${videoModeOutputPaths(testInfo).metadata}`); + }); + + return () => { + offBeforeTest(); + offAfterTestFinalize(); + }; }, }; }; diff --git a/writing-middlewright-tests.md b/writing-middlewright-tests.md new file mode 100644 index 0000000..0f540bd --- /dev/null +++ b/writing-middlewright-tests.md @@ -0,0 +1,16 @@ +## Timeouts + +The default `actionTimeout` in your playwright config should be *very aggressive* and short. The `spinner-waiter` plugin allows this. If and when test fail because of this, there are two recommended courses of action, neither of which involves just bumping an assertion timeout. The first is of course to just figure out why the UI is sometimes slow and fix it. But if that's not possible, or beyond the scope of the work you're doing, the second recommended fix is to add a loading spinner to the product UI - we've identified a slow part of your app, so real users should also see a loading spinner, or some text like "Loading..."/"Pending..."/"Creating foobar..." etc. + +## Locators over `expect` + +Avoid using the expect-based API for asserting that UI is visible/in a particular state. For example, don't bother with `await expect(page.getByRole("button", { name: "Run" })).toBeEnabled()` before clicking a button. Just call `await page.getByRole("button", { name: "Run" }).click()` directly. The `.click` implementation already waits for the button to exist, be visible, and to be enabled. Similarly if you want to assert that something is present on the page you can just do `await page.getByText("Welcome").waitFor()`. No need for any `await expect(...).toBeVisible()` rubbish. Similarly, no stupid assertions like `await expect(plugged.getByText("Receipt ready")).toContainText("Receipt ready");`. Just use `await page.getbyText("Receipt ready").waitFor()`. + +Avoid using `timeout` for actions like `click`, `waitFor` etc. Read the [middlewright docs](https://github.com/iterate/middlewright) for why (TL;DR: we should have progress UI in our app rather than bumping test timeouts): `.waitFor({ timeout: 5_000 })` + +Avoid doing `await myButton.waitFor()` and then `await runButton.click()`. It's another code-smell. `.click()` should _already_ wait for the button to be clickable so the `.waitFor()` is doing nothing other than give you another chance to run the test and hope for the flake gods to smile on you this time. + + +## Error UI + +For common developer pitfalls, instead of littering your test code with defensive try/catch statements and custom selectors for app error UI, just add the `data-type="error"` attribute to relevant UI elements. Then, the `ui-error-reporter` plugin will pick up any errors on screen automatically (including toasts rendered using the `sonner` library). The plugin will find elements annotated in this way and include their text content in error reports, so agents and humans will quickly be able to get an indication of what went wrong.