@volter-ai-dev/supercode-browser-playwright 0.1.1 → 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/dist/index.mjs CHANGED
@@ -1,6 +1,142 @@
1
1
  // src/executor.ts
2
2
  import { errors } from "playwright-core";
3
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
+
4
140
  // src/protocol.ts
5
141
  var SUPERCODE_BROWSER_PROVIDER_PROTOCOL = "supercode/browser-provider-v1";
6
142
  var SUPERCODE_BROWSER_OPERATION_PROTOCOL = "supercode/browser-operation-v1";
@@ -429,9 +565,13 @@ var PlaywrightOperationExecutor = class {
429
565
  page;
430
566
  options;
431
567
  ready;
568
+ targets;
569
+ /** Where the mouse is: `page.mouse` keeps it, but does not report it. */
570
+ mouse = { x: 0, y: 0 };
432
571
  constructor(page, options) {
433
572
  this.page = page;
434
573
  this.options = options;
574
+ this.targets = new BrowserSideTargets(page, options.snapshotExclude ?? null);
435
575
  this.ready = (async () => {
436
576
  await page.addInitScript(installRevisionCounter, REVISION_KEY);
437
577
  await page.evaluate(installRevisionCounter, REVISION_KEY).catch(() => void 0);
@@ -505,10 +645,55 @@ var PlaywrightOperationExecutor = class {
505
645
  throw error;
506
646
  }
507
647
  }
508
- /** The host's policy for an action; an element that is not there is the action's own failure. */
509
- async guard(action) {
510
- if (!this.options.actionGuard || await action.locator.count().catch(() => 0) === 0) return;
511
- await this.options.actionGuard(action);
648
+ /**
649
+ * Resolves the locator's element once (waiting up to the action budget),
650
+ * asks the host's policy about it, and returns the handle the action must
651
+ * use, so the element guarded is the element acted on. Without a policy the
652
+ * action runs on the locator. Resolution or description failing refuses.
653
+ */
654
+ async guarded(locator, action, value) {
655
+ const guard = this.options.actionGuard;
656
+ if (!guard) return null;
657
+ let handle;
658
+ try {
659
+ handle = await locator.elementHandle({ timeout: ACTION_TIMEOUT_MS });
660
+ } catch (error) {
661
+ if (error instanceof errors.TimeoutError)
662
+ throw new BrowserActionRefusal("NOT_FOUND", `${action} found no element for the locator in ${ACTION_TIMEOUT_MS}ms.`);
663
+ throw error;
664
+ }
665
+ if (!handle) throw new BrowserActionRefusal("NOT_FOUND", `${action} found no element for the locator.`);
666
+ try {
667
+ await guard({
668
+ action,
669
+ target: await this.describe(() => this.targets.ofHandle(handle, ACTION_TIMEOUT_MS)),
670
+ ...value !== void 0 ? { value } : {}
671
+ });
672
+ } catch (error) {
673
+ await handle.dispose().catch(() => void 0);
674
+ throw error;
675
+ }
676
+ return handle;
677
+ }
678
+ /** Asks the host's policy about the element at a viewport point. */
679
+ async guardPoint(action, x, y) {
680
+ const guard = this.options.actionGuard;
681
+ if (!guard) return;
682
+ await guard({ action, target: await this.describe(() => this.targets.atPoint(x, y)) });
683
+ }
684
+ /** A browser-side description, or the refusal that replaces the action. */
685
+ async describe(run) {
686
+ try {
687
+ return await run();
688
+ } catch (error) {
689
+ if (error instanceof HostElementTarget) throw new BrowserActionRefusal("UNSUPPORTED", error.message);
690
+ if (error instanceof UnidentifiedTarget)
691
+ throw new BrowserActionRefusal("UNSUPPORTED", `${error.message} The action was refused because its target could not be checked.`);
692
+ throw new BrowserActionRefusal(
693
+ "UNSUPPORTED",
694
+ `The action was refused because its target could not be checked: ${errorMessage(error)}`
695
+ );
696
+ }
512
697
  }
513
698
  /** The aria refs of the elements `snapshotExclude` matches, and of their contents. */
514
699
  async excludedRefs() {
@@ -584,35 +769,48 @@ var PlaywrightOperationExecutor = class {
584
769
  if (call.operation === "browser.script") return this.script(call);
585
770
  if (call.operation === "browser.mouse") {
586
771
  const action = input.action;
587
- if (action === "move") await page.mouse.move(input.x, input.y);
588
- else if (action === "click") await page.mouse.click(input.x, input.y);
772
+ const at = typeof input.x === "number" && typeof input.y === "number" ? { x: input.x, y: input.y } : this.mouse;
773
+ if (action === "move") await this.guardPoint("hover", at.x, at.y);
774
+ else await this.guardPoint(action === "click" ? "click" : action === "down" ? "mousedown" : "mouseup", at.x, at.y);
775
+ if (action === "move") await page.mouse.move(at.x, at.y);
776
+ else if (action === "click") await page.mouse.click(at.x, at.y);
589
777
  else if (action === "down") await page.mouse.down();
590
778
  else await page.mouse.up();
779
+ if (action === "move" || action === "click") this.mouse = { x: at.x, y: at.y };
591
780
  return this.success(call.operation, { action, ...typeof input.x === "number" ? { x: input.x, y: input.y } : {} });
592
781
  }
593
782
  if (call.operation === "browser.wheel") {
594
783
  const deltaX = typeof input.deltaX === "number" ? input.deltaX : 0;
595
784
  const deltaY = typeof input.deltaY === "number" ? input.deltaY : 0;
785
+ await this.guardPoint("wheel", this.mouse.x, this.mouse.y);
596
786
  await page.mouse.wheel(deltaX, deltaY);
597
787
  return this.success(call.operation, { deltaX, deltaY });
598
788
  }
599
789
  if (call.operation === "browser.drag") {
600
790
  const resolve = async (endpoint) => {
601
- if (endpoint.locator === void 0) return { x: endpoint.x, y: endpoint.y };
791
+ if (endpoint.locator === void 0) {
792
+ const point = { x: endpoint.x, y: endpoint.y };
793
+ await this.guardPoint("drag", point.x, point.y);
794
+ return point;
795
+ }
602
796
  await this.refreshRefs(endpoint.locator);
603
797
  const locator2 = this.locator(endpoint.locator).first();
604
- const box = await locator2.count() === 0 ? null : await locator2.boundingBox({ timeout: ACTION_TIMEOUT_MS });
605
- if (!box) throw new BrowserActionRefusal("NOT_FOUND", "The drag locator matched no element.");
606
- return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
798
+ const handle2 = await this.guarded(locator2, "drag");
799
+ try {
800
+ const box = handle2 ? await handle2.boundingBox() : await locator2.count() === 0 ? null : await locator2.boundingBox({ timeout: ACTION_TIMEOUT_MS });
801
+ if (!box) throw new BrowserActionRefusal("NOT_FOUND", "The drag locator matched no element.");
802
+ return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
803
+ } finally {
804
+ await handle2?.dispose().catch(() => void 0);
805
+ }
607
806
  };
608
- for (const end of [input.from, input.to])
609
- if (end.locator !== void 0) await this.guard({ action: "drag", locator: this.locator(end.locator).first() });
610
807
  const from = await resolve(input.from);
611
808
  const to = await resolve(input.to);
612
809
  await page.mouse.move(from.x, from.y);
613
810
  await page.mouse.down();
614
811
  await page.mouse.move(to.x, to.y, { steps: typeof input.steps === "number" ? input.steps : 8 });
615
812
  await page.mouse.up();
813
+ this.mouse = to;
616
814
  return this.success(call.operation, { from, to });
617
815
  }
618
816
  if (call.operation === "browser.back") {
@@ -636,28 +834,38 @@ var PlaywrightOperationExecutor = class {
636
834
  return this.success(call.operation, box);
637
835
  }
638
836
  if (call.operation === "browser.press" && !locator) {
639
- await this.guard({ action: "press", locator: page.locator("*:focus").first(), value: input.key });
640
- await page.keyboard.press(input.key);
837
+ const focused = page.locator("*:focus").first();
838
+ const handle2 = await this.guarded(await focused.count() ? focused : page.locator("body"), "press", input.key);
839
+ try {
840
+ await page.keyboard.press(input.key);
841
+ } finally {
842
+ await handle2?.dispose().catch(() => void 0);
843
+ }
641
844
  return this.success(call.operation, { key: input.key });
642
845
  }
643
846
  const target = locator;
644
- const guarded = call.operation === "browser.select" ? "select" : call.operation.slice("browser.".length);
645
- await this.guard({
646
- action: guarded,
647
- locator: target.first(),
648
- ...call.operation === "browser.fill" ? { value: input.value } : call.operation === "browser.press" ? { value: input.key } : call.operation === "browser.select" ? { value: input.values } : {}
649
- });
847
+ const guardedAction = call.operation === "browser.select" ? "select" : call.operation.slice("browser.".length);
848
+ const handle = await this.guarded(
849
+ target.first(),
850
+ guardedAction,
851
+ call.operation === "browser.fill" ? input.value : call.operation === "browser.press" ? input.key : call.operation === "browser.select" ? input.values : void 0
852
+ );
853
+ const element = handle ?? { ...bind(target), focus: () => target.focus({ timeout }) };
650
854
  const before = await this.inspect(target).catch(() => null);
651
- if (call.operation === "browser.click") await this.act(target, "click", () => target.click({ timeout }));
652
- else if (call.operation === "browser.fill") await this.act(target, "fill", () => target.fill(input.value, { timeout }));
653
- else if (call.operation === "browser.press") await this.act(target, "press", () => target.press(input.key, { timeout }));
654
- else if (call.operation === "browser.hover") await this.act(target, "hover", () => target.hover({ timeout }));
655
- else if (call.operation === "browser.focus") await this.act(target, "focus", () => target.focus({ timeout }));
656
- else if (call.operation === "browser.check") await this.act(target, "check", () => target.check({ timeout }));
657
- else if (call.operation === "browser.uncheck") await this.act(target, "uncheck", () => target.uncheck({ timeout }));
658
- else if (call.operation === "browser.select") {
659
- const values = await this.act(target, "selectOption", () => target.selectOption(input.values, { timeout }));
660
- return this.success(call.operation, { values });
855
+ try {
856
+ if (call.operation === "browser.click") await this.act(target, "click", () => element.click({ timeout }));
857
+ else if (call.operation === "browser.fill") await this.act(target, "fill", () => element.fill(input.value, { timeout }));
858
+ else if (call.operation === "browser.press") await this.act(target, "press", () => element.press(input.key, { timeout }));
859
+ else if (call.operation === "browser.hover") await this.act(target, "hover", () => element.hover({ timeout }));
860
+ else if (call.operation === "browser.focus") await this.act(target, "focus", () => element.focus());
861
+ else if (call.operation === "browser.check") await this.act(target, "check", () => element.check({ timeout }));
862
+ else if (call.operation === "browser.uncheck") await this.act(target, "uncheck", () => element.uncheck({ timeout }));
863
+ else if (call.operation === "browser.select") {
864
+ const values = await this.act(target, "selectOption", () => element.selectOption(input.values, { timeout }));
865
+ return this.success(call.operation, { values });
866
+ }
867
+ } finally {
868
+ await handle?.dispose().catch(() => void 0);
661
869
  }
662
870
  const after = await this.inspect(target).catch(() => null);
663
871
  const inspection = after ?? before;
@@ -725,6 +933,17 @@ function operationName(raw) {
725
933
  const candidate = raw && typeof raw === "object" && !Array.isArray(raw) ? raw.operation : void 0;
726
934
  return typeof candidate === "string" && BROWSER_OPERATION_NAMES.includes(candidate) ? candidate : "browser.status";
727
935
  }
936
+ function bind(locator) {
937
+ return {
938
+ click: locator.click.bind(locator),
939
+ fill: locator.fill.bind(locator),
940
+ press: locator.press.bind(locator),
941
+ hover: locator.hover.bind(locator),
942
+ check: locator.check.bind(locator),
943
+ uncheck: locator.uncheck.bind(locator),
944
+ selectOption: locator.selectOption.bind(locator)
945
+ };
946
+ }
728
947
 
729
948
  // src/provider.ts
730
949
  import { timingSafeEqual } from "node:crypto";