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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/executor.mjs CHANGED
@@ -6,6 +6,8 @@ var UnidentifiedTarget = class extends Error {
6
6
  };
7
7
  var HostElementTarget = class extends Error {
8
8
  };
9
+ var DocumentChanged = class extends Error {
10
+ };
9
11
  var WORLD = "supercode-guard";
10
12
  var GROUP = "supercode-guard";
11
13
  var MAX_NODES = 8;
@@ -20,6 +22,15 @@ function findInWorld(query) {
20
22
  }
21
23
  return false;
22
24
  };
25
+ const frame = (element) => /^(IFRAME|FRAME|OBJECT|EMBED)$/.test(element.tagName);
26
+ if (query.kind === "focus") {
27
+ let element = document.activeElement ?? document.body;
28
+ while (element?.shadowRoot?.activeElement) element = element.shadowRoot.activeElement;
29
+ if (!element) return "Nothing has focus.";
30
+ if (frame(element)) return "Focus is inside an embedded frame, which the guard cannot inspect.";
31
+ if (onHost(element)) return "host";
32
+ return [element];
33
+ }
23
34
  if (query.kind === "point") {
24
35
  let element = document.elementFromPoint(query.x, query.y);
25
36
  if (!element) return "Nothing is at that point.";
@@ -30,24 +41,43 @@ function findInWorld(query) {
30
41
  element = inner;
31
42
  if (onHost(element)) return "host";
32
43
  }
33
- if (/^(IFRAME|FRAME|OBJECT|EMBED)$/.test(element.tagName))
34
- return "The point is inside an embedded frame, which the guard cannot inspect.";
44
+ if (frame(element)) return "The point is inside an embedded frame, which the guard cannot inspect.";
35
45
  return [element];
36
46
  }
37
47
  const { box } = query;
48
+ const marked = [];
38
49
  const found = [];
39
50
  const near = (a, b) => Math.abs(a - b) <= 1;
40
51
  const visit = (root) => {
41
52
  for (const element of Array.from(root.querySelectorAll("*"))) {
42
- if (found.length >= query.max) return;
53
+ if (found.length > query.max - 1) return;
43
54
  const rect = element.getBoundingClientRect();
55
+ if (element.hasAttribute(query.marker)) {
56
+ marked.push(element);
57
+ element.removeAttribute(query.marker);
58
+ }
44
59
  if (near(rect.x, box.x) && near(rect.y, box.y) && near(rect.width, box.width) && near(rect.height, box.height))
45
60
  found.push(element);
46
61
  if (element.shadowRoot) visit(element.shadowRoot);
47
62
  }
48
63
  };
64
+ const cap = query.max;
65
+ query.max = Number.MAX_SAFE_INTEGER;
49
66
  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).";
67
+ query.max = cap;
68
+ if (marked.length !== 1 || !found.includes(marked[0]))
69
+ return "The element the action targets could not be confirmed at its place on the page.";
70
+ if (!found.length)
71
+ return "The element could not be found in the page's top document (it may be inside an embedded frame or a closed shadow root).";
72
+ if (found.length >= query.max)
73
+ return `More than ${query.max - 1} elements share the target's box, so which one the action lands on cannot be told.`;
74
+ if (found.some(frame)) return "The target is an embedded frame, whose content the guard cannot inspect.";
75
+ for (const element of [...found]) {
76
+ const label = element.closest("label");
77
+ const control = label ? label.control : null;
78
+ if (control && !found.includes(control)) found.push(control);
79
+ }
80
+ return found;
51
81
  }
