Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
e0a2cff
Improve spinner waiter late-spinner failures
mmkal Jun 17, 2026
268d99c
better
mmkal Jun 19, 2026
83a0424
Improve video-mode dead-air trimming
mmkal Jun 22, 2026
bc8fb2d
Add video-mode dead-air threshold
mmkal Jun 22, 2026
faea66f
Add plugin page extensions
mmkal Jun 22, 2026
0615654
Render video-mode annotations in post
mmkal Jun 22, 2026
031604b
Avoid dead-air tail before video highlights
mmkal Jun 22, 2026
608f342
Add video-mode frame stepper report attachment
mmkal Jun 22, 2026
1c73aa2
Clean up video-mode indentation
mmkal Jun 22, 2026
9dfeb0a
Expose video-mode artifact helpers
mmkal Jun 22, 2026
c8f9af2
Add video-mode source range controls
mmkal Jun 22, 2026
01b9e1d
Add test writing guidance and pkg pr previews
mmkal Jun 22, 2026
c560839
Merge remote-tracking branch 'origin/main' into spinner-waiter-late-s…
mmkal Jun 22, 2026
50ccef4
Keep spinner fast-fail inside middleware chain
mmkal Jun 22, 2026
988280f
Guard attached timing observer after stop
mmkal Jun 22, 2026
31ecd95
Install ffmpeg in CI
mmkal Jun 22, 2026
3a4e572
Avoid synthetic attached timestamps
mmkal Jun 22, 2026
d0b932b
ff
mmkal Jun 23, 2026
0fe764f
Disable bundled plugins in Playwright debug mode
mmkal Jun 23, 2026
546bfa5
pointer
mmkal Jun 23, 2026
1b2723a
Use MDI cursor assets for video pointer mode
mmkal Jun 23, 2026
0955527
Configure video-mode highlight rendering
mmkal Jun 23, 2026
70fb3db
Hide video pointer after final action
mmkal Jun 23, 2026
f76065b
Animate initial video pointer from center
mmkal Jun 23, 2026
6620ff7
Plan video pointer movement timing
mmkal Jun 23, 2026
cac3126
Use text cursor for video text actions
mmkal Jun 23, 2026
07b2896
Hold target cursor shape after arrival
mmkal Jun 24, 2026
ba16c90
Skip rendering empty video source ranges
mmkal Jun 24, 2026
0b93737
Add pointer tail after text cursor hold
mmkal Jun 24, 2026
97f0d8d
Add shareable video frame state
mmkal Jun 24, 2026
e1d7510
Skip action frames after highlight holds
mmkal Jun 24, 2026
e863fd3
Keep pointer visible after final action
mmkal Jun 24, 2026
ac145dd
Address video-mode review comments
mmkal Jun 24, 2026
0237d32
Harden text cursor video assertion
mmkal Jun 24, 2026
4e1ddc7
Record attached actionability waits as dead air
mmkal Jun 24, 2026
cbdf837
Avoid recording immediate actions as dead air
mmkal Jun 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@ jobs:
- run: pnpm typecheck
- run: pnpm build
- run: pnpm exec publint
- run: sudo apt-get update && sudo apt-get install -y ffmpeg
- run: pnpm exec playwright install chromium --with-deps
- run: pnpm test
- run: pnpm exec pkg-pr-new publish --packageManager=pnpm
if: github.event_name == 'pull_request'
- uses: actions/upload-artifact@v5
if: failure()
with:
Expand Down
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# middlewright Agent Notes

## Plugin Boundaries

When plugins interact, preserve caller ergonomics and plugin ownership. Expose shared cross-cutting facts through neutral middleware context rather than coupling one plugin to another plugin's feature.

For example, `spinnerWaiter` should own spinner-specific waiting and errors, while `videoMode` should own video highlighting, dead-air metadata, and ffmpeg output. If both need timing information, add or use neutral middleware context such as `ActionContext.timing`; do not make `spinnerWaiter` know about `videoMode.deadAir`.

### Writing tests

