@volter-ai-dev/supercode-browser-playwright 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -28
- package/dist/executor.d.ts +42 -3
- package/dist/executor.mjs +397 -64
- package/dist/executor.mjs.map +4 -4
- package/dist/guard.d.ts +63 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.mjs +397 -64
- package/dist/index.mjs.map +4 -4
- package/dist/script-scope.d.ts +27 -0
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -6,6 +6,8 @@ var UnidentifiedTarget = class extends Error {
|
|
|
6
6
|
};
|
|
7
7
|
var HostElementTarget = class extends Error {
|
|
8
8
|
};
|
|
9
|
+
var DocumentChanged = class extends Error {
|
|
10
|
+
};
|
|
9
11
|
var WORLD = "supercode-guard";
|
|
10
12
|
var GROUP = "supercode-guard";
|
|
11
13
|
var MAX_NODES = 8;
|
|
@@ -20,6 +22,15 @@ function findInWorld(query) {
|
|
|
20
22
|
}
|
|
21
23
|
return false;
|
|
22
24
|
};
|
|
25
|
+
const frame = (element) => /^(IFRAME|FRAME|OBJECT|EMBED)$/.test(element.tagName);
|
|
26
|
+
if (query.kind === "focus") {
|
|
27
|
+
let element = document.activeElement ?? document.body;
|
|
28
|
+
while (element?.shadowRoot?.activeElement) element = element.shadowRoot.activeElement;
|
|
29
|
+
if (!element) return "Nothing has focus.";
|
|
30
|
+
if (frame(element)) return "Focus is inside an embedded frame, which the guard cannot inspect.";
|
|
31
|
+
if (onHost(element)) return "host";
|
|
32
|
+
return [element];
|
|
33
|
+
}
|
|
23
34
|
if (query.kind === "point") {
|
|
24
35
|
let element = document.elementFromPoint(query.x, query.y);
|
|
25
36
|
if (!element) return "Nothing is at that point.";
|
|
@@ -30,24 +41,43 @@ function findInWorld(query) {
|
|
|
30
41
|
element = inner;
|
|
31
42
|
if (onHost(element)) return "host";
|
|
32
43
|
}
|
|
33
|
-
if (
|
|
34
|
-
return "The point is inside an embedded frame, which the guard cannot inspect.";
|
|
44
|
+
if (frame(element)) return "The point is inside an embedded frame, which the guard cannot inspect.";
|
|
35
45
|
return [element];
|
|
36
46
|
}
|
|
37
47
|
const { box } = query;
|
|
48
|
+
const marked = [];
|
|
38
49
|
const found = [];
|
|
39
50
|
const near = (a, b) => Math.abs(a - b) <= 1;
|
|
40
51
|
const visit = (root) => {
|
|
41
52
|
for (const element of Array.from(root.querySelectorAll("*"))) {
|
|
42
|
-
if (found.length
|
|
53
|
+
if (found.length > query.max - 1) return;
|
|
43
54
|
const rect = element.getBoundingClientRect();
|
|
55
|
+
if (element.hasAttribute(query.marker)) {
|
|
56
|
+
marked.push(element);
|
|
57
|
+
element.removeAttribute(query.marker);
|
|
58
|
+
}
|
|
44
59
|
if (near(rect.x, box.x) && near(rect.y, box.y) && near(rect.width, box.width) && near(rect.height, box.height))
|
|
45
60
|
found.push(element);
|
|
46
61
|
if (element.shadowRoot) visit(element.shadowRoot);
|
|
47
62
|
}
|
|
48
63
|
};
|
|
64
|
+
const cap = query.max;
|
|
65
|
+
query.max = Number.MAX_SAFE_INTEGER;
|
|
49
66
|
visit(document);
|
|
50
|
-
|
|
67
|
+
query.max = cap;
|
|
68
|
+
if (marked.length !== 1 || !found.includes(marked[0]))
|
|
69
|
+
return "The element the action targets could not be confirmed at its place on the page.";
|
|
70
|
+
if (!found.length)
|
|
71
|
+
return "The element could not be found in the page's top document (it may be inside an embedded frame or a closed shadow root).";
|
|
72
|
+
if (found.length >= query.max)
|
|
73
|
+
return `More than ${query.max - 1} elements share the target's box, so which one the action lands on cannot be told.`;
|
|
74
|
+
if (found.some(frame)) return "The target is an embedded frame, whose content the guard cannot inspect.";
|
|
75
|
+
for (const element of [...found]) {
|
|
76
|
+
const label = element.closest("label");
|
|
77
|
+
const control = label ? label.control : null;
|
|
78
|
+
if (control && !found.includes(control)) found.push(control);
|
|
79
|
+
}
|
|
80
|
+
return found;
|
|
51
81
|
}
|
|
52
82
|
var BrowserSideTargets = class {
|
|
53
83
|
constructor(page, host) {
|
|
@@ -55,20 +85,150 @@ var BrowserSideTargets = class {
|
|
|
55
85
|
this.host = host;
|
|
56
86
|
}
|
|
57
87
|
session = null;
|
|
58
|
-
/**
|
|
88
|
+
/** A main-frame navigation has been requested or is loading and has not finished. */
|
|
89
|
+
navigating = false;
|
|
90
|
+
mainFrame = "";
|
|
91
|
+
/**
|
|
92
|
+
* The element a resolved handle is, scrolled into view. It must be in the
|
|
93
|
+
* page's main frame, and it must be one of the elements found browser-side
|
|
94
|
+
* at its box (the handle is marked with a one-off attribute the isolated
|
|
95
|
+
* world looks for, then removes).
|
|
96
|
+
*/
|
|
59
97
|
async ofHandle(handle, timeout) {
|
|
98
|
+
if (await handle.ownerFrame() !== this.page.mainFrame())
|
|
99
|
+
throw new UnidentifiedTarget("The element is inside an embedded frame, which the guard cannot inspect.");
|
|
60
100
|
await handle.scrollIntoViewIfNeeded({ timeout });
|
|
61
101
|
const box = await handle.boundingBox();
|
|
62
102
|
if (!box || box.width === 0 || box.height === 0)
|
|
63
103
|
throw new UnidentifiedTarget("The element has no box on the page.");
|
|
64
|
-
|
|
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;
|
|
65
171
|
}
|
|
66
|
-
/**
|
|
172
|
+
/**
|
|
173
|
+
* The element at a viewport point, as best known. A point on the host's own
|
|
174
|
+
* interface is refused; a point whose element cannot be identified is
|
|
175
|
+
* returned with no nodes and the reason, for a guard that decides on raw
|
|
176
|
+
* pointer input regardless.
|
|
177
|
+
*/
|
|
67
178
|
async atPoint(x, y) {
|
|
68
|
-
|
|
179
|
+
try {
|
|
180
|
+
return { ...await this.resolve({ kind: "point", x, y, host: this.host }), point: { x, y } };
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (error instanceof HostElementTarget) throw error;
|
|
183
|
+
return {
|
|
184
|
+
url: this.page.url(),
|
|
185
|
+
nodes: [],
|
|
186
|
+
ancestors: [],
|
|
187
|
+
point: { x, y },
|
|
188
|
+
unidentified: error instanceof Error ? error.message : String(error)
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** The main frame's document now. */
|
|
193
|
+
async documentState() {
|
|
194
|
+
const cdp = await this.cdp();
|
|
195
|
+
const tree = await cdp.send("Page.getFrameTree");
|
|
196
|
+
this.mainFrame = tree.frameTree.frame.id;
|
|
197
|
+
return { loaderId: tree.frameTree.frame.loaderId, url: tree.frameTree.frame.url };
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Just before input is dispatched: the main frame must still hold the
|
|
201
|
+
* document the target was checked on, with no navigation in flight.
|
|
202
|
+
*/
|
|
203
|
+
async sameDocument(checked) {
|
|
204
|
+
const now = await this.documentState();
|
|
205
|
+
if (this.navigating) throw new DocumentChanged("The page is navigating, so nothing was sent; ask again once it has loaded.");
|
|
206
|
+
if (now.loaderId !== checked.loaderId || now.url !== checked.url)
|
|
207
|
+
throw new DocumentChanged("The page changed since the action was checked, so nothing was sent; ask again.");
|
|
69
208
|
}
|
|
70
209
|
cdp() {
|
|
71
|
-
this.session ??= this.page.context().newCDPSession(this.page).
|
|
210
|
+
this.session ??= this.page.context().newCDPSession(this.page).then(async (session) => {
|
|
211
|
+
const main2 = (frameId) => !this.mainFrame || frameId === this.mainFrame;
|
|
212
|
+
session.on("Page.frameRequestedNavigation", (event) => {
|
|
213
|
+
if (main2(event.frameId)) this.navigating = true;
|
|
214
|
+
});
|
|
215
|
+
session.on("Page.frameStartedNavigating", (event) => {
|
|
216
|
+
if (main2(event.frameId)) this.navigating = true;
|
|
217
|
+
});
|
|
218
|
+
session.on("Page.frameStartedLoading", (event) => {
|
|
219
|
+
if (main2(event.frameId)) this.navigating = true;
|
|
220
|
+
});
|
|
221
|
+
const settled = (event) => {
|
|
222
|
+
const frameId = event.frameId ?? event.frame?.id;
|
|
223
|
+
if (main2(frameId) && !event.frame?.parentId) this.navigating = false;
|
|
224
|
+
};
|
|
225
|
+
session.on("Page.frameStoppedLoading", settled);
|
|
226
|
+
session.on("Page.frameNavigated", settled);
|
|
227
|
+
session.on("Page.navigatedWithinDocument", settled);
|
|
228
|
+
session.on("Page.downloadWillBegin", settled);
|
|
229
|
+
await session.send("Page.enable").catch(() => void 0);
|
|
230
|
+
return session;
|
|
231
|
+
}, (error) => {
|
|
72
232
|
this.session = null;
|
|
73
233
|
throw error;
|
|
74
234
|
});
|
|
@@ -76,12 +236,11 @@ var BrowserSideTargets = class {
|
|
|
76
236
|
}
|
|
77
237
|
async resolve(query) {
|
|
78
238
|
const cdp = await this.cdp();
|
|
79
|
-
const
|
|
80
|
-
const world = await cdp.send("Page.createIsolatedWorld", { frameId: tree.frameTree.frame.id, worldName: WORLD });
|
|
239
|
+
const contextId = await this.world(cdp);
|
|
81
240
|
try {
|
|
82
241
|
const evaluated = await cdp.send("Runtime.evaluate", {
|
|
83
242
|
expression: `(${findInWorld.toString()})(${JSON.stringify(query)})`,
|
|
84
|
-
contextId
|
|
243
|
+
contextId,
|
|
85
244
|
objectGroup: GROUP,
|
|
86
245
|
returnByValue: false
|
|
87
246
|
});
|
|
@@ -99,7 +258,14 @@ var BrowserSideTargets = class {
|
|
|
99
258
|
const ancestors = [];
|
|
100
259
|
const seen = /* @__PURE__ */ new Set();
|
|
101
260
|
for (const objectId of objects) {
|
|
102
|
-
const described = await cdp.send("DOM.describeNode", { objectId });
|
|
261
|
+
const described = await cdp.send("DOM.describeNode", { objectId, depth: 1, pierce: true });
|
|
262
|
+
if (query.kind === "focus" && described.node.shadowRoots?.some((root) => root.shadowRootType === "closed"))
|
|
263
|
+
throw new UnidentifiedTarget("Focus is inside a closed shadow root, which the guard cannot inspect.");
|
|
264
|
+
const facts = await cdp.send("Runtime.callFunctionOn", {
|
|
265
|
+
objectId,
|
|
266
|
+
functionDeclaration: "function () { return { form: !!(this.form || (this.closest && this.closest('form'))), text: (this.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 80) }; }",
|
|
267
|
+
returnByValue: true
|
|
268
|
+
});
|
|
103
269
|
const attributes = {};
|
|
104
270
|
const list = described.node.attributes ?? [];
|
|
105
271
|
for (let index = 0; index + 1 < list.length; index += 2) attributes[list[index].toLowerCase()] = list[index + 1];
|
|
@@ -109,7 +275,9 @@ var BrowserSideTargets = class {
|
|
|
109
275
|
tag: described.node.nodeName.toLowerCase(),
|
|
110
276
|
attributes,
|
|
111
277
|
role: ax.self?.role ?? "",
|
|
112
|
-
name: ax.self?.name ?? ""
|
|
278
|
+
name: ax.self?.name ?? "",
|
|
279
|
+
inForm: facts.result.value?.form === true,
|
|
280
|
+
...typeof facts.result.value?.text === "string" && facts.result.value.text ? { text: facts.result.value.text } : {}
|
|
113
281
|
});
|
|
114
282
|
seen.add(described.node.backendNodeId);
|
|
115
283
|
for (const ancestor of ax.ancestors) {
|
|
@@ -137,6 +305,78 @@ var BrowserSideTargets = class {
|
|
|
137
305
|
}
|
|
138
306
|
};
|
|
139
307
|
|
|
308
|
+
// src/script-scope.ts
|
|
309
|
+
function isData(value) {
|
|
310
|
+
if (Array.isArray(value) || ArrayBuffer.isView(value) || value instanceof ArrayBuffer || value instanceof Date || value instanceof RegExp || value instanceof Map || value instanceof Set || value instanceof Promise || value instanceof Error) return true;
|
|
311
|
+
const prototype = Object.getPrototypeOf(value);
|
|
312
|
+
return prototype === Object.prototype || prototype === null;
|
|
313
|
+
}
|
|
314
|
+
var ScriptScope = class {
|
|
315
|
+
closed = false;
|
|
316
|
+
proxies = /* @__PURE__ */ new WeakMap();
|
|
317
|
+
targets = /* @__PURE__ */ new WeakMap();
|
|
318
|
+
disposables = [];
|
|
319
|
+
/** The membrane's view of `value`. */
|
|
320
|
+
wrap(value) {
|
|
321
|
+
if (value === null || typeof value !== "object" && typeof value !== "function") return value;
|
|
322
|
+
const object = value;
|
|
323
|
+
if (typeof value === "object" && isData(object)) return value;
|
|
324
|
+
const existing = this.proxies.get(object);
|
|
325
|
+
if (existing) return existing;
|
|
326
|
+
const scope = this;
|
|
327
|
+
const proxy = new Proxy(object, {
|
|
328
|
+
get(target, property) {
|
|
329
|
+
scope.check();
|
|
330
|
+
const member = Reflect.get(target, property, target);
|
|
331
|
+
if (typeof member !== "function") return scope.wrap(member);
|
|
332
|
+
return function(...args) {
|
|
333
|
+
scope.check();
|
|
334
|
+
return scope.returned(Reflect.apply(member, target, args.map((arg) => scope.unwrap(arg))));
|
|
335
|
+
};
|
|
336
|
+
},
|
|
337
|
+
set() {
|
|
338
|
+
throw new Error("A script cannot change Playwright's objects.");
|
|
339
|
+
},
|
|
340
|
+
apply(target, _self, args) {
|
|
341
|
+
scope.check();
|
|
342
|
+
return scope.returned(Reflect.apply(target, void 0, args.map((arg) => scope.unwrap(arg))));
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
this.proxies.set(object, proxy);
|
|
346
|
+
this.targets.set(proxy, object);
|
|
347
|
+
return proxy;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Ends the script's hold on the page: later calls through the membrane
|
|
351
|
+
* throw, and what it installed is removed.
|
|
352
|
+
*/
|
|
353
|
+
async close(page) {
|
|
354
|
+
this.closed = true;
|
|
355
|
+
for (const disposable of this.disposables.splice(0)) {
|
|
356
|
+
try {
|
|
357
|
+
await disposable.dispose();
|
|
358
|
+
} catch {
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
await page.unrouteAll({ behavior: "ignoreErrors" }).catch(() => void 0);
|
|
362
|
+
await page.context().unrouteAll({ behavior: "ignoreErrors" }).catch(() => void 0);
|
|
363
|
+
page.removeAllListeners();
|
|
364
|
+
page.context().removeAllListeners();
|
|
365
|
+
}
|
|
366
|
+
check() {
|
|
367
|
+
if (this.closed) throw new Error("The script has ended or was cancelled; it can no longer use the page.");
|
|
368
|
+
}
|
|
369
|
+
unwrap(value) {
|
|
370
|
+
return value !== null && (typeof value === "object" || typeof value === "function") ? this.targets.get(value) ?? value : value;
|
|
371
|
+
}
|
|
372
|
+
returned(value) {
|
|
373
|
+
if (value instanceof Promise) return value.then((resolved) => this.returned(resolved));
|
|
374
|
+
if (value !== null && typeof value === "object" && typeof value.dispose === "function")
|
|
375
|
+
this.disposables.push(value);
|
|
376
|
+
return this.wrap(value);
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
|
|
140
380
|
// src/protocol.ts
|
|
141
381
|
var SUPERCODE_BROWSER_PROVIDER_PROTOCOL = "supercode/browser-provider-v1";
|
|
142
382
|
var SUPERCODE_BROWSER_OPERATION_PROTOCOL = "supercode/browser-operation-v1";
|
|
@@ -566,7 +806,12 @@ var PlaywrightOperationExecutor = class {
|
|
|
566
806
|
options;
|
|
567
807
|
ready;
|
|
568
808
|
targets;
|
|
569
|
-
/**
|
|
809
|
+
/**
|
|
810
|
+
* Where the mouse is: `page.mouse` keeps it, but does not report it. A
|
|
811
|
+
* locator action moves it to the element's clickable point; it is then the
|
|
812
|
+
* centre of that element's box as measured after the action, or unknown
|
|
813
|
+
* (null) when that cannot be measured, and a press needs coordinates.
|
|
814
|
+
*/
|
|
570
815
|
mouse = { x: 0, y: 0 };
|
|
571
816
|
constructor(page, options) {
|
|
572
817
|
this.page = page;
|
|
@@ -664,22 +909,63 @@ var PlaywrightOperationExecutor = class {
|
|
|
664
909
|
}
|
|
665
910
|
if (!handle) throw new BrowserActionRefusal("NOT_FOUND", `${action} found no element for the locator.`);
|
|
666
911
|
try {
|
|
667
|
-
await
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
});
|
|
912
|
+
const target = await this.describe(() => this.targets.ofHandle(handle, ACTION_TIMEOUT_MS));
|
|
913
|
+
const described = typeof value === "function" ? await value(target) : value;
|
|
914
|
+
await guard({ action, target, ...described !== void 0 ? { value: described } : {} });
|
|
915
|
+
return { handle, target };
|
|
672
916
|
} catch (error) {
|
|
673
917
|
await handle.dispose().catch(() => void 0);
|
|
674
918
|
throw error;
|
|
675
919
|
}
|
|
676
|
-
return handle;
|
|
677
920
|
}
|
|
678
|
-
/**
|
|
679
|
-
async
|
|
921
|
+
/** The document an action is checked on; input later goes only to it. */
|
|
922
|
+
async checkpoint() {
|
|
923
|
+
if (!this.options.actionGuard) return null;
|
|
924
|
+
try {
|
|
925
|
+
return await this.targets.documentState();
|
|
926
|
+
} catch (error) {
|
|
927
|
+
throw new BrowserActionRefusal("UNSUPPORTED", `The page's document could not be identified, so nothing was sent: ${errorMessage(error)}`);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
/** Just before input: the same document, no navigation in flight, and the host's own last check. */
|
|
931
|
+
async dispatching(state) {
|
|
932
|
+
if (!state) return;
|
|
933
|
+
try {
|
|
934
|
+
await this.targets.sameDocument(state);
|
|
935
|
+
} catch (error) {
|
|
936
|
+
if (error instanceof DocumentChanged) throw new BrowserActionRefusal("STALE_PAGE", error.message);
|
|
937
|
+
throw new BrowserActionRefusal("UNSUPPORTED", `The page's document could not be confirmed, so nothing was sent: ${errorMessage(error)}`);
|
|
938
|
+
}
|
|
939
|
+
await this.options.beforeInput?.({ url: state.url });
|
|
940
|
+
}
|
|
941
|
+
/** The page-level action's guard. */
|
|
942
|
+
async guardPage(action, value) {
|
|
943
|
+
if (this.options.actionGuard)
|
|
944
|
+
await this.options.actionGuard({ action, url: this.page.url(), ...value !== void 0 ? { value } : {} });
|
|
945
|
+
}
|
|
946
|
+
/** The pointer's position for a press that names none; unknown after a locator action. */
|
|
947
|
+
pointer(input) {
|
|
948
|
+
if (typeof input.x === "number" && typeof input.y === "number") return { x: input.x, y: input.y };
|
|
949
|
+
if (!this.mouse)
|
|
950
|
+
throw new BrowserActionRefusal(
|
|
951
|
+
"INVALID_INPUT",
|
|
952
|
+
"Where the mouse is after a locator action is not known exactly; give x and y, or move the mouse first."
|
|
953
|
+
);
|
|
954
|
+
return this.mouse;
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Asks the host's policy about raw pointer input at a viewport point; the
|
|
958
|
+
* target is the element there as best known (`unidentified` when not).
|
|
959
|
+
*/
|
|
960
|
+
async guardPoint(action, x, y, value) {
|
|
680
961
|
const guard = this.options.actionGuard;
|
|
681
962
|
if (!guard) return;
|
|
682
|
-
await guard({
|
|
963
|
+
await guard({
|
|
964
|
+
action,
|
|
965
|
+
target: await this.describe(() => this.targets.atPoint(x, y)),
|
|
966
|
+
pointer: true,
|
|
967
|
+
...value !== void 0 ? { value } : {}
|
|
968
|
+
});
|
|
683
969
|
}
|
|
684
970
|
/** A browser-side description, or the refusal that replaces the action. */
|
|
685
971
|
async describe(run) {
|
|
@@ -759,6 +1045,9 @@ var PlaywrightOperationExecutor = class {
|
|
|
759
1045
|
}
|
|
760
1046
|
if (call.operation === "browser.scroll") {
|
|
761
1047
|
const direction = input.direction;
|
|
1048
|
+
const state2 = await this.checkpoint();
|
|
1049
|
+
await this.guardPage("scroll", { direction, ...typeof input.amount === "number" ? { amount: input.amount } : {} });
|
|
1050
|
+
await this.dispatching(state2);
|
|
762
1051
|
const position = await page.evaluate(scrollInPage, {
|
|
763
1052
|
left: direction === "left" ? -1 : direction === "right" ? 1 : 0,
|
|
764
1053
|
top: direction === "up" ? -1 : direction === "down" ? 1 : 0,
|
|
@@ -769,43 +1058,50 @@ var PlaywrightOperationExecutor = class {
|
|
|
769
1058
|
if (call.operation === "browser.script") return this.script(call);
|
|
770
1059
|
if (call.operation === "browser.mouse") {
|
|
771
1060
|
const action = input.action;
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
1061
|
+
if (action === "move") {
|
|
1062
|
+
const at2 = { x: input.x, y: input.y };
|
|
1063
|
+
await page.mouse.move(at2.x, at2.y);
|
|
1064
|
+
this.mouse = at2;
|
|
1065
|
+
return this.success(call.operation, { action, x: at2.x, y: at2.y });
|
|
1066
|
+
}
|
|
1067
|
+
const at = this.pointer(input);
|
|
1068
|
+
const state2 = await this.checkpoint();
|
|
1069
|
+
await this.guardPoint(action === "click" ? "click" : action === "down" ? "mousedown" : "mouseup", at.x, at.y);
|
|
1070
|
+
await this.dispatching(state2);
|
|
1071
|
+
await page.mouse.move(at.x, at.y);
|
|
1072
|
+
if (action === "click") await page.mouse.click(at.x, at.y);
|
|
777
1073
|
else if (action === "down") await page.mouse.down();
|
|
778
1074
|
else await page.mouse.up();
|
|
779
|
-
|
|
780
|
-
return this.success(call.operation, { action,
|
|
1075
|
+
this.mouse = at;
|
|
1076
|
+
return this.success(call.operation, { action, x: at.x, y: at.y });
|
|
781
1077
|
}
|
|
782
1078
|
if (call.operation === "browser.wheel") {
|
|
783
1079
|
const deltaX = typeof input.deltaX === "number" ? input.deltaX : 0;
|
|
784
1080
|
const deltaY = typeof input.deltaY === "number" ? input.deltaY : 0;
|
|
785
|
-
|
|
1081
|
+
const at = this.pointer(input);
|
|
1082
|
+
const state2 = await this.checkpoint();
|
|
1083
|
+
await this.guardPoint("wheel", at.x, at.y, { deltaX, deltaY });
|
|
1084
|
+
await this.dispatching(state2);
|
|
1085
|
+
await page.mouse.move(at.x, at.y);
|
|
786
1086
|
await page.mouse.wheel(deltaX, deltaY);
|
|
1087
|
+
this.mouse = at;
|
|
787
1088
|
return this.success(call.operation, { deltaX, deltaY });
|
|
788
1089
|
}
|
|
789
1090
|
if (call.operation === "browser.drag") {
|
|
790
1091
|
const resolve = async (endpoint) => {
|
|
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
|
-
}
|
|
1092
|
+
if (endpoint.locator === void 0) return { x: endpoint.x, y: endpoint.y };
|
|
796
1093
|
await this.refreshRefs(endpoint.locator);
|
|
797
1094
|
const locator2 = this.locator(endpoint.locator).first();
|
|
798
|
-
const
|
|
799
|
-
|
|
800
|
-
|
|
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
|
-
}
|
|
1095
|
+
const box = await locator2.boundingBox({ timeout: ACTION_TIMEOUT_MS }).catch(() => null);
|
|
1096
|
+
if (!box) throw new BrowserActionRefusal("NOT_FOUND", "The drag locator matched no element.");
|
|
1097
|
+
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
|
806
1098
|
};
|
|
807
1099
|
const from = await resolve(input.from);
|
|
808
1100
|
const to = await resolve(input.to);
|
|
1101
|
+
const drop = await this.describe(() => this.targets.atPoint(to.x, to.y));
|
|
1102
|
+
const state2 = await this.checkpoint();
|
|
1103
|
+
await this.guardPoint("drag", from.x, from.y, { from, to, drop });
|
|
1104
|
+
await this.dispatching(state2);
|
|
809
1105
|
await page.mouse.move(from.x, from.y);
|
|
810
1106
|
await page.mouse.down();
|
|
811
1107
|
await page.mouse.move(to.x, to.y, { steps: typeof input.steps === "number" ? input.steps : 8 });
|
|
@@ -814,14 +1110,23 @@ var PlaywrightOperationExecutor = class {
|
|
|
814
1110
|
return this.success(call.operation, { from, to });
|
|
815
1111
|
}
|
|
816
1112
|
if (call.operation === "browser.back") {
|
|
1113
|
+
const state2 = await this.checkpoint();
|
|
1114
|
+
await this.guardPage("back");
|
|
1115
|
+
await this.dispatching(state2);
|
|
817
1116
|
await page.goBack({ waitUntil: "commit", timeout: ACTION_TIMEOUT_MS });
|
|
818
1117
|
return this.success(call.operation, { requested: true });
|
|
819
1118
|
}
|
|
820
1119
|
if (call.operation === "browser.forward") {
|
|
1120
|
+
const state2 = await this.checkpoint();
|
|
1121
|
+
await this.guardPage("forward");
|
|
1122
|
+
await this.dispatching(state2);
|
|
821
1123
|
await page.goForward({ waitUntil: "commit", timeout: ACTION_TIMEOUT_MS });
|
|
822
1124
|
return this.success(call.operation, { requested: true });
|
|
823
1125
|
}
|
|
824
1126
|
if (call.operation === "browser.reload") {
|
|
1127
|
+
const state2 = await this.checkpoint();
|
|
1128
|
+
await this.guardPage("reload");
|
|
1129
|
+
await this.dispatching(state2);
|
|
825
1130
|
await page.reload({ waitUntil: "commit", timeout: ACTION_TIMEOUT_MS });
|
|
826
1131
|
return this.success(call.operation, { requested: true });
|
|
827
1132
|
}
|
|
@@ -834,37 +1139,64 @@ var PlaywrightOperationExecutor = class {
|
|
|
834
1139
|
return this.success(call.operation, box);
|
|
835
1140
|
}
|
|
836
1141
|
if (call.operation === "browser.press" && !locator) {
|
|
837
|
-
const
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
await handle2?.dispose().catch(() => void 0);
|
|
843
|
-
}
|
|
1142
|
+
const state2 = await this.checkpoint();
|
|
1143
|
+
if (this.options.actionGuard)
|
|
1144
|
+
await this.options.actionGuard({ action: "press", target: await this.describe(() => this.targets.focused()), value: input.key });
|
|
1145
|
+
await this.dispatching(state2);
|
|
1146
|
+
await page.keyboard.press(input.key);
|
|
844
1147
|
return this.success(call.operation, { key: input.key });
|
|
845
1148
|
}
|
|
846
1149
|
const target = locator;
|
|
847
1150
|
const guardedAction = call.operation === "browser.select" ? "select" : call.operation.slice("browser.".length);
|
|
848
|
-
const
|
|
1151
|
+
const state = await this.checkpoint();
|
|
1152
|
+
const guarded = await this.guarded(
|
|
849
1153
|
target.first(),
|
|
850
1154
|
guardedAction,
|
|
851
|
-
call.operation === "browser.fill" ? input.value : call.operation === "browser.press" ? input.key : call.operation === "browser.select" ? input.values : void 0
|
|
1155
|
+
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
|
|
852
1156
|
);
|
|
1157
|
+
const handle = guarded?.handle ?? null;
|
|
853
1158
|
const element = handle ?? { ...bind(target), focus: () => target.focus({ timeout }) };
|
|
1159
|
+
const press = async () => {
|
|
1160
|
+
if (!guarded) {
|
|
1161
|
+
await element.press(input.key, { timeout });
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
await guarded.handle.focus();
|
|
1165
|
+
if (!await this.targets.focusWithin(guarded.target))
|
|
1166
|
+
throw new BrowserActionRefusal(
|
|
1167
|
+
"UNSUPPORTED",
|
|
1168
|
+
"Focusing the element left focus elsewhere, so the key would go to another element; the press was refused."
|
|
1169
|
+
);
|
|
1170
|
+
await this.dispatching(state);
|
|
1171
|
+
await page.keyboard.press(input.key);
|
|
1172
|
+
};
|
|
854
1173
|
const before = await this.inspect(target).catch(() => null);
|
|
855
1174
|
try {
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
1175
|
+
const pointer = (run) => async () => {
|
|
1176
|
+
if (guarded) await run({ timeout, trial: true });
|
|
1177
|
+
await this.dispatching(state);
|
|
1178
|
+
await run({ timeout });
|
|
1179
|
+
};
|
|
1180
|
+
const direct = (run) => async () => {
|
|
1181
|
+
await this.dispatching(state);
|
|
1182
|
+
return await run();
|
|
1183
|
+
};
|
|
1184
|
+
if (call.operation === "browser.click") await this.act(target, "click", pointer((options) => element.click(options)));
|
|
1185
|
+
else if (call.operation === "browser.fill") await this.act(target, "fill", direct(() => element.fill(input.value, { timeout })));
|
|
1186
|
+
else if (call.operation === "browser.press") await this.act(target, "press", press);
|
|
1187
|
+
else if (call.operation === "browser.hover") await this.act(target, "hover", pointer((options) => element.hover(options)));
|
|
1188
|
+
else if (call.operation === "browser.focus") await this.act(target, "focus", direct(() => element.focus()));
|
|
1189
|
+
else if (call.operation === "browser.check") await this.act(target, "check", pointer((options) => element.check(options)));
|
|
1190
|
+
else if (call.operation === "browser.uncheck") await this.act(target, "uncheck", pointer((options) => element.uncheck(options)));
|
|
863
1191
|
else if (call.operation === "browser.select") {
|
|
864
|
-
const values = await this.act(target, "selectOption", () => element.selectOption(input.values, { timeout }));
|
|
1192
|
+
const values = await this.act(target, "selectOption", direct(() => element.selectOption(input.values, { timeout })));
|
|
865
1193
|
return this.success(call.operation, { values });
|
|
866
1194
|
}
|
|
867
1195
|
} finally {
|
|
1196
|
+
if (["browser.click", "browser.hover", "browser.check", "browser.uncheck"].includes(call.operation)) {
|
|
1197
|
+
const box = await (handle ?? target).boundingBox().catch(() => null);
|
|
1198
|
+
this.mouse = box ? { x: box.x + box.width / 2, y: box.y + box.height / 2 } : null;
|
|
1199
|
+
}
|
|
868
1200
|
await handle?.dispose().catch(() => void 0);
|
|
869
1201
|
}
|
|
870
1202
|
const after = await this.inspect(target).catch(() => null);
|
|
@@ -889,9 +1221,10 @@ var PlaywrightOperationExecutor = class {
|
|
|
889
1221
|
}
|
|
890
1222
|
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;
|
|
891
1223
|
let timer;
|
|
1224
|
+
const scope = new ScriptScope();
|
|
892
1225
|
try {
|
|
893
1226
|
const value = await Promise.race([
|
|
894
|
-
run(this.page, args),
|
|
1227
|
+
run(scope.wrap(this.page), args),
|
|
895
1228
|
new Promise((_resolve, reject) => {
|
|
896
1229
|
timer = setTimeout(() => reject(new BrowserActionRefusal("FAILED", `The script exceeded ${timeout}ms.` + (notice ? ` ${notice}` : ""))), timeout);
|
|
897
1230
|
})
|
|
@@ -902,7 +1235,7 @@ var PlaywrightOperationExecutor = class {
|
|
|
902
1235
|
});
|
|
903
1236
|
} finally {
|
|
904
1237
|
clearTimeout(timer);
|
|
905
|
-
this.page
|
|
1238
|
+
await scope.close(this.page);
|
|
906
1239
|
}
|
|
907
1240
|
}
|
|
908
1241
|
async target() {
|