middlewright 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,124 @@
1
+ var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
2
+ if (value !== null && value !== void 0) {
3
+ if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
4
+ var dispose, inner;
5
+ if (async) {
6
+ if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
7
+ dispose = value[Symbol.asyncDispose];
8
+ }
9
+ if (dispose === void 0) {
10
+ if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
11
+ dispose = value[Symbol.dispose];
12
+ if (async) inner = dispose;
13
+ }
14
+ if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
15
+ if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
16
+ env.stack.push({ value: value, dispose: dispose, async: async });
17
+ }
18
+ else if (async) {
19
+ env.stack.push({ async: true });
20
+ }
21
+ return value;
22
+ };
23
+ var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
24
+ return function (env) {
25
+ function fail(e) {
26
+ env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
27
+ env.hasError = true;
28
+ }
29
+ var r, s = 0;
30
+ function next() {
31
+ while (r = env.stack.pop()) {
32
+ try {
33
+ if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
34
+ if (r.dispose) {
35
+ var result = r.dispose.call(r.value);
36
+ if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
37
+ }
38
+ else s |= 1;
39
+ }
40
+ catch (e) {
41
+ fail(e);
42
+ }
43
+ }
44
+ if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
45
+ if (env.hasError) throw env.error;
46
+ }
47
+ return next();
48
+ };
49
+ })(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
50
+ var e = new Error(message);
51
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
52
+ });
53
+ /** Highlight element, pause, return disposable that unhighlights */
54
+ const setupHighlight = async (locator, style, pauseMs) => {
55
+ try {
56
+ await locator.evaluate((el, s) => {
57
+ const prev = el.getAttribute("style") || "";
58
+ el.setAttribute("data-video-prev-style", prev);
59
+ el.setAttribute("style", `${prev}; outline: ${s} !important; outline-offset: 2px !important;`);
60
+ }, style);
61
+ }
62
+ catch {
63
+ // Element may not be ready yet, ignore
64
+ }
65
+ await new Promise((resolve) => setTimeout(resolve, pauseMs));
66
+ return {
67
+ [Symbol.dispose]: () => {
68
+ // Fire-and-forget cleanup - don't wait for it
69
+ locator
70
+ .evaluate((el) => {
71
+ const prev = el.getAttribute("data-video-prev-style");
72
+ if (typeof prev === "string") {
73
+ el.setAttribute("style", prev);
74
+ el.removeAttribute("data-video-prev-style");
75
+ }
76
+ })
77
+ .catch(() => {
78
+ // Element may be gone or not actionable, ignore
79
+ });
80
+ },
81
+ };
82
+ };
83
+ /**
84
+ * Highlights elements before actions and pauses for video recording.
85
+ * Also pauses after tests complete for better video endings.
86
+ */
87
+ export const videoMode = (options = {}) => {
88
+ const pauseBefore = options.pauseBefore || 1000;
89
+ const pauseAfterTest = options.pauseAfterTest || 3000;
90
+ const highlightStyle = options.highlightStyle || "3px solid gold";
91
+ const skipMethods = options.skipMethods || ["waitFor"];
92
+ const skipStackFrames = options.skipStackFrames || [];
93
+ return {
94
+ name: "video-mode",
95
+ middleware: async ({ locator, method }, next) => {
96
+ const env_1 = { stack: [], error: void 0, hasError: false };
97
+ try {
98
+ if (skipMethods.includes(method))
99
+ return next();
100
+ // Skip if called from internal helpers (navigation, login flows etc)
101
+ if (skipStackFrames.length > 0) {
102
+ const stack = new Error().stack || "";
103
+ if (skipStackFrames.some((frame) => stack.includes(frame)))
104
+ return next();
105
+ }
106
+ const _ = __addDisposableResource(env_1, await setupHighlight(locator, highlightStyle, pauseBefore), false);
107
+ return await next();
108
+ }
109
+ catch (e_1) {
110
+ env_1.error = e_1;
111
+ env_1.hasError = true;
112
+ }
113
+ finally {
114
+ __disposeResources(env_1);
115
+ }
116
+ },
117
+ testLifecycle: (emitter) => {
118
+ return emitter.on("afterTest", async ({ testInfo }) => {
119
+ await new Promise((resolve) => setTimeout(resolve, pauseAfterTest));
120
+ console.log(`video will be written to ${testInfo.outputDir}/video.webm`);
121
+ });
122
+ },
123
+ };
124
+ };
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "middlewright",
3
+ "version": "0.1.0",
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
+ "keywords": [
6
+ "playwright",
7
+ "plugin",
8
+ "middleware",
9
+ "testing",
10
+ "e2e",
11
+ "spinner",
12
+ "flaky-tests"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/iterate/middlewright.git"
17
+ },
18
+ "license": "MIT",
19
+ "type": "module",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "default": "./dist/index.js"
24
+ }
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "src"
29
+ ],
30
+ "dependencies": {
31
+ "dedent": "^1.7.0",
32
+ "emittery": "^1.2.0"
33
+ },
34
+ "devDependencies": {
35
+ "@playwright/test": "^1.57.0",
36
+ "@types/node": "^24.10.9",
37
+ "publint": "^0.3.14",
38
+ "typescript": "^5.9.3"
39
+ },
40
+ "peerDependencies": {
41
+ "@playwright/test": ">=1.49.0"
42
+ },
43
+ "engines": {
44
+ "node": ">=20"
45
+ },
46
+ "scripts": {
47
+ "build": "tsc -p tsconfig.build.json",
48
+ "typecheck": "tsc --noEmit",
49
+ "test": "playwright test"
50
+ }
51
+ }
package/src/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ export {
2
+ addPlugins,
3
+ adjustError,
4
+ oneArgMethods,
5
+ overrideableMethods,
6
+ type Plugin,
7
+ type ActionContext,
8
+ type ActionMiddleware,
9
+ type NextFn,
10
+ type TestLifecycleEvents,
11
+ type LocatorWithOriginal,
12
+ type OverrideableMethod,
13
+ type OneArgMethod,
14
+ } from "./plugin-system.ts";
15
+
16
+ export * from "./plugins/index.ts";
@@ -0,0 +1,292 @@
1
+ /**
2
+ * middlewright plugin system.
3
+ *
4
+ * Extracted from the iterate monorepo's internal Playwright test
5
+ * infrastructure (github.com/iterate/iterate, private). Modifications from the
6
+ * original: playwright-core internals are resolved relative to
7
+ * `@playwright/test` so this works under pnpm's strict node_modules layout,
8
+ * and locator actions on pages that never had plugins added fall through to
9
+ * the original implementation instead of throwing.
10
+ */
11
+ import { createRequire } from "node:module";
12
+ import * as path from "node:path";
13
+ import Emittery from "emittery";
14
+ import type { Locator, Page, TestInfo } from "@playwright/test";
15
+
16
+ const require = createRequire(import.meta.url);
17
+
18
+ // Methods that take an extra argument before options (e.g., fill(value, options))
19
+ const oneArgMethods = ["fill", "type", "press"] as const;
20
+ type OneArgMethod = (typeof oneArgMethods)[number];
21
+
22
+ const overrideableMethods = [
23
+ "click",
24
+ "waitFor",
25
+ "clear",
26
+ "dblclick",
27
+ "blur",
28
+ "focus",
29
+ "hover",
30
+ ...oneArgMethods,
31
+ ] satisfies (keyof Locator)[];
32
+ type OverrideableMethod = (typeof overrideableMethods)[number];
33
+
34
+ export type LocatorWithOriginal = Locator & {
35
+ [K in OverrideableMethod as `${K}_original`]: Locator[K];
36
+ };
37
+
38
+ /**
39
+ * Append info to an error message and clean up stack trace.
40
+ * @param error - The error to modify
41
+ * @param info - Lines to append to the error message
42
+ * @param filterFile - Filename to remove from stack trace (e.g., "my-plugin.ts")
43
+ * @param color - ANSI color code (default: 33 = yellow). Use 31 for red.
44
+ */
45
+ export const adjustError = (
46
+ error: Error,
47
+ info: string[],
48
+ filterFile?: string,
49
+ { color = 33 } = {},
50
+ ) => {
51
+ if (!error?.message) return;
52
+
53
+ Object.assign(error, { originalMessage: error.message, originalStack: error.stack });
54
+
55
+ if (info.length > 0) {
56
+ const infoBlock = info.map((line) => ` ${line}`).join("\n");
57
+ error.message = `${error.message}\n\x1b[${color}m${infoBlock}\x1b[0m\n`;
58
+ }
59
+
60
+ if (filterFile && error.stack) {
61
+ error.stack = error.stack
62
+ .split("\n")
63
+ .filter((line) => !line.includes(filterFile))
64
+ .join("\n");
65
+ }
66
+ };
67
+
68
+ export type ActionContext = {
69
+ locator: LocatorWithOriginal;
70
+ method: OverrideableMethod;
71
+ args: unknown[];
72
+ page: Page;
73
+ testInfo: TestInfo;
74
+ };
75
+
76
+ /** Function that calls the next middleware or the original action */
77
+ export type NextFn = () => Promise<unknown>;
78
+
79
+ /** Middleware function - wraps an action, must call next() */
80
+ export type ActionMiddleware = (ctx: ActionContext, next: NextFn) => Promise<unknown>;
81
+
82
+ export type TestLifecycleEvents = {
83
+ beforeTest: { page: Page; testInfo: TestInfo };
84
+ afterTest: { page: Page; testInfo: TestInfo };
85
+ };
86
+
87
+ export type Plugin = {
88
+ name: string;
89
+ /** Middleware to wrap locator actions. Called in registration order. */
90
+ middleware?: ActionMiddleware;
91
+ /** Subscribe to test lifecycle events */
92
+ testLifecycle?: (emitter: Emittery<TestLifecycleEvents>) => void | (() => void);
93
+ };
94
+
95
+ const PLUGIN_STATE = Symbol("playwrightPluginState");
96
+
97
+ type PluginState = {
98
+ actionMiddlewares: ActionMiddleware[];
99
+ lifecycleEmitter: Emittery<TestLifecycleEvents>;
100
+ lifecycleCleanups: (() => void)[];
101
+ testInfo: TestInfo;
102
+ };
103
+
104
+ type PageWithPlugins = Page & {
105
+ [PLUGIN_STATE]: PluginState;
106
+ [Symbol.asyncDispose]: () => Promise<void>;
107
+ };
108
+
109
+ // Track if Locator prototype has been patched
110
+ let prototypePatched = false;
111
+
112
+ /** Get plugin state from a page */
113
+ const getPluginState = (page: Page): PluginState | undefined => {
114
+ return (page as PageWithPlugins)[PLUGIN_STATE];
115
+ };
116
+
117
+ /**
118
+ * Add plugins to a page. Returns a disposable page that cleans up on dispose.
119
+ *
120
+ * @example
121
+ * ```ts
122
+ * await using page = await addPlugins({
123
+ * page: basePage,
124
+ * testInfo,
125
+ * plugins: [
126
+ * hydrationWaiter(),
127
+ * spinnerWaiter(),
128
+ * videoMode(),
129
+ * ]
130
+ * });
131
+ * ```
132
+ */
133
+ export const addPlugins = async (params: {
134
+ page: Page;
135
+ testInfo: TestInfo;
136
+ plugins: (Plugin | false | null | undefined)[];
137
+ boxedStackPrefixes?: (defaults: string[]) => string[];
138
+ }): Promise<PageWithPlugins> => {
139
+ const { page, testInfo, plugins, boxedStackPrefixes } = params;
140
+ // Patch Locator prototype once globally
141
+ patchLocatorPrototype(page, boxedStackPrefixes);
142
+
143
+ // Initialize state on page
144
+ const state: PluginState = {
145
+ actionMiddlewares: [],
146
+ lifecycleEmitter: new Emittery(),
147
+ lifecycleCleanups: [],
148
+ testInfo,
149
+ };
150
+
151
+ // Register plugins
152
+ for (const plugin of plugins) {
153
+ if (!plugin) continue;
154
+
155
+ if (plugin.middleware) {
156
+ state.actionMiddlewares.push(plugin.middleware);
157
+ }
158
+
159
+ if (plugin.testLifecycle) {
160
+ const cleanup = plugin.testLifecycle(state.lifecycleEmitter);
161
+ if (cleanup) state.lifecycleCleanups.push(cleanup);
162
+ }
163
+ }
164
+
165
+ const pageWithPlugins = page as PageWithPlugins;
166
+ pageWithPlugins[PLUGIN_STATE] = state;
167
+
168
+ // Emit beforeTest
169
+ await state.lifecycleEmitter.emitSerial("beforeTest", { page, testInfo });
170
+
171
+ // Add async dispose
172
+ pageWithPlugins[Symbol.asyncDispose] = async () => {
173
+ await state.lifecycleEmitter.emitSerial("afterTest", { page, testInfo });
174
+ state.lifecycleCleanups.forEach((cleanup) => cleanup());
175
+ };
176
+
177
+ return pageWithPlugins;
178
+ };
179
+
180
+ /**
181
+ * Resolve playwright-core starting from @playwright/test. Under pnpm's strict
182
+ * layout, playwright-core isn't reachable from this package - only via the
183
+ * host project's @playwright/test.
184
+ */
185
+ const resolvePlaywrightInternals = () => {
186
+ const testPkg = require.resolve("@playwright/test/package.json");
187
+ const requireFromTest = createRequire(testPkg);
188
+ const playwrightPkg = requireFromTest.resolve("playwright/package.json");
189
+ const corePkg = createRequire(playwrightPkg).resolve("playwright-core/package.json");
190
+ return { testPkg, playwrightPkg, corePkg };
191
+ };
192
+
193
+ /**
194
+ * setBoxedStackPrefixes is an internal playwright-core API
195
+ * (https://github.com/microsoft/playwright/issues/38818 asks for it to be made
196
+ * official) and has already moved once: playwright-core <= 1.59 exposed it
197
+ * from lib/utils, 1.60+ from the utils namespace of lib/coreBundle. Returns
198
+ * null when it can't be found - everything works without it, you just see
199
+ * plugin frames in stack traces.
200
+ */
201
+ const loadSetBoxedStackPrefixes = (corePkg: string): ((prefixes: string[]) => void) | null => {
202
+ const coreRequire = createRequire(corePkg);
203
+ for (const load of [
204
+ () => coreRequire("./lib/utils").setBoxedStackPrefixes,
205
+ () => coreRequire("./lib/coreBundle").utils.setBoxedStackPrefixes,
206
+ ]) {
207
+ try {
208
+ const fn = load();
209
+ if (typeof fn === "function") return fn;
210
+ } catch {
211
+ // try the next layout
212
+ }
213
+ }
214
+ return null;
215
+ };
216
+
217
+ /** Patch Locator prototype to run middleware. Safe to call multiple times. */
218
+ const patchLocatorPrototype = (
219
+ page: Page,
220
+ boxedStackPrefixes?: (defaults: string[]) => string[],
221
+ ) => {
222
+ if (prototypePatched) return;
223
+ prototypePatched = true;
224
+
225
+ const internals = resolvePlaywrightInternals();
226
+
227
+ // Exclude this file from stack traces in Playwright reports
228
+ if (!process.env.PLAYWRIGHT_PLUGIN_DEBUG) {
229
+ const setBoxedStackPrefixes = loadSetBoxedStackPrefixes(internals.corePkg);
230
+ if (setBoxedStackPrefixes) {
231
+ const getPrefixes = boxedStackPrefixes || ((defaults) => defaults);
232
+ const prefixes = getPrefixes([
233
+ path.dirname(internals.testPkg),
234
+ path.dirname(internals.playwrightPkg),
235
+ path.dirname(internals.corePkg),
236
+ import.meta.filename,
237
+ ]);
238
+ setBoxedStackPrefixes(prefixes);
239
+ } else {
240
+ console.warn(
241
+ "[middlewright] could not find setBoxedStackPrefixes in this playwright-core version - " +
242
+ "stack traces in reports will include plugin frames",
243
+ );
244
+ }
245
+ }
246
+
247
+ const dummyLocator = page.locator("body");
248
+ const locatorPrototype = dummyLocator.constructor.prototype;
249
+
250
+ for (const method of overrideableMethods) {
251
+ locatorPrototype[`${method}_original`] = locatorPrototype[method];
252
+
253
+ const value = async function patchedMethod(
254
+ this: LocatorWithOriginal,
255
+ ...args: unknown[]
256
+ ): Promise<unknown> {
257
+ const callOriginal = () => (this[`${method}_original`] as Function)(...args);
258
+
259
+ // Pages that never had plugins added (e.g. a second page in the same
260
+ // worker) fall through to the original implementation.
261
+ const state = getPluginState(this.page());
262
+ if (!state) return callOriginal();
263
+ const actionMiddlewares = state.actionMiddlewares;
264
+
265
+ const ctx: ActionContext = {
266
+ locator: this,
267
+ method,
268
+ args,
269
+ page: this.page(),
270
+ testInfo: state.testInfo,
271
+ };
272
+
273
+ // Build middleware chain - each middleware calls next() to continue
274
+ let index = 0;
275
+ const next: NextFn = async () => {
276
+ if (index < actionMiddlewares.length) {
277
+ const middleware = actionMiddlewares[index++];
278
+ return middleware(ctx, next);
279
+ }
280
+ return callOriginal();
281
+ };
282
+
283
+ return next();
284
+ };
285
+
286
+ Object.defineProperty(locatorPrototype, method, { value });
287
+ }
288
+ };
289
+
290
+ // Re-export types for plugin authors
291
+ export type { OverrideableMethod, OneArgMethod };
292
+ export { oneArgMethods, overrideableMethods };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * hydration-waiter: don't interact with the app before it's interactive.
3
+ *
4
+ * Extracted from the iterate monorepo's internal Playwright test
5
+ * infrastructure (github.com/iterate/iterate, private).
6
+ */
7
+ import type { Plugin, LocatorWithOriginal } from "../plugin-system.ts";
8
+
9
+ export type HydrationWaiterOptions = {
10
+ /** Selector for unhydrated state. Default: '[data-hydrated="false"]' */
11
+ selector?: string;
12
+ /** Timeout for hydration. Default: 10_000 */
13
+ timeout?: number;
14
+ /** Whether to skip this plugin. Default: false */
15
+ disabled?: boolean;
16
+ };
17
+
18
+ /**
19
+ * Waits for the app to be hydrated before any locator action.
20
+ * Looks for `[data-hydrated="false"]` and waits for it to disappear.
21
+ *
22
+ * Your app needs to cooperate: render `data-hydrated="false"` on some element
23
+ * server-side and flip it to "true" (or remove it) once the framework has
24
+ * hydrated. With React, for example, flip it in a top-level component that
25
+ * only runs client-side.
26
+ */
27
+ export const hydrationWaiter = (options: HydrationWaiterOptions = {}): Plugin => {
28
+ const selector = options.selector || '[data-hydrated="false"]';
29
+ const timeout = options.timeout || 10_000;
30
+
31
+ return {
32
+ name: "hydration-waiter",
33
+ middleware: async ({ page }, next) => {
34
+ if (options.disabled) return next();
35
+
36
+ const unhydratedLocator = page.locator(selector) as LocatorWithOriginal;
37
+ const isUnhydrated = await unhydratedLocator.isVisible();
38
+
39
+ if (isUnhydrated) {
40
+ await unhydratedLocator.waitFor_original({ state: "hidden", timeout });
41
+ }
42
+
43
+ return next();
44
+ },
45
+ };
46
+ };
@@ -0,0 +1,11 @@
1
+ export { hydrationWaiter, type HydrationWaiterOptions } from "./hydration-waiter.ts";
2
+ export { videoMode, type VideoModeOptions } from "./video-mode.ts";
3
+ export { spinnerWaiter, type SpinnerWaiterOptions, defaultSelectors } from "./spinner-waiter.ts";
4
+ export { uiErrorReporter, type UIErrorReporterOptions } from "./ui-error-reporter.ts";
5
+ export {
6
+ llmRecover,
7
+ type LlmRecoverOptions,
8
+ type AttemptRecord,
9
+ type RecoveryContext,
10
+ type RequestRecoveryCodeFn,
11
+ } from "./llm-recover.ts";