A plugin/middleware system for Playwright locator actions — the one Playwright doesn't have.
Wrap click, fill, waitFor and friends with composable middleware, so your tests can be smart about why an action is slow or failing, without sprinkling waitForSomething() helpers through every test.
Install with pnpm add -D middlewright then wire it once in a fixture:
// test-helpers.ts
import { test as base } from "@playwright/test";
import { addPlugins, spinnerWaiter } from "middlewright";
export const test = base.extend({
page: async ({ page: basePage }, use, testInfo) => {
await using page = await addPlugins({
page: basePage,
testInfo,
plugins: [spinnerWaiter()],
});
await use(page);
},
});Then write completely ordinary tests — no special helpers, no wrapper calls:
import { test } from "./test-helpers";
test("kick off a slow report", async ({ page }) => {
await page.goto("/reports");
await page.getByRole("button", { name: "Generate report" }).click();
// The report takes ~20s. The app shows "generating..." while it runs, so
// spinnerWaiter waits patiently here — but if there were NO spinner, this
// would fail after the normal 1s actionTimeout, with a hint suggesting
// the product add a loading state.
await page.getByText("Report ready").click();
});Pair it with an aggressive actionTimeout in playwright.config.ts (e.g. 1_000) — the plugins are what make that viable.
For a fixture with all five plugins wired together, see the kitchen sink below.
Ships with five plugins:
| Plugin | What it does | |
|---|---|---|
spinnerWaiter |
If the app is visibly loading, wait longer for elements. If it isn't, fail fast. | source |
hydrationWaiter |
Don't interact with the app until it's hydrated. | source |
uiErrorReporter |
When an action fails, append any visible error toasts to the error message. | source |
videoMode |
Highlight elements and pause before actions, so recorded videos are watchable. | source |
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 |
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.
You should know what you're buying:
- It patches
Locator.prototypeat runtime. Once any page has plugins added, every locator in the process goes through the middleware dispatcher (pages without plugins fall through to the original behavior, but the patch itself is global). - It reaches into Playwright internals. Clean stack traces in reports depend on
setBoxedStackPrefixes, which is undocumented and untyped — see microsoft/playwright#38818 asking for it to be made official. It has already moved once (playwright-core ≤ 1.59:lib/utils; 1.60+:lib/coreBundle). middlewright knows both locations and degrades gracefully (with a console warning, and plugin frames in your stack traces) if a future version moves it again. SetPLAYWRIGHT_PLUGIN_DEBUG=1to skip stack-boxing entirely. - Pin your Playwright version and treat Playwright upgrades as potentially breaking for this package. It's tested against the version in this repo's lockfile (currently 1.60.x); the declared
@playwright/testpeer range is >= 1.49.
If Playwright ever ships official action middleware, use that instead and let this package die happy.
It started with action timeouts. A good test suite fails fast — a 1-second actionTimeout catches real bugs immediately instead of burning 30 seconds per failed assertion. But real apps have operations that legitimately take 20 seconds, and the user-facing contract for those is "show a spinner". So the timeout you actually want is conditional: 1 second normally, 30 seconds while a spinner is visible. That also creates a nice incentive loop: if a slow operation makes a test flaky, the fix is to add a loading state to the product — which is what your users wanted anyway.
We asked Playwright for this in 2022. The maintainers' verdict:
This would be tricky since it might be that spinner shows up after the action has started. […] I don't think it is technically feasible.
Fair enough — inside Playwright's watchdog architecture it may not be. But in userland, wrapping the action with a retry-while-spinning loop is straightforward. Once you have one wrapper, you notice the pattern generalizes: waiting for hydration, surfacing error toasts, highlighting for videos, even LLM-assisted recovery are all "do something around a locator action". That's a middleware chain. This package is that middleware chain, extracted from the test infrastructure of a production app at iterate.
The flagship. Before each action, if the target element isn't visible but a spinner is, waits (up to spinnerTimeout) for the element — bailing out early if the spinner disappears without producing it. If there's no spinner and the action fails, the error message suggests adding one:
Timeout 1000ms exceeded.
If this is a slow operation, update the product code to add a spinner while it's running.
This will improve the user experience and buy you more time for this assertion.
To add a spinner, show any UI element matching this locator:
locator('[aria-label="Loading"],[data-spinner=\'true\'],...')
spinnerWaiter({
spinnerSelectors: ['[data-spinner="true"]'], // default also matches aria-label="Loading" and trailing "...ing..." text
spinnerTimeout: 30_000,
});Runtime overrides go through AsyncLocalStorage — enterWith for the rest of the test, run for a single call:
test("a test where spinners are expected to hang", async ({ page }) => {
spinnerWaiter.settings.enterWith({ spinnerTimeout: 60_000 });
// ...
// or scope an override to one action:
await spinnerWaiter.settings.run({ disabled: true }, () =>
page.getByText("flash message").click(),
);
});Before each action, waits for [data-hydrated="false"] to disappear. Your app cooperates by rendering that attribute server-side and flipping it once the framework hydrates. Stops the classic "test clicked a button before React attached the handler" flake at the source.
hydrationWaiter({ selector: '[data-hydrated="false"]', timeout: 10_000 });When an action fails, grabs the text of any visible error UI (default selector [data-type="error"], which matches sonner error toasts) and appends it to the error message in red. Turns "Timeout 1000ms exceeded" into "Timeout 1000ms exceeded — Error UI visible: 'Could not save: quota exceeded'".
uiErrorReporter({ selector: '[data-type="error"]' });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.
videoMode({
pauseBefore: 1000,
pauseAfterTest: 3000,
highlightStyle: "3px solid gold",
skipMethods: ["waitFor"],
skipStackFrames: ["test-helpers.ts"], // don't slow down internal login/setup helpers
});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 evals it with { page, locator, error } in scope. Up to maxAttempts tries, with attempt history fed back to the model.
Two design decisions worth knowing about:
- Recovered tests still fail. On successful recovery it records a soft assertion failure, so the test keeps running (surfacing any further failures) but the run is marked failed, with the recovery code in the report. The point is to tell you what the fix probably is — e.g. "the button copy changed" — not to let the suite go green on vibes.
- The LLM can decline. If it deems the failure unrecoverable (real bug, not a locator/timing issue), the original error is rethrown with the model's explanation attached.
// gate it behind an env var — this runs LLM-generated code via eval, in
// your test process. Only enable it deliberately.
!!process.env.LLM_RECOVER && llmRecover({
model: "claude-opus-4-8", // default
maxAttempts: 3, // default
apiKey: "...", // default: process.env.ANTHROPIC_API_KEY
});For testing (or to swap in your own agent/provider), inject requestRecoveryCode — see spec/llm-recover.spec.ts.
Artifacts (every attempt, code, errors, timings) are written to <test-output-dir>/llm-recover/*.json.
All five plugins wired into one fixture — this mirrors how they ran in the app they were extracted from:
// test-helpers.ts
import { test as base } from "@playwright/test";
import {
addPlugins,
hydrationWaiter,
llmRecover,
spinnerWaiter,
uiErrorReporter,
videoMode,
} from "middlewright";
export const test = base.extend({
page: async ({ page: basePage }, use, testInfo) => {
await using page = await addPlugins({
page: basePage,
testInfo,
plugins: [
// order matters: the first plugin is outermost. llmRecover goes first
// so it sees errors after the other plugins have enriched them.
!!process.env.LLM_RECOVER && llmRecover(),
hydrationWaiter({ timeout: 60_000 }),
uiErrorReporter(),
spinnerWaiter(),
// opt-in: slows everything down to make recordings watchable
!!process.env.VIDEO_MODE && videoMode({ skipStackFrames: ["test-helpers.ts"] }),
],
// also hide this helper file from stack traces in reports
boxedStackPrefixes: (defaults) => [...defaults, import.meta.filename],
});
await use(page);
},
});// playwright.config.ts (the relevant bits)
export default defineConfig({
use: {
actionTimeout: process.env.VIDEO_MODE ? 10_000 : 1_000, // fail fast; spinnerWaiter buys time when deserved
video: { mode: process.env.VIDEO_MODE ? "on" : "retain-on-failure" },
},
});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 (conditional waiting + error enrichment + runtime settings via AsyncLocalStorage), hydration-waiter (the simplest one — start here), ui-error-reporter (catch/enrich/rethrow), video-mode (page mutation around actions + lifecycle hooks), llm-recover (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:
import type { Plugin } from "middlewright";
import { adjustError } from "middlewright";
export const slowActionLogger = (thresholdMs = 2000): Plugin => ({
name: "slow-action-logger",
// Wraps every locator action. ctx has { locator, method, args, page, testInfo }.
middleware: async (ctx, next) => {
const start = Date.now();
try {
return await next(); // call the next middleware, or the real action
} catch (error) {
adjustError(error as Error, [`action took ${Date.now() - start}ms before failing`]);
throw error;
} finally {
if (Date.now() - start > thresholdMs) {
console.warn(`${ctx.locator}.${ctx.method}() took ${Date.now() - start}ms`);
}
}
},
// Subscribe to beforeTest/afterTest events. Return a cleanup function if needed.
testLifecycle: (emitter) => {
emitter.on("afterTest", ({ testInfo }) => console.log(`finished: ${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 (likellmRecover) first of all, so they see fully-enriched errors. - Inside middleware, use the
_originalmethods (locator.waitFor_original(...)etc. — see theLocatorWithOriginaltype) 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.
addPlugins patches Locator.prototype (once per process), replacing click, dblclick, fill, type, press, clear, blur, focus, hover and waitFor with a dispatcher. The dispatcher looks up the plugin state stored on the action's page; if the page has plugins, it runs the middleware chain (each middleware calling next() until the original method runs); if not, it calls the original method directly.
To keep Playwright's HTML report pointing at your test code rather than plugin internals, it registers the plugin files with playwright-core's internal setBoxedStackPrefixes — the same mechanism Playwright uses to hide its own frames. This is the unofficial-API part of the hack; set PLAYWRIGHT_PLUGIN_DEBUG=1 to disable it when debugging the plugins themselves.
pnpm install
pnpm exec playwright install chromium
pnpm test # playwright tests (no app needed — pages are built with setContent)
pnpm typecheck
pnpm buildThe llm-recover live-API tests are skipped unless you set LLM_RECOVER=1 (and ANTHROPIC_API_KEY); provider-injected tests for the same plugin always run.
Extracted from the internal test infrastructure of the iterate monorepo, where these plugins ran against a production app. Prior art / motivation: microsoft/playwright#16007, microsoft/playwright#38818.
MIT