52
82
  var BrowserSideTargets = class {
53
83
  constructor(page, host) {
@@ -55,20 +85,150 @@ var BrowserSideTargets = class {
55
85
  this.host = host;
56
86
  }
57
87
  session = null;
58
- /** The element a resolved handle is, scrolled into view. */
88
+ /** A main-frame navigation has been requested or is loading and has not finished. */
89
+ navigating = false;
90
+ mainFrame = "";
91
+ /**
92
+ * The element a resolved handle is, scrolled into view. It must be in the
93
+ * page's main frame, and it must be one of the elements found browser-side
94
+ * at its box (the handle is marked with a one-off attribute the isolated
95
+ * world looks for, then removes).
96
+ */
59
97
  async ofHandle(handle, timeout) {
98
+ if (await handle.ownerFrame() !== this.page.mainFrame())
99
+ throw new UnidentifiedTarget("The element is inside an embedded frame, which the guard cannot inspect.");
60
100
  await handle.scrollIntoViewIfNeeded({ timeout });
61
101
  const box = await handle.boundingBox();
62
102
  if (!box || box.width === 0 || box.height === 0)
63
103
  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 });
104
+ const marker = `data-supercode-guard-${Array.from(crypto.getRandomValues(new Uint8Array(8)), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
105
+ await handle.evaluate((element, name) => element.setAttribute(name, ""), marker);
106
+ try {
107
+ return await this.resolve({ kind: "box", box, host: this.host, max: MAX_NODES + 1, marker });
108
+ } finally {
109
+ await handle.evaluate((element, name) => element.removeAttribute(name), marker).catch(() => void 0);
110
+ }
111
+ }
112
+ /**
113
+ * Where a key press goes: the focused element. Focus inside an embedded
114
+ * frame or a closed shadow root is refused.
115
+ */
116
+ async focused() {
117
+ return await this.resolve({ kind: "focus", host: this.host });
118
+ }
119
+ /**
120
+ * Whether focus is now on one of the target's elements or inside one (open
121
+ * shadow roots included), judged in the isolated world: a locator press is
122
+ * refused when focusing its element left focus elsewhere.
123
+ */
124
+ async focusWithin(target) {
125
+ return await this.inWorld(target, `function (...nodes) {
126
+ let active = document.activeElement;
127
+ while (active && active.shadowRoot && active.shadowRoot.activeElement) active = active.shadowRoot.activeElement;
128
+ for (let node = active; node; node = node.parentNode || node.host) if (nodes.includes(node)) return true;
129
+ return false;
130
+ }`) === true;
131
+ }
132
+ /** The options a select action will choose, by value or label, read browser-side. */
133
+ async selectOptions(target, wanted) {
134
+ const select = { ...target, nodes: target.nodes.filter((node) => node.tag === "select") };
135
+ if (!select.nodes.length) return [];
136
+ const options = await this.inWorld(select, `function (wanted) {
137
+ return Array.from(this.options).filter((option) => wanted.includes(option.value) || wanted.includes(option.label))
138
+ .map((option) => ({ label: option.label, value: option.value }));
139
+ }`, [wanted]);
140
+ return Array.isArray(options) ? options : [];
65
141
  }
66
- /** The element at a viewport point. */
142
+ /**
143
+ * Calls `source` in the isolated world with the target's first node as
144
+ * `this`, and its nodes (or `extra` values) as arguments.
145
+ */
146
+ async inWorld(target, source, extra) {
147
+ const cdp = await this.cdp();
148
+ const contextId = await this.world(cdp);
149
+ try {
150
+ const objects = [];
151
+ for (const node of target.nodes) {
152
+ const resolved = await cdp.send("DOM.resolveNode", { backendNodeId: node.backendNodeId, executionContextId: contextId, objectGroup: GROUP });
153
+ if (resolved.object.objectId) objects.push(resolved.object.objectId);
154
+ }
155
+ if (!objects.length) return void 0;
156
+ const called = await cdp.send("Runtime.callFunctionOn", {
157
+ objectId: objects[0],
158
+ functionDeclaration: source,
159
+ arguments: extra ? extra.map((value) => ({ value })) : objects.map((objectId) => ({ objectId })),
160
+ returnByValue: true
161
+ });
162
+ return called.exceptionDetails ? void 0 : called.result.value;
163
+ } finally {
164
+ await cdp.send("Runtime.releaseObjectGroup", { objectGroup: GROUP }).catch(() => void 0);
165
+ }
166
+ }
167
+ async world(cdp) {
168
+ const tree = await cdp.send("Page.getFrameTree");
169
+ const world = await cdp.send("Page.createIsolatedWorld", { frameId: tree.frameTree.frame.id, worldName: WORLD });
170
+ return world.executionContextId;
171
+ }
172
+ /**
173
+ * The element at a viewport point, as best known. A point on the host's own
174
+ * interface is refused; a point whose element cannot be identified is
175
+ * returned with no nodes and the reason, for a guard that decides on raw
176
+ * pointer input regardless.
177
+ */
67
178
  async atPoint(x, y) {
68
- return { ...await this.resolve({ kind: "point", x, y, host: this.host }), point: { x, y } };
179
+ try {
180
+ return { ...await this.resolve({ kind: "point", x, y, host: this.host }), point: { x, y } };
181
+ } catch (error) {
182
+ if (error instanceof HostElementTarget) throw error;
183
+ return {
184
+ url: this.page.url(),
185
+ nodes: [],
186
+ ancestors: [],
187
+ point: { x, y },
188
+ unidentified: error instanceof Error ? error.message : String(error)
189
+ };
190
+ }
191
+ }
192
+ /** The main frame's document now. */
193
+ async documentState() {
194
+ const cdp = await this.cdp();
195
+ const tree = await cdp.send("Page.getFrameTree");
196
+ this.mainFrame = tree.frameTree.frame.id;
197
+ return { loaderId: tree.frameTree.frame.loaderId, url: tree.frameTree.frame.url };
198
+ }
199
+ /**
200
+ * Just before input is dispatched: the main frame must still hold the
201
+ * document the target was checked on, with no navigation in flight.
202
+ */
203
+ async sameDocument(checked) {
204
+ const now = await this.documentState();
205
+ if (this.navigating) throw new DocumentChanged("The page is navigating, so nothing was sent; ask again once it has loaded.");
206
+ if (now.loaderId !== checked.loaderId || now.url !== checked.url)
207
+ throw new DocumentChanged("The page changed since the action was checked, so nothing was sent; ask again.");
69
208
  }
70
209
  cdp() {
71
- this.session ??= this.page.context().newCDPSession(this.page).catch((error) => {
210
+ this.session ??= this.page.context().newCDPSession(this.page).then(async (session) => {
211
+ const main = (frameId) => !this.mainFrame || frameId === this.mainFrame;
212
+ session.on("Page.frameRequestedNavigation", (event) => {
213
+ if (main(event.frameId)) this.navigating = true;
214
+ });
215
+ session.on("Page.frameStartedNavigating", (event) => {
216
+ if (main(event.frameId)) this.navigating = true;
217
+ });
218
+ session.on("Page.frameStartedLoading", (event) => {
219
+ if (main(event.frameId)) this.navigating = true;
220
+ });
221
+ const settled = (event) => {
222
+ const frameId = event.frameId ?? event.frame?.id;
223
+ if (main(frameId) && !event.frame?.parentId) this.navigating = false;
224
+ };
225
+ session.on("Page.frameStoppedLoading", settled);
226
+ session.on("Page.frameNavigated", settled);
227
+ session.on("Page.navigatedWithinDocument", settled);
228
+ session.on("Page.downloadWillBegin", settled);
229
+ await session.send("Page.enable").catch(() => void 0);
230
+ return session;
231
+ }, (error) => {
72
232
  this.session = null;
73
233
  throw error;
74
234
  });
@@ -76,12 +236,11 @@ var BrowserSideTargets = class {
76
236
  }
77
237
  async resolve(query) {
78
238
  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 });
239
+ const contextId = await this.world(cdp);
81
240
  try {
82
241
  const evaluated = await cdp.send("Runtime.evaluate", {
83
242
  expression: `(${findInWorld.toString()})(${JSON.stringify(query)})`,
84
- contextId: world.executionContextId,
243
+ contextId,
85
244
  objectGroup: GROUP,
86
245
  returnByValue: false
87
246
  });
@@ -99,7 +258,14 @@ var BrowserSideTargets = class {
99
258
  const ancestors = [];
100
259
  const seen = /* @__PURE__ */ new Set();
101
260
  for (const objectId of objects) {
102
- const described = await cdp.send("DOM.describeNode", { objectId });
261
+ const described = await cdp.send("DOM.describeNode", { objectId, depth: 1, pierce: true });
262
+ if (query.kind === "focus" && described.node.shadowRoots?.some((root) => root.shadowRootType === "closed"))
263
+ throw new UnidentifiedTarget("Focus is inside a closed shadow root, which the guard cannot inspect.");
264
+ const facts = await cdp.send("Runtime.callFunctionOn", {
265
+ objectId,
266
+ functionDeclaration: "function () { return { form: !!(this.form || (this.closest && this.closest('form'))), text: (this.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 80) }; }",
267
+ returnByValue: true
268
+ });
103
269
  const attributes = {};
104
270
  const list = described.node.attributes ?? [];
105
271
  for (let index = 0; index + 1 < list.length; index += 2) attributes[list[index].toLowerCase()] = list[index + 1];
@@ -109,7 +275,9 @@ var BrowserSideTargets = class {
109
275
  tag: described.node.nodeName.toLowerCase(),
110
276
  attributes,
111
277
  role: ax.self?.role ?? "",
112
- name: ax.self?.name ?? ""
278
+ name: ax.self?.name ?? "",
279
+ inForm: facts.result.value?.form === true,
280
+ ...typeof facts.result.value?.text === "string" && facts.result.value.text ? { text: facts.result.value.text } : {}
113
281
  });
114
282
  seen.add(described.node.backendNodeId);
115
283
  for (const ancestor of ax.ancestors) {
@@ -137,6 +305,78 @@ var BrowserSideTargets = class {
137
305
  }
138
306
  };
139
307
 
308
+ // src/script-scope.ts
309
+ function isData(value) {
310
+ if (Array.isArray(value) || ArrayBuffer.isView(value) || value instanceof ArrayBuffer || value instanceof Date || value instanceof RegExp || value instanceof Map || value instanceof Set || value instanceof Promise || value instanceof Error) return true;
311
+ const prototype = Object.getPrototypeOf(value);
312
+ return prototype === Object.prototype || prototype === null;
313
+ }
314
+ var ScriptScope = class {
315
+ closed = false;
316
+ proxies = /* @__PURE__ */ new WeakMap();
317
+ targets = /* @__PURE__ */ new WeakMap();
318
+ disposables = [];
319
+ /** The membrane's view of `value`. */
320
+ wrap(value) {
321
+ if (value === null || typeof value !== "object" && typeof value !== "function") return value;
322
+ const object = value;
323
+ if (typeof value === "object" && isData(object)) return value;
324
+ const existing = this.proxies.get(object);
325
+ if (existing) return existing;
326
+ const scope = this;
327
+ const proxy = new Proxy(object, {
328
+ get(target, property) {
329
+ scope.check();
330
+ const member = Reflect.get(target, property, target);
331
+ if (typeof member !== "function") return scope.wrap(member);
332
+ return function(...args) {
333
+ scope.check();
334
+ return scope.returned(Reflect.apply(member, target, args.map((arg) => scope.unwrap(arg))));
335
+ };
336
+ },
337
+ set() {
338
+ throw new Error("A script cannot change Playwright's objects.");
339
+ },
340
+ apply(target, _self, args) {
341
+ scope.check();
342
+ return scope.returned(Reflect.apply(target, void 0, args.map((arg) => scope.unwrap(arg))));
343
+ }
344
+ });
345
+ this.proxies.set(object, proxy);
346
+ this.targets.set(proxy, object);
347
+ return proxy;
348
+ }
349
+ /**
350
+ * Ends the script's hold on the page: later calls through the membrane
351
+ * throw, and what it installed is removed.
352
+ */
353
+ async close(page) {
354
+ this.closed = true;
355
+ for (const disposable of this.disposables.splice(0)) {
356
+ try {
357
+ await disposable.dispose();
358
+ } catch {
359
+ }
360
+ }
361
+ await page.unrouteAll({ behavior: "ignoreErrors" }).catch(() => void 0);
362
+ await page.context().unrouteAll({ behavior: "ignoreErrors" }).catch(() => void 0);
363
+ page.removeAllListeners();
364
+ page.context().removeAllListeners();
365
+ }
366
+ check() {
367
+ if (this.closed) throw new Error("The script has ended or was cancelled; it can no longer use the page.");
368
+ }
369
+ unwrap(value) {
370
+ return value !== null && (typeof value === "object" || typeof value === "function") ? this.targets.get(value) ?? value : value;
371
+ }
372
+ returned(value) {
373
+ if (value instanceof Promise) return value.then((resolved) => this.returned(resolved));
374
+ if (value !== null && typeof value === "object" && typeof value.dispose === "function")
375
+ this.disposables.push(value);
376
+ return this.wrap(value);
377
+ }
378
+ };
379
+
140
380
  // src/executor.ts
141
381
  import {
142
382
  BROWSER_OPERATION_NAMES,
@@ -364,7 +604,12 @@ var PlaywrightOperationExecutor = class {
364
604
  options;
365
605
  ready;
366
606
  targets;
367
- /** Where the mouse is: `page.mouse` keeps it, but does not report it. */
607
+ /**
608
+ * Where the mouse is: `page.mouse` keeps it, but does not report it. A
609
+ * locator action moves it to the element's clickable point; it is then the
610
+ * centre of that element's box as measured after the action, or unknown
611
+ * (null) when that cannot be measured, and a press needs coordinates.
612
+ */
368
613
  mouse = { x: 0, y: 0 };
369
614
  constructor(page, options) {
370
615
  this.page = page;
@@ -462,22 +707,63 @@ var PlaywrightOperationExecutor = class {
462
707
  }
463
708
  if (!handle) throw new BrowserActionRefusal("NOT_FOUND", `${action} found no element for the locator.`);
464
709
  try {
465
- await guard({
466
- action,
467
- target: await this.describe(() => this.targets.ofHandle(handle, ACTION_TIMEOUT_MS)),
468
- ...value !== void 0 ? { value } : {}
469
- });
710
+ const target = await this.describe(() => this.targets.ofHandle(handle, ACTION_TIMEOUT_MS));
711
+ const described = typeof value === "function" ? await value(target) : value;
712
+ await guard({ action, target, ...described !== void 0 ? { value: described } : {} });
713
+ return { handle, target };
470
714
  } catch (error) {
471
715
  await handle.dispose().catch(() => void 0);
472
716
  throw error;
473
717
  }
474
- return handle;
475
718
  }
476
- /** Asks the host's policy about the element at a viewport point. */
477
- async guardPoint(action, x, y) {
719
+ /** The document an action is checked on; input later goes only to it. */
720
+ async checkpoint() {
721
+ if (!this.options.actionGuard) return null;
722
+ try {
723
+ return await this.targets.documentState();
724
+ } catch (error) {
725
+ throw new BrowserActionRefusal("UNSUPPORTED", `The page's document could not be identified, so nothing was sent: ${errorMessage(error)}`);
726
+ }
727
+ }
728
+ /** Just before input: the same document, no navigation in flight, and the host's own last check. */
729
+ async dispatching(state) {
730
+ if (!state) return;
731
+ try {
732
+ await this.targets.sameDocument(state);
733
+ } catch (error) {
734
+ if (error instanceof DocumentChanged) throw new BrowserActionRefusal("STALE_PAGE", error.message);
735
+ throw new BrowserActionRefusal("UNSUPPORTED", `The page's document could not be confirmed, so nothing was sent: ${errorMessage(error)}`);
736
+ }
737
+ await this.options.beforeInput?.({ url: state.url });
738
+ }
739
+ /** The page-level action's guard. */
740
+ async guardPage(action, value) {
741
+ if (this.options.actionGuard)
742
+ await this.options.actionGuard({ action, url: this.page.url(), ...value !== void 0 ? { value } : {} });
743
+ }
744
+ /** The pointer's position for a press that names none; unknown after a locator action. */
745
+ pointer(input) {
746
+ if (typeof input.x === "number" && typeof input.y === "number") return { x: input.x, y: input.y };
747
+ if (!this.mouse)
748
+ throw new BrowserActionRefusal(
749
+ "INVALID_INPUT",
750
+ "Where the mouse is after a locator action is not known exactly; give x and y, or move the mouse first."
751
+ );
752
+ return this.mouse;
753
+ }
754
+ /**
755
+ * Asks the host's policy about raw pointer input at a viewport point; the
756
+ * target is the element there as best known (`unidentified` when not).
757
+ */
758
+ async guardPoint(action, x, y, value) {
478
759
  const guard = this.options.actionGuard;
479
760
  if (!guard) return;
480
- await guard({ action, target: await this.describe(() => this.targets.atPoint(x, y)) });
761
+ await guard({
762
+ action,
763
+ target: await this.describe(() => this.targets.atPoint(x, y)),
764
+ pointer: true,
765
+ ...value !== void 0 ? { value } : {}
766
+ });
481
767
  }
