@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/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";
@@ -253,6 +389,49 @@ var PLAYWRIGHT_FEATURES = Object.freeze([
253
389
  "page.screenshot.png",
254
390
  "page.evaluate"
255
391
  ]);
392
+ var SNAPSHOT_EXCLUDE_LIMIT = 16;
393
+ function collectRefs(nodes, into) {
394
+ if (Array.isArray(nodes)) {
395
+ for (const node2 of nodes) collectRefs(node2, into);
396
+ return;
397
+ }
398
+ if (!nodes || typeof nodes !== "object") return;
399
+ const node = nodes;
400
+ if (typeof node.ref === "string") into.add(node.ref);
401
+ collectRefs(node.children, into);
402
+ }
403
+ function snapshotWithoutRefs(text, refs) {
404
+ if (refs.size === 0) return text;
405
+ const root = { text: "", indent: -1, children: [], removed: false };
406
+ const stack = [root];
407
+ const lines = [];
408
+ for (const raw of text.split("\n")) {
409
+ const indent = raw.length - raw.trimStart().length;
410
+ const line = { text: raw, indent, children: [], removed: false };
411
+ while (stack.length > 1 && stack[stack.length - 1].indent >= indent) stack.pop();
412
+ stack[stack.length - 1].children.push(line);
413
+ stack.push(line);
414
+ lines.push(line);
415
+ }
416
+ const refOf = (line) => /\[ref=([^\]\s]+)\]/.exec(line.text)?.[1];
417
+ const remove = (line) => {
418
+ line.removed = true;
419
+ for (const child of line.children) remove(child);
420
+ };
421
+ const visit = (line) => {
422
+ const ref = refOf(line);
423
+ if (ref !== void 0 && refs.has(ref)) {
424
+ remove(line);
425
+ return;
426
+ }
427
+ const before = line.children.length;
428
+ for (const child of line.children) visit(child);
429
+ if (line !== root && ref === void 0 && before > 0 && line.children.every((child) => child.removed))
430
+ line.removed = true;
431
+ };
432
+ visit(root);
433
+ return lines.filter((line) => !line.removed).map((line) => line.text).join("\n");
434
+ }
256
435
  var REVISION_KEY = "__supercodeBrowserRevision";
257
436
  function installRevisionCounter(key) {
258
437
  const scope = globalThis;
@@ -386,9 +565,13 @@ var PlaywrightOperationExecutor = class {
386
565
  page;
387
566
  options;
388
567
  ready;
568
+ targets;
569
+ /** Where the mouse is: `page.mouse` keeps it, but does not report it. */
570
+ mouse = { x: 0, y: 0 };
389
571
  constructor(page, options) {
390
572
  this.page = page;
391
573
  this.options = options;
574
+ this.targets = new BrowserSideTargets(page, options.snapshotExclude ?? null);
392
575
  this.ready = (async () => {
393
576
  await page.addInitScript(installRevisionCounter, REVISION_KEY);
394
577
  await page.evaluate(installRevisionCounter, REVISION_KEY).catch(() => void 0);
@@ -462,10 +645,68 @@ var PlaywrightOperationExecutor = class {
462
645
  throw error;
463
646
  }
464
647
  }
465
- /** The host's policy for an action; an element that is not there is the action's own failure. */
466
- async guard(action) {
467
- if (!this.options.actionGuard || await action.locator.count().catch(() => 0) === 0) return;
468
- 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
+ }
697
+ }
698
+ /** The aria refs of the elements `snapshotExclude` matches, and of their contents. */
699
+ async excludedRefs() {
700
+ const refs = /* @__PURE__ */ new Set();
701
+ const selector = this.options.snapshotExclude;
702
+ if (!selector) return refs;
703
+ const matches = this.page.locator(selector);
704
+ const count = Math.min(await matches.count().catch(() => 0), SNAPSHOT_EXCLUDE_LIMIT);
705
+ for (let index = 0; index < count; index += 1) {
706
+ const nodes = await matches.nth(index).ariaSnapshotJSON({ mode: "ai", timeout: ACTION_TIMEOUT_MS }).catch(() => null);
707
+ collectRefs(nodes, refs);
708
+ }
709
+ return refs;
469
710
  }
470
711
  async revision() {
471
712
  const value = await this.page.evaluate((key) => globalThis[key], REVISION_KEY).catch(() => void 0);
@@ -491,7 +732,8 @@ var PlaywrightOperationExecutor = class {
491
732
  if (call.operation === "browser.snapshot") {
492
733
  await this.refreshRefs(input.locator);
493
734
  const scoped = input.locator ? this.locator(input.locator).first() : null;
494
- 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 });
735
+ const excluded = await this.excludedRefs();
736
+ 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);
495
737
  const bounded = text.slice(0, 64e3);
496
738
  return this.success(call.operation, { text: bounded, truncated: bounded.length < text.length });
497
739
  }
@@ -527,35 +769,48 @@ var PlaywrightOperationExecutor = class {
527
769
  if (call.operation === "browser.script") return this.script(call);
528
770
  if (call.operation === "browser.mouse") {
529
771
  const action = input.action;
530
- if (action === "move") await page.mouse.move(input.x, input.y);
531
- 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);
532
777
  else if (action === "down") await page.mouse.down();
533
778
  else await page.mouse.up();
779
+ if (action === "move" || action === "click") this.mouse = { x: at.x, y: at.y };
534
780
  return this.success(call.operation, { action, ...typeof input.x === "number" ? { x: input.x, y: input.y } : {} });
535
781
  }
536
782
  if (call.operation === "browser.wheel") {
537
783
  const deltaX = typeof input.deltaX === "number" ? input.deltaX : 0;
538
784
  const deltaY = typeof input.deltaY === "number" ? input.deltaY : 0;
785
+ await this.guardPoint("wheel", this.mouse.x, this.mouse.y);
539
786
  await page.mouse.wheel(deltaX, deltaY);
540
787
  return this.success(call.operation, { deltaX, deltaY });
541
788
  }
542
789
  if (call.operation === "browser.drag") {
543
790
  const resolve = async (endpoint) => {
544
- 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
+ }
545
796
  await this.refreshRefs(endpoint.locator);
546
797
  const locator2 = this.locator(endpoint.locator).first();
547
- const box = await locator2.count() === 0 ? null : await locator2.boundingBox({ timeout: ACTION_TIMEOUT_MS });
548
- if (!box) throw new BrowserActionRefusal("NOT_FOUND", "The drag locator matched no element.");
549
- 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
+ }
550
806
  };
551
- for (const end of [input.from, input.to])
552
- if (end.locator !== void 0) await this.guard({ action: "drag", locator: this.locator(end.locator).first() });
553
807
  const from = await resolve(input.from);
554
808
  const to = await resolve(input.to);
555
809
  await page.mouse.move(from.x, from.y);
556
810
  await page.mouse.down();
557
811
  await page.mouse.move(to.x, to.y, { steps: typeof input.steps === "number" ? input.steps : 8 });
558
812
  await page.mouse.up();
813
+ this.mouse = to;
559
814
  return this.success(call.operation, { from, to });
560
815
  }
