@volter-ai-dev/supercode-browser-playwright 0.1.1 → 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/README.md +44 -10
- package/dist/executor.d.ts +74 -14
- package/dist/executor.mjs +583 -29
- package/dist/executor.mjs.map +4 -4
- package/dist/guard.d.ts +124 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.mjs +581 -29
- package/dist/index.mjs.map +4 -4
- package/dist/script-scope.d.ts +27 -0
- package/package.json +1 -1
package/dist/executor.mjs
CHANGED
|
@@ -1,5 +1,383 @@
|
|
|
1
1
|
// src/executor.ts
|
|
2
2
|
import { errors } from "playwright-core";
|
|
3
|
+
|
|
4
|
+
// src/guard.ts
|
|
5
|
+
var UnidentifiedTarget = class extends Error {
|
|
6
|
+
};
|
|
7
|
+
var HostElementTarget = class extends Error {
|
|
8
|
+
};
|
|
9
|
+
var DocumentChanged = class extends Error {
|
|
10
|
+
};
|
|
11
|
+
var WORLD = "supercode-guard";
|
|
12
|
+
var GROUP = "supercode-guard";
|
|
13
|
+
var MAX_NODES = 8;
|
|
14
|
+
var MAX_ANCESTORS = 6;
|
|
15
|
+
function findInWorld(query) {
|
|
16
|
+
const onHost = (element) => {
|
|
17
|
+
if (!query.host) return false;
|
|
18
|
+
for (let node = element; node; ) {
|
|
19
|
+
if (node.matches(query.host)) return true;
|
|
20
|
+
const parent = node.parentElement;
|
|
21
|
+
node = parent ?? (node.getRootNode().host ?? null);
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
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
|
+
}
|
|
34
|
+
if (query.kind === "point") {
|
|
35
|
+
let element = document.elementFromPoint(query.x, query.y);
|
|
36
|
+
if (!element) return "Nothing is at that point.";
|
|
37
|
+
if (onHost(element)) return "host";
|
|
38
|
+
while (element.shadowRoot) {
|
|
39
|
+
const inner = element.shadowRoot.elementFromPoint(query.x, query.y);
|
|
40
|
+
if (!inner || inner === element) break;
|
|
41
|
+
element = inner;
|
|
42
|
+
if (onHost(element)) return "host";
|
|
43
|
+
}
|
|
44
|
+
if (frame(element)) return "The point is inside an embedded frame, which the guard cannot inspect.";
|
|
45
|
+
return [element];
|
|
46
|
+
}
|
|
47
|
+
const { box } = query;
|
|
48
|
+
const marked = [];
|
|
49
|
+
const found = [];
|
|
50
|
+
const near = (a, b) => Math.abs(a - b) <= 1;
|
|
51
|
+
const visit = (root) => {
|
|
52
|
+
for (const element of Array.from(root.querySelectorAll("*"))) {
|
|
53
|
+
if (found.length > query.max - 1) return;
|
|
54
|
+
const rect = element.getBoundingClientRect();
|
|
55
|
+
if (element.hasAttribute(query.marker)) {
|
|
56
|
+
marked.push(element);
|
|
57
|
+
element.removeAttribute(query.marker);
|
|
58
|
+
}
|
|
59
|
+
if (near(rect.x, box.x) && near(rect.y, box.y) && near(rect.width, box.width) && near(rect.height, box.height))
|
|
60
|
+
found.push(element);
|
|
61
|
+
if (element.shadowRoot) visit(element.shadowRoot);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const cap = query.max;
|
|
65
|
+
query.max = Number.MAX_SAFE_INTEGER;
|
|
66
|
+
visit(document);
|
|
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;
|
|
81
|
+
}
|
|
82
|
+
var BrowserSideTargets = class {
|
|
83
|
+
constructor(page, host) {
|
|
84
|
+
this.page = page;
|
|
85
|
+
this.host = host;
|
|
86
|
+
}
|
|
87
|
+
session = null;
|
|
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
|
+
*/
|
|
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.");
|
|
100
|
+
await handle.scrollIntoViewIfNeeded({ timeout });
|
|
101
|
+
const box = await handle.boundingBox();
|
|
102
|
+
if (!box || box.width === 0 || box.height === 0)
|
|
103
|
+
throw new UnidentifiedTarget("The element has no box on the page.");
|
|
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 : [];
|
|
141
|
+
}
|
|
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
|
+
*/
|
|
178
|
+
async atPoint(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.");
|
|
208
|
+
}
|
|
209
|
+
cdp() {
|
|
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) => {
|
|
232
|
+
this.session = null;
|
|
233
|
+
throw error;
|
|
234
|
+
});
|
|
235
|
+
return this.session;
|
|
236
|
+
}
|
|
237
|
+
async resolve(query) {
|
|
238
|
+
const cdp = await this.cdp();
|
|
239
|
+
const contextId = await this.world(cdp);
|
|
240
|
+
try {
|
|
241
|
+
const evaluated = await cdp.send("Runtime.evaluate", {
|
|
242
|
+
expression: `(${findInWorld.toString()})(${JSON.stringify(query)})`,
|
|
243
|
+
contextId,
|
|
244
|
+
objectGroup: GROUP,
|
|
245
|
+
returnByValue: false
|
|
246
|
+
});
|
|
247
|
+
if (evaluated.exceptionDetails)
|
|
248
|
+
throw new UnidentifiedTarget(`The target could not be resolved: ${evaluated.exceptionDetails.text ?? "exception"}.`);
|
|
249
|
+
if (evaluated.result.type === "string") {
|
|
250
|
+
if (evaluated.result.value === "host") throw new HostElementTarget("The point is on the host's own interface, which agents cannot operate.");
|
|
251
|
+
throw new UnidentifiedTarget(String(evaluated.result.value));
|
|
252
|
+
}
|
|
253
|
+
if (!evaluated.result.objectId) throw new UnidentifiedTarget("The target could not be resolved.");
|
|
254
|
+
const properties = await cdp.send("Runtime.getProperties", { objectId: evaluated.result.objectId, ownProperties: true });
|
|
255
|
+
const objects = properties.result.filter((property) => /^\d+$/.test(property.name) && property.value?.objectId).map((property) => property.value.objectId);
|
|
256
|
+
if (!objects.length) throw new UnidentifiedTarget("The target could not be resolved.");
|
|
257
|
+
const nodes = [];
|
|
258
|
+
const ancestors = [];
|
|
259
|
+
const seen = /* @__PURE__ */ new Set();
|
|
260
|
+
for (const objectId of objects) {
|
|
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
|
+
});
|
|
269
|
+
const attributes = {};
|
|
270
|
+
const list = described.node.attributes ?? [];
|
|
271
|
+
for (let index = 0; index + 1 < list.length; index += 2) attributes[list[index].toLowerCase()] = list[index + 1];
|
|
272
|
+
const ax = await this.accessibility(cdp, described.node.backendNodeId);
|
|
273
|
+
nodes.push({
|
|
274
|
+
backendNodeId: described.node.backendNodeId,
|
|
275
|
+
tag: described.node.nodeName.toLowerCase(),
|
|
276
|
+
attributes,
|
|
277
|
+
role: ax.self?.role ?? "",
|
|
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 } : {}
|
|
281
|
+
});
|
|
282
|
+
seen.add(described.node.backendNodeId);
|
|
283
|
+
for (const ancestor of ax.ancestors) {
|
|
284
|
+
if (ancestors.length >= MAX_ANCESTORS || seen.has(ancestor.backendNodeId)) continue;
|
|
285
|
+
seen.add(ancestor.backendNodeId);
|
|
286
|
+
ancestors.push(ancestor);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return { url: this.page.url(), nodes, ancestors };
|
|
290
|
+
} finally {
|
|
291
|
+
await cdp.send("Runtime.releaseObjectGroup", { objectGroup: GROUP }).catch(() => void 0);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
async accessibility(cdp, backendNodeId) {
|
|
295
|
+
const tree = await cdp.send("Accessibility.getPartialAXTree", { backendNodeId, fetchRelatives: true });
|
|
296
|
+
const byId = new Map(tree.nodes.map((node) => [node.nodeId, node]));
|
|
297
|
+
const text = (value) => typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
|
|
298
|
+
const self = tree.nodes.find((node) => node.backendDOMNodeId === backendNodeId) ?? null;
|
|
299
|
+
const ancestors = [];
|
|
300
|
+
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) {
|
|
301
|
+
if (node.ignored || typeof node.backendDOMNodeId !== "number") continue;
|
|
302
|
+
ancestors.push({ backendNodeId: node.backendDOMNodeId, tag: "", attributes: {}, role: text(node.role?.value), name: text(node.name?.value) });
|
|
303
|
+
}
|
|
304
|
+
return { self: self ? { role: text(self.role?.value), name: text(self.name?.value) } : null, ancestors };
|
|
305
|
+
}
|
|
306
|
+
};
|
|
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
|
+
|
|
380
|
+
// src/executor.ts
|
|
3
381
|
import {
|
|
4
382
|
BROWSER_OPERATION_NAMES,
|
|
5
383
|
BrowserActionRefusal,
|
|
@@ -225,9 +603,18 @@ var PlaywrightOperationExecutor = class {
|
|
|
225
603
|
page;
|
|
226
604
|
options;
|
|
227
605
|
ready;
|
|
606
|
+
targets;
|
|
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
|
+
*/
|
|
613
|
+
mouse = { x: 0, y: 0 };
|
|
228
614
|
constructor(page, options) {
|
|
229
615
|
this.page = page;
|
|
230
616
|
this.options = options;
|
|
617
|
+
this.targets = new BrowserSideTargets(page, options.snapshotExclude ?? null);
|
|
231
618
|
this.ready = (async () => {
|
|
232
619
|
await page.addInitScript(installRevisionCounter, REVISION_KEY);
|
|
233
620
|
await page.evaluate(installRevisionCounter, REVISION_KEY).catch(() => void 0);
|
|
@@ -301,10 +688,96 @@ var PlaywrightOperationExecutor = class {
|
|
|
301
688
|
throw error;
|
|
302
689
|
}
|
|
303
690
|
}
|
|
304
|
-
/**
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
691
|
+
/**
|
|
692
|
+
* Resolves the locator's element once (waiting up to the action budget),
|
|
693
|
+
* asks the host's policy about it, and returns the handle the action must
|
|
694
|
+
* use, so the element guarded is the element acted on. Without a policy the
|
|
695
|
+
* action runs on the locator. Resolution or description failing refuses.
|
|
696
|
+
*/
|
|
697
|
+
async guarded(locator, action, value) {
|
|
698
|
+
const guard = this.options.actionGuard;
|
|
699
|
+
if (!guard) return null;
|
|
700
|
+
let handle;
|
|
701
|
+
try {
|
|
702
|
+
handle = await locator.elementHandle({ timeout: ACTION_TIMEOUT_MS });
|
|
703
|
+
} catch (error) {
|
|
704
|
+
if (error instanceof errors.TimeoutError)
|
|
705
|
+
throw new BrowserActionRefusal("NOT_FOUND", `${action} found no element for the locator in ${ACTION_TIMEOUT_MS}ms.`);
|
|
706
|
+
throw error;
|
|
707
|
+
}
|
|
708
|
+
if (!handle) throw new BrowserActionRefusal("NOT_FOUND", `${action} found no element for the locator.`);
|
|
709
|
+
try {
|
|
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 };
|
|
714
|
+
} catch (error) {
|
|
715
|
+
await handle.dispose().catch(() => void 0);
|
|
716
|
+
throw error;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
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) {
|
|
759
|
+
const guard = this.options.actionGuard;
|
|
760
|
+
if (!guard) return;
|
|
761
|
+
await guard({
|
|
762
|
+
action,
|
|
763
|
+
target: await this.describe(() => this.targets.atPoint(x, y)),
|
|
764
|
+
pointer: true,
|
|
765
|
+
...value !== void 0 ? { value } : {}
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
/** A browser-side description, or the refusal that replaces the action. */
|
|
769
|
+
async describe(run) {
|
|
770
|
+
try {
|
|
771
|
+
return await run();
|
|
772
|
+
} catch (error) {
|
|
773
|
+
if (error instanceof HostElementTarget) throw new BrowserActionRefusal("UNSUPPORTED", error.message);
|
|
774
|
+
if (error instanceof UnidentifiedTarget)
|
|
775
|
+
throw new BrowserActionRefusal("UNSUPPORTED", `${error.message} The action was refused because its target could not be checked.`);
|
|
776
|
+
throw new BrowserActionRefusal(
|
|
777
|
+
"UNSUPPORTED",
|
|
778
|
+
`The action was refused because its target could not be checked: ${errorMessage(error)}`
|
|
779
|
+
);
|
|
780
|
+
}
|
|
308
781
|
}
|
|
309
782
|
/** The aria refs of the elements `snapshotExclude` matches, and of their contents. */
|
|
310
783
|
async excludedRefs() {
|
|
@@ -370,6 +843,9 @@ var PlaywrightOperationExecutor = class {
|
|
|
370
843
|
}
|
|
371
844
|
if (call.operation === "browser.scroll") {
|
|
372
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);
|
|
373
849
|
const position = await page.evaluate(scrollInPage, {
|
|
374
850
|
left: direction === "left" ? -1 : direction === "right" ? 1 : 0,
|
|
375
851
|
top: direction === "up" ? -1 : direction === "down" ? 1 : 0,
|
|
@@ -380,16 +856,33 @@ var PlaywrightOperationExecutor = class {
|
|
|
380
856
|
if (call.operation === "browser.script") return this.script(call);
|
|
381
857
|
if (call.operation === "browser.mouse") {
|
|
382
858
|
const action = input.action;
|
|
383
|
-
if (action === "move")
|
|
384
|
-
|
|
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);
|
|
385
871
|
else if (action === "down") await page.mouse.down();
|
|
386
872
|
else await page.mouse.up();
|
|
387
|
-
|
|
873
|
+
this.mouse = at;
|
|
874
|
+
return this.success(call.operation, { action, x: at.x, y: at.y });
|
|
388
875
|
}
|
|
389
876
|
if (call.operation === "browser.wheel") {
|
|
390
877
|
const deltaX = typeof input.deltaX === "number" ? input.deltaX : 0;
|
|
391
878
|
const deltaY = typeof input.deltaY === "number" ? input.deltaY : 0;
|
|
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);
|
|
392
884
|
await page.mouse.wheel(deltaX, deltaY);
|
|
885
|
+
this.mouse = at;
|
|
393
886
|
return this.success(call.operation, { deltaX, deltaY });
|
|
394
887
|
}
|
|
395
888
|
if (call.operation === "browser.drag") {
|
|
@@ -397,29 +890,41 @@ var PlaywrightOperationExecutor = class {
|
|
|
397
890
|
if (endpoint.locator === void 0) return { x: endpoint.x, y: endpoint.y };
|
|
398
891
|
await this.refreshRefs(endpoint.locator);
|
|
399
892
|
const locator2 = this.locator(endpoint.locator).first();
|
|
400
|
-
const box = await locator2.
|
|
893
|
+
const box = await locator2.boundingBox({ timeout: ACTION_TIMEOUT_MS }).catch(() => null);
|
|
401
894
|
if (!box) throw new BrowserActionRefusal("NOT_FOUND", "The drag locator matched no element.");
|
|
402
895
|
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
|
403
896
|
};
|
|
404
|
-
for (const end of [input.from, input.to])
|
|
405
|
-
if (end.locator !== void 0) await this.guard({ action: "drag", locator: this.locator(end.locator).first() });
|
|
406
897
|
const from = await resolve(input.from);
|
|
407
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);
|
|
408
903
|
await page.mouse.move(from.x, from.y);
|
|
409
904
|
await page.mouse.down();
|
|
410
905
|
await page.mouse.move(to.x, to.y, { steps: typeof input.steps === "number" ? input.steps : 8 });
|
|
411
906
|
await page.mouse.up();
|
|
907
|
+
this.mouse = to;
|
|
412
908
|
return this.success(call.operation, { from, to });
|
|
413
909
|
}
|
|
414
910
|
if (call.operation === "browser.back") {
|
|
911
|
+
const state2 = await this.checkpoint();
|
|
912
|
+
await this.guardPage("back");
|
|
913
|
+
await this.dispatching(state2);
|
|
415
914
|
await page.goBack({ waitUntil: "commit", timeout: ACTION_TIMEOUT_MS });
|
|
416
915
|
return this.success(call.operation, { requested: true });
|
|
417
916
|
}
|
|
418
917
|
if (call.operation === "browser.forward") {
|
|
918
|
+
const state2 = await this.checkpoint();
|
|
919
|
+
await this.guardPage("forward");
|
|
920
|
+
await this.dispatching(state2);
|
|
419
921
|
await page.goForward({ waitUntil: "commit", timeout: ACTION_TIMEOUT_MS });
|
|
420
922
|
return this.success(call.operation, { requested: true });
|
|
421
923
|
}
|
|
422
924
|
if (call.operation === "browser.reload") {
|
|
925
|
+
const state2 = await this.checkpoint();
|
|
926
|
+
await this.guardPage("reload");
|
|
927
|
+
await this.dispatching(state2);
|
|
423
928
|
await page.reload({ waitUntil: "commit", timeout: ACTION_TIMEOUT_MS });
|
|
424
929
|
return this.success(call.operation, { requested: true });
|
|
425
930
|
}
|
|
@@ -432,28 +937,65 @@ var PlaywrightOperationExecutor = class {
|
|
|
432
937
|
return this.success(call.operation, box);
|
|
433
938
|
}
|
|
434
939
|
if (call.operation === "browser.press" && !locator) {
|
|
435
|
-
|
|
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);
|
|
436
944
|
await page.keyboard.press(input.key);
|
|
437
945
|
return this.success(call.operation, { key: input.key });
|
|
438
946
|
}
|
|
439
947
|
const target = locator;
|
|
440
|
-
const
|
|
441
|
-
await this.
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
948
|
+
const guardedAction = call.operation === "browser.select" ? "select" : call.operation.slice("browser.".length);
|
|
949
|
+
const state = await this.checkpoint();
|
|
950
|
+
const guarded = await this.guarded(
|
|
951
|
+
target.first(),
|
|
952
|
+
guardedAction,
|
|
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
|
|
954
|
+
);
|
|
955
|
+
const handle = guarded?.handle ?? null;
|
|
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
|
+
};
|
|
446
971
|
const before = await this.inspect(target).catch(() => null);
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
972
|
+
try {
|
|
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)));
|
|
989
|
+
else if (call.operation === "browser.select") {
|
|
990
|
+
const values = await this.act(target, "selectOption", direct(() => element.selectOption(input.values, { timeout })));
|
|
991
|
+
return this.success(call.operation, { values });
|
|
992
|
+
}
|
|
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
|
+
}
|
|
998
|
+
await handle?.dispose().catch(() => void 0);
|
|
457
999
|
}
|
|
458
1000
|
const after = await this.inspect(target).catch(() => null);
|
|
459
1001
|
const inspection = after ?? before;
|
|
@@ -477,9 +1019,10 @@ var PlaywrightOperationExecutor = class {
|
|
|
477
1019
|
}
|
|
478
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;
|
|
479
1021
|
let timer;
|
|
1022
|
+
const scope = new ScriptScope();
|
|
480
1023
|
try {
|
|
481
1024
|
const value = await Promise.race([
|
|
482
|
-
run(this.page, args),
|
|
1025
|
+
run(scope.wrap(this.page), args),
|
|
483
1026
|
new Promise((_resolve, reject) => {
|
|
484
1027
|
timer = setTimeout(() => reject(new BrowserActionRefusal("FAILED", `The script exceeded ${timeout}ms.` + (notice ? ` ${notice}` : ""))), timeout);
|
|
485
1028
|
})
|
|
@@ -490,7 +1033,7 @@ var PlaywrightOperationExecutor = class {
|
|
|
490
1033
|
});
|
|
491
1034
|
} finally {
|
|
492
1035
|
clearTimeout(timer);
|
|
493
|
-
this.page
|
|
1036
|
+
await scope.close(this.page);
|
|
494
1037
|
}
|
|
495
1038
|
}
|
|
496
1039
|
async target() {
|
|
@@ -521,6 +1064,17 @@ function operationName(raw) {
|
|
|
521
1064
|
const candidate = raw && typeof raw === "object" && !Array.isArray(raw) ? raw.operation : void 0;
|
|
522
1065
|
return typeof candidate === "string" && BROWSER_OPERATION_NAMES.includes(candidate) ? candidate : "browser.status";
|
|
523
1066
|
}
|
|
1067
|
+
function bind(locator) {
|
|
1068
|
+
return {
|
|
1069
|
+
click: locator.click.bind(locator),
|
|
1070
|
+
fill: locator.fill.bind(locator),
|
|
1071
|
+
press: locator.press.bind(locator),
|
|
1072
|
+
hover: locator.hover.bind(locator),
|
|
1073
|
+
check: locator.check.bind(locator),
|
|
1074
|
+
uncheck: locator.uncheck.bind(locator),
|
|
1075
|
+
selectOption: locator.selectOption.bind(locator)
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
524
1078
|
export {
|
|
525
1079
|
ACTION_TIMEOUT_MS,
|
|
526
1080
|
PLAYWRIGHT_FEATURES,
|