dsh-browser-playwright-codex 0.5.2 → 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.
@@ -0,0 +1,725 @@
1
+ /**
2
+ * 页面动作:可点击性检查、点击(几何阶梯 + Playwright 兜底)、填充与写后核对、按键、悬停、
3
+ * 滚动、前进后退、标签切换,以及承载它们的可中断等待管道(withAbort)。
4
+ * @module dsh-browser-playwright-codex/page-actions
5
+ */
6
+ import { hitTest } from "./page-probes.js";
7
+ import { BrowserError } from "./errors.js";
8
+ import { assertAllowedUrl } from "./url-policy.js";
9
+ import { asTimeoutError, isAbortError } from "./page-diagnostics.js";
10
+ import { clearLabelTargets, labelTargetProbe } from "./page-probes.js";
11
+ import { createBackgroundPage, isWindowMinimized } from "./window-focus.js";
12
+ /** Wrap a Playwright op so an aborted signal rejects while the op keeps draining in the background. */
13
+ export async function withAbort(promise, signal) {
14
+ // Attach handlers FIRST: the caller's promise may still be draining (a
15
+ // dialog can keep it pending forever), and an abandoned rejection would
16
+ // become an unhandledRejection and can take the host down.
17
+ const guarded = Promise.resolve(promise);
18
+ guarded.catch(() => { });
19
+ // A malformed signal (not an AbortSignal) is treated as "no signal".
20
+ if (signal === undefined || typeof signal.addEventListener !== 'function' || typeof signal.aborted !== 'boolean')
21
+ return guarded;
22
+ if (signal.aborted)
23
+ throw new DOMException('The operation was aborted', 'AbortError');
24
+ return new Promise((resolve, reject) => {
25
+ const onAbort = () => reject(new DOMException('The operation was aborted', 'AbortError'));
26
+ signal.addEventListener('abort', onAbort, { once: true });
27
+ guarded.then((value) => {
28
+ signal.removeEventListener('abort', onAbort);
29
+ resolve(value);
30
+ }, (reason) => {
31
+ signal.removeEventListener('abort', onAbort);
32
+ reject(reason);
33
+ });
34
+ });
35
+ }
36
+ /**
37
+ * Post-hover note. A hover proves nothing by itself: the call returning only
38
+ * means the pointer moved. The cheap evidence that content appeared is how the
39
+ * actionable ref count changed, so the note reports it and tells the model
40
+ * what to do when nothing appeared (retarget the owner of the menu, never
41
+ * retry the same hover, never fall back to raw coordinates).
42
+ * @param target - description of what was hovered (a ref, or a quoted label).
43
+ * @param refsBefore - actionable refs before the hover.
44
+ * @param refsAfter - actionable refs in the returned snapshot.
45
+ * @param navigated - whether the hover changed the page URL.
46
+ */
47
+ export function hoverNote(target, refsBefore, refsAfter, navigated) {
48
+ const grew = refsAfter - refsBefore;
49
+ if (navigated) {
50
+ return ('hover on ' +
51
+ target +
52
+ ' triggered a navigation (' +
53
+ refsBefore +
54
+ ' refs before, ' +
55
+ refsAfter +
56
+ ' after) — a hover should not navigate: verify the new page before continuing');
57
+ }
58
+ if (grew > 0) {
59
+ return ('hover delivered to ' +
60
+ target +
61
+ ': ' +
62
+ refsBefore +
63
+ ' refs before, ' +
64
+ refsAfter +
65
+ ' after (+' +
66
+ grew +
67
+ ') — hover-revealed content is in this snapshot; act on it next, because any other pointer action may close it (pre-hover refs are stale)');
68
+ }
69
+ return ('hover delivered to ' +
70
+ target +
71
+ ' but the page did not grow (' +
72
+ refsBefore +
73
+ ' 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');
74
+ }
75
+ /** Fast actionability pre-check (visible/enabled/editable) before an action. */
76
+ export async function assertActionable(_session, locator, options = {}) {
77
+ const editable = options.editable === true;
78
+ const visible = await locator.isVisible().catch(() => false);
79
+ if (!visible) {
80
+ 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');
81
+ }
82
+ const enabled = await locator.isEnabled().catch(() => false);
83
+ if (!enabled) {
84
+ throw new BrowserError('ELEMENT_NOT_ACTIONABLE', 'the element is disabled and cannot be acted on');
85
+ }
86
+ if (editable) {
87
+ const canEdit = await locator.isEditable().catch(() => false);
88
+ if (!canEdit) {
89
+ throw new BrowserError('ELEMENT_NOT_ACTIONABLE', 'the element is not editable (read-only or locked by the page)');
90
+ }
91
+ }
92
+ }
93
+ /**
94
+ * Run one page action that may raise a native dialog. Such an action never
95
+ * resolves while the dialog is up, so race it against the dialog appearing
96
+ * and hand the caller the pending state instead (Playwright-MCP pattern).
97
+ */
98
+ export async function runAction(session, page, action) {
99
+ const already = session.provider.pendingDialogFor(page);
100
+ if (already !== undefined)
101
+ return { blocked: true, pending: already };
102
+ const wait = session.provider.armDialogWait(page);
103
+ const outcome = action().then((value) => ({ ok: true, value }), (error) => ({ ok: false, error }));
104
+ const winner = await Promise.race([outcome, wait.promise]);
105
+ wait.cancel();
106
+ if (winner === true)
107
+ return { blocked: true, pending: session.provider.pendingDialogFor(page) };
108
+ if (winner.ok !== true)
109
+ throw winner.error;
110
+ return { blocked: false, value: winner.value };
111
+ }
112
+ export async function navigate(session, url, waitUntil, signal) {
113
+ return session.run(async () => {
114
+ const target = assertAllowedUrl(url, session.provider.config.allowedDomains ?? []);
115
+ const page = await session.ensurePage();
116
+ try {
117
+ const racedNav = await session.runAction(page, () => withAbort(page.goto(target.toString(), { waitUntil, timeout: session.timeoutMs() }), signal));
118
+ if (racedNav.blocked)
119
+ return session.blockedSnapshot(page, racedNav.pending);
120
+ }
121
+ catch (error) {
122
+ if (isAbortError(error) || signal?.aborted === true)
123
+ throw error;
124
+ throw asTimeoutError('navigation', error);
125
+ }
126
+ return session.settleAndSnapshot(page, true);
127
+ });
128
+ }
129
+ /**
130
+ * Codex-style click-point resolution. Tries a scroll-alignment ladder
131
+ * (centre, then end, then start). For each alignment: scroll the element
132
+ * into view with a native scrollIntoView, wait for its bounding rect to
133
+ * stop moving, sample the rect centre in-page (sub-pixel, no screenshot
134
+ * measurement), then hit-test that exact point with elementFromPoint — so
135
+ * an overlaying element can never swallow the click silently. Returns the
136
+ * first unobstructed {x, y}, or throws describing what intercepted it.
137
+ */
138
+ export async function resolveClickPoint(session, locator, signal) {
139
+ const alignments = [
140
+ { block: 'center', inline: 'nearest' },
141
+ { block: 'end', inline: 'end' },
142
+ { block: 'start', inline: 'start' },
143
+ ];
144
+ /** What the in-page probe answers. A discriminated union so the caller can switch on it. */
145
+ let lastBlockedBy;
146
+ let lastReason;
147
+ let lastGeometry;
148
+ /** Message from the most recent probe that threw, so the failure names its cause. */
149
+ let probeError;
150
+ // 2026-10-05: the first version of session loop dropped the probe's reason on the
151
+ // floor — the guard below read `lastReason === null` while the variable started
152
+ // as `undefined`, so it never ran and every transient miss came out as "could not
153
+ // find an unobstructed click point" with nothing to act on. That message is what
154
+ // the slower Windows runner produced while ubuntu and local runs passed, and it
155
+ // cost a debugging session to learn what it actually meant. Record the reason and
156
+ // keep the most informative one.
157
+ const rank = {
158
+ intercepted: 5,
159
+ 'zero-size': 4,
160
+ 'off-screen': 3,
161
+ 'nothing-at-point': 2,
162
+ unreachable: 1,
163
+ };
164
+ const record = (reason, geometry) => {
165
+ if (lastReason === undefined || (rank[reason] ?? 0) > (rank[lastReason] ?? 0)) {
166
+ lastReason = reason;
167
+ lastGeometry = geometry;
168
+ }
169
+ };
170
+ for (const align of alignments) {
171
+ try {
172
+ // Native scroll so block alignment is honoured; Playwright's
173
+ // scrollIntoViewIfNeeded only promises *some* visibility.
174
+ await withAbort(locator.evaluate((el, a) => {
175
+ if (typeof el.scrollIntoView === 'function')
176
+ el.scrollIntoView({ block: a.block, inline: a.inline, behavior: 'instant' });
177
+ }, align), signal);
178
+ }
179
+ catch {
180
+ /* try the next alignment */
181
+ }
182
+ // Sample a few times per alignment: a page that is still laying out can move the
183
+ // element between the scroll and the probe, and the first sample then misses even
184
+ // though a later one is fine. Retries are bounded and cheap — the stability wait
185
+ // only runs before the first sample, and the extra samples are 60ms apart.
186
+ for (let attempt = 0; attempt < 3; attempt += 1) {
187
+ if (attempt === 0)
188
+ await session.waitForElementStable(locator);
189
+ else
190
+ await new Promise((resolve) => setTimeout(resolve, 60));
191
+ const probe = await withAbort(locator.evaluate(hitTest), signal).catch((error) => {
192
+ // Keep the message. "unreachable" on its own told nobody anything, and
193
+ // swallowing the error here is exactly what let a broken probe look like an
194
+ // obstructed element for a whole debugging session.
195
+ probeError = error instanceof Error ? error.message : String(error);
196
+ return null;
197
+ });
198
+ if (probe !== null && probe.ok)
199
+ return { x: probe.x, y: probe.y, hitDesc: probe.hitDesc, hitSelf: probe.hitSelf };
200
+ if (probe === null)
201
+ record('unreachable', probeError);
202
+ else {
203
+ record(probe.reason, probe.geometry);
204
+ if (probe.reason === 'intercepted')
205
+ lastBlockedBy = probe.by;
206
+ }
207
+ // Interception is a property of the page, not of the moment: re-sampling cannot
208
+ // clear it, so spend the remaining budget on the next alignment instead.
209
+ if (probe !== null && probe.reason === 'intercepted')
210
+ break;
211
+ }
212
+ }
213
+ if (lastReason === 'intercepted') {
214
+ throw new BrowserError('ELEMENT_NOT_ACTIONABLE', 'the element does not receive pointer events at its centre: ' +
215
+ (lastBlockedBy || 'another element') +
216
+ ' intercepts the click (tried centre / end / start alignment)');
217
+ }
218
+ if (lastReason === 'off-screen') {
219
+ throw new BrowserError('ELEMENT_NOT_ACTIONABLE', 'the element is outside the viewport even after scrolling it into view; nothing of it is on screen' +
220
+ (lastGeometry === undefined ? '' : ' (' + lastGeometry + ')') +
221
+ '; take a fresh browser_snapshot');
222
+ }
223
+ if (lastReason === 'zero-size') {
224
+ throw new BrowserError('ELEMENT_NOT_ACTIONABLE', 'ref has no clickable geometry (zero size); take a fresh browser_snapshot');
225
+ }
226
+ throw new BrowserError('ELEMENT_NOT_ACTIONABLE', 'could not find an unobstructed click point for the ref (last probe: ' +
227
+ (lastReason ?? 'no probe completed') +
228
+ (lastGeometry === undefined ? '' : ' — ' + lastGeometry) +
229
+ '); take a fresh browser_snapshot');
230
+ }
231
+ /**
232
+ * Click an element the snapshot gave no ref, addressed by its visible label
233
+ * (e.g. a menu item revealed by browser_hover that is plain text). The
234
+ * label must equal an element's own direct text; the deepest match wins,
235
+ * and the landing note names what was actually clicked plus how many
236
+ * elements shared the label.
237
+ */
238
+ export async function clickText(session, text, signal) {
239
+ return session.run(async () => {
240
+ const page = session.currentPageSync();
241
+ const found = await page.evaluate(labelTargetProbe, { text: String(text) }).catch(() => null);
242
+ if (found === null || found.found !== true) {
243
+ throw new BrowserError('CLICK_TARGET_NOT_FOUND', 'no visible element on session page has the label ' +
244
+ JSON.stringify(text) +
245
+ '; take a browser_snapshot and copy the label exactly as it is shown there, or click by ref');
246
+ }
247
+ const locator = page.locator('[data-dsh-label-target="' + found.token + '"]').first();
248
+ try {
249
+ return await session.clickLocator(locator, signal, JSON.stringify(text) + ' (' + found.desc + ')', found.total);
250
+ }
251
+ finally {
252
+ // The stamp is only a locator handle: clear it once the click
253
+ // has been delivered so no residue is left in the page. While a
254
+ // dialog blocks the page session evaluate would hang, so skip it.
255
+ if (session.provider.pendingDialogFor(page) === undefined)
256
+ await page.evaluate(clearLabelTargets).catch(() => { });
257
+ }
258
+ });
259
+ }
260
+ /**
261
+ * Shared click core: enforce the URL policy for link targets, run the
262
+ * Codex-style geometry ladder with the Playwright actionability fallback,
263
+ * then report what actually received the click. The label names the target
264
+ * in the landing note; shared is how many elements matched it (0 for refs).
265
+ */
266
+ export async function clickLocator(session, locator, signal, label, shared) {
267
+ session.assertClickTargetAllowed(await locator.getAttribute('href'));
268
+ const page = session.currentPageSync();
269
+ const urlBefore = page.url();
270
+ let point;
271
+ try {
272
+ // Codex-style geometry pipeline first: alignment ladder →
273
+ // stable rect → centre point → elementFromPoint hit-test.
274
+ point = await session.resolveClickPoint(locator, signal);
275
+ }
276
+ catch (error) {
277
+ if (isAbortError(error))
278
+ throw error;
279
+ if (error instanceof BrowserError) {
280
+ // Ladder exhausted: let Playwright's own actionability
281
+ // machinery retry (it waits for transient overlays to
282
+ // disappear) and surface its verdict before ours.
283
+ try {
284
+ const racedFallback = await session.runAction(page, () => withAbort(locator.click({ timeout: Math.min(session.timeoutMs(), 3000) }), signal));
285
+ if (racedFallback.blocked)
286
+ return session.blockedSnapshot(page, racedFallback.pending);
287
+ const snap = await session.settleAndSnapshot(page, page.url() !== urlBefore);
288
+ snap.landingNote =
289
+ 'playwright actionability click delivered to ' +
290
+ label +
291
+ ' after the geometry ladder was blocked — verify the page state shows the intended change';
292
+ return snap;
293
+ }
294
+ catch (fallbackError) {
295
+ if (isAbortError(fallbackError))
296
+ throw fallbackError;
297
+ throw error;
298
+ }
299
+ }
300
+ throw error;
301
+ }
302
+ let raced;
303
+ try {
304
+ raced = await session.runAction(page, () => withAbort(page.mouse.click(point.x, point.y), signal));
305
+ }
306
+ catch (error) {
307
+ if (isAbortError(error))
308
+ throw error;
309
+ throw asTimeoutError('action', error);
310
+ }
311
+ if (raced.blocked)
312
+ return session.blockedSnapshot(page, raced.pending);
313
+ const snap = await session.settleAndSnapshot(page, page.url() !== urlBefore);
314
+ const note = 'click delivered at (' +
315
+ Math.round(point.x) +
316
+ ',' +
317
+ Math.round(point.y) +
318
+ ') — landed on ' +
319
+ point.hitDesc +
320
+ (point.hitSelf ? ' (the targeted element itself)' : ' (inside the targeted element)');
321
+ snap.landingNote =
322
+ shared > 1
323
+ ? 'clicked the FIRST of ' +
324
+ shared +
325
+ ' visible elements labelled ' +
326
+ label +
327
+ ' — if that is not the one you meant, take a browser_snapshot and click by ref. ' +
328
+ note
329
+ : note;
330
+ return snap;
331
+ }
332
+ export async function clickAt(session, x, y, signal) {
333
+ return session.run(async () => {
334
+ const page = session.currentPageSync();
335
+ const urlBefore = page.url();
336
+ // Raw mode has no locator: sample what sits at the target point so the
337
+ // returned snapshot can say what actually received the click, and so the
338
+ // URL policy can be enforced for the link that point belongs to.
339
+ const probe = await page
340
+ .evaluate((pt) => {
341
+ const px = pt.x;
342
+ const py = pt.y;
343
+ let hit = null;
344
+ try {
345
+ hit = document.elementFromPoint(px, py);
346
+ }
347
+ catch {
348
+ hit = null;
349
+ }
350
+ if (!hit)
351
+ return { desc: '(nothing — blank area or outside the viewport)', linkHref: null };
352
+ const cls = typeof hit.className === 'string' && hit.className.trim()
353
+ ? '.' + hit.className.trim().split(/\s+/).filter(Boolean).join('.')
354
+ : '';
355
+ const role = hit.getAttribute && hit.getAttribute('role');
356
+ const text = hit.childElementCount === 0 && hit.textContent ? ' "' + hit.textContent.trim().slice(0, 24) + '"' : '';
357
+ const anchor = hit.closest('a[href]');
358
+ return {
359
+ desc: (hit.tagName ? hit.tagName.toLowerCase() : hit.nodeName) +
360
+ (hit.id ? '#' + hit.id : '') +
361
+ cls +
362
+ (role ? '[role=' + role + ']' : '') +
363
+ text,
364
+ linkHref: anchor === null ? null : anchor.getAttribute('href'),
365
+ };
366
+ }, { x, y })
367
+ .catch(() => null);
368
+ const hitDesc = probe === null ? '(unavailable)' : probe.desc;
369
+ // A raw click can navigate: enforce the policy for the link under the
370
+ // point before any input is delivered.
371
+ session.assertClickTargetAllowed(probe === null ? null : probe.linkHref);
372
+ let racedAt = { blocked: false };
373
+ try {
374
+ // Raw native click by viewport coordinates (CDP Input
375
+ // dispatch, isTrusted=true — indistinguishable from a human
376
+ // click). No ref lookup, no visibility/actionability gate:
377
+ // the escape hatch for elements the snapshot tree cannot
378
+ // represent or stubborn rows that refuse ref-based clicks.
379
+ racedAt = await session.runAction(page, () => withAbort(page.mouse.click(x, y), signal));
380
+ }
381
+ catch (error) {
382
+ if (isAbortError(error))
383
+ throw error;
384
+ throw asTimeoutError('action', error);
385
+ }
386
+ if (racedAt.blocked)
387
+ return session.blockedSnapshot(page, racedAt.pending);
388
+ const snap = await session.settleAndSnapshot(page, page.url() !== urlBefore);
389
+ snap.landingNote = 'raw click delivered at (' + x + ',' + y + ') — that point held: ' + hitDesc;
390
+ return snap;
391
+ });
392
+ }
393
+ export async function clickAtRef(session, ref, signal) {
394
+ return session.run(async () => {
395
+ const locator = await session.refLocator(ref);
396
+ const page = session.currentPageSync();
397
+ const urlBefore = page.url();
398
+ // Ref mode resolves the element before clicking, so the link policy can
399
+ // be enforced up front — the same rule browser_click applies.
400
+ session.assertClickTargetAllowed(await locator.getAttribute('href'));
401
+ try {
402
+ // Codex-style geometry click with obstruction awareness:
403
+ // scroll (centre → end → start), wait for the bounding rect to
404
+ // stop moving, then click the RECT CENTRE computed in-page —
405
+ // no screenshot measurement, no devicePixelRatio guesswork,
406
+ // sub-pixel exact. Before committing, hit-test the point so an
407
+ // overlay can never swallow the click silently; if something
408
+ // intercepts, realign and retry up to the alignment ladder.
409
+ const point = await session.resolveClickPoint(locator, signal);
410
+ const racedRef = await session.runAction(page, () => withAbort(page.mouse.click(point.x, point.y), signal));
411
+ if (racedRef.blocked)
412
+ return session.blockedSnapshot(page, racedRef.pending);
413
+ const snap = await session.settleAndSnapshot(page, page.url() !== urlBefore);
414
+ snap.landingNote =
415
+ 'click delivered at (' +
416
+ Math.round(point.x) +
417
+ ',' +
418
+ Math.round(point.y) +
419
+ ') — landed on ' +
420
+ point.hitDesc +
421
+ (point.hitSelf ? ' (the targeted element itself)' : ' (inside the targeted element)');
422
+ return snap;
423
+ }
424
+ catch (error) {
425
+ if (isAbortError(error))
426
+ throw error;
427
+ if (error instanceof BrowserError) {
428
+ // Escape hatch: when the geometry ladder reports an
429
+ // interception, still try ONE bare native click at the raw
430
+ // centre point. Some sites layer a transparent hit-catcher
431
+ // that reports interception but forwards the click anyway,
432
+ // or only listen for CDP-native isTrusted=true events.
433
+ const raw = await locator
434
+ .evaluate((el) => {
435
+ const r = el.getBoundingClientRect();
436
+ return {
437
+ x: Math.max(0, r.left + r.width / 2),
438
+ y: Math.max(0, r.top + r.height / 2),
439
+ w: r.width,
440
+ h: r.height,
441
+ };
442
+ })
443
+ .catch(() => null);
444
+ if (raw && raw.w > 0 && raw.h > 0) {
445
+ try {
446
+ await withAbort(page.mouse.click(raw.x, raw.y), signal);
447
+ const snap = await session.settleAndSnapshot(page, page.url() !== urlBefore);
448
+ // The bare click went to the same point the hit-test
449
+ // flagged as intercepted — so the topmost element at
450
+ // that point (NOT the intended target) received it,
451
+ // unless the overlay forwards events. Say so loudly.
452
+ snap.landingNote =
453
+ 'CAUTION: the intended target was flagged as intercepted; a bare native click was sent to its centre (' +
454
+ Math.round(raw.x) +
455
+ ',' +
456
+ Math.round(raw.y) +
457
+ ') as a last resort. ' +
458
+ '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).';
459
+ return snap;
460
+ }
461
+ catch (rawError) {
462
+ if (isAbortError(rawError))
463
+ throw rawError;
464
+ }
465
+ }
466
+ }
467
+ throw error;
468
+ }
469
+ });
470
+ }
471
+ export async function fill(session, ref, text, signal) {
472
+ return session.run(async () => {
473
+ const locator = await session.refLocator(ref);
474
+ const page = session.currentPageSync();
475
+ await session.waitForElementStable(locator);
476
+ await session.assertActionable(locator, { editable: true });
477
+ const tag = await locator.evaluate((el) => el.tagName.toLowerCase()).catch(() => 'input');
478
+ let lastError;
479
+ let written = false;
480
+ for (let attempt = 0; attempt < 2; attempt += 1) {
481
+ try {
482
+ if (tag === 'select') {
483
+ await withAbort(locator.selectOption({ label: text }, { timeout: session.timeoutMs() }), signal);
484
+ }
485
+ else {
486
+ await withAbort(locator.fill(text, { timeout: session.timeoutMs() }), signal);
487
+ }
488
+ }
489
+ catch (error) {
490
+ if (isAbortError(error))
491
+ throw error;
492
+ lastError = error;
493
+ continue;
494
+ }
495
+ // Post-action verification: read the value back. Lenient match
496
+ // so formatted/masked inputs do not false-positive, but a value
497
+ // that never stuck gets one retry then a clear diagnostic.
498
+ written = await session.verifyFill(locator, tag, text);
499
+ if (written)
500
+ break;
501
+ }
502
+ if (!written) {
503
+ const detail = lastError instanceof Error ? lastError.message : '';
504
+ throw new BrowserError('VALUE_NOT_SET', 'the fill did not stick after 2 attempts — the value was not written to the field' +
505
+ (detail ? ': ' + detail : ''));
506
+ }
507
+ return session.settleAndSnapshot(page, false);
508
+ });
509
+ }
510
+ /** Read the field back and decide whether the fill landed. */
511
+ export async function verifyFill(_session, locator, tag, text) {
512
+ try {
513
+ if (tag === 'select') {
514
+ const label = await locator.evaluate((el) => {
515
+ const o = el.selectedOptions[0];
516
+ return o === undefined ? '' : o.label;
517
+ });
518
+ return label === text;
519
+ }
520
+ const value = await locator.inputValue();
521
+ if (value === text)
522
+ return true;
523
+ // Lenient: formatted inputs (masks, prefixes) rewrite the display
524
+ // value; accept containment or a near-equal length as "written".
525
+ return (text.length > 0 &&
526
+ value.length > 0 &&
527
+ (value.includes(text) || text.includes(value) || Math.abs(value.length - text.length) <= 2));
528
+ }
529
+ catch {
530
+ return false;
531
+ }
532
+ }
533
+ export async function press(session, ref, key, signal) {
534
+ return session.run(async () => {
535
+ const locator = await session.refLocator(ref);
536
+ const page = session.currentPageSync();
537
+ await session.waitForElementStable(locator);
538
+ const urlBefore = page.url();
539
+ try {
540
+ const racedPress = await session.runAction(page, () => withAbort(locator.press(key, { timeout: session.timeoutMs() }), signal));
541
+ if (racedPress.blocked)
542
+ return session.blockedSnapshot(page, racedPress.pending);
543
+ }
544
+ catch (error) {
545
+ if (isAbortError(error))
546
+ throw error;
547
+ throw asTimeoutError('action', error);
548
+ }
549
+ return session.settleAndSnapshot(page, page.url() !== urlBefore);
550
+ });
551
+ }
552
+ /**
553
+ * Hover a trigger the snapshot gave no ref, addressed by its visible label
554
+ * (e.g. a plain span that reveals a menu on mouseenter). The label must
555
+ * equal an element's own direct text; the deepest match wins, and the
556
+ * landing note names what was hovered plus how many elements shared it.
557
+ */
558
+ export async function hoverText(session, text, signal) {
559
+ return session.run(async () => {
560
+ const page = session.currentPageSync();
561
+ const found = await page.evaluate(labelTargetProbe, { text: String(text) }).catch(() => null);
562
+ if (found === null || found.found !== true) {
563
+ throw new BrowserError('HOVER_TARGET_NOT_FOUND', 'no visible element on session page has the label ' +
564
+ JSON.stringify(text) +
565
+ '; take a browser_snapshot and copy the label exactly as it is shown there, or hover by ref');
566
+ }
567
+ const locator = page.locator('[data-dsh-label-target="' + found.token + '"]').first();
568
+ try {
569
+ return await session.hoverLocator(locator, signal, JSON.stringify(text) + ' (' + found.desc + ')', found.total);
570
+ }
571
+ finally {
572
+ // The stamp is only a locator handle: clear it once the hover
573
+ // has been delivered so no residue is left in the page.
574
+ await page.evaluate(clearLabelTargets).catch(() => { });
575
+ }
576
+ });
577
+ }
578
+ /**
579
+ * Shared hover core: rest the pointer on a resolved locator, then report
580
+ * what the actionable ref count did. The label names the target in the
581
+ * landing note; shared is how many elements matched that label (0 when the
582
+ * target came from a ref).
583
+ */
584
+ export async function hoverLocator(session, locator, signal, label, shared) {
585
+ await session.assertActionable(locator);
586
+ const page = session.currentPageSync();
587
+ await session.waitForElementStable(locator);
588
+ const urlBefore = page.url();
589
+ const before = await session.capture(page, undefined);
590
+ try {
591
+ await withAbort(locator.hover({ timeout: session.timeoutMs() }), signal);
592
+ }
593
+ catch (error) {
594
+ if (isAbortError(error))
595
+ throw error;
596
+ throw asTimeoutError('action', error);
597
+ }
598
+ const navigated = page.url() !== urlBefore;
599
+ const snap = await session.settleAndSnapshot(page, navigated);
600
+ const note = hoverNote(label, before.totalRefs, snap.totalRefs, navigated);
601
+ snap.landingNote =
602
+ shared > 1
603
+ ? 'hovered the FIRST of ' +
604
+ shared +
605
+ ' visible elements labelled ' +
606
+ label +
607
+ ' — if that is not the one you meant, take a browser_snapshot and hover by ref. ' +
608
+ note
609
+ : note;
610
+ return snap;
611
+ }
612
+ export async function scroll(session, direction, amount, ref, signal) {
613
+ return session.run(async () => {
614
+ const page = await session.ensurePage();
615
+ try {
616
+ if (ref !== undefined) {
617
+ await withAbort((await session.refLocator(ref)).scrollIntoViewIfNeeded({ timeout: session.timeoutMs() }), signal);
618
+ }
619
+ const delta = direction === 'down' ? amount : -amount;
620
+ await withAbort(page.mouse.wheel(0, delta), signal);
621
+ }
622
+ catch (error) {
623
+ if (isAbortError(error))
624
+ throw error;
625
+ throw asTimeoutError('action', error);
626
+ }
627
+ // Lazy-loading pages fire async loaders after a wheel; a quiet page
628
+ // that has not started loading yet would otherwise look "stable"
629
+ // instantly and the snapshot would miss the late content. Give the
630
+ // loaders a beat, then settle (bounded) before capturing.
631
+ await withAbort(new Promise((resolve) => setTimeout(resolve, 450)), signal);
632
+ await session.waitForPageStable(page, 1200);
633
+ return session.capture(page, undefined);
634
+ });
635
+ }
636
+ export async function back(session, signal) {
637
+ return session.run(async () => {
638
+ const page = await session.ensurePage();
639
+ try {
640
+ await withAbort(page.goBack({ timeout: session.timeoutMs() }), signal);
641
+ }
642
+ catch (error) {
643
+ if (isAbortError(error))
644
+ throw error;
645
+ throw asTimeoutError('navigation', error);
646
+ }
647
+ return session.settleAndSnapshot(page, true);
648
+ });
649
+ }
650
+ export async function forward(session, signal) {
651
+ return session.run(async () => {
652
+ const page = await session.ensurePage();
653
+ try {
654
+ await withAbort(page.goForward({ timeout: session.timeoutMs() }), signal);
655
+ }
656
+ catch (error) {
657
+ if (isAbortError(error))
658
+ throw error;
659
+ throw asTimeoutError('navigation', error);
660
+ }
661
+ return session.settleAndSnapshot(page, true);
662
+ });
663
+ }
664
+ export async function switchTab(session, index, signal) {
665
+ return session.run(async () => {
666
+ session.assertLive();
667
+ session.guardBlockedContext();
668
+ const page = session.pageAt(index);
669
+ if (page === undefined)
670
+ throw new BrowserError('REF_NOT_FOUND', 'no tab at index ' + String(index));
671
+ // Switching is the only way session session starts driving a tab it did not
672
+ // open itself, so the host policy is applied here: a page-opened tab
673
+ // outside the allow-list cannot be reached. The refusal happens before
674
+ // currentIndex moves, so the session stays on the tab it was driving.
675
+ session.assertTabAllowed(page);
676
+ session.currentIndex = index;
677
+ // A minimized window stays minimized: switching tabs is a
678
+ // context-level operation that needs no OS window activation.
679
+ if (!(await isWindowMinimized(page)))
680
+ await withAbort(page.bringToFront(), signal);
681
+ return session.snapshot();
682
+ });
683
+ }
684
+ export async function openTab(session, url, waitUntil, signal) {
685
+ return session.run(async () => {
686
+ session.assertLive();
687
+ session.guardBlockedContext();
688
+ const target = assertAllowedUrl(url, session.provider.config.allowedDomains ?? []);
689
+ // Background creation: a new tab must not yank a minimized window
690
+ // into the user's face (see createBackgroundPage).
691
+ const page = await createBackgroundPage(session.context);
692
+ const index = session.context.pages().indexOf(page);
693
+ session.currentIndex = index >= 0 ? index : session.context.pages().length - 1;
694
+ session.provider.armDialogGuard(page);
695
+ // Track before the first navigation. Attaching the console/network
696
+ // listeners any later loses the requests that brought the tab into
697
+ // existence, so an opened tab looked silent from its very first load.
698
+ session.trackPage(page);
699
+ try {
700
+ await withAbort(page.goto(target.toString(), { waitUntil, timeout: session.timeoutMs() }), signal);
701
+ }
702
+ catch (error) {
703
+ if (isAbortError(error))
704
+ throw error;
705
+ throw asTimeoutError('navigation', error);
706
+ }
707
+ return session.settleAndSnapshot(page, true);
708
+ });
709
+ }
710
+ export async function closeTab(session, index, signal) {
711
+ return session.run(async () => {
712
+ session.assertLive();
713
+ session.guardBlockedContext();
714
+ const pages = session.context.pages();
715
+ const page = pages[index];
716
+ if (page === undefined)
717
+ throw new BrowserError('REF_NOT_FOUND', 'no tab at index ' + String(index));
718
+ if (pages.length <= 1) {
719
+ await withAbort(page.goto('about:blank'), signal);
720
+ return;
721
+ }
722
+ await withAbort(page.close(), signal);
723
+ session.currentIndex = Math.min(session.currentIndex, session.context.pages().length - 1);
724
+ });
725
+ }