playwriter 0.0.63 → 0.0.89
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/dist/a11y-client.js +18 -8
- package/dist/aria-snapshot.d.ts +41 -3
- package/dist/aria-snapshot.d.ts.map +1 -1
- package/dist/aria-snapshot.js +134 -55
- package/dist/aria-snapshot.js.map +1 -1
- package/dist/aria-snapshot.test.js +5 -2
- package/dist/aria-snapshot.test.js.map +1 -1
- package/dist/aria-snapshot.unit.test.js +83 -41
- package/dist/aria-snapshot.unit.test.js.map +1 -1
- package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts +5 -0
- package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts.map +1 -0
- package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js +5 -0
- package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js.map +1 -0
- package/dist/bippy.js +1 -1
- package/dist/cdp-log.d.ts +1 -1
- package/dist/cdp-log.d.ts.map +1 -1
- package/dist/cdp-log.js +1 -1
- package/dist/cdp-log.js.map +1 -1
- package/dist/cdp-relay.d.ts.map +1 -1
- package/dist/cdp-relay.js +492 -298
- package/dist/cdp-relay.js.map +1 -1
- package/dist/cdp-session.d.ts.map +1 -1
- package/dist/cdp-session.js.map +1 -1
- package/dist/cdp-types.d.ts.map +1 -1
- package/dist/cdp-types.js +7 -7
- package/dist/cdp-types.js.map +1 -1
- package/dist/clean-html.d.ts.map +1 -1
- package/dist/clean-html.js +4 -5
- package/dist/clean-html.js.map +1 -1
- package/dist/cli.js +45 -27
- package/dist/cli.js.map +1 -1
- package/dist/create-logger.d.ts.map +1 -1
- package/dist/create-logger.js +3 -1
- package/dist/create-logger.js.map +1 -1
- package/dist/debugger-examples-types.d.ts.map +1 -1
- package/dist/debugger.d.ts.map +1 -1
- package/dist/debugger.js +1 -3
- package/dist/debugger.js.map +1 -1
- package/dist/diff-utils.d.ts.map +1 -1
- package/dist/diff-utils.js +1 -4
- package/dist/diff-utils.js.map +1 -1
- package/dist/editor-api.md +12 -2
- package/dist/editor-examples.d.ts +1 -1
- package/dist/editor-examples.d.ts.map +1 -1
- package/dist/editor-examples.js +1 -1
- package/dist/editor-examples.js.map +1 -1
- package/dist/editor.d.ts +1 -1
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +1 -1
- package/dist/editor.js.map +1 -1
- package/dist/executor.d.ts +26 -3
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +297 -64
- package/dist/executor.js.map +1 -1
- package/dist/executor.unit.test.js +38 -1
- package/dist/executor.unit.test.js.map +1 -1
- package/dist/extension-connection.test.js +139 -36
- package/dist/extension-connection.test.js.map +1 -1
- package/dist/ffmpeg.d.ts +148 -0
- package/dist/ffmpeg.d.ts.map +1 -0
- package/dist/ffmpeg.js +523 -0
- package/dist/ffmpeg.js.map +1 -0
- package/dist/ghost-browser.d.ts.map +1 -1
- package/dist/ghost-browser.js.map +1 -1
- package/dist/ghost-cursor-client.js +287 -0
- package/dist/ghost-cursor.d.ts +27 -0
- package/dist/ghost-cursor.d.ts.map +1 -0
- package/dist/ghost-cursor.js +63 -0
- package/dist/ghost-cursor.js.map +1 -0
- package/dist/htmlrewrite.d.ts.map +1 -1
- package/dist/htmlrewrite.js +17 -55
- package/dist/htmlrewrite.js.map +1 -1
- package/dist/htmlrewrite.test.js.map +1 -1
- package/dist/kill-port.d.ts.map +1 -1
- package/dist/kill-port.js +1 -3
- package/dist/kill-port.js.map +1 -1
- package/dist/locator-selector.test.d.ts +2 -0
- package/dist/locator-selector.test.d.ts.map +1 -0
- package/dist/locator-selector.test.js +96 -0
- package/dist/locator-selector.test.js.map +1 -0
- package/dist/mcp-client.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +8 -3
- package/dist/mcp.js.map +1 -1
- package/dist/on-mouse-action.test.d.ts +2 -0
- package/dist/on-mouse-action.test.d.ts.map +1 -0
- package/dist/on-mouse-action.test.js +155 -0
- package/dist/on-mouse-action.test.js.map +1 -0
- package/dist/page-markdown.js +4 -4
- package/dist/page-markdown.js.map +1 -1
- package/dist/prompt.md +450 -377
- package/dist/protocol.d.ts +4 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/readability.js +16 -2
- package/dist/recording-ghost-cursor.d.ts +41 -0
- package/dist/recording-ghost-cursor.d.ts.map +1 -0
- package/dist/recording-ghost-cursor.js +79 -0
- package/dist/recording-ghost-cursor.js.map +1 -0
- package/dist/recording-relay.d.ts.map +1 -1
- package/dist/recording-relay.js +8 -8
- package/dist/recording-relay.js.map +1 -1
- package/dist/relay-client.d.ts +17 -4
- package/dist/relay-client.d.ts.map +1 -1
- package/dist/relay-client.js +45 -11
- package/dist/relay-client.js.map +1 -1
- package/dist/relay-core.test.d.ts.map +1 -1
- package/dist/relay-core.test.js +515 -26
- package/dist/relay-core.test.js.map +1 -1
- package/dist/relay-navigation.test.d.ts.map +1 -1
- package/dist/relay-navigation.test.js +169 -31
- package/dist/relay-navigation.test.js.map +1 -1
- package/dist/relay-session.test.d.ts.map +1 -1
- package/dist/relay-session.test.js +113 -65
- package/dist/relay-session.test.js.map +1 -1
- package/dist/relay-state.d.ts +158 -0
- package/dist/relay-state.d.ts.map +1 -0
- package/dist/relay-state.js +306 -0
- package/dist/relay-state.js.map +1 -0
- package/dist/relay-state.test.d.ts +2 -0
- package/dist/relay-state.test.d.ts.map +1 -0
- package/dist/relay-state.test.js +472 -0
- package/dist/relay-state.test.js.map +1 -0
- package/dist/scoped-fs.d.ts.map +1 -1
- package/dist/scoped-fs.js.map +1 -1
- package/dist/screen-recording.d.ts +66 -4
- package/dist/screen-recording.d.ts.map +1 -1
- package/dist/screen-recording.js +150 -13
- package/dist/screen-recording.js.map +1 -1
- package/dist/screen-recording.test.d.ts +2 -0
- package/dist/screen-recording.test.d.ts.map +1 -0
- package/dist/screen-recording.test.js +102 -0
- package/dist/screen-recording.test.js.map +1 -0
- package/dist/selector-generator.js +1 -1
- package/dist/snapshot-tools.test.js +71 -28
- package/dist/snapshot-tools.test.js.map +1 -1
- package/dist/start-relay-server.d.ts +1 -1
- package/dist/start-relay-server.d.ts.map +1 -1
- package/dist/start-relay-server.js +1 -1
- package/dist/start-relay-server.js.map +1 -1
- package/dist/styles-api.md +8 -1
- package/dist/styles-examples.d.ts +1 -1
- package/dist/styles-examples.d.ts.map +1 -1
- package/dist/styles-examples.js +1 -1
- package/dist/styles-examples.js.map +1 -1
- package/dist/styles.d.ts.map +1 -1
- package/dist/styles.js +1 -3
- package/dist/styles.js.map +1 -1
- package/dist/test-declarations.d.ts.map +1 -1
- package/dist/test-utils.d.ts +1 -1
- package/dist/test-utils.d.ts.map +1 -1
- package/dist/test-utils.js +7 -5
- package/dist/test-utils.js.map +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js.map +1 -1
- package/dist/wait-for-page-load.d.ts.map +1 -1
- package/dist/wait-for-page-load.js +1 -1
- package/dist/wait-for-page-load.js.map +1 -1
- package/package.json +4 -3
- package/src/a11y-client.ts +5 -4
- package/src/aria-snapshot.test.ts +5 -2
- package/src/aria-snapshot.ts +306 -117
- package/src/aria-snapshot.unit.test.ts +199 -141
- package/src/aria-snapshots/github-interactive.txt +2 -0
- package/src/aria-snapshots/github-raw.txt +5 -1
- package/src/aria-snapshots/hackernews-interactive.txt +238 -241
- package/src/aria-snapshots/hackernews-raw.txt +265 -269
- package/src/assets/aria-labels-example.png +0 -0
- package/src/assets/aria-labels-github.png +0 -0
- package/src/assets/aria-labels-hacker-news.png +0 -0
- package/src/assets/aria-labels-old-reddit.png +0 -0
- package/src/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.ts +5 -0
- package/src/assets/cursors/screen-studio/pointer-macos-tahoe.svg +18 -0
- package/src/cdp-log.ts +4 -1
- package/src/cdp-relay.ts +1059 -737
- package/src/cdp-session.ts +12 -3
- package/src/cdp-types.ts +51 -51
- package/src/clean-html.ts +4 -5
- package/src/cli.ts +82 -55
- package/src/create-logger.ts +5 -3
- package/src/debugger-examples-types.ts +4 -1
- package/src/debugger.ts +1 -5
- package/src/diff-utils.ts +2 -5
- package/src/editor-examples.ts +11 -1
- package/src/editor.ts +10 -2
- package/src/executor.ts +374 -73
- package/src/executor.unit.test.ts +48 -1
- package/src/extension-connection.test.ts +612 -488
- package/src/ffmpeg.ts +769 -0
- package/src/ghost-browser.ts +4 -6
- package/src/ghost-cursor-client.ts +369 -0
- package/src/ghost-cursor.ts +110 -0
- package/src/htmlrewrite.test.ts +6 -2
- package/src/htmlrewrite.ts +348 -386
- package/src/kill-port.ts +1 -3
- package/src/locator-selector.test.ts +115 -0
- package/src/mcp-client.ts +1 -1
- package/src/mcp.ts +21 -15
- package/src/on-mouse-action.test.ts +196 -0
- package/src/page-markdown.ts +7 -7
- package/src/protocol.ts +73 -57
- package/src/recording-ghost-cursor.ts +113 -0
- package/src/recording-relay.ts +20 -12
- package/src/relay-client.ts +85 -18
- package/src/relay-core.test.ts +1117 -578
- package/src/relay-navigation.test.ts +648 -483
- package/src/relay-session.test.ts +984 -929
- package/src/relay-state.test.ts +570 -0
- package/src/relay-state.ts +497 -0
- package/src/resource.md +21 -49
- package/src/scoped-fs.ts +9 -3
- package/src/screen-recording.test.ts +111 -0
- package/src/screen-recording.ts +256 -31
- package/src/skill.md +476 -396
- package/src/snapshot-tools.test.ts +580 -528
- package/src/snapshots/shadcn-ui-accessibility-full.md +8 -8
- package/src/snapshots/shadcn-ui-accessibility-interactive.md +8 -8
- package/src/start-relay-server.ts +14 -11
- package/src/styles-examples.ts +8 -1
- package/src/styles.ts +20 -21
- package/src/test-declarations.ts +6 -6
- package/src/test-utils.ts +104 -91
- package/src/utils.ts +2 -1
- package/src/wait-for-page-load.ts +6 -1
package/dist/executor.js
CHANGED
|
@@ -22,11 +22,13 @@ import { Editor } from './editor.js';
|
|
|
22
22
|
import { getStylesForLocator, formatStylesAsText } from './styles.js';
|
|
23
23
|
import { getReactSource } from './react-source.js';
|
|
24
24
|
import { ScopedFS } from './scoped-fs.js';
|
|
25
|
-
import { screenshotWithAccessibilityLabels, getAriaSnapshot, } from './aria-snapshot.js';
|
|
25
|
+
import { screenshotWithAccessibilityLabels, getAriaSnapshot, resizeImage, } from './aria-snapshot.js';
|
|
26
26
|
import { createGhostBrowserChrome } from './ghost-browser.js';
|
|
27
27
|
import { getCleanHTML } from './clean-html.js';
|
|
28
28
|
import { getPageMarkdown } from './page-markdown.js';
|
|
29
|
-
import {
|
|
29
|
+
import { createRecordingApi } from './screen-recording.js';
|
|
30
|
+
import { createDemoVideo } from './ffmpeg.js';
|
|
31
|
+
import { RecordingGhostCursorController } from './recording-ghost-cursor.js';
|
|
30
32
|
const __filename = fileURLToPath(import.meta.url);
|
|
31
33
|
const __dirname = path.dirname(__filename);
|
|
32
34
|
const require = createRequire(import.meta.url);
|
|
@@ -53,10 +55,11 @@ const usefulGlobals = {
|
|
|
53
55
|
structuredClone,
|
|
54
56
|
};
|
|
55
57
|
/**
|
|
56
|
-
*
|
|
57
|
-
* Returns
|
|
58
|
+
* Parse code and check if it's a single expression that should be auto-returned.
|
|
59
|
+
* Returns the exact expression source (without trailing semicolon) using AST
|
|
60
|
+
* node offsets, or null if the code should not be auto-wrapped. See #58.
|
|
58
61
|
*/
|
|
59
|
-
export function
|
|
62
|
+
export function getAutoReturnExpression(code) {
|
|
60
63
|
try {
|
|
61
64
|
const ast = acorn.parse(code, {
|
|
62
65
|
ecmaVersion: 'latest',
|
|
@@ -66,37 +69,55 @@ export function shouldAutoReturn(code) {
|
|
|
66
69
|
});
|
|
67
70
|
// Must be exactly one statement
|
|
68
71
|
if (ast.body.length !== 1) {
|
|
69
|
-
return
|
|
72
|
+
return null;
|
|
70
73
|
}
|
|
71
74
|
const stmt = ast.body[0];
|
|
72
75
|
// If it's already a return statement, don't auto-wrap
|
|
73
76
|
if (stmt.type === 'ReturnStatement') {
|
|
74
|
-
return
|
|
77
|
+
return null;
|
|
75
78
|
}
|
|
76
79
|
// Must be an ExpressionStatement
|
|
77
80
|
if (stmt.type !== 'ExpressionStatement') {
|
|
78
|
-
return
|
|
81
|
+
return null;
|
|
79
82
|
}
|
|
80
83
|
// Don't auto-return side-effect expressions
|
|
81
84
|
const expr = stmt.expression;
|
|
82
85
|
if (expr.type === 'AssignmentExpression' ||
|
|
83
86
|
expr.type === 'UpdateExpression' ||
|
|
84
87
|
(expr.type === 'UnaryExpression' && expr.operator === 'delete')) {
|
|
85
|
-
return
|
|
88
|
+
return null;
|
|
86
89
|
}
|
|
87
90
|
// Don't auto-return sequence expressions that contain assignments
|
|
88
91
|
if (expr.type === 'SequenceExpression') {
|
|
89
92
|
const hasAssignment = expr.expressions.some((e) => e.type === 'AssignmentExpression');
|
|
90
93
|
if (hasAssignment) {
|
|
91
|
-
return
|
|
94
|
+
return null;
|
|
92
95
|
}
|
|
93
96
|
}
|
|
94
|
-
|
|
97
|
+
// Use the expression node's start/end offsets to extract just the expression
|
|
98
|
+
// source, excluding any trailing semicolon. This is more robust than regex.
|
|
99
|
+
return code.slice(expr.start, expr.end);
|
|
95
100
|
}
|
|
96
101
|
catch {
|
|
97
102
|
// Parse failed, don't auto-return
|
|
98
|
-
return
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Backward-compatible helper: returns true if code should be auto-wrapped. */
|
|
107
|
+
export function shouldAutoReturn(code) {
|
|
108
|
+
return getAutoReturnExpression(code) !== null;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Wraps user code in an async IIFE for vm execution.
|
|
112
|
+
* Uses AST node offsets to extract the expression without trailing semicolons,
|
|
113
|
+
* avoiding SyntaxError when embedding inside `return await (...)`. See #58.
|
|
114
|
+
*/
|
|
115
|
+
export function wrapCode(code) {
|
|
116
|
+
const expr = getAutoReturnExpression(code);
|
|
117
|
+
if (expr !== null) {
|
|
118
|
+
return `(async () => { return await (${expr}) })()`;
|
|
99
119
|
}
|
|
120
|
+
return `(async () => { ${code} })()`;
|
|
100
121
|
}
|
|
101
122
|
const EXTENSION_NOT_CONNECTED_ERROR = `The Playwriter Chrome extension is not connected. Make sure you have:
|
|
102
123
|
1. Installed the extension: https://chromewebstore.google.com/detail/playwriter-mcp/jfeammnjpkecdekppnclgkkffahnhfhe
|
|
@@ -156,6 +177,17 @@ export class PlaywrightExecutor {
|
|
|
156
177
|
browserLogs = new Map();
|
|
157
178
|
lastSnapshots = new WeakMap();
|
|
158
179
|
lastRefToLocator = new WeakMap();
|
|
180
|
+
warningEvents = [];
|
|
181
|
+
nextWarningEventId = 0;
|
|
182
|
+
lastDeliveredWarningEventId = 0;
|
|
183
|
+
// Recording timestamp tracking: when recording is active, each execute()
|
|
184
|
+
// call pushes {start, end} (seconds relative to recordingStartedAt).
|
|
185
|
+
// Returned by stopRecording() so the model can speed up idle sections.
|
|
186
|
+
recordingStartedAt = null;
|
|
187
|
+
executionTimestamps = [];
|
|
188
|
+
activeWarningScopes = new Set();
|
|
189
|
+
pagesWithListeners = new WeakSet();
|
|
190
|
+
suppressPageCloseWarnings = false;
|
|
159
191
|
scopedFs;
|
|
160
192
|
sandboxedRequire;
|
|
161
193
|
cdpConfig;
|
|
@@ -200,15 +232,6 @@ export class PlaywrightExecutor {
|
|
|
200
232
|
}
|
|
201
233
|
options.deviceScaleFactor = 2;
|
|
202
234
|
}
|
|
203
|
-
async preserveSystemColorScheme(context) {
|
|
204
|
-
const options = context._options;
|
|
205
|
-
if (!options) {
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
options.colorScheme = 'no-override';
|
|
209
|
-
options.reducedMotion = 'no-override';
|
|
210
|
-
options.forcedColors = 'no-override';
|
|
211
|
-
}
|
|
212
235
|
clearUserState() {
|
|
213
236
|
Object.keys(this.userState).forEach((key) => delete this.userState[key]);
|
|
214
237
|
}
|
|
@@ -218,6 +241,42 @@ export class PlaywrightExecutor {
|
|
|
218
241
|
this.page = null;
|
|
219
242
|
this.context = null;
|
|
220
243
|
}
|
|
244
|
+
enqueueWarning(message) {
|
|
245
|
+
this.nextWarningEventId += 1;
|
|
246
|
+
this.warningEvents.push({ id: this.nextWarningEventId, message });
|
|
247
|
+
}
|
|
248
|
+
beginWarningScope() {
|
|
249
|
+
const scope = {
|
|
250
|
+
cursor: this.nextWarningEventId,
|
|
251
|
+
};
|
|
252
|
+
this.activeWarningScopes.add(scope);
|
|
253
|
+
return scope;
|
|
254
|
+
}
|
|
255
|
+
flushWarningsForScope(scope) {
|
|
256
|
+
const relevantWarnings = this.warningEvents.filter((warning) => {
|
|
257
|
+
return warning.id > scope.cursor;
|
|
258
|
+
});
|
|
259
|
+
const latestWarningId = relevantWarnings.at(-1)?.id;
|
|
260
|
+
if (latestWarningId && latestWarningId > this.lastDeliveredWarningEventId) {
|
|
261
|
+
this.lastDeliveredWarningEventId = latestWarningId;
|
|
262
|
+
}
|
|
263
|
+
this.activeWarningScopes.delete(scope);
|
|
264
|
+
this.pruneDeliveredWarnings();
|
|
265
|
+
if (relevantWarnings.length === 0) {
|
|
266
|
+
return '';
|
|
267
|
+
}
|
|
268
|
+
return `${relevantWarnings.map((warning) => `[WARNING] ${warning.message}`).join('\n')}\n`;
|
|
269
|
+
}
|
|
270
|
+
pruneDeliveredWarnings() {
|
|
271
|
+
const activeCursors = [...this.activeWarningScopes].map((scope) => {
|
|
272
|
+
return scope.cursor;
|
|
273
|
+
});
|
|
274
|
+
const minActiveCursor = activeCursors.length > 0 ? Math.min(...activeCursors) : this.lastDeliveredWarningEventId;
|
|
275
|
+
const pruneBeforeOrAt = Math.min(this.lastDeliveredWarningEventId, minActiveCursor);
|
|
276
|
+
this.warningEvents = this.warningEvents.filter((warning) => {
|
|
277
|
+
return warning.id > pruneBeforeOrAt;
|
|
278
|
+
});
|
|
279
|
+
}
|
|
221
280
|
warnIfExtensionOutdated(playwriterVersion) {
|
|
222
281
|
if (this.hasWarnedExtensionOutdated) {
|
|
223
282
|
return;
|
|
@@ -228,6 +287,75 @@ export class PlaywrightExecutor {
|
|
|
228
287
|
this.hasWarnedExtensionOutdated = true;
|
|
229
288
|
}
|
|
230
289
|
}
|
|
290
|
+
setupPageListeners(page) {
|
|
291
|
+
if (this.pagesWithListeners.has(page)) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
this.pagesWithListeners.add(page);
|
|
295
|
+
this.setupPageCloseDetection(page);
|
|
296
|
+
this.setupPageConsoleListener(page);
|
|
297
|
+
this.setupPopupDetection(page);
|
|
298
|
+
}
|
|
299
|
+
setupPageCloseDetection(page) {
|
|
300
|
+
page.on('close', () => {
|
|
301
|
+
const stateKeysForClosedPage = Object.entries(this.userState)
|
|
302
|
+
.filter(([, value]) => {
|
|
303
|
+
return value === page;
|
|
304
|
+
})
|
|
305
|
+
.map(([key]) => key);
|
|
306
|
+
const wasCurrentPage = this.page === page;
|
|
307
|
+
let replacementPageInfo = null;
|
|
308
|
+
if (wasCurrentPage) {
|
|
309
|
+
this.page = null;
|
|
310
|
+
const context = this.context || page.context();
|
|
311
|
+
const openPages = context.pages().filter((candidate) => {
|
|
312
|
+
return !candidate.isClosed();
|
|
313
|
+
});
|
|
314
|
+
if (openPages.length > 0) {
|
|
315
|
+
const replacementPage = openPages[0];
|
|
316
|
+
this.page = replacementPage;
|
|
317
|
+
const replacementIndex = context.pages().indexOf(replacementPage);
|
|
318
|
+
replacementPageInfo = {
|
|
319
|
+
index: replacementIndex >= 0 ? String(replacementIndex) : 'unknown',
|
|
320
|
+
url: replacementPage.url() || 'unknown',
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (!this.isConnected || this.suppressPageCloseWarnings || stateKeysForClosedPage.length === 0) {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const stateKeyLabel = stateKeysForClosedPage.map((key) => `state.${key}`).join(', ');
|
|
328
|
+
const closedUrl = page.url() || 'unknown';
|
|
329
|
+
if (!wasCurrentPage) {
|
|
330
|
+
this.enqueueWarning(`Page closed (url: ${closedUrl}) for ${stateKeyLabel}. ` +
|
|
331
|
+
`Assign a new open page to ${stateKeyLabel} before reusing it.`);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (replacementPageInfo) {
|
|
335
|
+
this.enqueueWarning(`The current page in ${stateKeyLabel} was closed (url: ${closedUrl}). ` +
|
|
336
|
+
`Switched active page to index ${replacementPageInfo.index} (url: ${replacementPageInfo.url}). ` +
|
|
337
|
+
`Reassign ${stateKeyLabel} before using it again.`);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
this.enqueueWarning(`The current page in ${stateKeyLabel} was closed (url: ${closedUrl}). ` +
|
|
341
|
+
`No open pages remain. Open a tab with Playwriter enabled, then reassign ${stateKeyLabel}.`);
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
setupPopupDetection(page) {
|
|
345
|
+
// Listen for popup events (window.open, target=_blank) on each page.
|
|
346
|
+
// This is more reliable than checking page.opener() on context 'page' event,
|
|
347
|
+
// which also fires for context.newPage() and CDP reconnection scenarios.
|
|
348
|
+
page.on('popup', (popup) => {
|
|
349
|
+
const context = page.context();
|
|
350
|
+
const pages = context.pages();
|
|
351
|
+
const rawIndex = pages.indexOf(popup);
|
|
352
|
+
const pageIndex = rawIndex >= 0 ? String(rawIndex) : 'unknown';
|
|
353
|
+
const url = popup.url();
|
|
354
|
+
this.enqueueWarning(`Popup window detected (page index ${pageIndex}, url: ${url}). ` +
|
|
355
|
+
`Popup windows cannot be controlled by playwriter. ` +
|
|
356
|
+
`Repeat the interaction in a way that does not open a popup, or navigate to the URL directly in a new tab.`);
|
|
357
|
+
});
|
|
358
|
+
}
|
|
231
359
|
setupPageConsoleListener(page) {
|
|
232
360
|
// Use targetId() if available, fallback to internal _guid for CDP connections
|
|
233
361
|
const targetId = page.targetId() || page._guid;
|
|
@@ -280,14 +408,18 @@ export class PlaywrightExecutor {
|
|
|
280
408
|
}
|
|
281
409
|
return (await fallback.json());
|
|
282
410
|
}
|
|
283
|
-
const data = await response.json();
|
|
411
|
+
const data = (await response.json());
|
|
284
412
|
const extension = data.extensions.find((item) => {
|
|
285
413
|
return item.extensionId === extensionId || item.stableKey === extensionId;
|
|
286
414
|
});
|
|
287
415
|
if (!extension) {
|
|
288
416
|
return notConnected;
|
|
289
417
|
}
|
|
290
|
-
return {
|
|
418
|
+
return {
|
|
419
|
+
connected: true,
|
|
420
|
+
activeTargets: extension.activeTargets,
|
|
421
|
+
playwriterVersion: extension?.playwriterVersion || null,
|
|
422
|
+
};
|
|
291
423
|
}
|
|
292
424
|
const response = await fetch(`${httpBaseUrl}/extension/status`, {
|
|
293
425
|
signal: AbortSignal.timeout(2000),
|
|
@@ -320,12 +452,16 @@ export class PlaywrightExecutor {
|
|
|
320
452
|
});
|
|
321
453
|
const contexts = browser.contexts();
|
|
322
454
|
const context = contexts.length > 0 ? contexts[0] : await browser.newContext();
|
|
455
|
+
// Action timeout (click, fill, hover, etc.) is longer to tolerate slower
|
|
456
|
+
// SPA/Turbo navigations and post-click settling on real sites.
|
|
457
|
+
// Navigation timeout (goto, reload) remains separate.
|
|
458
|
+
context.setDefaultTimeout(60000);
|
|
459
|
+
context.setDefaultNavigationTimeout(10000);
|
|
323
460
|
context.on('page', (page) => {
|
|
324
|
-
this.
|
|
461
|
+
this.setupPageListeners(page);
|
|
325
462
|
});
|
|
326
|
-
context.pages().forEach((p) => this.
|
|
463
|
+
context.pages().forEach((p) => this.setupPageListeners(p));
|
|
327
464
|
const page = await this.ensurePageForContext({ context, timeout: 10000 });
|
|
328
|
-
await this.preserveSystemColorScheme(context);
|
|
329
465
|
await this.setDeviceScaleFactorForMacOS(context);
|
|
330
466
|
this.browser = browser;
|
|
331
467
|
this.page = page;
|
|
@@ -358,12 +494,16 @@ export class PlaywrightExecutor {
|
|
|
358
494
|
}
|
|
359
495
|
async reset() {
|
|
360
496
|
if (this.browser) {
|
|
497
|
+
this.suppressPageCloseWarnings = true;
|
|
361
498
|
try {
|
|
362
499
|
await this.browser.close();
|
|
363
500
|
}
|
|
364
501
|
catch (e) {
|
|
365
502
|
this.logger.error('Error closing browser:', e);
|
|
366
503
|
}
|
|
504
|
+
finally {
|
|
505
|
+
this.suppressPageCloseWarnings = false;
|
|
506
|
+
}
|
|
367
507
|
}
|
|
368
508
|
this.clearConnectionState();
|
|
369
509
|
this.clearUserState();
|
|
@@ -382,12 +522,16 @@ export class PlaywrightExecutor {
|
|
|
382
522
|
});
|
|
383
523
|
const contexts = browser.contexts();
|
|
384
524
|
const context = contexts.length > 0 ? contexts[0] : await browser.newContext();
|
|
525
|
+
// Action timeout (click, fill, hover, etc.) is longer to tolerate slower
|
|
526
|
+
// SPA/Turbo navigations and post-click settling on real sites.
|
|
527
|
+
// Navigation timeout (goto, reload) remains separate.
|
|
528
|
+
context.setDefaultTimeout(60000);
|
|
529
|
+
context.setDefaultNavigationTimeout(10000);
|
|
385
530
|
context.on('page', (page) => {
|
|
386
|
-
this.
|
|
531
|
+
this.setupPageListeners(page);
|
|
387
532
|
});
|
|
388
|
-
context.pages().forEach((p) => this.
|
|
533
|
+
context.pages().forEach((p) => this.setupPageListeners(p));
|
|
389
534
|
const page = await this.ensurePageForContext({ context, timeout: 10000 });
|
|
390
|
-
await this.preserveSystemColorScheme(context);
|
|
391
535
|
await this.setDeviceScaleFactorForMacOS(context);
|
|
392
536
|
this.browser = browser;
|
|
393
537
|
this.page = page;
|
|
@@ -397,6 +541,7 @@ export class PlaywrightExecutor {
|
|
|
397
541
|
}
|
|
398
542
|
async execute(code, timeout = 10000) {
|
|
399
543
|
const consoleLogs = [];
|
|
544
|
+
const warningScope = this.beginWarningScope();
|
|
400
545
|
const formatConsoleLogs = (logs, prefix = 'Console output') => {
|
|
401
546
|
if (logs.length === 0) {
|
|
402
547
|
return '';
|
|
@@ -407,7 +552,13 @@ export class PlaywrightExecutor {
|
|
|
407
552
|
.map((arg) => {
|
|
408
553
|
if (typeof arg === 'string')
|
|
409
554
|
return arg;
|
|
410
|
-
return util.inspect(arg, {
|
|
555
|
+
return util.inspect(arg, {
|
|
556
|
+
depth: 4,
|
|
557
|
+
colors: false,
|
|
558
|
+
maxArrayLength: 100,
|
|
559
|
+
maxStringLength: 1000,
|
|
560
|
+
breakLength: 80,
|
|
561
|
+
});
|
|
411
562
|
})
|
|
412
563
|
.join(' ');
|
|
413
564
|
text += `[${method}] ${formattedArgs}\n`;
|
|
@@ -418,7 +569,6 @@ export class PlaywrightExecutor {
|
|
|
418
569
|
await this.ensureConnection();
|
|
419
570
|
const page = await this.getCurrentPage(timeout);
|
|
420
571
|
const context = this.context || page.context();
|
|
421
|
-
context.setDefaultTimeout(timeout);
|
|
422
572
|
this.logger.log('Executing code:', code);
|
|
423
573
|
const customConsole = {
|
|
424
574
|
log: (...args) => {
|
|
@@ -437,14 +587,14 @@ export class PlaywrightExecutor {
|
|
|
437
587
|
consoleLogs.push({ method: 'debug', args });
|
|
438
588
|
},
|
|
439
589
|
};
|
|
440
|
-
const
|
|
441
|
-
const { page: targetPage, frame, locator, search, showDiffSinceLastCall =
|
|
590
|
+
const snapshot = async (options) => {
|
|
591
|
+
const { page: targetPage, frame, locator, search, showDiffSinceLastCall = !search, interactiveOnly = false, } = options;
|
|
442
592
|
const resolvedPage = targetPage || page;
|
|
443
593
|
if (!resolvedPage) {
|
|
444
|
-
throw new Error('
|
|
594
|
+
throw new Error('snapshot requires a page');
|
|
445
595
|
}
|
|
446
596
|
// Use new in-page implementation via getAriaSnapshot
|
|
447
|
-
const { snapshot: rawSnapshot, refs, getSelectorForRef } = await getAriaSnapshot({
|
|
597
|
+
const { snapshot: rawSnapshot, refs, getSelectorForRef, } = await getAriaSnapshot({
|
|
448
598
|
page: resolvedPage,
|
|
449
599
|
frame,
|
|
450
600
|
locator,
|
|
@@ -460,11 +610,19 @@ export class PlaywrightExecutor {
|
|
|
460
610
|
}
|
|
461
611
|
this.lastRefToLocator.set(resolvedPage, refToLocator);
|
|
462
612
|
const shouldCacheSnapshot = !frame;
|
|
463
|
-
|
|
613
|
+
// Cache keyed by locator selector so full-page and locator-scoped snapshots
|
|
614
|
+
// don't pollute each other's diff baselines
|
|
615
|
+
const snapshotKey = locator ? `locator:${locator.selector()}` : 'page';
|
|
616
|
+
let pageSnapshots = this.lastSnapshots.get(resolvedPage);
|
|
617
|
+
if (!pageSnapshots) {
|
|
618
|
+
pageSnapshots = new Map();
|
|
619
|
+
this.lastSnapshots.set(resolvedPage, pageSnapshots);
|
|
620
|
+
}
|
|
621
|
+
const previousSnapshot = shouldCacheSnapshot ? pageSnapshots.get(snapshotKey) : undefined;
|
|
464
622
|
if (shouldCacheSnapshot) {
|
|
465
|
-
|
|
623
|
+
pageSnapshots.set(snapshotKey, snapshotStr);
|
|
466
624
|
}
|
|
467
|
-
//
|
|
625
|
+
// Diff defaults off when search is provided, but agent can explicitly enable both
|
|
468
626
|
if (showDiffSinceLastCall && previousSnapshot && shouldCacheSnapshot) {
|
|
469
627
|
const diffResult = createSmartDiff({
|
|
470
628
|
oldContent: previousSnapshot,
|
|
@@ -625,16 +783,46 @@ export class PlaywrightExecutor {
|
|
|
625
783
|
// Recording uses chrome.tabCapture which requires activeTab permission.
|
|
626
784
|
// This permission is granted when the user clicks the Playwriter extension icon on a tab.
|
|
627
785
|
const relayPort = this.cdpConfig.port || 19988;
|
|
628
|
-
// Recording will work on any tab where the user has clicked the icon.
|
|
629
|
-
const withRecordingDefaults = (fn) => {
|
|
630
|
-
return async (options = {}) => {
|
|
631
|
-
const targetPage = options.page || page;
|
|
632
|
-
// Use Playwright's exposed sessionId directly
|
|
633
|
-
const sessionId = options.sessionId || targetPage.sessionId() || undefined;
|
|
634
|
-
return fn({ page: targetPage, sessionId, relayPort, ...options });
|
|
635
|
-
};
|
|
636
|
-
};
|
|
637
786
|
const self = this;
|
|
787
|
+
const recordingGhostCursor = new RecordingGhostCursorController({
|
|
788
|
+
logger: {
|
|
789
|
+
error: (...args) => {
|
|
790
|
+
self.logger.error(...args);
|
|
791
|
+
},
|
|
792
|
+
},
|
|
793
|
+
});
|
|
794
|
+
const showGhostCursor = async (options) => {
|
|
795
|
+
const targetPage = options?.page || page;
|
|
796
|
+
const cursorOptions = (() => {
|
|
797
|
+
if (!options) {
|
|
798
|
+
return undefined;
|
|
799
|
+
}
|
|
800
|
+
const { page: _ignoredPage, ...rest } = options;
|
|
801
|
+
return rest;
|
|
802
|
+
})();
|
|
803
|
+
await recordingGhostCursor.show({ page: targetPage, cursorOptions });
|
|
804
|
+
};
|
|
805
|
+
const hideGhostCursor = async (options) => {
|
|
806
|
+
const targetPage = options?.page || page;
|
|
807
|
+
await recordingGhostCursor.hide({ page: targetPage });
|
|
808
|
+
};
|
|
809
|
+
const recordingApi = createRecordingApi({
|
|
810
|
+
context,
|
|
811
|
+
defaultPage: page,
|
|
812
|
+
relayPort,
|
|
813
|
+
ghostCursorController: recordingGhostCursor,
|
|
814
|
+
onStart: () => {
|
|
815
|
+
self.recordingStartedAt = Date.now();
|
|
816
|
+
self.executionTimestamps = [];
|
|
817
|
+
},
|
|
818
|
+
onFinish: () => {
|
|
819
|
+
self.recordingStartedAt = null;
|
|
820
|
+
self.executionTimestamps = [];
|
|
821
|
+
},
|
|
822
|
+
getExecutionTimestamps: () => {
|
|
823
|
+
return self.executionTimestamps;
|
|
824
|
+
},
|
|
825
|
+
});
|
|
638
826
|
// Ghost Browser API - creates chrome object that mirrors Ghost Browser's APIs
|
|
639
827
|
// See extension/src/ghost-browser-api.d.ts for full API documentation
|
|
640
828
|
const chromeGhostBrowser = createGhostBrowserChrome(async (namespace, method, args) => {
|
|
@@ -651,7 +839,8 @@ export class PlaywrightExecutor {
|
|
|
651
839
|
context,
|
|
652
840
|
state: this.userState,
|
|
653
841
|
console: customConsole,
|
|
654
|
-
|
|
842
|
+
snapshot,
|
|
843
|
+
accessibilitySnapshot: snapshot, // backward compat alias
|
|
655
844
|
refToLocator,
|
|
656
845
|
getCleanHTML,
|
|
657
846
|
getPageMarkdown,
|
|
@@ -666,10 +855,23 @@ export class PlaywrightExecutor {
|
|
|
666
855
|
formatStylesAsText,
|
|
667
856
|
getReactSource: getReactSourceFn,
|
|
668
857
|
screenshotWithAccessibilityLabels: screenshotWithAccessibilityLabelsFn,
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
858
|
+
resizeImage,
|
|
859
|
+
ghostCursor: {
|
|
860
|
+
show: showGhostCursor,
|
|
861
|
+
hide: hideGhostCursor,
|
|
862
|
+
},
|
|
863
|
+
recording: {
|
|
864
|
+
start: recordingApi.start,
|
|
865
|
+
stop: recordingApi.stop,
|
|
866
|
+
isRecording: recordingApi.isRecording,
|
|
867
|
+
cancel: recordingApi.cancel,
|
|
868
|
+
},
|
|
869
|
+
// Backward-compatible aliases
|
|
870
|
+
startRecording: recordingApi.start,
|
|
871
|
+
stopRecording: recordingApi.stop,
|
|
872
|
+
isRecording: recordingApi.isRecording,
|
|
873
|
+
cancelRecording: recordingApi.cancel,
|
|
874
|
+
createDemoVideo,
|
|
673
875
|
resetPlaywright: async () => {
|
|
674
876
|
const { page: newPage, context: newContext } = await self.reset();
|
|
675
877
|
vmContextObj.page = newPage;
|
|
@@ -683,15 +885,36 @@ export class PlaywrightExecutor {
|
|
|
683
885
|
...usefulGlobals,
|
|
684
886
|
};
|
|
685
887
|
const vmContext = vm.createContext(vmContextObj);
|
|
686
|
-
const
|
|
687
|
-
const wrappedCode =
|
|
688
|
-
? `(async () => { return await (${
|
|
888
|
+
const autoReturnExpr = getAutoReturnExpression(code);
|
|
889
|
+
const wrappedCode = autoReturnExpr !== null
|
|
890
|
+
? `(async () => { return await (${autoReturnExpr}) })()`
|
|
689
891
|
: `(async () => { ${code} })()`;
|
|
690
|
-
const hasExplicitReturn =
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
892
|
+
const hasExplicitReturn = autoReturnExpr !== null || /\breturn\b/.test(code);
|
|
893
|
+
// Track execution timestamps relative to recording start (seconds).
|
|
894
|
+
// Used to identify idle gaps that can be sped up in demo videos.
|
|
895
|
+
// Captured before execution so we can record timing even if it throws.
|
|
896
|
+
const recordingStartSnapshot = this.recordingStartedAt;
|
|
897
|
+
const execStartSec = recordingStartSnapshot !== null
|
|
898
|
+
? (Date.now() - recordingStartSnapshot) / 1000
|
|
899
|
+
: -1;
|
|
900
|
+
const result = await (async () => {
|
|
901
|
+
try {
|
|
902
|
+
return await Promise.race([
|
|
903
|
+
vm.runInContext(wrappedCode, vmContext, { timeout, displayErrors: true }),
|
|
904
|
+
new Promise((_, reject) => setTimeout(() => reject(new CodeExecutionTimeoutError(timeout)), timeout)),
|
|
905
|
+
]);
|
|
906
|
+
}
|
|
907
|
+
finally {
|
|
908
|
+
// Record timestamp even on error — the execution still occupied real time
|
|
909
|
+
// that should not be sped up in the demo video.
|
|
910
|
+
// Compare against snapshot to avoid cross-session contamination if
|
|
911
|
+
// recording was stopped and restarted inside the same execute() call.
|
|
912
|
+
if (recordingStartSnapshot !== null && execStartSec >= 0 && this.recordingStartedAt === recordingStartSnapshot) {
|
|
913
|
+
const execEndSec = (Date.now() - recordingStartSnapshot) / 1000;
|
|
914
|
+
this.executionTimestamps.push({ start: execStartSec, end: execEndSec });
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
})();
|
|
695
918
|
let responseText = formatConsoleLogs(consoleLogs);
|
|
696
919
|
// Only show return value if user explicitly used return
|
|
697
920
|
if (hasExplicitReturn) {
|
|
@@ -699,12 +922,19 @@ export class PlaywrightExecutor {
|
|
|
699
922
|
if (resolvedResult !== undefined) {
|
|
700
923
|
const formatted = typeof resolvedResult === 'string'
|
|
701
924
|
? resolvedResult
|
|
702
|
-
: util.inspect(resolvedResult, {
|
|
925
|
+
: util.inspect(resolvedResult, {
|
|
926
|
+
depth: 4,
|
|
927
|
+
colors: false,
|
|
928
|
+
maxArrayLength: 100,
|
|
929
|
+
maxStringLength: 1000,
|
|
930
|
+
breakLength: 80,
|
|
931
|
+
});
|
|
703
932
|
if (formatted.trim()) {
|
|
704
933
|
responseText += `[return value] ${formatted}\n`;
|
|
705
934
|
}
|
|
706
935
|
}
|
|
707
936
|
}
|
|
937
|
+
responseText += this.flushWarningsForScope(warningScope);
|
|
708
938
|
if (!responseText.trim()) {
|
|
709
939
|
responseText = 'Code executed successfully (no output)';
|
|
710
940
|
}
|
|
@@ -725,14 +955,17 @@ export class PlaywrightExecutor {
|
|
|
725
955
|
}
|
|
726
956
|
catch (error) {
|
|
727
957
|
const errorStack = error.stack || error.message;
|
|
728
|
-
const isTimeoutError = error instanceof CodeExecutionTimeoutError || error
|
|
958
|
+
const isTimeoutError = error instanceof CodeExecutionTimeoutError || error?.name === 'TimeoutError' || error?.name === 'AbortError';
|
|
729
959
|
this.logger.error('Error in execute:', errorStack);
|
|
730
960
|
const logsText = formatConsoleLogs(consoleLogs, 'Console output (before error)');
|
|
961
|
+
const warningText = this.flushWarningsForScope(warningScope);
|
|
731
962
|
const resetHint = isTimeoutError
|
|
732
963
|
? ''
|
|
733
964
|
: '\n\n[HINT: If this is an internal Playwright error, page/browser closed, or connection issue, call reset to reconnect.]';
|
|
965
|
+
// timeout stacks are internal noise (Promise.race / setTimeout); only show the message
|
|
966
|
+
const errorText = isTimeoutError ? error.message : errorStack;
|
|
734
967
|
return {
|
|
735
|
-
text: `${logsText}\nError executing code: ${
|
|
968
|
+
text: `${logsText}${warningText}\nError executing code: ${errorText}${resetHint}`,
|
|
736
969
|
images: [],
|
|
737
970
|
isError: true,
|
|
738
971
|
};
|
|
@@ -762,7 +995,7 @@ export class PlaywrightExecutor {
|
|
|
762
995
|
throw new Error(NO_PAGES_AVAILABLE_ERROR);
|
|
763
996
|
}
|
|
764
997
|
const page = await context.newPage();
|
|
765
|
-
this.
|
|
998
|
+
this.setupPageListeners(page);
|
|
766
999
|
const pageUrl = page.url();
|
|
767
1000
|
if (pageUrl === 'about:blank') {
|
|
768
1001
|
return page;
|