482
768
  /** A browser-side description, or the refusal that replaces the action. */
483
769
  async describe(run) {
@@ -557,6 +843,9 @@ var PlaywrightOperationExecutor = class {
557
843
  }
558
844
  if (call.operation === "browser.scroll") {
559
845
  const direction = input.direction;
846
+ const state2 = await this.checkpoint();
847
+ await this.guardPage("scroll", { direction, ...typeof input.amount === "number" ? { amount: input.amount } : {} });
848
+ await this.dispatching(state2);
560
849
  const position = await page.evaluate(scrollInPage, {
561
850
  left: direction === "left" ? -1 : direction === "right" ? 1 : 0,
562
851
  top: direction === "up" ? -1 : direction === "down" ? 1 : 0,
@@ -567,43 +856,50 @@ var PlaywrightOperationExecutor = class {
567
856
  if (call.operation === "browser.script") return this.script(call);
568
857
  if (call.operation === "browser.mouse") {
569
858
  const action = input.action;
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);
859
+ if (action === "move") {
860
+ const at2 = { x: input.x, y: input.y };
861
+ await page.mouse.move(at2.x, at2.y);
862
+ this.mouse = at2;
863
+ return this.success(call.operation, { action, x: at2.x, y: at2.y });
864
+ }
865
+ const at = this.pointer(input);
866
+ const state2 = await this.checkpoint();
867
+ await this.guardPoint(action === "click" ? "click" : action === "down" ? "mousedown" : "mouseup", at.x, at.y);
868
+ await this.dispatching(state2);
869
+ await page.mouse.move(at.x, at.y);
870
+ if (action === "click") await page.mouse.click(at.x, at.y);
575
871
  else if (action === "down") await page.mouse.down();
576
872
  else await page.mouse.up();
577
- if (action === "move" || action === "click") this.mouse = { x: at.x, y: at.y };
578
- return this.success(call.operation, { action, ...typeof input.x === "number" ? { x: input.x, y: input.y } : {} });
873
+ this.mouse = at;
874
+ return this.success(call.operation, { action, x: at.x, y: at.y });
579
875
  }
580
876
  if (call.operation === "browser.wheel") {
581
877
  const deltaX = typeof input.deltaX === "number" ? input.deltaX : 0;
582
878
  const deltaY = typeof input.deltaY === "number" ? input.deltaY : 0;
583
- await this.guardPoint("wheel", this.mouse.x, this.mouse.y);
879
+ const at = this.pointer(input);
880
+ const state2 = await this.checkpoint();
881
+ await this.guardPoint("wheel", at.x, at.y, { deltaX, deltaY });
882
+ await this.dispatching(state2);
883
+ await page.mouse.move(at.x, at.y);
584
884
  await page.mouse.wheel(deltaX, deltaY);
885
+ this.mouse = at;
585
886
  return this.success(call.operation, { deltaX, deltaY });
586
887
  }
587
888
  if (call.operation === "browser.drag") {
588
889
  const resolve = async (endpoint) => {
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
- }
890
+ if (endpoint.locator === void 0) return { x: endpoint.x, y: endpoint.y };
594
891
  await this.refreshRefs(endpoint.locator);
595
892
  const locator2 = this.locator(endpoint.locator).first();
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
- }
893
+ const box = await locator2.boundingBox({ timeout: ACTION_TIMEOUT_MS }).catch(() => null);
894
+ if (!box) throw new BrowserActionRefusal("NOT_FOUND", "The drag locator matched no element.");
895
+ return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
604
896
  };
605
897
  const from = await resolve(input.from);
606
898
  const to = await resolve(input.to);
899
+ const drop = await this.describe(() => this.targets.atPoint(to.x, to.y));
900
+ const state2 = await this.checkpoint();
901
+ await this.guardPoint("drag", from.x, from.y, { from, to, drop });
902
+ await this.dispatching(state2);
607
903
  await page.mouse.move(from.x, from.y);
608
904
  await page.mouse.down();
609
905
  await page.mouse.move(to.x, to.y, { steps: typeof input.steps === "number" ? input.steps : 8 });
@@ -612,14 +908,23 @@ var PlaywrightOperationExecutor = class {
612
908
  return this.success(call.operation, { from, to });
613
909
  }
614
910
  if (call.operation === "browser.back") {
911
+ const state2 = await this.checkpoint();
912
+ await this.guardPage("back");
913
+ await this.dispatching(state2);
615
914
  await page.goBack({ waitUntil: "commit", timeout: ACTION_TIMEOUT_MS });
616
915
  return this.success(call.operation, { requested: true });
617
916
  }
618
917
  if (call.operation === "browser.forward") {
918
+ const state2 = await this.checkpoint();
919
+ await this.guardPage("forward");
920
+ await this.dispatching(state2);
619
921
  await page.goForward({ waitUntil: "commit", timeout: ACTION_TIMEOUT_MS });
620
922
  return this.success(call.operation, { requested: true });
621
923
  }
622
924
  if (call.operation === "browser.reload") {
925
+ const state2 = await this.checkpoint();
926
+ await this.guardPage("reload");
927
+ await this.dispatching(state2);
623
928
  await page.reload({ waitUntil: "commit", timeout: ACTION_TIMEOUT_MS });
624
929
  return this.success(call.operation, { requested: true });
625
930
  }
@@ -632,37 +937,64 @@ var PlaywrightOperationExecutor = class {
632
937
  return this.success(call.operation, box);
633
938
  }
634
939
  if (call.operation === "browser.press" && !locator) {
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
- }
940
+ const state2 = await this.checkpoint();
941
+ if (this.options.actionGuard)
942
+ await this.options.actionGuard({ action: "press", target: await this.describe(() => this.targets.focused()), value: input.key });
943
+ await this.dispatching(state2);
944
+ await page.keyboard.press(input.key);
642
945
  return this.success(call.operation, { key: input.key });
643
946
  }
644
947
  const target = locator;
645
948
  const guardedAction = call.operation === "browser.select" ? "select" : call.operation.slice("browser.".length);
646
- const handle = await this.guarded(
949
+ const state = await this.checkpoint();
950
+ const guarded = await this.guarded(
647
951
  target.first(),
648
952
  guardedAction,
649
- call.operation === "browser.fill" ? input.value : call.operation === "browser.press" ? input.key : call.operation === "browser.select" ? input.values : void 0
953
+ call.operation === "browser.fill" ? input.value : call.operation === "browser.press" ? input.key : call.operation === "browser.select" ? async (described) => ({ values: input.values, options: await this.targets.selectOptions(described, input.values) }) : void 0
650
954
  );
955
+ const handle = guarded?.handle ?? null;
651
956
  const element = handle ?? { ...bind(target), focus: () => target.focus({ timeout }) };
957
+ const press = async () => {
958
+ if (!guarded) {
959
+ await element.press(input.key, { timeout });
960
+ return;
961
+ }
962
+ await guarded.handle.focus();
963
+ if (!await this.targets.focusWithin(guarded.target))
964
+ throw new BrowserActionRefusal(
965
+ "UNSUPPORTED",
966
+ "Focusing the element left focus elsewhere, so the key would go to another element; the press was refused."
967
+ );
968
+ await this.dispatching(state);
969
+ await page.keyboard.press(input.key);
970
+ };
652
971
  const before = await this.inspect(target).catch(() => null);
653
972
  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 }));