Guidance for *end-users* to write tests is in [./writing-middlewright-tests.md](./writing-middlewright-tests.md). In many cases we should follow this guidance too, but we may additionally want to validate the assumptions we ask end-users to make, so deviate from that guidance consciously, but not arbitrarily or unthinkingly.
80 changes: 71 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,11 @@ Ships with five plugins:
| [`spinnerWaiter`](#spinnerwaiter) | If the app is visibly loading, wait longer for elements. If it isn't, fail fast. | [source](./src/plugins/spinner-waiter.ts) |
| [`hydrationWaiter`](#hydrationwaiter) | Don't interact with the app until it's hydrated. | [source](./src/plugins/hydration-waiter.ts) |
| [`uiErrorReporter`](#uierrorreporter) | When an action fails, append any visible error toasts to the error message. | [source](./src/plugins/ui-error-reporter.ts) |
| [`videoMode`](#videomode) | Highlight elements and pause before actions, so recorded videos are watchable. | [source](./src/plugins/video-mode.ts) |
| [`videoMode`](#videomode) | Record action/dead-air facts and render watchable annotated videos after the run. | [source](./src/plugins/video-mode.ts) |
| [`llmRecover`](#llmrecover) | When an action fails, ask an LLM to write and run recovery code. Marks the test as soft-failed so nothing silently passes. | [source](./src/plugins/llm-recover.ts) |

When Playwright is launched with `--debug`, it sets `PWDEBUG=1`; the bundled plugins treat that as a hard debug-mode no-op. They still return plugin objects so your fixture can stay unchanged, but they do not wrap locator actions, wait for app state, recover failures, or write video artifacts.

`spinnerWaiter` is the best one. It makes your test pass fast, fail fast, and it incentivises agents to *improve* the product when tests fail, instead of bumping timeouts which makes tests worse and lets your product get away with bad UX.

## ⚠️ This is a hack
Expand Down Expand Up @@ -130,18 +132,52 @@ uiErrorReporter({ selector: '[data-type="error"]' });

### videoMode

For producing demo/debugging videos people can actually follow: outlines the element in gold, pauses before each action, and pauses after the test so the video doesn't cut off abruptly. Enable it conditionally (e.g. `!!process.env.VIDEO_MODE && videoMode()`) together with Playwright's `video: "on"` and a generous `actionTimeout`.
For producing demo/debugging videos people can actually follow: marks pre-action waiting as dead air, records action bounding boxes, and renders highlights/final holds into the video after the test run. Enable it conditionally (e.g. `!!process.env.VIDEO_MODE && videoMode()`) together with Playwright's `video: "on"` and a generous `actionTimeout`.

```ts
await using page = await addPlugins({
page: basePage,
testInfo,
plugins: [
videoMode({
highlight: { mode: "pointer", duration: 1000 },
finalHold: 3000,
deadAirThreshold: 300,
skipMethods: ["waitFor"],
skipStackFrames: ["test-helpers.ts"], // don't annotate internal login/setup helpers
}),
],
});

// Use page.videoMode for invisible setup/bookkeeping that should be marked as
// dead air instead of highlighted in video mode.
await page.videoMode.deadAir(async () => {
await page.goto("/login");
await page.locator("#email").fill("demo@example.com");
});

const videoPaths = page.videoMode.outputPaths();
const videoMetadata = await page.videoMode.metadata();
console.log(videoPaths.rendered);
console.log(videoMetadata.highlights.length);

page.videoMode.setStartTime();
await page.locator("#important-flow").click();
page.videoMode.setEndTime();
```

When Playwright video recording is enabled, `videoMode` saves `video-raw.webm`, uses `ffmpeg` to write `video-rendered.webm`, writes a sibling `video-mode.html` frame-stepper for inspecting both videos, and attaches all of them with `video-mode.json` to the test report. The frame-stepper stores its active video and frame in the URL, so links like `video-mode.html?active=rendered&frame=28` reopen the same frame. If `ffmpeg` or `ffprobe` is missing, the render step fails plainly so you know to install ffmpeg.

`video-mode.json` records raw dead-air spans and highlight rectangles. `deadAirThreshold` is applied only when writing the rendered video: dead-air spans longer than the threshold are sped up so they render within that duration. Spans at or below the threshold are left at normal speed. `highlight` duration and `finalHold` are also applied at render time, so they do not slow down the browser test. `highlight: true` is equivalent to the default pointer mode, `{ mode: "pointer", duration: 1000 }`. For outline boxes, use a simple solid CSS-style string:

```ts
videoMode({
pauseBefore: 1000,
pauseAfterTest: 3000,
highlightStyle: "3px solid gold",
skipMethods: ["waitFor"],
skipStackFrames: ["test-helpers.ts"], // don't slow down internal login/setup helpers
highlight: { mode: "outline", style: "1px solid yellow" },
});
```

Put `spinnerWaiter` before `videoMode` when you use both. Spinner-waiter still owns spinner-specific waiting and errors, while video-mode records the preceding middleware wait as dead air and records the action target immediately before the action.

### llmRecover

The most fun one, and the most dangerous one. When an action fails, it captures a screenshot, the accessibility snapshot, the page HTML and the error, asks Claude to respond with a JavaScript recovery function, and `eval`s it with `{ page, locator, error }` in scope. Up to `maxAttempts` tries, with attempt history fed back to the model.
Expand Down Expand Up @@ -216,9 +252,9 @@ export default defineConfig({

## Writing your own plugin

**Writing your own plugins is the intended way to use this package.** The bundled five exist because they were useful for one particular app; your app has its own loading conventions, error surfaces, and flake patterns. Each bundled plugin is one small self-contained file — use them as inspiration: [spinner-waiter](./src/plugins/spinner-waiter.ts) (conditional waiting + error enrichment + runtime settings via `AsyncLocalStorage`), [hydration-waiter](./src/plugins/hydration-waiter.ts) (the simplest one — start here), [ui-error-reporter](./src/plugins/ui-error-reporter.ts) (catch/enrich/rethrow), [video-mode](./src/plugins/video-mode.ts) (page mutation around actions + lifecycle hooks), [llm-recover](./src/plugins/llm-recover.ts) (recovery loops, artifacts, soft assertions). The source also ships inside the npm package, so it's right there in `node_modules/middlewright/src`.
**Writing your own plugins is the intended way to use this package.** The bundled five exist because they were useful for one particular app; your app has its own loading conventions, error surfaces, and flake patterns. Each bundled plugin is one small self-contained file — use them as inspiration: [spinner-waiter](./src/plugins/spinner-waiter.ts) (conditional waiting + error enrichment + runtime settings via `AsyncLocalStorage`), [hydration-waiter](./src/plugins/hydration-waiter.ts) (the simplest one — start here), [ui-error-reporter](./src/plugins/ui-error-reporter.ts) (catch/enrich/rethrow), [video-mode](./src/plugins/video-mode.ts) (video annotations/artifacts + lifecycle hooks), [llm-recover](./src/plugins/llm-recover.ts) (recovery loops, artifacts, soft assertions). The source also ships inside the npm package, so it's right there in `node_modules/middlewright/src`.

A plugin is a name plus optional `middleware` and `testLifecycle` hooks:
A plugin is a name plus optional `middleware`, `testLifecycle`, and `pageExtension` hooks:

```ts
import type { Plugin } from "middlewright";
Expand Down Expand Up @@ -249,9 +285,35 @@ export const slowActionLogger = (thresholdMs = 2000): Plugin => ({
});
```

Use `pageExtension` for explicit controls tests can call through the page returned from `addPlugins`:

```ts
export const debugTools = (): Plugin<{
debugTools: {
title(): string;
};
}> => ({
name: "debug-tools",
pageExtension: ({ testInfo }) => ({
debugTools: {
title: () => testInfo.title,
},
}),
});

await using page = await addPlugins({
page: basePage,
testInfo,
plugins: [debugTools()],
});

expect(page.debugTools.title()).toBe(testInfo.title);
```

Notes for plugin authors:

- Middleware runs in registration order; the first plugin in the array is outermost. Error-enriching plugins (like `uiErrorReporter`) should generally be registered *before* the plugins whose errors they enrich, and recovery plugins (like `llmRecover`) first of all, so they see fully-enriched errors.
- Keep page extensions namespaced (`page.videoMode`, `page.debugTools`) so plugin controls do not collide with Playwright's own `Page` methods or other plugins.
- Inside middleware, use the `_original` methods (`locator.waitFor_original(...)` etc. — see the `LocatorWithOriginal` type) when you need to perform locator actions *without* re-entering the middleware chain.
- `adjustError(error, infoLines, filterFile?)` appends colored info lines to an error message and optionally scrubs your plugin's frames from the stack trace.

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"devDependencies": {
"@playwright/test": "^1.57.0",
"@types/node": "^24.10.9",
"pkg-pr-new": "^0.0.75",
"publint": "^0.3.14",
"typescript": "^5.9.3"
},
Expand Down
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

96 changes: 96 additions & 0 deletions spec/debug-mode.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { expect, test } from "@playwright/test";
import {
addPlugins,
hydrationWaiter,
llmRecover,
spinnerWaiter,
uiErrorReporter,
videoMode,
} from "../src/index.ts";

test("action middleware plugins are inert when PWDEBUG is set", async ({ page }, testInfo) => {
using _debug = withPwdebug();
let recoveryCalls = 0;

await using plugged = await addPlugins({
page,
testInfo,
plugins: [
llmRecover({
requestRecoveryCode: async () => {
recoveryCalls += 1;
return null;
},
}),
hydrationWaiter({ timeout: 200 }),
uiErrorReporter(),
spinnerWaiter({ spinnerTimeout: 3001 }),
],
});
await plugged.setContent(`
<div data-hydrated="false">hydrating forever</div>
<div data-type="error">Exploded visibly</div>
<button disabled>Submit approval</button>
<div aria-label="Loading">Loading...</div>
`);

const start = Date.now();
const error = await plugged
.getByRole("button", { name: "Submit approval" })
.click({ timeout: 100 })
.catch((e: Error) => e);

expect(Date.now() - start).toBeLessThan(500);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain("Timeout 100ms exceeded");
expect((error as Error).message).not.toContain("Error UI visible");
expect((error as Error).message).not.toContain("If this is a slow operation");
expect(recoveryCalls).toBe(0);
});

test("videoMode controls are inert when PWDEBUG is set", async ({ page }, testInfo) => {
using _debug = withPwdebug();

const plugged = await addPlugins({
page,
testInfo,
plugins: [videoMode({ finalHold: 50, highlight: { mode: "pointer", duration: 20 } })],
});
await plugged.setContent(`<button>press</button>`);

plugged.videoMode.setStartTime();
await plugged.videoMode.deadAir(async () => {
await plugged.waitForTimeout(20);
});
await plugged.getByRole("button", { name: "press" }).click();
plugged.videoMode.setEndTime();

await expect(plugged.videoMode.metadata()).resolves.toMatchObject({
deadAir: [],
highlights: [],
outputs: {},
sourceRange: {},
});
expect(plugged.videoMode.getVideoTimestamp()).toBe(0);

await plugged[Symbol.asyncDispose]();
expect(existsSync(join(testInfo.outputDir, "video-mode.json"))).toBe(false);
});

function withPwdebug() {
const previous = process.env.PWDEBUG;
process.env.PWDEBUG = "1";

return {
[Symbol.dispose]: () => {
if (previous === undefined) {
delete process.env.PWDEBUG;
return;
}

process.env.PWDEBUG = previous;
},
};
}
90 changes: 90 additions & 0 deletions spec/plugin-system.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,33 @@ test("middleware wraps actions in registration order", async ({ page }, testInfo
]);
});

test("middleware can pass adjusted action args to later middleware", async ({ page }, testInfo) => {
let innerArgs: unknown[] = [];
await using plugged = await addPlugins({
page,
testInfo,
plugins: [
{
name: "rewrite-fill",
middleware: async (_ctx, next) => next(["rewritten"]),
},
{
name: "inner-spy",
middleware: async (ctx, next) => {
innerArgs = ctx.args;
return next();
},
},
],
});
await plugged.setContent(`<input id="name">`);

await plugged.locator("#name").fill("original");

expect(innerArgs).toEqual(["rewritten"]);
expect(await plugged.locator("#name").inputValue()).toBe("rewritten");
});

test("falsy entries in the plugins array are skipped", async ({ page }, testInfo) => {
const calls: string[] = [];
await using plugged = await addPlugins({
Expand Down Expand Up @@ -72,6 +99,69 @@ test("middleware receives testInfo", async ({ page }, testInfo) => {
expect(seenTitle).toBe("middleware receives testInfo");
});

test("middleware receives action timing", async ({ page }, testInfo) => {
let seenTiming: any;
await using plugged = await addPlugins({
page,
testInfo,
plugins: [
{
name: "timing-spy",
middleware: async (ctx, next) => {
seenTiming = ctx.timing;
return next();
},
},
],
});
await plugged.setContent(`<button>hi</button>`);

await plugged.locator("button").click();

expect(seenTiming).toMatchObject({
actionStartedAt: expect.any(Number),
attachedAt: expect.any(Number),
attachedAtStart: true,
middlewares: [
expect.objectContaining({
endedAt: expect.any(Number),
name: "timing-spy",
startedAt: expect.any(Number),
}),
],
});
});

test("plugins can expose typed controls on the plugged page", async ({ page }, testInfo) => {
const helper = {
name: "page-helper",
pageExtension: ({ page, testInfo }) => ({
pageHelper: {
renderMessage: async (message: string) => {
await page.setContent(`<main>${message}</main>`);
},
title: () => testInfo.title,
},
}),
} satisfies Plugin<{
pageHelper: {
renderMessage(message: string): Promise<void>;
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,
Expand Down
Loading
Loading