dsh-browser-playwright-codex 0.5.1 → 0.5.3
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/CHANGELOG.md +54 -1
- package/lib/browser-lifecycle.d.ts +12 -0
- package/lib/browser-lifecycle.js +186 -0
- package/lib/challenge.d.ts +20 -0
- package/lib/challenge.js +40 -0
- package/lib/config.d.ts +81 -0
- package/lib/config.js +61 -0
- package/lib/extract.d.ts +13 -0
- package/lib/extract.js +34 -0
- package/lib/index.d.ts +4 -2
- package/lib/index.js +3 -1
- package/lib/login-state.d.ts +10 -0
- package/lib/login-state.js +83 -0
- package/lib/page-actions.d.ts +134 -0
- package/lib/page-actions.js +725 -0
- package/lib/page-diagnostics.d.ts +65 -0
- package/lib/page-diagnostics.js +181 -0
- package/lib/page-probes.d.ts +78 -0
- package/lib/page-probes.js +281 -0
- package/lib/page-snapshot.d.ts +96 -0
- package/lib/page-snapshot.js +280 -0
- package/lib/playwright.d.ts +5 -636
- package/lib/playwright.js +28 -2494
- package/lib/provider.d.ts +157 -0
- package/lib/provider.js +352 -0
- package/lib/session.d.ts +410 -0
- package/lib/session.js +633 -0
- package/lib/tool-render.d.ts +20 -0
- package/lib/tool-render.js +137 -0
- package/lib/tool-schemas.d.ts +76 -0
- package/lib/tool-schemas.js +46 -0
- package/lib/tool-types.d.ts +82 -0
- package/lib/tool-types.js +1 -0
- package/lib/tool.d.ts +2 -26
- package/lib/tool.js +5 -252
- package/lib/url-policy.d.ts +2 -0
- package/lib/url-policy.js +26 -0
- package/lib/window-focus.d.ts +27 -0
- package/lib/window-focus.js +54 -0
- package/package.json +4 -4
- package/scripts/dsh-browser-verify.mjs +2 -1
- package/scripts/prompt-cost.mjs +1 -1
package/lib/playwright.js
CHANGED
|
@@ -1,77 +1,40 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Registration entry for the browser capability. cordis.patch.yml points its loader row at
|
|
3
|
+
* \`<pkg>/playwright\`, so this is the module Cordis loads: it must keep exporting \`Config\` (the
|
|
4
|
+
* schemastery schema for the plugin's config — without it Cordis cannot fill in defaults),
|
|
5
|
+
* \`name\`/\`inject\`, and \`apply\`, plus the pre-split public surface re-exported below.
|
|
6
|
+
*
|
|
7
|
+
* The implementation lives in sibling modules, one per responsibility:
|
|
8
|
+
* provider.ts profile/context lifecycle, session registry, idle disposal
|
|
9
|
+
* session.ts one session's interactions
|
|
10
|
+
* page-actions.ts click / fill / press / hover / scroll / history / tabs
|
|
11
|
+
* page-snapshot.ts capture, ref signatures, ref lookup
|
|
12
|
+
* page-diagnostics.ts console messages, page errors, network records
|
|
13
|
+
* browser-lifecycle.ts launching/reusing the persistent context, window state
|
|
14
|
+
* login-state.ts exporting and restoring login state
|
|
15
|
+
* @module dsh-browser-playwright-codex/playwright
|
|
16
|
+
*/
|
|
17
|
+
import { PlaywrightProvider } from "./provider.js";
|
|
18
|
+
import {} from "./config.js";
|
|
19
|
+
import { subscribe as subscribeEnabled } from "./runtime-state.js";
|
|
10
20
|
/** Cordis plugin name used by loader diagnostics. */
|
|
11
21
|
export const name = 'browser-playwright';
|
|
12
22
|
/** The browser runtime this provider registers into. */
|
|
13
23
|
export const inject = ['browser'];
|
|
14
|
-
/** Schemastery validation for {@link PlaywrightConfig}. */
|
|
15
|
-
export const Config = z.object({
|
|
16
|
-
launch: z.object({
|
|
17
|
-
executablePath: z.string(),
|
|
18
|
-
channel: z.string(),
|
|
19
|
-
// Visible window by default: the agent drives a browser the user can
|
|
20
|
-
// watch and rescue by hand (login, captcha, dialogs).
|
|
21
|
-
headless: z.boolean().default(false),
|
|
22
|
-
// Persistent context mode: one shared profile-backed window whose
|
|
23
|
-
// login state survives close/reopen. false = legacy launch() mode.
|
|
24
|
-
persistent: z.boolean().default(true),
|
|
25
|
-
// Profile directory for persistent mode. Empty = ~/.dsh/browser-profiles/playwright.
|
|
26
|
-
profileDir: z.string().default(''),
|
|
27
|
-
// Headless only: a headful window must behave like a normal Chrome the
|
|
28
|
-
// user opened by hand, so the fixed CSS viewport is ignored there (see
|
|
29
|
-
// ensureContext) and the page tracks the real window size instead.
|
|
30
|
-
viewport: z
|
|
31
|
-
.object({ width: z.number().default(1280), height: z.number().default(800) })
|
|
32
|
-
.default({ width: 1280, height: 800 }),
|
|
33
|
-
navigationTimeoutMs: z.number().default(30000),
|
|
34
|
-
ignoreHTTPSErrors: z.boolean().default(false),
|
|
35
|
-
}),
|
|
36
|
-
allowedDomains: z.array(z.string()),
|
|
37
|
-
idleTimeoutMs: z.number().default(600000),
|
|
38
|
-
maxSessions: z.number().default(8),
|
|
39
|
-
snapshot: z
|
|
40
|
-
.object({
|
|
41
|
-
// 'legacy' keeps the injected DOM walker as the default: existing
|
|
42
|
-
// tests, verify and every consumer see byte-identical snapshots until
|
|
43
|
-
// a caller opts into the aria engine.
|
|
44
|
-
engine: z.union([z.const('legacy'), z.const('aria')]).default('legacy'),
|
|
45
|
-
maxNodes: z.number().default(500),
|
|
46
|
-
maxNameLength: z.number().default(120),
|
|
47
|
-
maxTextLength: z.number().default(300),
|
|
48
|
-
// diff=false keeps every return path a full snapshot: zero behavioral
|
|
49
|
-
// change unless a caller explicitly turns the incremental mode on.
|
|
50
|
-
diff: z.boolean().default(false),
|
|
51
|
-
})
|
|
52
|
-
.default({ engine: 'legacy', maxNodes: 500, maxNameLength: 120, maxTextLength: 300, diff: false }),
|
|
53
|
-
});
|
|
54
|
-
/** Channels probed in order when none is configured. */
|
|
55
|
-
const AUTO_CHANNELS = ['chromium', 'chrome', 'msedge', 'edge'];
|
|
56
|
-
/**
|
|
57
|
-
* Launch flags that strip Playwright's obvious automation markers (anti-bot
|
|
58
|
-
* detection layer L1). --enable-automation is what sets navigator.webdriver
|
|
59
|
-
* and the "controlled by automated test software" infobar; dropping it plus
|
|
60
|
-
* --disable-blink-features=AutomationControlled makes the browser present as
|
|
61
|
-
* a plain real Chrome, which it is. No fingerprint spoofing is added: real
|
|
62
|
-
* Chrome's own values are consistent by construction (spoofing would invent
|
|
63
|
-
* a device that never existed and draw MORE suspicion).
|
|
64
|
-
*/
|
|
65
|
-
const HUMANIZED_LAUNCH = {
|
|
66
|
-
ignoreDefaultArgs: ['--enable-automation'],
|
|
67
|
-
args: ['--disable-blink-features=AutomationControlled'],
|
|
68
|
-
};
|
|
69
|
-
const REF_PATTERN = /^e[0-9]+$/;
|
|
70
24
|
/**
|
|
71
25
|
* Register this provider on the browser runtime for the plugin's lifetime.
|
|
72
26
|
* @param ctx - plugin context carrying the browser runtime.
|
|
73
27
|
* @param config - launch and fleet configuration.
|
|
74
28
|
*/
|
|
29
|
+
// The loader row in cordis.patch.yml points at <pkg>/playwright, so this entry must keep
|
|
30
|
+
// exporting the config schema: without `Config`, Cordis cannot fill in defaults and the
|
|
31
|
+
// provider reads `config.snapshot.engine` off an un-defaulted object (regression 2026-10-06,
|
|
32
|
+
// introduced when the code was split into modules — the schema moved to config.ts and the
|
|
33
|
+
// subpath stopped re-exporting it). The other names keep the pre-split public surface intact.
|
|
34
|
+
export { Config } from "./config.js";
|
|
35
|
+
export { PlaywrightProvider } from "./provider.js";
|
|
36
|
+
export { assertAllowedUrl } from "./url-policy.js";
|
|
37
|
+
export { resolveSnapshotEngine } from "./page-snapshot.js";
|
|
75
38
|
export function apply(ctx, config) {
|
|
76
39
|
const provider = new PlaywrightProvider(config);
|
|
77
40
|
ctx.browser.registerProvider(provider);
|
|
@@ -84,2432 +47,3 @@ export function apply(ctx, config) {
|
|
|
84
47
|
void provider.dispose();
|
|
85
48
|
});
|
|
86
49
|
}
|
|
87
|
-
/** Validate one absolute URL against the navigation policy. */
|
|
88
|
-
export function assertAllowedUrl(raw, allowedDomains) {
|
|
89
|
-
let parsed;
|
|
90
|
-
try {
|
|
91
|
-
parsed = new URL(raw);
|
|
92
|
-
}
|
|
93
|
-
catch {
|
|
94
|
-
throw new BrowserError('URL_NOT_ALLOWED', 'invalid URL: ' + raw);
|
|
95
|
-
}
|
|
96
|
-
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
97
|
-
throw new BrowserError('URL_NOT_ALLOWED', 'only http(s) URLs can be navigated, got: ' + parsed.protocol);
|
|
98
|
-
}
|
|
99
|
-
if (allowedDomains.length > 0) {
|
|
100
|
-
const host = parsed.hostname.toLowerCase();
|
|
101
|
-
const allowed = allowedDomains.some((suffix) => host === suffix || host.endsWith('.' + suffix));
|
|
102
|
-
if (!allowed) {
|
|
103
|
-
throw new BrowserError('URL_NOT_ALLOWED', 'host ' + parsed.hostname + ' is not in allowedDomains');
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
return parsed;
|
|
107
|
-
}
|
|
108
|
-
/**
|
|
109
|
-
* Which page-reading mode a capture uses. The user's choice on the settings page
|
|
110
|
-
* (Settings → 浏览器 → 页面识别方式) wins over the deployment default, so flipping
|
|
111
|
-
* that switch changes the very next operation — no restart, no config edit.
|
|
112
|
-
* @param configured - the mode from this provider's config.
|
|
113
|
-
* @returns the effective mode.
|
|
114
|
-
*/
|
|
115
|
-
export function resolveSnapshotEngine(configured) {
|
|
116
|
-
return getSnapshotEngine() ?? configured;
|
|
117
|
-
}
|
|
118
|
-
/** Wrap a Playwright op so an aborted signal rejects while the op keeps draining in the background. */
|
|
119
|
-
async function withAbort(promise, signal) {
|
|
120
|
-
// Attach handlers FIRST: the caller's promise may still be draining (a
|
|
121
|
-
// dialog can keep it pending forever), and an abandoned rejection would
|
|
122
|
-
// become an unhandledRejection and can take the host down.
|
|
123
|
-
const guarded = Promise.resolve(promise);
|
|
124
|
-
guarded.catch(() => { });
|
|
125
|
-
// A malformed signal (not an AbortSignal) is treated as "no signal".
|
|
126
|
-
if (signal === undefined || typeof signal.addEventListener !== 'function' || typeof signal.aborted !== 'boolean')
|
|
127
|
-
return guarded;
|
|
128
|
-
if (signal.aborted)
|
|
129
|
-
throw new DOMException('The operation was aborted', 'AbortError');
|
|
130
|
-
return new Promise((resolve, reject) => {
|
|
131
|
-
const onAbort = () => reject(new DOMException('The operation was aborted', 'AbortError'));
|
|
132
|
-
signal.addEventListener('abort', onAbort, { once: true });
|
|
133
|
-
guarded.then((value) => {
|
|
134
|
-
signal.removeEventListener('abort', onAbort);
|
|
135
|
-
resolve(value);
|
|
136
|
-
}, (reason) => {
|
|
137
|
-
signal.removeEventListener('abort', onAbort);
|
|
138
|
-
reject(reason);
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
/**
|
|
143
|
-
* True when the OS window hosting this page is minimized. Tab selection lives
|
|
144
|
-
* in the browser context, not in the OS window, so activation is a side effect
|
|
145
|
-
* we are free to decline — and declining it is what keeps the agent from
|
|
146
|
-
* yanking a minimized window into the user's face. If the window state cannot
|
|
147
|
-
* be read we answer true (treat as minimized): the safe direction is to leave
|
|
148
|
-
* the user's desktop alone, and a skipped bringToFront costs nothing functional.
|
|
149
|
-
*/
|
|
150
|
-
async function isWindowMinimized(page) {
|
|
151
|
-
let session;
|
|
152
|
-
try {
|
|
153
|
-
session = await page.context().newCDPSession(page);
|
|
154
|
-
const { windowId } = await session.send('Browser.getWindowForTarget');
|
|
155
|
-
const { bounds } = await session.send('Browser.getWindowBounds', { windowId });
|
|
156
|
-
return bounds.windowState === 'minimized';
|
|
157
|
-
}
|
|
158
|
-
catch {
|
|
159
|
-
return true;
|
|
160
|
-
}
|
|
161
|
-
finally {
|
|
162
|
-
await session?.detach().catch(() => { });
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
/**
|
|
166
|
-
* Create a tab without raising the window. context.newPage() routes through CDP
|
|
167
|
-
* Target.createTarget without `background`, and Chromium activates the window
|
|
168
|
-
* on every tab creation -- plainly visible once the user has minimized it.
|
|
169
|
-
* Sending the command ourselves with background:true creates the tab silently;
|
|
170
|
-
* Playwright still auto-attaches and returns a fully driveable Page (verified
|
|
171
|
-
* live: Playwright learns the page and evaluate() runs on it). The URL is left
|
|
172
|
-
* at about:blank on purpose so the caller's own goto keeps owning navigation
|
|
173
|
-
* policy, timeouts and abort handling. On any failure this falls back to
|
|
174
|
-
* newPage(): a tab that pops the window beats no tab at all.
|
|
175
|
-
*/
|
|
176
|
-
async function createBackgroundPage(context) {
|
|
177
|
-
const anchor = context.pages()[0];
|
|
178
|
-
if (anchor === undefined)
|
|
179
|
-
return await context.newPage();
|
|
180
|
-
let session;
|
|
181
|
-
try {
|
|
182
|
-
session = await context.newCDPSession(anchor);
|
|
183
|
-
// Arm the listener before sending: Playwright attaches as soon as the
|
|
184
|
-
// target exists, which can happen before createTarget resolves.
|
|
185
|
-
const attached = context.waitForEvent('page', { timeout: 10000 });
|
|
186
|
-
await session.send('Target.createTarget', { url: 'about:blank', background: true });
|
|
187
|
-
return await attached;
|
|
188
|
-
}
|
|
189
|
-
catch {
|
|
190
|
-
return await context.newPage();
|
|
191
|
-
}
|
|
192
|
-
finally {
|
|
193
|
-
await session?.detach().catch(() => { });
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
export class PlaywrightProvider {
|
|
197
|
-
config;
|
|
198
|
-
id = 'playwright';
|
|
199
|
-
/** Legacy (persistent:false) shared browser instance. */
|
|
200
|
-
browser;
|
|
201
|
-
/** Persistent-mode shared profile-backed context (the personal window). */
|
|
202
|
-
context;
|
|
203
|
-
entries = new Map();
|
|
204
|
-
launchError;
|
|
205
|
-
/** Resolved profile directory (persistent mode only). */
|
|
206
|
-
profileDir = '';
|
|
207
|
-
/**
|
|
208
|
-
* Where cookies+localStorage are exported for the session-cookie fallback.
|
|
209
|
-
* Undefined outside persistent mode, and that is load-bearing: every writer
|
|
210
|
-
* and reader below guards on `undefined`, while an empty string would make
|
|
211
|
-
* `'' + '.tmp'` land in the process working directory.
|
|
212
|
-
*/
|
|
213
|
-
stateFile = undefined;
|
|
214
|
-
/** Owner that raised the dialog currently parked on a page. */
|
|
215
|
-
lastActor;
|
|
216
|
-
lastStatePersist = 0;
|
|
217
|
-
constructor(config) {
|
|
218
|
-
this.config = config;
|
|
219
|
-
// Tell the settings card which page-reading mode this deployment was
|
|
220
|
-
// configured with, so it can show "当前:X(默认)" truthfully instead of
|
|
221
|
-
// assuming the built-in default.
|
|
222
|
-
publishConfiguredEngine(config.snapshot.engine);
|
|
223
|
-
if (config.launch.persistent) {
|
|
224
|
-
const dir = config.launch.profileDir !== undefined && config.launch.profileDir.trim() !== ''
|
|
225
|
-
? config.launch.profileDir
|
|
226
|
-
: path.join(os.homedir(), '.dsh', 'browser-profiles', 'playwright');
|
|
227
|
-
this.profileDir = dir;
|
|
228
|
-
this.stateFile = path.join(dir, 'dsh-storage-state.json');
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
available() {
|
|
232
|
-
return true;
|
|
233
|
-
}
|
|
234
|
-
/** Pages already carrying the dialog guard (WeakSet: pages stay collectable). */
|
|
235
|
-
armedDialogs = new WeakSet();
|
|
236
|
-
/** Pending native dialog per page: { dialog, type, message }. Never auto-resolved. */
|
|
237
|
-
pendingDialogs = new Map();
|
|
238
|
-
/** Resolvers waiting for the next dialog on a page (the action/dialog race). */
|
|
239
|
-
dialogWaits = new Map();
|
|
240
|
-
/**
|
|
241
|
-
* Make a page report native dialogs instead of having them silently
|
|
242
|
-
* dismissed. The dialog is parked as a per-page pending state and left
|
|
243
|
-
* untouched: the page stays blocked until browser_dialog answers it, and
|
|
244
|
-
* whichever action raised it returns early through the dialog race.
|
|
245
|
-
* See DIALOG-POLICY.md for why this shape beats any parameter-level check.
|
|
246
|
-
*/
|
|
247
|
-
armDialogGuard(page) {
|
|
248
|
-
if (this.armedDialogs.has(page))
|
|
249
|
-
return;
|
|
250
|
-
this.armedDialogs.add(page);
|
|
251
|
-
page.on('dialog', (dialog) => {
|
|
252
|
-
this.pendingDialogs.set(page, { dialog, type: dialog.type(), message: dialog.message(), owner: this.lastActor });
|
|
253
|
-
const waiters = this.dialogWaits.get(page);
|
|
254
|
-
this.dialogWaits.delete(page);
|
|
255
|
-
if (waiters !== undefined) {
|
|
256
|
-
for (const wake of waiters)
|
|
257
|
-
wake();
|
|
258
|
-
}
|
|
259
|
-
});
|
|
260
|
-
}
|
|
261
|
-
/** The dialog currently blocking this page, or undefined. */
|
|
262
|
-
pendingDialogFor(page) {
|
|
263
|
-
return this.pendingDialogs.get(page);
|
|
264
|
-
}
|
|
265
|
-
/**
|
|
266
|
-
* Remove and return the pending dialog (atomic: one dialog, one answer).
|
|
267
|
-
* Only the owner that raised it may take it: in persistent mode several
|
|
268
|
-
* sessions share one page, and answering another session's dialog would
|
|
269
|
-
* execute its action on their behalf.
|
|
270
|
-
*/
|
|
271
|
-
takePendingDialog(page, owner) {
|
|
272
|
-
const pending = this.pendingDialogs.get(page);
|
|
273
|
-
if (pending === undefined)
|
|
274
|
-
return undefined;
|
|
275
|
-
if (pending.owner !== undefined && owner !== undefined && pending.owner !== owner)
|
|
276
|
-
return undefined;
|
|
277
|
-
this.pendingDialogs.delete(page);
|
|
278
|
-
return pending;
|
|
279
|
-
}
|
|
280
|
-
/**
|
|
281
|
-
* Wake when a dialog appears on this page. cancel() unregisters the waiter
|
|
282
|
-
* so a finished action leaves nothing behind.
|
|
283
|
-
*/
|
|
284
|
-
armDialogWait(page) {
|
|
285
|
-
const waiters = this.dialogWaits.get(page) ?? new Set();
|
|
286
|
-
let wake = () => { };
|
|
287
|
-
const promise = new Promise((resolve) => {
|
|
288
|
-
wake = () => resolve(true);
|
|
289
|
-
});
|
|
290
|
-
waiters.add(wake);
|
|
291
|
-
this.dialogWaits.set(page, waiters);
|
|
292
|
-
return {
|
|
293
|
-
promise,
|
|
294
|
-
cancel: () => {
|
|
295
|
-
waiters.delete(wake);
|
|
296
|
-
},
|
|
297
|
-
};
|
|
298
|
-
}
|
|
299
|
-
/** One line describing a parked dialog for the tool result. */
|
|
300
|
-
dialogNoteFor(pending) {
|
|
301
|
-
return ('[dialog] ' +
|
|
302
|
-
pending.type +
|
|
303
|
-
' "' +
|
|
304
|
-
pending.message.replace(/\s+/g, ' ').trim().slice(0, 300) +
|
|
305
|
-
'"' +
|
|
306
|
-
' is PENDING and the page is blocked until it is answered. Nothing has been accepted or cancelled yet.' +
|
|
307
|
-
' Answer it with browser_dialog({ accept }) — accept only if the user authorized exactly this action,' +
|
|
308
|
-
' otherwise dismiss; no other tool can act until it is answered.');
|
|
309
|
-
}
|
|
310
|
-
async acquire(owner) {
|
|
311
|
-
if (!getEnabled()) {
|
|
312
|
-
throw new BrowserError('DISABLED', 'dsh-browser-playwright-codex is disabled; open Settings → Plugins → 浏览器 and turn it on to use the browser');
|
|
313
|
-
}
|
|
314
|
-
const existing = this.entries.get(owner);
|
|
315
|
-
if (existing) {
|
|
316
|
-
existing.lastUsed = Date.now();
|
|
317
|
-
this.armIdle(existing);
|
|
318
|
-
return existing.session;
|
|
319
|
-
}
|
|
320
|
-
if (this.config.launch.persistent) {
|
|
321
|
-
// Personal-browser mode: all owners drive one shared profile-backed
|
|
322
|
-
// window (Codex-style). Entries are lightweight session handles on
|
|
323
|
-
// the shared context; the last owner to leave closes the window.
|
|
324
|
-
const context = await this.ensureContext();
|
|
325
|
-
const session = new PlaywrightSession(this, owner, context);
|
|
326
|
-
const entry = { context, session, lastUsed: Date.now(), pending: 0, timer: undefined };
|
|
327
|
-
this.entries.set(owner, entry);
|
|
328
|
-
this.armIdle(entry);
|
|
329
|
-
return session;
|
|
330
|
-
}
|
|
331
|
-
const browser = await this.ensureBrowser();
|
|
332
|
-
while (this.entries.size >= this.config.maxSessions) {
|
|
333
|
-
const oldest = [...this.entries.entries()].sort((a, b) => a[1].lastUsed - b[1].lastUsed)[0];
|
|
334
|
-
if (oldest === undefined)
|
|
335
|
-
break;
|
|
336
|
-
await this.disposeOwner(oldest[0]);
|
|
337
|
-
}
|
|
338
|
-
const context = await browser.newContext({
|
|
339
|
-
// Headful: viewport: null lets each page track its real window
|
|
340
|
-
// (system DPI respected) exactly like a hand-opened Chrome, instead
|
|
341
|
-
// of Playwright forcing a fixed CSS viewport that overflows the
|
|
342
|
-
// window on scaled displays. Headless keeps the fixed viewport.
|
|
343
|
-
...(this.config.launch.headless ? { viewport: this.config.launch.viewport } : { viewport: null }),
|
|
344
|
-
ignoreHTTPSErrors: this.config.launch.ignoreHTTPSErrors,
|
|
345
|
-
});
|
|
346
|
-
context.on('page', (page) => this.armDialogGuard(page));
|
|
347
|
-
await context.addInitScript(SNAPSHOT_SCRIPT);
|
|
348
|
-
const session = new PlaywrightSession(this, owner, context);
|
|
349
|
-
const entry = { context, session, lastUsed: Date.now(), pending: 0, timer: undefined };
|
|
350
|
-
this.entries.set(owner, entry);
|
|
351
|
-
this.armIdle(entry);
|
|
352
|
-
void context.on('close', () => {
|
|
353
|
-
if (this.entries.get(owner) === entry) {
|
|
354
|
-
if (entry.timer !== undefined)
|
|
355
|
-
clearTimeout(entry.timer);
|
|
356
|
-
this.entries.delete(owner);
|
|
357
|
-
}
|
|
358
|
-
});
|
|
359
|
-
return session;
|
|
360
|
-
}
|
|
361
|
-
async disposeOwner(owner) {
|
|
362
|
-
const entry = this.entries.get(owner);
|
|
363
|
-
if (entry === undefined)
|
|
364
|
-
return;
|
|
365
|
-
if (entry.timer !== undefined)
|
|
366
|
-
clearTimeout(entry.timer);
|
|
367
|
-
this.entries.delete(owner);
|
|
368
|
-
if (this.config.launch.persistent) {
|
|
369
|
-
// Close the shared window only when the last owner leaves; the
|
|
370
|
-
// profile and state file keep the login for the next launch.
|
|
371
|
-
if (this.entries.size === 0 && this.context !== undefined) {
|
|
372
|
-
const ctx = this.context;
|
|
373
|
-
this.context = undefined;
|
|
374
|
-
await this.persistState(ctx);
|
|
375
|
-
await ctx.close().catch(() => { });
|
|
376
|
-
}
|
|
377
|
-
return;
|
|
378
|
-
}
|
|
379
|
-
await entry.context.close().catch(() => { });
|
|
380
|
-
}
|
|
381
|
-
async dispose() {
|
|
382
|
-
const owners = [...this.entries.keys()];
|
|
383
|
-
for (const owner of owners)
|
|
384
|
-
await this.disposeOwner(owner);
|
|
385
|
-
if (this.config.launch.persistent) {
|
|
386
|
-
if (this.context !== undefined) {
|
|
387
|
-
const ctx = this.context;
|
|
388
|
-
this.context = undefined;
|
|
389
|
-
await this.persistState(ctx);
|
|
390
|
-
await ctx.close().catch(() => { });
|
|
391
|
-
}
|
|
392
|
-
return;
|
|
393
|
-
}
|
|
394
|
-
if (this.browser !== undefined) {
|
|
395
|
-
await this.browser.close().catch(() => { });
|
|
396
|
-
this.browser = undefined;
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
/** Touch the entry's last-used clock and re-arm its idle timer. */
|
|
400
|
-
touch(owner) {
|
|
401
|
-
const entry = this.entries.get(owner);
|
|
402
|
-
if (entry === undefined)
|
|
403
|
-
return;
|
|
404
|
-
entry.lastUsed = Date.now();
|
|
405
|
-
this.armIdle(entry);
|
|
406
|
-
}
|
|
407
|
-
/** Mark one operation in flight so the idle timer cannot kill it mid-way. */
|
|
408
|
-
beginOp(owner) {
|
|
409
|
-
// Remember who is driving: a dialog raised during this call belongs to
|
|
410
|
-
// that owner, and no other session may answer it.
|
|
411
|
-
this.lastActor = owner;
|
|
412
|
-
const entry = this.entries.get(owner);
|
|
413
|
-
if (entry !== undefined)
|
|
414
|
-
entry.pending += 1;
|
|
415
|
-
}
|
|
416
|
-
/** Settle one operation and restart the idle countdown. */
|
|
417
|
-
endOp(owner) {
|
|
418
|
-
const entry = this.entries.get(owner);
|
|
419
|
-
if (entry === undefined)
|
|
420
|
-
return;
|
|
421
|
-
entry.pending = Math.max(0, entry.pending - 1);
|
|
422
|
-
this.touch(owner);
|
|
423
|
-
// Opportunistically export login state after actions (throttled) so a
|
|
424
|
-
// manual window close by the user never loses session cookies.
|
|
425
|
-
this.persistStateSoon(entry.context);
|
|
426
|
-
}
|
|
427
|
-
/** Ensure the session entry for an owner still exists. */
|
|
428
|
-
isLive(owner) {
|
|
429
|
-
return this.entries.has(owner);
|
|
430
|
-
}
|
|
431
|
-
/** Drop an owner's entry after a page-level crash without closing the window. */
|
|
432
|
-
invalidateOwner(owner) {
|
|
433
|
-
const entry = this.entries.get(owner);
|
|
434
|
-
if (entry === undefined)
|
|
435
|
-
return;
|
|
436
|
-
if (entry.timer !== undefined)
|
|
437
|
-
clearTimeout(entry.timer);
|
|
438
|
-
this.entries.delete(owner);
|
|
439
|
-
}
|
|
440
|
-
armIdle(entry) {
|
|
441
|
-
if (entry.timer !== undefined)
|
|
442
|
-
clearTimeout(entry.timer);
|
|
443
|
-
entry.timer = undefined;
|
|
444
|
-
const idle = this.config.idleTimeoutMs;
|
|
445
|
-
if (idle <= 0)
|
|
446
|
-
return;
|
|
447
|
-
entry.timer = setTimeout(() => {
|
|
448
|
-
// A slow operation can outlive the idle window; defer disposal until
|
|
449
|
-
// it settles instead of killing its browser context mid-flight.
|
|
450
|
-
if (entry.pending > 0) {
|
|
451
|
-
this.armIdle(entry);
|
|
452
|
-
return;
|
|
453
|
-
}
|
|
454
|
-
void this.disposeOwner(entry.session.owner);
|
|
455
|
-
}, idle);
|
|
456
|
-
entry.timer.unref?.();
|
|
457
|
-
}
|
|
458
|
-
/**
|
|
459
|
-
* Ensure the shared persistent context is alive (persistent mode). A
|
|
460
|
-
* profile-backed window is launched once and reused until closed by the
|
|
461
|
-
* last owner, idle disposal, or an external close; login state lives in
|
|
462
|
-
* the profile plus the exported state file, so reopening is seamless.
|
|
463
|
-
*/
|
|
464
|
-
async ensureContext() {
|
|
465
|
-
if (this.context !== undefined) {
|
|
466
|
-
try {
|
|
467
|
-
const browser = this.context.browser();
|
|
468
|
-
if (browser !== null && browser.isConnected())
|
|
469
|
-
return this.context;
|
|
470
|
-
}
|
|
471
|
-
catch {
|
|
472
|
-
/* context died underneath us: relaunch */
|
|
473
|
-
}
|
|
474
|
-
this.context = undefined;
|
|
475
|
-
}
|
|
476
|
-
fs.mkdirSync(this.profileDir, { recursive: true });
|
|
477
|
-
const { executablePath, channel, headless } = this.config.launch;
|
|
478
|
-
const headful = !headless;
|
|
479
|
-
const attempts = [];
|
|
480
|
-
const baseOptions = {
|
|
481
|
-
headless,
|
|
482
|
-
// Headful windows must render like a normal Chrome the user opened
|
|
483
|
-
// by hand: the page viewport equals the real window size and the
|
|
484
|
-
// system DPI scaling (e.g. 150%) is honoured. Playwright's default
|
|
485
|
-
// is the opposite — it pins every page to a fixed CSS viewport at
|
|
486
|
-
// deviceScaleFactor 1 via Emulation.setDeviceMetricsOverride, which
|
|
487
|
-
// on scaled displays overflows the window: content looks zoomed in
|
|
488
|
-
// and clipped. viewport: null disables that override entirely.
|
|
489
|
-
// Headless mode keeps the fixed viewport (no window to track).
|
|
490
|
-
...(headless ? { viewport: this.config.launch.viewport } : { viewport: null }),
|
|
491
|
-
ignoreHTTPSErrors: this.config.launch.ignoreHTTPSErrors,
|
|
492
|
-
...HUMANIZED_LAUNCH,
|
|
493
|
-
args: [
|
|
494
|
-
'--no-first-run',
|
|
495
|
-
'--no-default-browser-check',
|
|
496
|
-
// Open maximised like a human would, so the first paint fills
|
|
497
|
-
// the screen instead of a small default window.
|
|
498
|
-
...(headful ? ['--start-maximized'] : []),
|
|
499
|
-
...HUMANIZED_LAUNCH.args,
|
|
500
|
-
],
|
|
501
|
-
};
|
|
502
|
-
const start = async (extra) => {
|
|
503
|
-
try {
|
|
504
|
-
this.context = await chromium.launchPersistentContext(this.profileDir, { ...baseOptions, ...extra });
|
|
505
|
-
}
|
|
506
|
-
catch (error) {
|
|
507
|
-
// A stale Chrome singleton lock from a dirty shutdown breaks a
|
|
508
|
-
// relaunch into the same profile; clear the lock files and
|
|
509
|
-
// retry once before surfacing the failure.
|
|
510
|
-
if (error instanceof Error && /user data directory|SingletonLock|ProcessSingleton/i.test(error.message)) {
|
|
511
|
-
await this.clearChromeLocks();
|
|
512
|
-
this.context = await chromium.launchPersistentContext(this.profileDir, { ...baseOptions, ...extra });
|
|
513
|
-
}
|
|
514
|
-
else {
|
|
515
|
-
throw error;
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
const ctx = this.context;
|
|
519
|
-
// External close (user closed the window, crash): forget every
|
|
520
|
-
// session handle bound to this context; the next acquire relaunches.
|
|
521
|
-
void ctx.on('close', () => {
|
|
522
|
-
if (this.context !== ctx)
|
|
523
|
-
return;
|
|
524
|
-
this.context = undefined;
|
|
525
|
-
for (const [key, entry] of [...this.entries]) {
|
|
526
|
-
if (entry.context !== ctx)
|
|
527
|
-
continue;
|
|
528
|
-
if (entry.timer !== undefined)
|
|
529
|
-
clearTimeout(entry.timer);
|
|
530
|
-
this.entries.delete(key);
|
|
531
|
-
}
|
|
532
|
-
});
|
|
533
|
-
ctx.on('page', (page) => this.armDialogGuard(page));
|
|
534
|
-
try {
|
|
535
|
-
await this.restoreState(ctx);
|
|
536
|
-
}
|
|
537
|
-
catch {
|
|
538
|
-
/* malformed state file must not block startup */
|
|
539
|
-
}
|
|
540
|
-
await ctx.addInitScript(SNAPSHOT_SCRIPT);
|
|
541
|
-
// The initial blank page predates addInitScript: reload so the
|
|
542
|
-
// snapshot engine and restored localStorage apply on first use.
|
|
543
|
-
for (const page of ctx.pages()) {
|
|
544
|
-
await page.reload({ waitUntil: 'domcontentloaded' }).catch(() => { });
|
|
545
|
-
}
|
|
546
|
-
return ctx;
|
|
547
|
-
};
|
|
548
|
-
if (executablePath !== undefined) {
|
|
549
|
-
try {
|
|
550
|
-
return await start({ executablePath });
|
|
551
|
-
}
|
|
552
|
-
catch (error) {
|
|
553
|
-
this.launchError = error;
|
|
554
|
-
attempts.push('executablePath ' + executablePath);
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
else {
|
|
558
|
-
const channels = channel !== undefined && channel !== '' ? [channel] : [...AUTO_CHANNELS];
|
|
559
|
-
for (const candidate of channels) {
|
|
560
|
-
try {
|
|
561
|
-
return await start({ channel: candidate });
|
|
562
|
-
}
|
|
563
|
-
catch (error) {
|
|
564
|
-
this.launchError = error;
|
|
565
|
-
attempts.push('channel ' + candidate);
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
const hint = 'Tried: ' +
|
|
570
|
-
attempts.join(', ') +
|
|
571
|
-
'. ' +
|
|
572
|
-
'Install a Chromium-family browser, or run: npx playwright-core install chromium ' +
|
|
573
|
-
'(in restricted networks set PLAYWRIGHT_DOWNLOAD_HOST=https://cdn.npmmirror.com/binaries/playwright), ' +
|
|
574
|
-
'or configure launch.channel / launch.executablePath.';
|
|
575
|
-
if (this.launchError instanceof Error && /executable doesn't exist|not found/i.test(this.launchError.message)) {
|
|
576
|
-
throw new BrowserError('NO_BROWSER', hint);
|
|
577
|
-
}
|
|
578
|
-
throw launchFailed(this.launchError);
|
|
579
|
-
}
|
|
580
|
-
/** Remove stale Chrome singleton lock files inside the profile directory. */
|
|
581
|
-
async clearChromeLocks() {
|
|
582
|
-
for (const lock of ['SingletonLock', 'SingletonCookie', 'SingletonSocket']) {
|
|
583
|
-
try {
|
|
584
|
-
fs.rmSync(path.join(this.profileDir, lock), { force: true });
|
|
585
|
-
}
|
|
586
|
-
catch {
|
|
587
|
-
/* best-effort */
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
/**
|
|
592
|
-
* Collect the login-state fallback WITHOUT ever opening a page while the
|
|
593
|
-
* window is put away.
|
|
594
|
-
*
|
|
595
|
-
* `context.storageState()` is not a pure read. For every origin this context
|
|
596
|
-
* has visited whose page is since gone, it opens a TEMPORARY PAGE, navigates
|
|
597
|
-
* it to that origin, reads its storage, and closes it again
|
|
598
|
-
* (playwright-core 1.62.1 `coreBundle.js:51663-51683`). The origins set is
|
|
599
|
-
* accumulated by `addVisitedOrigin` on every navigation
|
|
600
|
-
* (`coreBundle.js:22352` → `51634`) and only reset by `setStorageState`, so a
|
|
601
|
-
* long-lived context always has leftovers. That temporary page is created
|
|
602
|
-
* through `Target.createTarget` without `background: true`
|
|
603
|
-
* (`coreBundle.js:38340-38342`) — and Chromium activates the window on every
|
|
604
|
-
* tab creation. Same root as Cause 2 in FOCUS-STEALING.md, different trigger:
|
|
605
|
-
* a bookkeeping step that ran after every call could drag a minimized window
|
|
606
|
-
* back onto the screen.
|
|
607
|
-
*
|
|
608
|
-
* Cookies alone never open a page (`coreBundle.js:51639`), so when the window
|
|
609
|
-
* is minimized — or its state cannot be read at all — export cookies only.
|
|
610
|
-
* Nothing durable is lost: this file's job is the session-cookie fallback, and
|
|
611
|
-
* localStorage already survives in the persistent profile. When the window is
|
|
612
|
-
* positively visible we take the full `storageState()`, because a temporary
|
|
613
|
-
* page cannot disturb someone who is already looking at the window.
|
|
614
|
-
*/
|
|
615
|
-
async collectLoginState(context) {
|
|
616
|
-
const anchor = context.pages()[0];
|
|
617
|
-
if (anchor !== undefined && !(await isWindowMinimized(anchor)))
|
|
618
|
-
return await context.storageState();
|
|
619
|
-
return { cookies: await context.cookies(), origins: [] };
|
|
620
|
-
}
|
|
621
|
-
/** Export the login-state fallback to the state file (session-cookie fallback). */
|
|
622
|
-
async persistState(context) {
|
|
623
|
-
if (this.stateFile === undefined || context === undefined)
|
|
624
|
-
return;
|
|
625
|
-
try {
|
|
626
|
-
const state = await this.collectLoginState(context);
|
|
627
|
-
const tmp = this.stateFile + '.tmp';
|
|
628
|
-
fs.mkdirSync(path.dirname(this.stateFile), { recursive: true });
|
|
629
|
-
fs.writeFileSync(tmp, JSON.stringify(state));
|
|
630
|
-
fs.renameSync(tmp, this.stateFile);
|
|
631
|
-
this.lastStatePersist = Date.now();
|
|
632
|
-
}
|
|
633
|
-
catch {
|
|
634
|
-
/* best-effort: the profile itself already holds durable cookies */
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
/**
|
|
638
|
-
* Throttled, fire-and-forget variant called after every operation.
|
|
639
|
-
*
|
|
640
|
-
* The "is the window minimized?" question moved inside `collectLoginState`,
|
|
641
|
-
* which answers it by choosing a safe API rather than by giving up: the export
|
|
642
|
-
* now runs in either window state, so the session-cookie fallback stops having
|
|
643
|
-
* a hole exactly where it matters most (a user who minimized the window is the
|
|
644
|
-
* one most likely to close it by hand).
|
|
645
|
-
*/
|
|
646
|
-
persistStateSoon(context) {
|
|
647
|
-
if (this.stateFile === undefined || context === undefined)
|
|
648
|
-
return;
|
|
649
|
-
if (Date.now() - this.lastStatePersist < 1000)
|
|
650
|
-
return;
|
|
651
|
-
void this.persistState(context).catch(() => { });
|
|
652
|
-
}
|
|
653
|
-
/**
|
|
654
|
-
* Re-inject a previously exported state. Durable cookies already live in
|
|
655
|
-
* the profile; this covers session cookies the browser drops on close and
|
|
656
|
-
* localStorage entries, so logins survive window close/reopen.
|
|
657
|
-
*/
|
|
658
|
-
async restoreState(context) {
|
|
659
|
-
if (this.stateFile === undefined)
|
|
660
|
-
return;
|
|
661
|
-
let state;
|
|
662
|
-
try {
|
|
663
|
-
state = JSON.parse(fs.readFileSync(this.stateFile, 'utf8'));
|
|
664
|
-
}
|
|
665
|
-
catch {
|
|
666
|
-
return;
|
|
667
|
-
}
|
|
668
|
-
if (state === null || typeof state !== 'object')
|
|
669
|
-
return;
|
|
670
|
-
const cookies = Array.isArray(state.cookies)
|
|
671
|
-
? state.cookies.filter((c) => c !== null &&
|
|
672
|
-
typeof c === 'object' &&
|
|
673
|
-
typeof c.name === 'string' &&
|
|
674
|
-
c.value !== undefined &&
|
|
675
|
-
typeof c.domain === 'string')
|
|
676
|
-
: [];
|
|
677
|
-
if (cookies.length > 0) {
|
|
678
|
-
await context.addCookies(cookies.map((c) => ({
|
|
679
|
-
name: c.name,
|
|
680
|
-
value: c.value,
|
|
681
|
-
domain: c.domain,
|
|
682
|
-
path: typeof c.path === 'string' && c.path !== '' ? c.path : '/',
|
|
683
|
-
...(typeof c.expires === 'number' && c.expires > 0 ? { expires: c.expires } : {}),
|
|
684
|
-
...(typeof c.httpOnly === 'boolean' ? { httpOnly: c.httpOnly } : {}),
|
|
685
|
-
...(typeof c.secure === 'boolean' ? { secure: c.secure } : {}),
|
|
686
|
-
...(typeof c.sameSite === 'string' ? { sameSite: c.sameSite } : {}),
|
|
687
|
-
})));
|
|
688
|
-
}
|
|
689
|
-
const origins = Array.isArray(state.origins)
|
|
690
|
-
? state.origins.filter((o) => o !== null &&
|
|
691
|
-
typeof o === 'object' &&
|
|
692
|
-
typeof o.origin === 'string' &&
|
|
693
|
-
Array.isArray(o.localStorage) &&
|
|
694
|
-
o.localStorage.length > 0)
|
|
695
|
-
: [];
|
|
696
|
-
if (origins.length > 0) {
|
|
697
|
-
await context.addInitScript(({ rows }) => {
|
|
698
|
-
for (const o of rows) {
|
|
699
|
-
if (location.origin !== o.origin)
|
|
700
|
-
continue;
|
|
701
|
-
for (const kv of o.localStorage) {
|
|
702
|
-
try {
|
|
703
|
-
localStorage.setItem(kv.name, kv.value);
|
|
704
|
-
}
|
|
705
|
-
catch {
|
|
706
|
-
/* quota / privacy mode */
|
|
707
|
-
}
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
}, { rows: origins });
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
/** Launch the browser once, probing the configured or auto-detected channel. */
|
|
714
|
-
async ensureBrowser() {
|
|
715
|
-
if (this.browser !== undefined)
|
|
716
|
-
return this.browser;
|
|
717
|
-
const { executablePath, channel, headless } = this.config.launch;
|
|
718
|
-
const attempts = [];
|
|
719
|
-
if (executablePath !== undefined) {
|
|
720
|
-
try {
|
|
721
|
-
this.browser = await chromium.launch({ executablePath, headless, ...HUMANIZED_LAUNCH });
|
|
722
|
-
return this.browser;
|
|
723
|
-
}
|
|
724
|
-
catch (error) {
|
|
725
|
-
this.launchError = error;
|
|
726
|
-
attempts.push('executablePath ' + executablePath);
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
else {
|
|
730
|
-
const channels = channel !== undefined && channel !== '' ? [channel] : [...AUTO_CHANNELS];
|
|
731
|
-
for (const candidate of channels) {
|
|
732
|
-
try {
|
|
733
|
-
this.browser = await chromium.launch({ channel: candidate, headless, ...HUMANIZED_LAUNCH });
|
|
734
|
-
return this.browser;
|
|
735
|
-
}
|
|
736
|
-
catch (error) {
|
|
737
|
-
this.launchError = error;
|
|
738
|
-
attempts.push('channel ' + candidate);
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
const hint = 'Tried: ' +
|
|
743
|
-
attempts.join(', ') +
|
|
744
|
-
'. ' +
|
|
745
|
-
'Install a Chromium-family browser, or run: npx playwright-core install chromium ' +
|
|
746
|
-
'(in restricted networks set PLAYWRIGHT_DOWNLOAD_HOST=https://cdn.npmmirror.com/binaries/playwright), ' +
|
|
747
|
-
'or configure launch.channel / launch.executablePath.';
|
|
748
|
-
if (this.launchError instanceof Error && /executable doesn't exist|not found/i.test(this.launchError.message)) {
|
|
749
|
-
throw new BrowserError('NO_BROWSER', hint);
|
|
750
|
-
}
|
|
751
|
-
throw launchFailed(this.launchError);
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
/** Serialize a Playwright timeout into a stable browser error. Code is
|
|
755
|
-
* prefixed on the message so the model sees a stable machine-readable label. */
|
|
756
|
-
function asTimeoutError(kind, cause) {
|
|
757
|
-
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
758
|
-
if (kind === 'navigation')
|
|
759
|
-
return new BrowserError('NAVIGATION_TIMEOUT', '[NAVIGATION_TIMEOUT] the browser operation timed out: ' + detail);
|
|
760
|
-
return new BrowserError('ACTION_TIMEOUT', '[ACTION_TIMEOUT] the browser action timed out: ' + detail);
|
|
761
|
-
}
|
|
762
|
-
/** Feature flags carried by an added diff entry. */
|
|
763
|
-
function flagsOf(sig) {
|
|
764
|
-
const flags = [];
|
|
765
|
-
if (sig.checked === true)
|
|
766
|
-
flags.push('checked');
|
|
767
|
-
if (sig.selected === true)
|
|
768
|
-
flags.push('selected');
|
|
769
|
-
if (sig.disabled === true)
|
|
770
|
-
flags.push('disabled');
|
|
771
|
-
if (sig.level !== undefined)
|
|
772
|
-
flags.push('level=' + String(sig.level));
|
|
773
|
-
if (sig.href !== undefined)
|
|
774
|
-
flags.push('href=' + sig.href);
|
|
775
|
-
return flags;
|
|
776
|
-
}
|
|
777
|
-
/** Flag keys whose value changed between two signatures. */
|
|
778
|
-
function flagsDeltaOf(before, after) {
|
|
779
|
-
const delta = [];
|
|
780
|
-
const keys = ['checked', 'selected', 'disabled', 'level', 'href'];
|
|
781
|
-
for (const key of keys) {
|
|
782
|
-
const b = before[key];
|
|
783
|
-
const a = after[key];
|
|
784
|
-
if (String(b ?? '') === String(a ?? ''))
|
|
785
|
-
continue;
|
|
786
|
-
delta.push(a !== undefined && a !== false
|
|
787
|
-
? key === 'level'
|
|
788
|
-
? 'level=' + String(a)
|
|
789
|
-
: key === 'href'
|
|
790
|
-
? 'href=' + a
|
|
791
|
-
: key
|
|
792
|
-
: key);
|
|
793
|
-
}
|
|
794
|
-
return delta;
|
|
795
|
-
}
|
|
796
|
-
/** Build a SnapshotDiffEntry, dropping undefined optionals (exactOptionalPropertyTypes). */
|
|
797
|
-
function diffEntry(input) {
|
|
798
|
-
const out = { ref: input.ref };
|
|
799
|
-
if (input.role !== undefined)
|
|
800
|
-
out.role = input.role;
|
|
801
|
-
if (input.name !== undefined)
|
|
802
|
-
out.name = input.name;
|
|
803
|
-
if (input.flags !== undefined)
|
|
804
|
-
out.flags = input.flags;
|
|
805
|
-
if (input.flagsDelta !== undefined)
|
|
806
|
-
out.flagsDelta = input.flagsDelta;
|
|
807
|
-
if (input.parentRef !== undefined)
|
|
808
|
-
out.parentRef = input.parentRef;
|
|
809
|
-
return out;
|
|
810
|
-
}
|
|
811
|
-
/** Live session over one browser context owned by one caller. */
|
|
812
|
-
class PlaywrightSession {
|
|
813
|
-
provider;
|
|
814
|
-
owner;
|
|
815
|
-
context;
|
|
816
|
-
currentIndex = 0;
|
|
817
|
-
closed = false;
|
|
818
|
-
/** Set while answering a dialog or reporting it: page access must not be refused then. */
|
|
819
|
-
bypassDialogGuard = false;
|
|
820
|
-
/** Reused CDP session for the current page (DOM nodeIds are session-bound). */
|
|
821
|
-
cdpSession;
|
|
822
|
-
/** The page the cached CDP session is attached to. */
|
|
823
|
-
cdpPage;
|
|
824
|
-
/** Pages already wired for console/network capture (WeakSet: pages stay collectable). */
|
|
825
|
-
trackedPages = new WeakSet();
|
|
826
|
-
/** Stable id per page, so diagnostics can be reported for one page only. */
|
|
827
|
-
pageIds = new WeakMap();
|
|
828
|
-
/** Ids handed out so far (monotonic within the session). */
|
|
829
|
-
pageIdSeq = 0;
|
|
830
|
-
/** Bounded console + pageerror ring, shared by every tracked page of this session. */
|
|
831
|
-
consoleLog = [];
|
|
832
|
-
/** Bounded network ring, shared by every tracked page of this session. */
|
|
833
|
-
networkLog = [];
|
|
834
|
-
/** Monotonic sequence handed to each captured entry. */
|
|
835
|
-
diagSeq = 0;
|
|
836
|
-
/** Evicted entries, so truncation is visible instead of silent. */
|
|
837
|
-
diagDropped = { console: 0, network: 0 };
|
|
838
|
-
/** In-flight requests keyed by request object, for status and duration attribution. */
|
|
839
|
-
requestEntries = new WeakMap();
|
|
840
|
-
/** Ring capacity per stream. */
|
|
841
|
-
diagLimit = 200;
|
|
842
|
-
/** ref -> feature signature of the previous capture (diff baseline). */
|
|
843
|
-
lastRefSignatures = undefined;
|
|
844
|
-
/** Nonce of the baseline ref space; a change means navigation reset. */
|
|
845
|
-
lastRefNonce = undefined;
|
|
846
|
-
constructor(provider, owner, context) {
|
|
847
|
-
this.provider = provider;
|
|
848
|
-
this.owner = owner;
|
|
849
|
-
this.context = context;
|
|
850
|
-
}
|
|
851
|
-
/** Run one operation under busy tracking: idle disposal defers until it settles. */
|
|
852
|
-
run(operation) {
|
|
853
|
-
this.provider.beginOp(this.owner);
|
|
854
|
-
return Promise.resolve()
|
|
855
|
-
.then(operation)
|
|
856
|
-
.catch((error) => {
|
|
857
|
-
if (error instanceof BrowserError || isAbortError(error))
|
|
858
|
-
throw error;
|
|
859
|
-
// The browser or its page died mid-operation: forget this owner's
|
|
860
|
-
// session handle so the next acquire relaunches the window (the
|
|
861
|
-
// profile and state file restore the login). Surface a clear code
|
|
862
|
-
// instead of a raw protocol error.
|
|
863
|
-
if (isCrashError(error)) {
|
|
864
|
-
this.closed = true;
|
|
865
|
-
this.provider.invalidateOwner(this.owner);
|
|
866
|
-
throw new BrowserError('BROWSER_CRASHED', 'the browser crashed or disconnected during the operation; the next browser call reopens it with the saved login state. Original error: ' +
|
|
867
|
-
(error.message ?? String(error)));
|
|
868
|
-
}
|
|
869
|
-
throw error;
|
|
870
|
-
})
|
|
871
|
-
.finally(() => this.provider.endOp(this.owner));
|
|
872
|
-
}
|
|
873
|
-
/** Fast actionability pre-check (visible/enabled/editable) before an action. */
|
|
874
|
-
async assertActionable(locator, options = {}) {
|
|
875
|
-
const editable = options.editable === true;
|
|
876
|
-
const visible = await locator.isVisible().catch(() => false);
|
|
877
|
-
if (!visible) {
|
|
878
|
-
throw new BrowserError('ELEMENT_NOT_ACTIONABLE', 'the element is not visible — it may have moved or the page changed; take a fresh browser_snapshot and use a ref from its result');
|
|
879
|
-
}
|
|
880
|
-
const enabled = await locator.isEnabled().catch(() => false);
|
|
881
|
-
if (!enabled) {
|
|
882
|
-
throw new BrowserError('ELEMENT_NOT_ACTIONABLE', 'the element is disabled and cannot be acted on');
|
|
883
|
-
}
|
|
884
|
-
if (editable) {
|
|
885
|
-
const canEdit = await locator.isEditable().catch(() => false);
|
|
886
|
-
if (!canEdit) {
|
|
887
|
-
throw new BrowserError('ELEMENT_NOT_ACTIONABLE', 'the element is not editable (read-only or locked by the page)');
|
|
888
|
-
}
|
|
889
|
-
}
|
|
890
|
-
}
|
|
891
|
-
/** Wait for an in-flight navigation (started by a click) to settle. */
|
|
892
|
-
async settleNavigation(page) {
|
|
893
|
-
await page.waitForLoadState('domcontentloaded', { timeout: this.timeoutMs() }).catch(() => { });
|
|
894
|
-
}
|
|
895
|
-
/** Page-level render stability, bounded — a busy page never stalls the agent. */
|
|
896
|
-
async waitForPageStable(page, timeoutMs = 1200) {
|
|
897
|
-
await page.evaluate(pageStabilityProbe, { frames: 2, timeoutMs }).catch(() => false);
|
|
898
|
-
}
|
|
899
|
-
/** Element-level stability before a pointer/keyboard action, bounded. Returns true when stable. */
|
|
900
|
-
async waitForElementStable(locator) {
|
|
901
|
-
const ok = await locator.evaluate(elementStabilityProbe, { frames: 2, timeoutMs: 1500 }).catch(() => false);
|
|
902
|
-
return ok === true;
|
|
903
|
-
}
|
|
904
|
-
/** Post-action settle: wait out a real navigation, then render-stabilize before snapshotting. */
|
|
905
|
-
async settleAndSnapshot(page, navChanged) {
|
|
906
|
-
// Race the settle+capture against a fresh dialog: a chained dialog
|
|
907
|
-
// (one raised the moment the previous was answered) would block
|
|
908
|
-
// page.evaluate forever and hang this call.
|
|
909
|
-
const raced = await this.runAction(page, async () => {
|
|
910
|
-
if (navChanged) {
|
|
911
|
-
await page.waitForLoadState('load', { timeout: this.timeoutMs() }).catch(() => { });
|
|
912
|
-
}
|
|
913
|
-
await this.waitForPageStable(page, navChanged ? 1500 : 800);
|
|
914
|
-
return await this.capture(page, undefined);
|
|
915
|
-
});
|
|
916
|
-
if (raced.blocked)
|
|
917
|
-
return this.blockedSnapshot(page, raced.pending);
|
|
918
|
-
return raced.value;
|
|
919
|
-
}
|
|
920
|
-
/**
|
|
921
|
-
* Snapshot substitute while a native dialog blocks the page: only locally
|
|
922
|
-
* cached data (page.url() needs no round trip) — page.evaluate() and
|
|
923
|
-
* page.title() would both stall behind the dialog.
|
|
924
|
-
*/
|
|
925
|
-
blockedSnapshot(page, pending) {
|
|
926
|
-
return {
|
|
927
|
-
url: page.url(),
|
|
928
|
-
title: '',
|
|
929
|
-
nodes: [],
|
|
930
|
-
totalRefs: 0,
|
|
931
|
-
truncated: false,
|
|
932
|
-
dialogNote: this.provider.dialogNoteFor(pending),
|
|
933
|
-
};
|
|
934
|
-
}
|
|
935
|
-
/**
|
|
936
|
-
* Run one page action that may raise a native dialog. Such an action never
|
|
937
|
-
* resolves while the dialog is up, so race it against the dialog appearing
|
|
938
|
-
* and hand the caller the pending state instead (Playwright-MCP pattern).
|
|
939
|
-
*/
|
|
940
|
-
async runAction(page, action) {
|
|
941
|
-
const already = this.provider.pendingDialogFor(page);
|
|
942
|
-
if (already !== undefined)
|
|
943
|
-
return { blocked: true, pending: already };
|
|
944
|
-
const wait = this.provider.armDialogWait(page);
|
|
945
|
-
const outcome = action().then((value) => ({ ok: true, value }), (error) => ({ ok: false, error }));
|
|
946
|
-
const winner = await Promise.race([outcome, wait.promise]);
|
|
947
|
-
wait.cancel();
|
|
948
|
-
if (winner === true)
|
|
949
|
-
return { blocked: true, pending: this.provider.pendingDialogFor(page) };
|
|
950
|
-
if (winner.ok !== true)
|
|
951
|
-
throw winner.error;
|
|
952
|
-
return { blocked: false, value: winner.value };
|
|
953
|
-
}
|
|
954
|
-
/**
|
|
955
|
-
* Answer the pending native dialog and let the action blocked behind it
|
|
956
|
-
* finish. This is the only place a dialog is ever accepted or dismissed.
|
|
957
|
-
*/
|
|
958
|
-
async resolveDialog(accept, promptText, signal) {
|
|
959
|
-
return this.run(async () => {
|
|
960
|
-
this.assertLive();
|
|
961
|
-
if (typeof accept !== 'boolean') {
|
|
962
|
-
throw new BrowserError('PARAM_MISSING', 'browser_dialog needs accept: true (confirm) or false (cancel)');
|
|
963
|
-
}
|
|
964
|
-
const page = await this.withDialogGuardLifted(() => this.ensurePage());
|
|
965
|
-
// Atomic take: two concurrent answers cannot both resolve one dialog.
|
|
966
|
-
const pending = this.provider.takePendingDialog(page, this.owner);
|
|
967
|
-
if (pending === undefined) {
|
|
968
|
-
throw new BrowserError('NO_DIALOG', 'no native dialog is pending on this page: there is nothing to accept or dismiss');
|
|
969
|
-
}
|
|
970
|
-
const urlBefore = page.url();
|
|
971
|
-
try {
|
|
972
|
-
await withAbort(accept === true
|
|
973
|
-
? promptText !== undefined
|
|
974
|
-
? pending.dialog.accept(promptText)
|
|
975
|
-
: pending.dialog.accept()
|
|
976
|
-
: pending.dialog.dismiss(), signal);
|
|
977
|
-
}
|
|
978
|
-
catch (error) {
|
|
979
|
-
if (isAbortError(error))
|
|
980
|
-
throw error;
|
|
981
|
-
throw asTimeoutError('action', error);
|
|
982
|
-
}
|
|
983
|
-
const snap = await this.settleAndSnapshot(page, page.url() !== urlBefore);
|
|
984
|
-
snap.landingNote =
|
|
985
|
-
'[dialog] ' +
|
|
986
|
-
pending.type +
|
|
987
|
-
' "' +
|
|
988
|
-
pending.message.replace(/\s+/g, ' ').trim().slice(0, 300) +
|
|
989
|
-
'" was ' +
|
|
990
|
-
(accept === true ? 'ACCEPTED' : 'DISMISSED') +
|
|
991
|
-
' on request; the action blocked behind it has now resumed.';
|
|
992
|
-
return snap;
|
|
993
|
-
});
|
|
994
|
-
}
|
|
995
|
-
/** Read the snapshot tree from one page (must run inside a run()). */
|
|
996
|
-
async capture(page, opts) {
|
|
997
|
-
this.provider.armDialogGuard(page);
|
|
998
|
-
const raw = resolveSnapshotEngine(this.provider.config.snapshot.engine) === 'aria'
|
|
999
|
-
? await this.captureAria(page, opts)
|
|
1000
|
-
: await this.captureLegacy(page, opts);
|
|
1001
|
-
return this.attachDiff(raw);
|
|
1002
|
-
}
|
|
1003
|
-
/**
|
|
1004
|
-
* Semantic-tree capture via Playwright's official ariaSnapshot(mode:'ai').
|
|
1005
|
-
* An unavailable or unreadable aria tree (`missing`) degrades to the legacy
|
|
1006
|
-
* DOM walker instead of returning an empty snapshot: the model must never
|
|
1007
|
-
* receive "the page has no elements" because the engine failed to parse.
|
|
1008
|
-
*/
|
|
1009
|
-
async captureAria(page, opts) {
|
|
1010
|
-
const o = {
|
|
1011
|
-
interactiveOnly: opts?.interactiveOnly === true,
|
|
1012
|
-
maxNodes: this.provider.config.snapshot.maxNodes,
|
|
1013
|
-
maxNameLength: this.provider.config.snapshot.maxNameLength,
|
|
1014
|
-
maxTextLength: this.provider.config.snapshot.maxTextLength,
|
|
1015
|
-
};
|
|
1016
|
-
let raw;
|
|
1017
|
-
try {
|
|
1018
|
-
raw = await captureAriaSnapshot(page, o);
|
|
1019
|
-
}
|
|
1020
|
-
catch (error) {
|
|
1021
|
-
// Same mid-evaluate navigation race as the legacy path: settle once and retry.
|
|
1022
|
-
if (!isContextDestroyed(error))
|
|
1023
|
-
throw error;
|
|
1024
|
-
await this.settleNavigation(page);
|
|
1025
|
-
raw = await captureAriaSnapshot(page, o);
|
|
1026
|
-
}
|
|
1027
|
-
if (raw.missing === true) {
|
|
1028
|
-
// Degrade honestly: the tree below is the compatible engine's, and the
|
|
1029
|
-
// snapshot says so. Silence here is how a user who explicitly picked smart
|
|
1030
|
-
// mode keeps believing smart mode is what they are reading.
|
|
1031
|
-
const legacy = await this.captureLegacy(page, opts);
|
|
1032
|
-
return {
|
|
1033
|
-
...legacy,
|
|
1034
|
-
engineNote: 'smart mode could not read this page, so it was rendered with the compatible engine. ' +
|
|
1035
|
-
'The refs below belong to the compatible engine. Nothing is wrong with the page; ' +
|
|
1036
|
-
'switch the page-reading mode in Settings if you want to stop seeing this.',
|
|
1037
|
-
};
|
|
1038
|
-
}
|
|
1039
|
-
return {
|
|
1040
|
-
url: page.url(),
|
|
1041
|
-
title: await page.title(),
|
|
1042
|
-
nodes: raw.nodes ?? [],
|
|
1043
|
-
totalRefs: raw.totalRefs ?? 0,
|
|
1044
|
-
truncated: raw.truncated ?? false,
|
|
1045
|
-
};
|
|
1046
|
-
}
|
|
1047
|
-
/** Legacy capture: injected DOM walker (default engine, byte-identical output). */
|
|
1048
|
-
async captureLegacy(page, opts) {
|
|
1049
|
-
const o = {
|
|
1050
|
-
...(opts?.interactiveOnly !== undefined ? { interactiveOnly: opts.interactiveOnly } : {}),
|
|
1051
|
-
maxNodes: this.provider.config.snapshot.maxNodes,
|
|
1052
|
-
maxNameLength: this.provider.config.snapshot.maxNameLength,
|
|
1053
|
-
maxTextLength: this.provider.config.snapshot.maxTextLength,
|
|
1054
|
-
};
|
|
1055
|
-
const read = (options) => {
|
|
1056
|
-
const fn = window.__dshSnapshot;
|
|
1057
|
-
if (typeof fn !== 'function')
|
|
1058
|
-
return { nodes: [], truncated: false, totalRefs: 0, missing: true };
|
|
1059
|
-
return fn(options);
|
|
1060
|
-
};
|
|
1061
|
-
let raw;
|
|
1062
|
-
try {
|
|
1063
|
-
raw = await page.evaluate(read, o);
|
|
1064
|
-
}
|
|
1065
|
-
catch (error) {
|
|
1066
|
-
// A click can start a JS navigation that destroys the execution
|
|
1067
|
-
// context mid-evaluate; wait for the new document and retry once.
|
|
1068
|
-
if (!isContextDestroyed(error))
|
|
1069
|
-
throw error;
|
|
1070
|
-
await this.settleNavigation(page);
|
|
1071
|
-
raw = await page.evaluate(read, o);
|
|
1072
|
-
}
|
|
1073
|
-
return {
|
|
1074
|
-
url: page.url(),
|
|
1075
|
-
title: await page.title(),
|
|
1076
|
-
nodes: raw.nodes ?? [],
|
|
1077
|
-
totalRefs: raw.totalRefs ?? 0,
|
|
1078
|
-
truncated: raw.truncated ?? false,
|
|
1079
|
-
};
|
|
1080
|
-
}
|
|
1081
|
-
/**
|
|
1082
|
-
* Attach an incremental diff when snapshot.diff is enabled. Absence of the
|
|
1083
|
-
* diff field means "full snapshot follows": first capture of the session,
|
|
1084
|
-
* navigation reset, or a truncated tree. The diff is keyed by the stable
|
|
1085
|
-
* data-dsh-ref, so legacy and aria engines share the same contract.
|
|
1086
|
-
*/
|
|
1087
|
-
attachDiff(snap) {
|
|
1088
|
-
if (this.provider.config.snapshot.diff !== true) {
|
|
1089
|
-
this.lastRefSignatures = undefined;
|
|
1090
|
-
this.lastRefNonce = undefined;
|
|
1091
|
-
return snap;
|
|
1092
|
-
}
|
|
1093
|
-
const sigs = this.buildRefSignatures(snap.nodes);
|
|
1094
|
-
const nonce = this.refNonce(snap.nodes);
|
|
1095
|
-
const base = this.lastRefSignatures;
|
|
1096
|
-
const baseNonce = this.lastRefNonce;
|
|
1097
|
-
this.lastRefSignatures = sigs;
|
|
1098
|
-
this.lastRefNonce = nonce;
|
|
1099
|
-
// No baseline yet (first capture) — the full snapshot is the baseline.
|
|
1100
|
-
if (base === undefined || baseNonce === undefined)
|
|
1101
|
-
return snap;
|
|
1102
|
-
// Navigation reset (ref nonce changed) or truncation: the delta is void.
|
|
1103
|
-
if (nonce !== baseNonce || snap.truncated === true) {
|
|
1104
|
-
return { ...snap, diff: { added: [], removed: [], changed: [], same: 0, navigationReset: true } };
|
|
1105
|
-
}
|
|
1106
|
-
const added = [];
|
|
1107
|
-
const removed = [];
|
|
1108
|
-
const changed = [];
|
|
1109
|
-
let same = 0;
|
|
1110
|
-
for (const [ref, sig] of sigs) {
|
|
1111
|
-
const oldSig = base.get(ref);
|
|
1112
|
-
if (oldSig === undefined) {
|
|
1113
|
-
added.push(diffEntry({ ref, role: sig.role, name: sig.name, flags: flagsOf(sig), parentRef: sig.parentRef }));
|
|
1114
|
-
}
|
|
1115
|
-
else {
|
|
1116
|
-
const delta = flagsDeltaOf(oldSig, sig);
|
|
1117
|
-
if (sig.role !== oldSig.role || sig.name !== oldSig.name || delta.length > 0) {
|
|
1118
|
-
changed.push(diffEntry({ ref, role: sig.role, name: sig.name, flagsDelta: delta, parentRef: sig.parentRef }));
|
|
1119
|
-
}
|
|
1120
|
-
else {
|
|
1121
|
-
same += 1;
|
|
1122
|
-
}
|
|
1123
|
-
}
|
|
1124
|
-
}
|
|
1125
|
-
for (const ref of base.keys()) {
|
|
1126
|
-
if (!sigs.has(ref))
|
|
1127
|
-
removed.push(ref);
|
|
1128
|
-
}
|
|
1129
|
-
return { ...snap, diff: { added, removed, changed, same, navigationReset: false } };
|
|
1130
|
-
}
|
|
1131
|
-
/** Build ref -> feature signature map from a captured tree, with parent refs. */
|
|
1132
|
-
buildRefSignatures(nodes) {
|
|
1133
|
-
const out = new Map();
|
|
1134
|
-
const walk = (list, parentRef) => {
|
|
1135
|
-
for (const node of list) {
|
|
1136
|
-
if (node.ref !== undefined) {
|
|
1137
|
-
out.set(node.ref, {
|
|
1138
|
-
role: node.role,
|
|
1139
|
-
name: node.name,
|
|
1140
|
-
...(node.level !== undefined ? { level: node.level } : {}),
|
|
1141
|
-
checked: node.checked === true,
|
|
1142
|
-
selected: node.selected === true,
|
|
1143
|
-
disabled: node.disabled === true,
|
|
1144
|
-
...(node.href !== undefined ? { href: node.href } : {}),
|
|
1145
|
-
...(parentRef !== undefined ? { parentRef } : {}),
|
|
1146
|
-
});
|
|
1147
|
-
}
|
|
1148
|
-
walk(node.children ?? [], node.ref ?? parentRef);
|
|
1149
|
-
}
|
|
1150
|
-
};
|
|
1151
|
-
walk(nodes);
|
|
1152
|
-
return out;
|
|
1153
|
-
}
|
|
1154
|
-
/** The document nonce shared by this snapshot's refs, or undefined. */
|
|
1155
|
-
refNonce(nodes) {
|
|
1156
|
-
for (const node of nodes) {
|
|
1157
|
-
if (node.ref !== undefined && node.ref.length >= 10)
|
|
1158
|
-
return node.ref.slice(1, 10);
|
|
1159
|
-
const nested = this.refNonce(node.children ?? []);
|
|
1160
|
-
if (nested !== undefined)
|
|
1161
|
-
return nested;
|
|
1162
|
-
}
|
|
1163
|
-
return undefined;
|
|
1164
|
-
}
|
|
1165
|
-
async navigate(url, waitUntil, signal) {
|
|
1166
|
-
return this.run(async () => {
|
|
1167
|
-
const target = assertAllowedUrl(url, this.provider.config.allowedDomains ?? []);
|
|
1168
|
-
const page = await this.ensurePage();
|
|
1169
|
-
try {
|
|
1170
|
-
const racedNav = await this.runAction(page, () => withAbort(page.goto(target.toString(), { waitUntil, timeout: this.timeoutMs() }), signal));
|
|
1171
|
-
if (racedNav.blocked)
|
|
1172
|
-
return this.blockedSnapshot(page, racedNav.pending);
|
|
1173
|
-
}
|
|
1174
|
-
catch (error) {
|
|
1175
|
-
if (isAbortError(error) || signal?.aborted === true)
|
|
1176
|
-
throw error;
|
|
1177
|
-
throw asTimeoutError('navigation', error);
|
|
1178
|
-
}
|
|
1179
|
-
return this.settleAndSnapshot(page, true);
|
|
1180
|
-
});
|
|
1181
|
-
}
|
|
1182
|
-
async snapshot(opts) {
|
|
1183
|
-
return this.run(async () => {
|
|
1184
|
-
this.assertLive();
|
|
1185
|
-
const page = await this.withDialogGuardLifted(() => this.ensurePage());
|
|
1186
|
-
this.provider.touch(this.owner);
|
|
1187
|
-
// A parked dialog blocks the page: never round-trip into it.
|
|
1188
|
-
const pending = this.provider.pendingDialogFor(page);
|
|
1189
|
-
if (pending !== undefined)
|
|
1190
|
-
return this.blockedSnapshot(page, pending);
|
|
1191
|
-
const racedSnap = await this.runAction(page, async () => {
|
|
1192
|
-
await this.waitForPageStable(page, 400);
|
|
1193
|
-
return await this.capture(page, opts);
|
|
1194
|
-
});
|
|
1195
|
-
if (racedSnap.blocked)
|
|
1196
|
-
return this.blockedSnapshot(page, racedSnap.pending);
|
|
1197
|
-
return racedSnap.value;
|
|
1198
|
-
});
|
|
1199
|
-
}
|
|
1200
|
-
/**
|
|
1201
|
-
* Codex-style click-point resolution. Tries a scroll-alignment ladder
|
|
1202
|
-
* (centre, then end, then start). For each alignment: scroll the element
|
|
1203
|
-
* into view with a native scrollIntoView, wait for its bounding rect to
|
|
1204
|
-
* stop moving, sample the rect centre in-page (sub-pixel, no screenshot
|
|
1205
|
-
* measurement), then hit-test that exact point with elementFromPoint — so
|
|
1206
|
-
* an overlaying element can never swallow the click silently. Returns the
|
|
1207
|
-
* first unobstructed {x, y}, or throws describing what intercepted it.
|
|
1208
|
-
*/
|
|
1209
|
-
async resolveClickPoint(locator, signal) {
|
|
1210
|
-
const alignments = [
|
|
1211
|
-
{ block: 'center', inline: 'nearest' },
|
|
1212
|
-
{ block: 'end', inline: 'end' },
|
|
1213
|
-
{ block: 'start', inline: 'start' },
|
|
1214
|
-
];
|
|
1215
|
-
const hitTest = (el) => {
|
|
1216
|
-
const r = el.getBoundingClientRect();
|
|
1217
|
-
const x = Math.max(0, r.left + r.width / 2);
|
|
1218
|
-
const y = Math.max(0, r.top + r.height / 2);
|
|
1219
|
-
const w = r.width;
|
|
1220
|
-
const h = r.height;
|
|
1221
|
-
if (w <= 0 || h <= 0)
|
|
1222
|
-
return { ok: false, reason: 'zero-size', x, y };
|
|
1223
|
-
const describe = (n) => {
|
|
1224
|
-
if (!n)
|
|
1225
|
-
return '(nothing)';
|
|
1226
|
-
const cls = typeof n.className === 'string' && n.className.trim()
|
|
1227
|
-
? '.' + n.className.trim().split(/\s+/).filter(Boolean).join('.')
|
|
1228
|
-
: '';
|
|
1229
|
-
const role = n.getAttribute && n.getAttribute('role');
|
|
1230
|
-
const text = n.childElementCount === 0 && n.textContent ? ' "' + n.textContent.trim().slice(0, 24) + '"' : '';
|
|
1231
|
-
return ((n.tagName ? n.tagName.toLowerCase() : n.nodeName) +
|
|
1232
|
-
(n.id ? '#' + n.id : '') +
|
|
1233
|
-
cls +
|
|
1234
|
-
(role ? '[role=' + role + ']' : '') +
|
|
1235
|
-
text);
|
|
1236
|
-
};
|
|
1237
|
-
let hit = null;
|
|
1238
|
-
try {
|
|
1239
|
-
hit = document.elementFromPoint(x, y);
|
|
1240
|
-
}
|
|
1241
|
-
catch {
|
|
1242
|
-
hit = null;
|
|
1243
|
-
}
|
|
1244
|
-
if (!hit)
|
|
1245
|
-
return { ok: false, reason: 'nothing-at-point', x, y };
|
|
1246
|
-
let belongs = false;
|
|
1247
|
-
if (hit === el || el.contains(hit)) {
|
|
1248
|
-
belongs = true;
|
|
1249
|
-
}
|
|
1250
|
-
else {
|
|
1251
|
-
// Walk up from the hit; crossing a shadow boundary hops to the
|
|
1252
|
-
// shadow host so targets inside shadow DOM still count.
|
|
1253
|
-
let node = hit;
|
|
1254
|
-
const seen = new Set();
|
|
1255
|
-
while (node && !seen.has(node)) {
|
|
1256
|
-
seen.add(node);
|
|
1257
|
-
if (node === el) {
|
|
1258
|
-
belongs = true;
|
|
1259
|
-
break;
|
|
1260
|
-
}
|
|
1261
|
-
const root = node.getRootNode
|
|
1262
|
-
? node.getRootNode()
|
|
1263
|
-
: null;
|
|
1264
|
-
node = node.parentNode || (root && root.host ? root.host : null);
|
|
1265
|
-
}
|
|
1266
|
-
}
|
|
1267
|
-
return belongs
|
|
1268
|
-
? { ok: true, x, y, hitDesc: describe(hit), hitSelf: hit === el }
|
|
1269
|
-
: { ok: false, reason: 'intercepted', x, y, by: describe(hit) };
|
|
1270
|
-
};
|
|
1271
|
-
let lastBlockedBy;
|
|
1272
|
-
let lastReason;
|
|
1273
|
-
for (const align of alignments) {
|
|
1274
|
-
try {
|
|
1275
|
-
// Native scroll so block alignment is honoured; Playwright's
|
|
1276
|
-
// scrollIntoViewIfNeeded only promises *some* visibility.
|
|
1277
|
-
await withAbort(locator.evaluate((el, a) => {
|
|
1278
|
-
if (typeof el.scrollIntoView === 'function')
|
|
1279
|
-
el.scrollIntoView({ block: a.block, inline: a.inline, behavior: 'instant' });
|
|
1280
|
-
}, align), signal);
|
|
1281
|
-
}
|
|
1282
|
-
catch {
|
|
1283
|
-
/* try the next alignment */
|
|
1284
|
-
}
|
|
1285
|
-
// Wait out layout shifts before sampling the centre point.
|
|
1286
|
-
await this.waitForElementStable(locator);
|
|
1287
|
-
const probe = await withAbort(locator.evaluate(hitTest), signal).catch(() => null);
|
|
1288
|
-
if (probe && probe.ok)
|
|
1289
|
-
return { x: probe.x, y: probe.y, hitDesc: probe.hitDesc, hitSelf: probe.hitSelf };
|
|
1290
|
-
if (probe && probe.reason === 'intercepted') {
|
|
1291
|
-
lastBlockedBy = probe.by;
|
|
1292
|
-
lastReason = 'intercepted';
|
|
1293
|
-
}
|
|
1294
|
-
else if (lastReason === null) {
|
|
1295
|
-
lastReason = probe ? probe.reason : 'unreachable';
|
|
1296
|
-
}
|
|
1297
|
-
}
|
|
1298
|
-
if (lastReason === 'intercepted') {
|
|
1299
|
-
throw new BrowserError('ELEMENT_NOT_ACTIONABLE', 'the element does not receive pointer events at its centre: ' +
|
|
1300
|
-
(lastBlockedBy || 'another element') +
|
|
1301
|
-
' intercepts the click (tried centre / end / start alignment)');
|
|
1302
|
-
}
|
|
1303
|
-
if (lastReason === 'zero-size') {
|
|
1304
|
-
throw new BrowserError('ELEMENT_NOT_ACTIONABLE', 'ref has no clickable geometry (zero size); take a fresh browser_snapshot');
|
|
1305
|
-
}
|
|
1306
|
-
throw new BrowserError('ELEMENT_NOT_ACTIONABLE', 'could not find an unobstructed click point for the ref; take a fresh browser_snapshot');
|
|
1307
|
-
}
|
|
1308
|
-
async click(ref, signal) {
|
|
1309
|
-
return this.run(async () => {
|
|
1310
|
-
const locator = await this.refLocator(ref);
|
|
1311
|
-
return this.clickLocator(locator, signal, 'ref ' + ref, 0);
|
|
1312
|
-
});
|
|
1313
|
-
}
|
|
1314
|
-
/**
|
|
1315
|
-
* Click an element the snapshot gave no ref, addressed by its visible label
|
|
1316
|
-
* (e.g. a menu item revealed by browser_hover that is plain text). The
|
|
1317
|
-
* label must equal an element's own direct text; the deepest match wins,
|
|
1318
|
-
* and the landing note names what was actually clicked plus how many
|
|
1319
|
-
* elements shared the label.
|
|
1320
|
-
*/
|
|
1321
|
-
async clickText(text, signal) {
|
|
1322
|
-
return this.run(async () => {
|
|
1323
|
-
const page = this.currentPageSync();
|
|
1324
|
-
const found = await page.evaluate(labelTargetProbe, { text: String(text) }).catch(() => null);
|
|
1325
|
-
if (found === null || found.found !== true) {
|
|
1326
|
-
throw new BrowserError('CLICK_TARGET_NOT_FOUND', 'no visible element on this page has the label ' +
|
|
1327
|
-
JSON.stringify(text) +
|
|
1328
|
-
'; take a browser_snapshot and copy the label exactly as it is shown there, or click by ref');
|
|
1329
|
-
}
|
|
1330
|
-
const locator = page.locator('[data-dsh-label-target="' + found.token + '"]').first();
|
|
1331
|
-
try {
|
|
1332
|
-
return await this.clickLocator(locator, signal, JSON.stringify(text) + ' (' + found.desc + ')', found.total);
|
|
1333
|
-
}
|
|
1334
|
-
finally {
|
|
1335
|
-
// The stamp is only a locator handle: clear it once the click
|
|
1336
|
-
// has been delivered so no residue is left in the page. While a
|
|
1337
|
-
// dialog blocks the page this evaluate would hang, so skip it.
|
|
1338
|
-
if (this.provider.pendingDialogFor(page) === undefined)
|
|
1339
|
-
await page.evaluate(clearLabelTargets).catch(() => { });
|
|
1340
|
-
}
|
|
1341
|
-
});
|
|
1342
|
-
}
|
|
1343
|
-
/**
|
|
1344
|
-
* Enforce the navigation policy for the link a click is about to follow.
|
|
1345
|
-
* Click navigation does not run through navigate(), so this is the only place
|
|
1346
|
-
* the rule lives and every click path (ref, text, geometric ref, raw x/y)
|
|
1347
|
-
* calls it — a new path cannot quietly skip the allow-list.
|
|
1348
|
-
*
|
|
1349
|
-
* The policy is about which HOSTS this browser may visit, so it applies to
|
|
1350
|
-
* http(s) destinations. An in-page `javascript:` link runs the page's own
|
|
1351
|
-
* code without navigating anywhere and is allowed (refusing it made ordinary
|
|
1352
|
-
* JS links unclickable); every other scheme — file:, data:, mailto:, tel:,
|
|
1353
|
-
* custom ones — stays refused.
|
|
1354
|
-
*/
|
|
1355
|
-
assertClickTargetAllowed(href) {
|
|
1356
|
-
if (href === null || href === '')
|
|
1357
|
-
return;
|
|
1358
|
-
let target;
|
|
1359
|
-
try {
|
|
1360
|
-
target = new URL(href, this.currentPageSync().url());
|
|
1361
|
-
}
|
|
1362
|
-
catch {
|
|
1363
|
-
throw new BrowserError('URL_NOT_ALLOWED', 'invalid link target: ' + href);
|
|
1364
|
-
}
|
|
1365
|
-
if (target.protocol === 'javascript:')
|
|
1366
|
-
return;
|
|
1367
|
-
assertAllowedUrl(target.toString(), this.provider.config.allowedDomains ?? []);
|
|
1368
|
-
}
|
|
1369
|
-
/**
|
|
1370
|
-
* Enforce the host policy on a tab before this session starts driving it. A
|
|
1371
|
-
* page can open a tab by itself (`target="_blank"`, `window.open`) and the
|
|
1372
|
-
* plugin never sees that navigation, so the policy is applied at the point of
|
|
1373
|
-
* use instead: a tab outside the allow-list stays open — the user may well be
|
|
1374
|
-
* using it — but this agent refuses to switch to it.
|
|
1375
|
-
*
|
|
1376
|
-
* http(s) is checked against allowedDomains; `about:blank` is allowed because
|
|
1377
|
-
* it is the plugin's own interim state and has no host; every other scheme
|
|
1378
|
-
* (file:, data:, chrome:, devtools:) is refused outright.
|
|
1379
|
-
*/
|
|
1380
|
-
assertTabAllowed(page) {
|
|
1381
|
-
const raw = page.url();
|
|
1382
|
-
if (raw === '')
|
|
1383
|
-
return;
|
|
1384
|
-
let target;
|
|
1385
|
-
try {
|
|
1386
|
-
target = new URL(raw);
|
|
1387
|
-
}
|
|
1388
|
-
catch {
|
|
1389
|
-
return;
|
|
1390
|
-
}
|
|
1391
|
-
if (target.protocol === 'about:')
|
|
1392
|
-
return;
|
|
1393
|
-
assertAllowedUrl(target.toString(), this.provider.config.allowedDomains ?? []);
|
|
1394
|
-
}
|
|
1395
|
-
/**
|
|
1396
|
-
* Shared click core: enforce the URL policy for link targets, run the
|
|
1397
|
-
* Codex-style geometry ladder with the Playwright actionability fallback,
|
|
1398
|
-
* then report what actually received the click. The label names the target
|
|
1399
|
-
* in the landing note; shared is how many elements matched it (0 for refs).
|
|
1400
|
-
*/
|
|
1401
|
-
async clickLocator(locator, signal, label, shared) {
|
|
1402
|
-
this.assertClickTargetAllowed(await locator.getAttribute('href'));
|
|
1403
|
-
const page = this.currentPageSync();
|
|
1404
|
-
const urlBefore = page.url();
|
|
1405
|
-
let point;
|
|
1406
|
-
try {
|
|
1407
|
-
// Codex-style geometry pipeline first: alignment ladder →
|
|
1408
|
-
// stable rect → centre point → elementFromPoint hit-test.
|
|
1409
|
-
point = await this.resolveClickPoint(locator, signal);
|
|
1410
|
-
}
|
|
1411
|
-
catch (error) {
|
|
1412
|
-
if (isAbortError(error))
|
|
1413
|
-
throw error;
|
|
1414
|
-
if (error instanceof BrowserError) {
|
|
1415
|
-
// Ladder exhausted: let Playwright's own actionability
|
|
1416
|
-
// machinery retry (it waits for transient overlays to
|
|
1417
|
-
// disappear) and surface its verdict before ours.
|
|
1418
|
-
try {
|
|
1419
|
-
const racedFallback = await this.runAction(page, () => withAbort(locator.click({ timeout: Math.min(this.timeoutMs(), 3000) }), signal));
|
|
1420
|
-
if (racedFallback.blocked)
|
|
1421
|
-
return this.blockedSnapshot(page, racedFallback.pending);
|
|
1422
|
-
const snap = await this.settleAndSnapshot(page, page.url() !== urlBefore);
|
|
1423
|
-
snap.landingNote =
|
|
1424
|
-
'playwright actionability click delivered to ' +
|
|
1425
|
-
label +
|
|
1426
|
-
' after the geometry ladder was blocked — verify the page state shows the intended change';
|
|
1427
|
-
return snap;
|
|
1428
|
-
}
|
|
1429
|
-
catch (fallbackError) {
|
|
1430
|
-
if (isAbortError(fallbackError))
|
|
1431
|
-
throw fallbackError;
|
|
1432
|
-
throw error;
|
|
1433
|
-
}
|
|
1434
|
-
}
|
|
1435
|
-
throw error;
|
|
1436
|
-
}
|
|
1437
|
-
let raced;
|
|
1438
|
-
try {
|
|
1439
|
-
raced = await this.runAction(page, () => withAbort(page.mouse.click(point.x, point.y), signal));
|
|
1440
|
-
}
|
|
1441
|
-
catch (error) {
|
|
1442
|
-
if (isAbortError(error))
|
|
1443
|
-
throw error;
|
|
1444
|
-
throw asTimeoutError('action', error);
|
|
1445
|
-
}
|
|
1446
|
-
if (raced.blocked)
|
|
1447
|
-
return this.blockedSnapshot(page, raced.pending);
|
|
1448
|
-
const snap = await this.settleAndSnapshot(page, page.url() !== urlBefore);
|
|
1449
|
-
const note = 'click delivered at (' +
|
|
1450
|
-
Math.round(point.x) +
|
|
1451
|
-
',' +
|
|
1452
|
-
Math.round(point.y) +
|
|
1453
|
-
') — landed on ' +
|
|
1454
|
-
point.hitDesc +
|
|
1455
|
-
(point.hitSelf ? ' (the targeted element itself)' : ' (inside the targeted element)');
|
|
1456
|
-
snap.landingNote =
|
|
1457
|
-
shared > 1
|
|
1458
|
-
? 'clicked the FIRST of ' +
|
|
1459
|
-
shared +
|
|
1460
|
-
' visible elements labelled ' +
|
|
1461
|
-
label +
|
|
1462
|
-
' — if that is not the one you meant, take a browser_snapshot and click by ref. ' +
|
|
1463
|
-
note
|
|
1464
|
-
: note;
|
|
1465
|
-
return snap;
|
|
1466
|
-
}
|
|
1467
|
-
async clickAt(x, y, signal) {
|
|
1468
|
-
return this.run(async () => {
|
|
1469
|
-
const page = this.currentPageSync();
|
|
1470
|
-
const urlBefore = page.url();
|
|
1471
|
-
// Raw mode has no locator: sample what sits at the target point so the
|
|
1472
|
-
// returned snapshot can say what actually received the click, and so the
|
|
1473
|
-
// URL policy can be enforced for the link that point belongs to.
|
|
1474
|
-
const probe = await page
|
|
1475
|
-
.evaluate((pt) => {
|
|
1476
|
-
const px = pt.x;
|
|
1477
|
-
const py = pt.y;
|
|
1478
|
-
let hit = null;
|
|
1479
|
-
try {
|
|
1480
|
-
hit = document.elementFromPoint(px, py);
|
|
1481
|
-
}
|
|
1482
|
-
catch {
|
|
1483
|
-
hit = null;
|
|
1484
|
-
}
|
|
1485
|
-
if (!hit)
|
|
1486
|
-
return { desc: '(nothing — blank area or outside the viewport)', linkHref: null };
|
|
1487
|
-
const cls = typeof hit.className === 'string' && hit.className.trim()
|
|
1488
|
-
? '.' + hit.className.trim().split(/\s+/).filter(Boolean).join('.')
|
|
1489
|
-
: '';
|
|
1490
|
-
const role = hit.getAttribute && hit.getAttribute('role');
|
|
1491
|
-
const text = hit.childElementCount === 0 && hit.textContent ? ' "' + hit.textContent.trim().slice(0, 24) + '"' : '';
|
|
1492
|
-
const anchor = hit.closest('a[href]');
|
|
1493
|
-
return {
|
|
1494
|
-
desc: (hit.tagName ? hit.tagName.toLowerCase() : hit.nodeName) +
|
|
1495
|
-
(hit.id ? '#' + hit.id : '') +
|
|
1496
|
-
cls +
|
|
1497
|
-
(role ? '[role=' + role + ']' : '') +
|
|
1498
|
-
text,
|
|
1499
|
-
linkHref: anchor === null ? null : anchor.getAttribute('href'),
|
|
1500
|
-
};
|
|
1501
|
-
}, { x, y })
|
|
1502
|
-
.catch(() => null);
|
|
1503
|
-
const hitDesc = probe === null ? '(unavailable)' : probe.desc;
|
|
1504
|
-
// A raw click can navigate: enforce the policy for the link under the
|
|
1505
|
-
// point before any input is delivered.
|
|
1506
|
-
this.assertClickTargetAllowed(probe === null ? null : probe.linkHref);
|
|
1507
|
-
let racedAt = { blocked: false };
|
|
1508
|
-
try {
|
|
1509
|
-
// Raw native click by viewport coordinates (CDP Input
|
|
1510
|
-
// dispatch, isTrusted=true — indistinguishable from a human
|
|
1511
|
-
// click). No ref lookup, no visibility/actionability gate:
|
|
1512
|
-
// the escape hatch for elements the snapshot tree cannot
|
|
1513
|
-
// represent or stubborn rows that refuse ref-based clicks.
|
|
1514
|
-
racedAt = await this.runAction(page, () => withAbort(page.mouse.click(x, y), signal));
|
|
1515
|
-
}
|
|
1516
|
-
catch (error) {
|
|
1517
|
-
if (isAbortError(error))
|
|
1518
|
-
throw error;
|
|
1519
|
-
throw asTimeoutError('action', error);
|
|
1520
|
-
}
|
|
1521
|
-
if (racedAt.blocked)
|
|
1522
|
-
return this.blockedSnapshot(page, racedAt.pending);
|
|
1523
|
-
const snap = await this.settleAndSnapshot(page, page.url() !== urlBefore);
|
|
1524
|
-
snap.landingNote = 'raw click delivered at (' + x + ',' + y + ') — that point held: ' + hitDesc;
|
|
1525
|
-
return snap;
|
|
1526
|
-
});
|
|
1527
|
-
}
|
|
1528
|
-
async clickAtRef(ref, signal) {
|
|
1529
|
-
return this.run(async () => {
|
|
1530
|
-
const locator = await this.refLocator(ref);
|
|
1531
|
-
const page = this.currentPageSync();
|
|
1532
|
-
const urlBefore = page.url();
|
|
1533
|
-
// Ref mode resolves the element before clicking, so the link policy can
|
|
1534
|
-
// be enforced up front — the same rule browser_click applies.
|
|
1535
|
-
this.assertClickTargetAllowed(await locator.getAttribute('href'));
|
|
1536
|
-
try {
|
|
1537
|
-
// Codex-style geometry click with obstruction awareness:
|
|
1538
|
-
// scroll (centre → end → start), wait for the bounding rect to
|
|
1539
|
-
// stop moving, then click the RECT CENTRE computed in-page —
|
|
1540
|
-
// no screenshot measurement, no devicePixelRatio guesswork,
|
|
1541
|
-
// sub-pixel exact. Before committing, hit-test the point so an
|
|
1542
|
-
// overlay can never swallow the click silently; if something
|
|
1543
|
-
// intercepts, realign and retry up to the alignment ladder.
|
|
1544
|
-
const point = await this.resolveClickPoint(locator, signal);
|
|
1545
|
-
const racedRef = await this.runAction(page, () => withAbort(page.mouse.click(point.x, point.y), signal));
|
|
1546
|
-
if (racedRef.blocked)
|
|
1547
|
-
return this.blockedSnapshot(page, racedRef.pending);
|
|
1548
|
-
const snap = await this.settleAndSnapshot(page, page.url() !== urlBefore);
|
|
1549
|
-
snap.landingNote =
|
|
1550
|
-
'click delivered at (' +
|
|
1551
|
-
Math.round(point.x) +
|
|
1552
|
-
',' +
|
|
1553
|
-
Math.round(point.y) +
|
|
1554
|
-
') — landed on ' +
|
|
1555
|
-
point.hitDesc +
|
|
1556
|
-
(point.hitSelf ? ' (the targeted element itself)' : ' (inside the targeted element)');
|
|
1557
|
-
return snap;
|
|
1558
|
-
}
|
|
1559
|
-
catch (error) {
|
|
1560
|
-
if (isAbortError(error))
|
|
1561
|
-
throw error;
|
|
1562
|
-
if (error instanceof BrowserError) {
|
|
1563
|
-
// Escape hatch: when the geometry ladder reports an
|
|
1564
|
-
// interception, still try ONE bare native click at the raw
|
|
1565
|
-
// centre point. Some sites layer a transparent hit-catcher
|
|
1566
|
-
// that reports interception but forwards the click anyway,
|
|
1567
|
-
// or only listen for CDP-native isTrusted=true events.
|
|
1568
|
-
const raw = await locator
|
|
1569
|
-
.evaluate((el) => {
|
|
1570
|
-
const r = el.getBoundingClientRect();
|
|
1571
|
-
return {
|
|
1572
|
-
x: Math.max(0, r.left + r.width / 2),
|
|
1573
|
-
y: Math.max(0, r.top + r.height / 2),
|
|
1574
|
-
w: r.width,
|
|
1575
|
-
h: r.height,
|
|
1576
|
-
};
|
|
1577
|
-
})
|
|
1578
|
-
.catch(() => null);
|
|
1579
|
-
if (raw && raw.w > 0 && raw.h > 0) {
|
|
1580
|
-
try {
|
|
1581
|
-
await withAbort(page.mouse.click(raw.x, raw.y), signal);
|
|
1582
|
-
const snap = await this.settleAndSnapshot(page, page.url() !== urlBefore);
|
|
1583
|
-
// The bare click went to the same point the hit-test
|
|
1584
|
-
// flagged as intercepted — so the topmost element at
|
|
1585
|
-
// that point (NOT the intended target) received it,
|
|
1586
|
-
// unless the overlay forwards events. Say so loudly.
|
|
1587
|
-
snap.landingNote =
|
|
1588
|
-
'CAUTION: the intended target was flagged as intercepted; a bare native click was sent to its centre (' +
|
|
1589
|
-
Math.round(raw.x) +
|
|
1590
|
-
',' +
|
|
1591
|
-
Math.round(raw.y) +
|
|
1592
|
-
') as a last resort. ' +
|
|
1593
|
-
'The element that was ON TOP at that point received the click unless it forwards events — check whether the page changed as intended (the interceptor, if any, is named in the error that would otherwise have been thrown).';
|
|
1594
|
-
return snap;
|
|
1595
|
-
}
|
|
1596
|
-
catch (rawError) {
|
|
1597
|
-
if (isAbortError(rawError))
|
|
1598
|
-
throw rawError;
|
|
1599
|
-
}
|
|
1600
|
-
}
|
|
1601
|
-
}
|
|
1602
|
-
throw error;
|
|
1603
|
-
}
|
|
1604
|
-
});
|
|
1605
|
-
}
|
|
1606
|
-
async fill(ref, text, signal) {
|
|
1607
|
-
return this.run(async () => {
|
|
1608
|
-
const locator = await this.refLocator(ref);
|
|
1609
|
-
const page = this.currentPageSync();
|
|
1610
|
-
await this.waitForElementStable(locator);
|
|
1611
|
-
await this.assertActionable(locator, { editable: true });
|
|
1612
|
-
const tag = await locator.evaluate((el) => el.tagName.toLowerCase()).catch(() => 'input');
|
|
1613
|
-
let lastError;
|
|
1614
|
-
let written = false;
|
|
1615
|
-
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1616
|
-
try {
|
|
1617
|
-
if (tag === 'select') {
|
|
1618
|
-
await withAbort(locator.selectOption({ label: text }, { timeout: this.timeoutMs() }), signal);
|
|
1619
|
-
}
|
|
1620
|
-
else {
|
|
1621
|
-
await withAbort(locator.fill(text, { timeout: this.timeoutMs() }), signal);
|
|
1622
|
-
}
|
|
1623
|
-
}
|
|
1624
|
-
catch (error) {
|
|
1625
|
-
if (isAbortError(error))
|
|
1626
|
-
throw error;
|
|
1627
|
-
lastError = error;
|
|
1628
|
-
continue;
|
|
1629
|
-
}
|
|
1630
|
-
// Post-action verification: read the value back. Lenient match
|
|
1631
|
-
// so formatted/masked inputs do not false-positive, but a value
|
|
1632
|
-
// that never stuck gets one retry then a clear diagnostic.
|
|
1633
|
-
written = await this.verifyFill(locator, tag, text);
|
|
1634
|
-
if (written)
|
|
1635
|
-
break;
|
|
1636
|
-
}
|
|
1637
|
-
if (!written) {
|
|
1638
|
-
const detail = lastError instanceof Error ? lastError.message : '';
|
|
1639
|
-
throw new BrowserError('VALUE_NOT_SET', 'the fill did not stick after 2 attempts — the value was not written to the field' +
|
|
1640
|
-
(detail ? ': ' + detail : ''));
|
|
1641
|
-
}
|
|
1642
|
-
return this.settleAndSnapshot(page, false);
|
|
1643
|
-
});
|
|
1644
|
-
}
|
|
1645
|
-
/** Read the field back and decide whether the fill landed. */
|
|
1646
|
-
async verifyFill(locator, tag, text) {
|
|
1647
|
-
try {
|
|
1648
|
-
if (tag === 'select') {
|
|
1649
|
-
const label = await locator.evaluate((el) => {
|
|
1650
|
-
const o = el.selectedOptions[0];
|
|
1651
|
-
return o === undefined ? '' : o.label;
|
|
1652
|
-
});
|
|
1653
|
-
return label === text;
|
|
1654
|
-
}
|
|
1655
|
-
const value = await locator.inputValue();
|
|
1656
|
-
if (value === text)
|
|
1657
|
-
return true;
|
|
1658
|
-
// Lenient: formatted inputs (masks, prefixes) rewrite the display
|
|
1659
|
-
// value; accept containment or a near-equal length as "written".
|
|
1660
|
-
return (text.length > 0 &&
|
|
1661
|
-
value.length > 0 &&
|
|
1662
|
-
(value.includes(text) || text.includes(value) || Math.abs(value.length - text.length) <= 2));
|
|
1663
|
-
}
|
|
1664
|
-
catch {
|
|
1665
|
-
return false;
|
|
1666
|
-
}
|
|
1667
|
-
}
|
|
1668
|
-
async press(ref, key, signal) {
|
|
1669
|
-
return this.run(async () => {
|
|
1670
|
-
const locator = await this.refLocator(ref);
|
|
1671
|
-
const page = this.currentPageSync();
|
|
1672
|
-
await this.waitForElementStable(locator);
|
|
1673
|
-
const urlBefore = page.url();
|
|
1674
|
-
try {
|
|
1675
|
-
const racedPress = await this.runAction(page, () => withAbort(locator.press(key, { timeout: this.timeoutMs() }), signal));
|
|
1676
|
-
if (racedPress.blocked)
|
|
1677
|
-
return this.blockedSnapshot(page, racedPress.pending);
|
|
1678
|
-
}
|
|
1679
|
-
catch (error) {
|
|
1680
|
-
if (isAbortError(error))
|
|
1681
|
-
throw error;
|
|
1682
|
-
throw asTimeoutError('action', error);
|
|
1683
|
-
}
|
|
1684
|
-
return this.settleAndSnapshot(page, page.url() !== urlBefore);
|
|
1685
|
-
});
|
|
1686
|
-
}
|
|
1687
|
-
/**
|
|
1688
|
-
* Hover the referenced element: rest the pointer on it so hover-revealed
|
|
1689
|
-
* content appears (CSS :hover rules, JS mouseenter handlers, submenus,
|
|
1690
|
-
* tooltips, chart values, player controls). Hover never activates
|
|
1691
|
-
* anything: it only reveals. The ref count before and after the pointer
|
|
1692
|
-
* moves is the only cheap proof that something appeared, so the returned
|
|
1693
|
-
* snapshot carries a note comparing them.
|
|
1694
|
-
*/
|
|
1695
|
-
async hover(ref, signal) {
|
|
1696
|
-
return this.run(async () => {
|
|
1697
|
-
const locator = await this.refLocator(ref);
|
|
1698
|
-
return this.hoverLocator(locator, signal, 'ref ' + ref, 0);
|
|
1699
|
-
});
|
|
1700
|
-
}
|
|
1701
|
-
/**
|
|
1702
|
-
* Hover a trigger the snapshot gave no ref, addressed by its visible label
|
|
1703
|
-
* (e.g. a plain span that reveals a menu on mouseenter). The label must
|
|
1704
|
-
* equal an element's own direct text; the deepest match wins, and the
|
|
1705
|
-
* landing note names what was hovered plus how many elements shared it.
|
|
1706
|
-
*/
|
|
1707
|
-
async hoverText(text, signal) {
|
|
1708
|
-
return this.run(async () => {
|
|
1709
|
-
const page = this.currentPageSync();
|
|
1710
|
-
const found = await page.evaluate(labelTargetProbe, { text: String(text) }).catch(() => null);
|
|
1711
|
-
if (found === null || found.found !== true) {
|
|
1712
|
-
throw new BrowserError('HOVER_TARGET_NOT_FOUND', 'no visible element on this page has the label ' +
|
|
1713
|
-
JSON.stringify(text) +
|
|
1714
|
-
'; take a browser_snapshot and copy the label exactly as it is shown there, or hover by ref');
|
|
1715
|
-
}
|
|
1716
|
-
const locator = page.locator('[data-dsh-label-target="' + found.token + '"]').first();
|
|
1717
|
-
try {
|
|
1718
|
-
return await this.hoverLocator(locator, signal, JSON.stringify(text) + ' (' + found.desc + ')', found.total);
|
|
1719
|
-
}
|
|
1720
|
-
finally {
|
|
1721
|
-
// The stamp is only a locator handle: clear it once the hover
|
|
1722
|
-
// has been delivered so no residue is left in the page.
|
|
1723
|
-
await page.evaluate(clearLabelTargets).catch(() => { });
|
|
1724
|
-
}
|
|
1725
|
-
});
|
|
1726
|
-
}
|
|
1727
|
-
/**
|
|
1728
|
-
* Shared hover core: rest the pointer on a resolved locator, then report
|
|
1729
|
-
* what the actionable ref count did. The label names the target in the
|
|
1730
|
-
* landing note; shared is how many elements matched that label (0 when the
|
|
1731
|
-
* target came from a ref).
|
|
1732
|
-
*/
|
|
1733
|
-
async hoverLocator(locator, signal, label, shared) {
|
|
1734
|
-
await this.assertActionable(locator);
|
|
1735
|
-
const page = this.currentPageSync();
|
|
1736
|
-
await this.waitForElementStable(locator);
|
|
1737
|
-
const urlBefore = page.url();
|
|
1738
|
-
const before = await this.capture(page, undefined);
|
|
1739
|
-
try {
|
|
1740
|
-
await withAbort(locator.hover({ timeout: this.timeoutMs() }), signal);
|
|
1741
|
-
}
|
|
1742
|
-
catch (error) {
|
|
1743
|
-
if (isAbortError(error))
|
|
1744
|
-
throw error;
|
|
1745
|
-
throw asTimeoutError('action', error);
|
|
1746
|
-
}
|
|
1747
|
-
const navigated = page.url() !== urlBefore;
|
|
1748
|
-
const snap = await this.settleAndSnapshot(page, navigated);
|
|
1749
|
-
const note = hoverNote(label, before.totalRefs, snap.totalRefs, navigated);
|
|
1750
|
-
snap.landingNote =
|
|
1751
|
-
shared > 1
|
|
1752
|
-
? 'hovered the FIRST of ' +
|
|
1753
|
-
shared +
|
|
1754
|
-
' visible elements labelled ' +
|
|
1755
|
-
label +
|
|
1756
|
-
' — if that is not the one you meant, take a browser_snapshot and hover by ref. ' +
|
|
1757
|
-
note
|
|
1758
|
-
: note;
|
|
1759
|
-
return snap;
|
|
1760
|
-
}
|
|
1761
|
-
async scroll(direction, amount, ref, signal) {
|
|
1762
|
-
return this.run(async () => {
|
|
1763
|
-
const page = await this.ensurePage();
|
|
1764
|
-
try {
|
|
1765
|
-
if (ref !== undefined) {
|
|
1766
|
-
await withAbort((await this.refLocator(ref)).scrollIntoViewIfNeeded({ timeout: this.timeoutMs() }), signal);
|
|
1767
|
-
}
|
|
1768
|
-
const delta = direction === 'down' ? amount : -amount;
|
|
1769
|
-
await withAbort(page.mouse.wheel(0, delta), signal);
|
|
1770
|
-
}
|
|
1771
|
-
catch (error) {
|
|
1772
|
-
if (isAbortError(error))
|
|
1773
|
-
throw error;
|
|
1774
|
-
throw asTimeoutError('action', error);
|
|
1775
|
-
}
|
|
1776
|
-
// Lazy-loading pages fire async loaders after a wheel; a quiet page
|
|
1777
|
-
// that has not started loading yet would otherwise look "stable"
|
|
1778
|
-
// instantly and the snapshot would miss the late content. Give the
|
|
1779
|
-
// loaders a beat, then settle (bounded) before capturing.
|
|
1780
|
-
await withAbort(new Promise((resolve) => setTimeout(resolve, 450)), signal);
|
|
1781
|
-
await this.waitForPageStable(page, 1200);
|
|
1782
|
-
return this.capture(page, undefined);
|
|
1783
|
-
});
|
|
1784
|
-
}
|
|
1785
|
-
async back(signal) {
|
|
1786
|
-
return this.run(async () => {
|
|
1787
|
-
const page = await this.ensurePage();
|
|
1788
|
-
try {
|
|
1789
|
-
await withAbort(page.goBack({ timeout: this.timeoutMs() }), signal);
|
|
1790
|
-
}
|
|
1791
|
-
catch (error) {
|
|
1792
|
-
if (isAbortError(error))
|
|
1793
|
-
throw error;
|
|
1794
|
-
throw asTimeoutError('navigation', error);
|
|
1795
|
-
}
|
|
1796
|
-
return this.settleAndSnapshot(page, true);
|
|
1797
|
-
});
|
|
1798
|
-
}
|
|
1799
|
-
async forward(signal) {
|
|
1800
|
-
return this.run(async () => {
|
|
1801
|
-
const page = await this.ensurePage();
|
|
1802
|
-
try {
|
|
1803
|
-
await withAbort(page.goForward({ timeout: this.timeoutMs() }), signal);
|
|
1804
|
-
}
|
|
1805
|
-
catch (error) {
|
|
1806
|
-
if (isAbortError(error))
|
|
1807
|
-
throw error;
|
|
1808
|
-
throw asTimeoutError('navigation', error);
|
|
1809
|
-
}
|
|
1810
|
-
return this.settleAndSnapshot(page, true);
|
|
1811
|
-
});
|
|
1812
|
-
}
|
|
1813
|
-
async wait(ms, signal) {
|
|
1814
|
-
return this.run(async () => {
|
|
1815
|
-
this.provider.touch(this.owner);
|
|
1816
|
-
await withAbort(new Promise((resolve) => setTimeout(resolve, ms)), signal);
|
|
1817
|
-
});
|
|
1818
|
-
}
|
|
1819
|
-
async tabs() {
|
|
1820
|
-
return this.run(async () => {
|
|
1821
|
-
this.assertLive();
|
|
1822
|
-
this.provider.touch(this.owner);
|
|
1823
|
-
const pages = this.context.pages();
|
|
1824
|
-
// The tab the other tools act on, so the model never has to guess.
|
|
1825
|
-
const activeIndex = Math.min(this.currentIndex, Math.max(0, pages.length - 1));
|
|
1826
|
-
const out = [];
|
|
1827
|
-
for (let index = 0; index < pages.length; index += 1) {
|
|
1828
|
-
const page = pages[index];
|
|
1829
|
-
if (page === undefined)
|
|
1830
|
-
continue;
|
|
1831
|
-
this.guardBlockedDialog(page);
|
|
1832
|
-
out.push({ index, url: page.url(), title: await page.title(), active: index === activeIndex });
|
|
1833
|
-
}
|
|
1834
|
-
return out;
|
|
1835
|
-
});
|
|
1836
|
-
}
|
|
1837
|
-
async switchTab(index, signal) {
|
|
1838
|
-
return this.run(async () => {
|
|
1839
|
-
this.assertLive();
|
|
1840
|
-
this.guardBlockedContext();
|
|
1841
|
-
const page = this.pageAt(index);
|
|
1842
|
-
if (page === undefined)
|
|
1843
|
-
throw new BrowserError('REF_NOT_FOUND', 'no tab at index ' + String(index));
|
|
1844
|
-
// Switching is the only way this session starts driving a tab it did not
|
|
1845
|
-
// open itself, so the host policy is applied here: a page-opened tab
|
|
1846
|
-
// outside the allow-list cannot be reached. The refusal happens before
|
|
1847
|
-
// currentIndex moves, so the session stays on the tab it was driving.
|
|
1848
|
-
this.assertTabAllowed(page);
|
|
1849
|
-
this.currentIndex = index;
|
|
1850
|
-
// A minimized window stays minimized: switching tabs is a
|
|
1851
|
-
// context-level operation that needs no OS window activation.
|
|
1852
|
-
if (!(await isWindowMinimized(page)))
|
|
1853
|
-
await withAbort(page.bringToFront(), signal);
|
|
1854
|
-
return this.snapshot();
|
|
1855
|
-
});
|
|
1856
|
-
}
|
|
1857
|
-
async openTab(url, waitUntil, signal) {
|
|
1858
|
-
return this.run(async () => {
|
|
1859
|
-
this.assertLive();
|
|
1860
|
-
this.guardBlockedContext();
|
|
1861
|
-
const target = assertAllowedUrl(url, this.provider.config.allowedDomains ?? []);
|
|
1862
|
-
// Background creation: a new tab must not yank a minimized window
|
|
1863
|
-
// into the user's face (see createBackgroundPage).
|
|
1864
|
-
const page = await createBackgroundPage(this.context);
|
|
1865
|
-
const index = this.context.pages().indexOf(page);
|
|
1866
|
-
this.currentIndex = index >= 0 ? index : this.context.pages().length - 1;
|
|
1867
|
-
this.provider.armDialogGuard(page);
|
|
1868
|
-
// Track before the first navigation. Attaching the console/network
|
|
1869
|
-
// listeners any later loses the requests that brought the tab into
|
|
1870
|
-
// existence, so an opened tab looked silent from its very first load.
|
|
1871
|
-
this.trackPage(page);
|
|
1872
|
-
try {
|
|
1873
|
-
await withAbort(page.goto(target.toString(), { waitUntil, timeout: this.timeoutMs() }), signal);
|
|
1874
|
-
}
|
|
1875
|
-
catch (error) {
|
|
1876
|
-
if (isAbortError(error))
|
|
1877
|
-
throw error;
|
|
1878
|
-
throw asTimeoutError('navigation', error);
|
|
1879
|
-
}
|
|
1880
|
-
return this.settleAndSnapshot(page, true);
|
|
1881
|
-
});
|
|
1882
|
-
}
|
|
1883
|
-
async closeTab(index, signal) {
|
|
1884
|
-
return this.run(async () => {
|
|
1885
|
-
this.assertLive();
|
|
1886
|
-
this.guardBlockedContext();
|
|
1887
|
-
const pages = this.context.pages();
|
|
1888
|
-
const page = pages[index];
|
|
1889
|
-
if (page === undefined)
|
|
1890
|
-
throw new BrowserError('REF_NOT_FOUND', 'no tab at index ' + String(index));
|
|
1891
|
-
if (pages.length <= 1) {
|
|
1892
|
-
await withAbort(page.goto('about:blank'), signal);
|
|
1893
|
-
return;
|
|
1894
|
-
}
|
|
1895
|
-
await withAbort(page.close(), signal);
|
|
1896
|
-
this.currentIndex = Math.min(this.currentIndex, this.context.pages().length - 1);
|
|
1897
|
-
});
|
|
1898
|
-
}
|
|
1899
|
-
async screenshot(opts, signal) {
|
|
1900
|
-
return this.run(async () => {
|
|
1901
|
-
this.assertLive();
|
|
1902
|
-
this.provider.touch(this.owner);
|
|
1903
|
-
const page = await this.ensurePage();
|
|
1904
|
-
if (opts?.ref !== undefined) {
|
|
1905
|
-
const bytes = await withAbort((await this.refLocator(opts.ref)).screenshot({ type: 'png', timeout: this.timeoutMs() }), signal);
|
|
1906
|
-
return { mime: 'image/png', bytes };
|
|
1907
|
-
}
|
|
1908
|
-
const bytes = await withAbort(page.screenshot({
|
|
1909
|
-
type: 'png',
|
|
1910
|
-
fullPage: opts?.fullPage ?? false,
|
|
1911
|
-
animations: 'disabled',
|
|
1912
|
-
caret: 'hide',
|
|
1913
|
-
timeout: this.timeoutMs(),
|
|
1914
|
-
}), signal);
|
|
1915
|
-
return { mime: 'image/png', bytes };
|
|
1916
|
-
});
|
|
1917
|
-
}
|
|
1918
|
-
async pageData() {
|
|
1919
|
-
return this.run(async () => {
|
|
1920
|
-
this.assertLive();
|
|
1921
|
-
this.provider.touch(this.owner);
|
|
1922
|
-
const page = await this.ensurePage();
|
|
1923
|
-
const read = () => {
|
|
1924
|
-
const fn = window.__dshPageData;
|
|
1925
|
-
if (typeof fn !== 'function')
|
|
1926
|
-
return { url: location.href, title: document.title, text: '', truncated: false, links: [], inputs: [] };
|
|
1927
|
-
return fn({ maxTextChars: 30000, maxLinks: 300, maxInputs: 200 });
|
|
1928
|
-
};
|
|
1929
|
-
let data;
|
|
1930
|
-
try {
|
|
1931
|
-
data = await page.evaluate(read);
|
|
1932
|
-
}
|
|
1933
|
-
catch (error) {
|
|
1934
|
-
if (!isContextDestroyed(error))
|
|
1935
|
-
throw error;
|
|
1936
|
-
await this.settleNavigation(page);
|
|
1937
|
-
data = await page.evaluate(read);
|
|
1938
|
-
}
|
|
1939
|
-
return data;
|
|
1940
|
-
});
|
|
1941
|
-
}
|
|
1942
|
-
async evaluate(expression, signal) {
|
|
1943
|
-
return this.run(async () => {
|
|
1944
|
-
this.assertLive();
|
|
1945
|
-
this.provider.touch(this.owner);
|
|
1946
|
-
const page = await this.ensurePage();
|
|
1947
|
-
const fn = new Function('return (' + expression + ')');
|
|
1948
|
-
try {
|
|
1949
|
-
return await withAbort(page.evaluate(fn), signal);
|
|
1950
|
-
}
|
|
1951
|
-
catch (error) {
|
|
1952
|
-
if (!isContextDestroyed(error) || signal?.aborted === true)
|
|
1953
|
-
throw error;
|
|
1954
|
-
await this.settleNavigation(page);
|
|
1955
|
-
return withAbort(page.evaluate(fn), signal);
|
|
1956
|
-
}
|
|
1957
|
-
});
|
|
1958
|
-
}
|
|
1959
|
-
/**
|
|
1960
|
-
* Send one raw CDP command on the current page's session and return its
|
|
1961
|
-
* result. This is the "direct protocol" escape hatch behind every other
|
|
1962
|
-
* tool — the model can inspect geometry, dispatch trusted input events or
|
|
1963
|
-
* query the DOM the same way Chrome DevTools does. Policy (which methods
|
|
1964
|
-
* are allowed) lives in the tool layer via lib/cdp-policy.js; this method
|
|
1965
|
-
* itself performs no filtering so internal callers keep full control.
|
|
1966
|
-
*/
|
|
1967
|
-
async cdp(method, params, signal) {
|
|
1968
|
-
return this.run(async () => {
|
|
1969
|
-
this.assertLive();
|
|
1970
|
-
this.provider.touch(this.owner);
|
|
1971
|
-
const page = await this.ensurePage();
|
|
1972
|
-
// Reuse the cached CDP session when still on the same page: DOM
|
|
1973
|
-
// nodeIds and execution contexts are session-bound, so creating a
|
|
1974
|
-
// fresh session per call would invalidate ids from earlier calls.
|
|
1975
|
-
if (this.cdpSession === undefined || this.cdpSession === null || this.cdpPage !== page) {
|
|
1976
|
-
await this.cdpSession?.detach().catch(() => { });
|
|
1977
|
-
this.cdpSession = await this.context.newCDPSession(page);
|
|
1978
|
-
this.cdpPage = page;
|
|
1979
|
-
}
|
|
1980
|
-
try {
|
|
1981
|
-
// CDPSession.send is a prototype method: it needs its receiver, so bind it
|
|
1982
|
-
// before handing it to withAbort. An unbound reference dies on `this._channel`
|
|
1983
|
-
// inside the driver, and the failure gets reported as a protocol error.
|
|
1984
|
-
// CDPSession.send is a prototype method: it needs its receiver, so bind it
|
|
1985
|
-
// before handing it to withAbort. An unbound reference dies on `this._channel`
|
|
1986
|
-
// inside the driver, and the failure gets reported as a protocol error.
|
|
1987
|
-
const session = this.cdpSession;
|
|
1988
|
-
const send = session.send.bind(session);
|
|
1989
|
-
return await withAbort(send(method, params ?? {}), signal);
|
|
1990
|
-
}
|
|
1991
|
-
catch (error) {
|
|
1992
|
-
if (isAbortError(error))
|
|
1993
|
-
throw error;
|
|
1994
|
-
// CDP protocol errors are already structured ("Protocol error
|
|
1995
|
-
// (DOM.foo): ..."); wrap non-abort failures in a stable code.
|
|
1996
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
1997
|
-
if (/Protocol error/.test(detail)) {
|
|
1998
|
-
const m = /\(([^)]+)\): ([\s\S]+)/.exec(detail);
|
|
1999
|
-
throw new BrowserError('CDP_ERROR', m
|
|
2000
|
-
? 'CDP command ' + (m[1] ?? '') + ' failed: ' + (m[2] ?? '').trim().slice(0, 400)
|
|
2001
|
-
: 'CDP command failed: ' + detail.slice(0, 400));
|
|
2002
|
-
}
|
|
2003
|
-
throw new BrowserError('CDP_ERROR', 'CDP command ' + method + ' failed: ' + detail.slice(0, 400));
|
|
2004
|
-
}
|
|
2005
|
-
});
|
|
2006
|
-
}
|
|
2007
|
-
async close() {
|
|
2008
|
-
if (this.closed)
|
|
2009
|
-
return;
|
|
2010
|
-
this.closed = true;
|
|
2011
|
-
await this.cdpSession?.detach().catch(() => { });
|
|
2012
|
-
this.cdpSession = undefined;
|
|
2013
|
-
await this.provider.disposeOwner(this.owner);
|
|
2014
|
-
}
|
|
2015
|
-
timeoutMs() {
|
|
2016
|
-
return this.provider.config.launch.navigationTimeoutMs;
|
|
2017
|
-
}
|
|
2018
|
-
async ensurePage() {
|
|
2019
|
-
this.assertLive();
|
|
2020
|
-
const pages = this.context.pages();
|
|
2021
|
-
if (pages.length === 0) {
|
|
2022
|
-
const created = await this.context.newPage();
|
|
2023
|
-
this.currentIndex = 0;
|
|
2024
|
-
this.provider.armDialogGuard(created);
|
|
2025
|
-
this.guardBlockedDialog(created);
|
|
2026
|
-
this.trackPage(created);
|
|
2027
|
-
return created;
|
|
2028
|
-
}
|
|
2029
|
-
this.currentIndex = Math.min(this.currentIndex, pages.length - 1);
|
|
2030
|
-
const page = pages[this.currentIndex] ?? pages[0];
|
|
2031
|
-
if (page === undefined) {
|
|
2032
|
-
const created = await this.context.newPage();
|
|
2033
|
-
this.currentIndex = 0;
|
|
2034
|
-
this.provider.armDialogGuard(created);
|
|
2035
|
-
this.guardBlockedDialog(created);
|
|
2036
|
-
this.trackPage(created);
|
|
2037
|
-
return created;
|
|
2038
|
-
}
|
|
2039
|
-
this.provider.armDialogGuard(page);
|
|
2040
|
-
this.guardBlockedDialog(page);
|
|
2041
|
-
this.trackPage(page);
|
|
2042
|
-
return page;
|
|
2043
|
-
}
|
|
2044
|
-
/**
|
|
2045
|
-
* Wire bounded console/network capture onto one page, exactly once. Capture is
|
|
2046
|
-
* best-effort observation for the model: it must never throw into a page that is
|
|
2047
|
-
* otherwise healthy, and it must never grow without a bound.
|
|
2048
|
-
*/
|
|
2049
|
-
trackPage(page) {
|
|
2050
|
-
if (this.trackedPages.has(page))
|
|
2051
|
-
return;
|
|
2052
|
-
this.trackedPages.add(page);
|
|
2053
|
-
const pageId = this.pageIdFor(page);
|
|
2054
|
-
const record = (kind, entry) => {
|
|
2055
|
-
this.diagSeq += 1;
|
|
2056
|
-
entry.n = this.diagSeq;
|
|
2057
|
-
entry.at = Date.now();
|
|
2058
|
-
// Tag the owner: the rings are session-wide, the readers are per page.
|
|
2059
|
-
entry.page = pageId;
|
|
2060
|
-
const ring = kind === 'console' ? this.consoleLog : this.networkLog;
|
|
2061
|
-
ring.push(entry);
|
|
2062
|
-
while (ring.length > this.diagLimit) {
|
|
2063
|
-
ring.shift();
|
|
2064
|
-
this.diagDropped[kind] += 1;
|
|
2065
|
-
}
|
|
2066
|
-
};
|
|
2067
|
-
try {
|
|
2068
|
-
page.on('console', (msg) => {
|
|
2069
|
-
record('console', { level: msg.type(), text: msg.text(), source: (msg.location() ?? {}).url ?? '' });
|
|
2070
|
-
});
|
|
2071
|
-
page.on('pageerror', (err) => {
|
|
2072
|
-
record('console', { level: 'pageerror', text: err?.message ?? String(err), source: page.url() });
|
|
2073
|
-
});
|
|
2074
|
-
page.on('request', (request) => {
|
|
2075
|
-
const entry = {
|
|
2076
|
-
method: request.method(),
|
|
2077
|
-
url: request.url(),
|
|
2078
|
-
status: null,
|
|
2079
|
-
kind: request.resourceType(),
|
|
2080
|
-
ms: null,
|
|
2081
|
-
failure: null,
|
|
2082
|
-
};
|
|
2083
|
-
this.requestEntries.set(request, { entry, startedAt: Date.now() });
|
|
2084
|
-
record('network', entry);
|
|
2085
|
-
});
|
|
2086
|
-
page.on('response', (response) => {
|
|
2087
|
-
const slot = this.requestEntries.get(response.request());
|
|
2088
|
-
if (slot === undefined)
|
|
2089
|
-
return;
|
|
2090
|
-
slot.entry.status = response.status();
|
|
2091
|
-
slot.entry.ms = Date.now() - slot.startedAt;
|
|
2092
|
-
this.requestEntries.delete(response.request());
|
|
2093
|
-
});
|
|
2094
|
-
page.on('requestfailed', (request) => {
|
|
2095
|
-
const slot = this.requestEntries.get(request);
|
|
2096
|
-
if (slot === undefined)
|
|
2097
|
-
return;
|
|
2098
|
-
slot.entry.ms = Date.now() - slot.startedAt;
|
|
2099
|
-
slot.entry.failure = request.failure()?.errorText ?? 'request failed';
|
|
2100
|
-
this.requestEntries.delete(request);
|
|
2101
|
-
});
|
|
2102
|
-
}
|
|
2103
|
-
catch {
|
|
2104
|
-
// Diagnostics are optional: a page that refuses hooks still works.
|
|
2105
|
-
}
|
|
2106
|
-
}
|
|
2107
|
-
/** Stable id for one page of this session (used to scope diagnostics). */
|
|
2108
|
-
pageIdFor(page) {
|
|
2109
|
-
const existing = this.pageIds.get(page);
|
|
2110
|
-
if (existing !== undefined)
|
|
2111
|
-
return existing;
|
|
2112
|
-
this.pageIdSeq += 1;
|
|
2113
|
-
this.pageIds.set(page, this.pageIdSeq);
|
|
2114
|
-
return this.pageIdSeq;
|
|
2115
|
-
}
|
|
2116
|
-
/** Id of the page this session is driving, or undefined when there is none. */
|
|
2117
|
-
currentPageId() {
|
|
2118
|
-
try {
|
|
2119
|
-
const page = this.pageAt(this.currentIndex);
|
|
2120
|
-
return page === undefined ? undefined : this.pageIdFor(page);
|
|
2121
|
-
}
|
|
2122
|
-
catch {
|
|
2123
|
-
return undefined;
|
|
2124
|
-
}
|
|
2125
|
-
}
|
|
2126
|
-
/**
|
|
2127
|
-
* Split diagnostics into "the page this session is driving" and "everything
|
|
2128
|
-
* else". The rings keep every page's entries so chronology and eviction stay
|
|
2129
|
-
* honest, but a reader that promises one tab must not serve another tab's
|
|
2130
|
-
* lines: that is how a model diagnoses an error that belongs to a page it is
|
|
2131
|
-
* not even looking at.
|
|
2132
|
-
*/
|
|
2133
|
-
scopeToCurrentPage(entries) {
|
|
2134
|
-
const currentId = this.currentPageId();
|
|
2135
|
-
if (currentId === undefined)
|
|
2136
|
-
return { kept: entries.slice(), otherPages: 0 };
|
|
2137
|
-
const kept = [];
|
|
2138
|
-
let otherPages = 0;
|
|
2139
|
-
for (const entry of entries) {
|
|
2140
|
-
if (entry.page === currentId)
|
|
2141
|
-
kept.push(entry);
|
|
2142
|
-
else
|
|
2143
|
-
otherPages += 1;
|
|
2144
|
-
}
|
|
2145
|
-
return { kept, otherPages };
|
|
2146
|
-
}
|
|
2147
|
-
/** URL of the tab this session is driving right now, or null. */
|
|
2148
|
-
currentUrl() {
|
|
2149
|
-
try {
|
|
2150
|
-
return this.pageAt(this.currentIndex)?.url() ?? null;
|
|
2151
|
-
}
|
|
2152
|
-
catch {
|
|
2153
|
-
return null;
|
|
2154
|
-
}
|
|
2155
|
-
}
|
|
2156
|
-
/** Recent console messages and uncaught page errors, newest last. */
|
|
2157
|
-
consoleMessages(args) {
|
|
2158
|
-
const wanted = typeof args.level === 'string' && args.level.length > 0 ? args.level : 'all';
|
|
2159
|
-
const limit = typeof args.limit === 'number' && Number.isFinite(args.limit)
|
|
2160
|
-
? Math.max(1, Math.min(this.diagLimit, args.limit))
|
|
2161
|
-
: 50;
|
|
2162
|
-
const matches = (entry) => {
|
|
2163
|
-
if (wanted === 'all')
|
|
2164
|
-
return true;
|
|
2165
|
-
if (wanted === 'error')
|
|
2166
|
-
return entry.level === 'error' || entry.level === 'pageerror';
|
|
2167
|
-
if (wanted === 'warning')
|
|
2168
|
-
return entry.level === 'warning' || entry.level === 'warn';
|
|
2169
|
-
return entry.level === wanted;
|
|
2170
|
-
};
|
|
2171
|
-
const { kept, otherPages } = this.scopeToCurrentPage(this.consoleLog.filter(matches));
|
|
2172
|
-
return {
|
|
2173
|
-
url: this.currentUrl(),
|
|
2174
|
-
level: wanted,
|
|
2175
|
-
total: kept.length,
|
|
2176
|
-
returned: Math.min(limit, kept.length),
|
|
2177
|
-
dropped: this.diagDropped.console,
|
|
2178
|
-
otherPages,
|
|
2179
|
-
note: otherPages > 0
|
|
2180
|
-
? 'Only the tab this session is driving is reported; ' +
|
|
2181
|
-
String(otherPages) +
|
|
2182
|
-
' matching entries from other tabs of the same window are not shown.'
|
|
2183
|
-
: 'Capture starts when this session first drives a tab. In persistent mode every session shares one window; only the tab this session is driving is reported.',
|
|
2184
|
-
entries: kept.slice(-limit).map((entry) => ({
|
|
2185
|
-
n: entry.n,
|
|
2186
|
-
level: entry.level,
|
|
2187
|
-
text: (entry.text ?? '').slice(0, 400),
|
|
2188
|
-
source: String(entry.source).slice(0, 200),
|
|
2189
|
-
})),
|
|
2190
|
-
};
|
|
2191
|
-
}
|
|
2192
|
-
/** Recent network requests, newest last, with status and duration where known. */
|
|
2193
|
-
networkRequests(args) {
|
|
2194
|
-
const limit = typeof args.limit === 'number' && Number.isFinite(args.limit)
|
|
2195
|
-
? Math.max(1, Math.min(this.diagLimit, args.limit))
|
|
2196
|
-
: 50;
|
|
2197
|
-
const needle = typeof args.urlContains === 'string' ? args.urlContains.toLowerCase() : '';
|
|
2198
|
-
const failedOnly = args.failedOnly === true;
|
|
2199
|
-
let matching = this.networkLog;
|
|
2200
|
-
if (needle.length > 0)
|
|
2201
|
-
matching = matching.filter((entry) => (entry.url ?? '').toLowerCase().includes(needle));
|
|
2202
|
-
if (failedOnly)
|
|
2203
|
-
matching = matching.filter((entry) => entry.failure !== null || ((entry.status ?? null) !== null && (entry.status ?? 0) >= 400));
|
|
2204
|
-
const { kept, otherPages } = this.scopeToCurrentPage(matching);
|
|
2205
|
-
return {
|
|
2206
|
-
url: this.currentUrl(),
|
|
2207
|
-
total: kept.length,
|
|
2208
|
-
returned: Math.min(limit, kept.length),
|
|
2209
|
-
dropped: this.diagDropped.network,
|
|
2210
|
-
otherPages,
|
|
2211
|
-
note: otherPages > 0
|
|
2212
|
-
? 'Only the tab this session is driving is reported; ' +
|
|
2213
|
-
String(otherPages) +
|
|
2214
|
-
' matching requests from other tabs of the same window are not shown. Response bodies are not captured.'
|
|
2215
|
-
: 'Capture starts when this session first drives a tab. In persistent mode every session shares one window; only the tab this session is driving is reported. Response bodies are not captured.',
|
|
2216
|
-
entries: kept.slice(-limit).map((entry) => ({
|
|
2217
|
-
n: entry.n,
|
|
2218
|
-
method: entry.method,
|
|
2219
|
-
url: (entry.url ?? '').slice(0, 300),
|
|
2220
|
-
status: entry.status,
|
|
2221
|
-
kind: entry.kind,
|
|
2222
|
-
ms: entry.ms,
|
|
2223
|
-
failure: entry.failure,
|
|
2224
|
-
})),
|
|
2225
|
-
};
|
|
2226
|
-
}
|
|
2227
|
-
/**
|
|
2228
|
-
* Refuse page access while a native dialog blocks the page: every call
|
|
2229
|
-
* would otherwise stall until the action timeout. Only browser_dialog and
|
|
2230
|
-
* the blocked-snapshot report are allowed through (DIALOG-POLICY.md).
|
|
2231
|
-
*/
|
|
2232
|
-
guardBlockedDialog(page) {
|
|
2233
|
-
if (this.bypassDialogGuard === true)
|
|
2234
|
-
return;
|
|
2235
|
-
const pending = this.provider.pendingDialogFor(page);
|
|
2236
|
-
if (pending !== undefined)
|
|
2237
|
-
throw new BrowserError('DIALOG_PENDING', this.provider.dialogNoteFor(pending));
|
|
2238
|
-
}
|
|
2239
|
-
/** Refuse context-wide tab work while any page in it has a parked dialog. */
|
|
2240
|
-
guardBlockedContext() {
|
|
2241
|
-
if (this.bypassDialogGuard === true)
|
|
2242
|
-
return;
|
|
2243
|
-
for (const page of this.context.pages()) {
|
|
2244
|
-
const pending = this.provider.pendingDialogFor(page);
|
|
2245
|
-
if (pending !== undefined)
|
|
2246
|
-
throw new BrowserError('DIALOG_PENDING', this.provider.dialogNoteFor(pending));
|
|
2247
|
-
}
|
|
2248
|
-
}
|
|
2249
|
-
/** Run one internal step with the dialog guard lifted. */
|
|
2250
|
-
async withDialogGuardLifted(fn) {
|
|
2251
|
-
this.bypassDialogGuard = true;
|
|
2252
|
-
try {
|
|
2253
|
-
return await fn();
|
|
2254
|
-
}
|
|
2255
|
-
finally {
|
|
2256
|
-
this.bypassDialogGuard = false;
|
|
2257
|
-
}
|
|
2258
|
-
}
|
|
2259
|
-
pageAt(index) {
|
|
2260
|
-
return this.context.pages()[index];
|
|
2261
|
-
}
|
|
2262
|
-
async refLocator(ref) {
|
|
2263
|
-
this.assertLive();
|
|
2264
|
-
if (!REF_PATTERN.test(ref)) {
|
|
2265
|
-
throw new BrowserError('REF_NOT_FOUND', 'invalid ref ' + JSON.stringify(ref) + ': use a ref from the latest browser_snapshot');
|
|
2266
|
-
}
|
|
2267
|
-
const locator = this.currentPageSync()
|
|
2268
|
-
.locator('[data-dsh-ref="' + ref + '"]')
|
|
2269
|
-
.first();
|
|
2270
|
-
// Fast-fail on stale refs (per-snapshot nonces make a reused number
|
|
2271
|
-
// impossible) instead of burning the full action timeout.
|
|
2272
|
-
if ((await locator.count()) === 0) {
|
|
2273
|
-
throw new BrowserError('REF_NOT_FOUND', 'ref ' +
|
|
2274
|
-
JSON.stringify(ref) +
|
|
2275
|
-
' no longer matches an element; take a fresh browser_snapshot and use a ref from its result');
|
|
2276
|
-
}
|
|
2277
|
-
return locator;
|
|
2278
|
-
}
|
|
2279
|
-
currentPageSync() {
|
|
2280
|
-
const pages = this.context.pages();
|
|
2281
|
-
const page = pages[Math.min(this.currentIndex, Math.max(0, pages.length - 1))];
|
|
2282
|
-
if (page === undefined)
|
|
2283
|
-
throw new BrowserError('SESSION_CLOSED', 'the browser session has no open page');
|
|
2284
|
-
this.guardBlockedDialog(page);
|
|
2285
|
-
return page;
|
|
2286
|
-
}
|
|
2287
|
-
assertLive() {
|
|
2288
|
-
if (this.closed) {
|
|
2289
|
-
throw new BrowserError('SESSION_CLOSED', 'the browser session was closed via browser_close; the next browser call opens a fresh one');
|
|
2290
|
-
}
|
|
2291
|
-
if (!this.provider.isLive(this.owner)) {
|
|
2292
|
-
// The window went away without this session asking for it: idle
|
|
2293
|
-
// disposal, an external manual close, or a crash. Surface it as a
|
|
2294
|
-
// crash so the model retries — the next call relaunches with the
|
|
2295
|
-
// saved login state instead of reporting a misleading close.
|
|
2296
|
-
this.closed = true;
|
|
2297
|
-
throw new BrowserError('BROWSER_CRASHED', 'the browser window is no longer available (idle timeout, manual close, or a crash); the next browser call reopens it with the saved login state');
|
|
2298
|
-
}
|
|
2299
|
-
}
|
|
2300
|
-
}
|
|
2301
|
-
/** Whether an error is a caller-initiated abort, which must propagate untouched. */
|
|
2302
|
-
function isAbortError(error) {
|
|
2303
|
-
return error instanceof DOMException && error.name === 'AbortError';
|
|
2304
|
-
}
|
|
2305
|
-
/** Whether a Playwright failure is a mid-evaluate navigation race. */
|
|
2306
|
-
function isContextDestroyed(error) {
|
|
2307
|
-
return error instanceof Error && /Execution context was destroyed/.test(error.message);
|
|
2308
|
-
}
|
|
2309
|
-
/** Whether a failure means the browser or its page died and must be relaunched. */
|
|
2310
|
-
function isCrashError(error) {
|
|
2311
|
-
if (!(error instanceof Error))
|
|
2312
|
-
return false;
|
|
2313
|
-
return /browser has disconnected|has been closed|Target closed|Target crashed|ProcessSingleton|Session closed|Cannot access object .* destroyed|The page .* was closed/i.test(error.message);
|
|
2314
|
-
}
|
|
2315
|
-
/**
|
|
2316
|
-
* Post-hover note. A hover proves nothing by itself: the call returning only
|
|
2317
|
-
* means the pointer moved. The cheap evidence that content appeared is how the
|
|
2318
|
-
* actionable ref count changed, so the note reports it and tells the model
|
|
2319
|
-
* what to do when nothing appeared (retarget the owner of the menu, never
|
|
2320
|
-
* retry the same hover, never fall back to raw coordinates).
|
|
2321
|
-
* @param target - description of what was hovered (a ref, or a quoted label).
|
|
2322
|
-
* @param refsBefore - actionable refs before the hover.
|
|
2323
|
-
* @param refsAfter - actionable refs in the returned snapshot.
|
|
2324
|
-
* @param navigated - whether the hover changed the page URL.
|
|
2325
|
-
*/
|
|
2326
|
-
function hoverNote(target, refsBefore, refsAfter, navigated) {
|
|
2327
|
-
const grew = refsAfter - refsBefore;
|
|
2328
|
-
if (navigated) {
|
|
2329
|
-
return ('hover on ' +
|
|
2330
|
-
target +
|
|
2331
|
-
' triggered a navigation (' +
|
|
2332
|
-
refsBefore +
|
|
2333
|
-
' refs before, ' +
|
|
2334
|
-
refsAfter +
|
|
2335
|
-
' after) — a hover should not navigate: verify the new page before continuing');
|
|
2336
|
-
}
|
|
2337
|
-
if (grew > 0) {
|
|
2338
|
-
return ('hover delivered to ' +
|
|
2339
|
-
target +
|
|
2340
|
-
': ' +
|
|
2341
|
-
refsBefore +
|
|
2342
|
-
' refs before, ' +
|
|
2343
|
-
refsAfter +
|
|
2344
|
-
' after (+' +
|
|
2345
|
-
grew +
|
|
2346
|
-
') — hover-revealed content is in this snapshot; act on it next, because any other pointer action may close it (pre-hover refs are stale)');
|
|
2347
|
-
}
|
|
2348
|
-
return ('hover delivered to ' +
|
|
2349
|
-
target +
|
|
2350
|
-
' but the page did not grow (' +
|
|
2351
|
-
refsBefore +
|
|
2352
|
-
' refs before and after): if what you expected is still missing, THIS target does not reveal on hover — do NOT repeat this call and do NOT fall back to raw coordinates; hover the parent/container or the sibling that owns the menu instead, and if that reveals nothing either, hover is not implemented for this control');
|
|
2353
|
-
}
|
|
2354
|
-
/** Remove browser label stamps. Used via page.evaluate(clearLabelTargets). */
|
|
2355
|
-
const clearLabelTargets = () => {
|
|
2356
|
-
for (const el of document.querySelectorAll('[data-dsh-label-target]'))
|
|
2357
|
-
el.removeAttribute('data-dsh-label-target');
|
|
2358
|
-
};
|
|
2359
|
-
/**
|
|
2360
|
-
* Page-side resolution for browser click/hover label mode: find the visible element
|
|
2361
|
-
* whose label (aria-label / alt / title / placeholder / own text) equals the
|
|
2362
|
-
* prefer the deepest match, stamp it with data-dsh-label-target, and describe
|
|
2363
|
-
* it so the landing note can name what was actually hovered. Runs inside the
|
|
2364
|
-
* page; used via page.evaluate(labelTargetProbe, { text }).
|
|
2365
|
-
*/
|
|
2366
|
-
const labelTargetProbe = (arg) => {
|
|
2367
|
-
const wanted = String(arg.text).replace(/\s+/g, ' ').trim();
|
|
2368
|
-
const norm = (s) => (s || '').replace(/\s+/g, ' ').trim();
|
|
2369
|
-
const ownText = (el) => {
|
|
2370
|
-
let out = '';
|
|
2371
|
-
for (const node of el.childNodes) {
|
|
2372
|
-
if (node.nodeType === 3)
|
|
2373
|
-
out += node.textContent;
|
|
2374
|
-
else if (node.nodeType === 1 && node.tagName.toLowerCase() === 'br')
|
|
2375
|
-
out += ' ';
|
|
2376
|
-
}
|
|
2377
|
-
return norm(out);
|
|
2378
|
-
};
|
|
2379
|
-
// Mirror the snapshot's name sources, so anything the page shows under a
|
|
2380
|
-
// name can be addressed by that name: aria-label / alt / title / placeholder
|
|
2381
|
-
// first, then the element's own visible text.
|
|
2382
|
-
const labelOf = (el) => {
|
|
2383
|
-
const attr = (name) => {
|
|
2384
|
-
const v = el.getAttribute(name);
|
|
2385
|
-
return v && v.trim() ? v : '';
|
|
2386
|
-
};
|
|
2387
|
-
return (norm(attr('aria-label')) || norm(attr('alt')) || norm(attr('title')) || norm(attr('placeholder')) || ownText(el));
|
|
2388
|
-
};
|
|
2389
|
-
const visible = (el) => {
|
|
2390
|
-
if (!(el instanceof Element))
|
|
2391
|
-
return false;
|
|
2392
|
-
if (el.getAttribute('hidden') !== null || el.getAttribute('aria-hidden') === 'true')
|
|
2393
|
-
return false;
|
|
2394
|
-
const style = getComputedStyle(el);
|
|
2395
|
-
if (style.display === 'none' || style.visibility === 'hidden')
|
|
2396
|
-
return false;
|
|
2397
|
-
const rect = el.getBoundingClientRect();
|
|
2398
|
-
return rect.width > 0 || rect.height > 0;
|
|
2399
|
-
};
|
|
2400
|
-
const depthOf = (el) => {
|
|
2401
|
-
let depth = 0;
|
|
2402
|
-
let cur = el;
|
|
2403
|
-
while (cur !== null && cur.parentElement !== null) {
|
|
2404
|
-
depth += 1;
|
|
2405
|
-
cur = cur.parentElement;
|
|
2406
|
-
}
|
|
2407
|
-
return depth;
|
|
2408
|
-
};
|
|
2409
|
-
const root = document.body !== null && document.body !== undefined ? document.body : document.documentElement;
|
|
2410
|
-
const all = Array.from(root.querySelectorAll('*')).slice(0, 8000).filter(visible);
|
|
2411
|
-
let pool = all.filter((el) => labelOf(el) === wanted);
|
|
2412
|
-
let exact = true;
|
|
2413
|
-
if (pool.length === 0) {
|
|
2414
|
-
exact = false;
|
|
2415
|
-
pool = all.filter((el) => ownText(el).indexOf(wanted) !== -1 || labelOf(el).indexOf(wanted) !== -1);
|
|
2416
|
-
}
|
|
2417
|
-
if (pool.length === 0)
|
|
2418
|
-
return { found: false, total: 0 };
|
|
2419
|
-
pool.sort((a, b) => depthOf(b) - depthOf(a));
|
|
2420
|
-
const picked = pool[0];
|
|
2421
|
-
if (picked === undefined)
|
|
2422
|
-
return { found: false, total: pool.length };
|
|
2423
|
-
for (const el of document.querySelectorAll('[data-dsh-label-target]'))
|
|
2424
|
-
el.removeAttribute('data-dsh-label-target');
|
|
2425
|
-
const token = 'ht' + Date.now().toString(36) + Math.floor(Math.random() * 1e6).toString(36);
|
|
2426
|
-
try {
|
|
2427
|
-
picked.setAttribute('data-dsh-label-target', token);
|
|
2428
|
-
}
|
|
2429
|
-
catch (_) {
|
|
2430
|
-
return { found: false, total: pool.length };
|
|
2431
|
-
}
|
|
2432
|
-
const cls = typeof picked.className === 'string' && picked.className.trim() !== ''
|
|
2433
|
-
? '.' + picked.className.trim().split(/\s+/).slice(0, 2).join('.')
|
|
2434
|
-
: '';
|
|
2435
|
-
return {
|
|
2436
|
-
found: true,
|
|
2437
|
-
token,
|
|
2438
|
-
desc: picked.tagName.toLowerCase() + (picked.id ? '#' + picked.id : '') + cls,
|
|
2439
|
-
total: pool.length,
|
|
2440
|
-
exact,
|
|
2441
|
-
};
|
|
2442
|
-
};
|
|
2443
|
-
/**
|
|
2444
|
-
* Element-side bounded stability probe (Codex-style): samples the element's
|
|
2445
|
-
* boundingClientRect every animation frame; resolves true once it repeats for
|
|
2446
|
-
* `frames` consecutive frames, false after `timeoutMs`. Never hangs: the cap
|
|
2447
|
-
* always resolves. Used via locator.evaluate(elementStabilityProbe, arg).
|
|
2448
|
-
*/
|
|
2449
|
-
const elementStabilityProbe = (el, arg) => new Promise((resolve) => {
|
|
2450
|
-
const frames = arg.frames;
|
|
2451
|
-
const timeoutMs = arg.timeoutMs;
|
|
2452
|
-
const deadline = performance.now() + timeoutMs;
|
|
2453
|
-
let prev = null;
|
|
2454
|
-
let stable = 0;
|
|
2455
|
-
const sig = () => {
|
|
2456
|
-
const r = el.getBoundingClientRect();
|
|
2457
|
-
return [r.left, r.top, r.width, r.height].join(',');
|
|
2458
|
-
};
|
|
2459
|
-
const tick = () => {
|
|
2460
|
-
const cur = sig();
|
|
2461
|
-
if (prev !== null && cur === prev) {
|
|
2462
|
-
stable += 1;
|
|
2463
|
-
if (stable >= frames) {
|
|
2464
|
-
resolve(true);
|
|
2465
|
-
return;
|
|
2466
|
-
}
|
|
2467
|
-
}
|
|
2468
|
-
else {
|
|
2469
|
-
stable = 0;
|
|
2470
|
-
}
|
|
2471
|
-
prev = cur;
|
|
2472
|
-
if (performance.now() >= deadline) {
|
|
2473
|
-
resolve(false);
|
|
2474
|
-
return;
|
|
2475
|
-
}
|
|
2476
|
-
requestAnimationFrame(tick);
|
|
2477
|
-
};
|
|
2478
|
-
requestAnimationFrame(tick);
|
|
2479
|
-
});
|
|
2480
|
-
/**
|
|
2481
|
-
* Page-side bounded stability probe: samples layout size + pending images +
|
|
2482
|
-
* readyState. Used via page.evaluate(pageStabilityProbe, arg). The returned
|
|
2483
|
-
* "false on timeout" is intentional: a busy page must not stall the agent.
|
|
2484
|
-
*/
|
|
2485
|
-
const pageStabilityProbe = (arg) => new Promise((resolve) => {
|
|
2486
|
-
const frames = arg.frames;
|
|
2487
|
-
const timeoutMs = arg.timeoutMs;
|
|
2488
|
-
const deadline = performance.now() + timeoutMs;
|
|
2489
|
-
let prev = null;
|
|
2490
|
-
let stable = 0;
|
|
2491
|
-
const sig = () => {
|
|
2492
|
-
const de = document.documentElement;
|
|
2493
|
-
return [de.scrollWidth, de.scrollHeight, document.querySelectorAll('img:not([complete])').length].join(',');
|
|
2494
|
-
};
|
|
2495
|
-
const tick = () => {
|
|
2496
|
-
const cur = sig();
|
|
2497
|
-
if (prev !== null && cur === prev) {
|
|
2498
|
-
stable += 1;
|
|
2499
|
-
if (stable >= frames) {
|
|
2500
|
-
resolve(true);
|
|
2501
|
-
return;
|
|
2502
|
-
}
|
|
2503
|
-
}
|
|
2504
|
-
else {
|
|
2505
|
-
stable = 0;
|
|
2506
|
-
}
|
|
2507
|
-
prev = cur;
|
|
2508
|
-
if (performance.now() >= deadline) {
|
|
2509
|
-
resolve(false);
|
|
2510
|
-
return;
|
|
2511
|
-
}
|
|
2512
|
-
requestAnimationFrame(tick);
|
|
2513
|
-
};
|
|
2514
|
-
requestAnimationFrame(tick);
|
|
2515
|
-
});
|