@volter-ai-dev/supercode-browser-playwright 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -58,7 +58,8 @@ Both carry the same envelope, `{protocol, id, token, call}` in and
58
58
 
59
59
  - **TCP** is the wire the harness speaks (`crates/harness/src/browser.rs`): one
60
60
  newline-terminated JSON request per connection, one JSON response, then the
61
- provider closes. The provider listens on `127.0.0.1` and writes an owner-only
61
+ provider closes. A provider waiting for the person may first write
62
+ `{protocol, id, pending}` lines; each extends the harness's wait to 120 s. The provider listens on `127.0.0.1` and writes an owner-only
62
63
  discovery record (file `0600`, directory `0700`) to
63
64
  `<providers dir>/browser/playwright.cdp.<port>.json`, scoped to the
64
65
  canonical workspace, and removes it on stop.
@@ -129,8 +130,37 @@ Two entry points use no Node API:
129
130
  `@volter/almostcdp/playwright`, and answers the operations against an
130
131
  AlmostCDP surface with no Node process.
131
132
 
132
- A host's `actionGuard(action)` is asked before each locator action (`click`,
133
- `fill`, `press`, `hover`, `focus`, `check`, `uncheck`, `select`, `drag`) with
134
- the action's `Locator` and value; throwing a `BrowserActionRefusal` answers the
135
- call with its code, such as `APPROVAL_REQUIRED`. `browser.script` is not
136
- guarded: a script is trusted with the page.
133
+ A host's `actionGuard(action)` is asked before each element action and before
134
+ each `browser.script` (`{action: "script", source, args}`); throwing a
135
+ `BrowserActionRefusal` answers the call with its code, such as
136
+ `APPROVAL_REQUIRED`. A script's own locator calls are the real `page`'s and are
137
+ not asked again, so a host that guards element actions guards scripts as a
138
+ whole.
139
+
140
+ An element action (`click`, `fill`, `press`, `hover`, `focus`, `check`,
141
+ `uncheck`, `select`, `drag`, and the coordinate actions `mousedown`, `mouseup`,
142
+ `wheel`) carries a `target`, described from the browser side:
143
+
144
+ - A locator is resolved once, waiting up to the action budget (5 s), so an
145
+ element that renders late is guarded, not skipped. The guard sees that
146
+ element and the action then runs on the same element handle.
147
+ - A coordinate action (`browser.mouse` click/down/up/move, `browser.drag` from
148
+ or to x/y, `browser.wheel` at the mouse's position) is guarded on the element
149
+ at that point.
150
+ - The elements are found in an isolated world the page's scripts cannot reach
151
+ (the element's exact box, open shadow roots included; for a point,
152
+ `elementFromPoint`), then described over CDP from each backend node:
153
+ `DOM.describeNode` for tag and attributes, `Accessibility.getPartialAXTree`
154
+ for role, name and accessibility ancestors. A page cannot change what the
155
+ guard reads by overriding DOM functions. Over AlmostCDP the CDP endpoint
156
+ itself runs in the page, so there the description is only as trustworthy as
157
+ the page's world.
158
+ - A target that cannot be resolved or described (inside an embedded frame or a
159
+ closed shadow root, without a box, or a CDP failure) refuses the action with
160
+ `UNSUPPORTED`; the guard is never skipped.
161
+
162
+ A host's `snapshotExclude` is a CSS selector for its own elements in the page
163
+ (an overlay, a launcher): `browser.snapshot` leaves out the nodes of every
164
+ element it matches, found by their aria refs, and a node left empty by that,
165
+ and a coordinate action that lands on one is refused with `UNSUPPORTED`. The
166
+ page is not changed, so assistive technology still reads those elements.
@@ -4,7 +4,8 @@
4
4
  * over CDP. No Node API is used, so the executor also runs in a browser realm
5
5
  * with a browser build of playwright-core (`@volter/almostcdp/playwright`).
6
6
  */
7
- import { type Locator, type Page } from "playwright-core";
7
+ import { type Page } from "playwright-core";
8
+ import { type GuardedTarget } from "./guard.js";
8
9
  import { type BrowserOperationErrorCode, type BrowserOperationResult } from "./protocol.js";
9
10
  /** The implementation this provider reports; the pinned playwright-core. */
10
11
  export declare const PLAYWRIGHT_IMPLEMENTATION = "playwright-core 1.63.0";
@@ -22,24 +23,56 @@ export interface PlaywrightOperationExecutorOptions {
22
23
  /** `browser.version()` of the connected endpoint, reported by `browser.status`. */
23
24
  endpoint: string;
24
25
  /**
25
- * The host's policy for one locator action, asked before it runs; throwing
26
- * a `BrowserActionRefusal` refuses it with that code. `browser.script` is
27
- * not guarded: a script is trusted with the page.
26
+ * The host's policy for one action, asked before it runs: each locator
27
+ * action, each coordinate action (`browser.mouse`, `browser.drag`,
28
+ * `browser.wheel`), and each `browser.script` before its source is
29
+ * compiled. An element action's target is resolved once, described from the
30
+ * browser side (see `GuardedTarget`), and the action then runs on that same
31
+ * element. A target that cannot be resolved or described refuses the
32
+ * operation without asking. Throwing a `BrowserActionRefusal` refuses the
33
+ * operation with that code.
28
34
  */
29
35
  actionGuard?: (action: PlaywrightAction) => void | Promise<void>;
36
+ /**
37
+ * A CSS selector for the host's own elements: `browser.snapshot` leaves out
38
+ * every matching element's nodes (Playwright's selector, which pierces open
39
+ * shadow roots), and a coordinate action that lands on one is refused. The
40
+ * page itself is not changed, so what assistive technology reads stays the
41
+ * same.
42
+ */
43
+ snapshotExclude?: string;
30
44
  }
31
- /** One locator action an operation is about to perform. */
32
- export interface PlaywrightAction {
33
- action: "click" | "fill" | "press" | "hover" | "focus" | "check" | "uncheck" | "select" | "drag";
34
- locator: Locator;
45
+ /** One action an operation is about to perform. */
46
+ export type PlaywrightAction = PlaywrightElementAction | PlaywrightScriptAction;
47
+ export type { GuardedNode, GuardedTarget } from "./guard.js";
48
+ /** An action that lands on an element: by locator, or at a viewport point. */
49
+ export interface PlaywrightElementAction {
50
+ action: "click" | "fill" | "press" | "hover" | "focus" | "check" | "uncheck" | "select" | "drag" | "mousedown" | "mouseup" | "wheel";
51
+ /** What the action lands on, described from the browser side. */
52
+ target: GuardedTarget;
35
53
  value?: unknown;
36
54
  }
55
+ export interface PlaywrightScriptAction {
56
+ /** `browser.script`: the author's source, run with the real `page`. */
57
+ action: "script";
58
+ source: string;
59
+ args: Record<string, unknown>;
60
+ }
61
+ /**
62
+ * An AI-mode aria snapshot's YAML without the nodes whose `[ref=…]` is in
63
+ * `refs`, their subtrees, and any ref-less node whose children all went with
64
+ * them. A node is a `- ` line; its subtree is the lines indented under it.
65
+ */
66
+ export declare function snapshotWithoutRefs(text: string, refs: ReadonlySet<string>): string;
37
67
  /** Map a Playwright failure onto the wire's error codes. */
38
68
  export declare function classifyPlaywrightError(error: unknown): BrowserOperationErrorCode;
39
69
  export declare class PlaywrightOperationExecutor {
40
70
  private readonly page;
41
71
  private readonly options;
42
72
  private readonly ready;
73
+ private readonly targets;
74
+ /** Where the mouse is: `page.mouse` keeps it, but does not report it. */
75
+ private mouse;
43
76
  constructor(page: Page, options: PlaywrightOperationExecutorOptions);
44
77
  execute(raw: unknown): Promise<BrowserOperationResult>;
45
78
  private locator;
@@ -55,8 +88,19 @@ export declare class PlaywrightOperationExecutor {
55
88
  private inspect;
56
89
  /** Run a locator action; a failure on a locator that matches nothing is NOT_FOUND. */
57
90
  private act;
58
- /** The host's policy for an action; an element that is not there is the action's own failure. */
59
- private guard;
91
+ /**
92
+ * Resolves the locator's element once (waiting up to the action budget),
93
+ * asks the host's policy about it, and returns the handle the action must
94
+ * use, so the element guarded is the element acted on. Without a policy the
95
+ * action runs on the locator. Resolution or description failing refuses.
96
+ */
97
+ private guarded;
98
+ /** Asks the host's policy about the element at a viewport point. */
99
+ private guardPoint;
100
+ /** A browser-side description, or the refusal that replaces the action. */
101
+ private describe;
102
+ /** The aria refs of the elements `snapshotExclude` matches, and of their contents. */
103
+ private excludedRefs;
60
104
  private revision;
61
105
  private executeCall;
62
106
  private script;
package/dist/executor.mjs CHANGED
@@ -1,5 +1,143 @@
1
1
  // src/executor.ts
2
2
  import { errors } from "playwright-core";
3
+
4
+ // src/guard.ts
5
+ var UnidentifiedTarget = class extends Error {
6
+ };
7
+ var HostElementTarget = class extends Error {
8
+ };
9
+ var WORLD = "supercode-guard";
10
+ var GROUP = "supercode-guard";
11
+ var MAX_NODES = 8;
12
+ var MAX_ANCESTORS = 6;
13
+ function findInWorld(query) {
14
+ const onHost = (element) => {
15
+ if (!query.host) return false;
16
+ for (let node = element; node; ) {
17
+ if (node.matches(query.host)) return true;
18
+ const parent = node.parentElement;
19
+ node = parent ?? (node.getRootNode().host ?? null);
20
+ }
21
+ return false;
22
+ };
23
+ if (query.kind === "point") {
24
+ let element = document.elementFromPoint(query.x, query.y);
25
+ if (!element) return "Nothing is at that point.";
26
+ if (onHost(element)) return "host";
27
+ while (element.shadowRoot) {
28
+ const inner = element.shadowRoot.elementFromPoint(query.x, query.y);
29
+ if (!inner || inner === element) break;
30
+ element = inner;
31
+ if (onHost(element)) return "host";
32
+ }
33
+ if (/^(IFRAME|FRAME|OBJECT|EMBED)$/.test(element.tagName))
34
+ return "The point is inside an embedded frame, which the guard cannot inspect.";
35
+ return [element];
36
+ }
37
+ const { box } = query;
38
+ const found = [];
39
+ const near = (a, b) => Math.abs(a - b) <= 1;
40
+ const visit = (root) => {
41
+ for (const element of Array.from(root.querySelectorAll("*"))) {
42
+ if (found.length >= query.max) return;
43
+ const rect = element.getBoundingClientRect();
44
+ if (near(rect.x, box.x) && near(rect.y, box.y) && near(rect.width, box.width) && near(rect.height, box.height))
45
+ found.push(element);
46
+ if (element.shadowRoot) visit(element.shadowRoot);
47
+ }
48
+ };
49
+ visit(document);
50
+ return found.length ? found : "The element could not be found in the page's top document (it may be inside an embedded frame or a closed shadow root).";
51
+ }
52
+ var BrowserSideTargets = class {
53
+ constructor(page, host) {
54
+ this.page = page;
55
+ this.host = host;
56
+ }
57
+ session = null;
58
+ /** The element a resolved handle is, scrolled into view. */
59
+ async ofHandle(handle, timeout) {
60
+ await handle.scrollIntoViewIfNeeded({ timeout });
61
+ const box = await handle.boundingBox();
62
+ if (!box || box.width === 0 || box.height === 0)
63
+ throw new UnidentifiedTarget("The element has no box on the page.");
64
+ return await this.resolve({ kind: "box", box, host: this.host, max: MAX_NODES });
65
+ }
66
+ /** The element at a viewport point. */
67
+ async atPoint(x, y) {
68
+ return { ...await this.resolve({ kind: "point", x, y, host: this.host }), point: { x, y } };
69
+ }
70
+ cdp() {
71
+ this.session ??= this.page.context().newCDPSession(this.page).catch((error) => {
72
+ this.session = null;
73
+ throw error;
74
+ });
75
+ return this.session;
76
+ }
77
+ async resolve(query) {
78
+ const cdp = await this.cdp();
79
+ const tree = await cdp.send("Page.getFrameTree");
80
+ const world = await cdp.send("Page.createIsolatedWorld", { frameId: tree.frameTree.frame.id, worldName: WORLD });
81
+ try {
82
+ const evaluated = await cdp.send("Runtime.evaluate", {
83
+ expression: `(${findInWorld.toString()})(${JSON.stringify(query)})`,
84
+ contextId: world.executionContextId,
85
+ objectGroup: GROUP,
86
+ returnByValue: false
87
+ });
88
+ if (evaluated.exceptionDetails)
89
+ throw new UnidentifiedTarget(`The target could not be resolved: ${evaluated.exceptionDetails.text ?? "exception"}.`);
90
+ if (evaluated.result.type === "string") {
91
+ if (evaluated.result.value === "host") throw new HostElementTarget("The point is on the host's own interface, which agents cannot operate.");
92
+ throw new UnidentifiedTarget(String(evaluated.result.value));
93
+ }
94
+ if (!evaluated.result.objectId) throw new UnidentifiedTarget("The target could not be resolved.");
95
+ const properties = await cdp.send("Runtime.getProperties", { objectId: evaluated.result.objectId, ownProperties: true });
96
+ const objects = properties.result.filter((property) => /^\d+$/.test(property.name) && property.value?.objectId).map((property) => property.value.objectId);
97
+ if (!objects.length) throw new UnidentifiedTarget("The target could not be resolved.");
98
+ const nodes = [];
99
+ const ancestors = [];
100
+ const seen = /* @__PURE__ */ new Set();
101
+ for (const objectId of objects) {
102
+ const described = await cdp.send("DOM.describeNode", { objectId });
103
+ const attributes = {};
104
+ const list = described.node.attributes ?? [];
105
+ for (let index = 0; index + 1 < list.length; index += 2) attributes[list[index].toLowerCase()] = list[index + 1];
106
+ const ax = await this.accessibility(cdp, described.node.backendNodeId);
107
+ nodes.push({
108
+ backendNodeId: described.node.backendNodeId,
109
+ tag: described.node.nodeName.toLowerCase(),
110
+ attributes,
111
+ role: ax.self?.role ?? "",
112
+ name: ax.self?.name ?? ""
113
+ });
114
+ seen.add(described.node.backendNodeId);
115
+ for (const ancestor of ax.ancestors) {
116
+ if (ancestors.length >= MAX_ANCESTORS || seen.has(ancestor.backendNodeId)) continue;
117
+ seen.add(ancestor.backendNodeId);
118
+ ancestors.push(ancestor);
119
+ }
120
+ }
121
+ return { url: this.page.url(), nodes, ancestors };
122
+ } finally {
123
+ await cdp.send("Runtime.releaseObjectGroup", { objectGroup: GROUP }).catch(() => void 0);
124
+ }
125
+ }
126
+ async accessibility(cdp, backendNodeId) {
127
+ const tree = await cdp.send("Accessibility.getPartialAXTree", { backendNodeId, fetchRelatives: true });
128
+ const byId = new Map(tree.nodes.map((node) => [node.nodeId, node]));
129
+ const text = (value) => typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
130
+ const self = tree.nodes.find((node) => node.backendDOMNodeId === backendNodeId) ?? null;
131
+ const ancestors = [];
132
+ for (let node = self?.parentId ? byId.get(self.parentId) : void 0; node && ancestors.length < MAX_ANCESTORS; node = node.parentId ? byId.get(node.parentId) : void 0) {
133
+ if (node.ignored || typeof node.backendDOMNodeId !== "number") continue;
134
+ ancestors.push({ backendNodeId: node.backendDOMNodeId, tag: "", attributes: {}, role: text(node.role?.value), name: text(node.name?.value) });
135
+ }
136
+ return { self: self ? { role: text(self.role?.value), name: text(self.name?.value) } : null, ancestors };
137
+ }
138
+ };
139
+
140
+ // src/executor.ts
3
141
  import {
4
142
  BROWSER_OPERATION_NAMES,
5
143
  BrowserActionRefusal,
@@ -49,6 +187,49 @@ var PLAYWRIGHT_FEATURES = Object.freeze([
49
187
  "page.screenshot.png",
50
188
  "page.evaluate"
51
189
  ]);
190
+ var SNAPSHOT_EXCLUDE_LIMIT = 16;
191
+ function collectRefs(nodes, into) {
192
+ if (Array.isArray(nodes)) {
193
+ for (const node2 of nodes) collectRefs(node2, into);
194
+ return;
195
+ }
196
+ if (!nodes || typeof nodes !== "object") return;
197
+ const node = nodes;
198
+ if (typeof node.ref === "string") into.add(node.ref);
199
+ collectRefs(node.children, into);
200
+ }
201
+ function snapshotWithoutRefs(text, refs) {
202
+ if (refs.size === 0) return text;
203
+ const root = { text: "", indent: -1, children: [], removed: false };
204
+ const stack = [root];
205
+ const lines = [];
206
+ for (const raw of text.split("\n")) {
207
+ const indent = raw.length - raw.trimStart().length;
208
+ const line = { text: raw, indent, children: [], removed: false };
209
+ while (stack.length > 1 && stack[stack.length - 1].indent >= indent) stack.pop();
210
+ stack[stack.length - 1].children.push(line);
211
+ stack.push(line);
212
+ lines.push(line);
213
+ }
214
+ const refOf = (line) => /\[ref=([^\]\s]+)\]/.exec(line.text)?.[1];
215
+ const remove = (line) => {
216
+ line.removed = true;
217
+ for (const child of line.children) remove(child);
218
+ };
219
+ const visit = (line) => {
220
+ const ref = refOf(line);
221
+ if (ref !== void 0 && refs.has(ref)) {
222
+ remove(line);
223
+ return;
224
+ }
225
+ const before = line.children.length;
226
+ for (const child of line.children) visit(child);
227
+ if (line !== root && ref === void 0 && before > 0 && line.children.every((child) => child.removed))
228
+ line.removed = true;
229
+ };
230
+ visit(root);
231
+ return lines.filter((line) => !line.removed).map((line) => line.text).join("\n");
232
+ }
52
233
  var REVISION_KEY = "__supercodeBrowserRevision";
53
234
  function installRevisionCounter(key) {
54
235
  const scope = globalThis;
@@ -182,9 +363,13 @@ var PlaywrightOperationExecutor = class {
182
363
  page;
183
364
  options;
184
365
  ready;
366
+ targets;
367
+ /** Where the mouse is: `page.mouse` keeps it, but does not report it. */
368
+ mouse = { x: 0, y: 0 };
185
369
  constructor(page, options) {
186
370
  this.page = page;
187
371
  this.options = options;
372
+ this.targets = new BrowserSideTargets(page, options.snapshotExclude ?? null);
188
373
  this.ready = (async () => {
189
374
  await page.addInitScript(installRevisionCounter, REVISION_KEY);
190
375
  await page.evaluate(installRevisionCounter, REVISION_KEY).catch(() => void 0);
@@ -258,10 +443,68 @@ var PlaywrightOperationExecutor = class {
258
443
  throw error;
259
444
  }
260
445
  }
261
- /** The host's policy for an action; an element that is not there is the action's own failure. */
262
- async guard(action) {
263
- if (!this.options.actionGuard || await action.locator.count().catch(() => 0) === 0) return;
264
- await this.options.actionGuard(action);
446
+ /**
447
+ * Resolves the locator's element once (waiting up to the action budget),
448
+ * asks the host's policy about it, and returns the handle the action must
449
+ * use, so the element guarded is the element acted on. Without a policy the
450
+ * action runs on the locator. Resolution or description failing refuses.
451
+ */
452
+ async guarded(locator, action, value) {
453
+ const guard = this.options.actionGuard;
454
+ if (!guard) return null;
455
+ let handle;
456
+ try {
457
+ handle = await locator.elementHandle({ timeout: ACTION_TIMEOUT_MS });
458
+ } catch (error) {
459
+ if (error instanceof errors.TimeoutError)
460
+ throw new BrowserActionRefusal("NOT_FOUND", `${action} found no element for the locator in ${ACTION_TIMEOUT_MS}ms.`);
461
+ throw error;
462
+ }
463
+ if (!handle) throw new BrowserActionRefusal("NOT_FOUND", `${action} found no element for the locator.`);
464
+ try {
465
+ await guard({
466
+ action,
467
+ target: await this.describe(() => this.targets.ofHandle(handle, ACTION_TIMEOUT_MS)),
468
+ ...value !== void 0 ? { value } : {}
469
+ });
470
+ } catch (error) {
471
+ await handle.dispose().catch(() => void 0);
472
+ throw error;
473
+ }
474
+ return handle;
475
+ }
476
+ /** Asks the host's policy about the element at a viewport point. */
477
+ async guardPoint(action, x, y) {
478
+ const guard = this.options.actionGuard;
479
+ if (!guard) return;
480
+ await guard({ action, target: await this.describe(() => this.targets.atPoint(x, y)) });
481
+ }
482
+ /** A browser-side description, or the refusal that replaces the action. */
483
+ async describe(run) {
484
+ try {
485
+ return await run();
486
+ } catch (error) {
487
+ if (error instanceof HostElementTarget) throw new BrowserActionRefusal("UNSUPPORTED", error.message);
488
+ if (error instanceof UnidentifiedTarget)
489
+ throw new BrowserActionRefusal("UNSUPPORTED", `${error.message} The action was refused because its target could not be checked.`);
490
+ throw new BrowserActionRefusal(
491
+ "UNSUPPORTED",
492
+ `The action was refused because its target could not be checked: ${errorMessage(error)}`
493
+ );
494
+ }
495
+ }
496
+ /** The aria refs of the elements `snapshotExclude` matches, and of their contents. */
497
+ async excludedRefs() {
498
+ const refs = /* @__PURE__ */ new Set();
499
+ const selector = this.options.snapshotExclude;
500
+ if (!selector) return refs;
501
+ const matches = this.page.locator(selector);
502
+ const count = Math.min(await matches.count().catch(() => 0), SNAPSHOT_EXCLUDE_LIMIT);
503
+ for (let index = 0; index < count; index += 1) {
504
+ const nodes = await matches.nth(index).ariaSnapshotJSON({ mode: "ai", timeout: ACTION_TIMEOUT_MS }).catch(() => null);
505
+ collectRefs(nodes, refs);
506
+ }
507
+ return refs;
265
508
  }
266
509
  async revision() {
267
510
  const value = await this.page.evaluate((key) => globalThis[key], REVISION_KEY).catch(() => void 0);
@@ -287,7 +530,8 @@ var PlaywrightOperationExecutor = class {
287
530
  if (call.operation === "browser.snapshot") {
288
531
  await this.refreshRefs(input.locator);
289
532
  const scoped = input.locator ? this.locator(input.locator).first() : null;
290
- const text = scoped ? await this.act(scoped, "ariaSnapshot", () => scoped.ariaSnapshot({ mode: "ai", timeout: ACTION_TIMEOUT_MS })) : await page.ariaSnapshot({ mode: "ai", timeout: ACTION_TIMEOUT_MS });
533
+ const excluded = await this.excludedRefs();
534
+ const text = snapshotWithoutRefs(scoped ? await this.act(scoped, "ariaSnapshot", () => scoped.ariaSnapshot({ mode: "ai", timeout: ACTION_TIMEOUT_MS })) : await page.ariaSnapshot({ mode: "ai", timeout: ACTION_TIMEOUT_MS }), excluded);
291
535
  const bounded = text.slice(0, 64e3);
292
536
  return this.success(call.operation, { text: bounded, truncated: bounded.length < text.length });
293
537
  }
@@ -323,35 +567,48 @@ var PlaywrightOperationExecutor = class {
323
567
  if (call.operation === "browser.script") return this.script(call);
324
568
  if (call.operation === "browser.mouse") {
325
569
  const action = input.action;
326
- if (action === "move") await page.mouse.move(input.x, input.y);
327
- else if (action === "click") await page.mouse.click(input.x, input.y);
570
+ const at = typeof input.x === "number" && typeof input.y === "number" ? { x: input.x, y: input.y } : this.mouse;
571
+ if (action === "move") await this.guardPoint("hover", at.x, at.y);
572
+ else await this.guardPoint(action === "click" ? "click" : action === "down" ? "mousedown" : "mouseup", at.x, at.y);
573
+ if (action === "move") await page.mouse.move(at.x, at.y);
574
+ else if (action === "click") await page.mouse.click(at.x, at.y);
328
575
  else if (action === "down") await page.mouse.down();
329
576
  else await page.mouse.up();
577
+ if (action === "move" || action === "click") this.mouse = { x: at.x, y: at.y };
330
578
  return this.success(call.operation, { action, ...typeof input.x === "number" ? { x: input.x, y: input.y } : {} });
331
579
  }
332
580
  if (call.operation === "browser.wheel") {
333
581
  const deltaX = typeof input.deltaX === "number" ? input.deltaX : 0;
334
582
  const deltaY = typeof input.deltaY === "number" ? input.deltaY : 0;
583
+ await this.guardPoint("wheel", this.mouse.x, this.mouse.y);
335
584
  await page.mouse.wheel(deltaX, deltaY);
336
585
  return this.success(call.operation, { deltaX, deltaY });
337
586
  }
338
587
  if (call.operation === "browser.drag") {
339
588
  const resolve = async (endpoint) => {
340
- if (endpoint.locator === void 0) return { x: endpoint.x, y: endpoint.y };
589
+ if (endpoint.locator === void 0) {
590
+ const point = { x: endpoint.x, y: endpoint.y };
591
+ await this.guardPoint("drag", point.x, point.y);
592
+ return point;
593
+ }
341
594
  await this.refreshRefs(endpoint.locator);
342
595
  const locator2 = this.locator(endpoint.locator).first();
343
- const box = await locator2.count() === 0 ? null : await locator2.boundingBox({ timeout: ACTION_TIMEOUT_MS });
344
- if (!box) throw new BrowserActionRefusal("NOT_FOUND", "The drag locator matched no element.");
345
- return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
596
+ const handle2 = await this.guarded(locator2, "drag");
597
+ try {
598
+ const box = handle2 ? await handle2.boundingBox() : await locator2.count() === 0 ? null : await locator2.boundingBox({ timeout: ACTION_TIMEOUT_MS });
599
+ if (!box) throw new BrowserActionRefusal("NOT_FOUND", "The drag locator matched no element.");
600
+ return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
601
+ } finally {
602
+ await handle2?.dispose().catch(() => void 0);
603
+ }
346
604
  };
347
- for (const end of [input.from, input.to])
348
- if (end.locator !== void 0) await this.guard({ action: "drag", locator: this.locator(end.locator).first() });
349
605
  const from = await resolve(input.from);
350
606
  const to = await resolve(input.to);
351
607
  await page.mouse.move(from.x, from.y);
352
608
  await page.mouse.down();
353
609
  await page.mouse.move(to.x, to.y, { steps: typeof input.steps === "number" ? input.steps : 8 });
354
610
  await page.mouse.up();
611
+ this.mouse = to;
355
612
  return this.success(call.operation, { from, to });
356
613
  }
357
614
  if (call.operation === "browser.back") {
@@ -375,28 +632,38 @@ var PlaywrightOperationExecutor = class {
375
632
  return this.success(call.operation, box);
376
633
  }
377
634
  if (call.operation === "browser.press" && !locator) {
378
- await this.guard({ action: "press", locator: page.locator("*:focus").first(), value: input.key });
379
- await page.keyboard.press(input.key);
635
+ const focused = page.locator("*:focus").first();
636
+ const handle2 = await this.guarded(await focused.count() ? focused : page.locator("body"), "press", input.key);
637
+ try {
638
+ await page.keyboard.press(input.key);
639
+ } finally {
640
+ await handle2?.dispose().catch(() => void 0);
641
+ }
380
642
  return this.success(call.operation, { key: input.key });
381
643
  }
382
644
  const target = locator;
383
- const guarded = call.operation === "browser.select" ? "select" : call.operation.slice("browser.".length);
384
- await this.guard({
385
- action: guarded,
386
- locator: target.first(),
387
- ...call.operation === "browser.fill" ? { value: input.value } : call.operation === "browser.press" ? { value: input.key } : call.operation === "browser.select" ? { value: input.values } : {}
388
- });
645
+ const guardedAction = call.operation === "browser.select" ? "select" : call.operation.slice("browser.".length);
646
+ const handle = await this.guarded(
647
+ target.first(),
648
+ guardedAction,
649
+ call.operation === "browser.fill" ? input.value : call.operation === "browser.press" ? input.key : call.operation === "browser.select" ? input.values : void 0
650
+ );
651
+ const element = handle ?? { ...bind(target), focus: () => target.focus({ timeout }) };
389
652
  const before = await this.inspect(target).catch(() => null);
390
- if (call.operation === "browser.click") await this.act(target, "click", () => target.click({ timeout }));
391
- else if (call.operation === "browser.fill") await this.act(target, "fill", () => target.fill(input.value, { timeout }));
392
- else if (call.operation === "browser.press") await this.act(target, "press", () => target.press(input.key, { timeout }));
393
- else if (call.operation === "browser.hover") await this.act(target, "hover", () => target.hover({ timeout }));
394
- else if (call.operation === "browser.focus") await this.act(target, "focus", () => target.focus({ timeout }));
395
- else if (call.operation === "browser.check") await this.act(target, "check", () => target.check({ timeout }));
396
- else if (call.operation === "browser.uncheck") await this.act(target, "uncheck", () => target.uncheck({ timeout }));
397
- else if (call.operation === "browser.select") {
398
- const values = await this.act(target, "selectOption", () => target.selectOption(input.values, { timeout }));
399
- return this.success(call.operation, { values });
653
+ try {
654
+ if (call.operation === "browser.click") await this.act(target, "click", () => element.click({ timeout }));
655
+ else if (call.operation === "browser.fill") await this.act(target, "fill", () => element.fill(input.value, { timeout }));
656
+ else if (call.operation === "browser.press") await this.act(target, "press", () => element.press(input.key, { timeout }));
657
+ else if (call.operation === "browser.hover") await this.act(target, "hover", () => element.hover({ timeout }));
658
+ else if (call.operation === "browser.focus") await this.act(target, "focus", () => element.focus());
659
+ else if (call.operation === "browser.check") await this.act(target, "check", () => element.check({ timeout }));
660
+ else if (call.operation === "browser.uncheck") await this.act(target, "uncheck", () => element.uncheck({ timeout }));
661
+ else if (call.operation === "browser.select") {
662
+ const values = await this.act(target, "selectOption", () => element.selectOption(input.values, { timeout }));
663
+ return this.success(call.operation, { values });
664
+ }
665
+ } finally {
666
+ await handle?.dispose().catch(() => void 0);
400
667
  }
401
668
  const after = await this.inspect(target).catch(() => null);
402
669
  const inspection = after ?? before;
@@ -411,6 +678,7 @@ var PlaywrightOperationExecutor = class {
411
678
  const timeout = Math.min(requested, SCRIPT_TIMEOUT_CLAMP_MS);
412
679
  const AsyncFunction = Object.getPrototypeOf(async function() {
413
680
  }).constructor;
681
+ if (this.options.actionGuard) await this.options.actionGuard({ action: "script", source, args });
414
682
  let run;
415
683
  try {
416
684
  run = new AsyncFunction("page", "args", source);
@@ -463,6 +731,17 @@ function operationName(raw) {
463
731
  const candidate = raw && typeof raw === "object" && !Array.isArray(raw) ? raw.operation : void 0;
464
732
  return typeof candidate === "string" && BROWSER_OPERATION_NAMES.includes(candidate) ? candidate : "browser.status";
465
733
  }
734
+ function bind(locator) {
735
+ return {
736
+ click: locator.click.bind(locator),
737
+ fill: locator.fill.bind(locator),
738
+ press: locator.press.bind(locator),
739
+ hover: locator.hover.bind(locator),
740
+ check: locator.check.bind(locator),
741
+ uncheck: locator.uncheck.bind(locator),
742
+ selectOption: locator.selectOption.bind(locator)
743
+ };
744
+ }
466
745
  export {
467
746
  ACTION_TIMEOUT_MS,
468
747
  PLAYWRIGHT_FEATURES,
@@ -470,6 +749,7 @@ export {
470
749
  PlaywrightOperationExecutor,
471
750
  SCRIPT_TIMEOUT_CLAMP_MS,
472
751
  WAIT_TIMEOUT_CLAMP_MS,
473
- classifyPlaywrightError
752
+ classifyPlaywrightError,
753
+ snapshotWithoutRefs
474
754
  };
475
755
  //# sourceMappingURL=executor.mjs.map