561
816
  if (call.operation === "browser.back") {
@@ -579,28 +834,38 @@ var PlaywrightOperationExecutor = class {
579
834
  return this.success(call.operation, box);
580
835
  }
581
836
  if (call.operation === "browser.press" && !locator) {
582
- await this.guard({ action: "press", locator: page.locator("*:focus").first(), value: input.key });
583
- 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
+ }
584
844
  return this.success(call.operation, { key: input.key });
585
845
  }
586
846
  const target = locator;
587
- const guarded = call.operation === "browser.select" ? "select" : call.operation.slice("browser.".length);
588
- await this.guard({
589
- action: guarded,
590
- locator: target.first(),
591
- ...call.operation === "browser.fill" ? { value: input.value } : call.operation === "browser.press" ? { value: input.key } : call.operation === "browser.select" ? { value: input.values } : {}
592
- });
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 }) };
593
854
  const before = await this.inspect(target).catch(() => null);
594
- if (call.operation === "browser.click") await this.act(target, "click", () => target.click({ timeout }));
595
- else if (call.operation === "browser.fill") await this.act(target, "fill", () => target.fill(input.value, { timeout }));
596
- else if (call.operation === "browser.press") await this.act(target, "press", () => target.press(input.key, { timeout }));
597
- else if (call.operation === "browser.hover") await this.act(target, "hover", () => target.hover({ timeout }));
598
- else if (call.operation === "browser.focus") await this.act(target, "focus", () => target.focus({ timeout }));
599
- else if (call.operation === "browser.check") await this.act(target, "check", () => target.check({ timeout }));
600
- else if (call.operation === "browser.uncheck") await this.act(target, "uncheck", () => target.uncheck({ timeout }));
601
- else if (call.operation === "browser.select") {
602
- const values = await this.act(target, "selectOption", () => target.selectOption(input.values, { timeout }));
603
- 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);
604
869
  }
605
870
  const after = await this.inspect(target).catch(() => null);
606
871
  const inspection = after ?? before;
@@ -615,6 +880,7 @@ var PlaywrightOperationExecutor = class {
615
880
  const timeout = Math.min(requested, SCRIPT_TIMEOUT_CLAMP_MS);
616
881
  const AsyncFunction = Object.getPrototypeOf(async function() {
617
882
  }).constructor;
883
+ if (this.options.actionGuard) await this.options.actionGuard({ action: "script", source, args });
618
884
  let run;
619
885
  try {
620
886
  run = new AsyncFunction("page", "args", source);
@@ -667,6 +933,17 @@ function operationName(raw) {
667
933
  const candidate = raw && typeof raw === "object" && !Array.isArray(raw) ? raw.operation : void 0;
668
934
  return typeof candidate === "string" && BROWSER_OPERATION_NAMES.includes(candidate) ? candidate : "browser.status";
669
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
+ }
670
947
 
671
948
  // src/provider.ts
672
949
  import { timingSafeEqual } from "node:crypto";
@@ -1011,6 +1288,7 @@ export {
1011
1288
  parseBrowserOperationCall,
1012
1289
  parseBrowserOperationResult,
1013
1290
  serveHttp,
1014
- serveTcp
1291
+ serveTcp,
1292
+ snapshotWithoutRefs
1015
1293
  };
1016
1294
  //# sourceMappingURL=index.mjs.map