middlewright 0.1.0 → 0.1.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "middlewright",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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": "d9b03dd"
49
+ },
46
50
  "scripts": {
47
51
  "build": "tsc -p tsconfig.build.json",
48
- "typecheck": "tsc --noEmit",
49
- "test": "playwright test"
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,
@@ -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 Plugin = {
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: ActionMiddleware[];
120
+ actionMiddlewares: RegisteredActionMiddleware[];
99
121
  lifecycleEmitter: Emittery<TestLifecycleEvents>;
100
122
  lifecycleCleanups: (() => void)[];
101
123
  testInfo: TestInfo;
102
124
  };
103
125
 
104
- type PageWithPlugins = Page & {
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: (Plugin | false | null | undefined)[];
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(plugin.middleware);
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
- const callOriginal = () => (this[`${method}_original`] as Function)(...args);
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
- return middleware(ctx, next);
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
- return next();
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
 
@@ -1,5 +1,19 @@
1
1
  export { hydrationWaiter, type HydrationWaiterOptions } from "./hydration-waiter.ts";
2
- export { videoMode, type VideoModeOptions } from "./video-mode.ts";
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 { Plugin, LocatorWithOriginal } from "../plugin-system.ts";
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("(loading|pending|creating|verifying|starting|processing|syncing)\\.\\.\\.$", "i")`,
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,20 +77,24 @@ 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 visible
77
- const elementVisible = await waitForVisible(locator, { timeout: 1000 });
78
- if (elementVisible) {
79
- settings.log(`${locator} already visible, proceeding`);
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
 
@@ -87,9 +105,9 @@ export const spinnerWaiter = Object.assign(
87
105
 
88
106
  if (!spinnerVisible) {
89
107
  // No spinner - call action, suggest adding one if it fails
90
- settings.log(`${locator} not visible, no spinner, proceeding anyway`);
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 waitForVisibleWhileSpinning(locator, spinnerLocator, {
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} appeared after waiting`);
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 visible — loading finished without expected result`,
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 never appeared.`
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 waitForVisible(locator: Locator, { timeout = 1000 } = {}) {
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.isVisible()) return true;
175
+ if (await locatorIsReady(locator, method)) return true;
148
176
  await new Promise((resolve) => setTimeout(resolve, 100));
149
177
  }
150
- return false;
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 visible, but bail early if `spinner` disappears.
155
- * Returns "appeared" if target showed up, "spinner-gone" if loading finished
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 waitForVisibleWhileSpinning(
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,7 +209,7 @@ 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.isVisible()) return "appeared";
212
+ if (await locatorIsReady(target, method)) return "appeared";
168
213
  const elapsed = Date.now() - start;
169
214
  if (elapsed > spinnerGracePeriodMs && !(await spinner.isVisible())) return "spinner-gone";
170
215
  await new Promise((resolve) => setTimeout(resolve, 250));
@@ -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 {