@nexface/tools 0.1.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/browser/controller.d.ts +30 -0
- package/dist/browser/controller.js +783 -0
- package/dist/browser/controller.js.map +1 -0
- package/dist/browser/dom.d.ts +43 -0
- package/dist/browser/dom.js +438 -0
- package/dist/browser/dom.js.map +1 -0
- package/dist/browser/feedback.d.ts +4 -0
- package/dist/browser/feedback.js +5 -0
- package/dist/browser/feedback.js.map +1 -0
- package/dist/browser/locator.d.ts +19 -0
- package/dist/browser/locator.js +277 -0
- package/dist/browser/locator.js.map +1 -0
- package/dist/browser/prompt.d.ts +5 -0
- package/dist/browser/prompt.generated.d.ts +1 -0
- package/dist/browser/prompt.generated.js +50 -0
- package/dist/browser/prompt.generated.js.map +1 -0
- package/dist/browser/prompt.js +9 -0
- package/dist/browser/prompt.js.map +1 -0
- package/dist/browser/snapshot.d.ts +7 -0
- package/dist/browser/snapshot.js +424 -0
- package/dist/browser/snapshot.js.map +1 -0
- package/dist/browser/toolset.d.ts +35 -0
- package/dist/browser/toolset.js +383 -0
- package/dist/browser/toolset.js.map +1 -0
- package/dist/browser/types.d.ts +129 -0
- package/dist/browser/types.js +2 -0
- package/dist/browser/types.js.map +1 -0
- package/dist/browser/worker-runner.d.ts +11 -0
- package/dist/browser/worker-runner.js +400 -0
- package/dist/browser/worker-runner.js.map +1 -0
- package/dist/definition.d.ts +16 -0
- package/dist/definition.js +72 -0
- package/dist/definition.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/internal-react.d.ts +12 -0
- package/dist/internal-react.js +23 -0
- package/dist/internal-react.js.map +1 -0
- package/dist/internal-types.d.ts +11 -0
- package/dist/internal-types.js +2 -0
- package/dist/internal-types.js.map +1 -0
- package/dist/internal.d.ts +9 -0
- package/dist/internal.js +5 -0
- package/dist/internal.js.map +1 -0
- package/dist/react.d.ts +25 -0
- package/dist/react.js +258 -0
- package/dist/react.js.map +1 -0
- package/dist/runtime.d.ts +93 -0
- package/dist/runtime.js +492 -0
- package/dist/runtime.js.map +1 -0
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +46 -0
- package/dist/schema.js.map +1 -0
- package/dist/types.d.ts +80 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/webmcp.d.ts +2 -0
- package/dist/webmcp.js +73 -0
- package/dist/webmcp.js.map +1 -0
- package/package.json +26 -0
- package/src/browser/controller.ts +984 -0
- package/src/browser/dom.ts +507 -0
- package/src/browser/feedback.ts +5 -0
- package/src/browser/locator.ts +383 -0
- package/src/browser/prompt.generated.ts +50 -0
- package/src/browser/prompt.ts +10 -0
- package/src/browser/snapshot.ts +542 -0
- package/src/browser/toolset.ts +522 -0
- package/src/browser/types.ts +172 -0
- package/src/browser/worker-runner.ts +468 -0
- package/src/definition.ts +115 -0
- package/src/index.ts +35 -0
- package/src/internal-react.tsx +62 -0
- package/src/internal-types.ts +12 -0
- package/src/internal.ts +36 -0
- package/src/react.tsx +397 -0
- package/src/runtime.ts +722 -0
- package/src/schema.ts +52 -0
- package/src/types.ts +123 -0
- package/src/webmcp.ts +102 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
import { createIdentity, createScrollableIdentity, getElementScrollability, normalizeText, } from "./dom.js";
|
|
2
|
+
import { PageCommandError, findElementHitTarget, prepareLocatorForAction, prepareRefForAction, requireUnique, requireValidRef, resolveLocator, waitForLocator, } from "./locator.js";
|
|
3
|
+
const LOCATOR_METHODS = new Set([
|
|
4
|
+
"click",
|
|
5
|
+
"fill",
|
|
6
|
+
"press",
|
|
7
|
+
"check",
|
|
8
|
+
"uncheck",
|
|
9
|
+
"selectOption",
|
|
10
|
+
"hover",
|
|
11
|
+
"focus",
|
|
12
|
+
"blur",
|
|
13
|
+
"scrollBy",
|
|
14
|
+
"scrollTo",
|
|
15
|
+
"scrollIntoView",
|
|
16
|
+
"exists",
|
|
17
|
+
"text",
|
|
18
|
+
"waitFor",
|
|
19
|
+
]);
|
|
20
|
+
const PAGE_METHODS = new Set([
|
|
21
|
+
"scrollBy",
|
|
22
|
+
"waitForTimeout",
|
|
23
|
+
"waitForURL",
|
|
24
|
+
]);
|
|
25
|
+
const WAIT_FOR_URL_TIMEOUT_MS = 3_000;
|
|
26
|
+
const URL_POLL_INTERVAL_MS = 50;
|
|
27
|
+
const ACTION_METHODS = new Set([
|
|
28
|
+
"click",
|
|
29
|
+
"fill",
|
|
30
|
+
"press",
|
|
31
|
+
"check",
|
|
32
|
+
"uncheck",
|
|
33
|
+
"selectOption",
|
|
34
|
+
"hover",
|
|
35
|
+
"focus",
|
|
36
|
+
"blur",
|
|
37
|
+
"scrollIntoView",
|
|
38
|
+
"scrollBy",
|
|
39
|
+
"scrollTo",
|
|
40
|
+
]);
|
|
41
|
+
function dispatchInput(element) {
|
|
42
|
+
element.dispatchEvent(new InputEvent("input", {
|
|
43
|
+
bubbles: true,
|
|
44
|
+
inputType: "insertText",
|
|
45
|
+
}));
|
|
46
|
+
element.dispatchEvent(new Event("change", { bubbles: true }));
|
|
47
|
+
}
|
|
48
|
+
function setNativeValue(element, value) {
|
|
49
|
+
const prototype = element instanceof HTMLInputElement
|
|
50
|
+
? HTMLInputElement.prototype
|
|
51
|
+
: HTMLTextAreaElement.prototype;
|
|
52
|
+
const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set;
|
|
53
|
+
setter?.call(element, value);
|
|
54
|
+
dispatchInput(element);
|
|
55
|
+
}
|
|
56
|
+
function dispatchClickSequence(element) {
|
|
57
|
+
const hit = findElementHitTarget(element);
|
|
58
|
+
if (!hit) {
|
|
59
|
+
throw new PageCommandError({
|
|
60
|
+
code: "ELEMENT_COVERED",
|
|
61
|
+
message: "Click target does not have an uncovered hit point.",
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
const { element: hitTarget, clientX, clientY, } = hit;
|
|
65
|
+
const init = {
|
|
66
|
+
bubbles: true,
|
|
67
|
+
cancelable: true,
|
|
68
|
+
composed: true,
|
|
69
|
+
view: window,
|
|
70
|
+
clientX,
|
|
71
|
+
clientY,
|
|
72
|
+
button: 0,
|
|
73
|
+
detail: 1,
|
|
74
|
+
};
|
|
75
|
+
hitTarget.dispatchEvent(new PointerEvent("pointerdown", {
|
|
76
|
+
...init,
|
|
77
|
+
buttons: 1,
|
|
78
|
+
pointerId: 1,
|
|
79
|
+
pointerType: "mouse",
|
|
80
|
+
isPrimary: true,
|
|
81
|
+
}));
|
|
82
|
+
const shouldFocus = hitTarget.dispatchEvent(new MouseEvent("mousedown", { ...init, buttons: 1 }));
|
|
83
|
+
if (shouldFocus &&
|
|
84
|
+
element instanceof HTMLElement &&
|
|
85
|
+
element.ownerDocument.activeElement !== element) {
|
|
86
|
+
element.focus({ preventScroll: true });
|
|
87
|
+
}
|
|
88
|
+
if (!element.isConnected || !hitTarget.isConnected) {
|
|
89
|
+
throw new PageCommandError({
|
|
90
|
+
code: "REF_ACTION_FAILED",
|
|
91
|
+
message: "Click target changed after mousedown.",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
hitTarget.dispatchEvent(new PointerEvent("pointerup", {
|
|
95
|
+
...init,
|
|
96
|
+
buttons: 0,
|
|
97
|
+
pointerId: 1,
|
|
98
|
+
pointerType: "mouse",
|
|
99
|
+
isPrimary: true,
|
|
100
|
+
}));
|
|
101
|
+
hitTarget.dispatchEvent(new MouseEvent("mouseup", { ...init, buttons: 0 }));
|
|
102
|
+
if (hitTarget instanceof HTMLElement) {
|
|
103
|
+
hitTarget.click();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
hitTarget.dispatchEvent(new MouseEvent("click", { ...init, buttons: 0 }));
|
|
107
|
+
}
|
|
108
|
+
function targetLabel(command) {
|
|
109
|
+
if (command.target.type === "ref")
|
|
110
|
+
return command.target.ref;
|
|
111
|
+
if (command.target.type === "locator") {
|
|
112
|
+
return JSON.stringify(command.target.locator).slice(0, 500);
|
|
113
|
+
}
|
|
114
|
+
return "page";
|
|
115
|
+
}
|
|
116
|
+
function invalidCommand(message) {
|
|
117
|
+
return new PageCommandError({ code: "INVALID_COMMAND", message });
|
|
118
|
+
}
|
|
119
|
+
function validateMatcher(value, maxChars) {
|
|
120
|
+
if (typeof value === "string") {
|
|
121
|
+
if (value.length > maxChars)
|
|
122
|
+
throw invalidCommand("Matcher is too long.");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (!value || typeof value !== "object") {
|
|
126
|
+
throw invalidCommand("Matcher must be a string or normalized regex.");
|
|
127
|
+
}
|
|
128
|
+
const regex = value;
|
|
129
|
+
if (typeof regex.source !== "string" ||
|
|
130
|
+
regex.source.length > maxChars ||
|
|
131
|
+
typeof regex.flags !== "string" ||
|
|
132
|
+
!/^[dgimsuvy]*$/.test(regex.flags)) {
|
|
133
|
+
throw invalidCommand("Regex matcher is invalid.");
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
new RegExp(regex.source, regex.flags);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
throw invalidCommand("Regex matcher could not be compiled.");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function validateLocator(descriptor, maxChars) {
|
|
143
|
+
if (!descriptor ||
|
|
144
|
+
typeof descriptor !== "object" ||
|
|
145
|
+
!Array.isArray(descriptor.steps) ||
|
|
146
|
+
descriptor.steps.length === 0) {
|
|
147
|
+
throw invalidCommand("Locator must contain at least one step.");
|
|
148
|
+
}
|
|
149
|
+
if (descriptor.steps.length > 12) {
|
|
150
|
+
throw invalidCommand("Locator contains too many steps.");
|
|
151
|
+
}
|
|
152
|
+
for (const step of descriptor.steps) {
|
|
153
|
+
if (step.kind === "role") {
|
|
154
|
+
if (!step.role || step.role.length > 80) {
|
|
155
|
+
throw invalidCommand("Locator role is invalid.");
|
|
156
|
+
}
|
|
157
|
+
if (step.options?.name !== undefined) {
|
|
158
|
+
validateMatcher(step.options.name, maxChars);
|
|
159
|
+
}
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (step.kind === "text" || step.kind === "label") {
|
|
163
|
+
validateMatcher(step.text, maxChars);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (step.kind === "filter") {
|
|
167
|
+
validateMatcher(step.hasText, maxChars);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
throw invalidCommand("Locator contains an unsupported step.");
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function validateArgs(command, maxChars) {
|
|
174
|
+
if (!Array.isArray(command.args)) {
|
|
175
|
+
throw invalidCommand("Command args must be an array.");
|
|
176
|
+
}
|
|
177
|
+
let serialized;
|
|
178
|
+
try {
|
|
179
|
+
serialized = JSON.stringify(command.args);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
throw invalidCommand("Command args must be serializable.");
|
|
183
|
+
}
|
|
184
|
+
if (serialized.length > maxChars) {
|
|
185
|
+
throw invalidCommand("Command args are too large.");
|
|
186
|
+
}
|
|
187
|
+
if (command.method === "fill" || command.method === "press") {
|
|
188
|
+
if (command.args.length !== 1 || typeof command.args[0] !== "string") {
|
|
189
|
+
throw invalidCommand(`${command.method}() requires one string argument.`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (command.method === "selectOption") {
|
|
193
|
+
if (command.args.length !== 1 ||
|
|
194
|
+
(typeof command.args[0] !== "string" &&
|
|
195
|
+
!Array.isArray(command.args[0]))) {
|
|
196
|
+
throw invalidCommand("selectOption() requires a string or string array.");
|
|
197
|
+
}
|
|
198
|
+
if (Array.isArray(command.args[0]) &&
|
|
199
|
+
command.args[0].some((item) => typeof item !== "string")) {
|
|
200
|
+
throw invalidCommand("selectOption() array values must be strings.");
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (command.method === "waitFor") {
|
|
204
|
+
const options = command.args[0];
|
|
205
|
+
if (command.args.length > 1 ||
|
|
206
|
+
(options !== undefined &&
|
|
207
|
+
(typeof options !== "object" || options === null || Array.isArray(options)))) {
|
|
208
|
+
throw invalidCommand("waitFor() options are invalid.");
|
|
209
|
+
}
|
|
210
|
+
const value = options;
|
|
211
|
+
if (value?.state !== undefined &&
|
|
212
|
+
!["visible", "hidden", "attached"].includes(String(value.state))) {
|
|
213
|
+
throw invalidCommand("waitFor() state is invalid.");
|
|
214
|
+
}
|
|
215
|
+
if (value?.timeout !== undefined &&
|
|
216
|
+
(typeof value.timeout !== "number" ||
|
|
217
|
+
!Number.isFinite(value.timeout) ||
|
|
218
|
+
value.timeout < 0)) {
|
|
219
|
+
throw invalidCommand("waitFor() timeout is invalid.");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (command.method === "waitForTimeout") {
|
|
223
|
+
if (command.args.length !== 1 ||
|
|
224
|
+
typeof command.args[0] !== "number" ||
|
|
225
|
+
!Number.isFinite(command.args[0]) ||
|
|
226
|
+
command.args[0] < 0) {
|
|
227
|
+
throw invalidCommand("waitForTimeout() requires one non-negative number.");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (command.method === "waitForURL") {
|
|
231
|
+
const expected = command.args[0];
|
|
232
|
+
const options = command.args[1];
|
|
233
|
+
if (command.args.length < 1 ||
|
|
234
|
+
command.args.length > 2 ||
|
|
235
|
+
typeof expected !== "string" ||
|
|
236
|
+
expected.length === 0 ||
|
|
237
|
+
(options !== undefined &&
|
|
238
|
+
(typeof options !== "object" || options === null || Array.isArray(options)))) {
|
|
239
|
+
throw invalidCommand("waitForURL() requires a URL string and optional timeout options.");
|
|
240
|
+
}
|
|
241
|
+
const value = options;
|
|
242
|
+
if (value &&
|
|
243
|
+
(Object.keys(value).some((key) => key !== "timeout") ||
|
|
244
|
+
(value.timeout !== undefined &&
|
|
245
|
+
(typeof value.timeout !== "number" ||
|
|
246
|
+
!Number.isFinite(value.timeout) ||
|
|
247
|
+
value.timeout < 0)))) {
|
|
248
|
+
throw invalidCommand("waitForURL() options are invalid.");
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (command.method === "scrollBy") {
|
|
252
|
+
const value = command.args[0];
|
|
253
|
+
if (command.args.length !== 1 ||
|
|
254
|
+
!value ||
|
|
255
|
+
typeof value !== "object" ||
|
|
256
|
+
Array.isArray(value) ||
|
|
257
|
+
Object.keys(value).length === 0 ||
|
|
258
|
+
Object.keys(value).some((key) => !["x", "y"].includes(key)) ||
|
|
259
|
+
(value.x !== undefined &&
|
|
260
|
+
(typeof value.x !== "number" || !Number.isFinite(value.x))) ||
|
|
261
|
+
(value.y !== undefined &&
|
|
262
|
+
(typeof value.y !== "number" || !Number.isFinite(value.y)))) {
|
|
263
|
+
throw invalidCommand("scrollBy() requires finite numeric x/y values.");
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (command.method === "scrollTo") {
|
|
267
|
+
const value = command.args[0];
|
|
268
|
+
if (command.args.length !== 1 ||
|
|
269
|
+
!value ||
|
|
270
|
+
typeof value !== "object" ||
|
|
271
|
+
Array.isArray(value) ||
|
|
272
|
+
Object.keys(value).length === 0 ||
|
|
273
|
+
Object.keys(value).some((key) => !["left", "top"].includes(key)) ||
|
|
274
|
+
(value.left !== undefined &&
|
|
275
|
+
(typeof value.left !== "number" || !Number.isFinite(value.left))) ||
|
|
276
|
+
(value.top !== undefined &&
|
|
277
|
+
(typeof value.top !== "number" || !Number.isFinite(value.top)))) {
|
|
278
|
+
throw invalidCommand("scrollTo() requires finite numeric left/top values.");
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async function waitAnimationFrames(count, signal) {
|
|
283
|
+
for (let index = 0; index < count; index += 1) {
|
|
284
|
+
signal.throwIfAborted();
|
|
285
|
+
await new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
|
286
|
+
}
|
|
287
|
+
signal.throwIfAborted();
|
|
288
|
+
}
|
|
289
|
+
export class PageController {
|
|
290
|
+
limits;
|
|
291
|
+
animator;
|
|
292
|
+
executions = new Map();
|
|
293
|
+
constructor(limits, animator) {
|
|
294
|
+
this.limits = limits;
|
|
295
|
+
this.animator = animator;
|
|
296
|
+
}
|
|
297
|
+
beginExecution(executionId, registry) {
|
|
298
|
+
this.executions.set(executionId, {
|
|
299
|
+
abortController: new AbortController(),
|
|
300
|
+
callCount: 0,
|
|
301
|
+
completedActions: [],
|
|
302
|
+
lastSeq: 0,
|
|
303
|
+
registry,
|
|
304
|
+
status: "running",
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
cancelExecution(executionId) {
|
|
308
|
+
const record = this.executions.get(executionId);
|
|
309
|
+
if (!record)
|
|
310
|
+
return;
|
|
311
|
+
record.status = "cancelled";
|
|
312
|
+
record.abortController.abort();
|
|
313
|
+
}
|
|
314
|
+
completeExecution(executionId) {
|
|
315
|
+
const record = this.executions.get(executionId);
|
|
316
|
+
if (!record)
|
|
317
|
+
return { completedActions: [] };
|
|
318
|
+
record.status = "completed";
|
|
319
|
+
record.abortController.abort();
|
|
320
|
+
this.executions.delete(executionId);
|
|
321
|
+
return {
|
|
322
|
+
completedActions: [...record.completedActions].sort((a, b) => a.callIndex - b.callIndex),
|
|
323
|
+
...(record.failedCall ? { failedCall: record.failedCall } : {}),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
requireActive(command) {
|
|
327
|
+
if (!command ||
|
|
328
|
+
typeof command !== "object" ||
|
|
329
|
+
typeof command.executionId !== "string" ||
|
|
330
|
+
!Number.isSafeInteger(command.commandSeq) ||
|
|
331
|
+
command.commandSeq <= 0) {
|
|
332
|
+
throw invalidCommand("Command envelope is invalid.");
|
|
333
|
+
}
|
|
334
|
+
const record = this.executions.get(command.executionId);
|
|
335
|
+
if (!record ||
|
|
336
|
+
record.status !== "running" ||
|
|
337
|
+
record.abortController.signal.aborted) {
|
|
338
|
+
throw new PageCommandError({
|
|
339
|
+
code: "EXECUTION_CANCELLED",
|
|
340
|
+
message: "The execution is no longer active.",
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
if (command.commandSeq <= record.lastSeq) {
|
|
344
|
+
throw invalidCommand("Command sequence must be strictly increasing.");
|
|
345
|
+
}
|
|
346
|
+
record.lastSeq = command.commandSeq;
|
|
347
|
+
record.callCount += 1;
|
|
348
|
+
if (record.callCount > this.limits.maxPageCalls) {
|
|
349
|
+
throw invalidCommand(`Page API call budget exceeded (${this.limits.maxPageCalls}).`);
|
|
350
|
+
}
|
|
351
|
+
this.validateCommand(command);
|
|
352
|
+
return record;
|
|
353
|
+
}
|
|
354
|
+
validateCommand(command) {
|
|
355
|
+
if (!command.target ||
|
|
356
|
+
typeof command.target !== "object" ||
|
|
357
|
+
!["page", "ref", "locator"].includes(command.target.type) ||
|
|
358
|
+
typeof command.method !== "string") {
|
|
359
|
+
throw invalidCommand("Command target or method is invalid.");
|
|
360
|
+
}
|
|
361
|
+
if (command.target.type === "page") {
|
|
362
|
+
if (!PAGE_METHODS.has(command.method)) {
|
|
363
|
+
throw invalidCommand(`Unsupported page method: ${command.method}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
if (!LOCATOR_METHODS.has(command.method)) {
|
|
368
|
+
throw invalidCommand(`Unsupported locator method: ${command.method}`);
|
|
369
|
+
}
|
|
370
|
+
if (command.target.type === "ref") {
|
|
371
|
+
if (!/^@\d+$/.test(command.target.ref)) {
|
|
372
|
+
throw invalidCommand("Snapshot ref is invalid.");
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
validateLocator(command.target.locator, this.limits.maxArgumentChars);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
validateArgs(command, this.limits.maxArgumentChars);
|
|
380
|
+
}
|
|
381
|
+
async execute(command) {
|
|
382
|
+
let record = this.executions.get(command.executionId);
|
|
383
|
+
try {
|
|
384
|
+
record = this.requireActive(command);
|
|
385
|
+
const value = command.target.type === "page"
|
|
386
|
+
? await this.executePage(command, record)
|
|
387
|
+
: await this.executeTarget(command, record);
|
|
388
|
+
if (ACTION_METHODS.has(command.method) &&
|
|
389
|
+
!record.completedActions.some((action) => action.callIndex === command.commandSeq)) {
|
|
390
|
+
this.recordCompletedAction(record, command);
|
|
391
|
+
}
|
|
392
|
+
return { ok: true, value };
|
|
393
|
+
}
|
|
394
|
+
catch (error) {
|
|
395
|
+
if (record && !record.failedCall) {
|
|
396
|
+
const target = targetLabel(command);
|
|
397
|
+
record.failedCall = {
|
|
398
|
+
callIndex: command.commandSeq,
|
|
399
|
+
method: command.method,
|
|
400
|
+
...(target ? { target } : {}),
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
if (error instanceof PageCommandError) {
|
|
404
|
+
return { ok: false, error: error.detail };
|
|
405
|
+
}
|
|
406
|
+
return {
|
|
407
|
+
ok: false,
|
|
408
|
+
error: {
|
|
409
|
+
code: "UNSUPPORTED_OPERATION",
|
|
410
|
+
message: error instanceof Error ? error.message : String(error),
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
async executePage(command, record) {
|
|
416
|
+
if (command.method === "scrollBy") {
|
|
417
|
+
const value = (command.args[0] ?? {});
|
|
418
|
+
window.scrollBy({
|
|
419
|
+
left: Number(value.x ?? 0),
|
|
420
|
+
top: Number(value.y ?? 0),
|
|
421
|
+
behavior: "auto",
|
|
422
|
+
});
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
if (command.method === "waitForTimeout") {
|
|
426
|
+
const requested = Number(command.args[0] ?? 0);
|
|
427
|
+
const duration = Math.max(0, Math.min(requested, this.limits.singleActionTimeoutMs));
|
|
428
|
+
await new Promise((resolve, reject) => {
|
|
429
|
+
const onAbort = () => {
|
|
430
|
+
window.clearTimeout(timer);
|
|
431
|
+
reject(new PageCommandError({
|
|
432
|
+
code: "EXECUTION_CANCELLED",
|
|
433
|
+
message: "Execution was cancelled while waiting.",
|
|
434
|
+
}));
|
|
435
|
+
};
|
|
436
|
+
const timer = window.setTimeout(() => {
|
|
437
|
+
record.abortController.signal.removeEventListener("abort", onAbort);
|
|
438
|
+
resolve();
|
|
439
|
+
}, duration);
|
|
440
|
+
record.abortController.signal.addEventListener("abort", onAbort, {
|
|
441
|
+
once: true,
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
if (command.method === "waitForURL") {
|
|
447
|
+
let expectedUrl;
|
|
448
|
+
try {
|
|
449
|
+
expectedUrl = new URL(String(command.args[0]), location.href).href;
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
throw invalidCommand("waitForURL() URL is invalid.");
|
|
453
|
+
}
|
|
454
|
+
const options = (command.args[1] ?? {});
|
|
455
|
+
const timeout = Math.min(options.timeout ?? WAIT_FOR_URL_TIMEOUT_MS, WAIT_FOR_URL_TIMEOUT_MS, this.limits.singleActionTimeoutMs);
|
|
456
|
+
if (location.href === expectedUrl)
|
|
457
|
+
return null;
|
|
458
|
+
await new Promise((resolve, reject) => {
|
|
459
|
+
let settled = false;
|
|
460
|
+
const cleanup = () => {
|
|
461
|
+
window.clearInterval(interval);
|
|
462
|
+
window.clearTimeout(timer);
|
|
463
|
+
record.abortController.signal.removeEventListener("abort", onAbort);
|
|
464
|
+
};
|
|
465
|
+
const settle = (error) => {
|
|
466
|
+
if (settled)
|
|
467
|
+
return;
|
|
468
|
+
settled = true;
|
|
469
|
+
cleanup();
|
|
470
|
+
if (error)
|
|
471
|
+
reject(error);
|
|
472
|
+
else
|
|
473
|
+
resolve();
|
|
474
|
+
};
|
|
475
|
+
const onAbort = () => settle(new PageCommandError({
|
|
476
|
+
code: "EXECUTION_CANCELLED",
|
|
477
|
+
message: "Execution was cancelled while waiting for the URL.",
|
|
478
|
+
}));
|
|
479
|
+
const interval = window.setInterval(() => {
|
|
480
|
+
if (location.href === expectedUrl)
|
|
481
|
+
settle();
|
|
482
|
+
}, URL_POLL_INTERVAL_MS);
|
|
483
|
+
const timer = window.setTimeout(() => settle(new PageCommandError({
|
|
484
|
+
code: "ACTION_TIMEOUT",
|
|
485
|
+
message: `URL did not become ${expectedUrl} within ${timeout}ms.`,
|
|
486
|
+
})), timeout);
|
|
487
|
+
record.abortController.signal.addEventListener("abort", onAbort, {
|
|
488
|
+
once: true,
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
throw invalidCommand(`Unsupported page method: ${command.method}`);
|
|
494
|
+
}
|
|
495
|
+
resolveRef(command, record) {
|
|
496
|
+
if (command.target.type !== "ref")
|
|
497
|
+
return undefined;
|
|
498
|
+
return record.registry.refs.get(command.target.ref);
|
|
499
|
+
}
|
|
500
|
+
locator(command) {
|
|
501
|
+
return command.target.type === "locator"
|
|
502
|
+
? command.target.locator
|
|
503
|
+
: undefined;
|
|
504
|
+
}
|
|
505
|
+
async executeTarget(command, record) {
|
|
506
|
+
const signal = record.abortController.signal;
|
|
507
|
+
const ref = this.resolveRef(command, record);
|
|
508
|
+
const locator = this.locator(command);
|
|
509
|
+
if (command.method === "exists") {
|
|
510
|
+
if (ref) {
|
|
511
|
+
try {
|
|
512
|
+
requireValidRef(ref);
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return locator ? resolveLocator(locator).length > 0 : false;
|
|
520
|
+
}
|
|
521
|
+
if (command.method === "waitFor") {
|
|
522
|
+
const options = (command.args[0] ?? {});
|
|
523
|
+
if (!locator) {
|
|
524
|
+
requireValidRef(ref);
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
await waitForLocator(locator, options.state ?? "visible", Math.min(options.timeout ?? this.limits.singleActionTimeoutMs, this.limits.singleActionTimeoutMs), signal);
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
if (command.method === "text") {
|
|
531
|
+
const element = ref
|
|
532
|
+
? requireValidRef(ref)
|
|
533
|
+
: locator
|
|
534
|
+
? requireUnique(locator)
|
|
535
|
+
: undefined;
|
|
536
|
+
if (!element)
|
|
537
|
+
throw invalidCommand("Target is missing.");
|
|
538
|
+
if (element instanceof HTMLInputElement ||
|
|
539
|
+
element instanceof HTMLTextAreaElement ||
|
|
540
|
+
element instanceof HTMLSelectElement) {
|
|
541
|
+
return element.value;
|
|
542
|
+
}
|
|
543
|
+
return normalizeText(element.textContent);
|
|
544
|
+
}
|
|
545
|
+
if (command.method === "scrollIntoView") {
|
|
546
|
+
const element = ref
|
|
547
|
+
? requireValidRef(ref)
|
|
548
|
+
: locator
|
|
549
|
+
? requireUnique(locator)
|
|
550
|
+
: undefined;
|
|
551
|
+
if (!element)
|
|
552
|
+
throw invalidCommand("Target is missing.");
|
|
553
|
+
const beforeIdentity = createIdentity(element);
|
|
554
|
+
element.scrollIntoView({
|
|
555
|
+
block: "center",
|
|
556
|
+
inline: "nearest",
|
|
557
|
+
behavior: "auto",
|
|
558
|
+
});
|
|
559
|
+
this.recordCompletedAction(record, command);
|
|
560
|
+
await new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
|
561
|
+
signal.throwIfAborted();
|
|
562
|
+
if (!element.isConnected || createIdentity(element) !== beforeIdentity) {
|
|
563
|
+
throw new PageCommandError({
|
|
564
|
+
code: "REF_ACTION_FAILED",
|
|
565
|
+
message: "Target changed while scrolling.",
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
if (command.method === "scrollBy" || command.method === "scrollTo") {
|
|
571
|
+
const element = ref
|
|
572
|
+
? ref.element
|
|
573
|
+
: locator
|
|
574
|
+
? requireUnique(locator)
|
|
575
|
+
: undefined;
|
|
576
|
+
if (!(element instanceof HTMLElement)) {
|
|
577
|
+
throw new PageCommandError({
|
|
578
|
+
code: "UNSUPPORTED_OPERATION",
|
|
579
|
+
message: "Scroll target is not an HTML element.",
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
if (ref?.kind !== "scrollable") {
|
|
583
|
+
if (ref) {
|
|
584
|
+
throw new PageCommandError({
|
|
585
|
+
code: "UNSUPPORTED_OPERATION",
|
|
586
|
+
message: "Snapshot ref is not a scrollable container.",
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
const identity = createScrollableIdentity(element);
|
|
591
|
+
if (!element.isConnected || (ref && identity !== ref.identity)) {
|
|
592
|
+
throw new PageCommandError({
|
|
593
|
+
code: "REF_ACTION_FAILED",
|
|
594
|
+
message: "Scrollable target changed after the snapshot.",
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
const scrollability = getElementScrollability(element);
|
|
598
|
+
if (!scrollability) {
|
|
599
|
+
throw new PageCommandError({
|
|
600
|
+
code: "UNSUPPORTED_OPERATION",
|
|
601
|
+
message: "Target does not currently have a scroll range.",
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
this.requireStillActive(record);
|
|
605
|
+
if (command.method === "scrollBy") {
|
|
606
|
+
const value = command.args[0];
|
|
607
|
+
if ((value.x !== undefined && value.x !== 0 && !scrollability.x) ||
|
|
608
|
+
(value.y !== undefined && value.y !== 0 && !scrollability.y)) {
|
|
609
|
+
throw new PageCommandError({
|
|
610
|
+
code: "UNSUPPORTED_OPERATION",
|
|
611
|
+
message: "Requested axis does not have a scroll range.",
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
element.scrollBy({
|
|
615
|
+
left: Number(value.x ?? 0),
|
|
616
|
+
top: Number(value.y ?? 0),
|
|
617
|
+
behavior: "auto",
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
const value = command.args[0];
|
|
622
|
+
if ((value.left !== undefined &&
|
|
623
|
+
value.left !== element.scrollLeft &&
|
|
624
|
+
!scrollability.x) ||
|
|
625
|
+
(value.top !== undefined &&
|
|
626
|
+
value.top !== element.scrollTop &&
|
|
627
|
+
!scrollability.y)) {
|
|
628
|
+
throw new PageCommandError({
|
|
629
|
+
code: "UNSUPPORTED_OPERATION",
|
|
630
|
+
message: "Requested axis does not have a scroll range.",
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
element.scrollTo({
|
|
634
|
+
left: Number(value.left ?? element.scrollLeft),
|
|
635
|
+
top: Number(value.top ?? element.scrollTop),
|
|
636
|
+
behavior: "auto",
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
this.recordCompletedAction(record, command);
|
|
640
|
+
await waitAnimationFrames(2, signal);
|
|
641
|
+
if (!element.isConnected || createScrollableIdentity(element) !== identity) {
|
|
642
|
+
throw new PageCommandError({
|
|
643
|
+
code: "REF_ACTION_FAILED",
|
|
644
|
+
message: "Scrollable target changed while scrolling.",
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
const element = ref
|
|
650
|
+
? await prepareRefForAction(ref, signal)
|
|
651
|
+
: locator
|
|
652
|
+
? (await waitForLocator(locator, "visible", this.limits.singleActionTimeoutMs, signal),
|
|
653
|
+
await prepareLocatorForAction(locator, signal))
|
|
654
|
+
: undefined;
|
|
655
|
+
if (!element)
|
|
656
|
+
throw invalidCommand("Target is missing.");
|
|
657
|
+
this.requireStillActive(record);
|
|
658
|
+
this.animator.before(command.method, element);
|
|
659
|
+
this.requireStillActive(record);
|
|
660
|
+
const result = this.performAction(element, command);
|
|
661
|
+
this.recordCompletedAction(record, command);
|
|
662
|
+
this.animator.after(command.method);
|
|
663
|
+
return result;
|
|
664
|
+
}
|
|
665
|
+
recordCompletedAction(record, command) {
|
|
666
|
+
if (record.completedActions.some((action) => action.callIndex === command.commandSeq)) {
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
const target = targetLabel(command);
|
|
670
|
+
record.completedActions.push({
|
|
671
|
+
callIndex: command.commandSeq,
|
|
672
|
+
method: command.method,
|
|
673
|
+
...(target ? { target } : {}),
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
requireStillActive(record) {
|
|
677
|
+
if (record.status !== "running" ||
|
|
678
|
+
record.abortController.signal.aborted) {
|
|
679
|
+
throw new PageCommandError({
|
|
680
|
+
code: "EXECUTION_CANCELLED",
|
|
681
|
+
message: "Execution was cancelled before the DOM action.",
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
performAction(element, command) {
|
|
686
|
+
if (command.method === "click") {
|
|
687
|
+
dispatchClickSequence(element);
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
if (command.method === "fill") {
|
|
691
|
+
const value = String(command.args[0] ?? "");
|
|
692
|
+
if ((element instanceof HTMLInputElement ||
|
|
693
|
+
element instanceof HTMLTextAreaElement) &&
|
|
694
|
+
!element.readOnly) {
|
|
695
|
+
element.focus({ preventScroll: true });
|
|
696
|
+
setNativeValue(element, value);
|
|
697
|
+
return null;
|
|
698
|
+
}
|
|
699
|
+
if (element instanceof HTMLElement && element.isContentEditable) {
|
|
700
|
+
element.focus({ preventScroll: true });
|
|
701
|
+
element.textContent = value;
|
|
702
|
+
dispatchInput(element);
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
throw new PageCommandError({
|
|
706
|
+
code: "NOT_EDITABLE",
|
|
707
|
+
message: "fill() target is not editable.",
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
if (command.method === "press") {
|
|
711
|
+
const key = String(command.args[0] ?? "");
|
|
712
|
+
element.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true }));
|
|
713
|
+
element.dispatchEvent(new KeyboardEvent("keyup", { key, bubbles: true }));
|
|
714
|
+
return null;
|
|
715
|
+
}
|
|
716
|
+
if (command.method === "check" || command.method === "uncheck") {
|
|
717
|
+
const checked = command.method === "check";
|
|
718
|
+
if (element instanceof HTMLInputElement &&
|
|
719
|
+
["checkbox", "radio"].includes(element.type)) {
|
|
720
|
+
if (element.type === "radio" && !checked) {
|
|
721
|
+
throw new PageCommandError({
|
|
722
|
+
code: "UNSUPPORTED_OPERATION",
|
|
723
|
+
message: "Native radio elements cannot be unchecked directly.",
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
if (element.checked !== checked) {
|
|
727
|
+
dispatchClickSequence(element);
|
|
728
|
+
}
|
|
729
|
+
return null;
|
|
730
|
+
}
|
|
731
|
+
if (element.getAttribute("aria-checked") !== null) {
|
|
732
|
+
const current = element.getAttribute("aria-checked") === "true";
|
|
733
|
+
if (current !== checked)
|
|
734
|
+
dispatchClickSequence(element);
|
|
735
|
+
return null;
|
|
736
|
+
}
|
|
737
|
+
throw new PageCommandError({
|
|
738
|
+
code: "UNSUPPORTED_OPERATION",
|
|
739
|
+
message: `${command.method}() target is not checkable.`,
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
if (command.method === "selectOption") {
|
|
743
|
+
if (!(element instanceof HTMLSelectElement)) {
|
|
744
|
+
throw new PageCommandError({
|
|
745
|
+
code: "UNSUPPORTED_OPERATION",
|
|
746
|
+
message: "selectOption() currently supports native select elements.",
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
const requested = command.args[0];
|
|
750
|
+
const values = Array.isArray(requested)
|
|
751
|
+
? requested.map(String)
|
|
752
|
+
: [String(requested ?? "")];
|
|
753
|
+
const availableValues = new Set(Array.from(element.options, (option) => option.value));
|
|
754
|
+
const missing = values.filter((value) => !availableValues.has(value));
|
|
755
|
+
if (missing.length > 0) {
|
|
756
|
+
throw new PageCommandError({
|
|
757
|
+
code: "NOT_FOUND",
|
|
758
|
+
message: `selectOption() values were not found: ${missing.join(", ")}`,
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
for (const option of element.options) {
|
|
762
|
+
option.selected = values.includes(option.value);
|
|
763
|
+
}
|
|
764
|
+
dispatchInput(element);
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
767
|
+
if (command.method === "hover") {
|
|
768
|
+
element.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, composed: true }));
|
|
769
|
+
element.dispatchEvent(new MouseEvent("mouseenter", { bubbles: false, composed: true }));
|
|
770
|
+
return null;
|
|
771
|
+
}
|
|
772
|
+
if (command.method === "focus") {
|
|
773
|
+
element.focus();
|
|
774
|
+
return null;
|
|
775
|
+
}
|
|
776
|
+
if (command.method === "blur") {
|
|
777
|
+
element.blur();
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
780
|
+
throw invalidCommand(`Unsupported locator method: ${command.method}`);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
//# sourceMappingURL=controller.js.map
|