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 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) | Highlight elements and pause before actions, so recorded videos are watchable. | [source](./src/plugins/video-mode.ts) |
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: 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`.
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
- 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
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) (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`.
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 `testLifecycle` hooks:
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";
@@ -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 Plugin = {
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: ActionMiddleware[];
72
+ actionMiddlewares: RegisteredActionMiddleware[];
51
73
  lifecycleEmitter: Emittery<TestLifecycleEvents>;
52
74
  lifecycleCleanups: (() => void)[];
53
75
  testInfo: TestInfo;
54
76
  };
55
- type PageWithPlugins = Page & {
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: (Plugin | false | null | undefined)[];
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 };
@@ -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(plugin.middleware);
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
- const callOriginal = () => this[`${method}_original`](...args);
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
- return middleware(ctx, next);
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
- return next();
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 {
@@ -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";
@@ -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("(loading|pending|creating|verifying|starting|processing|syncing)\\.\\.\\.$", "i")`,
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 visible
55
- const elementVisible = await waitForVisible(locator, { timeout: 1000 });
56
- if (elementVisible) {
57
- settings.log(`${locator} already visible, proceeding`);
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.isVisible();
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 visible, no spinner, proceeding anyway`);
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 waitForVisibleWhileSpinning(locator, spinnerLocator, {
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} appeared after waiting`);
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 visible — loading finished without expected result`);
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 never appeared.`
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 waitForVisible(locator, { timeout = 1000 } = {}) {
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.isVisible())
135
+ if (await locatorIsReady(locator, method))
114
136
  return true;
115
137
  await new Promise((resolve) => setTimeout(resolve, 100));
116
138
  }
117
- return false;
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 visible, but bail early if `spinner` disappears.
121
- * Returns "appeared" if target showed up, "spinner-gone" if loading finished
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 waitForVisibleWhileSpinning(target, spinner, { timeout = 1000 } = {}) {
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.isVisible())
166
+ if (await locatorIsReady(target, method))
130
167
  return "appeared";
131
168
  const elapsed = Date.now() - start;
132
- if (elapsed > spinnerGracePeriodMs && !(await spinner.isVisible()))
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",