middlewright 0.1.0 → 0.1.2
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/README.md +95 -9
- package/dist/index.d.ts +1 -1
- package/dist/plugin-system.d.ts +40 -7
- package/dist/plugin-system.js +84 -6
- package/dist/plugins/hydration-waiter.js +3 -0
- package/dist/plugins/index.d.ts +1 -1
- package/dist/plugins/index.js +1 -1
- package/dist/plugins/llm-recover.js +3 -0
- package/dist/plugins/spinner-waiter.js +68 -22
- package/dist/plugins/ui-error-reporter.js +3 -0
- package/dist/plugins/video-mode.d.ts +111 -11
- package/dist/plugins/video-mode.js +1571 -93
- package/package.json +7 -3
- package/src/index.ts +3 -0
- package/src/plugin-system.ts +152 -14
- package/src/plugins/hydration-waiter.ts +4 -0
- package/src/plugins/index.ts +15 -1
- package/src/plugins/llm-recover.ts +4 -0
- package/src/plugins/spinner-waiter.ts +78 -23
- package/src/plugins/ui-error-reporter.ts +4 -0
- package/src/plugins/video-mode.ts +2203 -48
package/README.md
CHANGED
|
@@ -52,9 +52,11 @@ Ships with five plugins:
|
|
|
52
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
53
|
| [`hydrationWaiter`](#hydrationwaiter) | Don't interact with the app until it's hydrated. | [source](./src/plugins/hydration-waiter.ts) |
|
|
54
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) |
|
|
55
|
+
| [`videoMode`](#videomode) | Record action/dead-air facts and render watchable annotated videos after the run. | [source](./src/plugins/video-mode.ts) |
|
|
56
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
57
|
|
|
58
|
+
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.
|
|
59
|
+
|
|
58
60
|
`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
61
|
|
|
60
62
|
## ⚠️ This is a hack
|
|
@@ -130,18 +132,76 @@ uiErrorReporter({ selector: '[data-type="error"]' });
|
|
|
130
132
|
|
|
131
133
|
### videoMode
|
|
132
134
|
|
|
133
|
-
For producing demo/debugging videos people can actually follow:
|
|
135
|
+
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`.
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
await using page = await addPlugins({
|
|
139
|
+
page: basePage,
|
|
140
|
+
testInfo,
|
|
141
|
+
plugins: [
|
|
142
|
+
videoMode({
|
|
143
|
+
highlight: { mode: "pointer", duration: 1000 },
|
|
144
|
+
finalHold: 3000,
|
|
145
|
+
deadAirThreshold: 300,
|
|
146
|
+
skipMethods: ["waitFor"],
|
|
147
|
+
skipStackFrames: ["test-helpers.ts"], // don't annotate internal login/setup helpers
|
|
148
|
+
}),
|
|
149
|
+
],
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Use page.videoMode for invisible setup/bookkeeping that should be marked as
|
|
153
|
+
// dead air instead of highlighted in video mode.
|
|
154
|
+
await page.videoMode.deadAir(async () => {
|
|
155
|
+
await page.goto("/login");
|
|
156
|
+
await page.locator("#email").fill("demo@example.com");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const videoPaths = page.videoMode.outputPaths();
|
|
160
|
+
const videoMetadata = await page.videoMode.metadata();
|
|
161
|
+
console.log(videoPaths.rendered);
|
|
162
|
+
console.log(videoMetadata.highlights.length);
|
|
163
|
+
|
|
164
|
+
page.videoMode.setStartTime();
|
|
165
|
+
await page.locator("#important-flow").click();
|
|
166
|
+
page.videoMode.setEndTime();
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
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.
|
|
170
|
+
|
|
171
|
+
#### Trimming the blank startup lead-in (`trimStart`)
|
|
172
|
+
|
|
173
|
+
Recording begins at browser-context creation, so a video usually opens with a few seconds of `about:blank` + loading shell before the app paints. `trimStart` finds where that lead-in ends and starts the video on real content — instead of calling `setStartTime()` by hand in every test.
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
videoMode({ /* trimStart: "auto" is the default */ });
|
|
177
|
+
|
|
178
|
+
// start when a known "ready" element first becomes visible (falls back to
|
|
179
|
+
// blank detection if it never appears):
|
|
180
|
+
videoMode({ trimStart: ["selector", "[data-app-ready]"] });
|
|
181
|
+
|
|
182
|
+
// pin the start for a video whose exact frames you assert on:
|
|
183
|
+
videoMode({ trimStart: "never" });
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
`trimStart` is one of:
|
|
187
|
+
|
|
188
|
+
- **`"auto"`** (default) — pick a sensible strategy; currently the blank detector. Chosen so consumers get lead-in trimming just by upgrading.
|
|
189
|
+
- **`"detect-blank"`** — decode a coarse strip of the opening seconds and find the first frame that *differs* from the opening frame (the moment the static blank lead-in ends), starting there only when the lead-in is long enough to be worth trimming. Keys on change-from-the-opening-frame, not how "busy" a frame is, so it's robust to letterbox bars and dark loading shells.
|
|
190
|
+
- **`["selector", css]`** — start the moment `css` first becomes visible (waited for once, live); falls back to blank detection if it never appears.
|
|
191
|
+
- **`"never"`** — don't trim.
|
|
192
|
+
|
|
193
|
+
An explicit `setStartTime()` always wins over `trimStart`.
|
|
194
|
+
|
|
195
|
+
`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:
|
|
134
196
|
|
|
135
197
|
```ts
|
|
136
198
|
videoMode({
|
|
137
|
-
|
|
138
|
-
pauseAfterTest: 3000,
|
|
139
|
-
highlightStyle: "3px solid gold",
|
|
140
|
-
skipMethods: ["waitFor"],
|
|
141
|
-
skipStackFrames: ["test-helpers.ts"], // don't slow down internal login/setup helpers
|
|
199
|
+
highlight: { mode: "outline", style: "1px solid yellow" },
|
|
142
200
|
});
|
|
143
201
|
```
|
|
144
202
|
|
|
203
|
+
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.
|
|
204
|
+
|
|
145
205
|
### llmRecover
|
|
146
206
|
|
|
147
207
|
The most fun one, and the most dangerous one. When an action fails, it captures a screenshot, the accessibility snapshot, the page HTML and the error, asks Claude to respond with a JavaScript recovery function, and `eval`s it with `{ page, locator, error }` in scope. Up to `maxAttempts` tries, with attempt history fed back to the model.
|
|
@@ -216,9 +276,9 @@ export default defineConfig({
|
|
|
216
276
|
|
|
217
277
|
## Writing your own plugin
|
|
218
278
|
|
|
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) (
|
|
279
|
+
**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`.
|
|
220
280
|
|
|
221
|
-
A plugin is a name plus optional `middleware` and `
|
|
281
|
+
A plugin is a name plus optional `middleware`, `testLifecycle`, and `pageExtension` hooks:
|
|
222
282
|
|
|
223
283
|
```ts
|
|
224
284
|
import type { Plugin } from "middlewright";
|
|
@@ -249,9 +309,35 @@ export const slowActionLogger = (thresholdMs = 2000): Plugin => ({
|
|
|
249
309
|
});
|
|
250
310
|
```
|
|
251
311
|
|
|
312
|
+
Use `pageExtension` for explicit controls tests can call through the page returned from `addPlugins`:
|
|
313
|
+
|
|
314
|
+
```ts
|
|
315
|
+
export const debugTools = (): Plugin<{
|
|
316
|
+
debugTools: {
|
|
317
|
+
title(): string;
|
|
318
|
+
};
|
|
319
|
+
}> => ({
|
|
320
|
+
name: "debug-tools",
|
|
321
|
+
pageExtension: ({ testInfo }) => ({
|
|
322
|
+
debugTools: {
|
|
323
|
+
title: () => testInfo.title,
|
|
324
|
+
},
|
|
325
|
+
}),
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
await using page = await addPlugins({
|
|
329
|
+
page: basePage,
|
|
330
|
+
testInfo,
|
|
331
|
+
plugins: [debugTools()],
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
expect(page.debugTools.title()).toBe(testInfo.title);
|
|
335
|
+
```
|
|
336
|
+
|
|
252
337
|
Notes for plugin authors:
|
|
253
338
|
|
|
254
339
|
- 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.
|
|
340
|
+
- Keep page extensions namespaced (`page.videoMode`, `page.debugTools`) so plugin controls do not collide with Playwright's own `Page` methods or other plugins.
|
|
255
341
|
- 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
342
|
- `adjustError(error, infoLines, filterFile?)` appends colored info lines to an error message and optionally scrubs your plugin's frames from the stack trace.
|
|
257
343
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +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";
|
|
1
|
+
export { addPlugins, adjustError, oneArgMethods, overrideableMethods, type Plugin, type PageExtensionContext, type ActionContext, type ActionMiddleware, type ActionMiddlewareTiming, type ActionTiming, type NextFn, type TestLifecycleEvents, type LocatorWithOriginal, type OverrideableMethod, type OneArgMethod, } from "./plugin-system.ts";
|
|
2
2
|
export * from "./plugins/index.ts";
|
package/dist/plugin-system.d.ts
CHANGED
|
@@ -23,9 +23,21 @@ export type ActionContext = {
|
|
|
23
23
|
args: unknown[];
|
|
24
24
|
page: Page;
|
|
25
25
|
testInfo: TestInfo;
|
|
26
|
+
timing: ActionTiming;
|
|
27
|
+
};
|
|
28
|
+
export type ActionMiddlewareTiming = {
|
|
29
|
+
name: string;
|
|
30
|
+
startedAt: number;
|
|
31
|
+
endedAt?: number;
|
|
32
|
+
};
|
|
33
|
+
export type ActionTiming = {
|
|
34
|
+
actionStartedAt: number;
|
|
35
|
+
attachedAt?: number;
|
|
36
|
+
attachedAtStart: boolean;
|
|
37
|
+
middlewares: ActionMiddlewareTiming[];
|
|
26
38
|
};
|
|
27
39
|
/** Function that calls the next middleware or the original action */
|
|
28
|
-
export type NextFn = () => Promise<unknown>;
|
|
40
|
+
export type NextFn = (args?: unknown[]) => Promise<unknown>;
|
|
29
41
|
/** Middleware function - wraps an action, must call next() */
|
|
30
42
|
export type ActionMiddleware = (ctx: ActionContext, next: NextFn) => Promise<unknown>;
|
|
31
43
|
export type TestLifecycleEvents = {
|
|
@@ -37,22 +49,43 @@ export type TestLifecycleEvents = {
|
|
|
37
49
|
page: Page;
|
|
38
50
|
testInfo: TestInfo;
|
|
39
51
|
};
|
|
52
|
+
afterTestFinalize: {
|
|
53
|
+
page: Page;
|
|
54
|
+
testInfo: TestInfo;
|
|
55
|
+
};
|
|
40
56
|
};
|
|
41
|
-
export type
|
|
57
|
+
export type PageExtensionContext = {
|
|
58
|
+
page: Page;
|
|
59
|
+
testInfo: TestInfo;
|
|
60
|
+
};
|
|
61
|
+
export type Plugin<PageExtension extends object = {}> = {
|
|
42
62
|
name: string;
|
|
43
63
|
/** Middleware to wrap locator actions. Called in registration order. */
|
|
44
64
|
middleware?: ActionMiddleware;
|
|
45
65
|
/** Subscribe to test lifecycle events */
|
|
46
66
|
testLifecycle?: (emitter: Emittery<TestLifecycleEvents>) => void | (() => void);
|
|
67
|
+
/** Add explicit test controls to the page returned from addPlugins. */
|
|
68
|
+
pageExtension?: (ctx: PageExtensionContext) => PageExtension;
|
|
47
69
|
};
|
|
48
70
|
declare const PLUGIN_STATE: unique symbol;
|
|
49
71
|
type PluginState = {
|
|
50
|
-
actionMiddlewares:
|
|
72
|
+
actionMiddlewares: RegisteredActionMiddleware[];
|
|
51
73
|
lifecycleEmitter: Emittery<TestLifecycleEvents>;
|
|
52
74
|
lifecycleCleanups: (() => void)[];
|
|
53
75
|
testInfo: TestInfo;
|
|
54
76
|
};
|
|
55
|
-
type
|
|
77
|
+
type RegisteredActionMiddleware = {
|
|
78
|
+
name: string;
|
|
79
|
+
middleware: ActionMiddleware;
|
|
80
|
+
};
|
|
81
|
+
type MaybePlugin = Plugin<object> | false | null | undefined;
|
|
82
|
+
type PluginPageExtension<T> = T extends Plugin<infer PageExtension> ? PageExtension : {};
|
|
83
|
+
type PluginPageExtensionForEntry<T> = [Extract<T, Plugin<object>>] extends [never] ? {} : [Exclude<T, Plugin<object>>] extends [never] ? PluginPageExtension<T> : Partial<PluginPageExtension<T>>;
|
|
84
|
+
type UnionToIntersection<T> = (T extends unknown ? (value: T) => void : never) extends (value: infer Intersection) => void ? Intersection : never;
|
|
85
|
+
type PageExtensions<T extends readonly MaybePlugin[]> = UnionToIntersection<{
|
|
86
|
+
[Index in keyof T]: PluginPageExtensionForEntry<T[Index]>;
|
|
87
|
+
}[number]> & object;
|
|
88
|
+
type PageWithPlugins<PageExtension extends object = {}> = Page & PageExtension & {
|
|
56
89
|
[PLUGIN_STATE]: PluginState;
|
|
57
90
|
[Symbol.asyncDispose]: () => Promise<void>;
|
|
58
91
|
};
|
|
@@ -72,11 +105,11 @@ type PageWithPlugins = Page & {
|
|
|
72
105
|
* });
|
|
73
106
|
* ```
|
|
74
107
|
*/
|
|
75
|
-
export declare const addPlugins: (params: {
|
|
108
|
+
export declare const addPlugins: <const Plugins extends readonly MaybePlugin[]>(params: {
|
|
76
109
|
page: Page;
|
|
77
110
|
testInfo: TestInfo;
|
|
78
|
-
plugins:
|
|
111
|
+
plugins: Plugins;
|
|
79
112
|
boxedStackPrefixes?: (defaults: string[]) => string[];
|
|
80
|
-
}) => Promise<PageWithPlugins
|
|
113
|
+
}) => Promise<PageWithPlugins<PageExtensions<Plugins>>>;
|
|
81
114
|
export type { OverrideableMethod, OneArgMethod };
|
|
82
115
|
export { oneArgMethods, overrideableMethods };
|
package/dist/plugin-system.js
CHANGED
|
@@ -85,7 +85,10 @@ export const addPlugins = async (params) => {
|
|
|
85
85
|
if (!plugin)
|
|
86
86
|
continue;
|
|
87
87
|
if (plugin.middleware) {
|
|
88
|
-
state.actionMiddlewares.push(
|
|
88
|
+
state.actionMiddlewares.push({
|
|
89
|
+
middleware: plugin.middleware,
|
|
90
|
+
name: plugin.name,
|
|
91
|
+
});
|
|
89
92
|
}
|
|
90
93
|
if (plugin.testLifecycle) {
|
|
91
94
|
const cleanup = plugin.testLifecycle(state.lifecycleEmitter);
|
|
@@ -95,11 +98,19 @@ export const addPlugins = async (params) => {
|
|
|
95
98
|
}
|
|
96
99
|
const pageWithPlugins = page;
|
|
97
100
|
pageWithPlugins[PLUGIN_STATE] = state;
|
|
101
|
+
for (const plugin of plugins) {
|
|
102
|
+
if (!plugin)
|
|
103
|
+
continue;
|
|
104
|
+
if (!plugin.pageExtension)
|
|
105
|
+
continue;
|
|
106
|
+
Object.assign(pageWithPlugins, plugin.pageExtension({ page, testInfo }));
|
|
107
|
+
}
|
|
98
108
|
// Emit beforeTest
|
|
99
109
|
await state.lifecycleEmitter.emitSerial("beforeTest", { page, testInfo });
|
|
100
110
|
// Add async dispose
|
|
101
111
|
pageWithPlugins[Symbol.asyncDispose] = async () => {
|
|
102
112
|
await state.lifecycleEmitter.emitSerial("afterTest", { page, testInfo });
|
|
113
|
+
await state.lifecycleEmitter.emitSerial("afterTestFinalize", { page, testInfo });
|
|
103
114
|
state.lifecycleCleanups.forEach((cleanup) => cleanup());
|
|
104
115
|
};
|
|
105
116
|
return pageWithPlugins;
|
|
@@ -141,6 +152,41 @@ const loadSetBoxedStackPrefixes = (corePkg) => {
|
|
|
141
152
|
}
|
|
142
153
|
return null;
|
|
143
154
|
};
|
|
155
|
+
const locatorIsAttached = async (locator) => {
|
|
156
|
+
try {
|
|
157
|
+
return (await locator.count()) > 0;
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
const observeAttachedAt = (locator, timing, pollIntervalMs = 50) => {
|
|
164
|
+
let stopped = false;
|
|
165
|
+
let timeout;
|
|
166
|
+
const poll = async () => {
|
|
167
|
+
if (stopped || timing.attachedAt !== undefined) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const attached = await locatorIsAttached(locator);
|
|
171
|
+
if (stopped || timing.attachedAt !== undefined) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (attached) {
|
|
175
|
+
timing.attachedAt = performance.now();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
timeout = setTimeout(() => {
|
|
179
|
+
void poll();
|
|
180
|
+
}, pollIntervalMs);
|
|
181
|
+
};
|
|
182
|
+
void poll();
|
|
183
|
+
return () => {
|
|
184
|
+
stopped = true;
|
|
185
|
+
if (timeout) {
|
|
186
|
+
clearTimeout(timeout);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
};
|
|
144
190
|
/** Patch Locator prototype to run middleware. Safe to call multiple times. */
|
|
145
191
|
const patchLocatorPrototype = (page, boxedStackPrefixes) => {
|
|
146
192
|
if (prototypePatched)
|
|
@@ -170,30 +216,62 @@ const patchLocatorPrototype = (page, boxedStackPrefixes) => {
|
|
|
170
216
|
for (const method of overrideableMethods) {
|
|
171
217
|
locatorPrototype[`${method}_original`] = locatorPrototype[method];
|
|
172
218
|
const value = async function patchedMethod(...args) {
|
|
173
|
-
|
|
219
|
+
let currentArgs = args;
|
|
220
|
+
const callOriginal = () => this[`${method}_original`](...currentArgs);
|
|
174
221
|
// Pages that never had plugins added (e.g. a second page in the same
|
|
175
222
|
// worker) fall through to the original implementation.
|
|
176
223
|
const state = getPluginState(this.page());
|
|
177
224
|
if (!state)
|
|
178
225
|
return callOriginal();
|
|
179
226
|
const actionMiddlewares = state.actionMiddlewares;
|
|
227
|
+
const actionStartedAt = performance.now();
|
|
228
|
+
const attachedAtStart = await locatorIsAttached(this);
|
|
229
|
+
const timing = {
|
|
230
|
+
actionStartedAt,
|
|
231
|
+
attachedAt: attachedAtStart ? actionStartedAt : undefined,
|
|
232
|
+
attachedAtStart,
|
|
233
|
+
middlewares: [],
|
|
234
|
+
};
|
|
235
|
+
const stopObservingAttached = attachedAtStart
|
|
236
|
+
? () => { }
|
|
237
|
+
: observeAttachedAt(this, timing);
|
|
180
238
|
const ctx = {
|
|
181
239
|
locator: this,
|
|
182
240
|
method,
|
|
183
241
|
args,
|
|
184
242
|
page: this.page(),
|
|
185
243
|
testInfo: state.testInfo,
|
|
244
|
+
timing,
|
|
186
245
|
};
|
|
187
246
|
// Build middleware chain - each middleware calls next() to continue
|
|
188
247
|
let index = 0;
|
|
189
|
-
const next = async () => {
|
|
248
|
+
const next = async (nextArgs) => {
|
|
249
|
+
if (nextArgs) {
|
|
250
|
+
currentArgs = nextArgs;
|
|
251
|
+
ctx.args = nextArgs;
|
|
252
|
+
}
|
|
190
253
|
if (index < actionMiddlewares.length) {
|
|
191
|
-
const middleware = actionMiddlewares[index++];
|
|
192
|
-
|
|
254
|
+
const { middleware, name } = actionMiddlewares[index++];
|
|
255
|
+
const middlewareTiming = {
|
|
256
|
+
name,
|
|
257
|
+
startedAt: performance.now(),
|
|
258
|
+
};
|
|
259
|
+
timing.middlewares.push(middlewareTiming);
|
|
260
|
+
try {
|
|
261
|
+
return await middleware(ctx, next);
|
|
262
|
+
}
|
|
263
|
+
finally {
|
|
264
|
+
middlewareTiming.endedAt = performance.now();
|
|
265
|
+
}
|
|
193
266
|
}
|
|
194
267
|
return callOriginal();
|
|
195
268
|
};
|
|
196
|
-
|
|
269
|
+
try {
|
|
270
|
+
return await next();
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
stopObservingAttached();
|
|
274
|
+
}
|
|
197
275
|
};
|
|
198
276
|
Object.defineProperty(locatorPrototype, method, { value });
|
|
199
277
|
}
|
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
* only runs client-side.
|
|
9
9
|
*/
|
|
10
10
|
export const hydrationWaiter = (options = {}) => {
|
|
11
|
+
if (process.env.PWDEBUG) {
|
|
12
|
+
return { name: "hydration-waiter" };
|
|
13
|
+
}
|
|
11
14
|
const selector = options.selector || '[data-hydrated="false"]';
|
|
12
15
|
const timeout = options.timeout || 10_000;
|
|
13
16
|
return {
|
package/dist/plugins/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { hydrationWaiter, type HydrationWaiterOptions } from "./hydration-waiter.ts";
|
|
2
|
-
export { videoMode, type VideoModeOptions } from "./video-mode.ts";
|
|
2
|
+
export { videoMode, type VideoModeControls, type VideoModeHighlight, type VideoModeMetadata, type VideoModeOptions, type VideoModeOutputPaths, type VideoModeOutputs, type VideoModePageExtension, type VideoModeRect, type VideoModePlugin, type VideoModeSourceRange, type VideoModeSpan, type VideoModeViewport, } from "./video-mode.ts";
|
|
3
3
|
export { spinnerWaiter, type SpinnerWaiterOptions, defaultSelectors } from "./spinner-waiter.ts";
|
|
4
4
|
export { uiErrorReporter, type UIErrorReporterOptions } from "./ui-error-reporter.ts";
|
|
5
5
|
export { llmRecover, type LlmRecoverOptions, type AttemptRecord, type RecoveryContext, type RequestRecoveryCodeFn, } from "./llm-recover.ts";
|
package/dist/plugins/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { hydrationWaiter } from "./hydration-waiter.js";
|
|
2
|
-
export { videoMode } from "./video-mode.js";
|
|
2
|
+
export { videoMode, } from "./video-mode.js";
|
|
3
3
|
export { spinnerWaiter, defaultSelectors } from "./spinner-waiter.js";
|
|
4
4
|
export { uiErrorReporter } from "./ui-error-reporter.js";
|
|
5
5
|
export { llmRecover, } from "./llm-recover.js";
|
|
@@ -25,6 +25,9 @@ import { adjustError } from "../plugin-system.js";
|
|
|
25
25
|
// Uses a WeakSet of pages currently being recovered.
|
|
26
26
|
const pagesInRecovery = new WeakSet();
|
|
27
27
|
export const llmRecover = (options = {}) => {
|
|
28
|
+
if (process.env.PWDEBUG) {
|
|
29
|
+
return { name: "llm-recover" };
|
|
30
|
+
}
|
|
28
31
|
const expect = options.expect || playwrightExpect;
|
|
29
32
|
const maxAttempts = options.maxAttempts || 3;
|
|
30
33
|
const requestRecovery = options.requestRecoveryCode || createAnthropicProvider(options);
|
|
@@ -7,12 +7,24 @@
|
|
|
7
7
|
* a different effective action timeout while the app is visibly loading.
|
|
8
8
|
*/
|
|
9
9
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
10
|
-
import { adjustError } from "../plugin-system.js";
|
|
10
|
+
import { adjustError, oneArgMethods } from "../plugin-system.js";
|
|
11
|
+
/** Match `loading...`, (or really `anyVerbing...`). Also matches an ellipsis character "…" rather than "..." since LLMs like fancy unicode. */
|
|
12
|
+
const loadingTextPattern = /(loading|pending|creating|verifying|starting|processing|syncing|building|\b\w+ing)[\s\w]*(\.\.\.|…)$/;
|
|
11
13
|
const defaultSelectors = [
|
|
12
14
|
`[aria-label="Loading"]`,
|
|
13
15
|
`[data-spinner='true']`,
|
|
14
|
-
`:text-matches(
|
|
16
|
+
`:text-matches(${JSON.stringify(loadingTextPattern.source)}, "i")`,
|
|
15
17
|
];
|
|
18
|
+
const oneArgMethodNames = new Set(oneArgMethods);
|
|
19
|
+
const enabledActionMethods = new Set([
|
|
20
|
+
"clear",
|
|
21
|
+
"click",
|
|
22
|
+
"dblclick",
|
|
23
|
+
"fill",
|
|
24
|
+
"focus",
|
|
25
|
+
"press",
|
|
26
|
+
"type",
|
|
27
|
+
]);
|
|
16
28
|
const defaults = {
|
|
17
29
|
spinnerSelectors: defaultSelectors,
|
|
18
30
|
spinnerTimeout: 30_000,
|
|
@@ -43,29 +55,32 @@ const suggestSpinnerMessage = (spinnerLocator) => [
|
|
|
43
55
|
* `spinnerWaiter.settings.run({...}, () => locator.click())`.
|
|
44
56
|
*/
|
|
45
57
|
export const spinnerWaiter = Object.assign((options = {}) => {
|
|
58
|
+
if (process.env.PWDEBUG) {
|
|
59
|
+
return { name: "spinner-waiter" };
|
|
60
|
+
}
|
|
46
61
|
return {
|
|
47
62
|
name: "spinner-waiter",
|
|
48
|
-
middleware: async ({ locator, method, page }, next) => {
|
|
63
|
+
middleware: async ({ args, locator, method, page }, next) => {
|
|
49
64
|
const settings = getSettings(options);
|
|
50
65
|
if (settings.disabled)
|
|
51
66
|
return next();
|
|
52
67
|
const start = Date.now();
|
|
53
68
|
settings.log(`${locator}.${method}(...) starting`);
|
|
54
|
-
// Quick check if element is already
|
|
55
|
-
const
|
|
56
|
-
if (
|
|
57
|
-
settings.log(`${locator} already
|
|
69
|
+
// Quick check if element is already ready for the attempted action.
|
|
70
|
+
const elementReady = await waitForReady(locator, method, { timeout: 1000 });
|
|
71
|
+
if (elementReady) {
|
|
72
|
+
settings.log(`${locator} already ready, proceeding`);
|
|
58
73
|
return next();
|
|
59
74
|
}
|
|
60
75
|
// Check for spinner
|
|
61
76
|
const spinnerSelector = settings.spinnerSelectors.join(",");
|
|
62
77
|
const spinnerLocator = page.locator(spinnerSelector);
|
|
63
|
-
const spinnerVisible = await spinnerLocator
|
|
78
|
+
const spinnerVisible = await anySpinnerVisible(spinnerLocator);
|
|
64
79
|
if (!spinnerVisible) {
|
|
65
80
|
// No spinner - call action, suggest adding one if it fails
|
|
66
|
-
settings.log(`${locator} not
|
|
81
|
+
settings.log(`${locator} not ready, no spinner, failing fast`);
|
|
67
82
|
try {
|
|
68
|
-
return await next();
|
|
83
|
+
return await next(withTimeoutOption(method, args, 1));
|
|
69
84
|
}
|
|
70
85
|
catch (error) {
|
|
71
86
|
adjustError(error, suggestSpinnerMessage(spinnerLocator), "spinner-waiter.ts");
|
|
@@ -75,15 +90,15 @@ export const spinnerWaiter = Object.assign((options = {}) => {
|
|
|
75
90
|
settings.log(`Spinner visible, waiting up to ${settings.spinnerTimeout - 2000}ms for ${locator}`);
|
|
76
91
|
// Spinner is visible — wait for the element, but bail early if the spinner
|
|
77
92
|
// disappears (the loading operation finished without producing the expected element).
|
|
78
|
-
const waitResult = await
|
|
93
|
+
const waitResult = await waitForReadyWhileSpinning(locator, method, spinnerLocator, {
|
|
79
94
|
timeout: settings.spinnerTimeout - 2000,
|
|
80
95
|
});
|
|
81
96
|
if (waitResult === "appeared") {
|
|
82
|
-
settings.log(`${locator}
|
|
97
|
+
settings.log(`${locator} became ready after waiting`);
|
|
83
98
|
return next();
|
|
84
99
|
}
|
|
85
100
|
if (waitResult === "spinner-gone") {
|
|
86
|
-
settings.log(`Spinner disappeared but element not
|
|
101
|
+
settings.log(`Spinner disappeared but element not ready — loading finished without expected result`);
|
|
87
102
|
}
|
|
88
103
|
else {
|
|
89
104
|
settings.log(`Spinner still visible after ${settings.spinnerTimeout}ms, UI likely stuck`);
|
|
@@ -94,7 +109,7 @@ export const spinnerWaiter = Object.assign((options = {}) => {
|
|
|
94
109
|
}
|
|
95
110
|
catch (error) {
|
|
96
111
|
const message = waitResult === "spinner-gone"
|
|
97
|
-
? `Loading finished (spinner disappeared after ${Date.now() - start}ms) but the expected element
|
|
112
|
+
? `Loading finished (spinner disappeared after ${Date.now() - start}ms) but the expected element was not ready.`
|
|
98
113
|
: `Spinner was still visible after ${settings.spinnerTimeout}ms, the UI is likely stuck.`;
|
|
99
114
|
adjustError(error, [message], "spinner-waiter.ts");
|
|
100
115
|
throw error;
|
|
@@ -107,32 +122,63 @@ export const spinnerWaiter = Object.assign((options = {}) => {
|
|
|
107
122
|
/** Default settings values */
|
|
108
123
|
defaults,
|
|
109
124
|
});
|
|
110
|
-
async function
|
|
125
|
+
async function locatorIsReady(locator, method) {
|
|
126
|
+
if (!(await locator.isVisible()))
|
|
127
|
+
return false;
|
|
128
|
+
if (!enabledActionMethods.has(method))
|
|
129
|
+
return true;
|
|
130
|
+
return await locator.isEnabled();
|
|
131
|
+
}
|
|
132
|
+
async function waitForReady(locator, method, { timeout = 1000 } = {}) {
|
|
111
133
|
const start = Date.now();
|
|
112
134
|
while (Date.now() - start < timeout) {
|
|
113
|
-
if (await locator
|
|
135
|
+
if (await locatorIsReady(locator, method))
|
|
114
136
|
return true;
|
|
115
137
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
116
138
|
}
|
|
117
|
-
return
|
|
139
|
+
return await locatorIsReady(locator, method);
|
|
140
|
+
}
|
|
141
|
+
function withTimeoutOption(method, args, timeout) {
|
|
142
|
+
const optionsIndex = oneArgMethodNames.has(method) ? 1 : 0;
|
|
143
|
+
const nextArgs = [...args];
|
|
144
|
+
const options = nextArgs[optionsIndex];
|
|
145
|
+
if (isOptionsObject(options)) {
|
|
146
|
+
nextArgs[optionsIndex] = { ...options, timeout };
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
nextArgs[optionsIndex] = { timeout };
|
|
150
|
+
}
|
|
151
|
+
return nextArgs;
|
|
152
|
+
}
|
|
153
|
+
function isOptionsObject(value) {
|
|
154
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
118
155
|
}
|
|
119
156
|
/**
|
|
120
|
-
* Wait for `target` to become
|
|
121
|
-
* Returns "appeared" if target
|
|
157
|
+
* Wait for `target` to become ready, but bail early if `spinner` disappears.
|
|
158
|
+
* Returns "appeared" if target became ready, "spinner-gone" if loading finished
|
|
122
159
|
* without the target, or "timeout" if spinner was still visible at deadline.
|
|
123
160
|
*/
|
|
124
|
-
async function
|
|
161
|
+
async function waitForReadyWhileSpinning(target, method, spinner, { timeout = 1000 } = {}) {
|
|
125
162
|
const start = Date.now();
|
|
126
163
|
// Give the spinner a grace period before checking — it may flicker during transitions
|
|
127
164
|
const spinnerGracePeriodMs = 3000;
|
|
128
165
|
while (Date.now() - start < timeout) {
|
|
129
|
-
if (await target
|
|
166
|
+
if (await locatorIsReady(target, method))
|
|
130
167
|
return "appeared";
|
|
131
168
|
const elapsed = Date.now() - start;
|
|
132
|
-
if (elapsed > spinnerGracePeriodMs && !(await spinner
|
|
169
|
+
if (elapsed > spinnerGracePeriodMs && !(await anySpinnerVisible(spinner)))
|
|
133
170
|
return "spinner-gone";
|
|
134
171
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
135
172
|
}
|
|
136
173
|
return "timeout";
|
|
137
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* Multi-element-safe "is any spinner visible": the spinner selector union can
|
|
177
|
+
* legitimately match several loading indicators at once (e.g. two panels each
|
|
178
|
+
* showing a pending fallback), where a bare `locator.isVisible()` throws a
|
|
179
|
+
* strict-mode violation. `filter({ visible: true })` needs no strictness.
|
|
180
|
+
*/
|
|
181
|
+
async function anySpinnerVisible(spinnerLocator) {
|
|
182
|
+
return (await spinnerLocator.filter({ visible: true }).count()) > 0;
|
|
183
|
+
}
|
|
138
184
|
export { defaultSelectors };
|
|
@@ -4,6 +4,9 @@ import { adjustError } from "../plugin-system.js";
|
|
|
4
4
|
* their text to the error message for easier debugging.
|
|
5
5
|
*/
|
|
6
6
|
export const uiErrorReporter = (options = {}) => {
|
|
7
|
+
if (process.env.PWDEBUG) {
|
|
8
|
+
return { name: "ui-error-reporter" };
|
|
9
|
+
}
|
|
7
10
|
const selector = options.selector || '[data-type="error"]';
|
|
8
11
|
return {
|
|
9
12
|
name: "ui-error-reporter",
|