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,40 @@
1
+ import { expect as playwrightExpect } from "@playwright/test";
2
+ import type { Plugin } from "../plugin-system.ts";
3
+ export type LlmRecoverOptions = {
4
+ /** Max recovery attempts per failing action. Default: 3 */
5
+ maxAttempts?: number;
6
+ /** Anthropic model to use. Default: "claude-opus-4-8" */
7
+ model?: string;
8
+ /** Max tokens for LLM response. Default: 4096 */
9
+ maxTokens?: number;
10
+ /** Max HTML length to send. Default: 50_000 */
11
+ maxHtmlLength?: number;
12
+ /** Anthropic API key. Default: process.env.ANTHROPIC_API_KEY */
13
+ apiKey?: string;
14
+ /** Override the LLM call for testing. Return JS function body string, or null to rethrow. */
15
+ requestRecoveryCode?: RequestRecoveryCodeFn;
16
+ expect?: typeof playwrightExpect;
17
+ };
18
+ export type AttemptRecord = {
19
+ code: string;
20
+ error: string;
21
+ durationMs: number;
22
+ };
23
+ export type RecoveryContext = {
24
+ testTitle: string;
25
+ testFile: string;
26
+ failingLine: string;
27
+ locatorString: string;
28
+ method: string;
29
+ args: string;
30
+ errorMessage: string;
31
+ errorStack: string;
32
+ screenshotBase64: string | null;
33
+ html: string | null;
34
+ accessibilitySnapshotYaml: string | null;
35
+ };
36
+ export type RequestRecoveryCodeFn = (context: RecoveryContext, attemptHistory: AttemptRecord[]) => Promise<{
37
+ code: string | null;
38
+ description: string;
39
+ } | null>;
40
+ export declare const llmRecover: (options?: LlmRecoverOptions) => Plugin;
@@ -0,0 +1,345 @@
1
+ /**
2
+ * llm-recover: when a locator action fails, ask an LLM to write recovery code.
3
+ *
4
+ * Extracted from the iterate monorepo's internal Playwright test
5
+ * infrastructure (github.com/iterate/iterate, private). Modifications from the
6
+ * original: the Doppler secrets fallback for the API key was removed (pass
7
+ * `apiKey` or set ANTHROPIC_API_KEY), and the default model was bumped to
8
+ * claude-opus-4-8.
9
+ */
10
+ import * as fs from "node:fs";
11
+ import * as path from "node:path";
12
+ import dedent from "dedent";
13
+ import { expect as playwrightExpect } from "@playwright/test";
14
+ import { adjustError } from "../plugin-system.js";
15
+ /**
16
+ * LLM-powered recovery plugin. When a locator action fails, captures context
17
+ * (screenshot, error, test info) and asks an LLM to generate a JS recovery
18
+ * function. The function is eval'd with { page, locator, error } in scope.
19
+ *
20
+ * This runs arbitrary LLM-generated code in your test process. Gate it behind
21
+ * an explicit opt-in env var (e.g. only add the plugin when LLM_RECOVER is
22
+ * set) rather than enabling it unconditionally.
23
+ */
24
+ // Re-entrancy guard: don't try to recover failures that happen inside recovery code.
25
+ // Uses a WeakSet of pages currently being recovered.
26
+ const pagesInRecovery = new WeakSet();
27
+ export const llmRecover = (options = {}) => {
28
+ const expect = options.expect || playwrightExpect;
29
+ const maxAttempts = options.maxAttempts || 3;
30
+ const requestRecovery = options.requestRecoveryCode || createAnthropicProvider(options);
31
+ return {
32
+ name: "llm-recover",
33
+ middleware: async (ctx, next) => {
34
+ // Skip if we're already inside a recovery attempt for this page
35
+ if (pagesInRecovery.has(ctx.page))
36
+ return next();
37
+ try {
38
+ return await next();
39
+ }
40
+ catch (originalError) {
41
+ if (!(originalError instanceof Error))
42
+ throw originalError;
43
+ const { page, locator, method, testInfo } = ctx;
44
+ const attemptHistory = [];
45
+ // Gather context once (screenshot + HTML don't change between LLM retries
46
+ // unless recovery code navigates, but that's fine — we capture at failure time)
47
+ const context = await gatherContext(ctx, originalError, options);
48
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
49
+ let recoveryResult;
50
+ try {
51
+ recoveryResult = await requestRecovery(context, attemptHistory);
52
+ }
53
+ catch (llmError) {
54
+ // LLM call itself failed — don't retry, just throw original
55
+ adjustError(originalError, [`[llm-recover] LLM call failed: ${String(llmError)}`], import.meta.filename);
56
+ throw originalError;
57
+ }
58
+ const code = recoveryResult?.code;
59
+ // null/empty = rethrow as-is
60
+ if (!code?.trim()) {
61
+ adjustError(originalError, [`[llm-recover] LLM call returned no code: ${recoveryResult?.description}`], import.meta.filename);
62
+ throw originalError;
63
+ }
64
+ const start = Date.now();
65
+ try {
66
+ let recoveryFn;
67
+ // eval is intentional: the LLM writes a recovery function, we run it
68
+ // eslint-disable-next-line no-eval
69
+ eval(`recoveryFn = ${code.trim()}`);
70
+ if (typeof recoveryFn !== "function") {
71
+ adjustError(originalError, [`[llm-recover] Recovery function is not a function: ${code}`], import.meta.filename);
72
+ throw originalError;
73
+ }
74
+ // Mark page as in-recovery so nested locator failures don't re-trigger LLM
75
+ pagesInRecovery.add(page);
76
+ try {
77
+ await recoveryFn({ page, locator, error: originalError });
78
+ }
79
+ finally {
80
+ pagesInRecovery.delete(page);
81
+ }
82
+ // Write artifact
83
+ const artifact = writeArtifact(testInfo, {
84
+ test: context.testTitle,
85
+ file: context.testFile,
86
+ failingLine: context.failingLine,
87
+ locator: context.locatorString,
88
+ method,
89
+ originalError: originalError.message,
90
+ attempts: [
91
+ ...attemptHistory,
92
+ { code, error: "(success)", durationMs: Date.now() - start },
93
+ ],
94
+ recovered: true,
95
+ });
96
+ // Recovery succeeded — record a soft error so the test is marked
97
+ // failed but continues executing. We push directly to testInfo.errors
98
+ // because the global `expect.soft` only works inside a test context.
99
+ const description = code.split("\n").slice(1, -1).join("\n");
100
+ const message = [
101
+ `[llm-recover] ${method} failed and was recovered by LLM.`,
102
+ `Original error:\n ${originalError.message.split("\n")[0]}`,
103
+ `Original locator:\n await page.${locator}.${method}()`,
104
+ `Recovery code:\n${description}`,
105
+ `Explanation::\n ${recoveryResult?.description}`,
106
+ `Artifact: ${artifact}`,
107
+ ].join("\n");
108
+ expect.soft("passed–after-llm-recovery", message).toBe("passed-in-the-first-place");
109
+ return; // recovery complete
110
+ }
111
+ catch (recoveryError) {
112
+ const errorMsg = recoveryError instanceof Error ? recoveryError.message : String(recoveryError);
113
+ attemptHistory.push({
114
+ code,
115
+ error: errorMsg,
116
+ durationMs: Date.now() - start,
117
+ });
118
+ // loop continues
119
+ }
120
+ }
121
+ // Exhausted all attempts — write artifact and rethrow
122
+ writeArtifact(ctx.testInfo, {
123
+ test: context.testTitle,
124
+ file: context.testFile,
125
+ failingLine: context.failingLine,
126
+ locator: context.locatorString,
127
+ method,
128
+ originalError: originalError.message,
129
+ attempts: attemptHistory,
130
+ recovered: false,
131
+ });
132
+ const summary = attemptHistory.map((a, i) => ` Attempt ${i + 1}: ${a.error}`).join("\n");
133
+ adjustError(originalError, [`[llm-recover] ${attemptHistory.length} recovery attempt(s) failed:`, summary], import.meta.filename);
134
+ throw originalError;
135
+ }
136
+ },
137
+ };
138
+ };
139
+ async function gatherContext(ctx, error, options) {
140
+ const { page, locator, method, args, testInfo } = ctx;
141
+ const maxHtmlLength = options.maxHtmlLength || 50_000;
142
+ // Screenshot
143
+ const screenshotBase64 = await page
144
+ .screenshot({ type: "png" })
145
+ .then((buffer) => buffer.toString("base64"))
146
+ .catch(() => null);
147
+ // HTML
148
+ const html = await page
149
+ .content()
150
+ .then((content) => {
151
+ if (content.length < maxHtmlLength)
152
+ return content;
153
+ return content.slice(0, maxHtmlLength) + "\n<!-- truncated -->";
154
+ })
155
+ .catch(() => "<!-- HTML unavailable -->");
156
+ // Accessibility snapshot (YAML)
157
+ const accessibilitySnapshotYaml = await page
158
+ .locator("body")
159
+ .ariaSnapshot()
160
+ .catch((e) => `<!-- accessibility snapshot unavailable (${e}) -->`);
161
+ // Parse failing line from stack
162
+ const failingLine = parseFailingLine(error.stack || "");
163
+ return {
164
+ testTitle: testInfo?.titlePath.join(" > ") || "(unknown test)",
165
+ testFile: testInfo?.file || "(unknown file)",
166
+ failingLine,
167
+ locatorString: locator.toString(),
168
+ method,
169
+ args: JSON.stringify(args),
170
+ errorMessage: error.message,
171
+ errorStack: error.stack || "",
172
+ screenshotBase64,
173
+ html,
174
+ accessibilitySnapshotYaml,
175
+ };
176
+ }
177
+ function parseFailingLine(stack) {
178
+ const lines = stack.split("\n");
179
+ for (const line of lines) {
180
+ // Look for spec file frames, skip node_modules and plugin files
181
+ if (line.includes(".spec.ts") && !line.includes("node_modules") && !line.includes("plugins/")) {
182
+ return line.trim();
183
+ }
184
+ }
185
+ // Fallback: first frame that's not node_modules
186
+ for (const line of lines) {
187
+ if (line.trim().startsWith("at ") && !line.includes("node_modules")) {
188
+ return line.trim();
189
+ }
190
+ }
191
+ return "(unknown)";
192
+ }
193
+ function writeArtifact(testInfo, data) {
194
+ if (!testInfo)
195
+ return "(no-test-info)";
196
+ const dir = path.join(testInfo.outputDir, "llm-recover");
197
+ fs.mkdirSync(dir, { recursive: true });
198
+ const filename = `attempt-${Date.now()}.json`;
199
+ const filepath = path.join(dir, filename);
200
+ fs.writeFileSync(filepath, JSON.stringify(data, null, 2));
201
+ return filepath;
202
+ }
203
+ // --- Anthropic provider ---
204
+ const SYSTEM_PROMPT = dedent `
205
+ You are a Playwright test recovery assistant. A locator action failed during a test. You will be given:
206
+
207
+ - The test name and file
208
+ - The failing line of code
209
+ - The locator and method that failed
210
+ - The error message and stack trace
211
+ - A screenshot of the page at the time of failure
212
+ - An accessibility snapshot in YAML format (preferred source for element names/roles/states)
213
+ - Optionally, the page HTML
214
+
215
+ Your job: respond with a JavaScript function body, wrapped by \`<code>...\<\/code>\` tags. It must be an async function named \`recover\` that takes a single context argument:
216
+
217
+ After the function definition, write a brief description of why the given recovery code was chosen. Sacrifice grammar for concision. Example:
218
+
219
+ ---
220
+
221
+ <code>
222
+ async function recover({ page, locator, error }){
223
+ await page.getByText("Create an account").click();
224
+ }
225
+ </code>
226
+
227
+ The original locator looked for the text "Create account", but the DOM shows "Create an account" instead.
228
+
229
+ ---
230
+
231
+ Where the context passed in has the following type:
232
+
233
+ \`\`\`ts
234
+ type RecoveryContext = {
235
+ /** The Playwright Page object, used by the test */
236
+ page: import("@playwright/test").Page;
237
+ /** The Locator that failed had a failing action */
238
+ locator: import("@playwright/test").Locator;
239
+ /** The original error thrown by the failing action */
240
+ error: Error;
241
+ };
242
+ \`\`\`
243
+
244
+ If the failure is UNRECOVERABLE (e.g. the test logic is fundamentally wrong, not a locator/timing issue), simply omit the <code>...</code> tags and return an explanation of why it's unrecoverable:
245
+
246
+ ---
247
+
248
+ Not recoverable: account creation is explicitly disabled in this UI.
249
+
250
+ ---
251
+
252
+ If the failure is RECOVERABLE, write code that:
253
+ 1. Performs any necessary setup (dismiss modals, wait for elements, scroll, etc.)
254
+ 2. Completes the action that the original locator was trying to do
255
+
256
+ Common recovery patterns:
257
+ - Wrong/stale locator: find the correct element and interact with it
258
+ - Timing issue: wait for the right condition then retry
259
+ - Modal/overlay blocking: dismiss it, then retry
260
+ - Element not in viewport: scroll to it, then interact
261
+
262
+ Keep the code minimal. Do not add unnecessary waits or retries.
263
+ `;
264
+ function createAnthropicProvider(options) {
265
+ const apiKey = options.apiKey || process.env.ANTHROPIC_API_KEY;
266
+ if (!apiKey) {
267
+ throw new Error("[llm-recover] An Anthropic API key is required: pass `apiKey` or set the ANTHROPIC_API_KEY environment variable");
268
+ }
269
+ const model = options.model || "claude-opus-4-8";
270
+ const maxTokens = options.maxTokens || 4096;
271
+ return async (context, attemptHistory) => {
272
+ const userContent = [];
273
+ // Text context
274
+ const textParts = [
275
+ `**Test:** ${context.testTitle}`,
276
+ `**File:** ${context.testFile}`,
277
+ `**Failing line:** ${context.failingLine}`,
278
+ `**Locator:** ${context.locatorString}`,
279
+ `**Method:** ${context.method}(${context.args})`,
280
+ `**Error:** ${context.errorMessage}`,
281
+ `**Stack:**\n\`\`\`\n${context.errorStack.slice(0, 2000)}\n\`\`\``,
282
+ `**Accessibility snapshot (YAML):**\n\`\`\`yaml\n${context.accessibilitySnapshotYaml}\n\`\`\``,
283
+ ];
284
+ if (context.html) {
285
+ const suffix = context.html.endsWith("<!-- truncated -->") ? " (truncated)" : "";
286
+ textParts.push(`**Page HTML${suffix}:**\n\`\`\`html\n${context.html}\n\`\`\``);
287
+ }
288
+ if (attemptHistory.length > 0) {
289
+ textParts.push(`**Previous recovery attempts (all failed):**`);
290
+ for (const [i, attempt] of attemptHistory.entries()) {
291
+ textParts.push(`Attempt ${i + 1} (${attempt.durationMs}ms):\nCode:\n\`\`\`js\n${attempt.code}\n\`\`\`\nError: ${attempt.error}`);
292
+ }
293
+ textParts.push(`Try a DIFFERENT approach than what was already attempted.`);
294
+ }
295
+ userContent.push({ type: "text", text: textParts.join("\n\n") });
296
+ // Screenshot
297
+ if (context.screenshotBase64) {
298
+ userContent.push({
299
+ type: "image",
300
+ source: {
301
+ type: "base64",
302
+ media_type: "image/png",
303
+ data: context.screenshotBase64,
304
+ },
305
+ });
306
+ }
307
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
308
+ method: "POST",
309
+ headers: {
310
+ "x-api-key": apiKey,
311
+ "anthropic-version": "2023-06-01",
312
+ "content-type": "application/json",
313
+ },
314
+ body: JSON.stringify({
315
+ model,
316
+ max_tokens: maxTokens,
317
+ system: SYSTEM_PROMPT,
318
+ messages: [{ role: "user", content: userContent }],
319
+ }),
320
+ });
321
+ if (!response.ok) {
322
+ const body = await response.text().catch(() => "(unreadable)");
323
+ throw new Error(`Anthropic API error ${response.status}: ${body}`);
324
+ }
325
+ const data = (await response.json());
326
+ const textBlock = data.content?.find((b) => b.type === "text");
327
+ if (!textBlock?.text)
328
+ return null;
329
+ return extractCode(textBlock.text);
330
+ };
331
+ }
332
+ /** Extract code from LLM response. Handles <code>...</code> tags and markdown fences. */
333
+ function extractCode(text) {
334
+ text = text.trim();
335
+ const codeStart = text.indexOf("<code>");
336
+ const codeEnd = text.indexOf("</code>");
337
+ if (codeStart === -1 || codeEnd === -1) {
338
+ return { code: null, description: text };
339
+ }
340
+ const code = text.slice(codeStart + 6, codeEnd).trim() || null;
341
+ const description = [text.slice(0, codeStart).trim(), text.slice(codeEnd + 7).trim()]
342
+ .filter(Boolean)
343
+ .join("\n\n");
344
+ return { code, description };
345
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * spinner-waiter: the plugin Playwright wouldn't build.
3
+ *
4
+ * Extracted from the iterate monorepo's internal Playwright test
5
+ * infrastructure (github.com/iterate/iterate, private). This implements the
6
+ * feature requested in https://github.com/microsoft/playwright/issues/16007 -
7
+ * a different effective action timeout while the app is visibly loading.
8
+ */
9
+ import { AsyncLocalStorage } from "node:async_hooks";
10
+ import type { Plugin } from "../plugin-system.ts";
11
+ export type SpinnerWaiterOptions = {
12
+ /** Selectors that indicate loading state */
13
+ spinnerSelectors?: string[];
14
+ /** Max time to wait for spinners (ms). Default: 30_000 */
15
+ spinnerTimeout?: number;
16
+ /** Whether to skip spinner checking. Default: false */
17
+ disabled?: boolean;
18
+ /** Debug logging function */
19
+ log?: (message: string) => void;
20
+ };
21
+ declare const defaultSelectors: string[];
22
+ /**
23
+ * Creates a spinner-waiter plugin.
24
+ *
25
+ * Runtime settings can be overridden per-test via
26
+ * `spinnerWaiter.settings.enterWith({...})`, or for a single call via
27
+ * `spinnerWaiter.settings.run({...}, () => locator.click())`.
28
+ */
29
+ export declare const spinnerWaiter: ((options?: SpinnerWaiterOptions) => Plugin) & {
30
+ /** Runtime settings override via AsyncLocalStorage */
31
+ settings: AsyncLocalStorage<Partial<SpinnerWaiterOptions>>;
32
+ /** Default settings values */
33
+ defaults: Required<SpinnerWaiterOptions>;
34
+ };
35
+ export { defaultSelectors };
@@ -0,0 +1,138 @@
1
+ /**
2
+ * spinner-waiter: the plugin Playwright wouldn't build.
3
+ *
4
+ * Extracted from the iterate monorepo's internal Playwright test
5
+ * infrastructure (github.com/iterate/iterate, private). This implements the
6
+ * feature requested in https://github.com/microsoft/playwright/issues/16007 -
7
+ * a different effective action timeout while the app is visibly loading.
8
+ */
9
+ import { AsyncLocalStorage } from "node:async_hooks";
10
+ import { adjustError } from "../plugin-system.js";
11
+ const defaultSelectors = [
12
+ `[aria-label="Loading"]`,
13
+ `[data-spinner='true']`,
14
+ `:text-matches("(loading|pending|creating|verifying|starting|processing|syncing)\\.\\.\\.$", "i")`,
15
+ ];
16
+ const defaults = {
17
+ spinnerSelectors: defaultSelectors,
18
+ spinnerTimeout: 30_000,
19
+ disabled: false,
20
+ log: () => { },
21
+ };
22
+ /** AsyncLocalStorage for runtime settings override */
23
+ const settingsStorage = new AsyncLocalStorage();
24
+ const getSettings = (baseOptions = {}) => {
25
+ const runtimeOverrides = settingsStorage.getStore() || {};
26
+ const result = { ...defaults, ...baseOptions, ...runtimeOverrides };
27
+ if (result.spinnerTimeout <= 3000) {
28
+ throw new Error("spinnerTimeout must be greater than 3000ms");
29
+ }
30
+ return result;
31
+ };
32
+ const suggestSpinnerMessage = (spinnerLocator) => [
33
+ `If this is a slow operation, update the product code to add a spinner while it's running.`,
34
+ `This will improve the user experience and buy you more time for this assertion.`,
35
+ `To add a spinner, show any UI element matching this locator:`,
36
+ ` ${spinnerLocator}`,
37
+ ];
38
+ /**
39
+ * Creates a spinner-waiter plugin.
40
+ *
41
+ * Runtime settings can be overridden per-test via
42
+ * `spinnerWaiter.settings.enterWith({...})`, or for a single call via
43
+ * `spinnerWaiter.settings.run({...}, () => locator.click())`.
44
+ */
45
+ export const spinnerWaiter = Object.assign((options = {}) => {
46
+ return {
47
+ name: "spinner-waiter",
48
+ middleware: async ({ locator, method, page }, next) => {
49
+ const settings = getSettings(options);
50
+ if (settings.disabled)
51
+ return next();
52
+ const start = Date.now();
53
+ 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`);
58
+ return next();
59
+ }
60
+ // Check for spinner
61
+ const spinnerSelector = settings.spinnerSelectors.join(",");
62
+ const spinnerLocator = page.locator(spinnerSelector);
63
+ const spinnerVisible = await spinnerLocator.isVisible();
64
+ if (!spinnerVisible) {
65
+ // No spinner - call action, suggest adding one if it fails
66
+ settings.log(`${locator} not visible, no spinner, proceeding anyway`);
67
+ try {
68
+ return await next();
69
+ }
70
+ catch (error) {
71
+ adjustError(error, suggestSpinnerMessage(spinnerLocator), "spinner-waiter.ts");
72
+ throw error;
73
+ }
74
+ }
75
+ settings.log(`Spinner visible, waiting up to ${settings.spinnerTimeout - 2000}ms for ${locator}`);
76
+ // Spinner is visible — wait for the element, but bail early if the spinner
77
+ // disappears (the loading operation finished without producing the expected element).
78
+ const waitResult = await waitForVisibleWhileSpinning(locator, spinnerLocator, {
79
+ timeout: settings.spinnerTimeout - 2000,
80
+ });
81
+ if (waitResult === "appeared") {
82
+ settings.log(`${locator} appeared after waiting`);
83
+ return next();
84
+ }
85
+ if (waitResult === "spinner-gone") {
86
+ settings.log(`Spinner disappeared but element not visible — loading finished without expected result`);
87
+ }
88
+ else {
89
+ settings.log(`Spinner still visible after ${settings.spinnerTimeout}ms, UI likely stuck`);
90
+ }
91
+ // Call action anyway (will likely fail), adjust error message
92
+ try {
93
+ return await next();
94
+ }
95
+ catch (error) {
96
+ const message = waitResult === "spinner-gone"
97
+ ? `Loading finished (spinner disappeared after ${Date.now() - start}ms) but the expected element never appeared.`
98
+ : `Spinner was still visible after ${settings.spinnerTimeout}ms, the UI is likely stuck.`;
99
+ adjustError(error, [message], "spinner-waiter.ts");
100
+ throw error;
101
+ }
102
+ },
103
+ };
104
+ }, {
105
+ /** Runtime settings override via AsyncLocalStorage */
106
+ settings: settingsStorage,
107
+ /** Default settings values */
108
+ defaults,
109
+ });
110
+ async function waitForVisible(locator, { timeout = 1000 } = {}) {
111
+ const start = Date.now();
112
+ while (Date.now() - start < timeout) {
113
+ if (await locator.isVisible())
114
+ return true;
115
+ await new Promise((resolve) => setTimeout(resolve, 100));
116
+ }
117
+ return false;
118
+ }
119
+ /**
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
122
+ * without the target, or "timeout" if spinner was still visible at deadline.
123
+ */
124
+ async function waitForVisibleWhileSpinning(target, spinner, { timeout = 1000 } = {}) {
125
+ const start = Date.now();
126
+ // Give the spinner a grace period before checking — it may flicker during transitions
127
+ const spinnerGracePeriodMs = 3000;
128
+ while (Date.now() - start < timeout) {
129
+ if (await target.isVisible())
130
+ return "appeared";
131
+ const elapsed = Date.now() - start;
132
+ if (elapsed > spinnerGracePeriodMs && !(await spinner.isVisible()))
133
+ return "spinner-gone";
134
+ await new Promise((resolve) => setTimeout(resolve, 250));
135
+ }
136
+ return "timeout";
137
+ }
138
+ export { defaultSelectors };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * ui-error-reporter: when an action fails, tell me what the app was screaming about.
3
+ *
4
+ * Extracted from the iterate monorepo's internal Playwright test
5
+ * infrastructure (github.com/iterate/iterate, private).
6
+ */
7
+ import type { Plugin } from "../plugin-system.ts";
8
+ export type UIErrorReporterOptions = {
9
+ /**
10
+ * Selector for error UI. Default: '[data-type="error"]' (which matches
11
+ * sonner error toasts, among others).
12
+ */
13
+ selector?: string;
14
+ };
15
+ /**
16
+ * When a locator action fails, checks for visible error toasts/other UI and appends
17
+ * their text to the error message for easier debugging.
18
+ */
19
+ export declare const uiErrorReporter: (options?: UIErrorReporterOptions) => Plugin;
@@ -0,0 +1,25 @@
1
+ import { adjustError } from "../plugin-system.js";
2
+ /**
3
+ * When a locator action fails, checks for visible error toasts/other UI and appends
4
+ * their text to the error message for easier debugging.
5
+ */
6
+ export const uiErrorReporter = (options = {}) => {
7
+ const selector = options.selector || '[data-type="error"]';
8
+ return {
9
+ name: "ui-error-reporter",
10
+ middleware: async ({ page }, next) => {
11
+ try {
12
+ return await next();
13
+ }
14
+ catch (error) {
15
+ const getToastErrors = () => page.locator(selector).allTextContents();
16
+ const messages = await getToastErrors().catch(() => []);
17
+ if (messages.length > 0 && error instanceof Error) {
18
+ const info = [`Error UI visible:`, ...messages.map((m) => JSON.stringify(m.trim()))];
19
+ adjustError(error, info, import.meta.filename, { color: 31 });
20
+ }
21
+ throw error;
22
+ }
23
+ },
24
+ };
25
+ };
@@ -0,0 +1,22 @@
1
+ import type { Plugin, OverrideableMethod } from "../plugin-system.ts";
2
+ export type VideoModeOptions = {
3
+ /** Pause duration before action (ms). Default: 1000 */
4
+ pauseBefore?: number;
5
+ /** Pause duration after test (ms). Default: 3000 */
6
+ pauseAfterTest?: number;
7
+ /** Highlight style. Default: '3px solid gold' */
8
+ highlightStyle?: string;
9
+ /** Methods to skip highlighting. Default: ['waitFor'] */
10
+ skipMethods?: OverrideableMethod[];
11
+ /**
12
+ * Skip highlighting for actions triggered from these files (matched as
13
+ * substrings of stack frames). Useful for internal helpers like login
14
+ * flows that shouldn't be slowed down. Default: []
15
+ */
16
+ skipStackFrames?: string[];
17
+ };
18
+ /**
19
+ * Highlights elements before actions and pauses for video recording.
20
+ * Also pauses after tests complete for better video endings.
21
+ */
22
+ export declare const videoMode: (options?: VideoModeOptions) => Plugin;