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.
- package/LICENSE +21 -0
- package/README.md +282 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/plugin-system.d.ts +82 -0
- package/dist/plugin-system.js +201 -0
- package/dist/plugins/hydration-waiter.d.ts +25 -0
- package/dist/plugins/hydration-waiter.js +26 -0
- package/dist/plugins/index.d.ts +5 -0
- package/dist/plugins/index.js +5 -0
- package/dist/plugins/llm-recover.d.ts +40 -0
- package/dist/plugins/llm-recover.js +345 -0
- package/dist/plugins/spinner-waiter.d.ts +35 -0
- package/dist/plugins/spinner-waiter.js +138 -0
- package/dist/plugins/ui-error-reporter.d.ts +19 -0
- package/dist/plugins/ui-error-reporter.js +25 -0
- package/dist/plugins/video-mode.d.ts +22 -0
- package/dist/plugins/video-mode.js +124 -0
- package/package.json +51 -0
- package/src/index.ts +16 -0
- package/src/plugin-system.ts +292 -0
- package/src/plugins/hydration-waiter.ts +46 -0
- package/src/plugins/index.ts +11 -0
- package/src/plugins/llm-recover.ts +453 -0
- package/src/plugins/spinner-waiter.ts +175 -0
- package/src/plugins/ui-error-reporter.ts +44 -0
- package/src/plugins/video-mode.ts +97 -0
|
@@ -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";
|