From e0a2cff6f80487efe7383c9c602443d33fbdd461 Mon Sep 17 00:00:00 2001
From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com>
Date: Wed, 17 Jun 2026 17:22:45 +0100
Subject: [PATCH 01/35] Improve spinner waiter late-spinner failures
Fail fast in the no-spinner path so a delayed loading indicator cannot appear during the final Playwright action timeout and make the add-spinner hint contradict the current UI.
Also preserve the expanded loading-text selector via RegExp.source and add a regression covering a late spinner. This commit includes the staged spec readability cleanup from the working tree.
---
spec/spinner-waiter.spec.ts | 29 ++++++++++++++++++++++
src/plugins/spinner-waiter.ts | 46 +++++++++++++++++++++++++++++------
2 files changed, 68 insertions(+), 7 deletions(-)
diff --git a/spec/spinner-waiter.spec.ts b/spec/spinner-waiter.spec.ts
index 33c9d80..6833297 100644
--- a/spec/spinner-waiter.spec.ts
+++ b/spec/spinner-waiter.spec.ts
@@ -47,6 +47,35 @@ test("slow button fails when spinner doesn't match selector", async ({ page }) =
expect(error.message).toMatch(/Timeout .* exceeded/);
expect(error.message).toMatch(/If this is a slow operation.../);
});
+
+test("fails before a late spinner can make the no-spinner hint misleading", async ({ page }) => {
+ await page.setContent(`
+ start operation
+
Loading...
+
+ `);
+
+ await page.locator("#start").click();
+
+ const start = Date.now();
+ const error = await page.getByText("operation complete").waitFor().catch((e: Error) => e);
+ const elapsed = Date.now() - start;
+
+ expect(error).toBeInstanceOf(Error);
+ expect(error?.message).toMatch(/If this is a slow operation.../);
+ expect(elapsed).toBeLessThan(1500); // we don't tolerate the spinner taking a long time to appear
+ expect(await page.locator('[aria-label="Loading"]').isVisible()).toBe(false);
+});
+
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/src/plugins/spinner-waiter.ts b/src/plugins/spinner-waiter.ts
index 4ee9253..7817d1f 100644
--- a/src/plugins/spinner-waiter.ts
+++ b/src/plugins/spinner-waiter.ts
@@ -8,8 +8,8 @@
*/
import { AsyncLocalStorage } from "node:async_hooks";
import type { Locator } from "@playwright/test";
-import type { Plugin, LocatorWithOriginal } from "../plugin-system.ts";
-import { adjustError } from "../plugin-system.ts";
+import type { ActionContext, LocatorWithOriginal, Plugin } from "../plugin-system.ts";
+import { adjustError, oneArgMethods } from "../plugin-system.ts";
export type SpinnerWaiterOptions = {
/** Selectors that indicate loading state */
@@ -22,12 +22,17 @@ export type SpinnerWaiterOptions = {
log?: (message: string) => void;
};
+/** Match `loading...`, (or really `anyVerbing...`). Also matches an ellipsis character "…" rather than "..." since LLMs like fancy unicode. */
+const loadingTextPattern = /(loading|pending|creating|verifying|starting|processing|syncing|building|\b\w+ing)[\s\w]*(\.\.\.|…)$/;
+
const defaultSelectors = [
`[aria-label="Loading"]`,
`[data-spinner='true']`,
- `:text-matches("(loading|pending|creating|verifying|starting|processing|syncing)\\.\\.\\.$", "i")`,
+ `:text-matches(${JSON.stringify(loadingTextPattern.source)}, "i")`,
];
+const oneArgMethodNames = new Set(oneArgMethods);
+
const defaults: Required = {
spinnerSelectors: defaultSelectors,
spinnerTimeout: 30_000,
@@ -66,7 +71,7 @@ export const spinnerWaiter = Object.assign(
return {
name: "spinner-waiter",
- middleware: async ({ locator, method, page }, next) => {
+ middleware: async ({ args, locator, method, page }, next) => {
const settings = getSettings(options);
if (settings.disabled) return next();
@@ -87,9 +92,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, proceeding anyway`);
+ settings.log(`${locator} not visible, no spinner, failing fast`);
try {
- return await next();
+ return await callOriginalWithTimeout(locator, method, args, 1);
} catch (error) {
adjustError(error as Error, suggestSpinnerMessage(spinnerLocator), "spinner-waiter.ts");
throw error;
@@ -147,7 +152,34 @@ async function waitForVisible(locator: Locator, { timeout = 1000 } = {}) {
if (await locator.isVisible()) return true;
await new Promise((resolve) => setTimeout(resolve, 100));
}
- return false;
+ return await locator.isVisible();
+}
+
+async function callOriginalWithTimeout(
+ locator: LocatorWithOriginal,
+ method: ActionContext["method"],
+ args: unknown[],
+ timeout: number,
+) {
+ return await (locator[`${method}_original`] as Function)(
+ ...withTimeoutOption(method, args, timeout),
+ );
+}
+
+function withTimeoutOption(method: ActionContext["method"], args: unknown[], timeout: number) {
+ const optionsIndex = oneArgMethodNames.has(method) ? 1 : 0;
+ const nextArgs = [...args];
+ const options = nextArgs[optionsIndex];
+ if (isOptionsObject(options)) {
+ nextArgs[optionsIndex] = { ...options, timeout };
+ } else {
+ nextArgs[optionsIndex] = { timeout };
+ }
+ return nextArgs;
+}
+
+function isOptionsObject(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
From 268d99cbb3eaf91ffd69e20368c4217281d92ca5 Mon Sep 17 00:00:00 2001
From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com>
Date: Fri, 19 Jun 2026 22:38:17 +0100
Subject: [PATCH 02/35] better
---
AGENTS.md | 7 +
README.md | 15 +-
spec/plugin-system.spec.ts | 33 +++
spec/video-mode-ffmpeg.spec.ts | 132 +++++++++++
spec/video-mode.spec.ts | 143 ++++++++++-
src/index.ts | 2 +
src/plugin-system.ts | 98 +++++++-
src/plugins/index.ts | 9 +-
src/plugins/video-mode.ts | 422 ++++++++++++++++++++++++++++++++-
9 files changed, 843 insertions(+), 18 deletions(-)
create mode 100644 AGENTS.md
create mode 100644 spec/video-mode-ffmpeg.spec.ts
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..3652b56
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,7 @@
+# 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`.
diff --git a/README.md b/README.md
index 43832f1..b6eb45a 100644
--- a/README.md
+++ b/README.md
@@ -130,18 +130,29 @@ 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: waits for the target element to become attached as dead air, outlines the element in gold, pauses before each action, and pauses after the test as dead air so the final cut doesn't keep teardown padding. Enable it conditionally (e.g. `!!process.env.VIDEO_MODE && videoMode()`) together with Playwright's `video: "on"` and a generous `actionTimeout`.
```ts
-videoMode({
+const video = videoMode({
pauseBefore: 1000,
pauseAfterTest: 3000,
highlightStyle: "3px solid gold",
skipMethods: ["waitFor"],
skipStackFrames: ["test-helpers.ts"], // don't slow down internal login/setup helpers
});
+
+// Use the returned plugin for invisible setup/bookkeeping that should not be
+// highlighted or slowed in video mode.
+await video.deadAir(async () => {
+ await page.goto("/login");
+ await page.locator("#email").fill("demo@example.com");
+});
```
+When Playwright video recording is enabled, `videoMode` saves `video-raw.webm`, uses `ffmpeg` to write `video-tight.webm` with dead air removed, and attaches both videos plus `video-mode.json` to the test report. If `ffmpeg` or `ffprobe` is missing, the trim step fails plainly so you know to install ffmpeg.
+
+Put `spinnerWaiter` before `videoMode` when you use both. Spinner-waiter still owns spinner-specific waiting and errors, while video-mode observes the pre-action "wait for attached" period as dead air and highlights 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.
diff --git a/spec/plugin-system.spec.ts b/spec/plugin-system.spec.ts
index ac8747c..540e0a6 100644
--- a/spec/plugin-system.spec.ts
+++ b/spec/plugin-system.spec.ts
@@ -72,6 +72,39 @@ 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(`hi `);
+
+ 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("pages without plugins fall through to the original behavior", async ({
page,
context,
diff --git a/spec/video-mode-ffmpeg.spec.ts b/spec/video-mode-ffmpeg.spec.ts
new file mode 100644
index 0000000..28f57ff
--- /dev/null
+++ b/spec/video-mode-ffmpeg.spec.ts
@@ -0,0 +1,132 @@
+import { execFile as execFileCallback } from "node:child_process";
+import { readFile, stat } from "node:fs/promises";
+import { join } from "node:path";
+import { promisify } from "node:util";
+import { test, expect } from "@playwright/test";
+import { addPlugins, spinnerWaiter, videoMode } from "../src/index.ts";
+
+const execFile = promisify(execFileCallback);
+
+test.use({ video: "on" });
+
+test("writes a video with dead air removed", async ({ page }, testInfo) => {
+ const video = videoMode({ pauseBefore: 1000, pauseAfterTest: 700 });
+ {
+ await using plugged = await addPlugins({
+ page,
+ testInfo,
+ plugins: [
+ spinnerWaiter({
+ log: (message) => console.log(`[spinnerWaiter] ${message}`),
+ spinnerTimeout: 12_000,
+ }),
+ video,
+ ],
+ });
+ await plugged.setViewportSize({ width: 800, height: 600 });
+ await plugged.setContent(`
+
+ Dead air workflow
+ Ready
+ Start import
+
+
+ `);
+
+ await plugged.locator("#start").click();
+ await plugged.locator("#review").click();
+ await plugged.locator("#approve").click();
+ await video.deadAir(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 1700));
+ });
+ await plugged.locator("#receipt").click();
+
+ await plugged.locator("#done").waitFor();
+ await expect(plugged.locator("#done")).toContainText("Receipt ready");
+ }
+
+ const metadata = JSON.parse(
+ await readFile(join(testInfo.outputDir, "video-mode.json"), "utf8"),
+ );
+ expect(metadata).toMatchObject({
+ outputs: {
+ deadAirRemoved: "video-tight.webm",
+ raw: "video-raw.webm",
+ },
+ });
+ expect(
+ metadata.deadAir.filter((span: { end: number; start: number }) => span.end - span.start >= 1500)
+ .length,
+ ).toBeGreaterThanOrEqual(4);
+
+ const rawPath = join(testInfo.outputDir, metadata.outputs.raw);
+ const tightPath = join(testInfo.outputDir, metadata.outputs.deadAirRemoved);
+ const rawStats = await stat(rawPath);
+ const tightStats = await stat(tightPath);
+ console.log(`raw video written to ${rawPath}`);
+ console.log(`tight video written to ${tightPath}`);
+
+ expect(rawStats.size).toBeGreaterThan(0);
+ expect(tightStats.size).toBeGreaterThan(0);
+
+ const rawDuration = await videoDurationMs(rawPath);
+ const tightDuration = await videoDurationMs(tightPath);
+ expect(tightDuration).toBeLessThan(rawDuration);
+});
+
+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);
+};
diff --git a/spec/video-mode.spec.ts b/spec/video-mode.spec.ts
index 740c21d..5d3f1ca 100644
--- a/spec/video-mode.spec.ts
+++ b/spec/video-mode.spec.ts
@@ -1,3 +1,5 @@
+import { readFile } from "node:fs/promises";
+import { join } from "node:path";
import { test, expect } from "@playwright/test";
import { addPlugins, videoMode } from "../src/index.ts";
@@ -31,15 +33,152 @@ test("highlights the element while the action runs, then cleans up", async ({
});
test("skipped methods are not highlighted or slowed down", async ({ page }, testInfo) => {
+ const video = videoMode({ pauseBefore: 5000, pauseAfterTest: 50, skipMethods: ["click"] });
await using plugged = await addPlugins({
page,
testInfo,
- plugins: [videoMode({ pauseBefore: 5000, pauseAfterTest: 50, skipMethods: ["click"] })],
+ plugins: [video],
});
- await plugged.setContent(`press `);
+ 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);
+ expect(video.metadata().deadAir.some((span) => span.end - span.start >= 100)).toBe(true);
+});
+
+test("marks pre-action waits for attachment as dead air", async ({ page }, testInfo) => {
+ const video = videoMode({ pauseBefore: 20, pauseAfterTest: 50 });
+ await using plugged = await addPlugins({
+ page,
+ testInfo,
+ plugins: [video],
+ });
+ await plugged.setContent(`
+
+
+ `);
+
+ await plugged.locator("#late").click();
+
+ await expect(plugged.locator("#result")).toContainText("clicked");
+ expect(video.metadata().deadAir).toContainEqual(
+ expect.objectContaining({
+ end: expect.any(Number),
+ start: expect.any(Number),
+ }),
+ );
+ expect(video.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({ pauseBefore: 20, pauseAfterTest: 50 })],
+ });
+ 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({ pauseBefore: 20, pauseAfterTest: 50 });
+ await using plugged = await addPlugins({
+ page,
+ testInfo,
+ plugins: [video],
+ });
+ await plugged.setContent(`
+
+ `);
+
+ await plugged.locator("#late").waitFor({ state: "attached" });
+
+ expect(video.metadata().deadAir.some((span) => span.end - span.start >= 100)).toBe(true);
+});
+
+test("deadAir runs actions without video highlighting and records metadata", async ({
+ page,
+}, testInfo) => {
+ const video = videoMode({ pauseBefore: 5000, pauseAfterTest: 50 });
+ {
+ await using plugged = await addPlugins({
+ page,
+ testInfo,
+ plugins: [video],
+ });
+
+ await plugged.setContent(`
+ press
+
+
+ `);
+
+ const start = Date.now();
+ await video.deadAir(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ await plugged.locator("#btn").click();
+ });
+
+ expect(Date.now() - start).toBeLessThan(2000);
+ await expect(plugged.locator("#result")).toContainText("(no style)");
+ expect(video.metadata()).toMatchObject({
+ outputs: {},
+ schemaVersion: 1,
+ timebase: "ms",
+ });
+ expect(video.metadata().deadAir).toContainEqual(
+ expect.objectContaining({ end: expect.any(Number), start: expect.any(Number) }),
+ );
+ }
+
+ const metadata = JSON.parse(
+ await readFile(join(testInfo.outputDir, "video-mode.json"), "utf8"),
+ );
+ expect(metadata).toMatchObject({
+ 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..5017322 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -6,6 +6,8 @@ export {
type Plugin,
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..9013691 100644
--- a/src/plugin-system.ts
+++ b/src/plugin-system.ts
@@ -71,6 +71,20 @@ 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 */
@@ -95,12 +109,17 @@ export type Plugin = {
const PLUGIN_STATE = Symbol("playwrightPluginState");
type PluginState = {
- actionMiddlewares: ActionMiddleware[];
+ actionMiddlewares: RegisteredActionMiddleware[];
lifecycleEmitter: Emittery;
lifecycleCleanups: (() => void)[];
testInfo: TestInfo;
};
+type RegisteredActionMiddleware = {
+ name: string;
+ middleware: ActionMiddleware;
+};
+
type PageWithPlugins = Page & {
[PLUGIN_STATE]: PluginState;
[Symbol.asyncDispose]: () => Promise;
@@ -153,7 +172,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) {
@@ -214,6 +236,47 @@ 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;
+ }
+
+ if (await locatorIsAttached(locator)) {
+ 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,
@@ -261,6 +324,17 @@ const patchLocatorPrototype = (
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 +342,33 @@ 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 () => {
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/index.ts b/src/plugins/index.ts
index 6ab1545..c79d292 100644
--- a/src/plugins/index.ts
+++ b/src/plugins/index.ts
@@ -1,5 +1,12 @@
export { hydrationWaiter, type HydrationWaiterOptions } from "./hydration-waiter.ts";
-export { videoMode, type VideoModeOptions } from "./video-mode.ts";
+export {
+ videoMode,
+ type VideoModeMetadata,
+ type VideoModeOptions,
+ type VideoModeOutputs,
+ type VideoModePlugin,
+ type VideoModeSpan,
+} 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/video-mode.ts b/src/plugins/video-mode.ts
index af2fde6..cc03f6b 100644
--- a/src/plugins/video-mode.ts
+++ b/src/plugins/video-mode.ts
@@ -6,9 +6,42 @@
* original: the hardcoded skip for iterate's test-helpers file is now the
* `skipStackFrames` option.
*/
+import { execFile as execFileCallback } from "node:child_process";
+import { mkdir, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { promisify } from "node:util";
import type { Locator } from "@playwright/test";
import type { Plugin, OverrideableMethod } from "../plugin-system.ts";
+const execFile = promisify(execFileCallback);
+
+export type VideoModeSpan = {
+ start: number;
+ end: number;
+};
+
+export type VideoModeOutputs = {
+ raw?: string;
+ deadAirRemoved?: string;
+};
+
+export type VideoModeMetadata = {
+ schemaVersion: 1;
+ timebase: "ms";
+ deadAir: VideoModeSpan[];
+ outputs: VideoModeOutputs;
+};
+
+export type VideoModePlugin = Plugin & {
+ /**
+ * Run invisible video bookkeeping without video-mode highlighting/pauses,
+ * and write the elapsed span to video-mode metadata.
+ */
+ deadAir(action: () => Promise): Promise;
+ /** Current metadata snapshot. Written to video-mode.json after the test. */
+ metadata(): VideoModeMetadata;
+};
+
export type VideoModeOptions = {
/** Pause duration before action (ms). Default: 1000 */
pauseBefore?: number;
@@ -26,8 +59,26 @@ export type VideoModeOptions = {
skipStackFrames?: string[];
};
+type VideoModeState = {
+ deadAirDepth: number;
+ deadAirSpans: VideoModeSpan[];
+ outputs: VideoModeOutputs;
+ startedAt?: number;
+};
+
+type TightVideoSegment = {
+ start: number;
+ end: number;
+};
+
/** Highlight element, pause, return disposable that unhighlights */
const setupHighlight = async (locator: Locator, style: string, pauseMs: number) => {
+ if (!(await locatorIsAttached(locator))) {
+ return {
+ [Symbol.dispose]: () => {},
+ };
+ }
+
try {
await locator.evaluate((el, s) => {
const prev = el.getAttribute("style") || "";
@@ -60,22 +111,278 @@ const setupHighlight = async (locator: Locator, style: string, pauseMs: number)
};
};
+const metadataFor = (state: VideoModeState): VideoModeMetadata => {
+ return {
+ deadAir: mergeVideoSpans(state.deadAirSpans),
+ outputs: state.outputs,
+ schemaVersion: 1,
+ timebase: "ms",
+ };
+};
+
+const recordDeadAir = async (state: VideoModeState, action: () => Promise) => {
+ if (!state.startedAt || 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;
+ state.deadAirSpans.push({
+ end: Math.round(end),
+ start: Math.round(start),
+ });
+ }
+};
+
+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 formatSeconds = (ms: number) => {
+ const value = (ms / 1000).toFixed(3).replace(/\.?0+$/, "");
+ return value || "0";
+};
+
+const clipVideoSpan = (span: VideoModeSpan, finalEnd: number): VideoModeSpan | undefined => {
+ const start = Math.max(0, Math.min(Math.round(span.start), finalEnd));
+ const end = Math.max(0, Math.min(Math.round(span.end), finalEnd));
+
+ if (end <= start) {
+ return undefined;
+ }
+
+ return { end, start };
+};
+
+const videoSpansOverlap = (left: VideoModeSpan, right: VideoModeSpan) => {
+ return left.start < right.end && right.start < left.end;
+};
+
+const locatorIsAttached = async (locator: Locator) => {
+ try {
+ return (await locator.count()) > 0;
+ } catch {
+ return false;
+ }
+};
+
+const waitForTargetsAttached = (args: unknown[]) => {
+ const targetState = (args[0] as { state?: string } | undefined)?.state;
+ return !targetState || targetState === "attached";
+};
+
+const recordAttachedWaitFromTiming = async (
+ state: VideoModeState,
+ timing: { actionStartedAt: number; attachedAt?: number; attachedAtStart: boolean },
+ locator: Locator,
+) => {
+ if (!state.startedAt || timing.attachedAtStart) {
+ return;
+ }
+
+ if (timing.attachedAt === undefined && (await locatorIsAttached(locator))) {
+ timing.attachedAt = performance.now();
+ }
+
+ if (timing.attachedAt === undefined) {
+ return;
+ }
+
+ const start = Math.round(timing.actionStartedAt - state.startedAt);
+ const end = Math.round(timing.attachedAt - state.startedAt);
+
+ if (end > start) {
+ state.deadAirSpans.push({ end, start });
+ }
+};
+
+const tightVideoSegments = (options: {
+ deadAir: VideoModeSpan[];
+ finalEnd: number;
+}): TightVideoSegment[] => {
+ const finalEnd = Math.max(0, Math.round(options.finalEnd));
+
+ if (finalEnd === 0) {
+ return [];
+ }
+
+ const deadAir = mergeVideoSpans(
+ options.deadAir
+ .map((span) => clipVideoSpan(span, finalEnd))
+ .filter((span): span is VideoModeSpan => Boolean(span)),
+ );
+ const boundaries = new Set([0, finalEnd]);
+
+ for (const span of deadAir) {
+ boundaries.add(span.start);
+ boundaries.add(span.end);
+ }
+
+ const sortedBoundaries = [...boundaries].sort((left, right) => left - right);
+ const segments: TightVideoSegment[] = [];
+
+ for (let index = 0; index < sortedBoundaries.length - 1; index += 1) {
+ const start = sortedBoundaries[index];
+ const end = sortedBoundaries[index + 1];
+
+ if (end <= start) {
+ continue;
+ }
+
+ if (deadAir.some((span) => videoSpansOverlap(span, { end, start }))) {
+ continue;
+ }
+
+ const previous = segments[segments.length - 1];
+
+ if (previous && previous.end === start) {
+ previous.end = end;
+ continue;
+ }
+
+ segments.push({ end, start });
+ }
+
+ return segments;
+};
+
+const tightVideoFilter = (options: {
+ deadAir: VideoModeSpan[];
+ finalEnd: number;
+}) => {
+ const segments = tightVideoSegments(options);
+
+ if (
+ segments.length === 0 ||
+ (segments.length === 1 &&
+ segments[0].start === 0 &&
+ segments[0].end === Math.round(options.finalEnd))
+ ) {
+ return undefined;
+ }
+
+ const filters: string[] = [];
+ const labels: string[] = [];
+
+ for (let index = 0; index < segments.length; index += 1) {
+ const segment = segments[index];
+ const label = `tight${index}`;
+ labels.push(`[${label}]`);
+ filters.push(
+ `[0:v]trim=start=${formatSeconds(segment.start)}:end=${formatSeconds(segment.end)},setpts=PTS-STARTPTS[${label}]`,
+ );
+ }
+
+ const outputLabel = labels.length === 1 ? labels[0].slice(1, -1) : "tightout";
+
+ if (labels.length > 1) {
+ filters.push(`${labels.join("")}concat=n=${labels.length}:v=1:a=0[${outputLabel}]`);
+ }
+
+ return {
+ outputLabel,
+ value: filters.join(";"),
+ };
+};
+
+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 },
+ );
+ const seconds = Number(stdout.trim());
+
+ if (!Number.isFinite(seconds) || seconds <= 0) {
+ throw new Error(`Could not read video duration from ffprobe output: ${stdout}`);
+ }
+
+ return Math.round(seconds * 1000);
+};
+
+const removeDeadAirFromVideo = async (options: {
+ inputPath: string;
+ outputPath: string;
+ deadAir: VideoModeSpan[];
+}) => {
+ const finalEnd = await videoDurationMs(options.inputPath);
+ const filter = tightVideoFilter({
+ deadAir: options.deadAir,
+ finalEnd,
+ });
+
+ if (!filter) {
+ return false;
+ }
+
+ await execFile(
+ "ffmpeg",
+ [
+ "-y",
+ "-i",
+ options.inputPath,
+ "-filter_complex",
+ filter.value,
+ "-map",
+ `[${filter.outputLabel}]`,
+ "-an",
+ options.outputPath,
+ ],
+ { maxBuffer: 10 * 1024 * 1024 },
+ );
+
+ return true;
+};
+
/**
* Highlights elements before actions and pauses for video recording.
* Also pauses after tests complete for better video endings.
*/
-export const videoMode = (options: VideoModeOptions = {}): Plugin => {
+export const videoMode = (options: VideoModeOptions = {}): VideoModePlugin => {
const pauseBefore = options.pauseBefore || 1000;
const pauseAfterTest = options.pauseAfterTest || 3000;
const highlightStyle = options.highlightStyle || "3px solid gold";
const skipMethods = options.skipMethods || ["waitFor"];
const skipStackFrames = options.skipStackFrames || [];
+ const state: VideoModeState = {
+ deadAirDepth: 0,
+ deadAirSpans: [],
+ outputs: {},
+ startedAt: performance.now(),
+ };
return {
name: "video-mode",
- middleware: async ({ locator, method }, next) => {
- if (skipMethods.includes(method)) return next();
+ middleware: async ({ args, locator, method, timing }, next) => {
+ if (state.deadAirDepth > 0) return next();
// Skip if called from internal helpers (navigation, login flows etc)
if (skipStackFrames.length > 0) {
@@ -83,15 +390,114 @@ 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") {
+ if (!waitForTargetsAttached(args)) {
+ return next();
+ }
+ try {
+ return await next();
+ } finally {
+ await recordAttachedWaitFromTiming(state, timing, locator);
+ }
+ }
+
+ if (skipMethods.includes(method)) {
+ try {
+ return await next();
+ } finally {
+ await recordAttachedWaitFromTiming(state, timing, locator);
+ }
+ }
+
+ try {
+ using _ = await setupHighlight(locator, highlightStyle, pauseBefore);
+ return await next();
+ } finally {
+ await recordAttachedWaitFromTiming(state, timing, locator);
+ }
+ },
+
+ deadAir: async (action) => {
+ return await recordDeadAir(state, action);
},
+ metadata: () => metadataFor(state),
+
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.outputs = {};
+ if (state.startedAt) {
+ state.deadAirSpans.push({
+ end: Math.round(performance.now() - state.startedAt),
+ start: 0,
+ });
+ } else {
+ state.startedAt = performance.now();
+ }
});
+
+ const offAfterTest = emitter.on("afterTest", async ({ page, testInfo }) => {
+ await recordDeadAir(state, async () => {
+ await new Promise((resolve) => setTimeout(resolve, pauseAfterTest));
+ });
+
+ const deadAir = metadataFor(state).deadAir;
+ const video = page.video();
+
+ if (video) {
+ const rawPath = join(testInfo.outputDir, "video-raw.webm");
+ const tightPath = join(testInfo.outputDir, "video-tight.webm");
+ await mkdir(testInfo.outputDir, { recursive: true });
+
+ if (!page.isClosed()) {
+ await page.close({ runBeforeUnload: false });
+ }
+
+ await video.saveAs(rawPath);
+ state.outputs.raw = "video-raw.webm";
+ await testInfo.attach("video-raw", {
+ contentType: "video/webm",
+ path: rawPath,
+ });
+
+ if (deadAir.length > 0) {
+ const wroteTightVideo = await removeDeadAirFromVideo({
+ deadAir,
+ inputPath: rawPath,
+ outputPath: tightPath,
+ });
+
+ if (wroteTightVideo) {
+ state.outputs.deadAirRemoved = "video-tight.webm";
+ await testInfo.attach("video-tight", {
+ contentType: "video/webm",
+ path: tightPath,
+ });
+ }
+ }
+ }
+
+ const metadata = metadataFor(state);
+ if (metadata.deadAir.length > 0) {
+ const path = join(testInfo.outputDir, "video-mode.json");
+ 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 ${testInfo.outputDir}/video-mode.json`);
+ });
+
+ return () => {
+ offBeforeTest();
+ offAfterTest();
+ };
},
};
};
From 83a0424bbc380863b8dca32aa9fdc310b8e0f398 Mon Sep 17 00:00:00 2001
From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com>
Date: Mon, 22 Jun 2026 12:42:35 +0100
Subject: [PATCH 03/35] Improve video-mode dead-air trimming
Teach spinnerWaiter to treat visible-but-disabled targets as not ready for click-like actions, so spinner-backed disabled buttons get the longer spinner wait instead of timing out at the action timeout.
Record elapsed middleware time before videoMode as dead air, which keeps spinnerWaiter independent while still trimming readiness waits from tight videos. Keep a short final visible hold before trimming after-test padding so the final result remains visible in the tight cut.
Update the ffmpeg video spec to use always-rendered disabled stage buttons, assert the final hold, and keep README guidance aligned with the plugin ordering behavior.
---
README.md | 4 +-
spec/spinner-waiter.spec.ts | 21 +++++++
spec/video-mode-ffmpeg.spec.ts | 102 +++++++++++++++++----------------
src/plugins/spinner-waiter.ts | 52 +++++++++++------
src/plugins/video-mode.ts | 71 +++++++++++++++++++++--
5 files changed, 176 insertions(+), 74 deletions(-)
diff --git a/README.md b/README.md
index b6eb45a..34d3c87 100644
--- a/README.md
+++ b/README.md
@@ -130,7 +130,7 @@ uiErrorReporter({ selector: '[data-type="error"]' });
### videoMode
-For producing demo/debugging videos people can actually follow: waits for the target element to become attached as dead air, outlines the element in gold, pauses before each action, and pauses after the test as dead air so the final cut doesn't keep teardown padding. 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, outlines the element in gold, pauses before each action, and keeps a short final hold before trimming after-test padding as dead air. Enable it conditionally (e.g. `!!process.env.VIDEO_MODE && videoMode()`) together with Playwright's `video: "on"` and a generous `actionTimeout`.
```ts
const video = videoMode({
@@ -151,7 +151,7 @@ await video.deadAir(async () => {
When Playwright video recording is enabled, `videoMode` saves `video-raw.webm`, uses `ffmpeg` to write `video-tight.webm` with dead air removed, and attaches both videos plus `video-mode.json` to the test report. If `ffmpeg` or `ffprobe` is missing, the trim step fails plainly so you know to install ffmpeg.
-Put `spinnerWaiter` before `videoMode` when you use both. Spinner-waiter still owns spinner-specific waiting and errors, while video-mode observes the pre-action "wait for attached" period as dead air and highlights immediately before the action.
+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 highlights immediately before the action.
### llmRecover
diff --git a/spec/spinner-waiter.spec.ts b/spec/spinner-waiter.spec.ts
index 6833297..c9c3208 100644
--- a/spec/spinner-waiter.spec.ts
+++ b/spec/spinner-waiter.spec.ts
@@ -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(`
+ Submit approval
+ 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();
diff --git a/spec/video-mode-ffmpeg.spec.ts b/spec/video-mode-ffmpeg.spec.ts
index 28f57ff..0adcb14 100644
--- a/spec/video-mode-ffmpeg.spec.ts
+++ b/spec/video-mode-ffmpeg.spec.ts
@@ -25,71 +25,70 @@ test("writes a video with dead air removed", async ({ page }, testInfo) => {
});
await plugged.setViewportSize({ width: 800, height: 600 });
await plugged.setContent(`
-
- Dead air workflow
- Ready
- Start import
+
+ Loading...
+
+ Start import
+ Review records
+ Approve import
+ Download receipt
+
+
`);
- await plugged.locator("#start").click();
- await plugged.locator("#review").click();
- await plugged.locator("#approve").click();
+ await plugged.getByText("Start import").click();
+ await plugged.getByText("Review records").click();
+ await plugged.getByText("Approve import").click();
await video.deadAir(async () => {
await new Promise((resolve) => setTimeout(resolve, 1700));
});
- await plugged.locator("#receipt").click();
+ await plugged.getByText("Download receipt").click();
- await plugged.locator("#done").waitFor();
- await expect(plugged.locator("#done")).toContainText("Receipt ready");
+ await plugged.getByText("Receipt ready").waitFor();
+ await expect(plugged.getByText("Receipt ready")).toContainText("Receipt ready");
}
const metadata = JSON.parse(
@@ -105,6 +104,9 @@ test("writes a video with dead air removed", async ({ page }, testInfo) => {
metadata.deadAir.filter((span: { end: number; start: number }) => span.end - span.start >= 1500)
.length,
).toBeGreaterThanOrEqual(4);
+ const finalDeadAirSpan = metadata.deadAir[metadata.deadAir.length - 1];
+ const previousDeadAirSpan = metadata.deadAir[metadata.deadAir.length - 2];
+ expect(finalDeadAirSpan.start - previousDeadAirSpan.end).toBeGreaterThanOrEqual(400);
const rawPath = join(testInfo.outputDir, metadata.outputs.raw);
const tightPath = join(testInfo.outputDir, metadata.outputs.deadAirRemoved);
diff --git a/src/plugins/spinner-waiter.ts b/src/plugins/spinner-waiter.ts
index 7817d1f..1128917 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,
@@ -78,10 +87,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,7 +101,7 @@ 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);
} catch (error) {
@@ -107,18 +116,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 +139,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,13 +155,23 @@ export const spinnerWaiter = Object.assign(
},
);
-async function waitForVisible(locator: Locator, { timeout = 1000 } = {}) {
+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 waitForReady(
+ locator: Locator,
+ method: ActionContext["method"],
+ { timeout = 1000 } = {},
+) {
const start = Date.now();
while (Date.now() - start < timeout) {
- if (await locator.isVisible()) return true;
+ if (await locatorIsReady(locator, method)) return true;
await new Promise((resolve) => setTimeout(resolve, 100));
}
- return await locator.isVisible();
+ return await locatorIsReady(locator, method);
}
async function callOriginalWithTimeout(
@@ -183,12 +202,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 +216,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/video-mode.ts b/src/plugins/video-mode.ts
index cc03f6b..068de68 100644
--- a/src/plugins/video-mode.ts
+++ b/src/plugins/video-mode.ts
@@ -7,11 +7,11 @@
* `skipStackFrames` option.
*/
import { execFile as execFileCallback } from "node:child_process";
-import { mkdir, writeFile } from "node:fs/promises";
+import { copyFile, mkdir, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import type { Locator } from "@playwright/test";
-import type { Plugin, OverrideableMethod } from "../plugin-system.ts";
+import type { ActionTiming, Plugin, OverrideableMethod } from "../plugin-system.ts";
const execFile = promisify(execFileCallback);
@@ -198,6 +198,8 @@ const waitForTargetsAttached = (args: unknown[]) => {
return !targetState || targetState === "attached";
};
+const visibleTailAfterTestMs = (pauseAfterTest: number) => Math.min(500, pauseAfterTest);
+
const recordAttachedWaitFromTiming = async (
state: VideoModeState,
timing: { actionStartedAt: number; attachedAt?: number; attachedAtStart: boolean },
@@ -223,6 +225,27 @@ const recordAttachedWaitFromTiming = async (
}
};
+const recordMiddlewareWaitBeforeVideoMode = (state: VideoModeState, timing: ActionTiming) => {
+ if (!state.startedAt) {
+ 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);
+
+ if (end > start) {
+ state.deadAirSpans.push({ end, start });
+ }
+};
+
const tightVideoSegments = (options: {
deadAir: VideoModeSpan[];
finalEnd: number;
@@ -327,6 +350,29 @@ const videoDurationMs = async (path: string) => {
return Math.round(seconds * 1000);
};
+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 removeDeadAirFromVideo = async (options: {
inputPath: string;
outputPath: string;
@@ -394,6 +440,7 @@ export const videoMode = (options: VideoModeOptions = {}): VideoModePlugin => {
if (!waitForTargetsAttached(args)) {
return next();
}
+ recordMiddlewareWaitBeforeVideoMode(state, timing);
try {
return await next();
} finally {
@@ -401,6 +448,8 @@ export const videoMode = (options: VideoModeOptions = {}): VideoModePlugin => {
}
}
+ recordMiddlewareWaitBeforeVideoMode(state, timing);
+
if (skipMethods.includes(method)) {
try {
return await next();
@@ -439,9 +488,17 @@ export const videoMode = (options: VideoModeOptions = {}): VideoModePlugin => {
});
const offAfterTest = emitter.on("afterTest", async ({ page, testInfo }) => {
- await recordDeadAir(state, async () => {
- await new Promise((resolve) => setTimeout(resolve, pauseAfterTest));
- });
+ const visibleTailMs = visibleTailAfterTestMs(pauseAfterTest);
+ if (visibleTailMs > 0) {
+ await new Promise((resolve) => setTimeout(resolve, visibleTailMs));
+ }
+
+ const remainingAfterTestMs = pauseAfterTest - visibleTailMs;
+ if (remainingAfterTestMs > 0) {
+ await recordDeadAir(state, async () => {
+ await new Promise((resolve) => setTimeout(resolve, remainingAfterTestMs));
+ });
+ }
const deadAir = metadataFor(state).deadAir;
const video = page.video();
@@ -455,7 +512,9 @@ export const videoMode = (options: VideoModeOptions = {}): VideoModePlugin => {
await page.close({ runBeforeUnload: false });
}
- await video.saveAs(rawPath);
+ const recordedVideoPath = await video.path();
+ await waitForNonEmptyFile(recordedVideoPath);
+ await copyFile(recordedVideoPath, rawPath);
state.outputs.raw = "video-raw.webm";
await testInfo.attach("video-raw", {
contentType: "video/webm",
From bc8fb2dfbfeaef16b85842a571af6cbd59690619 Mon Sep 17 00:00:00 2001
From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com>
Date: Mon, 22 Jun 2026 14:37:39 +0100
Subject: [PATCH 04/35] Add video-mode dead-air threshold
---
README.md | 5 ++-
spec/video-mode-ffmpeg.spec.ts | 14 ++++++-
src/plugins/video-mode.ts | 73 +++++++++++++++++++++++++++++-----
3 files changed, 79 insertions(+), 13 deletions(-)
diff --git a/README.md b/README.md
index 34d3c87..eb2956b 100644
--- a/README.md
+++ b/README.md
@@ -136,6 +136,7 @@ For producing demo/debugging videos people can actually follow: marks pre-action
const video = videoMode({
pauseBefore: 1000,
pauseAfterTest: 3000,
+ deadAirThreshold: 300,
highlightStyle: "3px solid gold",
skipMethods: ["waitFor"],
skipStackFrames: ["test-helpers.ts"], // don't slow down internal login/setup helpers
@@ -149,7 +150,9 @@ await video.deadAir(async () => {
});
```
-When Playwright video recording is enabled, `videoMode` saves `video-raw.webm`, uses `ffmpeg` to write `video-tight.webm` with dead air removed, and attaches both videos plus `video-mode.json` to the test report. If `ffmpeg` or `ffprobe` is missing, the trim step fails plainly so you know to install ffmpeg.
+When Playwright video recording is enabled, `videoMode` saves `video-raw.webm` and attaches it with `video-mode.json` to the test report. Set `deadAirThreshold` to also use `ffmpeg` to write `video-tight.webm` with dead air removed. If `ffmpeg` or `ffprobe` is missing, the trim step fails plainly so you know to install ffmpeg.
+
+`video-mode.json` records raw dead-air spans. `deadAirThreshold` is applied only when writing the tight video: it keeps that much of each dead-air span, split across the start and end of the span. Spans at or below the threshold are left intact.
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 highlights immediately before the action.
diff --git a/spec/video-mode-ffmpeg.spec.ts b/spec/video-mode-ffmpeg.spec.ts
index 0adcb14..e827f54 100644
--- a/spec/video-mode-ffmpeg.spec.ts
+++ b/spec/video-mode-ffmpeg.spec.ts
@@ -10,7 +10,12 @@ const execFile = promisify(execFileCallback);
test.use({ video: "on" });
test("writes a video with dead air removed", async ({ page }, testInfo) => {
- const video = videoMode({ pauseBefore: 1000, pauseAfterTest: 700 });
+ const deadAirThresholdMs = 300;
+ const video = videoMode({
+ deadAirThreshold: deadAirThresholdMs,
+ pauseBefore: 1000,
+ pauseAfterTest: 700,
+ });
{
await using plugged = await addPlugins({
page,
@@ -120,7 +125,14 @@ test("writes a video with dead air removed", async ({ page }, testInfo) => {
const rawDuration = await videoDurationMs(rawPath);
const tightDuration = await videoDurationMs(tightPath);
+ const expectedTightDuration =
+ rawDuration -
+ metadata.deadAir.reduce((removedDuration: number, span: { end: number; start: number }) => {
+ return removedDuration + Math.max(0, span.end - span.start - deadAirThresholdMs);
+ }, 0);
+
expect(tightDuration).toBeLessThan(rawDuration);
+ expect(Math.abs(tightDuration - expectedTightDuration)).toBeLessThan(1000);
});
const videoDurationMs = async (path: string) => {
diff --git a/src/plugins/video-mode.ts b/src/plugins/video-mode.ts
index 068de68..dac2d2f 100644
--- a/src/plugins/video-mode.ts
+++ b/src/plugins/video-mode.ts
@@ -57,6 +57,11 @@ export type VideoModeOptions = {
* flows that shouldn't be slowed down. Default: []
*/
skipStackFrames?: string[];
+ /**
+ * Minimum amount of each dead-air span to keep in video-tight.webm, split
+ * evenly before and after the removed middle.
+ */
+ deadAirThreshold?: number;
};
type VideoModeState = {
@@ -71,6 +76,18 @@ type TightVideoSegment = {
end: number;
};
+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;
+};
+
/** Highlight element, pause, return disposable that unhighlights */
const setupHighlight = async (locator: Locator, style: string, pauseMs: number) => {
if (!(await locatorIsAttached(locator))) {
@@ -120,7 +137,19 @@ const metadataFor = (state: VideoModeState): VideoModeMetadata => {
};
};
-const recordDeadAir = async (state: VideoModeState, action: () => Promise) => {
+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 || state.deadAirDepth > 0) {
return await action();
}
@@ -133,7 +162,7 @@ const recordDeadAir = async (state: VideoModeState, action: () => Promise)
} finally {
state.deadAirDepth -= 1;
const end = performance.now() - state.startedAt;
- state.deadAirSpans.push({
+ recordDeadAirSpan(state, {
end: Math.round(end),
start: Math.round(start),
});
@@ -181,6 +210,21 @@ const clipVideoSpan = (span: VideoModeSpan, finalEnd: number): VideoModeSpan | u
return { end, start };
};
+const trimDeadAirSpan = (
+ span: VideoModeSpan,
+ thresholdMs: number,
+): VideoModeSpan | undefined => {
+ const padding = thresholdMs / 2;
+ const start = Math.round(span.start + padding);
+ const end = Math.round(span.end - padding);
+
+ if (end <= start) {
+ return undefined;
+ }
+
+ return { end, start };
+};
+
const videoSpansOverlap = (left: VideoModeSpan, right: VideoModeSpan) => {
return left.start < right.end && right.start < left.end;
};
@@ -220,12 +264,13 @@ const recordAttachedWaitFromTiming = async (
const start = Math.round(timing.actionStartedAt - state.startedAt);
const end = Math.round(timing.attachedAt - state.startedAt);
- if (end > start) {
- state.deadAirSpans.push({ end, start });
- }
+ recordDeadAirSpan(state, { end, start });
};
-const recordMiddlewareWaitBeforeVideoMode = (state: VideoModeState, timing: ActionTiming) => {
+const recordMiddlewareWaitBeforeVideoMode = (
+ state: VideoModeState,
+ timing: ActionTiming,
+) => {
if (!state.startedAt) {
return;
}
@@ -241,14 +286,13 @@ const recordMiddlewareWaitBeforeVideoMode = (state: VideoModeState, timing: Acti
const start = Math.round(timing.actionStartedAt - state.startedAt);
const end = Math.round(currentMiddleware.startedAt - state.startedAt);
- if (end > start) {
- state.deadAirSpans.push({ end, start });
- }
+ recordDeadAirSpan(state, { end, start });
};
const tightVideoSegments = (options: {
deadAir: VideoModeSpan[];
finalEnd: number;
+ thresholdMs: number;
}): TightVideoSegment[] => {
const finalEnd = Math.max(0, Math.round(options.finalEnd));
@@ -259,6 +303,8 @@ const tightVideoSegments = (options: {
const deadAir = mergeVideoSpans(
options.deadAir
.map((span) => clipVideoSpan(span, finalEnd))
+ .filter((span): span is VideoModeSpan => Boolean(span))
+ .map((span) => trimDeadAirSpan(span, options.thresholdMs))
.filter((span): span is VideoModeSpan => Boolean(span)),
);
const boundaries = new Set([0, finalEnd]);
@@ -299,6 +345,7 @@ const tightVideoSegments = (options: {
const tightVideoFilter = (options: {
deadAir: VideoModeSpan[];
finalEnd: number;
+ thresholdMs: number;
}) => {
const segments = tightVideoSegments(options);
@@ -377,11 +424,13 @@ const removeDeadAirFromVideo = async (options: {
inputPath: string;
outputPath: string;
deadAir: VideoModeSpan[];
+ thresholdMs: number;
}) => {
const finalEnd = await videoDurationMs(options.inputPath);
const filter = tightVideoFilter({
deadAir: options.deadAir,
finalEnd,
+ thresholdMs: options.thresholdMs,
});
if (!filter) {
@@ -417,6 +466,7 @@ export const videoMode = (options: VideoModeOptions = {}): VideoModePlugin => {
const highlightStyle = options.highlightStyle || "3px solid gold";
const skipMethods = options.skipMethods || ["waitFor"];
const skipStackFrames = options.skipStackFrames || [];
+ const deadAirThreshold = resolveDeadAirThreshold(options.deadAirThreshold);
const state: VideoModeState = {
deadAirDepth: 0,
deadAirSpans: [],
@@ -478,7 +528,7 @@ export const videoMode = (options: VideoModeOptions = {}): VideoModePlugin => {
state.deadAirSpans = [];
state.outputs = {};
if (state.startedAt) {
- state.deadAirSpans.push({
+ recordDeadAirSpan(state, {
end: Math.round(performance.now() - state.startedAt),
start: 0,
});
@@ -521,11 +571,12 @@ export const videoMode = (options: VideoModeOptions = {}): VideoModePlugin => {
path: rawPath,
});
- if (deadAir.length > 0) {
+ if (deadAir.length > 0 && deadAirThreshold !== undefined) {
const wroteTightVideo = await removeDeadAirFromVideo({
deadAir,
inputPath: rawPath,
outputPath: tightPath,
+ thresholdMs: deadAirThreshold,
});
if (wroteTightVideo) {
From faea66f2e7c915511fd817803e90c1d0388ce25a Mon Sep 17 00:00:00 2001
From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com>
Date: Mon, 22 Jun 2026 15:33:20 +0100
Subject: [PATCH 05/35] Add plugin page extensions
---
README.md | 52 +++++++++++++++++++++++++++-------
spec/plugin-system.spec.ts | 30 ++++++++++++++++++++
spec/video-mode-ffmpeg.spec.ts | 2 +-
spec/video-mode.spec.ts | 11 +++----
src/index.ts | 1 +
src/plugin-system.ts | 49 ++++++++++++++++++++++++++++----
src/plugins/index.ts | 2 ++
src/plugins/video-mode.ts | 28 +++++++++++++-----
8 files changed, 146 insertions(+), 29 deletions(-)
diff --git a/README.md b/README.md
index eb2956b..e6e43b0 100644
--- a/README.md
+++ b/README.md
@@ -133,18 +133,24 @@ uiErrorReporter({ selector: '[data-type="error"]' });
For producing demo/debugging videos people can actually follow: marks pre-action waiting as dead air, outlines the element in gold, pauses before each action, and keeps a short final hold before trimming after-test padding as dead air. Enable it conditionally (e.g. `!!process.env.VIDEO_MODE && videoMode()`) together with Playwright's `video: "on"` and a generous `actionTimeout`.
```ts
-const video = videoMode({
- pauseBefore: 1000,
- pauseAfterTest: 3000,
- deadAirThreshold: 300,
- highlightStyle: "3px solid gold",
- skipMethods: ["waitFor"],
- skipStackFrames: ["test-helpers.ts"], // don't slow down internal login/setup helpers
+await using page = await addPlugins({
+ page: basePage,
+ testInfo,
+ plugins: [
+ videoMode({
+ pauseBefore: 1000,
+ pauseAfterTest: 3000,
+ deadAirThreshold: 300,
+ highlightStyle: "3px solid gold",
+ skipMethods: ["waitFor"],
+ skipStackFrames: ["test-helpers.ts"], // don't slow down internal login/setup helpers
+ }),
+ ],
});
-// Use the returned plugin for invisible setup/bookkeeping that should not be
+// Use page.videoMode for invisible setup/bookkeeping that should not be
// highlighted or slowed in video mode.
-await video.deadAir(async () => {
+await page.videoMode.deadAir(async () => {
await page.goto("/login");
await page.locator("#email").fill("demo@example.com");
});
@@ -232,7 +238,7 @@ export default defineConfig({
**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`.
-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";
@@ -263,9 +269,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/spec/plugin-system.spec.ts b/spec/plugin-system.spec.ts
index 540e0a6..8b16520 100644
--- a/spec/plugin-system.spec.ts
+++ b/spec/plugin-system.spec.ts
@@ -105,6 +105,36 @@ test("middleware receives action timing", async ({ page }, testInfo) => {
});
});
+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/video-mode-ffmpeg.spec.ts b/spec/video-mode-ffmpeg.spec.ts
index e827f54..8d21f38 100644
--- a/spec/video-mode-ffmpeg.spec.ts
+++ b/spec/video-mode-ffmpeg.spec.ts
@@ -87,7 +87,7 @@ test("writes a video with dead air removed", async ({ page }, testInfo) => {
await plugged.getByText("Start import").click();
await plugged.getByText("Review records").click();
await plugged.getByText("Approve import").click();
- await video.deadAir(async () => {
+ await plugged.videoMode.deadAir(async () => {
await new Promise((resolve) => setTimeout(resolve, 1700));
});
await plugged.getByText("Download receipt").click();
diff --git a/spec/video-mode.spec.ts b/spec/video-mode.spec.ts
index 5d3f1ca..8348ca1 100644
--- a/spec/video-mode.spec.ts
+++ b/spec/video-mode.spec.ts
@@ -133,12 +133,11 @@ test("marks explicit attached waitFor calls as dead air", async ({ page }, testI
test("deadAir runs actions without video highlighting and records metadata", async ({
page,
}, testInfo) => {
- const video = videoMode({ pauseBefore: 5000, pauseAfterTest: 50 });
{
await using plugged = await addPlugins({
page,
testInfo,
- plugins: [video],
+ plugins: [videoMode({ pauseBefore: 5000, pauseAfterTest: 50 })],
});
await plugged.setContent(`
@@ -152,19 +151,21 @@ test("deadAir runs actions without video highlighting and records metadata", asy
`);
const start = Date.now();
- await video.deadAir(async () => {
+ 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)");
- expect(video.metadata()).toMatchObject({
+ expect(plugged.videoMode.metadata()).toMatchObject({
outputs: {},
schemaVersion: 1,
timebase: "ms",
});
- expect(video.metadata().deadAir).toContainEqual(
+ expect(plugged.videoMode.metadata().deadAir).toContainEqual(
expect.objectContaining({ end: expect.any(Number), start: expect.any(Number) }),
);
}
diff --git a/src/index.ts b/src/index.ts
index 5017322..7b88cf4 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -4,6 +4,7 @@ export {
oneArgMethods,
overrideableMethods,
type Plugin,
+ type PageExtensionContext,
type ActionContext,
type ActionMiddleware,
type ActionMiddlewareTiming,
diff --git a/src/plugin-system.ts b/src/plugin-system.ts
index 9013691..5d134c3 100644
--- a/src/plugin-system.ts
+++ b/src/plugin-system.ts
@@ -98,12 +98,19 @@ export type TestLifecycleEvents = {
afterTest: { page: Page; testInfo: TestInfo };
};
-export type Plugin = {
+export type PageExtensionContext = {
+ page: Page;
+ testInfo: TestInfo;
+};
+
+export type Plugin = {
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");
@@ -120,7 +127,30 @@ type RegisteredActionMiddleware = {
middleware: ActionMiddleware;
};
-type PageWithPlugins = Page & {
+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;
};
@@ -149,12 +179,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);
@@ -184,9 +214,16 @@ 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 });
diff --git a/src/plugins/index.ts b/src/plugins/index.ts
index c79d292..f9f0996 100644
--- a/src/plugins/index.ts
+++ b/src/plugins/index.ts
@@ -1,9 +1,11 @@
export { hydrationWaiter, type HydrationWaiterOptions } from "./hydration-waiter.ts";
export {
videoMode,
+ type VideoModeControls,
type VideoModeMetadata,
type VideoModeOptions,
type VideoModeOutputs,
+ type VideoModePageExtension,
type VideoModePlugin,
type VideoModeSpan,
} from "./video-mode.ts";
diff --git a/src/plugins/video-mode.ts b/src/plugins/video-mode.ts
index dac2d2f..3a175c4 100644
--- a/src/plugins/video-mode.ts
+++ b/src/plugins/video-mode.ts
@@ -32,16 +32,24 @@ export type VideoModeMetadata = {
outputs: VideoModeOutputs;
};
-export type VideoModePlugin = Plugin & {
+export type VideoModeControls = {
/**
* Run invisible video bookkeeping without video-mode highlighting/pauses,
* and write the elapsed span to video-mode metadata.
*/
deadAir(action: () => Promise): Promise;
+ /** Milliseconds since video-mode started recording metadata for this test. */
+ getVideoTimestamp(): number;
/** Current metadata snapshot. Written to video-mode.json after the test. */
metadata(): VideoModeMetadata;
};
+export type VideoModePageExtension = {
+ videoMode: VideoModeControls;
+};
+
+export type VideoModePlugin = Plugin & VideoModeControls;
+
export type VideoModeOptions = {
/** Pause duration before action (ms). Default: 1000 */
pauseBefore?: number;
@@ -473,9 +481,21 @@ export const videoMode = (options: VideoModeOptions = {}): VideoModePlugin => {
outputs: {},
startedAt: performance.now(),
};
+ const controls: VideoModeControls = {
+ deadAir: async (action) => {
+ return await recordDeadAir(state, action);
+ },
+ getVideoTimestamp: () => {
+ const now = performance.now();
+ return Math.round(now - (state.startedAt ?? now));
+ },
+ metadata: () => metadataFor(state),
+ };
return {
+ ...controls,
name: "video-mode",
+ pageExtension: () => ({ videoMode: controls }),
middleware: async ({ args, locator, method, timing }, next) => {
if (state.deadAirDepth > 0) return next();
@@ -516,12 +536,6 @@ export const videoMode = (options: VideoModeOptions = {}): VideoModePlugin => {
}
},
- deadAir: async (action) => {
- return await recordDeadAir(state, action);
- },
-
- metadata: () => metadataFor(state),
-
testLifecycle: (emitter) => {
const offBeforeTest = emitter.on("beforeTest", () => {
state.deadAirDepth = 0;
From 06156549a4c51b88b13330f6d27739e2b55364cc Mon Sep 17 00:00:00 2001
From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com>
Date: Mon, 22 Jun 2026 17:33:29 +0100
Subject: [PATCH 06/35] Render video-mode annotations in post
---
README.md | 25 +-
spec/video-mode-ffmpeg.spec.ts | 267 +++++++++++++++-
spec/video-mode.spec.ts | 46 ++-
src/plugins/index.ts | 3 +
src/plugins/video-mode.ts | 535 +++++++++++++++++++++++++--------
5 files changed, 714 insertions(+), 162 deletions(-)
diff --git a/README.md b/README.md
index e6e43b0..62f8c3f 100644
--- a/README.md
+++ b/README.md
@@ -52,7 +52,7 @@ 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) |
`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.
@@ -130,7 +130,7 @@ uiErrorReporter({ selector: '[data-type="error"]' });
### videoMode
-For producing demo/debugging videos people can actually follow: marks pre-action waiting as dead air, outlines the element in gold, pauses before each action, and keeps a short final hold before trimming after-test padding as dead air. 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({
@@ -138,29 +138,30 @@ await using page = await addPlugins({
testInfo,
plugins: [
videoMode({
- pauseBefore: 1000,
- pauseAfterTest: 3000,
+ highlightDuration: 1000,
+ finalHold: 3000,
deadAirThreshold: 300,
- highlightStyle: "3px solid gold",
+ highlightColor: "gold",
+ highlightThickness: 3,
skipMethods: ["waitFor"],
- skipStackFrames: ["test-helpers.ts"], // don't slow down internal login/setup helpers
+ skipStackFrames: ["test-helpers.ts"], // don't annotate internal login/setup helpers
}),
],
});
-// Use page.videoMode for invisible setup/bookkeeping that should not be
-// highlighted or slowed in video mode.
+// 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");
});
```
-When Playwright video recording is enabled, `videoMode` saves `video-raw.webm` and attaches it with `video-mode.json` to the test report. Set `deadAirThreshold` to also use `ffmpeg` to write `video-tight.webm` with dead air removed. If `ffmpeg` or `ffprobe` is missing, the trim step fails plainly so you know to install ffmpeg.
+When Playwright video recording is enabled, `videoMode` saves `video-raw.webm`, uses `ffmpeg` to write `video-rendered.webm`, and attaches both with `video-mode.json` to the test report. 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. `deadAirThreshold` is applied only when writing the tight video: it keeps that much of each dead-air span, split across the start and end of the span. Spans at or below the threshold are left intact.
+`video-mode.json` records raw dead-air spans and highlight rectangles. `deadAirThreshold` is applied only when writing the rendered video: it keeps that much of each dead-air span, split across the start and end of the span. Spans at or below the threshold are left intact. `highlightDuration` and `finalHold` are also applied at render time, so they do not slow down the browser test.
-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 highlights immediately before the action.
+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
@@ -236,7 +237,7 @@ 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`, `testLifecycle`, and `pageExtension` hooks:
diff --git a/spec/video-mode-ffmpeg.spec.ts b/spec/video-mode-ffmpeg.spec.ts
index 8d21f38..509fb9d 100644
--- a/spec/video-mode-ffmpeg.spec.ts
+++ b/spec/video-mode-ffmpeg.spec.ts
@@ -9,12 +9,16 @@ const execFile = promisify(execFileCallback);
test.use({ video: "on" });
-test("writes a video with dead air removed", async ({ page }, testInfo) => {
+test("writes a rendered video with dead air removed and highlights added in post", async ({
+ page,
+}, testInfo) => {
const deadAirThresholdMs = 300;
+ const finalHoldMs = 700;
+ const highlightDurationMs = 1000;
const video = videoMode({
deadAirThreshold: deadAirThresholdMs,
- pauseBefore: 1000,
- pauseAfterTest: 700,
+ finalHold: finalHoldMs,
+ highlightDuration: highlightDurationMs,
});
{
await using plugged = await addPlugins({
@@ -101,7 +105,7 @@ test("writes a video with dead air removed", async ({ page }, testInfo) => {
);
expect(metadata).toMatchObject({
outputs: {
- deadAirRemoved: "video-tight.webm",
+ rendered: "video-rendered.webm",
raw: "video-raw.webm",
},
});
@@ -109,30 +113,135 @@ test("writes a video with dead air removed", async ({ page }, testInfo) => {
metadata.deadAir.filter((span: { end: number; start: number }) => span.end - span.start >= 1500)
.length,
).toBeGreaterThanOrEqual(4);
- const finalDeadAirSpan = metadata.deadAir[metadata.deadAir.length - 1];
- const previousDeadAirSpan = metadata.deadAir[metadata.deadAir.length - 2];
- expect(finalDeadAirSpan.start - previousDeadAirSpan.end).toBeGreaterThanOrEqual(400);
+ expect(metadata.highlights.length).toBeGreaterThanOrEqual(4);
const rawPath = join(testInfo.outputDir, metadata.outputs.raw);
- const tightPath = join(testInfo.outputDir, metadata.outputs.deadAirRemoved);
+ const renderedPath = join(testInfo.outputDir, metadata.outputs.rendered);
const rawStats = await stat(rawPath);
- const tightStats = await stat(tightPath);
+ const renderedStats = await stat(renderedPath);
console.log(`raw video written to ${rawPath}`);
- console.log(`tight video written to ${tightPath}`);
+ console.log(`rendered video written to ${renderedPath}`);
expect(rawStats.size).toBeGreaterThan(0);
- expect(tightStats.size).toBeGreaterThan(0);
+ expect(renderedStats.size).toBeGreaterThan(0);
const rawDuration = await videoDurationMs(rawPath);
- const tightDuration = await videoDurationMs(tightPath);
- const expectedTightDuration =
+ const renderedDuration = await videoDurationMs(renderedPath);
+ const expectedRenderedDuration =
rawDuration -
metadata.deadAir.reduce((removedDuration: number, span: { end: number; start: number }) => {
return removedDuration + Math.max(0, span.end - span.start - deadAirThresholdMs);
- }, 0);
+ }, 0) +
+ metadata.highlights.reduce(
+ (duration: number, highlight: { end: number; start: number }) =>
+ duration + highlight.end - highlight.start,
+ 0,
+ ) +
+ finalHoldMs;
- expect(tightDuration).toBeLessThan(rawDuration);
- expect(Math.abs(tightDuration - expectedTightDuration)).toBeLessThan(1000);
+ expect(renderedDuration).toBeLessThan(rawDuration + metadata.highlights.length * highlightDurationMs);
+ expect(Math.abs(renderedDuration - expectedRenderedDuration)).toBeLessThan(1500);
+});
+
+test("renders calibrated highlight boxes on a paused pre-click frame", async ({
+ page,
+}, testInfo) => {
+ const highlightDurationMs = 900;
+ const video = videoMode({
+ finalHold: 0,
+ highlightColor: "yellow",
+ highlightDuration: highlightDurationMs,
+ highlightThickness: 8,
+ });
+ {
+ 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 metadata = JSON.parse(
+ await readFile(join(testInfo.outputDir, "video-mode.json"), "utf8"),
+ );
+ 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 = join(testInfo.outputDir, metadata.outputs.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);
});
const videoDurationMs = async (path: string) => {
@@ -144,3 +253,129 @@ const videoDurationMs = async (path: string) => {
return Math.round(Number(stdout.trim()) * 1000);
};
+
+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) => {
+ 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 yellowBoundingBox = (frame: Awaited>) => {
+ 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: Awaited>,
+ 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),
+ };
+};
diff --git a/spec/video-mode.spec.ts b/spec/video-mode.spec.ts
index 8348ca1..2e1cf13 100644
--- a/spec/video-mode.spec.ts
+++ b/spec/video-mode.spec.ts
@@ -3,13 +3,13 @@ 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, highlightDuration: 300 })],
});
await plugged.setContent(`
press
@@ -23,17 +23,33 @@ 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)");
+ expect(plugged.videoMode.metadata().highlights).toContainEqual(
+ 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) => {
- const video = videoMode({ pauseBefore: 5000, pauseAfterTest: 50, skipMethods: ["click"] });
+test("skipped methods are not highlighted", async ({ page }, testInfo) => {
+ const video = videoMode({ finalHold: 50, highlightDuration: 5000, skipMethods: ["click"] });
await using plugged = await addPlugins({
page,
testInfo,
@@ -52,13 +68,13 @@ test("skipped methods are not highlighted or slowed down", async ({ page }, test
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);
expect(video.metadata().deadAir.some((span) => span.end - span.start >= 100)).toBe(true);
+ expect(video.metadata().highlights).toEqual([]);
});
test("marks pre-action waits for attachment as dead air", async ({ page }, testInfo) => {
- const video = videoMode({ pauseBefore: 20, pauseAfterTest: 50 });
+ const video = videoMode({ finalHold: 50, highlightDuration: 20 });
await using plugged = await addPlugins({
page,
testInfo,
@@ -92,7 +108,7 @@ test("pre-action attached waits honor action timeout", async ({ page }, testInfo
await using plugged = await addPlugins({
page,
testInfo,
- plugins: [videoMode({ pauseBefore: 20, pauseAfterTest: 50 })],
+ plugins: [videoMode({ finalHold: 50, highlightDuration: 20 })],
});
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 metadata = JSON.parse(
+ await readFile(join(testInfo.outputDir, "video-mode.json"), "utf8"),
+ );
+ const nextHighlight = metadata.highlights.find(
+ (highlight: { rect: { x: number } }) => highlight.rect.x > 300,
+ );
+ expect(nextHighlight).toBeTruthy();
+
+ const renderedPath = join(testInfo.outputDir, metadata.outputs.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).toEqual([]);
+
+ const rawPath = join(testInfo.outputDir, metadata.outputs.raw);
+ console.log(`raw video written to ${rawPath}`);
+ console.log(`rendered video written to ${renderedPath}`);
});
const videoDurationMs = async (path: string) => {
@@ -254,6 +384,38 @@ const videoDurationMs = async (path: string) => {
return Math.round(Number(stdout.trim()) * 1000);
};
+type VideoFrame = {
+ data: Buffer;
+ height: number;
+ width: number;
+};
+
+type VideoSpan = {
+ end: number;
+ start: number;
+};
+
+const trimmedDeadAirDuration = (
+ deadAir: VideoSpan[],
+ highlights: VideoSpan[],
+ thresholdMs: number,
+) => {
+ return deadAir.reduce((removedDuration, span) => {
+ if (span.end - span.start <= thresholdMs) {
+ return removedDuration;
+ }
+
+ const followingHighlight = highlights.find((highlight) => {
+ return highlight.start >= span.end && highlight.start - span.end <= thresholdMs;
+ });
+ const padding = thresholdMs / 2;
+ const trimStart = Math.round(span.start + padding);
+ const trimEnd = Math.round(followingHighlight ? followingHighlight.start : span.end - padding);
+
+ return removedDuration + Math.max(0, trimEnd - trimStart);
+ }, 0);
+};
+
const videoInfo = async (path: string) => {
const { stdout } = await execFile(
"ffprobe",
@@ -279,7 +441,7 @@ const videoInfo = async (path: string) => {
};
};
-const videoFrame = async (path: string, timestampMs: number) => {
+const videoFrame = async (path: string, timestampMs: number): Promise => {
const info = await videoInfo(path);
const { stdout } = await execFile(
"ffmpeg",
@@ -312,7 +474,44 @@ const videoFrame = async (path: string, timestampMs: number) => {
};
};
-const yellowBoundingBox = (frame: Awaited>) => {
+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;
@@ -349,10 +548,7 @@ const centerOf = (rect: { height: number; width: number; x: number; y: number })
y: rect.y + Math.round(rect.height / 2),
});
-const averagePixel = (
- frame: Awaited>,
- point: { x: number; y: number },
-) => {
+const averagePixel = (frame: VideoFrame, point: { x: number; y: number }) => {
const radius = 3;
let red = 0;
let green = 0;
@@ -379,3 +575,53 @@ const averagePixel = (
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 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/src/plugins/video-mode.ts b/src/plugins/video-mode.ts
index fe06fcb..64fbfb4 100644
--- a/src/plugins/video-mode.ts
+++ b/src/plugins/video-mode.ts
@@ -328,13 +328,26 @@ const clipVideoSpan = (span: VideoModeSpan, finalEnd: number): VideoModeSpan | u
return { end, start };
};
-const trimDeadAirSpan = (
- span: VideoModeSpan,
- thresholdMs: number,
-): VideoModeSpan | undefined => {
+const trimDeadAirSpan = (options: {
+ highlights: VideoModeHighlight[];
+ span: VideoModeSpan;
+ thresholdMs: number;
+}): VideoModeSpan | undefined => {
+ const span = options.span;
+ const thresholdMs = options.thresholdMs;
+
+ if (span.end - span.start <= thresholdMs) {
+ return undefined;
+ }
+
const padding = thresholdMs / 2;
+ // A following highlight already shows the post-wait state, so don't also
+ // render an unhighlighted tail frame for the same transition.
+ const followingHighlight = options.highlights.find((highlight) => {
+ return highlight.start >= span.end && highlight.start - span.end <= thresholdMs;
+ });
const start = Math.round(span.start + padding);
- const end = Math.round(span.end - padding);
+ const end = Math.round(followingHighlight ? followingHighlight.start : span.end - padding);
if (end <= start) {
return undefined;
@@ -408,6 +421,7 @@ const recordMiddlewareWaitBeforeVideoMode = (
const tightVideoSegments = (options: {
deadAir: VideoModeSpan[];
finalEnd: number;
+ highlights: VideoModeHighlight[];
thresholdMs?: number;
}): TightVideoSegment[] => {
const finalEnd = Math.max(0, Math.round(options.finalEnd));
@@ -421,11 +435,12 @@ const tightVideoSegments = (options: {
}
const thresholdMs = options.thresholdMs;
+ const highlights = normalizeVideoHighlights(options.highlights);
const deadAir = mergeVideoSpans(
options.deadAir
.map((span) => clipVideoSpan(span, finalEnd))
.filter((span): span is VideoModeSpan => Boolean(span))
- .map((span) => trimDeadAirSpan(span, thresholdMs))
+ .map((span) => trimDeadAirSpan({ highlights, span, thresholdMs }))
.filter((span): span is VideoModeSpan => Boolean(span)),
);
const boundaries = new Set([0, finalEnd]);
@@ -703,6 +718,7 @@ const renderVideo = async (options: {
const segments = tightVideoSegments({
deadAir: options.deadAir,
finalEnd: info.durationMs,
+ highlights: options.highlights,
thresholdMs: options.thresholdMs,
});
const filter = renderedVideoFilter({
From 608f342aff9253d558062f887d01414b9fce6e47 Mon Sep 17 00:00:00 2001
From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com>
Date: Mon, 22 Jun 2026 18:13:04 +0100
Subject: [PATCH 08/35] Add video-mode frame stepper report attachment
---
README.md | 2 +-
spec/video-mode-ffmpeg.spec.ts | 26 ++-
src/plugins/video-mode.ts | 295 +++++++++++++++++++++++++++++++--
3 files changed, 308 insertions(+), 15 deletions(-)
diff --git a/README.md b/README.md
index 62f8c3f..2e41926 100644
--- a/README.md
+++ b/README.md
@@ -157,7 +157,7 @@ await page.videoMode.deadAir(async () => {
});
```
-When Playwright video recording is enabled, `videoMode` saves `video-raw.webm`, uses `ffmpeg` to write `video-rendered.webm`, and attaches both with `video-mode.json` to the test report. If `ffmpeg` or `ffprobe` is missing, the render step fails plainly so you know to install ffmpeg.
+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. 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: it keeps that much of each dead-air span, split across the start and end of the span. Spans at or below the threshold are left intact. `highlightDuration` and `finalHold` are also applied at render time, so they do not slow down the browser test.
diff --git a/spec/video-mode-ffmpeg.spec.ts b/spec/video-mode-ffmpeg.spec.ts
index 3bd8d49..d5f6ce0 100644
--- a/spec/video-mode-ffmpeg.spec.ts
+++ b/spec/video-mode-ffmpeg.spec.ts
@@ -1,6 +1,7 @@
import { execFile as execFileCallback } from "node:child_process";
+import { createHash } from "node:crypto";
import { readFile, stat } from "node:fs/promises";
-import { join } from "node:path";
+import { extname, join } from "node:path";
import { promisify } from "node:util";
import { test, expect } from "@playwright/test";
import { addPlugins, spinnerWaiter, videoMode } from "../src/index.ts";
@@ -105,6 +106,7 @@ test("writes a rendered video with dead air removed and highlights added in post
);
expect(metadata).toMatchObject({
outputs: {
+ player: "video-mode.html",
rendered: "video-rendered.webm",
raw: "video-raw.webm",
},
@@ -117,13 +119,30 @@ test("writes a rendered video with dead air removed and highlights added in post
const rawPath = join(testInfo.outputDir, metadata.outputs.raw);
const renderedPath = join(testInfo.outputDir, metadata.outputs.rendered);
+ const playerPath = join(testInfo.outputDir, metadata.outputs.player);
+ const reportPlayerPath = join(testInfo.outputDir, "video-mode-report.html");
const rawStats = await stat(rawPath);
const renderedStats = await stat(renderedPath);
+ const playerStats = await stat(playerPath);
+ const reportPlayerStats = await stat(reportPlayerPath);
console.log(`raw video written to ${rawPath}`);
console.log(`rendered video written to ${renderedPath}`);
+ console.log(`video player written to ${playerPath}`);
+ console.log(`report video player written to ${reportPlayerPath}`);
expect(rawStats.size).toBeGreaterThan(0);
expect(renderedStats.size).toBeGreaterThan(0);
+ expect(playerStats.size).toBeGreaterThan(0);
+ expect(reportPlayerStats.size).toBeGreaterThan(0);
+ await expect(readFile(playerPath, "utf8")).resolves.toContain('src="video-rendered.webm"');
+ await expect(readFile(playerPath, "utf8")).resolves.toContain('src="video-raw.webm"');
+ await expect(readFile(playerPath, "utf8")).resolves.toContain("");
+ await expect(readFile(reportPlayerPath, "utf8")).resolves.toContain(
+ `src="${await playwrightReportAttachmentName(renderedPath)}"`,
+ );
+ await expect(readFile(reportPlayerPath, "utf8")).resolves.toContain(
+ `src="${await playwrightReportAttachmentName(rawPath)}"`,
+ );
const rawDuration = await videoDurationMs(rawPath);
const renderedDuration = await videoDurationMs(renderedPath);
@@ -395,6 +414,11 @@ type VideoSpan = {
start: number;
};
+const playwrightReportAttachmentName = async (path: string) => {
+ const data = await readFile(path);
+ return `${createHash("sha1").update(data).digest("hex")}${extname(path)}`;
+};
+
const trimmedDeadAirDuration = (
deadAir: VideoSpan[],
highlights: VideoSpan[],
diff --git a/src/plugins/video-mode.ts b/src/plugins/video-mode.ts
index 64fbfb4..7903ef2 100644
--- a/src/plugins/video-mode.ts
+++ b/src/plugins/video-mode.ts
@@ -7,8 +7,9 @@
* `skipStackFrames` option.
*/
import { execFile as execFileCallback } from "node:child_process";
-import { copyFile, mkdir, stat, writeFile } from "node:fs/promises";
-import { join } from "node:path";
+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";
@@ -21,6 +22,7 @@ export type VideoModeSpan = {
};
export type VideoModeOutputs = {
+ player?: string;
raw?: string;
rendered?: string;
};
@@ -697,6 +699,249 @@ const waitForNonEmptyFile = async (path: string, timeoutMs = 5000) => {
: 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: { label: string; source: string }) => {
+ const label = escapeHtml(options.label);
+ const source = escapeHtml(options.source);
+
+ return `
+ `;
+};
+
+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 rawDetails = options.rendered
+ ? `
+
+ Raw video
+ ${videoElementHtml({ label: "Raw video", source: options.raw })}
+ `
+ : "";
+
+ return `
+
+
+
+
+ video-mode player
+
+
+
+
+
+
+ ${videoElementHtml({ label: primaryLabel, source: primary })}
+ ${rawDetails}
+
+
+
+
+
+
+`;
+};
+
const renderVideo = async (options: {
finalHoldMs: number;
highlights: VideoModeHighlight[];
@@ -785,16 +1030,16 @@ export const videoMode = (options: VideoModeOptions = {}): VideoModePlugin => {
outputs: {},
startedAt: performance.now(),
};
- const controls: VideoModeControls = {
- deadAir: async (action) => {
- return await recordDeadAir(state, action);
- },
+ const controls: VideoModeControls = {
+ deadAir: async (action) => {
+ return await recordDeadAir(state, action);
+ },
getVideoTimestamp: () => {
const now = performance.now();
return Math.round(now - (state.startedAt || now));
},
- metadata: () => metadataFor(state),
- };
+ metadata: () => metadataFor(state),
+ };
return {
...controls,
@@ -873,6 +1118,8 @@ export const videoMode = (options: VideoModeOptions = {}): VideoModePlugin => {
if (video) {
const rawPath = join(testInfo.outputDir, "video-raw.webm");
const renderedPath = join(testInfo.outputDir, "video-rendered.webm");
+ const playerPath = join(testInfo.outputDir, "video-mode.html");
+ const reportPlayerPath = join(testInfo.outputDir, "video-mode-report.html");
await mkdir(testInfo.outputDir, { recursive: true });
if (!page.isClosed()) {
@@ -907,15 +1154,37 @@ export const videoMode = (options: VideoModeOptions = {}): VideoModePlugin => {
});
}
}
+
+ state.outputs.player = "video-mode.html";
+ await writeFile(
+ playerPath,
+ videoModePlayerHtml({
+ raw: state.outputs.raw,
+ rendered: state.outputs.rendered,
+ }),
+ );
+
+ const reportPlayerHtml = videoModePlayerHtml({
+ raw: await playwrightReportAttachmentName(rawPath),
+ rendered: state.outputs.rendered
+ ? await playwrightReportAttachmentName(renderedPath)
+ : undefined,
+ });
+ await writeFile(reportPlayerPath, reportPlayerHtml);
+ await testInfo.attach("video-mode-player", {
+ contentType: "text/html",
+ path: reportPlayerPath,
+ });
}
const metadata = metadataFor(state);
if (
- metadata.deadAir.length > 0 ||
- metadata.highlights.length > 0 ||
- metadata.outputs.raw ||
- metadata.outputs.rendered
- ) {
+ metadata.deadAir.length > 0 ||
+ metadata.highlights.length > 0 ||
+ metadata.outputs.player ||
+ metadata.outputs.raw ||
+ metadata.outputs.rendered
+ ) {
const path = join(testInfo.outputDir, "video-mode.json");
await mkdir(testInfo.outputDir, { recursive: true });
await writeFile(path, `${JSON.stringify(metadata, null, 2)}\n`);
From 1c73aa211b96ae55b25a5bc4dfe47fae8e81503b Mon Sep 17 00:00:00 2001
From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com>
Date: Mon, 22 Jun 2026 18:13:37 +0100
Subject: [PATCH 09/35] Clean up video-mode indentation
---
src/plugins/video-mode.ts | 32 ++++++++++++++++----------------
1 file changed, 16 insertions(+), 16 deletions(-)
diff --git a/src/plugins/video-mode.ts b/src/plugins/video-mode.ts
index 7903ef2..a5940b8 100644
--- a/src/plugins/video-mode.ts
+++ b/src/plugins/video-mode.ts
@@ -1030,16 +1030,16 @@ export const videoMode = (options: VideoModeOptions = {}): VideoModePlugin => {
outputs: {},
startedAt: performance.now(),
};
- const controls: VideoModeControls = {
- deadAir: async (action) => {
- return await recordDeadAir(state, action);
- },
- getVideoTimestamp: () => {
- const now = performance.now();
- return Math.round(now - (state.startedAt || now));
- },
- metadata: () => metadataFor(state),
- };
+ const controls: VideoModeControls = {
+ deadAir: async (action) => {
+ return await recordDeadAir(state, action);
+ },
+ getVideoTimestamp: () => {
+ const now = performance.now();
+ return Math.round(now - (state.startedAt || now));
+ },
+ metadata: () => metadataFor(state),
+ };
return {
...controls,
@@ -1179,12 +1179,12 @@ export const videoMode = (options: VideoModeOptions = {}): VideoModePlugin => {
const metadata = metadataFor(state);
if (
- metadata.deadAir.length > 0 ||
- metadata.highlights.length > 0 ||
- metadata.outputs.player ||
- metadata.outputs.raw ||
- metadata.outputs.rendered
- ) {
+ metadata.deadAir.length > 0 ||
+ metadata.highlights.length > 0 ||
+ metadata.outputs.player ||
+ metadata.outputs.raw ||
+ metadata.outputs.rendered
+ ) {
const path = join(testInfo.outputDir, "video-mode.json");
await mkdir(testInfo.outputDir, { recursive: true });
await writeFile(path, `${JSON.stringify(metadata, null, 2)}\n`);
From 9dfeb0ab6fd8871ee361527908fc775a26c46d27 Mon Sep 17 00:00:00 2001
From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com>
Date: Mon, 22 Jun 2026 19:41:38 +0100
Subject: [PATCH 10/35] Expose video-mode artifact helpers
---
README.md | 5 +
spec/video-mode-ffmpeg.spec.ts | 240 ++++++++++++++++-----------------
spec/video-mode.spec.ts | 69 +++++-----
src/plugins/index.ts | 1 +
src/plugins/video-mode.ts | 104 ++++++++++----
5 files changed, 244 insertions(+), 175 deletions(-)
diff --git a/README.md b/README.md
index 2e41926..d3ab439 100644
--- a/README.md
+++ b/README.md
@@ -155,6 +155,11 @@ 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);
```
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. If `ffmpeg` or `ffprobe` is missing, the render step fails plainly so you know to install ffmpeg.
diff --git a/spec/video-mode-ffmpeg.spec.ts b/spec/video-mode-ffmpeg.spec.ts
index d5f6ce0..b879d57 100644
--- a/spec/video-mode-ffmpeg.spec.ts
+++ b/spec/video-mode-ffmpeg.spec.ts
@@ -1,7 +1,7 @@
import { execFile as execFileCallback } from "node:child_process";
import { createHash } from "node:crypto";
import { readFile, stat } from "node:fs/promises";
-import { extname, join } from "node:path";
+import { extname } from "node:path";
import { promisify } from "node:util";
import { test, expect } from "@playwright/test";
import { addPlugins, spinnerWaiter, videoMode } from "../src/index.ts";
@@ -13,9 +13,90 @@ test.use({ video: "on" });
test("writes a rendered video with dead air removed and highlights added in post", async ({
page,
}, testInfo) => {
+ await using plugged = await addPlugins({
+ page,
+ testInfo,
+ plugins: [
+ spinnerWaiter(),
+ videoMode({
+ deadAirThreshold: 300,
+ finalHold: 700,
+ highlightDuration: 1000,
+ }),
+ ],
+ });
+ await plugged.setViewportSize({ width: 800, height: 600 });
+ await plugged.setContent(`
+
+ Loading...
+
+ Start import
+ Review records
+ Approve import
+ Download receipt
+
+
+
+
+ `);
+
+ 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 = 700;
- const highlightDurationMs = 1000;
+ const finalHoldMs = 500;
+ const highlightDurationMs = 600;
const video = videoMode({
deadAirThreshold: deadAirThresholdMs,
finalHold: finalHoldMs,
@@ -25,85 +106,27 @@ test("writes a rendered video with dead air removed and highlights added in post
await using plugged = await addPlugins({
page,
testInfo,
- plugins: [
- spinnerWaiter({
- log: (message) => console.log(`[spinnerWaiter] ${message}`),
- spinnerTimeout: 12_000,
- }),
- video,
- ],
+ plugins: [video],
});
- await plugged.setViewportSize({ width: 800, height: 600 });
await plugged.setContent(`
-
- Loading...
-
- Start import
- Review records
- Approve import
- Download receipt
-
-
-
+ Save
+
`);
- 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 new Promise((resolve) => setTimeout(resolve, 1200));
});
- await plugged.getByText("Download receipt").click();
-
- await plugged.getByText("Receipt ready").waitFor();
- await expect(plugged.getByText("Receipt ready")).toContainText("Receipt ready");
+ await plugged.locator("#save").click();
+ await expect(plugged.locator("#status")).toContainText("saved");
}
- const metadata = JSON.parse(
- await readFile(join(testInfo.outputDir, "video-mode.json"), "utf8"),
- );
+ const paths = video.outputPaths();
+ const metadata = await video.metadata();
expect(metadata).toMatchObject({
outputs: {
player: "video-mode.html",
@@ -111,41 +134,30 @@ test("writes a rendered video with dead air removed and highlights added in post
raw: "video-raw.webm",
},
});
- expect(
- metadata.deadAir.filter((span: { end: number; start: number }) => span.end - span.start >= 1500)
- .length,
- ).toBeGreaterThanOrEqual(4);
- expect(metadata.highlights.length).toBeGreaterThanOrEqual(4);
-
- const rawPath = join(testInfo.outputDir, metadata.outputs.raw);
- const renderedPath = join(testInfo.outputDir, metadata.outputs.rendered);
- const playerPath = join(testInfo.outputDir, metadata.outputs.player);
- const reportPlayerPath = join(testInfo.outputDir, "video-mode-report.html");
- const rawStats = await stat(rawPath);
- const renderedStats = await stat(renderedPath);
- const playerStats = await stat(playerPath);
- const reportPlayerStats = await stat(reportPlayerPath);
- console.log(`raw video written to ${rawPath}`);
- console.log(`rendered video written to ${renderedPath}`);
- console.log(`video player written to ${playerPath}`);
- console.log(`report video player written to ${reportPlayerPath}`);
+ 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(playerPath, "utf8")).resolves.toContain('src="video-rendered.webm"');
- await expect(readFile(playerPath, "utf8")).resolves.toContain('src="video-raw.webm"');
- await expect(readFile(playerPath, "utf8")).resolves.toContain("");
- await expect(readFile(reportPlayerPath, "utf8")).resolves.toContain(
- `src="${await playwrightReportAttachmentName(renderedPath)}"`,
+ 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.reportPlayer, "utf8")).resolves.toContain(
+ `src="${await playwrightReportAttachmentName(paths.rendered)}"`,
);
- await expect(readFile(reportPlayerPath, "utf8")).resolves.toContain(
- `src="${await playwrightReportAttachmentName(rawPath)}"`,
+ await expect(readFile(paths.reportPlayer, "utf8")).resolves.toContain(
+ `src="${await playwrightReportAttachmentName(paths.raw)}"`,
);
- const rawDuration = await videoDurationMs(rawPath);
- const renderedDuration = await videoDurationMs(renderedPath);
+ const rawDuration = await videoDurationMs(paths.raw);
+ const renderedDuration = await videoDurationMs(paths.rendered);
const expectedRenderedDuration =
rawDuration -
trimmedDeadAirDuration(metadata.deadAir, metadata.highlights, deadAirThresholdMs) +
@@ -160,9 +172,7 @@ test("writes a rendered video with dead air removed and highlights added in post
expect(Math.abs(renderedDuration - expectedRenderedDuration)).toBeLessThan(1500);
});
-test("renders calibrated highlight boxes on a paused pre-click frame", async ({
- page,
-}, testInfo) => {
+test("renders calibrated highlight boxes on a paused pre-click frame", async ({ page }, testInfo) => {
const highlightDurationMs = 900;
const video = videoMode({
finalHold: 0,
@@ -202,9 +212,8 @@ test("renders calibrated highlight boxes on a paused pre-click frame", async ({
await page.waitForTimeout(300);
}
- const metadata = JSON.parse(
- await readFile(join(testInfo.outputDir, "video-mode.json"), "utf8"),
- );
+ const paths = video.outputPaths();
+ const metadata = await video.metadata();
const [highlight] = metadata.highlights;
expect(highlight).toMatchObject({
color: "yellow",
@@ -221,7 +230,7 @@ test("renders calibrated highlight boxes on a paused pre-click frame", async ({
},
});
- const renderedPath = join(testInfo.outputDir, metadata.outputs.rendered);
+ const renderedPath = paths.rendered;
const pauseFrame = await videoFrame(
renderedPath,
highlight.start + Math.round(highlightDurationMs / 2),
@@ -259,10 +268,6 @@ test("renders calibrated highlight boxes on a paused pre-click frame", async ({
});
expect(pauseCenter.blue).toBeGreaterThan(pauseCenter.red + 80);
expect(afterClickCenter.red).toBeGreaterThan(afterClickCenter.blue + 80);
-
- const rawPath = join(testInfo.outputDir, metadata.outputs.raw);
- console.log(`raw video written to ${rawPath}`);
- console.log(`rendered video written to ${renderedPath}`);
});
test("does not flash the unhighlighted post-wait state before a following highlight", async ({
@@ -353,15 +358,12 @@ test("does not flash the unhighlighted post-wait state before a following highli
await page.waitForTimeout(300);
}
- const metadata = JSON.parse(
- await readFile(join(testInfo.outputDir, "video-mode.json"), "utf8"),
- );
- const nextHighlight = metadata.highlights.find(
- (highlight: { rect: { x: number } }) => highlight.rect.x > 300,
- );
- expect(nextHighlight).toBeTruthy();
+ const paths = video.outputPaths();
+ const metadata = await video.metadata();
+ const nextHighlight = metadata.highlights.find((highlight) => highlight.rect.x > 300)!;
+ expect(nextHighlight).toBeDefined();
- const renderedPath = join(testInfo.outputDir, metadata.outputs.rendered);
+ const renderedPath = paths.rendered;
const frames = await videoFrames(renderedPath);
const scale = Math.min(
frames[0].width / nextHighlight.viewport.width,
@@ -387,10 +389,6 @@ test("does not flash the unhighlighted post-wait state before a following highli
.map((sample) => sample.index);
expect(unhighlightedReadyFrames).toEqual([]);
-
- const rawPath = join(testInfo.outputDir, metadata.outputs.raw);
- console.log(`raw video written to ${rawPath}`);
- console.log(`rendered video written to ${renderedPath}`);
});
const videoDurationMs = async (path: string) => {
diff --git a/spec/video-mode.spec.ts b/spec/video-mode.spec.ts
index 2e1cf13..578a406 100644
--- a/spec/video-mode.spec.ts
+++ b/spec/video-mode.spec.ts
@@ -1,4 +1,3 @@
-import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { test, expect } from "@playwright/test";
import { addPlugins, videoMode } from "../src/index.ts";
@@ -28,24 +27,26 @@ test("records highlight metadata without mutating element styles", async ({
expect(Date.now() - start).toBeLessThan(1000);
await expect(plugged.locator("#result")).toContainText("(no style)");
- expect(plugged.videoMode.metadata().highlights).toContainEqual(
- 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),
+ 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),
+ }),
}),
- start: expect.any(Number),
- thickness: 3,
- viewport: expect.objectContaining({
- height: expect.any(Number),
- width: expect.any(Number),
- }),
- }),
- );
+ ]),
+ });
});
test("skipped methods are not highlighted", async ({ page }, testInfo) => {
@@ -69,8 +70,9 @@ test("skipped methods are not highlighted", async ({ page }, testInfo) => {
const start = Date.now();
await plugged.locator("#btn").click();
expect(Date.now() - start).toBeLessThan(2000);
- expect(video.metadata().deadAir.some((span) => span.end - span.start >= 100)).toBe(true);
- expect(video.metadata().highlights).toEqual([]);
+ 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) => {
@@ -95,13 +97,14 @@ test("marks pre-action waits for attachment as dead air", async ({ page }, testI
await plugged.locator("#late").click();
await expect(plugged.locator("#result")).toContainText("clicked");
- expect(video.metadata().deadAir).toContainEqual(
+ const metadata = await video.metadata();
+ expect(metadata.deadAir).toContainEqual(
expect.objectContaining({
end: expect.any(Number),
start: expect.any(Number),
}),
);
- expect(video.metadata().deadAir.some((span) => span.end - span.start >= 100)).toBe(true);
+ expect(metadata.deadAir.some((span) => span.end - span.start >= 100)).toBe(true);
});
test("pre-action attached waits honor action timeout", async ({ page }, testInfo) => {
@@ -143,17 +146,18 @@ test("marks explicit attached waitFor calls as dead air", async ({ page }, testI
await plugged.locator("#late").waitFor({ state: "attached" });
- expect(video.metadata().deadAir.some((span) => span.end - span.start >= 100)).toBe(true);
+ expect((await video.metadata()).deadAir.some((span) => span.end - span.start >= 100)).toBe(true);
});
test("deadAir runs actions without video highlighting and records metadata", async ({
page,
}, testInfo) => {
+ const video = videoMode({ finalHold: 50, highlightDuration: 5000 });
{
await using plugged = await addPlugins({
page,
testInfo,
- plugins: [videoMode({ finalHold: 50, highlightDuration: 5000 })],
+ plugins: [video],
});
await plugged.setContent(`
@@ -176,20 +180,25 @@ test("deadAir runs actions without video highlighting and records metadata", asy
expect(Date.now() - start).toBeLessThan(2000);
expect(plugged.videoMode.getVideoTimestamp()).toBeGreaterThanOrEqual(videoTimestamp);
await expect(plugged.locator("#result")).toContainText("(no style)");
- expect(plugged.videoMode.metadata()).toMatchObject({
+ await expect(plugged.videoMode.metadata()).resolves.toMatchObject({
outputs: {},
schemaVersion: 1,
timebase: "ms",
});
- expect(plugged.videoMode.metadata().deadAir).toContainEqual(
+ expect((await plugged.videoMode.metadata()).deadAir).toContainEqual(
expect.objectContaining({ end: expect.any(Number), start: expect.any(Number) }),
);
- expect(plugged.videoMode.metadata().highlights).toEqual([]);
+ expect((await plugged.videoMode.metadata()).highlights).toEqual([]);
}
- const metadata = JSON.parse(
- await readFile(join(testInfo.outputDir, "video-mode.json"), "utf8"),
- );
+ 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: {},
diff --git a/src/plugins/index.ts b/src/plugins/index.ts
index 8ea5022..fc7dca1 100644
--- a/src/plugins/index.ts
+++ b/src/plugins/index.ts
@@ -5,6 +5,7 @@ export {
type VideoModeHighlight,
type VideoModeMetadata,
type VideoModeOptions,
+ type VideoModeOutputPaths,
type VideoModeOutputs,
type VideoModePageExtension,
type VideoModeRect,
diff --git a/src/plugins/video-mode.ts b/src/plugins/video-mode.ts
index a5940b8..aa83a62 100644
--- a/src/plugins/video-mode.ts
+++ b/src/plugins/video-mode.ts
@@ -15,6 +15,11 @@ 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";
export type VideoModeSpan = {
start: number;
@@ -27,6 +32,14 @@ export type VideoModeOutputs = {
rendered?: string;
};
+export type VideoModeOutputPaths = {
+ metadata: string;
+ player: string;
+ raw: string;
+ rendered: string;
+ reportPlayer: string;
+};
+
export type VideoModeRect = {
x: number;
y: number;
@@ -62,8 +75,10 @@ export type VideoModeControls = {
deadAir(action: () => Promise): Promise;
/** Milliseconds since video-mode started recording metadata for this test. */
getVideoTimestamp(): number;
- /** Current metadata snapshot. Written to video-mode.json after the test. */
- metadata(): VideoModeMetadata;
+ /** 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;
};
export type VideoModePageExtension = {
@@ -170,6 +185,31 @@ const metadataFor = (state: VideoModeState): VideoModeMetadata => {
};
};
+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 {
+ 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;
@@ -872,7 +912,7 @@ const videoModePlayerHtml = (options: { raw: string; rendered?: string }) => {
frame: 0
duration: ? s
Left/right steps one frame. Shift+left/right steps ten. Space toggles play.
-
+
+ `);
+
+ 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 + Math.round(highlightDurationMs / 2),
+ );
+ 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("does not linger on the unhighlighted post-wait state before a following highlight", async ({
page,
}, testInfo) => {
diff --git a/src/plugins/video-mode.ts b/src/plugins/video-mode.ts
index 67174da..e4b3699 100644
--- a/src/plugins/video-mode.ts
+++ b/src/plugins/video-mode.ts
@@ -201,8 +201,10 @@ type CursorWaypoint = {
};
type CursorTarget = {
- highlight: VideoModeHighlight;
- piece: RenderedVideoPiece;
+ actionEnd?: number;
+ method?: OverrideableMethod;
+ outputEnd: number;
+ outputStart: number;
point: { x: number; y: number };
};
@@ -864,37 +866,52 @@ const cursorTargets = (options: {
pieces: RenderedVideoPiece[];
video: { width: number; height: number };
}) => {
- return options.highlights
- .map((highlight) => ({
- highlight,
- piece: options.pieces.find((piece) => piece.highlight === highlight),
+ const targets: CursorTarget[] = [];
+
+ for (const highlight of options.highlights) {
+ const piece = options.pieces.find((candidate) => candidate.highlight === highlight);
+
+ if (!piece) {
+ continue;
+ }
+
+ targets.push({
+ actionEnd:
+ highlight.actionEnd === undefined
+ ? undefined
+ : sourceTimeToRenderedTime(options.pieces, highlight.actionEnd),
+ method: highlight.method,
+ outputEnd: piece.outputEnd,
+ outputStart: piece.outputStart,
point: highlightCursorPoint(highlight, options.video),
- }))
- .filter(
- (
- target,
- ): target is CursorTarget => Boolean(target.piece),
- );
+ });
+ }
+
+ return targets;
};
-const cursorWaypoints = (targets: CursorTarget[], pieces: RenderedVideoPiece[]) => {
+const cursorWaypoints = (targets: CursorTarget[], video: { width: number; height: number }) => {
const waypoints: CursorWaypoint[] = [];
+ if (targets.length > 0 && targets[0].outputStart > 0) {
+ pushCursorWaypoint(waypoints, {
+ at: 0,
+ x: video.width / 2,
+ y: video.height / 2,
+ });
+ }
+
for (let index = 0; index < targets.length; index += 1) {
const target = targets[index];
const nextTarget = targets[index + 1];
- const actionEnd =
- target.highlight.actionEnd === undefined
- ? undefined
- : sourceTimeToRenderedTime(pieces, target.highlight.actionEnd);
const targetHoldEnd =
- actionEnd === undefined ? target.piece.outputEnd : Math.max(target.piece.outputEnd, actionEnd);
+ target.actionEnd === undefined ? target.outputEnd : Math.max(target.outputEnd, target.actionEnd);
const holdEnd = nextTarget
- ? Math.min(targetHoldEnd, nextTarget.piece.outputStart)
+ ? Math.min(targetHoldEnd, nextTarget.outputStart)
: targetHoldEnd;
pushCursorWaypoint(waypoints, {
- at: target.piece.outputStart,
+ at: target.outputStart,
x: target.point.x,
y: target.point.y,
});
@@ -904,9 +921,9 @@ const cursorWaypoints = (targets: CursorTarget[], pieces: RenderedVideoPiece[])
y: target.point.y,
});
- if (nextTarget && nextTarget.piece.outputStart > holdEnd) {
+ if (nextTarget && nextTarget.outputStart > holdEnd) {
pushCursorWaypoint(waypoints, {
- at: nextTarget.piece.outputStart,
+ at: nextTarget.outputStart,
x: nextTarget.point.x,
y: nextTarget.point.y,
});
@@ -918,22 +935,25 @@ const cursorWaypoints = (targets: CursorTarget[], pieces: RenderedVideoPiece[])
const clickHoldSpans = (targets: CursorTarget[]) => {
return targets
- .filter((target) => target.highlight.method === "click")
+ .filter((target) => target.method === "click")
.map((target) => ({
- end: target.piece.outputEnd,
- start: target.piece.outputStart,
+ end: target.outputEnd,
+ start: target.outputStart,
}))
.filter((span) => span.end > span.start);
};
-const cursorActivitySpan = (targets: CursorTarget[]): VideoModeSpan | undefined => {
- if (targets.length === 0) {
+const cursorActivitySpan = (
+ targets: CursorTarget[],
+ waypoints: CursorWaypoint[],
+): VideoModeSpan | undefined => {
+ if (targets.length === 0 || waypoints.length === 0) {
return undefined;
}
return {
- end: Math.max(...targets.map((target) => target.piece.outputEnd)),
- start: Math.min(...targets.map((target) => target.piece.outputStart)),
+ end: Math.max(...targets.map((target) => target.outputEnd)),
+ start: waypoints[0].at,
};
};
@@ -1024,9 +1044,9 @@ const renderedVideoFilter = (options: {
pieces: renderedPieces,
video: options.video,
});
- const waypoints = cursorWaypoints(targets, renderedPieces);
+ const waypoints = cursorWaypoints(targets, options.video);
const clickSpans = clickHoldSpans(targets);
- const activitySpan = cursorActivitySpan(targets);
+ const activitySpan = cursorActivitySpan(targets, waypoints);
const clickSpanExpression = videoSpanExpression(clickSpans);
if (pieces.length === 0) {
From 6620ff785f13810e408e1d4eb0c643f6342eada6 Mon Sep 17 00:00:00 2001
From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com>
Date: Tue, 23 Jun 2026 16:36:22 +0100
Subject: [PATCH 24/35] Plan video pointer movement timing
---
spec/video-mode-ffmpeg.spec.ts | 4 +-
src/plugins/video-mode.ts | 96 ++++++++++++++++++++--------------
2 files changed, 60 insertions(+), 40 deletions(-)
diff --git a/spec/video-mode-ffmpeg.spec.ts b/spec/video-mode-ffmpeg.spec.ts
index 8b4c534..6c9e929 100644
--- a/spec/video-mode-ffmpeg.spec.ts
+++ b/spec/video-mode-ffmpeg.spec.ts
@@ -427,7 +427,7 @@ test("hides the pointer cursor after the last highlighted action", async ({ page
const renderedPath = paths.rendered;
const clickHoldFrame = await videoFrame(
renderedPath,
- highlight.start + Math.round(highlightDurationMs / 2),
+ highlight.start + highlightDurationMs - 100,
);
const finalHoldFrame = await videoFrame(renderedPath, (await videoDurationMs(renderedPath)) - 100);
const scale = Math.min(
@@ -511,7 +511,7 @@ test("moves the pointer toward the first click after a waitFor", async ({ page }
const preClickFrame = await videoFrame(renderedPath, Math.max(100, highlight.start - 650));
const clickHoldFrame = await videoFrame(
renderedPath,
- highlight.start + Math.round(highlightDurationMs / 2),
+ highlight.start + highlightDurationMs - 100,
);
const scale = Math.min(
preClickFrame.width / highlight.viewport.width,
diff --git a/src/plugins/video-mode.ts b/src/plugins/video-mode.ts
index e4b3699..f5da17c 100644
--- a/src/plugins/video-mode.ts
+++ b/src/plugins/video-mode.ts
@@ -201,13 +201,17 @@ type CursorWaypoint = {
};
type CursorTarget = {
- actionEnd?: number;
method?: OverrideableMethod;
outputEnd: number;
outputStart: number;
point: { x: number; y: number };
};
+const CURSOR_MOVEMENT_MIN_MS = 200;
+const CURSOR_MOVEMENT_IDEAL_MS = 500;
+const CURSOR_MOVEMENT_MAX_MS = 1000;
+const CURSOR_REST_BEFORE_ACTION_MS = 200;
+
type HighlightInput = {
durationMs: number;
image: string;
@@ -818,20 +822,6 @@ const renderedVideoPieces = (pieces: VideoPiece[]) => {
return rendered;
};
-const sourceTimeToRenderedTime = (pieces: RenderedVideoPiece[], sourceTime: number) => {
- for (const piece of pieces) {
- if (piece.highlight?.image) {
- continue;
- }
-
- if (sourceTime >= piece.start && sourceTime <= piece.end) {
- return piece.outputStart + (sourceTime - piece.start) / piece.speed;
- }
- }
-
- return undefined;
-};
-
const highlightCursorPoint = (
highlight: VideoModeHighlight,
video: { width: number; height: number },
@@ -876,10 +866,6 @@ const cursorTargets = (options: {
}
targets.push({
- actionEnd:
- highlight.actionEnd === undefined
- ? undefined
- : sourceTimeToRenderedTime(options.pieces, highlight.actionEnd),
method: highlight.method,
outputEnd: piece.outputEnd,
outputStart: piece.outputStart,
@@ -890,44 +876,78 @@ const cursorTargets = (options: {
return targets;
};
+const cursorArrivalDeadline = (target: CursorTarget) => {
+ return Math.max(target.outputStart, target.outputEnd - CURSOR_REST_BEFORE_ACTION_MS);
+};
+
+const cursorMovementTiming = (options: { earliestStart: number; target: CursorTarget }) => {
+ const deadline = cursorArrivalDeadline(options.target);
+ const available = Math.max(0, deadline - options.earliestStart);
+ const idealDuration = Math.min(CURSOR_MOVEMENT_IDEAL_MS, 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,
+ };
+};
+
+/**
+ * Plan cursor motion backwards from each action's click/commit moment.
+ *
+ * The cursor starts in the center, aims to spend 500ms moving, and reaches the
+ * target at least 200ms before the rendered action hold ends. If the existing
+ * video timeline does not have enough room, movement is compressed into the
+ * available time instead of extending the UI video. This keeps cursor motion
+ * readable without slowing the product interaction unless the configured
+ * highlight duration itself already creates that time.
+ */
const cursorWaypoints = (targets: CursorTarget[], video: { width: number; height: number }) => {
const waypoints: CursorWaypoint[] = [];
+ let currentPoint = {
+ x: video.width / 2,
+ y: video.height / 2,
+ };
+ let earliestStart = 0;
- if (targets.length > 0 && targets[0].outputStart > 0) {
- pushCursorWaypoint(waypoints, {
- at: 0,
- x: video.width / 2,
- y: video.height / 2,
- });
+ if (targets.length === 0) {
+ return 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 targetHoldEnd =
- target.actionEnd === undefined ? target.outputEnd : Math.max(target.outputEnd, target.actionEnd);
+ const movement = cursorMovementTiming({ earliestStart, target });
const holdEnd = nextTarget
- ? Math.min(targetHoldEnd, nextTarget.outputStart)
- : targetHoldEnd;
+ ? Math.min(target.outputEnd, nextTarget.outputStart)
+ : target.outputEnd;
pushCursorWaypoint(waypoints, {
- at: target.outputStart,
+ 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: holdEnd,
+ at: Math.max(holdEnd, movement.arriveAt),
x: target.point.x,
y: target.point.y,
});
- if (nextTarget && nextTarget.outputStart > holdEnd) {
- pushCursorWaypoint(waypoints, {
- at: nextTarget.outputStart,
- x: nextTarget.point.x,
- y: nextTarget.point.y,
- });
- }
+ currentPoint = target.point;
+ earliestStart = Math.max(earliestStart, target.outputEnd);
}
return waypoints;
From cac3126f83f0f114b20a1df5a37e0a8792ef7f6b Mon Sep 17 00:00:00 2001
From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com>
Date: Tue, 23 Jun 2026 16:55:38 +0100
Subject: [PATCH 25/35] Use text cursor for video text actions
---
spec/video-mode-ffmpeg.spec.ts | 132 +++++++++++++++++++++++++++++++++
src/plugins/video-mode.ts | 96 +++++++++++++++++++-----
2 files changed, 211 insertions(+), 17 deletions(-)
diff --git a/spec/video-mode-ffmpeg.spec.ts b/spec/video-mode-ffmpeg.spec.ts
index 6c9e929..05bc6ce 100644
--- a/spec/video-mode-ffmpeg.spec.ts
+++ b/spec/video-mode-ffmpeg.spec.ts
@@ -535,6 +535,104 @@ test("moves the pointer toward the first click after a waitFor", async ({ page }
expect(cursorPixelCount(clickHoldFrame, runBox)).toBeGreaterThan(40);
});
+test("uses the text cursor for fill and type after the pointer arrives", 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 fillTravelFrame = await videoFrame(renderedPath, fillStart + 500);
+ const fillRestFrame = await videoFrame(renderedPath, fillStart + highlightDurationMs - 100);
+ const typeRestFrame = await videoFrame(renderedPath, typeStart + highlightDurationMs - 100);
+ const scale = Math.min(
+ fillRestFrame.width / fillHighlight.viewport.width,
+ fillRestFrame.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(fillTravelFrame, fillBox)).toBeLessThan(10);
+ expect(textCursorPixelCount(fillRestFrame, fillBox)).toBeGreaterThan(35);
+ expect(textCursorPixelCount(typeRestFrame, typeBox)).toBeGreaterThan(35);
+});
+
test("does not linger on the unhighlighted post-wait state before a following highlight", async ({
page,
}, testInfo) => {
@@ -711,6 +809,18 @@ const renderedTimestampForSourceTimestamp = (
}, 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",
@@ -903,6 +1013,28 @@ const cursorPixelCount = (
});
};
+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 countPixels = (
frame: VideoFrame,
rect: { height: number; width: number; x: number; y: number },
diff --git a/src/plugins/video-mode.ts b/src/plugins/video-mode.ts
index f5da17c..2bd9b94 100644
--- a/src/plugins/video-mode.ts
+++ b/src/plugins/video-mode.ts
@@ -22,8 +22,9 @@ 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 and cursor-pointer.svg.
+// 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 =
@@ -36,6 +37,11 @@ const VIDEO_MODE_CLICK_POINTER_PNG =
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;
@@ -207,6 +213,15 @@ type CursorTarget = {
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_IDEAL_MS = 500;
const CURSOR_MOVEMENT_MAX_MS = 1000;
@@ -904,8 +919,12 @@ const cursorMovementTiming = (options: { earliestStart: number; target: CursorTa
* readable without slowing the product interaction unless the configured
* highlight duration itself already creates that time.
*/
-const cursorWaypoints = (targets: CursorTarget[], video: { width: number; height: number }) => {
+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,
@@ -913,7 +932,7 @@ const cursorWaypoints = (targets: CursorTarget[], video: { width: number; height
let earliestStart = 0;
if (targets.length === 0) {
- return waypoints;
+ return { targets: plannedTargets, waypoints };
}
pushCursorWaypoint(waypoints, {
@@ -945,20 +964,24 @@ const cursorWaypoints = (targets: CursorTarget[], video: { width: number; height
x: target.point.x,
y: target.point.y,
});
+ plannedTargets.push({
+ ...target,
+ arriveAt: movement.arriveAt,
+ });
currentPoint = target.point;
earliestStart = Math.max(earliestStart, target.outputEnd);
}
- return waypoints;
+ return { targets: plannedTargets, waypoints };
};
-const clickHoldSpans = (targets: CursorTarget[]) => {
+const methodCursorSpans = (targets: PlannedCursorTarget[], methods: OverrideableMethod[]) => {
return targets
- .filter((target) => target.method === "click")
+ .filter((target) => target.method !== undefined && methods.includes(target.method))
.map((target) => ({
end: target.outputEnd,
- start: target.outputStart,
+ start: target.arriveAt,
}))
.filter((span) => span.end > span.start);
};
@@ -1049,6 +1072,7 @@ const renderedVideoFilter = (options: {
highlightInputs: HighlightInput[];
highlights: VideoModeHighlight[];
segments: RenderVideoSegment[];
+ textPointerInput?: PointerInput;
video: { width: number; height: number };
}): VideoFilter | undefined => {
const highlightInputByImage = new Map(
@@ -1064,10 +1088,12 @@ const renderedVideoFilter = (options: {
pieces: renderedPieces,
video: options.video,
});
- const waypoints = cursorWaypoints(targets, options.video);
- const clickSpans = clickHoldSpans(targets);
- const activitySpan = cursorActivitySpan(targets, waypoints);
+ const plan = cursorPlan(targets, options.video);
+ const clickSpans = methodCursorSpans(plan.targets, ["click"]);
+ const textSpans = methodCursorSpans(plan.targets, ["fill", "type"]);
+ const activitySpan = cursorActivitySpan(targets, plan.waypoints);
const clickSpanExpression = videoSpanExpression(clickSpans);
+ const textSpanExpression = videoSpanExpression(textSpans);
if (pieces.length === 0) {
return undefined;
@@ -1139,13 +1165,16 @@ const renderedVideoFilter = (options: {
if (
options.highlightMode === "pointer" &&
options.cursorPointerInput &&
- waypoints.length > 0 &&
+ plan.waypoints.length > 0 &&
activitySpan
) {
const cursorOutputLabel = "renderpointer";
const cursorActivityExpression = `between(t\\,${formatSeconds(activitySpan.start)}\\,${formatSeconds(activitySpan.end)})`;
- const cursorEnable = clickSpanExpression
- ? `${cursorActivityExpression}*not(${clickSpanExpression})`
+ const specialCursorExpression = [clickSpanExpression, textSpanExpression]
+ .filter(Boolean)
+ .join("+");
+ const cursorEnable = specialCursorExpression
+ ? `${cursorActivityExpression}*not(${specialCursorExpression})`
: cursorActivityExpression;
filters.push(
cursorOverlayFilters({
@@ -1153,19 +1182,35 @@ const renderedVideoFilter = (options: {
inputLabel: outputLabel,
outputLabel: cursorOutputLabel,
pointerInput: options.cursorPointerInput,
- waypoints,
+ 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: cursorOutputLabel,
+ inputLabel: pointerOutputLabel,
outputLabel: clickPointerOutputLabel,
pointerInput: options.clickPointerInput,
- waypoints,
+ waypoints: plan.waypoints,
}),
);
@@ -1176,7 +1221,7 @@ const renderedVideoFilter = (options: {
}
return {
- outputLabel: cursorOutputLabel,
+ outputLabel: pointerOutputLabel,
value: filters.join(";"),
};
}
@@ -1533,12 +1578,24 @@ const renderVideo = async (options: {
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,
@@ -1553,6 +1610,7 @@ const renderVideo = async (options: {
highlightInputs,
highlights: options.highlights,
segments,
+ textPointerInput,
video: info,
});
@@ -1563,6 +1621,9 @@ const renderVideo = async (options: {
await execFile(
"ffmpeg",
[
+ "-hide_banner",
+ "-loglevel",
+ "error",
"-y",
"-i",
options.inputPath,
@@ -1576,6 +1637,7 @@ const renderVideo = async (options: {
]),
...(cursorPointerInput ? ["-loop", "1", "-i", cursorPointerInput.path] : []),
...(clickPointerInput ? ["-loop", "1", "-i", clickPointerInput.path] : []),
+ ...(textPointerInput ? ["-loop", "1", "-i", textPointerInput.path] : []),
"-filter_complex",
filter.value,
"-map",
From 07b2896b69d12e25cd1471ddbd5a42af18a9f1b6 Mon Sep 17 00:00:00 2001
From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com>
Date: Wed, 24 Jun 2026 12:12:53 +0100
Subject: [PATCH 26/35] Hold target cursor shape after arrival
---
spec/video-mode-ffmpeg.spec.ts | 20 +++++++++++---------
src/plugins/video-mode.ts | 33 ++++++++++++++++++++++++---------
2 files changed, 35 insertions(+), 18 deletions(-)
diff --git a/spec/video-mode-ffmpeg.spec.ts b/spec/video-mode-ffmpeg.spec.ts
index 05bc6ce..ae274bb 100644
--- a/spec/video-mode-ffmpeg.spec.ts
+++ b/spec/video-mode-ffmpeg.spec.ts
@@ -535,7 +535,7 @@ test("moves the pointer toward the first click after a waitFor", async ({ page }
expect(cursorPixelCount(clickHoldFrame, runBox)).toBeGreaterThan(40);
});
-test("uses the text cursor for fill and type after the pointer arrives", async ({
+test("holds the text cursor for fill and type after the pointer arrives", async ({
page,
}, testInfo) => {
const highlightDurationMs = 1000;
@@ -608,12 +608,13 @@ test("uses the text cursor for fill and type after the pointer arrives", async (
const renderedPath = paths.rendered;
const fillStart = renderedHighlightStartWithoutDeadAir(fillHighlight, metadata.highlights);
const typeStart = renderedHighlightStartWithoutDeadAir(typeHighlight, metadata.highlights);
- const fillTravelFrame = await videoFrame(renderedPath, fillStart + 500);
- const fillRestFrame = await videoFrame(renderedPath, fillStart + highlightDurationMs - 100);
- const typeRestFrame = await videoFrame(renderedPath, typeStart + highlightDurationMs - 100);
+ const fillEarlyRestFrame = await videoFrame(renderedPath, fillStart + 300);
+ const fillLateRestFrame = await videoFrame(renderedPath, fillStart + highlightDurationMs - 100);
+ const typeEarlyRestFrame = await videoFrame(renderedPath, typeStart + 300);
+ const typeLateRestFrame = await videoFrame(renderedPath, typeStart + highlightDurationMs - 100);
const scale = Math.min(
- fillRestFrame.width / fillHighlight.viewport.width,
- fillRestFrame.height / fillHighlight.viewport.height,
+ fillLateRestFrame.width / fillHighlight.viewport.width,
+ fillLateRestFrame.height / fillHighlight.viewport.height,
);
const fillBox = {
height: Math.round(fillHighlight.rect.height * scale),
@@ -628,9 +629,10 @@ test("uses the text cursor for fill and type after the pointer arrives", async (
y: Math.round(typeHighlight.rect.y * scale),
};
- expect(textCursorPixelCount(fillTravelFrame, fillBox)).toBeLessThan(10);
- expect(textCursorPixelCount(fillRestFrame, fillBox)).toBeGreaterThan(35);
- expect(textCursorPixelCount(typeRestFrame, typeBox)).toBeGreaterThan(35);
+ expect(textCursorPixelCount(fillEarlyRestFrame, fillBox)).toBeGreaterThan(35);
+ expect(textCursorPixelCount(fillLateRestFrame, fillBox)).toBeGreaterThan(35);
+ expect(textCursorPixelCount(typeEarlyRestFrame, typeBox)).toBeGreaterThan(35);
+ expect(textCursorPixelCount(typeLateRestFrame, typeBox)).toBeGreaterThan(35);
});
test("does not linger on the unhighlighted post-wait state before a following highlight", async ({
diff --git a/src/plugins/video-mode.ts b/src/plugins/video-mode.ts
index 2bd9b94..9b46efa 100644
--- a/src/plugins/video-mode.ts
+++ b/src/plugins/video-mode.ts
@@ -226,6 +226,7 @@ const CURSOR_MOVEMENT_MIN_MS = 200;
const CURSOR_MOVEMENT_IDEAL_MS = 500;
const CURSOR_MOVEMENT_MAX_MS = 1000;
const CURSOR_REST_BEFORE_ACTION_MS = 200;
+const CURSOR_TARGET_HOLD_IDEAL_MS = 1000;
type HighlightInput = {
durationMs: number;
@@ -891,12 +892,26 @@ const cursorTargets = (options: {
return targets;
};
-const cursorArrivalDeadline = (target: CursorTarget) => {
- return Math.max(target.outputStart, target.outputEnd - CURSOR_REST_BEFORE_ACTION_MS);
+const cursorArrivalDeadline = (options: { 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 + CURSOR_MOVEMENT_MIN_MS;
+
+ return Math.min(
+ latestArrivalWithMinimumRest,
+ Math.max(idealHoldArrival, readableMovementArrival),
+ );
};
const cursorMovementTiming = (options: { earliestStart: number; target: CursorTarget }) => {
- const deadline = cursorArrivalDeadline(options.target);
+ const deadline = cursorArrivalDeadline(options);
const available = Math.max(0, deadline - options.earliestStart);
const idealDuration = Math.min(CURSOR_MOVEMENT_IDEAL_MS, CURSOR_MOVEMENT_MAX_MS);
const duration =
@@ -912,12 +927,12 @@ const cursorMovementTiming = (options: { earliestStart: number; target: CursorTa
/**
* Plan cursor motion backwards from each action's click/commit moment.
*
- * The cursor starts in the center, aims to spend 500ms moving, and reaches the
- * target at least 200ms before the rendered action hold ends. If the existing
- * video timeline does not have enough room, movement is compressed into the
- * available time instead of extending the UI video. This keeps cursor motion
- * readable without slowing the product interaction unless the configured
- * highlight duration itself already creates that time.
+ * The cursor starts in the center, aims to spend 500ms moving, and switches to
+ * the target-specific cursor shape only after arriving. The arrival point is
+ * chosen to preserve an ideal 1s target hold when the existing timeline has
+ * room; otherwise movement compresses toward 200ms so the hand/text cursor gets
+ * most of the configured highlight hold. This keeps cursor motion readable
+ * without extending the product interaction.
*/
const cursorPlan = (
targets: CursorTarget[],
From ba16c9097b144543b938097ba36d04297a0115b9 Mon Sep 17 00:00:00 2001
From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com>
Date: Wed, 24 Jun 2026 12:17:21 +0100
Subject: [PATCH 27/35] Skip rendering empty video source ranges
---
spec/video-mode-ffmpeg.spec.ts | 54 ++++++++++++++++++++++++++++++++++
src/plugins/video-mode.ts | 10 +++++--
2 files changed, 61 insertions(+), 3 deletions(-)
diff --git a/spec/video-mode-ffmpeg.spec.ts b/spec/video-mode-ffmpeg.spec.ts
index ae274bb..b76d0b2 100644
--- a/spec/video-mode-ffmpeg.spec.ts
+++ b/spec/video-mode-ffmpeg.spec.ts
@@ -282,6 +282,60 @@ test("renders only the selected video source range", async ({ page }, testInfo)
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({
diff --git a/src/plugins/video-mode.ts b/src/plugins/video-mode.ts
index 9b46efa..a2d86a0 100644
--- a/src/plugins/video-mode.ts
+++ b/src/plugins/video-mode.ts
@@ -1554,16 +1554,20 @@ const renderVideo = async (options: {
thresholdMs: number | undefined;
}) => {
const info = await videoInfo(options.inputPath);
- const rangeStart = Math.max(0, Math.min(Math.round(options.sourceRange.start || 0), info.durationMs));
+ 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(options.sourceRange.end || info.durationMs), info.durationMs),
+ Math.min(Math.round(sourceRangeEnd), info.durationMs),
);
if (rangeEnd <= rangeStart) {
- throw new Error(
+ console.warn(
`videoMode source range is empty: start ${rangeStart}ms must be before end ${rangeEnd}ms`,
);
+ return false;
}
const highlightInputs = options.highlights
From 0b93737f0fa8c6c87dfa9e03a25976ec77131ff0 Mon Sep 17 00:00:00 2001
From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com>
Date: Wed, 24 Jun 2026 12:30:17 +0100
Subject: [PATCH 28/35] Add pointer tail after text cursor hold
---
spec/video-mode-ffmpeg.spec.ts | 65 ++++++++++++++++++++++++++++------
src/plugins/video-mode.ts | 17 ++++++++-
2 files changed, 70 insertions(+), 12 deletions(-)
diff --git a/spec/video-mode-ffmpeg.spec.ts b/spec/video-mode-ffmpeg.spec.ts
index b76d0b2..5b1b5dd 100644
--- a/spec/video-mode-ffmpeg.spec.ts
+++ b/spec/video-mode-ffmpeg.spec.ts
@@ -589,7 +589,7 @@ test("moves the pointer toward the first click after a waitFor", async ({ page }
expect(cursorPixelCount(clickHoldFrame, runBox)).toBeGreaterThan(40);
});
-test("holds the text cursor for fill and type after the pointer arrives", async ({
+test("uses a normal pointer tail after text cursor holds", async ({
page,
}, testInfo) => {
const highlightDurationMs = 1000;
@@ -662,13 +662,13 @@ test("holds the text cursor for fill and type after the pointer arrives", async
const renderedPath = paths.rendered;
const fillStart = renderedHighlightStartWithoutDeadAir(fillHighlight, metadata.highlights);
const typeStart = renderedHighlightStartWithoutDeadAir(typeHighlight, metadata.highlights);
- const fillEarlyRestFrame = await videoFrame(renderedPath, fillStart + 300);
- const fillLateRestFrame = await videoFrame(renderedPath, fillStart + highlightDurationMs - 100);
- const typeEarlyRestFrame = await videoFrame(renderedPath, typeStart + 300);
- const typeLateRestFrame = await videoFrame(renderedPath, typeStart + highlightDurationMs - 100);
+ 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(
- fillLateRestFrame.width / fillHighlight.viewport.width,
- fillLateRestFrame.height / fillHighlight.viewport.height,
+ fillTextFrame.width / fillHighlight.viewport.width,
+ fillTextFrame.height / fillHighlight.viewport.height,
);
const fillBox = {
height: Math.round(fillHighlight.rect.height * scale),
@@ -683,10 +683,14 @@ test("holds the text cursor for fill and type after the pointer arrives", async
y: Math.round(typeHighlight.rect.y * scale),
};
- expect(textCursorPixelCount(fillEarlyRestFrame, fillBox)).toBeGreaterThan(35);
- expect(textCursorPixelCount(fillLateRestFrame, fillBox)).toBeGreaterThan(35);
- expect(textCursorPixelCount(typeEarlyRestFrame, typeBox)).toBeGreaterThan(35);
- expect(textCursorPixelCount(typeLateRestFrame, typeBox)).toBeGreaterThan(35);
+ expect(textCursorPixelCount(fillTextFrame, fillBox)).toBeGreaterThan(35);
+ expect(textCursorTopCapPixelCount(fillTextFrame, fillBox)).toBeGreaterThan(5);
+ expect(textCursorTopCapPixelCount(fillPointerTailFrame, fillBox)).toBeLessThan(3);
+ expect(pointerTailPixelCount(fillPointerTailFrame, fillBox)).toBeGreaterThan(20);
+ expect(textCursorPixelCount(typeTextFrame, typeBox)).toBeGreaterThan(35);
+ expect(textCursorTopCapPixelCount(typeTextFrame, typeBox)).toBeGreaterThan(5);
+ expect(textCursorTopCapPixelCount(typePointerTailFrame, typeBox)).toBeLessThan(3);
+ expect(pointerTailPixelCount(typePointerTailFrame, typeBox)).toBeGreaterThan(20);
});
test("does not linger on the unhighlighted post-wait state before a following highlight", async ({
@@ -1091,6 +1095,45 @@ const textCursorPixelCount = (
);
};
+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 },
diff --git a/src/plugins/video-mode.ts b/src/plugins/video-mode.ts
index a2d86a0..8402310 100644
--- a/src/plugins/video-mode.ts
+++ b/src/plugins/video-mode.ts
@@ -227,6 +227,8 @@ const CURSOR_MOVEMENT_IDEAL_MS = 500;
const CURSOR_MOVEMENT_MAX_MS = 1000;
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;
@@ -1001,6 +1003,19 @@ const methodCursorSpans = (targets: PlannedCursorTarget[], methods: Overrideable
.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[],
@@ -1105,7 +1120,7 @@ const renderedVideoFilter = (options: {
});
const plan = cursorPlan(targets, options.video);
const clickSpans = methodCursorSpans(plan.targets, ["click"]);
- const textSpans = methodCursorSpans(plan.targets, ["fill", "type"]);
+ const textSpans = textCursorSpans(plan.targets);
const activitySpan = cursorActivitySpan(targets, plan.waypoints);
const clickSpanExpression = videoSpanExpression(clickSpans);
const textSpanExpression = videoSpanExpression(textSpans);
From 97f0d8d9963af2698542c14956d9d38f7ab09c3d Mon Sep 17 00:00:00 2001
From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com>
Date: Wed, 24 Jun 2026 14:40:06 +0100
Subject: [PATCH 29/35] Add shareable video frame state
---
README.md | 2 +-
spec/video-mode-ffmpeg.spec.ts | 18 ++++
src/plugins/video-mode.ts | 159 ++++++++++++++++++++++++---------
3 files changed, 137 insertions(+), 42 deletions(-)
diff --git a/README.md b/README.md
index 48fde47..4138c9f 100644
--- a/README.md
+++ b/README.md
@@ -166,7 +166,7 @@ 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. If `ffmpeg` or `ffprobe` is missing, the render step fails plainly so you know to install ffmpeg.
+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:
diff --git a/spec/video-mode-ffmpeg.spec.ts b/spec/video-mode-ffmpeg.spec.ts
index 5b1b5dd..c1a602a 100644
--- a/spec/video-mode-ffmpeg.spec.ts
+++ b/spec/video-mode-ffmpeg.spec.ts
@@ -2,6 +2,7 @@ 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";
@@ -149,6 +150,10 @@ test("writes video-mode artifact files and report player", async ({ page }, test
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)}"`,
);
@@ -170,6 +175,19 @@ test("writes video-mode artifact files and report player", async ({ page }, test
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("speeds dead air up instead of cutting through it", async ({ page }, testInfo) => {
diff --git a/src/plugins/video-mode.ts b/src/plugins/video-mode.ts
index 8402310..1d67709 100644
--- a/src/plugins/video-mode.ts
+++ b/src/plugins/video-mode.ts
@@ -223,8 +223,10 @@ type CursorPlan = {
};
const CURSOR_MOVEMENT_MIN_MS = 200;
-const CURSOR_MOVEMENT_IDEAL_MS = 500;
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;
@@ -894,7 +896,27 @@ const cursorTargets = (options: {
return targets;
};
-const cursorArrivalDeadline = (options: { earliestStart: number; target: CursorTarget }) => {
+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,
@@ -904,7 +926,8 @@ const cursorArrivalDeadline = (options: { earliestStart: number; target: CursorT
target.outputStart,
target.outputEnd - CURSOR_TARGET_HOLD_IDEAL_MS,
);
- const readableMovementArrival = options.earliestStart + CURSOR_MOVEMENT_MIN_MS;
+ const readableMovementArrival =
+ options.earliestStart + cursorMovementIdealDuration(options);
return Math.min(
latestArrivalWithMinimumRest,
@@ -912,10 +935,14 @@ const cursorArrivalDeadline = (options: { earliestStart: number; target: CursorT
);
};
-const cursorMovementTiming = (options: { earliestStart: number; target: CursorTarget }) => {
+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(CURSOR_MOVEMENT_IDEAL_MS, CURSOR_MOVEMENT_MAX_MS);
+ 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);
@@ -929,12 +956,12 @@ const cursorMovementTiming = (options: { earliestStart: number; target: CursorTa
/**
* Plan cursor motion backwards from each action's click/commit moment.
*
- * The cursor starts in the center, aims to spend 500ms moving, and switches to
- * the target-specific cursor shape only after arriving. The arrival point is
- * chosen to preserve an ideal 1s target hold when the existing timeline has
- * room; otherwise movement compresses toward 200ms so the hand/text cursor gets
- * most of the configured highlight hold. This keeps cursor motion readable
- * without extending the product interaction.
+ * 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.
*/
const cursorPlan = (
targets: CursorTarget[],
@@ -961,7 +988,7 @@ const cursorPlan = (
for (let index = 0; index < targets.length; index += 1) {
const target = targets[index];
const nextTarget = targets[index + 1];
- const movement = cursorMovementTiming({ earliestStart, target });
+ const movement = cursorMovementTiming({ currentPoint, earliestStart, target });
const holdEnd = nextTarget
? Math.min(target.outputEnd, nextTarget.outputStart)
: target.outputEnd;
@@ -1326,14 +1353,19 @@ const escapeHtml = (value: string) => {
return value.replace(/[&"'<>]/g, (character) => entities[character]);
};
-const videoElementHtml = (options: { label: string; source: string }) => {
+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 `
-
+ `;
@@ -1347,11 +1379,12 @@ const playwrightReportAttachmentName = async (path: string) => {
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({ label: "Raw video", source: options.raw })}
+ ${videoElementHtml({ activeKey: "raw", label: "Raw video", source: options.raw })}
`
: "";
@@ -1478,7 +1511,7 @@ const videoModePlayerHtml = (options: { raw: string; rendered?: string }) => {
- ${videoElementHtml({ label: primaryLabel, source: primary })}
+ ${videoElementHtml({ activeKey: primaryActiveKey, label: primaryLabel, source: primary })}
${rawDetails}
@@ -1497,60 +1530,104 @@ const videoModePlayerHtml = (options: { raw: string; rendered?: string }) => {
const time = document.querySelector("#time");
const frame = document.querySelector("#frame");
const duration = document.querySelector("#duration");
+ const videoByActiveKey = new Map(videos.map((video) => [video.dataset.activeKey, video]));
let activeVideo = videos[0];
- const setActiveVideo = (video) => {
+ const rate = () => Number(fps.value) || 25;
+ const currentFrame = () => Math.round(activeVideo.currentTime * rate());
+ const activeKeyFor = (video) => video.dataset.activeKey || "rendered";
+ const revealVideo = (video) => {
+ const details = video.closest("details");
+ if (details) details.open = true;
+ };
+ const writeStateToUrl = () => {
+ const next = new URL(location.href);
+ next.searchParams.set("active", activeKeyFor(activeVideo));
+ next.searchParams.set("frame", String(currentFrame()));
+ history.replaceState(null, "", next.href);
+ };
+
+ const setActiveVideo = (video, writeUrl) => {
activeVideo = video;
- update();
+ revealVideo(video);
+ update(writeUrl);
};
- const update = () => {
- const rate = Number(fps.value) || 25;
+ const update = (writeUrl) => {
const title = activeVideo.closest(".video-section").querySelector(".section-title").textContent;
active.textContent = title;
time.textContent = activeVideo.currentTime.toFixed(3);
- frame.textContent = String(Math.round(activeVideo.currentTime * rate));
+ frame.textContent = String(currentFrame());
duration.textContent = Number.isFinite(activeVideo.duration) ? activeVideo.duration.toFixed(3) : "?";
+ if (writeUrl) writeStateToUrl();
};
- const stepFrames = (count) => {
- const rate = Number(fps.value) || 25;
+ const seekToFrame = (frameNumber, writeUrl) => {
+ if (!Number.isFinite(frameNumber)) return;
+ if (!Number.isFinite(activeVideo.duration)) {
+ activeVideo.addEventListener("loadedmetadata", () => seekToFrame(frameNumber, writeUrl), {
+ once: true,
+ });
+ return;
+ }
+
activeVideo.pause();
activeVideo.currentTime = Math.max(
0,
- Math.min(activeVideo.duration || Infinity, activeVideo.currentTime + count / rate),
+ Math.min(activeVideo.duration, Math.round(frameNumber) / rate()),
);
+ update(writeUrl);
+ };
+
+ const stepFrames = (count) => {
+ seekToFrame(currentFrame() + count, true);
};
for (const video of videos) {
- video.addEventListener("focus", () => setActiveVideo(video));
- video.addEventListener("pointerdown", () => setActiveVideo(video));
- video.addEventListener("mouseenter", () => setActiveVideo(video));
- video.addEventListener("loadedmetadata", update);
- video.addEventListener("seeked", update);
- video.addEventListener("timeupdate", update);
+ video.addEventListener("focus", () => setActiveVideo(video, true));
+ video.addEventListener("pointerdown", () => setActiveVideo(video, true));
+ video.addEventListener("mouseenter", () => setActiveVideo(video, true));
+ video.addEventListener("loadedmetadata", () => update(false));
+ video.addEventListener("seeked", () => update(true));
+ video.addEventListener("timeupdate", () => update(true));
}
document.querySelector("#back").addEventListener("click", () => stepFrames(-1));
document.querySelector("#forward").addEventListener("click", () => stepFrames(1));
- document.addEventListener("keydown", (event) => {
+ fps.addEventListener("input", () => update(true));
+ window.addEventListener("keydown", (event) => {
if (event.target instanceof HTMLInputElement) return;
+ let handled = true;
if (event.key === "ArrowRight") {
- event.preventDefault();
stepFrames(event.shiftKey ? 10 : 1);
- }
- if (event.key === "ArrowLeft") {
- event.preventDefault();
+ } else if (event.key === "ArrowLeft") {
stepFrames(event.shiftKey ? -10 : -1);
- }
- if (event.key === " ") {
- event.preventDefault();
+ } else if (event.key === " ") {
if (activeVideo.paused) activeVideo.play();
else activeVideo.pause();
+ } else {
+ handled = false;
}
- });
+ if (!handled) return;
+ event.preventDefault();
+ event.stopPropagation();
+ }, { capture: true });
+
+ const applyUrlState = () => {
+ const params = new URL(location.href).searchParams;
+ const requestedVideo = videoByActiveKey.get(params.get("active"));
+ setActiveVideo(requestedVideo || activeVideo, false);
+ const requestedFrame = Number(params.get("frame"));
+ if (Number.isFinite(requestedFrame)) {
+ seekToFrame(requestedFrame, false);
+ return;
+ }
+ update(false);
+ };
- update();
+ document.body.tabIndex = -1;
+ document.body.focus();
+ applyUrlState();