middlewright 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +282 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/plugin-system.d.ts +82 -0
- package/dist/plugin-system.js +201 -0
- package/dist/plugins/hydration-waiter.d.ts +25 -0
- package/dist/plugins/hydration-waiter.js +26 -0
- package/dist/plugins/index.d.ts +5 -0
- package/dist/plugins/index.js +5 -0
- package/dist/plugins/llm-recover.d.ts +40 -0
- package/dist/plugins/llm-recover.js +345 -0
- package/dist/plugins/spinner-waiter.d.ts +35 -0
- package/dist/plugins/spinner-waiter.js +138 -0
- package/dist/plugins/ui-error-reporter.d.ts +19 -0
- package/dist/plugins/ui-error-reporter.js +25 -0
- package/dist/plugins/video-mode.d.ts +22 -0
- package/dist/plugins/video-mode.js +124 -0
- package/package.json +51 -0
- package/src/index.ts +16 -0
- package/src/plugin-system.ts +292 -0
- package/src/plugins/hydration-waiter.ts +46 -0
- package/src/plugins/index.ts +11 -0
- package/src/plugins/llm-recover.ts +453 -0
- package/src/plugins/spinner-waiter.ts +175 -0
- package/src/plugins/ui-error-reporter.ts +44 -0
- package/src/plugins/video-mode.ts +97 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Iterate
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# middlewright
|
|
2
|
+
|
|
3
|
+
A plugin/middleware system for Playwright locator actions — the one Playwright doesn't have.
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
## Quick start
|
|
8
|
+
|
|
9
|
+
Install with `pnpm add -D middlewright` then wire it once in a fixture:
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
// test-helpers.ts
|
|
13
|
+
import { test as base } from "@playwright/test";
|
|
14
|
+
import { addPlugins, spinnerWaiter } from "middlewright";
|
|
15
|
+
|
|
16
|
+
export const test = base.extend({
|
|
17
|
+
page: async ({ page: basePage }, use, testInfo) => {
|
|
18
|
+
await using page = await addPlugins({
|
|
19
|
+
page: basePage,
|
|
20
|
+
testInfo,
|
|
21
|
+
plugins: [spinnerWaiter()],
|
|
22
|
+
});
|
|
23
|
+
await use(page);
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Then write completely ordinary tests — no special helpers, no wrapper calls:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { test } from "./test-helpers";
|
|
32
|
+
|
|
33
|
+
test("kick off a slow report", async ({ page }) => {
|
|
34
|
+
await page.goto("/reports");
|
|
35
|
+
await page.getByRole("button", { name: "Generate report" }).click();
|
|
36
|
+
// The report takes ~20s. The app shows "generating..." while it runs, so
|
|
37
|
+
// spinnerWaiter waits patiently here — but if there were NO spinner, this
|
|
38
|
+
// would fail after the normal 1s actionTimeout, with a hint suggesting
|
|
39
|
+
// the product add a loading state.
|
|
40
|
+
await page.getByText("Report ready").click();
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Pair it with an aggressive `actionTimeout` in `playwright.config.ts` (e.g. `1_000`) — the plugins are what make that viable.
|
|
45
|
+
|
|
46
|
+
For a fixture with all five plugins wired together, see the [kitchen sink](#kitchen-sink) below.
|
|
47
|
+
|
|
48
|
+
Ships with five plugins:
|
|
49
|
+
|
|
50
|
+
| Plugin | What it does | |
|
|
51
|
+
| --- | --- | --- |
|
|
52
|
+
| [`spinnerWaiter`](#spinnerwaiter) | If the app is visibly loading, wait longer for elements. If it isn't, fail fast. | [source](./src/plugins/spinner-waiter.ts) |
|
|
53
|
+
| [`hydrationWaiter`](#hydrationwaiter) | Don't interact with the app until it's hydrated. | [source](./src/plugins/hydration-waiter.ts) |
|
|
54
|
+
| [`uiErrorReporter`](#uierrorreporter) | When an action fails, append any visible error toasts to the error message. | [source](./src/plugins/ui-error-reporter.ts) |
|
|
55
|
+
| [`videoMode`](#videomode) | Highlight elements and pause before actions, so recorded videos are watchable. | [source](./src/plugins/video-mode.ts) |
|
|
56
|
+
| [`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) |
|
|
57
|
+
|
|
58
|
+
`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.
|
|
59
|
+
|
|
60
|
+
## ⚠️ This is a hack
|
|
61
|
+
|
|
62
|
+
You should know what you're buying:
|
|
63
|
+
|
|
64
|
+
- **It patches `Locator.prototype` at 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).
|
|
65
|
+
- **It reaches into Playwright internals.** Clean stack traces in reports depend on `setBoxedStackPrefixes`, which is undocumented and untyped — see [microsoft/playwright#38818](https://github.com/microsoft/playwright/issues/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. Set `PLAYWRIGHT_PLUGIN_DEBUG=1` to skip stack-boxing entirely.
|
|
66
|
+
- **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/test` peer range is >= 1.49.
|
|
67
|
+
|
|
68
|
+
If Playwright ever ships official action middleware, use that instead and let this package die happy.
|
|
69
|
+
|
|
70
|
+
## Why does this exist?
|
|
71
|
+
|
|
72
|
+
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.
|
|
73
|
+
|
|
74
|
+
We [asked Playwright for this in 2022](https://github.com/microsoft/playwright/issues/16007). The maintainers' verdict:
|
|
75
|
+
|
|
76
|
+
> 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.
|
|
77
|
+
|
|
78
|
+
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](https://github.com/iterate).
|
|
79
|
+
|
|
80
|
+
## Plugins
|
|
81
|
+
|
|
82
|
+
### spinnerWaiter
|
|
83
|
+
|
|
84
|
+
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:
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
Timeout 1000ms exceeded.
|
|
88
|
+
If this is a slow operation, update the product code to add a spinner while it's running.
|
|
89
|
+
This will improve the user experience and buy you more time for this assertion.
|
|
90
|
+
To add a spinner, show any UI element matching this locator:
|
|
91
|
+
locator('[aria-label="Loading"],[data-spinner=\'true\'],...')
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
spinnerWaiter({
|
|
96
|
+
spinnerSelectors: ['[data-spinner="true"]'], // default also matches aria-label="Loading" and trailing "...ing..." text
|
|
97
|
+
spinnerTimeout: 30_000,
|
|
98
|
+
});
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Runtime overrides go through `AsyncLocalStorage` — `enterWith` for the rest of the test, `run` for a single call:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
test("a test where spinners are expected to hang", async ({ page }) => {
|
|
105
|
+
spinnerWaiter.settings.enterWith({ spinnerTimeout: 60_000 });
|
|
106
|
+
// ...
|
|
107
|
+
|
|
108
|
+
// or scope an override to one action:
|
|
109
|
+
await spinnerWaiter.settings.run({ disabled: true }, () =>
|
|
110
|
+
page.getByText("flash message").click(),
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### hydrationWaiter
|
|
116
|
+
|
|
117
|
+
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.
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
hydrationWaiter({ selector: '[data-hydrated="false"]', timeout: 10_000 });
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### uiErrorReporter
|
|
124
|
+
|
|
125
|
+
When an action fails, grabs the text of any visible error UI (default selector `[data-type="error"]`, which matches [sonner](https://sonner.emilkowal.ski/) 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'".
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
uiErrorReporter({ selector: '[data-type="error"]' });
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### videoMode
|
|
132
|
+
|
|
133
|
+
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`.
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
videoMode({
|
|
137
|
+
pauseBefore: 1000,
|
|
138
|
+
pauseAfterTest: 3000,
|
|
139
|
+
highlightStyle: "3px solid gold",
|
|
140
|
+
skipMethods: ["waitFor"],
|
|
141
|
+
skipStackFrames: ["test-helpers.ts"], // don't slow down internal login/setup helpers
|
|
142
|
+
});
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### llmRecover
|
|
146
|
+
|
|
147
|
+
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.
|
|
148
|
+
|
|
149
|
+
Two design decisions worth knowing about:
|
|
150
|
+
|
|
151
|
+
- **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.
|
|
152
|
+
- **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.
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
// gate it behind an env var — this runs LLM-generated code via eval, in
|
|
156
|
+
// your test process. Only enable it deliberately.
|
|
157
|
+
!!process.env.LLM_RECOVER && llmRecover({
|
|
158
|
+
model: "claude-opus-4-8", // default
|
|
159
|
+
maxAttempts: 3, // default
|
|
160
|
+
apiKey: "...", // default: process.env.ANTHROPIC_API_KEY
|
|
161
|
+
});
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
For testing (or to swap in your own agent/provider), inject `requestRecoveryCode` — see [spec/llm-recover.spec.ts](spec/llm-recover.spec.ts).
|
|
165
|
+
|
|
166
|
+
Artifacts (every attempt, code, errors, timings) are written to `<test-output-dir>/llm-recover/*.json`.
|
|
167
|
+
|
|
168
|
+
### Kitchen sink
|
|
169
|
+
|
|
170
|
+
All five plugins wired into one fixture — this mirrors how they ran in the app they were extracted from:
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
// test-helpers.ts
|
|
174
|
+
import { test as base } from "@playwright/test";
|
|
175
|
+
import {
|
|
176
|
+
addPlugins,
|
|
177
|
+
hydrationWaiter,
|
|
178
|
+
llmRecover,
|
|
179
|
+
spinnerWaiter,
|
|
180
|
+
uiErrorReporter,
|
|
181
|
+
videoMode,
|
|
182
|
+
} from "middlewright";
|
|
183
|
+
|
|
184
|
+
export const test = base.extend({
|
|
185
|
+
page: async ({ page: basePage }, use, testInfo) => {
|
|
186
|
+
await using page = await addPlugins({
|
|
187
|
+
page: basePage,
|
|
188
|
+
testInfo,
|
|
189
|
+
plugins: [
|
|
190
|
+
// order matters: the first plugin is outermost. llmRecover goes first
|
|
191
|
+
// so it sees errors after the other plugins have enriched them.
|
|
192
|
+
!!process.env.LLM_RECOVER && llmRecover(),
|
|
193
|
+
hydrationWaiter({ timeout: 60_000 }),
|
|
194
|
+
uiErrorReporter(),
|
|
195
|
+
spinnerWaiter(),
|
|
196
|
+
// opt-in: slows everything down to make recordings watchable
|
|
197
|
+
!!process.env.VIDEO_MODE && videoMode({ skipStackFrames: ["test-helpers.ts"] }),
|
|
198
|
+
],
|
|
199
|
+
// also hide this helper file from stack traces in reports
|
|
200
|
+
boxedStackPrefixes: (defaults) => [...defaults, import.meta.filename],
|
|
201
|
+
});
|
|
202
|
+
await use(page);
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
// playwright.config.ts (the relevant bits)
|
|
209
|
+
export default defineConfig({
|
|
210
|
+
use: {
|
|
211
|
+
actionTimeout: process.env.VIDEO_MODE ? 10_000 : 1_000, // fail fast; spinnerWaiter buys time when deserved
|
|
212
|
+
video: { mode: process.env.VIDEO_MODE ? "on" : "retain-on-failure" },
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## Writing your own plugin
|
|
218
|
+
|
|
219
|
+
**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`.
|
|
220
|
+
|
|
221
|
+
A plugin is a name plus optional `middleware` and `testLifecycle` hooks:
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
import type { Plugin } from "middlewright";
|
|
225
|
+
import { adjustError } from "middlewright";
|
|
226
|
+
|
|
227
|
+
export const slowActionLogger = (thresholdMs = 2000): Plugin => ({
|
|
228
|
+
name: "slow-action-logger",
|
|
229
|
+
|
|
230
|
+
// Wraps every locator action. ctx has { locator, method, args, page, testInfo }.
|
|
231
|
+
middleware: async (ctx, next) => {
|
|
232
|
+
const start = Date.now();
|
|
233
|
+
try {
|
|
234
|
+
return await next(); // call the next middleware, or the real action
|
|
235
|
+
} catch (error) {
|
|
236
|
+
adjustError(error as Error, [`action took ${Date.now() - start}ms before failing`]);
|
|
237
|
+
throw error;
|
|
238
|
+
} finally {
|
|
239
|
+
if (Date.now() - start > thresholdMs) {
|
|
240
|
+
console.warn(`${ctx.locator}.${ctx.method}() took ${Date.now() - start}ms`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
// Subscribe to beforeTest/afterTest events. Return a cleanup function if needed.
|
|
246
|
+
testLifecycle: (emitter) => {
|
|
247
|
+
emitter.on("afterTest", ({ testInfo }) => console.log(`finished: ${testInfo.title}`));
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Notes for plugin authors:
|
|
253
|
+
|
|
254
|
+
- 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.
|
|
255
|
+
- 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.
|
|
256
|
+
- `adjustError(error, infoLines, filterFile?)` appends colored info lines to an error message and optionally scrubs your plugin's frames from the stack trace.
|
|
257
|
+
|
|
258
|
+
## How it works
|
|
259
|
+
|
|
260
|
+
`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.
|
|
261
|
+
|
|
262
|
+
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.
|
|
263
|
+
|
|
264
|
+
## Development
|
|
265
|
+
|
|
266
|
+
```bash
|
|
267
|
+
pnpm install
|
|
268
|
+
pnpm exec playwright install chromium
|
|
269
|
+
pnpm test # playwright tests (no app needed — pages are built with setContent)
|
|
270
|
+
pnpm typecheck
|
|
271
|
+
pnpm build
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
The 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.
|
|
275
|
+
|
|
276
|
+
## Credits
|
|
277
|
+
|
|
278
|
+
Extracted from the internal test infrastructure of the [iterate](https://github.com/iterate) monorepo, where these plugins ran against a production app. Prior art / motivation: [microsoft/playwright#16007](https://github.com/microsoft/playwright/issues/16007), [microsoft/playwright#38818](https://github.com/microsoft/playwright/issues/38818).
|
|
279
|
+
|
|
280
|
+
## License
|
|
281
|
+
|
|
282
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { addPlugins, adjustError, oneArgMethods, overrideableMethods, type Plugin, type ActionContext, type ActionMiddleware, type NextFn, type TestLifecycleEvents, type LocatorWithOriginal, type OverrideableMethod, type OneArgMethod, } from "./plugin-system.ts";
|
|
2
|
+
export * from "./plugins/index.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import Emittery from "emittery";
|
|
2
|
+
import type { Locator, Page, TestInfo } from "@playwright/test";
|
|
3
|
+
declare const oneArgMethods: readonly ["fill", "type", "press"];
|
|
4
|
+
type OneArgMethod = (typeof oneArgMethods)[number];
|
|
5
|
+
declare const overrideableMethods: ("fill" | "type" | "press" | "blur" | "clear" | "click" | "dblclick" | "focus" | "hover" | "waitFor")[];
|
|
6
|
+
type OverrideableMethod = (typeof overrideableMethods)[number];
|
|
7
|
+
export type LocatorWithOriginal = Locator & {
|
|
8
|
+
[K in OverrideableMethod as `${K}_original`]: Locator[K];
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Append info to an error message and clean up stack trace.
|
|
12
|
+
* @param error - The error to modify
|
|
13
|
+
* @param info - Lines to append to the error message
|
|
14
|
+
* @param filterFile - Filename to remove from stack trace (e.g., "my-plugin.ts")
|
|
15
|
+
* @param color - ANSI color code (default: 33 = yellow). Use 31 for red.
|
|
16
|
+
*/
|
|
17
|
+
export declare const adjustError: (error: Error, info: string[], filterFile?: string, { color }?: {
|
|
18
|
+
color?: number | undefined;
|
|
19
|
+
}) => void;
|
|
20
|
+
export type ActionContext = {
|
|
21
|
+
locator: LocatorWithOriginal;
|
|
22
|
+
method: OverrideableMethod;
|
|
23
|
+
args: unknown[];
|
|
24
|
+
page: Page;
|
|
25
|
+
testInfo: TestInfo;
|
|
26
|
+
};
|
|
27
|
+
/** Function that calls the next middleware or the original action */
|
|
28
|
+
export type NextFn = () => Promise<unknown>;
|
|
29
|
+
/** Middleware function - wraps an action, must call next() */
|
|
30
|
+
export type ActionMiddleware = (ctx: ActionContext, next: NextFn) => Promise<unknown>;
|
|
31
|
+
export type TestLifecycleEvents = {
|
|
32
|
+
beforeTest: {
|
|
33
|
+
page: Page;
|
|
34
|
+
testInfo: TestInfo;
|
|
35
|
+
};
|
|
36
|
+
afterTest: {
|
|
37
|
+
page: Page;
|
|
38
|
+
testInfo: TestInfo;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
export type Plugin = {
|
|
42
|
+
name: string;
|
|
43
|
+
/** Middleware to wrap locator actions. Called in registration order. */
|
|
44
|
+
middleware?: ActionMiddleware;
|
|
45
|
+
/** Subscribe to test lifecycle events */
|
|
46
|
+
testLifecycle?: (emitter: Emittery<TestLifecycleEvents>) => void | (() => void);
|
|
47
|
+
};
|
|
48
|
+
declare const PLUGIN_STATE: unique symbol;
|
|
49
|
+
type PluginState = {
|
|
50
|
+
actionMiddlewares: ActionMiddleware[];
|
|
51
|
+
lifecycleEmitter: Emittery<TestLifecycleEvents>;
|
|
52
|
+
lifecycleCleanups: (() => void)[];
|
|
53
|
+
testInfo: TestInfo;
|
|
54
|
+
};
|
|
55
|
+
type PageWithPlugins = Page & {
|
|
56
|
+
[PLUGIN_STATE]: PluginState;
|
|
57
|
+
[Symbol.asyncDispose]: () => Promise<void>;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Add plugins to a page. Returns a disposable page that cleans up on dispose.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```ts
|
|
64
|
+
* await using page = await addPlugins({
|
|
65
|
+
* page: basePage,
|
|
66
|
+
* testInfo,
|
|
67
|
+
* plugins: [
|
|
68
|
+
* hydrationWaiter(),
|
|
69
|
+
* spinnerWaiter(),
|
|
70
|
+
* videoMode(),
|
|
71
|
+
* ]
|
|
72
|
+
* });
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
export declare const addPlugins: (params: {
|
|
76
|
+
page: Page;
|
|
77
|
+
testInfo: TestInfo;
|
|
78
|
+
plugins: (Plugin | false | null | undefined)[];
|
|
79
|
+
boxedStackPrefixes?: (defaults: string[]) => string[];
|
|
80
|
+
}) => Promise<PageWithPlugins>;
|
|
81
|
+
export type { OverrideableMethod, OneArgMethod };
|
|
82
|
+
export { oneArgMethods, overrideableMethods };
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* middlewright plugin system.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from the iterate monorepo's internal Playwright test
|
|
5
|
+
* infrastructure (github.com/iterate/iterate, private). Modifications from the
|
|
6
|
+
* original: playwright-core internals are resolved relative to
|
|
7
|
+
* `@playwright/test` so this works under pnpm's strict node_modules layout,
|
|
8
|
+
* and locator actions on pages that never had plugins added fall through to
|
|
9
|
+
* the original implementation instead of throwing.
|
|
10
|
+
*/
|
|
11
|
+
import { createRequire } from "node:module";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import Emittery from "emittery";
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
// Methods that take an extra argument before options (e.g., fill(value, options))
|
|
16
|
+
const oneArgMethods = ["fill", "type", "press"];
|
|
17
|
+
const overrideableMethods = [
|
|
18
|
+
"click",
|
|
19
|
+
"waitFor",
|
|
20
|
+
"clear",
|
|
21
|
+
"dblclick",
|
|
22
|
+
"blur",
|
|
23
|
+
"focus",
|
|
24
|
+
"hover",
|
|
25
|
+
...oneArgMethods,
|
|
26
|
+
];
|
|
27
|
+
/**
|
|
28
|
+
* Append info to an error message and clean up stack trace.
|
|
29
|
+
* @param error - The error to modify
|
|
30
|
+
* @param info - Lines to append to the error message
|
|
31
|
+
* @param filterFile - Filename to remove from stack trace (e.g., "my-plugin.ts")
|
|
32
|
+
* @param color - ANSI color code (default: 33 = yellow). Use 31 for red.
|
|
33
|
+
*/
|
|
34
|
+
export const adjustError = (error, info, filterFile, { color = 33 } = {}) => {
|
|
35
|
+
if (!error?.message)
|
|
36
|
+
return;
|
|
37
|
+
Object.assign(error, { originalMessage: error.message, originalStack: error.stack });
|
|
38
|
+
if (info.length > 0) {
|
|
39
|
+
const infoBlock = info.map((line) => ` ${line}`).join("\n");
|
|
40
|
+
error.message = `${error.message}\n\x1b[${color}m${infoBlock}\x1b[0m\n`;
|
|
41
|
+
}
|
|
42
|
+
if (filterFile && error.stack) {
|
|
43
|
+
error.stack = error.stack
|
|
44
|
+
.split("\n")
|
|
45
|
+
.filter((line) => !line.includes(filterFile))
|
|
46
|
+
.join("\n");
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const PLUGIN_STATE = Symbol("playwrightPluginState");
|
|
50
|
+
// Track if Locator prototype has been patched
|
|
51
|
+
let prototypePatched = false;
|
|
52
|
+
/** Get plugin state from a page */
|
|
53
|
+
const getPluginState = (page) => {
|
|
54
|
+
return page[PLUGIN_STATE];
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Add plugins to a page. Returns a disposable page that cleans up on dispose.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* await using page = await addPlugins({
|
|
62
|
+
* page: basePage,
|
|
63
|
+
* testInfo,
|
|
64
|
+
* plugins: [
|
|
65
|
+
* hydrationWaiter(),
|
|
66
|
+
* spinnerWaiter(),
|
|
67
|
+
* videoMode(),
|
|
68
|
+
* ]
|
|
69
|
+
* });
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
export const addPlugins = async (params) => {
|
|
73
|
+
const { page, testInfo, plugins, boxedStackPrefixes } = params;
|
|
74
|
+
// Patch Locator prototype once globally
|
|
75
|
+
patchLocatorPrototype(page, boxedStackPrefixes);
|
|
76
|
+
// Initialize state on page
|
|
77
|
+
const state = {
|
|
78
|
+
actionMiddlewares: [],
|
|
79
|
+
lifecycleEmitter: new Emittery(),
|
|
80
|
+
lifecycleCleanups: [],
|
|
81
|
+
testInfo,
|
|
82
|
+
};
|
|
83
|
+
// Register plugins
|
|
84
|
+
for (const plugin of plugins) {
|
|
85
|
+
if (!plugin)
|
|
86
|
+
continue;
|
|
87
|
+
if (plugin.middleware) {
|
|
88
|
+
state.actionMiddlewares.push(plugin.middleware);
|
|
89
|
+
}
|
|
90
|
+
if (plugin.testLifecycle) {
|
|
91
|
+
const cleanup = plugin.testLifecycle(state.lifecycleEmitter);
|
|
92
|
+
if (cleanup)
|
|
93
|
+
state.lifecycleCleanups.push(cleanup);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const pageWithPlugins = page;
|
|
97
|
+
pageWithPlugins[PLUGIN_STATE] = state;
|
|
98
|
+
// Emit beforeTest
|
|
99
|
+
await state.lifecycleEmitter.emitSerial("beforeTest", { page, testInfo });
|
|
100
|
+
// Add async dispose
|
|
101
|
+
pageWithPlugins[Symbol.asyncDispose] = async () => {
|
|
102
|
+
await state.lifecycleEmitter.emitSerial("afterTest", { page, testInfo });
|
|
103
|
+
state.lifecycleCleanups.forEach((cleanup) => cleanup());
|
|
104
|
+
};
|
|
105
|
+
return pageWithPlugins;
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* Resolve playwright-core starting from @playwright/test. Under pnpm's strict
|
|
109
|
+
* layout, playwright-core isn't reachable from this package - only via the
|
|
110
|
+
* host project's @playwright/test.
|
|
111
|
+
*/
|
|
112
|
+
const resolvePlaywrightInternals = () => {
|
|
113
|
+
const testPkg = require.resolve("@playwright/test/package.json");
|
|
114
|
+
const requireFromTest = createRequire(testPkg);
|
|
115
|
+
const playwrightPkg = requireFromTest.resolve("playwright/package.json");
|
|
116
|
+
const corePkg = createRequire(playwrightPkg).resolve("playwright-core/package.json");
|
|
117
|
+
return { testPkg, playwrightPkg, corePkg };
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* setBoxedStackPrefixes is an internal playwright-core API
|
|
121
|
+
* (https://github.com/microsoft/playwright/issues/38818 asks for it to be made
|
|
122
|
+
* official) and has already moved once: playwright-core <= 1.59 exposed it
|
|
123
|
+
* from lib/utils, 1.60+ from the utils namespace of lib/coreBundle. Returns
|
|
124
|
+
* null when it can't be found - everything works without it, you just see
|
|
125
|
+
* plugin frames in stack traces.
|
|
126
|
+
*/
|
|
127
|
+
const loadSetBoxedStackPrefixes = (corePkg) => {
|
|
128
|
+
const coreRequire = createRequire(corePkg);
|
|
129
|
+
for (const load of [
|
|
130
|
+
() => coreRequire("./lib/utils").setBoxedStackPrefixes,
|
|
131
|
+
() => coreRequire("./lib/coreBundle").utils.setBoxedStackPrefixes,
|
|
132
|
+
]) {
|
|
133
|
+
try {
|
|
134
|
+
const fn = load();
|
|
135
|
+
if (typeof fn === "function")
|
|
136
|
+
return fn;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// try the next layout
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
};
|
|
144
|
+
/** Patch Locator prototype to run middleware. Safe to call multiple times. */
|
|
145
|
+
const patchLocatorPrototype = (page, boxedStackPrefixes) => {
|
|
146
|
+
if (prototypePatched)
|
|
147
|
+
return;
|
|
148
|
+
prototypePatched = true;
|
|
149
|
+
const internals = resolvePlaywrightInternals();
|
|
150
|
+
// Exclude this file from stack traces in Playwright reports
|
|
151
|
+
if (!process.env.PLAYWRIGHT_PLUGIN_DEBUG) {
|
|
152
|
+
const setBoxedStackPrefixes = loadSetBoxedStackPrefixes(internals.corePkg);
|
|
153
|
+
if (setBoxedStackPrefixes) {
|
|
154
|
+
const getPrefixes = boxedStackPrefixes || ((defaults) => defaults);
|
|
155
|
+
const prefixes = getPrefixes([
|
|
156
|
+
path.dirname(internals.testPkg),
|
|
157
|
+
path.dirname(internals.playwrightPkg),
|
|
158
|
+
path.dirname(internals.corePkg),
|
|
159
|
+
import.meta.filename,
|
|
160
|
+
]);
|
|
161
|
+
setBoxedStackPrefixes(prefixes);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
console.warn("[middlewright] could not find setBoxedStackPrefixes in this playwright-core version - " +
|
|
165
|
+
"stack traces in reports will include plugin frames");
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const dummyLocator = page.locator("body");
|
|
169
|
+
const locatorPrototype = dummyLocator.constructor.prototype;
|
|
170
|
+
for (const method of overrideableMethods) {
|
|
171
|
+
locatorPrototype[`${method}_original`] = locatorPrototype[method];
|
|
172
|
+
const value = async function patchedMethod(...args) {
|
|
173
|
+
const callOriginal = () => this[`${method}_original`](...args);
|
|
174
|
+
// Pages that never had plugins added (e.g. a second page in the same
|
|
175
|
+
// worker) fall through to the original implementation.
|
|
176
|
+
const state = getPluginState(this.page());
|
|
177
|
+
if (!state)
|
|
178
|
+
return callOriginal();
|
|
179
|
+
const actionMiddlewares = state.actionMiddlewares;
|
|
180
|
+
const ctx = {
|
|
181
|
+
locator: this,
|
|
182
|
+
method,
|
|
183
|
+
args,
|
|
184
|
+
page: this.page(),
|
|
185
|
+
testInfo: state.testInfo,
|
|
186
|
+
};
|
|
187
|
+
// Build middleware chain - each middleware calls next() to continue
|
|
188
|
+
let index = 0;
|
|
189
|
+
const next = async () => {
|
|
190
|
+
if (index < actionMiddlewares.length) {
|
|
191
|
+
const middleware = actionMiddlewares[index++];
|
|
192
|
+
return middleware(ctx, next);
|
|
193
|
+
}
|
|
194
|
+
return callOriginal();
|
|
195
|
+
};
|
|
196
|
+
return next();
|
|
197
|
+
};
|
|
198
|
+
Object.defineProperty(locatorPrototype, method, { value });
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
export { oneArgMethods, overrideableMethods };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hydration-waiter: don't interact with the app before it's interactive.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from the iterate monorepo's internal Playwright test
|
|
5
|
+
* infrastructure (github.com/iterate/iterate, private).
|
|
6
|
+
*/
|
|
7
|
+
import type { Plugin } from "../plugin-system.ts";
|
|
8
|
+
export type HydrationWaiterOptions = {
|
|
9
|
+
/** Selector for unhydrated state. Default: '[data-hydrated="false"]' */
|
|
10
|
+
selector?: string;
|
|
11
|
+
/** Timeout for hydration. Default: 10_000 */
|
|
12
|
+
timeout?: number;
|
|
13
|
+
/** Whether to skip this plugin. Default: false */
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Waits for the app to be hydrated before any locator action.
|
|
18
|
+
* Looks for `[data-hydrated="false"]` and waits for it to disappear.
|
|
19
|
+
*
|
|
20
|
+
* Your app needs to cooperate: render `data-hydrated="false"` on some element
|
|
21
|
+
* server-side and flip it to "true" (or remove it) once the framework has
|
|
22
|
+
* hydrated. With React, for example, flip it in a top-level component that
|
|
23
|
+
* only runs client-side.
|
|
24
|
+
*/
|
|
25
|
+
export declare const hydrationWaiter: (options?: HydrationWaiterOptions) => Plugin;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Waits for the app to be hydrated before any locator action.
|
|
3
|
+
* Looks for `[data-hydrated="false"]` and waits for it to disappear.
|
|
4
|
+
*
|
|
5
|
+
* Your app needs to cooperate: render `data-hydrated="false"` on some element
|
|
6
|
+
* server-side and flip it to "true" (or remove it) once the framework has
|
|
7
|
+
* hydrated. With React, for example, flip it in a top-level component that
|
|
8
|
+
* only runs client-side.
|
|
9
|
+
*/
|
|
10
|
+
export const hydrationWaiter = (options = {}) => {
|
|
11
|
+
const selector = options.selector || '[data-hydrated="false"]';
|
|
12
|
+
const timeout = options.timeout || 10_000;
|
|
13
|
+
return {
|
|
14
|
+
name: "hydration-waiter",
|
|
15
|
+
middleware: async ({ page }, next) => {
|
|
16
|
+
if (options.disabled)
|
|
17
|
+
return next();
|
|
18
|
+
const unhydratedLocator = page.locator(selector);
|
|
19
|
+
const isUnhydrated = await unhydratedLocator.isVisible();
|
|
20
|
+
if (isUnhydrated) {
|
|
21
|
+
await unhydratedLocator.waitFor_original({ state: "hidden", timeout });
|
|
22
|
+
}
|
|
23
|
+
return next();
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { hydrationWaiter, type HydrationWaiterOptions } from "./hydration-waiter.ts";
|
|
2
|
+
export { videoMode, type VideoModeOptions } from "./video-mode.ts";
|
|
3
|
+
export { spinnerWaiter, type SpinnerWaiterOptions, defaultSelectors } from "./spinner-waiter.ts";
|
|
4
|
+
export { uiErrorReporter, type UIErrorReporterOptions } from "./ui-error-reporter.ts";
|
|
5
|
+
export { llmRecover, type LlmRecoverOptions, type AttemptRecord, type RecoveryContext, type RequestRecoveryCodeFn, } from "./llm-recover.ts";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { hydrationWaiter } from "./hydration-waiter.js";
|
|
2
|
+
export { videoMode } from "./video-mode.js";
|
|
3
|
+
export { spinnerWaiter, defaultSelectors } from "./spinner-waiter.js";
|
|
4
|
+
export { uiErrorReporter } from "./ui-error-reporter.js";
|
|
5
|
+
export { llmRecover, } from "./llm-recover.js";
|