973
+ const pointer = (run) => async () => {
974
+ if (guarded) await run({ timeout, trial: true });
975
+ await this.dispatching(state);
976
+ await run({ timeout });
977
+ };
978
+ const direct = (run) => async () => {
979
+ await this.dispatching(state);
980
+ return await run();
981
+ };
982
+ if (call.operation === "browser.click") await this.act(target, "click", pointer((options) => element.click(options)));
983
+ else if (call.operation === "browser.fill") await this.act(target, "fill", direct(() => element.fill(input.value, { timeout })));
984
+ else if (call.operation === "browser.press") await this.act(target, "press", press);
985
+ else if (call.operation === "browser.hover") await this.act(target, "hover", pointer((options) => element.hover(options)));
986
+ else if (call.operation === "browser.focus") await this.act(target, "focus", direct(() => element.focus()));
987
+ else if (call.operation === "browser.check") await this.act(target, "check", pointer((options) => element.check(options)));
988
+ else if (call.operation === "browser.uncheck") await this.act(target, "uncheck", pointer((options) => element.uncheck(options)));
661
989
  else if (call.operation === "browser.select") {
662
- const values = await this.act(target, "selectOption", () => element.selectOption(input.values, { timeout }));
990
+ const values = await this.act(target, "selectOption", direct(() => element.selectOption(input.values, { timeout })));
663
991
  return this.success(call.operation, { values });
664
992
  }
