@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/README.md +33 -10
- package/dist/executor.d.ts +35 -14
- package/dist/executor.mjs +251 -30
- package/dist/executor.mjs.map +4 -4
- package/dist/guard.d.ts +63 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.mjs +249 -30
- package/dist/index.mjs.map +4 -4
- package/package.json +1 -1
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
|
-
/**
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
588
|
-
|
|
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)
|
|
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
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
|
|
640
|
-
await page.
|
|
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
|
|
645
|
-
await this.
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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";
|