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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "middlewright",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "A plugin/middleware system for Playwright locator actions — spinner-aware waiting, hydration gates, UI error reporting, video highlighting, and LLM-powered recovery. A hack, but a useful one.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"playwright",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@playwright/test": "^1.57.0",
|
|
36
36
|
"@types/node": "^24.10.9",
|
|
37
|
+
"pkg-pr-new": "^0.0.75",
|
|
37
38
|
"publint": "^0.3.14",
|
|
38
39
|
"typescript": "^5.9.3"
|
|
39
40
|
},
|
|
@@ -43,9 +44,12 @@
|
|
|
43
44
|
"engines": {
|
|
44
45
|
"node": ">=20"
|
|
45
46
|
},
|
|
47
|
+
"git": {
|
|
48
|
+
"sha": "c746cbd"
|
|
49
|
+
},
|
|
46
50
|
"scripts": {
|
|
47
51
|
"build": "tsc -p tsconfig.build.json",
|
|
48
|
-
"
|
|
49
|
-
"
|
|
52
|
+
"test": "playwright test",
|
|
53
|
+
"typecheck": "tsc --noEmit"
|
|
50
54
|
}
|
|
51
55
|
}
|
package/src/index.ts
CHANGED
|
@@ -4,8 +4,11 @@ export {
|
|
|
4
4
|
oneArgMethods,
|
|
5
5
|
overrideableMethods,
|
|
6
6
|
type Plugin,
|
|
7
|
+
type PageExtensionContext,
|
|
7
8
|
type ActionContext,
|
|
8
9
|
type ActionMiddleware,
|
|
10
|
+
type ActionMiddlewareTiming,
|
|
11
|
+
type ActionTiming,
|
|
9
12
|
type NextFn,
|
|
10
13
|
type TestLifecycleEvents,
|
|
11
14
|
type LocatorWithOriginal,
|
package/src/plugin-system.ts
CHANGED
|
@@ -71,10 +71,24 @@ export type ActionContext = {
|
|
|
71
71
|
args: unknown[];
|
|
72
72
|
page: Page;
|
|
73
73
|
testInfo: TestInfo;
|
|
74
|
+
timing: ActionTiming;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export type ActionMiddlewareTiming = {
|
|
78
|
+
name: string;
|
|
79
|
+
startedAt: number;
|
|
80
|
+
endedAt?: number;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export type ActionTiming = {
|
|
84
|
+
actionStartedAt: number;
|
|
85
|
+
attachedAt?: number;
|
|
86
|
+
attachedAtStart: boolean;
|
|
87
|
+
middlewares: ActionMiddlewareTiming[];
|
|
74
88
|
};
|
|
75
89
|
|
|
76
90
|
/** Function that calls the next middleware or the original action */
|
|
77
|
-
export type NextFn = () => Promise<unknown>;
|
|
91
|
+
export type NextFn = (args?: unknown[]) => Promise<unknown>;
|
|
78
92
|
|
|
79
93
|
/** Middleware function - wraps an action, must call next() */
|
|
80
94
|
export type ActionMiddleware = (ctx: ActionContext, next: NextFn) => Promise<unknown>;
|
|
@@ -82,26 +96,62 @@ export type ActionMiddleware = (ctx: ActionContext, next: NextFn) => Promise<unk
|
|
|
82
96
|
export type TestLifecycleEvents = {
|
|
83
97
|
beforeTest: { page: Page; testInfo: TestInfo };
|
|
84
98
|
afterTest: { page: Page; testInfo: TestInfo };
|
|
99
|
+
afterTestFinalize: { page: Page; testInfo: TestInfo };
|
|
85
100
|
};
|
|
86
101
|
|
|
87
|
-
export type
|
|
102
|
+
export type PageExtensionContext = {
|
|
103
|
+
page: Page;
|
|
104
|
+
testInfo: TestInfo;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export type Plugin<PageExtension extends object = {}> = {
|
|
88
108
|
name: string;
|
|
89
109
|
/** Middleware to wrap locator actions. Called in registration order. */
|
|
90
110
|
middleware?: ActionMiddleware;
|
|
91
111
|
/** Subscribe to test lifecycle events */
|
|
92
112
|
testLifecycle?: (emitter: Emittery<TestLifecycleEvents>) => void | (() => void);
|
|
113
|
+
/** Add explicit test controls to the page returned from addPlugins. */
|
|
114
|
+
pageExtension?: (ctx: PageExtensionContext) => PageExtension;
|
|
93
115
|
};
|
|
94
116
|
|
|
95
117
|
const PLUGIN_STATE = Symbol("playwrightPluginState");
|
|
96
118
|
|
|
97
119
|
type PluginState = {
|
|
98
|
-
actionMiddlewares:
|
|
120
|
+
actionMiddlewares: RegisteredActionMiddleware[];
|
|
99
121
|
lifecycleEmitter: Emittery<TestLifecycleEvents>;
|
|
100
122
|
lifecycleCleanups: (() => void)[];
|
|
101
123
|
testInfo: TestInfo;
|
|
102
124
|
};
|
|
103
125
|
|
|
104
|
-
type
|
|
126
|
+
type RegisteredActionMiddleware = {
|
|
127
|
+
name: string;
|
|
128
|
+
middleware: ActionMiddleware;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
type MaybePlugin = Plugin<object> | false | null | undefined;
|
|
132
|
+
|
|
133
|
+
type PluginPageExtension<T> = T extends Plugin<infer PageExtension> ? PageExtension : {};
|
|
134
|
+
|
|
135
|
+
type PluginPageExtensionForEntry<T> = [Extract<T, Plugin<object>>] extends [never]
|
|
136
|
+
? {}
|
|
137
|
+
: [Exclude<T, Plugin<object>>] extends [never]
|
|
138
|
+
? PluginPageExtension<T>
|
|
139
|
+
: Partial<PluginPageExtension<T>>;
|
|
140
|
+
|
|
141
|
+
type UnionToIntersection<T> = (T extends unknown ? (value: T) => void : never) extends (
|
|
142
|
+
value: infer Intersection,
|
|
143
|
+
) => void
|
|
144
|
+
? Intersection
|
|
145
|
+
: never;
|
|
146
|
+
|
|
147
|
+
type PageExtensions<T extends readonly MaybePlugin[]> = UnionToIntersection<
|
|
148
|
+
{
|
|
149
|
+
[Index in keyof T]: PluginPageExtensionForEntry<T[Index]>;
|
|
150
|
+
}[number]
|
|
151
|
+
> &
|
|
152
|
+
object;
|
|
153
|
+
|
|
154
|
+
type PageWithPlugins<PageExtension extends object = {}> = Page & PageExtension & {
|
|
105
155
|
[PLUGIN_STATE]: PluginState;
|
|
106
156
|
[Symbol.asyncDispose]: () => Promise<void>;
|
|
107
157
|
};
|
|
@@ -130,12 +180,12 @@ const getPluginState = (page: Page): PluginState | undefined => {
|
|
|
130
180
|
* });
|
|
131
181
|
* ```
|
|
132
182
|
*/
|
|
133
|
-
export const addPlugins = async (params: {
|
|
183
|
+
export const addPlugins = async <const Plugins extends readonly MaybePlugin[]>(params: {
|
|
134
184
|
page: Page;
|
|
135
185
|
testInfo: TestInfo;
|
|
136
|
-
plugins:
|
|
186
|
+
plugins: Plugins;
|
|
137
187
|
boxedStackPrefixes?: (defaults: string[]) => string[];
|
|
138
|
-
}): Promise<PageWithPlugins
|
|
188
|
+
}): Promise<PageWithPlugins<PageExtensions<Plugins>>> => {
|
|
139
189
|
const { page, testInfo, plugins, boxedStackPrefixes } = params;
|
|
140
190
|
// Patch Locator prototype once globally
|
|
141
191
|
patchLocatorPrototype(page, boxedStackPrefixes);
|
|
@@ -153,7 +203,10 @@ export const addPlugins = async (params: {
|
|
|
153
203
|
if (!plugin) continue;
|
|
154
204
|
|
|
155
205
|
if (plugin.middleware) {
|
|
156
|
-
state.actionMiddlewares.push(
|
|
206
|
+
state.actionMiddlewares.push({
|
|
207
|
+
middleware: plugin.middleware,
|
|
208
|
+
name: plugin.name,
|
|
209
|
+
});
|
|
157
210
|
}
|
|
158
211
|
|
|
159
212
|
if (plugin.testLifecycle) {
|
|
@@ -162,15 +215,23 @@ export const addPlugins = async (params: {
|
|
|
162
215
|
}
|
|
163
216
|
}
|
|
164
217
|
|
|
165
|
-
const pageWithPlugins = page as PageWithPlugins
|
|
218
|
+
const pageWithPlugins = page as PageWithPlugins<PageExtensions<Plugins>>;
|
|
166
219
|
pageWithPlugins[PLUGIN_STATE] = state;
|
|
167
220
|
|
|
221
|
+
for (const plugin of plugins) {
|
|
222
|
+
if (!plugin) continue;
|
|
223
|
+
if (!plugin.pageExtension) continue;
|
|
224
|
+
|
|
225
|
+
Object.assign(pageWithPlugins, plugin.pageExtension({ page, testInfo }));
|
|
226
|
+
}
|
|
227
|
+
|
|
168
228
|
// Emit beforeTest
|
|
169
229
|
await state.lifecycleEmitter.emitSerial("beforeTest", { page, testInfo });
|
|
170
230
|
|
|
171
231
|
// Add async dispose
|
|
172
232
|
pageWithPlugins[Symbol.asyncDispose] = async () => {
|
|
173
233
|
await state.lifecycleEmitter.emitSerial("afterTest", { page, testInfo });
|
|
234
|
+
await state.lifecycleEmitter.emitSerial("afterTestFinalize", { page, testInfo });
|
|
174
235
|
state.lifecycleCleanups.forEach((cleanup) => cleanup());
|
|
175
236
|
};
|
|
176
237
|
|
|
@@ -214,6 +275,52 @@ const loadSetBoxedStackPrefixes = (corePkg: string): ((prefixes: string[]) => vo
|
|
|
214
275
|
return null;
|
|
215
276
|
};
|
|
216
277
|
|
|
278
|
+
const locatorIsAttached = async (locator: Locator) => {
|
|
279
|
+
try {
|
|
280
|
+
return (await locator.count()) > 0;
|
|
281
|
+
} catch {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const observeAttachedAt = (
|
|
287
|
+
locator: Locator,
|
|
288
|
+
timing: ActionTiming,
|
|
289
|
+
pollIntervalMs = 50,
|
|
290
|
+
) => {
|
|
291
|
+
let stopped = false;
|
|
292
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
293
|
+
|
|
294
|
+
const poll = async () => {
|
|
295
|
+
if (stopped || timing.attachedAt !== undefined) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const attached = await locatorIsAttached(locator);
|
|
300
|
+
if (stopped || timing.attachedAt !== undefined) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (attached) {
|
|
305
|
+
timing.attachedAt = performance.now();
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
timeout = setTimeout(() => {
|
|
310
|
+
void poll();
|
|
311
|
+
}, pollIntervalMs);
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
void poll();
|
|
315
|
+
|
|
316
|
+
return () => {
|
|
317
|
+
stopped = true;
|
|
318
|
+
if (timeout) {
|
|
319
|
+
clearTimeout(timeout);
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
};
|
|
323
|
+
|
|
217
324
|
/** Patch Locator prototype to run middleware. Safe to call multiple times. */
|
|
218
325
|
const patchLocatorPrototype = (
|
|
219
326
|
page: Page,
|
|
@@ -254,13 +361,25 @@ const patchLocatorPrototype = (
|
|
|
254
361
|
this: LocatorWithOriginal,
|
|
255
362
|
...args: unknown[]
|
|
256
363
|
): Promise<unknown> {
|
|
257
|
-
|
|
364
|
+
let currentArgs = args;
|
|
365
|
+
const callOriginal = () => (this[`${method}_original`] as Function)(...currentArgs);
|
|
258
366
|
|
|
259
367
|
// Pages that never had plugins added (e.g. a second page in the same
|
|
260
368
|
// worker) fall through to the original implementation.
|
|
261
369
|
const state = getPluginState(this.page());
|
|
262
370
|
if (!state) return callOriginal();
|
|
263
371
|
const actionMiddlewares = state.actionMiddlewares;
|
|
372
|
+
const actionStartedAt = performance.now();
|
|
373
|
+
const attachedAtStart = await locatorIsAttached(this);
|
|
374
|
+
const timing: ActionTiming = {
|
|
375
|
+
actionStartedAt,
|
|
376
|
+
attachedAt: attachedAtStart ? actionStartedAt : undefined,
|
|
377
|
+
attachedAtStart,
|
|
378
|
+
middlewares: [],
|
|
379
|
+
};
|
|
380
|
+
const stopObservingAttached = attachedAtStart
|
|
381
|
+
? () => {}
|
|
382
|
+
: observeAttachedAt(this, timing);
|
|
264
383
|
|
|
265
384
|
const ctx: ActionContext = {
|
|
266
385
|
locator: this,
|
|
@@ -268,19 +387,38 @@ const patchLocatorPrototype = (
|
|
|
268
387
|
args,
|
|
269
388
|
page: this.page(),
|
|
270
389
|
testInfo: state.testInfo,
|
|
390
|
+
timing,
|
|
271
391
|
};
|
|
272
392
|
|
|
273
393
|
// Build middleware chain - each middleware calls next() to continue
|
|
274
394
|
let index = 0;
|
|
275
|
-
const next: NextFn = async () => {
|
|
395
|
+
const next: NextFn = async (nextArgs) => {
|
|
396
|
+
if (nextArgs) {
|
|
397
|
+
currentArgs = nextArgs;
|
|
398
|
+
ctx.args = nextArgs;
|
|
399
|
+
}
|
|
400
|
+
|
|
276
401
|
if (index < actionMiddlewares.length) {
|
|
277
|
-
const middleware = actionMiddlewares[index++];
|
|
278
|
-
|
|
402
|
+
const { middleware, name } = actionMiddlewares[index++];
|
|
403
|
+
const middlewareTiming: ActionMiddlewareTiming = {
|
|
404
|
+
name,
|
|
405
|
+
startedAt: performance.now(),
|
|
406
|
+
};
|
|
407
|
+
timing.middlewares.push(middlewareTiming);
|
|
408
|
+
try {
|
|
409
|
+
return await middleware(ctx, next);
|
|
410
|
+
} finally {
|
|
411
|
+
middlewareTiming.endedAt = performance.now();
|
|
412
|
+
}
|
|
279
413
|
}
|
|
280
414
|
return callOriginal();
|
|
281
415
|
};
|
|
282
416
|
|
|
283
|
-
|
|
417
|
+
try {
|
|
418
|
+
return await next();
|
|
419
|
+
} finally {
|
|
420
|
+
stopObservingAttached();
|
|
421
|
+
}
|
|
284
422
|
};
|
|
285
423
|
|
|
286
424
|
Object.defineProperty(locatorPrototype, method, { value });
|
|
@@ -25,6 +25,10 @@ export type HydrationWaiterOptions = {
|
|
|
25
25
|
* only runs client-side.
|
|
26
26
|
*/
|
|
27
27
|
export const hydrationWaiter = (options: HydrationWaiterOptions = {}): Plugin => {
|
|
28
|
+
if (process.env.PWDEBUG) {
|
|
29
|
+
return { name: "hydration-waiter" };
|
|
30
|
+
}
|
|
31
|
+
|
|
28
32
|
const selector = options.selector || '[data-hydrated="false"]';
|
|
29
33
|
const timeout = options.timeout || 10_000;
|
|
30
34
|
|
package/src/plugins/index.ts
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
export { hydrationWaiter, type HydrationWaiterOptions } from "./hydration-waiter.ts";
|
|
2
|
-
export {
|
|
2
|
+
export {
|
|
3
|
+
videoMode,
|
|
4
|
+
type VideoModeControls,
|
|
5
|
+
type VideoModeHighlight,
|
|
6
|
+
type VideoModeMetadata,
|
|
7
|
+
type VideoModeOptions,
|
|
8
|
+
type VideoModeOutputPaths,
|
|
9
|
+
type VideoModeOutputs,
|
|
10
|
+
type VideoModePageExtension,
|
|
11
|
+
type VideoModeRect,
|
|
12
|
+
type VideoModePlugin,
|
|
13
|
+
type VideoModeSourceRange,
|
|
14
|
+
type VideoModeSpan,
|
|
15
|
+
type VideoModeViewport,
|
|
16
|
+
} from "./video-mode.ts";
|
|
3
17
|
export { spinnerWaiter, type SpinnerWaiterOptions, defaultSelectors } from "./spinner-waiter.ts";
|
|
4
18
|
export { uiErrorReporter, type UIErrorReporterOptions } from "./ui-error-reporter.ts";
|
|
5
19
|
export {
|
|
@@ -69,6 +69,10 @@ export type RequestRecoveryCodeFn = (
|
|
|
69
69
|
const pagesInRecovery = new WeakSet<object>();
|
|
70
70
|
|
|
71
71
|
export const llmRecover = (options: LlmRecoverOptions = {}): Plugin => {
|
|
72
|
+
if (process.env.PWDEBUG) {
|
|
73
|
+
return { name: "llm-recover" };
|
|
74
|
+
}
|
|
75
|
+
|
|
72
76
|
const expect = options.expect || playwrightExpect;
|
|
73
77
|
const maxAttempts = options.maxAttempts || 3;
|
|
74
78
|
const requestRecovery = options.requestRecoveryCode || createAnthropicProvider(options);
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
10
10
|
import type { Locator } from "@playwright/test";
|
|
11
|
-
import type {
|
|
12
|
-
import { adjustError } from "../plugin-system.ts";
|
|
11
|
+
import type { ActionContext, LocatorWithOriginal, Plugin } from "../plugin-system.ts";
|
|
12
|
+
import { adjustError, oneArgMethods } from "../plugin-system.ts";
|
|
13
13
|
|
|
14
14
|
export type SpinnerWaiterOptions = {
|
|
15
15
|
/** Selectors that indicate loading state */
|
|
@@ -22,12 +22,26 @@ export type SpinnerWaiterOptions = {
|
|
|
22
22
|
log?: (message: string) => void;
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
+
/** Match `loading...`, (or really `anyVerbing...`). Also matches an ellipsis character "…" rather than "..." since LLMs like fancy unicode. */
|
|
26
|
+
const loadingTextPattern = /(loading|pending|creating|verifying|starting|processing|syncing|building|\b\w+ing)[\s\w]*(\.\.\.|…)$/;
|
|
27
|
+
|
|
25
28
|
const defaultSelectors = [
|
|
26
29
|
`[aria-label="Loading"]`,
|
|
27
30
|
`[data-spinner='true']`,
|
|
28
|
-
`:text-matches(
|
|
31
|
+
`:text-matches(${JSON.stringify(loadingTextPattern.source)}, "i")`,
|
|
29
32
|
];
|
|
30
33
|
|
|
34
|
+
const oneArgMethodNames = new Set<string>(oneArgMethods);
|
|
35
|
+
const enabledActionMethods = new Set<ActionContext["method"]>([
|
|
36
|
+
"clear",
|
|
37
|
+
"click",
|
|
38
|
+
"dblclick",
|
|
39
|
+
"fill",
|
|
40
|
+
"focus",
|
|
41
|
+
"press",
|
|
42
|
+
"type",
|
|
43
|
+
]);
|
|
44
|
+
|
|
31
45
|
const defaults: Required<SpinnerWaiterOptions> = {
|
|
32
46
|
spinnerSelectors: defaultSelectors,
|
|
33
47
|
spinnerTimeout: 30_000,
|
|
@@ -63,33 +77,37 @@ const suggestSpinnerMessage = (spinnerLocator: Locator) => [
|
|
|
63
77
|
*/
|
|
64
78
|
export const spinnerWaiter = Object.assign(
|
|
65
79
|
(options: SpinnerWaiterOptions = {}): Plugin => {
|
|
80
|
+
if (process.env.PWDEBUG) {
|
|
81
|
+
return { name: "spinner-waiter" };
|
|
82
|
+
}
|
|
83
|
+
|
|
66
84
|
return {
|
|
67
85
|
name: "spinner-waiter",
|
|
68
86
|
|
|
69
|
-
middleware: async ({ locator, method, page }, next) => {
|
|
87
|
+
middleware: async ({ args, locator, method, page }, next) => {
|
|
70
88
|
const settings = getSettings(options);
|
|
71
89
|
if (settings.disabled) return next();
|
|
72
90
|
|
|
73
91
|
const start = Date.now();
|
|
74
92
|
settings.log(`${locator}.${method}(...) starting`);
|
|
75
93
|
|
|
76
|
-
// Quick check if element is already
|
|
77
|
-
const
|
|
78
|
-
if (
|
|
79
|
-
settings.log(`${locator} already
|
|
94
|
+
// Quick check if element is already ready for the attempted action.
|
|
95
|
+
const elementReady = await waitForReady(locator, method, { timeout: 1000 });
|
|
96
|
+
if (elementReady) {
|
|
97
|
+
settings.log(`${locator} already ready, proceeding`);
|
|
80
98
|
return next();
|
|
81
99
|
}
|
|
82
100
|
|
|
83
101
|
// Check for spinner
|
|
84
102
|
const spinnerSelector = settings.spinnerSelectors.join(",");
|
|
85
103
|
const spinnerLocator = page.locator(spinnerSelector) as LocatorWithOriginal;
|
|
86
|
-
const spinnerVisible = await spinnerLocator
|
|
104
|
+
const spinnerVisible = await anySpinnerVisible(spinnerLocator);
|
|
87
105
|
|
|
88
106
|
if (!spinnerVisible) {
|
|
89
107
|
// No spinner - call action, suggest adding one if it fails
|
|
90
|
-
settings.log(`${locator} not
|
|
108
|
+
settings.log(`${locator} not ready, no spinner, failing fast`);
|
|
91
109
|
try {
|
|
92
|
-
return await next();
|
|
110
|
+
return await next(withTimeoutOption(method, args, 1));
|
|
93
111
|
} catch (error) {
|
|
94
112
|
adjustError(error as Error, suggestSpinnerMessage(spinnerLocator), "spinner-waiter.ts");
|
|
95
113
|
throw error;
|
|
@@ -102,18 +120,18 @@ export const spinnerWaiter = Object.assign(
|
|
|
102
120
|
|
|
103
121
|
// Spinner is visible — wait for the element, but bail early if the spinner
|
|
104
122
|
// disappears (the loading operation finished without producing the expected element).
|
|
105
|
-
const waitResult = await
|
|
123
|
+
const waitResult = await waitForReadyWhileSpinning(locator, method, spinnerLocator, {
|
|
106
124
|
timeout: settings.spinnerTimeout - 2000,
|
|
107
125
|
});
|
|
108
126
|
|
|
109
127
|
if (waitResult === "appeared") {
|
|
110
|
-
settings.log(`${locator}
|
|
128
|
+
settings.log(`${locator} became ready after waiting`);
|
|
111
129
|
return next();
|
|
112
130
|
}
|
|
113
131
|
|
|
114
132
|
if (waitResult === "spinner-gone") {
|
|
115
133
|
settings.log(
|
|
116
|
-
`Spinner disappeared but element not
|
|
134
|
+
`Spinner disappeared but element not ready — loading finished without expected result`,
|
|
117
135
|
);
|
|
118
136
|
} else {
|
|
119
137
|
settings.log(`Spinner still visible after ${settings.spinnerTimeout}ms, UI likely stuck`);
|
|
@@ -125,7 +143,7 @@ export const spinnerWaiter = Object.assign(
|
|
|
125
143
|
} catch (error) {
|
|
126
144
|
const message =
|
|
127
145
|
waitResult === "spinner-gone"
|
|
128
|
-
? `Loading finished (spinner disappeared after ${Date.now() - start}ms) but the expected element
|
|
146
|
+
? `Loading finished (spinner disappeared after ${Date.now() - start}ms) but the expected element was not ready.`
|
|
129
147
|
: `Spinner was still visible after ${settings.spinnerTimeout}ms, the UI is likely stuck.`;
|
|
130
148
|
adjustError(error as Error, [message], "spinner-waiter.ts");
|
|
131
149
|
throw error;
|
|
@@ -141,22 +159,49 @@ export const spinnerWaiter = Object.assign(
|
|
|
141
159
|
},
|
|
142
160
|
);
|
|
143
161
|
|
|
144
|
-
async function
|
|
162
|
+
async function locatorIsReady(locator: Locator, method: ActionContext["method"]) {
|
|
163
|
+
if (!(await locator.isVisible())) return false;
|
|
164
|
+
if (!enabledActionMethods.has(method)) return true;
|
|
165
|
+
return await locator.isEnabled();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function waitForReady(
|
|
169
|
+
locator: Locator,
|
|
170
|
+
method: ActionContext["method"],
|
|
171
|
+
{ timeout = 1000 } = {},
|
|
172
|
+
) {
|
|
145
173
|
const start = Date.now();
|
|
146
174
|
while (Date.now() - start < timeout) {
|
|
147
|
-
if (await locator
|
|
175
|
+
if (await locatorIsReady(locator, method)) return true;
|
|
148
176
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
149
177
|
}
|
|
150
|
-
return
|
|
178
|
+
return await locatorIsReady(locator, method);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function withTimeoutOption(method: ActionContext["method"], args: unknown[], timeout: number) {
|
|
182
|
+
const optionsIndex = oneArgMethodNames.has(method) ? 1 : 0;
|
|
183
|
+
const nextArgs = [...args];
|
|
184
|
+
const options = nextArgs[optionsIndex];
|
|
185
|
+
if (isOptionsObject(options)) {
|
|
186
|
+
nextArgs[optionsIndex] = { ...options, timeout };
|
|
187
|
+
} else {
|
|
188
|
+
nextArgs[optionsIndex] = { timeout };
|
|
189
|
+
}
|
|
190
|
+
return nextArgs;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isOptionsObject(value: unknown): value is Record<string, unknown> {
|
|
194
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
151
195
|
}
|
|
152
196
|
|
|
153
197
|
/**
|
|
154
|
-
* Wait for `target` to become
|
|
155
|
-
* Returns "appeared" if target
|
|
198
|
+
* Wait for `target` to become ready, but bail early if `spinner` disappears.
|
|
199
|
+
* Returns "appeared" if target became ready, "spinner-gone" if loading finished
|
|
156
200
|
* without the target, or "timeout" if spinner was still visible at deadline.
|
|
157
201
|
*/
|
|
158
|
-
async function
|
|
202
|
+
async function waitForReadyWhileSpinning(
|
|
159
203
|
target: Locator,
|
|
204
|
+
method: ActionContext["method"],
|
|
160
205
|
spinner: Locator,
|
|
161
206
|
{ timeout = 1000 } = {},
|
|
162
207
|
): Promise<"appeared" | "spinner-gone" | "timeout"> {
|
|
@@ -164,12 +209,22 @@ async function waitForVisibleWhileSpinning(
|
|
|
164
209
|
// Give the spinner a grace period before checking — it may flicker during transitions
|
|
165
210
|
const spinnerGracePeriodMs = 3000;
|
|
166
211
|
while (Date.now() - start < timeout) {
|
|
167
|
-
if (await target
|
|
212
|
+
if (await locatorIsReady(target, method)) return "appeared";
|
|
168
213
|
const elapsed = Date.now() - start;
|
|
169
|
-
if (elapsed > spinnerGracePeriodMs && !(await spinner
|
|
214
|
+
if (elapsed > spinnerGracePeriodMs && !(await anySpinnerVisible(spinner))) return "spinner-gone";
|
|
170
215
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
171
216
|
}
|
|
172
217
|
return "timeout";
|
|
173
218
|
}
|
|
174
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Multi-element-safe "is any spinner visible": the spinner selector union can
|
|
222
|
+
* legitimately match several loading indicators at once (e.g. two panels each
|
|
223
|
+
* showing a pending fallback), where a bare `locator.isVisible()` throws a
|
|
224
|
+
* strict-mode violation. `filter({ visible: true })` needs no strictness.
|
|
225
|
+
*/
|
|
226
|
+
async function anySpinnerVisible(spinnerLocator: Locator): Promise<boolean> {
|
|
227
|
+
return (await spinnerLocator.filter({ visible: true }).count()) > 0;
|
|
228
|
+
}
|
|
229
|
+
|
|
175
230
|
export { defaultSelectors };
|
|
@@ -20,6 +20,10 @@ export type UIErrorReporterOptions = {
|
|
|
20
20
|
* their text to the error message for easier debugging.
|
|
21
21
|
*/
|
|
22
22
|
export const uiErrorReporter = (options: UIErrorReporterOptions = {}): Plugin => {
|
|
23
|
+
if (process.env.PWDEBUG) {
|
|
24
|
+
return { name: "ui-error-reporter" };
|
|
25
|
+
}
|
|
26
|
+
|
|
23
27
|
const selector = options.selector || '[data-type="error"]';
|
|
24
28
|
|
|
25
29
|
return {
|