665
993
  } finally {
994
+ if (["browser.click", "browser.hover", "browser.check", "browser.uncheck"].includes(call.operation)) {
995
+ const box = await (handle ?? target).boundingBox().catch(() => null);
996
+ this.mouse = box ? { x: box.x + box.width / 2, y: box.y + box.height / 2 } : null;
997
+ }
666
998
  await handle?.dispose().catch(() => void 0);
667
999
  }
668
1000
  const after = await this.inspect(target).catch(() => null);
@@ -687,9 +1019,10 @@ var PlaywrightOperationExecutor = class {
687
1019
  }
688
1020
  const notice = timeout < requested ? `The script timeout is clamped to ${SCRIPT_TIMEOUT_CLAMP_MS}ms: the harness abandons a provider call at 12 s.` : void 0;
689
1021
  let timer;
1022
+ const scope = new ScriptScope();
690
1023
  try {
691
1024
  const value = await Promise.race([
692
- run(this.page, args),
1025
+ run(scope.wrap(this.page), args),
693
1026
  new Promise((_resolve, reject) => {
694
1027
  timer = setTimeout(() => reject(new BrowserActionRefusal("FAILED", `The script exceeded ${timeout}ms.` + (notice ? ` ${notice}` : ""))), timeout);
695
1028
  })
@@ -700,7 +1033,7 @@ var PlaywrightOperationExecutor = class {
700
1033
  });
701
1034
  } finally {
702
1035
  clearTimeout(timer);
703
- this.page.removeAllListeners();
1036
+ await scope.close(this.page);
704
1037
  }
705
1038
  }
706
1039
  async target() {