@vostride/agent-qa-web 0.1.21 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +2725 -772
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +139 -20
- package/dist/index.d.ts +139 -20
- package/dist/index.js +2731 -766
- package/dist/index.js.map +1 -1
- package/package.json +7 -4
package/dist/index.js
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
// src/adapter.ts
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
assertNever,
|
|
4
|
+
buildScreenState as buildScreenState3,
|
|
5
|
+
createSnapshotLiveStream,
|
|
6
|
+
LIVE_KEEPALIVE_QUIET_MS
|
|
7
|
+
} from "@vostride/agent-qa-runner";
|
|
8
|
+
import { convertKeysForPlatform, isMacPlatform } from "@vostride/agent-qa-runner";
|
|
9
|
+
|
|
10
|
+
// src/observer.ts
|
|
11
|
+
import { buildScreenState } from "@vostride/agent-qa-runner";
|
|
3
12
|
|
|
4
13
|
// src/dom-extractor.ts
|
|
5
14
|
var SKIP_TAGS = /* @__PURE__ */ new Set([
|
|
@@ -112,59 +121,87 @@ var CONTENT_ROLES = /* @__PURE__ */ new Set([
|
|
|
112
121
|
"region",
|
|
113
122
|
"navigation",
|
|
114
123
|
"progressbar",
|
|
115
|
-
"tabpanel"
|
|
124
|
+
"tabpanel",
|
|
125
|
+
"row",
|
|
126
|
+
"dialog",
|
|
127
|
+
"alertdialog"
|
|
116
128
|
]);
|
|
129
|
+
var LIMIT_KEYS = ["valuenow", "valuemin", "valuemax"];
|
|
117
130
|
var LINE_PATTERN = /^(\s*-\s*)(\w+)(?:\s+"([^"]*)")?(.*)$/;
|
|
118
131
|
var QUOTED_LINE_PATTERN = /^(\s*-\s*)'(\w+)(?:\s+"([^"]*)")?(.*)'\s*$/;
|
|
132
|
+
var REF_ANNOTATION = /\[ref=([A-Za-z0-9_-]+)\]/;
|
|
133
|
+
var BRACKET_ATTR = /\[(\w+)(?:=([^\]]*))?\]/g;
|
|
134
|
+
var INLINE_TEXT = /^(?:\s*\[[^\]]*\])*\s*:\s*(.*)$/;
|
|
135
|
+
function inlineText(suffix) {
|
|
136
|
+
const raw = suffix?.match(INLINE_TEXT)?.[1]?.trim();
|
|
137
|
+
if (!raw) return void 0;
|
|
138
|
+
if (raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"')) {
|
|
139
|
+
try {
|
|
140
|
+
const parsed = JSON.parse(raw);
|
|
141
|
+
if (typeof parsed === "string") return parsed;
|
|
142
|
+
} catch {
|
|
143
|
+
return raw.slice(1, -1);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return raw;
|
|
147
|
+
}
|
|
148
|
+
function appendText(node, text) {
|
|
149
|
+
const tidy2 = text?.replace(/\s+/g, " ").trim();
|
|
150
|
+
if (!tidy2) return;
|
|
151
|
+
node.text = node.text === void 0 ? tidy2 : `${node.text} ${tidy2}`;
|
|
152
|
+
}
|
|
119
153
|
function parseAriaSnapshot(ariaTree) {
|
|
120
154
|
const lines = ariaTree.split("\n");
|
|
121
|
-
const
|
|
122
|
-
const
|
|
123
|
-
const
|
|
124
|
-
let refCounter = 0;
|
|
125
|
-
const roleNameCounts = /* @__PURE__ */ new Map();
|
|
126
|
-
const refToKey = /* @__PURE__ */ new Map();
|
|
155
|
+
const nodes = [];
|
|
156
|
+
const nativeRefs = [];
|
|
157
|
+
const ancestors = [];
|
|
127
158
|
for (const line of lines) {
|
|
128
159
|
const match = line.match(LINE_PATTERN) || line.match(QUOTED_LINE_PATTERN);
|
|
129
|
-
if (!match)
|
|
130
|
-
|
|
131
|
-
continue;
|
|
132
|
-
}
|
|
133
|
-
const [, prefix, role, name, suffix] = match;
|
|
160
|
+
if (!match) continue;
|
|
161
|
+
const [, , role, name, suffix] = match;
|
|
134
162
|
const roleLower = role.toLowerCase();
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
163
|
+
const leadingSpaces = line.length - line.trimStart().length;
|
|
164
|
+
const depth = Math.floor(leadingSpaces / 2);
|
|
165
|
+
while (ancestors.length > 0 && ancestors[ancestors.length - 1].depth >= depth) {
|
|
166
|
+
ancestors.pop();
|
|
167
|
+
}
|
|
168
|
+
let keptAncestor;
|
|
169
|
+
for (let k = ancestors.length - 1; k >= 0; k--) {
|
|
170
|
+
if (ancestors[k].index !== void 0) {
|
|
171
|
+
keptAncestor = ancestors[k].index;
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (!INTERACTIVE_ROLES.has(roleLower) && !CONTENT_ROLES.has(roleLower)) {
|
|
176
|
+
if (keptAncestor !== void 0) {
|
|
177
|
+
appendText(nodes[keptAncestor], name);
|
|
178
|
+
appendText(nodes[keptAncestor], inlineText(suffix));
|
|
179
|
+
}
|
|
180
|
+
ancestors.push({ depth });
|
|
138
181
|
continue;
|
|
139
182
|
}
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
const
|
|
143
|
-
const currentCount = roleNameCounts.get(key) ?? 0;
|
|
144
|
-
roleNameCounts.set(key, currentCount + 1);
|
|
145
|
-
refToKey.set(ref, key);
|
|
146
|
-
refs[ref] = { role: roleLower, name, nth: currentCount };
|
|
183
|
+
const parentIndex = keptAncestor;
|
|
184
|
+
const refMatch = suffix?.match(REF_ANNOTATION);
|
|
185
|
+
const nativeRef = refMatch ? refMatch[1] : void 0;
|
|
147
186
|
const attributes = {};
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
187
|
+
if (suffix) {
|
|
188
|
+
for (const am of suffix.matchAll(BRACKET_ATTR)) {
|
|
189
|
+
if (am[1] === "ref") continue;
|
|
151
190
|
attributes[am[1]] = am[2] ?? "true";
|
|
152
191
|
}
|
|
153
192
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if (
|
|
157
|
-
|
|
158
|
-
if (
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
delete refs[ref].nth;
|
|
165
|
-
}
|
|
193
|
+
const node = { role: roleLower };
|
|
194
|
+
if (name) node.name = name;
|
|
195
|
+
if (parentIndex !== void 0) node.parentIndex = parentIndex;
|
|
196
|
+
if (Object.keys(attributes).length > 0) node.attributes = attributes;
|
|
197
|
+
if ("disabled" in attributes) node.state = { enabled: false };
|
|
198
|
+
if (CONTENT_ROLES.has(roleLower)) appendText(node, inlineText(suffix));
|
|
199
|
+
const index = nodes.length;
|
|
200
|
+
nodes.push(node);
|
|
201
|
+
nativeRefs.push(nativeRef);
|
|
202
|
+
ancestors.push({ depth, index });
|
|
166
203
|
}
|
|
167
|
-
return {
|
|
204
|
+
return { nodes, nativeRefs };
|
|
168
205
|
}
|
|
169
206
|
function hideAgentQaElements() {
|
|
170
207
|
const doc = globalThis.document;
|
|
@@ -180,8 +217,133 @@ function restoreAgentQaElements() {
|
|
|
180
217
|
el.removeAttribute("aria-hidden");
|
|
181
218
|
});
|
|
182
219
|
}
|
|
220
|
+
async function captureWebScroll(page) {
|
|
221
|
+
const fallbackViewport = page.viewportSize() ?? { width: 0, height: 0 };
|
|
222
|
+
try {
|
|
223
|
+
const raw = await page.evaluate(() => {
|
|
224
|
+
const w = globalThis;
|
|
225
|
+
const docEl = w.document.documentElement;
|
|
226
|
+
return {
|
|
227
|
+
width: w.innerWidth,
|
|
228
|
+
height: w.innerHeight,
|
|
229
|
+
offsetX: w.scrollX,
|
|
230
|
+
offsetY: w.scrollY,
|
|
231
|
+
contentWidth: docEl.scrollWidth,
|
|
232
|
+
contentHeight: docEl.scrollHeight
|
|
233
|
+
};
|
|
234
|
+
});
|
|
235
|
+
if (raw && typeof raw === "object") {
|
|
236
|
+
return {
|
|
237
|
+
viewport: {
|
|
238
|
+
width: numberOr(raw.width, fallbackViewport.width),
|
|
239
|
+
height: numberOr(raw.height, fallbackViewport.height)
|
|
240
|
+
},
|
|
241
|
+
scroll: {
|
|
242
|
+
offsetX: numberOr(raw.offsetX, 0),
|
|
243
|
+
offsetY: numberOr(raw.offsetY, 0),
|
|
244
|
+
contentWidth: numberOr(raw.contentWidth, 0),
|
|
245
|
+
contentHeight: numberOr(raw.contentHeight, 0)
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
} catch {
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
viewport: fallbackViewport,
|
|
253
|
+
scroll: { offsetX: 0, offsetY: 0, contentWidth: 0, contentHeight: 0 }
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
async function enrichWebNodes(page, nodes, nativeRefs) {
|
|
257
|
+
try {
|
|
258
|
+
const handles = [];
|
|
259
|
+
const targetIndices = [];
|
|
260
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
261
|
+
const ref = nativeRefs[i];
|
|
262
|
+
if (!ref) continue;
|
|
263
|
+
try {
|
|
264
|
+
const handle = await page.locator("aria-ref=" + ref).elementHandle();
|
|
265
|
+
if (handle) {
|
|
266
|
+
handles.push(handle);
|
|
267
|
+
targetIndices.push(i);
|
|
268
|
+
}
|
|
269
|
+
} catch {
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (handles.length === 0) return;
|
|
273
|
+
const raw = await page.evaluate((els) => {
|
|
274
|
+
const w = globalThis;
|
|
275
|
+
const active = w.document.activeElement;
|
|
276
|
+
return els.map((el) => {
|
|
277
|
+
if (!el) return null;
|
|
278
|
+
const rect = typeof el.getBoundingClientRect === "function" ? el.getBoundingClientRect() : null;
|
|
279
|
+
const style = typeof w.getComputedStyle === "function" ? w.getComputedStyle(el) : null;
|
|
280
|
+
const hidden = style ? style.visibility === "hidden" || style.display === "none" : false;
|
|
281
|
+
const hasBox = !!rect && rect.width > 0 && rect.height > 0;
|
|
282
|
+
const disabled = el.disabled === true || typeof el.hasAttribute === "function" && el.hasAttribute("disabled") || typeof el.getAttribute === "function" && el.getAttribute("aria-disabled") === "true";
|
|
283
|
+
const attr = (n) => typeof el.getAttribute === "function" ? el.getAttribute(n) : null;
|
|
284
|
+
const inputType = el.tagName === "INPUT" && typeof el.type === "string" ? el.type : "";
|
|
285
|
+
const isRange = inputType === "range";
|
|
286
|
+
const isNumber = inputType === "number";
|
|
287
|
+
return {
|
|
288
|
+
bounds: rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : null,
|
|
289
|
+
enabled: !disabled,
|
|
290
|
+
focused: el === active,
|
|
291
|
+
visible: hasBox && !hidden,
|
|
292
|
+
testId: attr("data-testid"),
|
|
293
|
+
ariaLabel: attr("aria-label"),
|
|
294
|
+
placeholder: attr("placeholder"),
|
|
295
|
+
title: attr("title"),
|
|
296
|
+
valuenow: attr("aria-valuenow") ?? (isRange || isNumber ? String(el.value) : null),
|
|
297
|
+
valuemin: attr("aria-valuemin") ?? (isRange ? el.min || "0" : isNumber ? el.min || null : null),
|
|
298
|
+
valuemax: attr("aria-valuemax") ?? (isRange ? el.max || "100" : isNumber ? el.max || null : null)
|
|
299
|
+
};
|
|
300
|
+
});
|
|
301
|
+
}, handles);
|
|
302
|
+
if (!Array.isArray(raw)) return;
|
|
303
|
+
for (let k = 0; k < targetIndices.length; k++) {
|
|
304
|
+
const info = raw[k];
|
|
305
|
+
if (!info || typeof info !== "object") continue;
|
|
306
|
+
const node = nodes[targetIndices[k]];
|
|
307
|
+
const bounds = coerceBounds(info.bounds);
|
|
308
|
+
if (bounds) node.bounds = bounds;
|
|
309
|
+
const state = { ...node.state ?? {} };
|
|
310
|
+
if (typeof info.enabled === "boolean") state.enabled = info.enabled;
|
|
311
|
+
if (typeof info.focused === "boolean") state.focused = info.focused;
|
|
312
|
+
if (typeof info.visible === "boolean") state.visible = info.visible;
|
|
313
|
+
if (Object.keys(state).length > 0) node.state = state;
|
|
314
|
+
const labels = { ...node.labels ?? {} };
|
|
315
|
+
const testId = coerceString(info.testId);
|
|
316
|
+
if (testId) labels.testId = testId;
|
|
317
|
+
const ariaLabel = coerceString(info.ariaLabel);
|
|
318
|
+
if (ariaLabel) labels.ariaLabel = ariaLabel;
|
|
319
|
+
const placeholder = coerceString(info.placeholder);
|
|
320
|
+
if (placeholder) labels.placeholder = placeholder;
|
|
321
|
+
const title = coerceString(info.title);
|
|
322
|
+
if (title) labels.title = title;
|
|
323
|
+
if (Object.keys(labels).length > 0) node.labels = labels;
|
|
324
|
+
const limits = {};
|
|
325
|
+
for (const key of LIMIT_KEYS) {
|
|
326
|
+
const value = coerceString(info[key]);
|
|
327
|
+
if (value) limits[key] = value;
|
|
328
|
+
}
|
|
329
|
+
if (Object.keys(limits).length > 0) node.attributes = { ...node.attributes ?? {}, ...limits };
|
|
330
|
+
}
|
|
331
|
+
} catch {
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
function coerceBounds(value) {
|
|
335
|
+
if (!value || typeof value !== "object") return void 0;
|
|
336
|
+
const v = value;
|
|
337
|
+
const x = v.x, y = v.y, width = v.width, height = v.height;
|
|
338
|
+
if (typeof x === "number" && Number.isFinite(x) && typeof y === "number" && Number.isFinite(y) && typeof width === "number" && Number.isFinite(width) && typeof height === "number" && Number.isFinite(height)) {
|
|
339
|
+
return { x, y, width, height };
|
|
340
|
+
}
|
|
341
|
+
return void 0;
|
|
342
|
+
}
|
|
343
|
+
function coerceString(value) {
|
|
344
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
345
|
+
}
|
|
183
346
|
async function observePage(page, options) {
|
|
184
|
-
const viewport = page.viewportSize();
|
|
185
347
|
const locator = options?.filterSelector ? page.locator(options.filterSelector) : page.locator(":root");
|
|
186
348
|
await page.evaluate(hideAgentQaElements);
|
|
187
349
|
let ariaTree;
|
|
@@ -190,141 +352,9 @@ async function observePage(page, options) {
|
|
|
190
352
|
} finally {
|
|
191
353
|
await page.evaluate(restoreAgentQaElements);
|
|
192
354
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
elements: [],
|
|
197
|
-
url: page.url(),
|
|
198
|
-
timestamp: Date.now(),
|
|
199
|
-
metadata: {
|
|
200
|
-
coordSpace: "viewport",
|
|
201
|
-
viewportWidth: viewport?.width ?? 0,
|
|
202
|
-
viewportHeight: viewport?.height ?? 0,
|
|
203
|
-
refMap: {}
|
|
204
|
-
}
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
const { tree, elements, refs } = parseAriaSnapshot(ariaTree);
|
|
208
|
-
try {
|
|
209
|
-
const refEntries = Object.entries(refs);
|
|
210
|
-
if (refEntries.length > 0) {
|
|
211
|
-
const refArgs = refEntries.map(
|
|
212
|
-
([ref, data]) => [ref, data.role, data.name, data.nth]
|
|
213
|
-
);
|
|
214
|
-
const result = await Promise.race([
|
|
215
|
-
page.evaluate((args) => {
|
|
216
|
-
const doc = globalThis.document;
|
|
217
|
-
const IMPLICIT_ROLES = {
|
|
218
|
-
button: "button",
|
|
219
|
-
a: "link",
|
|
220
|
-
input: "textbox",
|
|
221
|
-
select: "combobox",
|
|
222
|
-
textarea: "textbox",
|
|
223
|
-
h1: "heading",
|
|
224
|
-
h2: "heading",
|
|
225
|
-
h3: "heading",
|
|
226
|
-
h4: "heading",
|
|
227
|
-
h5: "heading",
|
|
228
|
-
h6: "heading",
|
|
229
|
-
nav: "navigation",
|
|
230
|
-
main: "main",
|
|
231
|
-
aside: "complementary",
|
|
232
|
-
footer: "contentinfo",
|
|
233
|
-
header: "banner",
|
|
234
|
-
form: "form",
|
|
235
|
-
table: "table",
|
|
236
|
-
img: "img",
|
|
237
|
-
ul: "list",
|
|
238
|
-
ol: "list",
|
|
239
|
-
li: "listitem",
|
|
240
|
-
dialog: "dialog",
|
|
241
|
-
details: "group",
|
|
242
|
-
summary: "button",
|
|
243
|
-
progress: "progressbar",
|
|
244
|
-
meter: "meter",
|
|
245
|
-
output: "status",
|
|
246
|
-
article: "article",
|
|
247
|
-
section: "region",
|
|
248
|
-
fieldset: "group",
|
|
249
|
-
legend: "legend"
|
|
250
|
-
};
|
|
251
|
-
const INPUT_TYPE_ROLES = {
|
|
252
|
-
checkbox: "checkbox",
|
|
253
|
-
radio: "radio",
|
|
254
|
-
range: "slider",
|
|
255
|
-
number: "spinbutton",
|
|
256
|
-
search: "searchbox",
|
|
257
|
-
email: "textbox",
|
|
258
|
-
tel: "textbox",
|
|
259
|
-
url: "textbox",
|
|
260
|
-
password: "textbox"
|
|
261
|
-
};
|
|
262
|
-
function getAccessibleName(el) {
|
|
263
|
-
const label = el.getAttribute("aria-label");
|
|
264
|
-
if (label) return label.trim();
|
|
265
|
-
const labelledBy = el.getAttribute("aria-labelledby");
|
|
266
|
-
if (labelledBy) {
|
|
267
|
-
const parts = labelledBy.split(/\s+/).map((id) => {
|
|
268
|
-
const ref = doc.getElementById(id);
|
|
269
|
-
return ref ? (ref.textContent || "").trim() : "";
|
|
270
|
-
}).filter(Boolean);
|
|
271
|
-
if (parts.length) return parts.join(" ");
|
|
272
|
-
}
|
|
273
|
-
const title = el.getAttribute("title");
|
|
274
|
-
if (title) return title.trim();
|
|
275
|
-
const alt = el.getAttribute("alt");
|
|
276
|
-
if (alt) return alt.trim();
|
|
277
|
-
return (el.textContent || "").trim();
|
|
278
|
-
}
|
|
279
|
-
function matchesName(elName, targetName) {
|
|
280
|
-
return elName.toLowerCase().includes(targetName.toLowerCase());
|
|
281
|
-
}
|
|
282
|
-
function getImplicitRole(el) {
|
|
283
|
-
const tag = el.tagName.toLowerCase();
|
|
284
|
-
if (tag === "input") {
|
|
285
|
-
const type = (el.getAttribute("type") || "text").toLowerCase();
|
|
286
|
-
return INPUT_TYPE_ROLES[type] || "textbox";
|
|
287
|
-
}
|
|
288
|
-
if (tag === "a" && el.hasAttribute("href")) return "link";
|
|
289
|
-
return IMPLICIT_ROLES[tag] || null;
|
|
290
|
-
}
|
|
291
|
-
const results = [];
|
|
292
|
-
for (const [ref, role, name, nth] of args) {
|
|
293
|
-
const candidates = [];
|
|
294
|
-
doc.querySelectorAll(`[role="${role}"]`).forEach((el) => candidates.push(el));
|
|
295
|
-
doc.querySelectorAll("*").forEach((el) => {
|
|
296
|
-
if (el.getAttribute("role")) return;
|
|
297
|
-
if (getImplicitRole(el) === role) candidates.push(el);
|
|
298
|
-
});
|
|
299
|
-
let matched = candidates;
|
|
300
|
-
if (name) {
|
|
301
|
-
matched = candidates.filter((el) => matchesName(getAccessibleName(el), name));
|
|
302
|
-
}
|
|
303
|
-
const target = matched[nth ?? 0];
|
|
304
|
-
if (target) {
|
|
305
|
-
const rect = target.getBoundingClientRect();
|
|
306
|
-
results.push([ref, {
|
|
307
|
-
x: Math.round(rect.x),
|
|
308
|
-
y: Math.round(rect.y),
|
|
309
|
-
width: Math.round(rect.width),
|
|
310
|
-
height: Math.round(rect.height)
|
|
311
|
-
}]);
|
|
312
|
-
} else {
|
|
313
|
-
results.push([ref, null]);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
return results;
|
|
317
|
-
}, refArgs),
|
|
318
|
-
new Promise((resolve) => setTimeout(() => resolve(null), 3e3))
|
|
319
|
-
]);
|
|
320
|
-
if (result) {
|
|
321
|
-
for (const [ref, box] of result) {
|
|
322
|
-
if (box) refs[ref].bounds = box;
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
} catch {
|
|
327
|
-
}
|
|
355
|
+
const timestamp = Date.now();
|
|
356
|
+
const url = page.url();
|
|
357
|
+
const { viewport: capturedViewport, scroll } = await captureWebScroll(page);
|
|
328
358
|
let domContext;
|
|
329
359
|
if (options?.extractDom) {
|
|
330
360
|
try {
|
|
@@ -332,23 +362,44 @@ async function observePage(page, options) {
|
|
|
332
362
|
} catch {
|
|
333
363
|
}
|
|
334
364
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
365
|
+
const { nodes, nativeRefs } = ariaTree ? parseAriaSnapshot(ariaTree) : { nodes: [], nativeRefs: [] };
|
|
366
|
+
if (nodes.length > 0) {
|
|
367
|
+
await enrichWebNodes(page, nodes, nativeRefs);
|
|
368
|
+
}
|
|
369
|
+
const { state } = buildScreenState({
|
|
370
|
+
platform: "web",
|
|
371
|
+
nodes,
|
|
372
|
+
viewport: capturedViewport,
|
|
373
|
+
scroll,
|
|
374
|
+
coordSpace: "viewport",
|
|
375
|
+
timestamp,
|
|
376
|
+
url,
|
|
377
|
+
...domContext ? { domContext } : {}
|
|
378
|
+
});
|
|
379
|
+
return state;
|
|
380
|
+
}
|
|
381
|
+
function numberOr(value, fallback) {
|
|
382
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
348
383
|
}
|
|
349
384
|
|
|
350
385
|
// src/element-resolver.ts
|
|
351
|
-
|
|
386
|
+
import { rowTextOf } from "@vostride/agent-qa-runner";
|
|
387
|
+
var ACTIONS_WITHOUT_REF = /* @__PURE__ */ new Set(["navigate", "waitFor", "delay", "waitForUrl", "assertValue"]);
|
|
388
|
+
function buildRefMap(binding, nodes, nativeRefs, elements) {
|
|
389
|
+
const refs = {};
|
|
390
|
+
for (const [ref, bind] of Object.entries(binding)) {
|
|
391
|
+
const node = nodes[bind.index];
|
|
392
|
+
const nativeRef = nativeRefs[bind.index];
|
|
393
|
+
const element = elements[bind.index];
|
|
394
|
+
if (!node || nativeRef === void 0 || !element) continue;
|
|
395
|
+
const entry = { nativeRef, role: node.role, name: node.name ?? "" };
|
|
396
|
+
const rowText = rowTextOf(element, elements);
|
|
397
|
+
if (rowText !== void 0) entry.rowText = rowText;
|
|
398
|
+
if (node.bounds !== void 0) entry.bounds = node.bounds;
|
|
399
|
+
refs[ref] = entry;
|
|
400
|
+
}
|
|
401
|
+
return refs;
|
|
402
|
+
}
|
|
352
403
|
var ElementResolver = class {
|
|
353
404
|
constructor(page, refs) {
|
|
354
405
|
this.page = page;
|
|
@@ -359,16 +410,7 @@ var ElementResolver = class {
|
|
|
359
410
|
if (!data) {
|
|
360
411
|
throw new Error(`Unknown ref "${ref}". Available refs: ${Object.keys(this.refs).join(", ") || "none"}`);
|
|
361
412
|
}
|
|
362
|
-
|
|
363
|
-
if (data.name) {
|
|
364
|
-
locator = this.page.getByRole(data.role, { name: data.name, exact: true });
|
|
365
|
-
} else {
|
|
366
|
-
locator = this.page.getByRole(data.role);
|
|
367
|
-
}
|
|
368
|
-
if (data.nth !== void 0) {
|
|
369
|
-
locator = locator.nth(data.nth);
|
|
370
|
-
}
|
|
371
|
-
return locator;
|
|
413
|
+
return this.page.locator("aria-ref=" + data.nativeRef);
|
|
372
414
|
}
|
|
373
415
|
resolveAction(action) {
|
|
374
416
|
if (ACTIONS_WITHOUT_REF.has(action.type)) {
|
|
@@ -389,6 +431,120 @@ var ElementResolver = class {
|
|
|
389
431
|
}
|
|
390
432
|
};
|
|
391
433
|
|
|
434
|
+
// src/binding.ts
|
|
435
|
+
import { buildScreenState as buildScreenState2, rowTextOf as rowTextOf2 } from "@vostride/agent-qa-runner";
|
|
436
|
+
var BINDING_LANES = ["ref", "fromRef", "toRef"];
|
|
437
|
+
var FRAME_REF = /^f\d+e/;
|
|
438
|
+
var INVALID_FRAME = /invalid frame/i;
|
|
439
|
+
var WHAT_CHANGED = {
|
|
440
|
+
"stale-observation": "the page holds a new document since the observation",
|
|
441
|
+
"frame-detached": "the frame that held the observed element is gone",
|
|
442
|
+
"binding-lost": "the observed element is no longer on the page",
|
|
443
|
+
"binding-changed": "the observed element now has a different role, name or row"
|
|
444
|
+
};
|
|
445
|
+
function hideAgentQaElements2() {
|
|
446
|
+
const doc = globalThis.document;
|
|
447
|
+
doc.querySelectorAll("[data-agent-qa-internal]").forEach((el) => {
|
|
448
|
+
el.style.display = "none";
|
|
449
|
+
el.setAttribute("aria-hidden", "true");
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
function restoreAgentQaElements2() {
|
|
453
|
+
const doc = globalThis.document;
|
|
454
|
+
doc.querySelectorAll("[data-agent-qa-internal]").forEach((el) => {
|
|
455
|
+
el.style.display = "";
|
|
456
|
+
el.removeAttribute("aria-hidden");
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
async function snapshotRoot(page, options = {}) {
|
|
460
|
+
const { timeoutMs, retryTimeoutMs } = options;
|
|
461
|
+
await page.evaluate(hideAgentQaElements2);
|
|
462
|
+
try {
|
|
463
|
+
if (timeoutMs === void 0) return await page.locator(":root").ariaSnapshot({ mode: "ai" });
|
|
464
|
+
return await page.locator(":root").ariaSnapshot({ mode: "ai", timeout: timeoutMs }).catch((error) => {
|
|
465
|
+
if (!(error instanceof Error && error.name === "TimeoutError")) throw error;
|
|
466
|
+
return page.locator(":root").ariaSnapshot({
|
|
467
|
+
mode: "ai",
|
|
468
|
+
...retryTimeoutMs !== void 0 ? { timeout: retryTimeoutMs } : {}
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
} finally {
|
|
472
|
+
await page.evaluate(restoreAgentQaElements2);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
async function captureEpoch(page) {
|
|
476
|
+
const document = await page.evaluateHandle(() => globalThis.document);
|
|
477
|
+
return { page, document };
|
|
478
|
+
}
|
|
479
|
+
async function disposeEpoch(epoch) {
|
|
480
|
+
try {
|
|
481
|
+
await epoch?.document.dispose();
|
|
482
|
+
} catch {
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
function isChildFrameRef(nativeRef) {
|
|
486
|
+
return FRAME_REF.test(nativeRef);
|
|
487
|
+
}
|
|
488
|
+
async function checkDocument(page, epoch) {
|
|
489
|
+
if (epoch === void 0 || epoch.page !== page) return { outcome: "stale-observation" };
|
|
490
|
+
try {
|
|
491
|
+
const same = await epoch.document.evaluate((d) => d === globalThis.document);
|
|
492
|
+
return same === true ? { outcome: "ok" } : { outcome: "stale-observation" };
|
|
493
|
+
} catch {
|
|
494
|
+
return { outcome: "stale-observation" };
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
async function frameDetached(page, nativeRef) {
|
|
498
|
+
if (!isChildFrameRef(nativeRef)) return false;
|
|
499
|
+
try {
|
|
500
|
+
await page.locator("aria-ref=" + nativeRef).count();
|
|
501
|
+
return false;
|
|
502
|
+
} catch (error) {
|
|
503
|
+
return error instanceof Error && INVALID_FRAME.test(error.message);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
function tidy(text) {
|
|
507
|
+
return text?.replace(/\s+/g, " ").trim();
|
|
508
|
+
}
|
|
509
|
+
async function checkBinding(page, epoch, entry, snapshot) {
|
|
510
|
+
const document = await checkDocument(page, epoch);
|
|
511
|
+
if (document.outcome !== "ok") return document;
|
|
512
|
+
const { nodes, nativeRefs } = parseAriaSnapshot(await snapshotRoot(page, snapshot));
|
|
513
|
+
const index = nativeRefs.indexOf(entry.nativeRef);
|
|
514
|
+
if (index < 0) {
|
|
515
|
+
const detached = await frameDetached(page, entry.nativeRef);
|
|
516
|
+
return { outcome: detached ? "frame-detached" : "binding-lost" };
|
|
517
|
+
}
|
|
518
|
+
const { state } = buildScreenState2({
|
|
519
|
+
platform: "web",
|
|
520
|
+
nodes,
|
|
521
|
+
viewport: { width: 0, height: 0 },
|
|
522
|
+
scroll: { offsetX: 0, offsetY: 0, contentWidth: 0, contentHeight: 0 },
|
|
523
|
+
coordSpace: "viewport",
|
|
524
|
+
timestamp: 0
|
|
525
|
+
});
|
|
526
|
+
const current = state.elements[index];
|
|
527
|
+
if (current === void 0 || current.role !== entry.role || tidy(current.name) !== tidy(entry.name) || tidy(rowTextOf2(current, state.elements)) !== tidy(entry.rowText)) {
|
|
528
|
+
return { outcome: "binding-changed" };
|
|
529
|
+
}
|
|
530
|
+
return { outcome: "ok" };
|
|
531
|
+
}
|
|
532
|
+
function laneRefs(action) {
|
|
533
|
+
const refs = [];
|
|
534
|
+
for (const lane of BINDING_LANES) {
|
|
535
|
+
const ref = Reflect.get(action, lane);
|
|
536
|
+
if (typeof ref === "string") refs.push(ref);
|
|
537
|
+
}
|
|
538
|
+
return refs;
|
|
539
|
+
}
|
|
540
|
+
function bindingRefusal(outcome, ref) {
|
|
541
|
+
return {
|
|
542
|
+
success: false,
|
|
543
|
+
error: `${outcome}: ref ${ref} \u2014 ${WHAT_CHANGED[outcome]}; nothing was sent`,
|
|
544
|
+
metadata: { binding: { outcome, ref, inputSent: false } }
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
392
548
|
// src/action-validator.ts
|
|
393
549
|
var ActionValidationError = class extends Error {
|
|
394
550
|
suggestion;
|
|
@@ -400,8 +556,8 @@ var ActionValidationError = class extends Error {
|
|
|
400
556
|
};
|
|
401
557
|
var FILL_ROLES = /* @__PURE__ */ new Set(["textbox", "searchbox", "spinbutton", "combobox"]);
|
|
402
558
|
var SELECT_ROLES = /* @__PURE__ */ new Set(["listbox", "combobox"]);
|
|
403
|
-
var NO_REF_ACTIONS = /* @__PURE__ */ new Set(["scroll", "navigate", "waitFor", "delay", "waitForUrl", "
|
|
404
|
-
var ANY_ROLE_ACTIONS = /* @__PURE__ */ new Set(["click", "hover", "keypress", "paste", "doubleClick", "rightClick"]);
|
|
559
|
+
var NO_REF_ACTIONS = /* @__PURE__ */ new Set(["scroll", "navigate", "waitFor", "delay", "waitForUrl", "tab", "inspect", "route", "unroute", "networkState", "storageState", "handleDialog", "uniqueValue", "assertValue", "assertAI", "extractAI", "reportValue", "setCookies", "setLocalStorage", "executeScript", "setVariable"]);
|
|
560
|
+
var ANY_ROLE_ACTIONS = /* @__PURE__ */ new Set(["click", "hover", "keypress", "paste", "doubleClick", "rightClick", "focus"]);
|
|
405
561
|
function validateAction(action, elementRole) {
|
|
406
562
|
if (NO_REF_ACTIONS.has(action.type)) {
|
|
407
563
|
return { valid: true };
|
|
@@ -525,150 +681,541 @@ async function runReadinessChecks(page) {
|
|
|
525
681
|
}
|
|
526
682
|
}
|
|
527
683
|
|
|
684
|
+
// src/compound-web.ts
|
|
685
|
+
import {
|
|
686
|
+
compareFillReread,
|
|
687
|
+
runUntilVisibleLoop,
|
|
688
|
+
runDirectionScroll,
|
|
689
|
+
runToEndScroll,
|
|
690
|
+
swipeSignalFromOffsets,
|
|
691
|
+
scrollerSetSwipeSignal
|
|
692
|
+
} from "@vostride/agent-qa-runner";
|
|
693
|
+
|
|
528
694
|
// src/scroll-helper.ts
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
password: "textbox"
|
|
579
|
-
};
|
|
580
|
-
function getAccessibleName(el) {
|
|
581
|
-
const label = el.getAttribute("aria-label");
|
|
582
|
-
if (label) return label.trim();
|
|
583
|
-
const labelledBy = el.getAttribute("aria-labelledby");
|
|
584
|
-
if (labelledBy) {
|
|
585
|
-
const parts = labelledBy.split(/\s+/).map((id) => {
|
|
586
|
-
const ref = doc.getElementById(id);
|
|
587
|
-
return ref ? (ref.textContent || "").trim() : "";
|
|
588
|
-
}).filter(Boolean);
|
|
589
|
-
if (parts.length) return parts.join(" ");
|
|
695
|
+
var NOT_FOUND = { bounds: null, scrolledContainer: "notFound", scrolled: false };
|
|
696
|
+
var EVALUATE_TIMEOUT_MS = 1e3;
|
|
697
|
+
async function scrollWithRef(page, lastRefs, ref, deltaX, deltaY, scrollAxis) {
|
|
698
|
+
const nativeRef = lastRefs[ref]?.nativeRef;
|
|
699
|
+
if (nativeRef !== void 0 && isChildFrameRef(nativeRef)) return NOT_FOUND;
|
|
700
|
+
try {
|
|
701
|
+
const locator = new ElementResolver(page, lastRefs).resolve(ref);
|
|
702
|
+
if (await locator.count() === 0) return NOT_FOUND;
|
|
703
|
+
const scrolled = await locator.evaluate(
|
|
704
|
+
(target, args) => {
|
|
705
|
+
const [dx, dy, axis] = args;
|
|
706
|
+
const win = globalThis;
|
|
707
|
+
const doc = win.document;
|
|
708
|
+
const el = target;
|
|
709
|
+
const prop = axis === "horizontal" ? "scrollLeft" : "scrollTop";
|
|
710
|
+
const maxOf = (node) => axis === "horizontal" ? node.scrollWidth - node.clientWidth : node.scrollHeight - node.clientHeight;
|
|
711
|
+
const boundsOf = () => {
|
|
712
|
+
const rect = el.getBoundingClientRect();
|
|
713
|
+
return { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) };
|
|
714
|
+
};
|
|
715
|
+
const scrollable = (node) => {
|
|
716
|
+
const style = win.getComputedStyle(node);
|
|
717
|
+
const overflow = axis === "horizontal" ? style.overflowX : style.overflowY;
|
|
718
|
+
return (overflow === "auto" || overflow === "scroll" || overflow === "hidden") && maxOf(node) > 0;
|
|
719
|
+
};
|
|
720
|
+
const scroller = doc.scrollingElement;
|
|
721
|
+
const rootStyle = win.getComputedStyle(doc.documentElement);
|
|
722
|
+
const bodyPropagates = rootStyle.overflowX === "visible" && rootStyle.overflowY === "visible";
|
|
723
|
+
const isDocumentScroller = (node) => node === doc.documentElement || node === scroller || bodyPropagates && node === doc.body;
|
|
724
|
+
const parentOf = (node) => {
|
|
725
|
+
if (node.assignedSlot) return node.assignedSlot;
|
|
726
|
+
if (node.parentElement) return node.parentElement;
|
|
727
|
+
const parent = node.parentNode;
|
|
728
|
+
return parent && parent.host ? parent.host : null;
|
|
729
|
+
};
|
|
730
|
+
if (!isDocumentScroller(el)) {
|
|
731
|
+
if (scrollable(el)) {
|
|
732
|
+
const before2 = el[prop];
|
|
733
|
+
el.scrollBy(dx, dy);
|
|
734
|
+
const after2 = el[prop];
|
|
735
|
+
return { bounds: boundsOf(), scrolledContainer: "element", scrolled: before2 !== after2, offsets: { before: before2, after: after2, max: maxOf(el) } };
|
|
736
|
+
}
|
|
737
|
+
for (let parent = parentOf(el); parent && !isDocumentScroller(parent); parent = parentOf(parent)) {
|
|
738
|
+
if (!scrollable(parent)) continue;
|
|
739
|
+
const before2 = parent[prop];
|
|
740
|
+
parent.scrollBy(dx, dy);
|
|
741
|
+
const after2 = parent[prop];
|
|
742
|
+
return { bounds: boundsOf(), scrolledContainer: "ancestor", scrolled: before2 !== after2, offsets: { before: before2, after: after2, max: maxOf(parent) } };
|
|
743
|
+
}
|
|
590
744
|
}
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
}
|
|
597
|
-
function getImplicitRole(el) {
|
|
598
|
-
const tag = el.tagName.toLowerCase();
|
|
599
|
-
if (tag === "input") {
|
|
600
|
-
const type = (el.getAttribute("type") || "text").toLowerCase();
|
|
601
|
-
return INPUT_TYPE_ROLES[type] || "textbox";
|
|
745
|
+
if (!scroller) {
|
|
746
|
+
const x = win.scrollX;
|
|
747
|
+
const y = win.scrollY;
|
|
748
|
+
win.scrollBy(dx, dy);
|
|
749
|
+
return { bounds: boundsOf(), scrolledContainer: "documentElement", scrolled: x !== win.scrollX || y !== win.scrollY };
|
|
602
750
|
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
doc.querySelectorAll(`[role="${role}"]`).forEach((el) => candidates.push(el));
|
|
608
|
-
doc.querySelectorAll("*").forEach((el) => {
|
|
609
|
-
if (el.getAttribute("role")) return;
|
|
610
|
-
if (getImplicitRole(el) === role) candidates.push(el);
|
|
611
|
-
});
|
|
612
|
-
let matched = candidates;
|
|
613
|
-
if (name) {
|
|
614
|
-
matched = candidates.filter(
|
|
615
|
-
(el) => getAccessibleName(el).toLowerCase().includes(name.toLowerCase())
|
|
616
|
-
);
|
|
617
|
-
}
|
|
618
|
-
const target = matched[nth];
|
|
619
|
-
if (!target) {
|
|
620
|
-
win.scrollBy(dx, dy);
|
|
621
|
-
return { bounds: null, scrolledContainer: "notFound", scrolled: true };
|
|
622
|
-
}
|
|
623
|
-
const targetStyle = win.getComputedStyle(target);
|
|
624
|
-
const targetOverflow = axis === "horizontal" ? targetStyle.overflowX : targetStyle.overflowY;
|
|
625
|
-
const targetScrollable = targetOverflow === "auto" || targetOverflow === "scroll" || targetOverflow === "hidden";
|
|
626
|
-
const targetHasContent = axis === "horizontal" ? target.scrollWidth > target.clientWidth : target.scrollHeight > target.clientHeight;
|
|
627
|
-
if (targetScrollable && targetHasContent) {
|
|
628
|
-
const prop2 = axis === "horizontal" ? "scrollLeft" : "scrollTop";
|
|
629
|
-
const before2 = target[prop2];
|
|
630
|
-
target.scrollBy(dx, dy);
|
|
631
|
-
const after2 = target[prop2];
|
|
632
|
-
const rect2 = target.getBoundingClientRect();
|
|
751
|
+
const before = scroller[prop];
|
|
752
|
+
scroller.scrollBy(dx, dy);
|
|
753
|
+
const after = scroller[prop];
|
|
754
|
+
const max = maxOf(scroller);
|
|
633
755
|
return {
|
|
634
|
-
bounds:
|
|
635
|
-
scrolledContainer: "
|
|
636
|
-
scrolled:
|
|
756
|
+
bounds: boundsOf(),
|
|
757
|
+
scrolledContainer: "documentElement",
|
|
758
|
+
scrolled: before !== after,
|
|
759
|
+
...max > 0 ? { offsets: { before, after, max } } : {}
|
|
637
760
|
};
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
761
|
+
},
|
|
762
|
+
[deltaX, deltaY, scrollAxis],
|
|
763
|
+
{ timeout: EVALUATE_TIMEOUT_MS }
|
|
764
|
+
);
|
|
765
|
+
return scrolled && typeof scrolled === "object" ? scrolled : NOT_FOUND;
|
|
766
|
+
} catch {
|
|
767
|
+
return NOT_FOUND;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// src/compound-web.ts
|
|
772
|
+
async function safeInputValue(locator) {
|
|
773
|
+
try {
|
|
774
|
+
return await locator.inputValue();
|
|
775
|
+
} catch {
|
|
776
|
+
try {
|
|
777
|
+
const value = await locator.evaluate((el) => {
|
|
778
|
+
const node = el;
|
|
779
|
+
return node.value ?? node.textContent;
|
|
780
|
+
});
|
|
781
|
+
return typeof value === "string" ? value : void 0;
|
|
782
|
+
} catch {
|
|
783
|
+
return void 0;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
async function runWebFillCompound(locator, boundingBox, action) {
|
|
788
|
+
const isAppend = action.clearMode === "append";
|
|
789
|
+
const prior = isAppend ? await safeInputValue(locator) ?? "" : "";
|
|
790
|
+
const secure = await locator.getAttribute("type") === "password";
|
|
791
|
+
if (isAppend) {
|
|
792
|
+
await locator.click();
|
|
793
|
+
await locator.press("End");
|
|
794
|
+
await locator.pressSequentially(action.text);
|
|
795
|
+
} else {
|
|
796
|
+
await locator.fill(action.text);
|
|
797
|
+
}
|
|
798
|
+
const expected = isAppend ? prior + action.text : action.text;
|
|
799
|
+
const actual = await safeInputValue(locator);
|
|
800
|
+
const box = boundingBox ? { boundingBox } : {};
|
|
801
|
+
if (actual === void 0) {
|
|
802
|
+
return { success: true, ...box, metadata: { fillVerdict: { verb: "fill", verdict: "unverified" } } };
|
|
803
|
+
}
|
|
804
|
+
const verdict = compareFillReread({ expected, actual, secure });
|
|
805
|
+
if (verdict.ok && action.submit) {
|
|
806
|
+
await locator.press("Enter");
|
|
807
|
+
}
|
|
808
|
+
const metadata = {
|
|
809
|
+
...verdict.meta ? { fillReread: verdict.meta } : {},
|
|
810
|
+
fillVerdict: {
|
|
811
|
+
verb: "fill",
|
|
812
|
+
verdict: verdict.verdict,
|
|
813
|
+
...verdict.secure ? { secure: true } : {}
|
|
814
|
+
}
|
|
815
|
+
};
|
|
816
|
+
return verdict.ok ? { success: true, ...box, metadata } : { success: false, error: verdict.selfCorrectingError, ...box, metadata };
|
|
817
|
+
}
|
|
818
|
+
function deltaFor(direction, magnitude) {
|
|
819
|
+
switch (direction) {
|
|
820
|
+
case "down":
|
|
821
|
+
return { dx: 0, dy: magnitude, axis: "vertical" };
|
|
822
|
+
case "up":
|
|
823
|
+
return { dx: 0, dy: -magnitude, axis: "vertical" };
|
|
824
|
+
case "right":
|
|
825
|
+
return { dx: magnitude, dy: 0, axis: "horizontal" };
|
|
826
|
+
case "left":
|
|
827
|
+
return { dx: -magnitude, dy: 0, axis: "horizontal" };
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
async function settle(page) {
|
|
831
|
+
await page.evaluate(`new Promise(resolve => {
|
|
832
|
+
const t = setTimeout(() => resolve('timeout'), 500);
|
|
833
|
+
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
834
|
+
clearTimeout(t);
|
|
835
|
+
resolve('raf');
|
|
836
|
+
}));
|
|
837
|
+
})`);
|
|
838
|
+
}
|
|
839
|
+
function snapshotScrollers(axis) {
|
|
840
|
+
const g = globalThis;
|
|
841
|
+
const doc = g.document;
|
|
842
|
+
const root = doc.scrollingElement ?? doc.documentElement;
|
|
843
|
+
const read = (el) => ({
|
|
844
|
+
x: el.scrollLeft,
|
|
845
|
+
y: el.scrollTop,
|
|
846
|
+
maxX: el.scrollWidth - el.clientWidth,
|
|
847
|
+
maxY: el.scrollHeight - el.clientHeight
|
|
848
|
+
});
|
|
849
|
+
const scrolls = (value) => value === "auto" || value === "scroll" || value === "overlay";
|
|
850
|
+
const rootStyle = g.getComputedStyle(doc.documentElement);
|
|
851
|
+
const bodyPropagates = rootStyle.overflowX === "visible" && rootStyle.overflowY === "visible";
|
|
852
|
+
const offsets = /* @__PURE__ */ new Map();
|
|
853
|
+
offsets.set(root, read(root));
|
|
854
|
+
const all = doc.querySelectorAll("*");
|
|
855
|
+
for (let i = 0; i < all.length; i += 1) {
|
|
856
|
+
const el = all[i];
|
|
857
|
+
if (el === root || el === doc.documentElement) continue;
|
|
858
|
+
if (el === doc.body && bodyPropagates) continue;
|
|
859
|
+
const o = read(el);
|
|
860
|
+
if (!((axis === "vertical" ? o.maxY : o.maxX) > 0)) continue;
|
|
861
|
+
const style = g.getComputedStyle(el);
|
|
862
|
+
if (!scrolls(axis === "vertical" ? style.overflowY : style.overflowX)) continue;
|
|
863
|
+
offsets.set(el, o);
|
|
864
|
+
}
|
|
865
|
+
const state = {
|
|
866
|
+
offsets,
|
|
867
|
+
target: null,
|
|
868
|
+
listener: () => void 0
|
|
869
|
+
};
|
|
870
|
+
state.listener = (event) => {
|
|
871
|
+
state.target = event.target;
|
|
872
|
+
};
|
|
873
|
+
g.addEventListener("wheel", state.listener, { capture: true, once: true, passive: true });
|
|
874
|
+
return state;
|
|
875
|
+
}
|
|
876
|
+
function readPointerChain(state) {
|
|
877
|
+
const g = globalThis;
|
|
878
|
+
const s = state;
|
|
879
|
+
if (!s.target) {
|
|
880
|
+
g.removeEventListener("wheel", s.listener, { capture: true });
|
|
881
|
+
return void 0;
|
|
882
|
+
}
|
|
883
|
+
const root = g.document.scrollingElement ?? g.document.documentElement;
|
|
884
|
+
const chain = [];
|
|
885
|
+
for (let el = s.target; el; el = el.parentElement) {
|
|
886
|
+
if (s.offsets.has(el) && !chain.includes(el)) chain.push(el);
|
|
887
|
+
}
|
|
888
|
+
if (!chain.includes(root)) chain.push(root);
|
|
889
|
+
return {
|
|
890
|
+
before: chain.map((el) => s.offsets.get(el)),
|
|
891
|
+
after: chain.map((el) => ({
|
|
892
|
+
x: el.scrollLeft,
|
|
893
|
+
y: el.scrollTop,
|
|
894
|
+
maxX: el.scrollWidth - el.clientWidth,
|
|
895
|
+
maxY: el.scrollHeight - el.clientHeight
|
|
896
|
+
}))
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
async function wheelPointerChain(page, dx, dy, axis) {
|
|
900
|
+
let handle;
|
|
901
|
+
try {
|
|
902
|
+
handle = await page.evaluateHandle(snapshotScrollers, axis);
|
|
903
|
+
} catch {
|
|
904
|
+
handle = void 0;
|
|
905
|
+
}
|
|
906
|
+
await page.mouse.wheel(dx, dy);
|
|
907
|
+
await settle(page);
|
|
908
|
+
if (!handle) return void 0;
|
|
909
|
+
try {
|
|
910
|
+
const chain = await handle.evaluate(readPointerChain);
|
|
911
|
+
if (!chain || typeof chain !== "object" || !Array.isArray(chain.before) || !Array.isArray(chain.after)) {
|
|
912
|
+
return void 0;
|
|
913
|
+
}
|
|
914
|
+
return { before: chain.before, after: chain.after };
|
|
915
|
+
} catch {
|
|
916
|
+
return void 0;
|
|
917
|
+
} finally {
|
|
918
|
+
await handle.dispose().catch(() => void 0);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
var FRAME_PROBE_TIMEOUT_MS = 1e3;
|
|
922
|
+
function judgeCandidates(elements) {
|
|
923
|
+
const g = globalThis;
|
|
924
|
+
const doc = g.document;
|
|
925
|
+
const root = doc.documentElement;
|
|
926
|
+
const rootStyle = g.getComputedStyle(root);
|
|
927
|
+
const bodyPropagates = rootStyle.overflowX === "visible" && rootStyle.overflowY === "visible";
|
|
928
|
+
const parentOf = (node) => node.assignedSlot ?? node.parentElement ?? node.parentNode?.host ?? null;
|
|
929
|
+
const clips = (node) => {
|
|
930
|
+
const style = g.getComputedStyle(node);
|
|
931
|
+
return style.overflowX !== "visible" || style.overflowY !== "visible";
|
|
932
|
+
};
|
|
933
|
+
const escapes = (node) => {
|
|
934
|
+
const position = g.getComputedStyle(node).position;
|
|
935
|
+
return position === "fixed" || position === "absolute";
|
|
936
|
+
};
|
|
937
|
+
const contentRects = (node) => {
|
|
938
|
+
const range = doc.createRange();
|
|
939
|
+
range.selectNodeContents(node);
|
|
940
|
+
return Array.from(range.getClientRects());
|
|
941
|
+
};
|
|
942
|
+
const replaced = /^(IMG|VIDEO|CANVAS|IFRAME|EMBED|OBJECT|SVG|INPUT|TEXTAREA|SELECT)$/;
|
|
943
|
+
const unpainted = (s) => s.backgroundImage === "none" && /^(transparent|rgba\(.*,\s*0\))$/.test(s.backgroundColor);
|
|
944
|
+
const emptyPseudo = (p) => p.content === "none" || p.content === "normal" || p.content === '""' && unpainted(p);
|
|
945
|
+
const transparent = (node, x, y) => {
|
|
946
|
+
const s = g.getComputedStyle(node);
|
|
947
|
+
if (s.opacity === "0") return true;
|
|
948
|
+
if (node.namespaceURI !== "http://www.w3.org/1999/xhtml") return false;
|
|
949
|
+
if (replaced.test(node.tagName)) return false;
|
|
950
|
+
if (node.shadowRoot || node.tagName.includes("-")) return false;
|
|
951
|
+
if (!unpainted(s) || s.backdropFilter !== "none") return false;
|
|
952
|
+
if (!emptyPseudo(g.getComputedStyle(node, "::before"))) return false;
|
|
953
|
+
if (!emptyPseudo(g.getComputedStyle(node, "::after"))) return false;
|
|
954
|
+
return !contentRects(node).some(
|
|
955
|
+
(t) => t.left <= x && x <= t.right && t.top <= y && y <= t.bottom
|
|
956
|
+
);
|
|
957
|
+
};
|
|
958
|
+
const judge = (el) => {
|
|
959
|
+
const style = g.getComputedStyle(el);
|
|
960
|
+
if (style.visibility !== "visible") return { state: "hidden" };
|
|
961
|
+
for (let n = el; n; n = parentOf(n)) {
|
|
962
|
+
if (g.getComputedStyle(n).opacity === "0") return { state: "hidden" };
|
|
963
|
+
}
|
|
964
|
+
let r = el.getBoundingClientRect();
|
|
965
|
+
const byText = style.display === "contents" || r.width === 0 || r.height === 0;
|
|
966
|
+
if (byText) {
|
|
967
|
+
const clipped = clips(el) || style.clip !== "auto" || style.clipPath !== "none";
|
|
968
|
+
if (style.display !== "contents" && clipped) return { state: "unknown" };
|
|
969
|
+
const rects = contentRects(el).filter((t) => t.width > 0 && t.height > 0);
|
|
970
|
+
if (rects.length === 0) return { state: "unknown" };
|
|
971
|
+
r = {
|
|
972
|
+
left: Math.min(...rects.map((t) => t.left)),
|
|
973
|
+
top: Math.min(...rects.map((t) => t.top)),
|
|
974
|
+
right: Math.max(...rects.map((t) => t.right)),
|
|
975
|
+
bottom: Math.max(...rects.map((t) => t.bottom))
|
|
976
|
+
};
|
|
977
|
+
} else if (r.width < 2 || r.height < 2) {
|
|
978
|
+
return { state: "hidden" };
|
|
979
|
+
}
|
|
980
|
+
let left = Math.max(r.left, 0);
|
|
981
|
+
let top = Math.max(r.top, 0);
|
|
982
|
+
let right = Math.min(r.right, g.innerWidth);
|
|
983
|
+
let bottom = Math.min(r.bottom, g.innerHeight);
|
|
984
|
+
if (!(right > left && bottom > top)) return { state: "hidden" };
|
|
985
|
+
let positioned = escapes(el);
|
|
986
|
+
for (let a = parentOf(el); a && a !== root; a = parentOf(a)) {
|
|
987
|
+
if (!(a === doc.body && bodyPropagates) && !positioned && clips(a)) {
|
|
988
|
+
const b = a.getBoundingClientRect();
|
|
989
|
+
const cl = b.left + a.clientLeft;
|
|
990
|
+
const ct = b.top + a.clientTop;
|
|
991
|
+
left = Math.max(left, cl);
|
|
992
|
+
top = Math.max(top, ct);
|
|
993
|
+
right = Math.min(right, cl + a.clientWidth);
|
|
994
|
+
bottom = Math.min(bottom, ct + a.clientHeight);
|
|
995
|
+
if (!(right > left && bottom > top)) return { state: "unknown" };
|
|
996
|
+
}
|
|
997
|
+
if (escapes(a)) positioned = true;
|
|
998
|
+
}
|
|
999
|
+
if (right - left < 2 || bottom - top < 2) return { state: "unknown" };
|
|
1000
|
+
const x = (left + right) / 2;
|
|
1001
|
+
const y = (top + bottom) / 2;
|
|
1002
|
+
const seen = { state: "visible", rect: { left, top, right, bottom } };
|
|
1003
|
+
for (const hit of el.getRootNode().elementsFromPoint(x, y)) {
|
|
1004
|
+
if (hit === el || el.contains(hit)) return seen;
|
|
1005
|
+
if (style.pointerEvents === "none" || byText) {
|
|
1006
|
+
for (let a = parentOf(el); a; a = parentOf(a)) {
|
|
1007
|
+
if (a === hit) return seen;
|
|
656
1008
|
}
|
|
657
|
-
parent = parent.parentElement;
|
|
658
1009
|
}
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
1010
|
+
if (!transparent(hit, x, y)) return { state: "unknown" };
|
|
1011
|
+
}
|
|
1012
|
+
return { state: "unknown" };
|
|
1013
|
+
};
|
|
1014
|
+
return elements.map(judge);
|
|
1015
|
+
}
|
|
1016
|
+
function projectThroughFrame(frameElement, box) {
|
|
1017
|
+
const g = globalThis;
|
|
1018
|
+
const doc = g.document;
|
|
1019
|
+
const root = doc.documentElement;
|
|
1020
|
+
const el = frameElement;
|
|
1021
|
+
const rootStyle = g.getComputedStyle(root);
|
|
1022
|
+
const bodyPropagates = rootStyle.overflowX === "visible" && rootStyle.overflowY === "visible";
|
|
1023
|
+
const parentOf = (node) => node.assignedSlot ?? node.parentElement ?? node.parentNode?.host ?? null;
|
|
1024
|
+
const clips = (node) => {
|
|
1025
|
+
const style2 = g.getComputedStyle(node);
|
|
1026
|
+
return style2.overflowX !== "visible" || style2.overflowY !== "visible";
|
|
1027
|
+
};
|
|
1028
|
+
if (g.getComputedStyle(el).visibility !== "visible") return null;
|
|
1029
|
+
for (let n = el; n; n = parentOf(n)) {
|
|
1030
|
+
if (g.getComputedStyle(n).opacity === "0") return null;
|
|
1031
|
+
}
|
|
1032
|
+
for (let n = el; n; n = parentOf(n)) {
|
|
1033
|
+
const style2 = g.getComputedStyle(n);
|
|
1034
|
+
if (style2.transform !== "none") return null;
|
|
1035
|
+
const zoom = Number.parseFloat(style2.zoom);
|
|
1036
|
+
if (Number.isFinite(zoom) && zoom !== 1) return null;
|
|
1037
|
+
}
|
|
1038
|
+
const style = g.getComputedStyle(el);
|
|
1039
|
+
const padLeft = Number.parseFloat(style.paddingLeft) || 0;
|
|
1040
|
+
const padTop = Number.parseFloat(style.paddingTop) || 0;
|
|
1041
|
+
const padRight = Number.parseFloat(style.paddingRight) || 0;
|
|
1042
|
+
const padBottom = Number.parseFloat(style.paddingBottom) || 0;
|
|
1043
|
+
const r = el.getBoundingClientRect();
|
|
1044
|
+
const ox = r.left + el.clientLeft + padLeft;
|
|
1045
|
+
const oy = r.top + el.clientTop + padTop;
|
|
1046
|
+
let left = Math.max(box.left + ox, ox, 0);
|
|
1047
|
+
let top = Math.max(box.top + oy, oy, 0);
|
|
1048
|
+
let right = Math.min(box.right + ox, ox + el.clientWidth - padLeft - padRight, g.innerWidth);
|
|
1049
|
+
let bottom = Math.min(box.bottom + oy, oy + el.clientHeight - padTop - padBottom, g.innerHeight);
|
|
1050
|
+
if (!(right > left && bottom > top)) return null;
|
|
1051
|
+
for (let a = parentOf(el); a && a !== root; a = parentOf(a)) {
|
|
1052
|
+
if (a === doc.body && bodyPropagates) continue;
|
|
1053
|
+
if (!clips(a)) continue;
|
|
1054
|
+
const b = a.getBoundingClientRect();
|
|
1055
|
+
const cl = b.left + a.clientLeft;
|
|
1056
|
+
const ct = b.top + a.clientTop;
|
|
1057
|
+
left = Math.max(left, cl);
|
|
1058
|
+
top = Math.max(top, ct);
|
|
1059
|
+
right = Math.min(right, cl + a.clientWidth);
|
|
1060
|
+
bottom = Math.min(bottom, ct + a.clientHeight);
|
|
1061
|
+
if (!(right > left && bottom > top)) return null;
|
|
1062
|
+
}
|
|
1063
|
+
const hit = el.getRootNode().elementFromPoint((left + right) / 2, (top + bottom) / 2);
|
|
1064
|
+
if (hit !== el) return null;
|
|
1065
|
+
return { left, top, right, bottom };
|
|
1066
|
+
}
|
|
1067
|
+
function candidatesIn(scope, target) {
|
|
1068
|
+
return scope.getByText(target).or(scope.getByLabel(target)).or(scope.getByAltText(target)).or(scope.getByTitle(target));
|
|
1069
|
+
}
|
|
1070
|
+
async function judgeIn(scope, target) {
|
|
1071
|
+
const judged = await candidatesIn(scope, target).evaluateAll(judgeCandidates);
|
|
1072
|
+
if (!Array.isArray(judged)) throw new Error("probe answered no list");
|
|
1073
|
+
return judged.map((candidate) => {
|
|
1074
|
+
const state = candidate?.state;
|
|
1075
|
+
if (state === "visible" && candidate?.rect) return { state, rect: candidate.rect };
|
|
1076
|
+
return { state: state === "hidden" ? "hidden" : "unknown" };
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
function combine(states) {
|
|
1080
|
+
if (states.includes("visible")) return "visible";
|
|
1081
|
+
if (states.includes("unknown")) return "unknown";
|
|
1082
|
+
return "hidden";
|
|
1083
|
+
}
|
|
1084
|
+
async function projectToMainFrame(frame, box) {
|
|
1085
|
+
let current = frame;
|
|
1086
|
+
let projected = box;
|
|
1087
|
+
for (let parent = current.parentFrame(); parent; parent = current.parentFrame()) {
|
|
1088
|
+
const handle = await current.frameElement();
|
|
1089
|
+
try {
|
|
1090
|
+
const next = await handle.evaluate(projectThroughFrame, projected);
|
|
1091
|
+
if (!next || typeof next !== "object") return false;
|
|
1092
|
+
projected = next;
|
|
1093
|
+
} finally {
|
|
1094
|
+
await handle.dispose().catch(() => void 0);
|
|
1095
|
+
}
|
|
1096
|
+
current = parent;
|
|
1097
|
+
}
|
|
1098
|
+
return true;
|
|
1099
|
+
}
|
|
1100
|
+
async function probeChildFrame(frame, target) {
|
|
1101
|
+
try {
|
|
1102
|
+
const states = [];
|
|
1103
|
+
for (const candidate of await judgeIn(frame, target)) {
|
|
1104
|
+
if (candidate.state === "visible" && candidate.rect && await projectToMainFrame(frame, candidate.rect)) {
|
|
1105
|
+
return "visible";
|
|
1106
|
+
}
|
|
1107
|
+
states.push("unknown");
|
|
1108
|
+
}
|
|
1109
|
+
return combine(states);
|
|
1110
|
+
} catch {
|
|
1111
|
+
return "unknown";
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
async function probeChildFrameBounded(frame, target, pending) {
|
|
1115
|
+
if (frame.url() === "" || pending.has(frame)) return "unknown";
|
|
1116
|
+
const liveness = frame.evaluate("1");
|
|
1117
|
+
let timer;
|
|
1118
|
+
const bound = new Promise((resolve) => {
|
|
1119
|
+
timer = setTimeout(() => {
|
|
1120
|
+
resolve("timeout");
|
|
1121
|
+
}, FRAME_PROBE_TIMEOUT_MS);
|
|
1122
|
+
});
|
|
1123
|
+
let answer;
|
|
1124
|
+
try {
|
|
1125
|
+
answer = await Promise.race([liveness.then(() => "alive"), bound]);
|
|
1126
|
+
} catch {
|
|
1127
|
+
return "unknown";
|
|
1128
|
+
} finally {
|
|
1129
|
+
clearTimeout(timer);
|
|
1130
|
+
}
|
|
1131
|
+
if (answer === "alive") return await probeChildFrame(frame, target);
|
|
1132
|
+
pending.set(frame, liveness);
|
|
1133
|
+
const release = () => {
|
|
1134
|
+
if (pending.get(frame) === liveness) pending.delete(frame);
|
|
1135
|
+
};
|
|
1136
|
+
liveness.then(release, release);
|
|
1137
|
+
return "unknown";
|
|
1138
|
+
}
|
|
1139
|
+
async function probeOnScreen(page, target, pending) {
|
|
1140
|
+
let states;
|
|
1141
|
+
try {
|
|
1142
|
+
states = (await judgeIn(page, target)).map((candidate) => candidate.state);
|
|
1143
|
+
} catch {
|
|
1144
|
+
return "unknown";
|
|
1145
|
+
}
|
|
1146
|
+
if (combine(states) === "visible") return "visible";
|
|
1147
|
+
try {
|
|
1148
|
+
const main = page.mainFrame();
|
|
1149
|
+
const children = page.frames().filter((frame) => frame !== main && !frame.isDetached());
|
|
1150
|
+
states.push(...await Promise.all(children.map((frame) => probeChildFrameBounded(frame, target, pending))));
|
|
1151
|
+
} catch {
|
|
1152
|
+
states.push("unknown");
|
|
1153
|
+
}
|
|
1154
|
+
return combine(states);
|
|
1155
|
+
}
|
|
1156
|
+
function buildScrollPrimitives(page, lastRefs, magnitude) {
|
|
1157
|
+
const pending = /* @__PURE__ */ new WeakMap();
|
|
1158
|
+
return {
|
|
1159
|
+
async swipeOnce(direction, ref) {
|
|
1160
|
+
const { dx, dy, axis } = deltaFor(direction, magnitude);
|
|
1161
|
+
if (ref) {
|
|
1162
|
+
const scrolled = await scrollWithRef(page, lastRefs, ref, dx, dy, axis);
|
|
1163
|
+
if (scrolled.scrolledContainer !== "notFound") await settle(page);
|
|
1164
|
+
return swipeSignalFromOffsets(direction, scrolled.offsets);
|
|
1165
|
+
}
|
|
1166
|
+
const chain = await wheelPointerChain(page, dx, dy, axis);
|
|
1167
|
+
return scrollerSetSwipeSignal(direction, chain?.before, chain?.after);
|
|
669
1168
|
},
|
|
670
|
-
|
|
671
|
-
|
|
1169
|
+
async nativeSearchScroll(target) {
|
|
1170
|
+
try {
|
|
1171
|
+
await page.getByText(target).first().scrollIntoViewIfNeeded();
|
|
1172
|
+
return true;
|
|
1173
|
+
} catch {
|
|
1174
|
+
return false;
|
|
1175
|
+
}
|
|
1176
|
+
},
|
|
1177
|
+
async readInterimTree() {
|
|
1178
|
+
const tree = await page.locator(":root").ariaSnapshot({ mode: "ai" });
|
|
1179
|
+
return parseAriaSnapshot(tree).nodes;
|
|
1180
|
+
},
|
|
1181
|
+
async probeTargetVisible(target) {
|
|
1182
|
+
return probeOnScreen(page, target, pending);
|
|
1183
|
+
}
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
async function runWebScrollCompound(page, lastRefs, action) {
|
|
1187
|
+
const viewport = page.viewportSize() ?? { width: 1280, height: 720 };
|
|
1188
|
+
const horizontal = action.direction === "left" || action.direction === "right";
|
|
1189
|
+
if (action.mode === "untilVisible") {
|
|
1190
|
+
const magnitude2 = horizontal ? viewport.width : viewport.height;
|
|
1191
|
+
const primitives2 = buildScrollPrimitives(page, lastRefs, magnitude2);
|
|
1192
|
+
const { meta: meta2 } = await runUntilVisibleLoop(primitives2, {
|
|
1193
|
+
target: action.text,
|
|
1194
|
+
direction: action.direction,
|
|
1195
|
+
maxSwipes: action.maxSwipes,
|
|
1196
|
+
retryOpposite: action.retryOpposite,
|
|
1197
|
+
...action.ref ? { ref: action.ref } : {}
|
|
1198
|
+
});
|
|
1199
|
+
return { success: true, metadata: { scrollUntilVisible: meta2 } };
|
|
1200
|
+
}
|
|
1201
|
+
const factor = action.amount === "halfPage" ? 0.5 : 1;
|
|
1202
|
+
const magnitude = Math.round((horizontal ? viewport.width : viewport.height) * factor);
|
|
1203
|
+
const primitives = buildScrollPrimitives(page, lastRefs, magnitude);
|
|
1204
|
+
if (action.amount === "toEnd") {
|
|
1205
|
+
const { meta: meta2 } = await runToEndScroll(primitives, action.direction, action.ref);
|
|
1206
|
+
return { success: true, metadata: { scrollUntilVisible: meta2 } };
|
|
1207
|
+
}
|
|
1208
|
+
const { meta } = await runDirectionScroll(primitives, {
|
|
1209
|
+
direction: action.direction,
|
|
1210
|
+
...action.ref ? { ref: action.ref } : {}
|
|
1211
|
+
});
|
|
1212
|
+
return { success: true, metadata: { scrollUntilVisible: meta } };
|
|
1213
|
+
}
|
|
1214
|
+
function webOpenAppError() {
|
|
1215
|
+
return {
|
|
1216
|
+
success: false,
|
|
1217
|
+
error: "openApp is not executable on web \u2014 use navigate(url) with the target app's web address instead."
|
|
1218
|
+
};
|
|
672
1219
|
}
|
|
673
1220
|
|
|
674
1221
|
// src/browser-install-error.ts
|
|
@@ -693,29 +1240,856 @@ function formatMissingBrowserError(browserName) {
|
|
|
693
1240
|
].join("\n");
|
|
694
1241
|
}
|
|
695
1242
|
|
|
696
|
-
// src/
|
|
697
|
-
|
|
698
|
-
|
|
1243
|
+
// src/recording.ts
|
|
1244
|
+
import { randomUUID } from "crypto";
|
|
1245
|
+
import { mkdir, rm } from "fs/promises";
|
|
1246
|
+
import { join } from "path";
|
|
1247
|
+
var RECORDING_DEFAULT_SIZE = { width: 1280, height: 720 };
|
|
1248
|
+
function jpegSize(bytes) {
|
|
1249
|
+
if (bytes[0] !== 255 || bytes[1] !== 216) return void 0;
|
|
1250
|
+
for (let i = 2; i + 8 < bytes.length; ) {
|
|
1251
|
+
if (bytes[i++] !== 255) return void 0;
|
|
1252
|
+
const marker = bytes[i++];
|
|
1253
|
+
const length = bytes.readUInt16BE(i);
|
|
1254
|
+
if (length < 2 || i + length > bytes.length) return void 0;
|
|
1255
|
+
if ([192, 193, 194].includes(marker)) {
|
|
1256
|
+
return { height: bytes.readUInt16BE(i + 3), width: bytes.readUInt16BE(i + 5) };
|
|
1257
|
+
}
|
|
1258
|
+
i += length;
|
|
1259
|
+
}
|
|
1260
|
+
return void 0;
|
|
699
1261
|
}
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
1262
|
+
function createScreencastRecording(getPage, config, getPageRevision) {
|
|
1263
|
+
let active;
|
|
1264
|
+
let recovery;
|
|
1265
|
+
let busy = false;
|
|
1266
|
+
let firstFrameEpoch;
|
|
1267
|
+
let scene;
|
|
1268
|
+
let sceneRevision = 0;
|
|
1269
|
+
let bracket;
|
|
1270
|
+
async function recover() {
|
|
1271
|
+
if (!recovery) return;
|
|
1272
|
+
await recovery.page.screencast.stop();
|
|
1273
|
+
await rm(recovery.stagingPath, { force: true });
|
|
1274
|
+
recovery = void 0;
|
|
1275
|
+
}
|
|
1276
|
+
return {
|
|
1277
|
+
async start() {
|
|
1278
|
+
if (busy || active) throw new Error("Recording already started");
|
|
1279
|
+
busy = true;
|
|
1280
|
+
try {
|
|
1281
|
+
await recover();
|
|
1282
|
+
const page = getPage();
|
|
1283
|
+
if (!page) throw new Error("Recording requires an initialized page");
|
|
1284
|
+
const stagingPath = join(config.videoDir, `${randomUUID()}.webm`);
|
|
1285
|
+
const size = config.videoSize ?? page.viewportSize() ?? RECORDING_DEFAULT_SIZE;
|
|
1286
|
+
firstFrameEpoch = void 0;
|
|
1287
|
+
scene = void 0;
|
|
1288
|
+
sceneRevision++;
|
|
1289
|
+
recovery = { page, stagingPath };
|
|
1290
|
+
try {
|
|
1291
|
+
await mkdir(config.videoDir, { recursive: true });
|
|
1292
|
+
await page.screencast.start({
|
|
1293
|
+
path: stagingPath,
|
|
1294
|
+
size,
|
|
1295
|
+
onFrame: async (frame) => {
|
|
1296
|
+
firstFrameEpoch ??= frame.timestamp;
|
|
1297
|
+
const pixels = jpegSize(frame.data);
|
|
1298
|
+
const next = pixels?.width === size.width && pixels.height === size.height && frame.viewportWidth === size.width && frame.viewportHeight === size.height ? { width: size.width, height: size.height } : void 0;
|
|
1299
|
+
if (next?.width !== scene?.width || next?.height !== scene?.height) sceneRevision++;
|
|
1300
|
+
scene = next;
|
|
1301
|
+
}
|
|
1302
|
+
});
|
|
1303
|
+
} catch (error) {
|
|
1304
|
+
try {
|
|
1305
|
+
await recover();
|
|
1306
|
+
} catch {
|
|
1307
|
+
}
|
|
1308
|
+
throw error;
|
|
1309
|
+
}
|
|
1310
|
+
active = recovery;
|
|
1311
|
+
recovery = void 0;
|
|
1312
|
+
return { zeroMs: performance.now() };
|
|
1313
|
+
} finally {
|
|
1314
|
+
busy = false;
|
|
1315
|
+
}
|
|
1316
|
+
},
|
|
1317
|
+
beginAnnotation() {
|
|
1318
|
+
const recording = active;
|
|
1319
|
+
const before = scene;
|
|
1320
|
+
const epoch = firstFrameEpoch;
|
|
1321
|
+
const revision = sceneRevision;
|
|
1322
|
+
if (!getPageRevision) return void 0;
|
|
1323
|
+
const pageRevision = getPageRevision();
|
|
1324
|
+
if (!recording || !before || epoch === void 0 || getPage() !== recording.page) return void 0;
|
|
1325
|
+
const viewport = recording.page.viewportSize();
|
|
1326
|
+
if (viewport?.width !== before.width || viewport.height !== before.height) return void 0;
|
|
1327
|
+
const startMs = performance.timeOrigin + performance.now() - epoch;
|
|
1328
|
+
const settle2 = () => {
|
|
1329
|
+
const after = recording.page.viewportSize();
|
|
1330
|
+
const endMs = performance.timeOrigin + performance.now() - epoch;
|
|
1331
|
+
if (active !== recording || getPage() !== recording.page || revision !== sceneRevision || pageRevision !== getPageRevision() || after?.width !== before.width || after.height !== before.height || !Number.isFinite(startMs) || !Number.isFinite(endMs) || startMs < 0 || endMs <= startMs) return void 0;
|
|
1332
|
+
return { startMs, endMs, scene: "recorded-page", transform: "identity", ...before };
|
|
1333
|
+
};
|
|
1334
|
+
let ended;
|
|
1335
|
+
bracket = { end() {
|
|
1336
|
+
ended = { witness: settle2() };
|
|
1337
|
+
} };
|
|
1338
|
+
return () => ended ? ended.witness : settle2();
|
|
1339
|
+
},
|
|
1340
|
+
endAction() {
|
|
1341
|
+
bracket?.end();
|
|
1342
|
+
},
|
|
1343
|
+
async stop() {
|
|
1344
|
+
if (busy) throw new Error("Recording operation already in progress");
|
|
1345
|
+
busy = true;
|
|
1346
|
+
try {
|
|
1347
|
+
const recording = active;
|
|
1348
|
+
if (!recording) {
|
|
1349
|
+
await recover();
|
|
1350
|
+
return void 0;
|
|
1351
|
+
}
|
|
1352
|
+
active = void 0;
|
|
1353
|
+
try {
|
|
1354
|
+
await recording.page.screencast.stop();
|
|
1355
|
+
} catch (error) {
|
|
1356
|
+
recovery = recording;
|
|
1357
|
+
try {
|
|
1358
|
+
await recover();
|
|
1359
|
+
} catch {
|
|
1360
|
+
}
|
|
1361
|
+
throw error;
|
|
1362
|
+
}
|
|
1363
|
+
return { stagingPath: recording.stagingPath, contentType: "video/webm" };
|
|
1364
|
+
} finally {
|
|
1365
|
+
busy = false;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
// src/operation-capture.ts
|
|
1372
|
+
import { createHash } from "crypto";
|
|
1373
|
+
import { extname } from "path";
|
|
1374
|
+
import { CLIPBOARD_TEXT_LIMIT } from "@vostride/agent-qa-runner";
|
|
1375
|
+
var DOWNLOAD_START_GRACE_MS = 500;
|
|
1376
|
+
var NAVIGATION_SETTLE_MS = 250;
|
|
1377
|
+
var DOWNLOAD_COMPLETION_CAP_MS = 12e4;
|
|
1378
|
+
var NAVIGATION_WINDOW_CAP_MS = 5e3;
|
|
1379
|
+
var DOWNLOAD_HOLD_FILE_CAP = 1024 * 1024;
|
|
1380
|
+
var DOWNLOAD_HOLD_TOTAL_CAP = 8 * 1024 * 1024;
|
|
1381
|
+
var DOWNLOAD_ACTIVATION_ACTIONS = /* @__PURE__ */ new Set([
|
|
1382
|
+
"click",
|
|
1383
|
+
"doubleClick",
|
|
1384
|
+
"rightClick",
|
|
1385
|
+
"keypress",
|
|
1386
|
+
"select",
|
|
1387
|
+
"navigate",
|
|
1388
|
+
"executeScript",
|
|
1389
|
+
"handleDialog",
|
|
1390
|
+
"fileUpload",
|
|
1391
|
+
"tab"
|
|
1392
|
+
]);
|
|
1393
|
+
function isDownloadActivation(action) {
|
|
1394
|
+
if (action.type === "fill") return action.submit === true;
|
|
1395
|
+
if (action.type === "tab") return action.action === "new";
|
|
1396
|
+
return DOWNLOAD_ACTIVATION_ACTIONS.has(action.type);
|
|
1397
|
+
}
|
|
1398
|
+
var UNFINISHED = /* @__PURE__ */ Symbol("unfinished");
|
|
1399
|
+
var CLIPBOARD_BINDING = "__agentQaAppClipboardWrite";
|
|
1400
|
+
var CLIPBOARD_EXCLUDED_ACTIONS = /* @__PURE__ */ new Set(["copy", "paste"]);
|
|
1401
|
+
var CLIPBOARD_SOURCES = /* @__PURE__ */ new Set(["writeText", "write", "copy-event"]);
|
|
1402
|
+
var READ_BACK_TIMEOUT_MS = 2e3;
|
|
1403
|
+
var CLIPBOARD_CLAIMS_PER_ACTION = 8;
|
|
1404
|
+
var CLIPBOARD_READBACK_ORIGIN = "https://agent-qa-readback.invalid";
|
|
1405
|
+
var PROBE_MATCHED = 1;
|
|
1406
|
+
var PROBE_DIFFERENT = 0;
|
|
1407
|
+
function installProbeReadback() {
|
|
1408
|
+
const scope = globalThis;
|
|
1409
|
+
let preImage;
|
|
1410
|
+
let status = "none";
|
|
1411
|
+
const read = async () => {
|
|
1412
|
+
try {
|
|
1413
|
+
const text = await scope.navigator.clipboard.readText();
|
|
1414
|
+
return typeof text === "string" ? text : void 0;
|
|
1415
|
+
} catch {
|
|
1416
|
+
return void 0;
|
|
1417
|
+
}
|
|
1418
|
+
};
|
|
1419
|
+
Object.defineProperty(globalThis, "__agentQaReadback", {
|
|
1420
|
+
value: Object.freeze({
|
|
1421
|
+
get status() {
|
|
1422
|
+
return status;
|
|
1423
|
+
},
|
|
1424
|
+
async snapshot() {
|
|
1425
|
+
preImage = void 0;
|
|
1426
|
+
status = "none";
|
|
1427
|
+
const text = await read();
|
|
1428
|
+
if (text === void 0) {
|
|
1429
|
+
status = "unreadable";
|
|
1430
|
+
} else {
|
|
1431
|
+
preImage = text;
|
|
1432
|
+
status = "read";
|
|
1433
|
+
}
|
|
1434
|
+
return status;
|
|
1435
|
+
},
|
|
1436
|
+
async compare(finalText) {
|
|
1437
|
+
if (status !== "read") return -2;
|
|
1438
|
+
const now = await read();
|
|
1439
|
+
if (now === void 0) return -2;
|
|
1440
|
+
if (now === preImage) return -3;
|
|
1441
|
+
return now === finalText ? 1 : 0;
|
|
1442
|
+
}
|
|
1443
|
+
})
|
|
1444
|
+
});
|
|
1445
|
+
}
|
|
1446
|
+
var CLIPBOARD_HOOK = `(() => {
|
|
1447
|
+
const binding = ${JSON.stringify(CLIPBOARD_BINDING)};
|
|
1448
|
+
const report = (via, text) => {
|
|
1449
|
+
const send = window[binding];
|
|
1450
|
+
if (typeof send !== 'function' || typeof text !== 'string' || text.length === 0) return;
|
|
1451
|
+
try { Promise.resolve(send({ via, text })).catch(() => {}); } catch {}
|
|
1452
|
+
};
|
|
1453
|
+
// 2026-10-08 (436-14): a write is reported only once the browser accepted it; the app still
|
|
1454
|
+
// gets the original promise, rejections included.
|
|
1455
|
+
const proto = typeof Clipboard === 'function' ? Clipboard.prototype : undefined;
|
|
1456
|
+
if (proto && typeof proto.writeText === 'function') {
|
|
1457
|
+
const writeText = proto.writeText;
|
|
1458
|
+
proto.writeText = function (data) {
|
|
1459
|
+
let text;
|
|
1460
|
+
try { text = String(data); } catch {}
|
|
1461
|
+
const result = writeText.apply(this, arguments);
|
|
1462
|
+
try { Promise.resolve(result).then(() => report('writeText', text), () => {}); } catch {}
|
|
1463
|
+
return result;
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1466
|
+
if (proto && typeof proto.write === 'function') {
|
|
1467
|
+
const write = proto.write;
|
|
1468
|
+
proto.write = function (items) {
|
|
1469
|
+
const texts = [];
|
|
1470
|
+
try {
|
|
1471
|
+
for (const item of Array.from(items || [])) {
|
|
1472
|
+
if (!item || !Array.from(item.types || []).includes('text/plain')) continue;
|
|
1473
|
+
texts.push(item.getType('text/plain').then((blob) => blob.text()).catch(() => undefined));
|
|
1474
|
+
}
|
|
1475
|
+
} catch {}
|
|
1476
|
+
const result = write.apply(this, arguments);
|
|
1477
|
+
try {
|
|
1478
|
+
Promise.resolve(result).then(
|
|
1479
|
+
() => Promise.all(texts).then((all) => { for (const text of all) report('write', text); }),
|
|
1480
|
+
() => {},
|
|
1481
|
+
);
|
|
1482
|
+
} catch {}
|
|
1483
|
+
return result;
|
|
1484
|
+
};
|
|
1485
|
+
}
|
|
1486
|
+
// A copy event is tracked from window's capture phase, which runs before any app handler can
|
|
1487
|
+
// stop propagation, and settled once dispatch is over: a prevented copy writes what the app
|
|
1488
|
+
// set (seen through setData), an unprevented one writes the selection taken at dispatch.
|
|
1489
|
+
let pending = null;
|
|
1490
|
+
const selected = (event) => {
|
|
1491
|
+
for (const el of [event.target, document.activeElement]) {
|
|
1492
|
+
if (!el || (el.tagName !== 'TEXTAREA' && el.tagName !== 'INPUT')) continue;
|
|
1493
|
+
try {
|
|
1494
|
+
const start = el.selectionStart, end = el.selectionEnd;
|
|
1495
|
+
if (typeof start === 'number' && typeof end === 'number' && end > start) return el.value.slice(start, end);
|
|
1496
|
+
} catch {}
|
|
1497
|
+
}
|
|
1498
|
+
const selection = document.getSelection();
|
|
1499
|
+
return selection ? selection.toString() : '';
|
|
1500
|
+
};
|
|
1501
|
+
const settle = () => {
|
|
1502
|
+
const copy = pending;
|
|
1503
|
+
pending = null;
|
|
1504
|
+
if (!copy) return;
|
|
1505
|
+
if (!copy.event.defaultPrevented) return report('copy-event', copy.selection);
|
|
1506
|
+
if (copy.set !== undefined) return report('copy-event', copy.set);
|
|
1507
|
+
try { report('copy-event', copy.event.clipboardData ? copy.event.clipboardData.getData('text/plain') : ''); } catch {}
|
|
1508
|
+
};
|
|
1509
|
+
if (typeof DataTransfer === 'function' && typeof DataTransfer.prototype.setData === 'function') {
|
|
1510
|
+
const setData = DataTransfer.prototype.setData;
|
|
1511
|
+
DataTransfer.prototype.setData = function (format, data) {
|
|
1512
|
+
if (pending && this === pending.event.clipboardData && /^text(\\/plain)?$/i.test(String(format))) pending.set = String(data);
|
|
1513
|
+
return setData.apply(this, arguments);
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
window.addEventListener('copy', (event) => {
|
|
1517
|
+
settle();
|
|
1518
|
+
pending = { event, selection: selected(event), set: undefined };
|
|
1519
|
+
setTimeout(settle, 0);
|
|
1520
|
+
}, true);
|
|
1521
|
+
})();`;
|
|
1522
|
+
var PLAYWRIGHT_DEBUG_ENV = ["PWDEBUG", "npm_config_pwdebug", "npm_package_config_pwdebug"];
|
|
1523
|
+
var DEBUG_OFF = /* @__PURE__ */ new Set(["", "0", "false"]);
|
|
1524
|
+
function isHeadlessLaunch(launch, env) {
|
|
1525
|
+
if (launch.headless !== true || launch.channel !== void 0) return false;
|
|
1526
|
+
return PLAYWRIGHT_DEBUG_ENV.every((name) => env[name] === void 0 || DEBUG_OFF.has(env[name]));
|
|
1527
|
+
}
|
|
1528
|
+
var OperationCapture = class {
|
|
1529
|
+
completionCapMs;
|
|
1530
|
+
startGraceMs;
|
|
1531
|
+
attachedPages = /* @__PURE__ */ new WeakSet();
|
|
1532
|
+
downloads = [];
|
|
1533
|
+
/** Downloads that started while no start window was open, kept for the next action. */
|
|
1534
|
+
held = [];
|
|
1535
|
+
/** Completed downloads' bytes by sha256, oldest first; memory only (439 D-04). */
|
|
1536
|
+
heldBytes = /* @__PURE__ */ new Map();
|
|
1537
|
+
heldByteTotal = 0;
|
|
1538
|
+
pendingNavigations = /* @__PURE__ */ new Map();
|
|
1539
|
+
clipboardWrites = [];
|
|
1540
|
+
armed = false;
|
|
1541
|
+
startWindow = false;
|
|
1542
|
+
navigationWindow = false;
|
|
1543
|
+
navigationSettledAt = 0;
|
|
1544
|
+
navigationSettledMain = false;
|
|
1545
|
+
actingPage;
|
|
1546
|
+
clipboardArmed = false;
|
|
1547
|
+
activation = false;
|
|
1548
|
+
boundAt = 0;
|
|
1549
|
+
abortSignal;
|
|
1550
|
+
notify = null;
|
|
1551
|
+
appContext = null;
|
|
1552
|
+
probe = null;
|
|
1553
|
+
probeContext = null;
|
|
1554
|
+
probeGeneration = 0;
|
|
1555
|
+
readsClipboard;
|
|
1556
|
+
/** The probe generation holding this action's pre-image; -1 when none was read. */
|
|
1557
|
+
preImageGeneration = -1;
|
|
1558
|
+
/** A probe generation whose read did not settle; skipped until resetProbe() replaces it. */
|
|
1559
|
+
unreadableGeneration = -1;
|
|
1560
|
+
constructor(options = {}) {
|
|
1561
|
+
this.completionCapMs = options.completionCapMs ?? DOWNLOAD_COMPLETION_CAP_MS;
|
|
1562
|
+
this.startGraceMs = options.startGraceMs ?? DOWNLOAD_START_GRACE_MS;
|
|
1563
|
+
this.readsClipboard = options.headless === true;
|
|
1564
|
+
}
|
|
1565
|
+
async attach(context) {
|
|
1566
|
+
this.appContext = context;
|
|
1567
|
+
for (const page of context.pages()) this.attachPage(page);
|
|
1568
|
+
context.on("page", (page) => this.attachPage(page));
|
|
1569
|
+
context.on("request", (request) => this.onRequest(request));
|
|
1570
|
+
context.on("response", (response) => {
|
|
1571
|
+
this.settleNavigation(response.request());
|
|
1572
|
+
});
|
|
1573
|
+
context.on("requestfailed", (request) => {
|
|
1574
|
+
this.settleNavigation(request);
|
|
1575
|
+
});
|
|
1576
|
+
await context.exposeBinding(CLIPBOARD_BINDING, (source, payload) => {
|
|
1577
|
+
this.onClipboardWrite(source.frame, payload);
|
|
1578
|
+
});
|
|
1579
|
+
await context.addInitScript(CLIPBOARD_HOOK);
|
|
1580
|
+
}
|
|
1581
|
+
async arm(action, options = {}, actingPage) {
|
|
1582
|
+
const carried = [...this.downloads.map((entry) => entry.download), ...this.held];
|
|
1583
|
+
this.held = [];
|
|
1584
|
+
this.downloads = carried.map((download) => ({ download, late: true }));
|
|
1585
|
+
this.clipboardWrites = [];
|
|
1586
|
+
this.armed = true;
|
|
1587
|
+
this.startWindow = true;
|
|
1588
|
+
this.clipboardArmed = false;
|
|
1589
|
+
this.activation = isDownloadActivation(action);
|
|
1590
|
+
this.navigationWindow = this.activation;
|
|
1591
|
+
this.navigationSettledAt = 0;
|
|
1592
|
+
this.navigationSettledMain = false;
|
|
1593
|
+
this.actingPage = actingPage;
|
|
1594
|
+
this.pendingNavigations.clear();
|
|
1595
|
+
const remaining = options.remainingStepMs;
|
|
1596
|
+
this.boundAt = Date.now() + (typeof remaining === "number" && Number.isFinite(remaining) ? Math.max(0, remaining) : this.completionCapMs);
|
|
1597
|
+
this.abortSignal = options.abortSignal;
|
|
1598
|
+
this.preImageGeneration = -1;
|
|
1599
|
+
const clipboardAction = !CLIPBOARD_EXCLUDED_ACTIONS.has(action.type);
|
|
1600
|
+
if (this.readsClipboard && clipboardAction && this.timeLeft() > 0) await this.takePreImage();
|
|
1601
|
+
this.clipboardArmed = this.armed && clipboardAction;
|
|
1602
|
+
}
|
|
1603
|
+
timeLeft() {
|
|
1604
|
+
return this.abortSignal?.aborted ? 0 : this.boundAt - Date.now();
|
|
1605
|
+
}
|
|
1606
|
+
// Calls `fire` once when the action's abort signal fires; the returned function removes it.
|
|
1607
|
+
onAbort(fire) {
|
|
1608
|
+
const signal = this.abortSignal;
|
|
1609
|
+
if (!signal) return () => void 0;
|
|
1610
|
+
if (signal.aborted) {
|
|
1611
|
+
queueMicrotask(fire);
|
|
1612
|
+
return () => void 0;
|
|
1613
|
+
}
|
|
1614
|
+
signal.addEventListener("abort", fire, { once: true });
|
|
1615
|
+
return () => {
|
|
1616
|
+
signal.removeEventListener("abort", fire);
|
|
1617
|
+
};
|
|
1618
|
+
}
|
|
1619
|
+
async collect() {
|
|
1620
|
+
if (!this.armed) return void 0;
|
|
1621
|
+
let next = 0;
|
|
1622
|
+
try {
|
|
1623
|
+
await this.waitForStartWindow();
|
|
1624
|
+
this.closeStartWindow();
|
|
1625
|
+
const writes = this.clipboardWrites;
|
|
1626
|
+
this.clipboardArmed = false;
|
|
1627
|
+
this.clipboardWrites = [];
|
|
1628
|
+
const clipboard = !this.readsClipboard ? writes.map(({ source, text }) => ({ source, text, readBack: "unavailable" })) : writes.length > 0 ? await this.readBack(writes) : [];
|
|
1629
|
+
const evidence = [];
|
|
1630
|
+
while (next < this.downloads.length) {
|
|
1631
|
+
const { download, late } = this.downloads[next++];
|
|
1632
|
+
const finished = await this.finish(download);
|
|
1633
|
+
evidence.push(late ? { ...finished, late: true } : finished);
|
|
1634
|
+
}
|
|
1635
|
+
if (evidence.length === 0 && clipboard.length === 0) return void 0;
|
|
1636
|
+
return {
|
|
1637
|
+
...evidence.length > 0 ? { downloads: evidence } : {},
|
|
1638
|
+
...clipboard.length > 0 ? { clipboard } : {}
|
|
1639
|
+
};
|
|
1640
|
+
} finally {
|
|
1641
|
+
this.held.push(...this.downloads.slice(next).map((entry) => entry.download));
|
|
1642
|
+
this.closeStartWindow();
|
|
1643
|
+
this.armed = false;
|
|
1644
|
+
this.clipboardArmed = false;
|
|
1645
|
+
this.downloads = [];
|
|
1646
|
+
this.clipboardWrites = [];
|
|
1647
|
+
this.abortSignal = void 0;
|
|
1648
|
+
this.actingPage = void 0;
|
|
1649
|
+
this.preImageGeneration = -1;
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
// The grace after an activation action, then for as long as a navigation it started has neither
|
|
1653
|
+
// a response nor a failure, plus NAVIGATION_SETTLE_MS after the last answer; all bounded by the
|
|
1654
|
+
// action's time left. Fixed when the wait begins: the acting page's main-frame navigation ends at
|
|
1655
|
+
// mainEnd (half the time left), any other at capEnd (also NAVIGATION_WINDOW_CAP_MS).
|
|
1656
|
+
async waitForStartWindow() {
|
|
1657
|
+
if (!this.activation) return;
|
|
1658
|
+
const start = Date.now();
|
|
1659
|
+
const graceEnd = start + Math.min(this.startGraceMs, Math.max(0, this.timeLeft()));
|
|
1660
|
+
const reserve = Math.max(0, this.timeLeft()) / 2;
|
|
1661
|
+
const capEnd = start + Math.min(NAVIGATION_WINDOW_CAP_MS, reserve);
|
|
1662
|
+
const mainEnd = start + reserve;
|
|
1663
|
+
for (; ; ) {
|
|
1664
|
+
const left = this.timeLeft();
|
|
1665
|
+
if (left <= 0) return;
|
|
1666
|
+
const now = Date.now();
|
|
1667
|
+
const settleEnd = Math.min(
|
|
1668
|
+
this.navigationSettledAt + NAVIGATION_SETTLE_MS,
|
|
1669
|
+
this.navigationSettledMain ? mainEnd : capEnd
|
|
1670
|
+
);
|
|
1671
|
+
const openFor = Math.max(graceEnd, settleEnd) - now;
|
|
1672
|
+
const pendingEnd = this.pendingNavigationEnd(mainEnd, capEnd);
|
|
1673
|
+
if (openFor > 0) await this.waitForSignal(Math.min(openFor, left));
|
|
1674
|
+
else if (now < pendingEnd) await this.waitForSignal(Math.min(pendingEnd - now, left));
|
|
1675
|
+
else return;
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
// mainEnd while a main-frame navigation of the acting page is pending, else capEnd while any
|
|
1679
|
+
// other is, else 0.
|
|
1680
|
+
pendingNavigationEnd(mainEnd, capEnd) {
|
|
1681
|
+
let end = 0;
|
|
1682
|
+
for (const { main } of this.pendingNavigations.values()) {
|
|
1683
|
+
if (main) return mainEnd;
|
|
1684
|
+
end = capEnd;
|
|
1685
|
+
}
|
|
1686
|
+
return end;
|
|
1687
|
+
}
|
|
1688
|
+
closeStartWindow() {
|
|
1689
|
+
this.startWindow = false;
|
|
1690
|
+
this.navigationWindow = false;
|
|
1691
|
+
this.pendingNavigations.clear();
|
|
1692
|
+
}
|
|
1693
|
+
onRequest(request) {
|
|
1694
|
+
if (!this.navigationWindow || !request.isNavigationRequest()) return;
|
|
1695
|
+
let page;
|
|
1696
|
+
let main = false;
|
|
1697
|
+
try {
|
|
1698
|
+
const frame = request.frame();
|
|
1699
|
+
page = frame.page();
|
|
1700
|
+
main = this.actingPage !== void 0 && frame === this.actingPage.mainFrame();
|
|
1701
|
+
} catch {
|
|
1702
|
+
}
|
|
1703
|
+
this.pendingNavigations.set(request, { page, main });
|
|
1704
|
+
}
|
|
1705
|
+
settleNavigation(request) {
|
|
1706
|
+
const pending = this.pendingNavigations.get(request);
|
|
1707
|
+
if (!pending) return;
|
|
1708
|
+
this.pendingNavigations.delete(request);
|
|
1709
|
+
this.navigationSettledAt = Date.now();
|
|
1710
|
+
this.navigationSettledMain = pending.main;
|
|
1711
|
+
this.notify?.();
|
|
1712
|
+
}
|
|
1713
|
+
onClipboardWrite(frame, payload) {
|
|
1714
|
+
if (!this.armed || !this.clipboardArmed) return;
|
|
1715
|
+
if (!payload || typeof payload !== "object") return;
|
|
1716
|
+
const { via, text } = payload;
|
|
1717
|
+
if (typeof via !== "string" || !CLIPBOARD_SOURCES.has(via) || typeof text !== "string") return;
|
|
1718
|
+
this.clipboardWrites.push({ source: via, text, frame });
|
|
1719
|
+
if (this.clipboardWrites.length > CLIPBOARD_CLAIMS_PER_ACTION) this.clipboardWrites.shift();
|
|
1720
|
+
}
|
|
1721
|
+
// Takes the probe page's pre-image of the clipboard; a closed or crashed probe is replaced once.
|
|
1722
|
+
async takePreImage() {
|
|
1723
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1724
|
+
const probe = await this.openProbe();
|
|
1725
|
+
if (!probe) return;
|
|
1726
|
+
if (probe.isClosed()) {
|
|
1727
|
+
await this.resetProbe();
|
|
1728
|
+
continue;
|
|
1729
|
+
}
|
|
1730
|
+
const generation = this.probeGeneration;
|
|
1731
|
+
const bound = Math.min(READ_BACK_TIMEOUT_MS, this.timeLeft());
|
|
1732
|
+
if (generation === this.unreadableGeneration || bound <= 0) return;
|
|
1733
|
+
try {
|
|
1734
|
+
const status = await this.bounded(
|
|
1735
|
+
probe.evaluate(() => globalThis.__agentQaReadback.snapshot()),
|
|
1736
|
+
bound
|
|
1737
|
+
);
|
|
1738
|
+
if (status === void 0) this.markUnreadable(generation, bound);
|
|
1739
|
+
else if (status === "read" && generation === this.probeGeneration) this.preImageGeneration = generation;
|
|
1740
|
+
return;
|
|
1741
|
+
} catch {
|
|
1742
|
+
await this.resetProbe();
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
// Only the final claim is compared, against a clipboard that changed during the action; an
|
|
1747
|
+
// earlier claim is matched only when its text equals a matched final claim.
|
|
1748
|
+
async readBack(writes) {
|
|
1749
|
+
const last = writes.length - 1;
|
|
1750
|
+
const final = writes[last];
|
|
1751
|
+
const answer = await this.readClipboard(final.frame, final.text);
|
|
1752
|
+
return writes.map(({ source, text }, position) => ({
|
|
1753
|
+
source,
|
|
1754
|
+
text,
|
|
1755
|
+
readBack: answer === PROBE_MATCHED && text === final.text ? "matched" : answer === PROBE_DIFFERENT && position === last ? "mismatched" : "unavailable"
|
|
1756
|
+
}));
|
|
1757
|
+
}
|
|
1758
|
+
// PROBE_MATCHED or PROBE_DIFFERENT when the clipboard changed during the action, else undefined.
|
|
1759
|
+
// Only the final claim's text enters the probe page and only a number comes back.
|
|
1760
|
+
async readClipboard(frame, finalText) {
|
|
1761
|
+
const generation = this.preImageGeneration;
|
|
1762
|
+
if (generation < 0 || generation !== this.probeGeneration || generation === this.unreadableGeneration) {
|
|
1763
|
+
return void 0;
|
|
1764
|
+
}
|
|
1765
|
+
if (frame.isDetached()) return void 0;
|
|
1766
|
+
try {
|
|
1767
|
+
if (new URL(frame.url()).origin === "null") return void 0;
|
|
1768
|
+
} catch {
|
|
1769
|
+
return void 0;
|
|
1770
|
+
}
|
|
1771
|
+
const frameBound = Math.min(READ_BACK_TIMEOUT_MS, this.timeLeft());
|
|
1772
|
+
if (frameBound <= 0) return void 0;
|
|
1773
|
+
const secure = await this.bounded(
|
|
1774
|
+
frame.evaluate("window.isSecureContext && !!(navigator.clipboard && navigator.clipboard.readText)"),
|
|
1775
|
+
frameBound
|
|
1776
|
+
).catch(() => false);
|
|
1777
|
+
if (secure !== true) return void 0;
|
|
1778
|
+
const probe = await this.openProbe();
|
|
1779
|
+
const bound = Math.min(READ_BACK_TIMEOUT_MS, this.timeLeft());
|
|
1780
|
+
if (!probe || generation !== this.probeGeneration || bound <= 0) return void 0;
|
|
1781
|
+
let answer;
|
|
1782
|
+
try {
|
|
1783
|
+
if (probe.isClosed()) throw new Error("probe closed");
|
|
1784
|
+
answer = await this.bounded(
|
|
1785
|
+
probe.evaluate((text) => globalThis.__agentQaReadback.compare(text), finalText),
|
|
1786
|
+
bound
|
|
1787
|
+
);
|
|
1788
|
+
} catch {
|
|
1789
|
+
await this.resetProbe();
|
|
1790
|
+
return void 0;
|
|
1791
|
+
}
|
|
1792
|
+
if (answer === void 0) this.markUnreadable(generation, bound);
|
|
1793
|
+
return answer === PROBE_MATCHED || answer === PROBE_DIFFERENT ? answer : void 0;
|
|
1794
|
+
}
|
|
1795
|
+
// A read cut short by the step's time or its abort says nothing about the probe.
|
|
1796
|
+
markUnreadable(generation, bound) {
|
|
1797
|
+
if (bound >= READ_BACK_TIMEOUT_MS && !this.abortSignal?.aborted) this.unreadableGeneration = generation;
|
|
1798
|
+
}
|
|
1799
|
+
// Resolves undefined after `ms` or on the action's abort; rejects when `promise` rejects.
|
|
1800
|
+
async bounded(promise, ms) {
|
|
1801
|
+
let off = () => void 0;
|
|
1802
|
+
try {
|
|
1803
|
+
return await Promise.race([
|
|
1804
|
+
withTimeout(promise, ms),
|
|
1805
|
+
new Promise((resolve) => {
|
|
1806
|
+
off = this.onAbort(() => {
|
|
1807
|
+
resolve(void 0);
|
|
1808
|
+
});
|
|
1809
|
+
})
|
|
1810
|
+
]);
|
|
1811
|
+
} finally {
|
|
1812
|
+
off();
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
openProbe() {
|
|
1816
|
+
if (!this.readsClipboard) return Promise.resolve(void 0);
|
|
1817
|
+
this.probe ??= this.createProbe(this.probeGeneration);
|
|
1818
|
+
return this.probe;
|
|
1819
|
+
}
|
|
1820
|
+
// A separate context of the app's browser: only the reserved origin loads (a blank document, no
|
|
1821
|
+
// init script), every other request is aborted, and only that origin may read the clipboard.
|
|
1822
|
+
async createProbe(generation) {
|
|
1823
|
+
let context;
|
|
1824
|
+
try {
|
|
1825
|
+
const browser = this.appContext?.browser();
|
|
1826
|
+
if (!browser) return void 0;
|
|
1827
|
+
context = await browser.newContext();
|
|
1828
|
+
if (generation !== this.probeGeneration) {
|
|
1829
|
+
await context.close().catch(() => void 0);
|
|
1830
|
+
return void 0;
|
|
1831
|
+
}
|
|
1832
|
+
this.probeContext = context;
|
|
1833
|
+
await context.route("**/*", (route) => {
|
|
1834
|
+
let origin = "";
|
|
1835
|
+
try {
|
|
1836
|
+
origin = new URL(route.request().url()).origin;
|
|
1837
|
+
} catch {
|
|
1838
|
+
}
|
|
1839
|
+
return origin === CLIPBOARD_READBACK_ORIGIN ? route.fulfill({ contentType: "text/html", body: "<!doctype html>" }) : route.abort();
|
|
1840
|
+
});
|
|
1841
|
+
await context.grantPermissions(["clipboard-read"], { origin: CLIPBOARD_READBACK_ORIGIN });
|
|
1842
|
+
const page = await context.newPage();
|
|
1843
|
+
page.once("crash", () => {
|
|
1844
|
+
if (generation === this.probeGeneration) void this.resetProbe();
|
|
1845
|
+
});
|
|
1846
|
+
await page.goto(`${CLIPBOARD_READBACK_ORIGIN}/`);
|
|
1847
|
+
await page.evaluate(installProbeReadback);
|
|
1848
|
+
return page;
|
|
1849
|
+
} catch {
|
|
1850
|
+
if (generation === this.probeGeneration) {
|
|
1851
|
+
this.probe = null;
|
|
1852
|
+
this.probeContext = null;
|
|
1853
|
+
}
|
|
1854
|
+
await context?.close().catch(() => void 0);
|
|
1855
|
+
return void 0;
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
/** The bytes of a completed download with this sha256 while the hold keeps them. */
|
|
1859
|
+
readDownloadBytes(sha256) {
|
|
1860
|
+
return this.heldBytes.get(sha256);
|
|
1861
|
+
}
|
|
1862
|
+
holdBytes(sha256, bytes) {
|
|
1863
|
+
if (bytes.length > DOWNLOAD_HOLD_FILE_CAP) return;
|
|
1864
|
+
const previous = this.heldBytes.get(sha256);
|
|
1865
|
+
if (previous !== void 0) {
|
|
1866
|
+
this.heldBytes.delete(sha256);
|
|
1867
|
+
this.heldByteTotal -= previous.length;
|
|
1868
|
+
}
|
|
1869
|
+
this.heldBytes.set(sha256, bytes);
|
|
1870
|
+
this.heldByteTotal += bytes.length;
|
|
1871
|
+
for (const [key, value] of this.heldBytes) {
|
|
1872
|
+
if (this.heldByteTotal <= DOWNLOAD_HOLD_TOTAL_CAP) break;
|
|
1873
|
+
this.heldBytes.delete(key);
|
|
1874
|
+
this.heldByteTotal -= value.length;
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
/** Cancels and deletes every held download, clears the held bytes, then closes the read-back probe context. */
|
|
1878
|
+
async dispose() {
|
|
1879
|
+
this.heldBytes.clear();
|
|
1880
|
+
this.heldByteTotal = 0;
|
|
1881
|
+
const held = this.held;
|
|
1882
|
+
this.held = [];
|
|
1883
|
+
for (const download of held) {
|
|
1884
|
+
await download.cancel().catch(() => void 0);
|
|
1885
|
+
await download.delete().catch(() => void 0);
|
|
1886
|
+
}
|
|
1887
|
+
await this.resetProbe();
|
|
1888
|
+
}
|
|
1889
|
+
// Closes only the probe context; held downloads are never touched here.
|
|
1890
|
+
async resetProbe() {
|
|
1891
|
+
const context = this.probeContext;
|
|
1892
|
+
this.probeGeneration += 1;
|
|
1893
|
+
this.unreadableGeneration = -1;
|
|
1894
|
+
this.probe = null;
|
|
1895
|
+
this.probeContext = null;
|
|
1896
|
+
await context?.close().catch(() => void 0);
|
|
1897
|
+
}
|
|
1898
|
+
attachPage(page) {
|
|
1899
|
+
if (this.attachedPages.has(page)) return;
|
|
1900
|
+
this.attachedPages.add(page);
|
|
1901
|
+
page.on("download", (download) => {
|
|
1902
|
+
if (!this.startWindow) {
|
|
1903
|
+
this.held.push(download);
|
|
1904
|
+
return;
|
|
1905
|
+
}
|
|
1906
|
+
this.downloads.push({ download, late: false });
|
|
1907
|
+
this.notify?.();
|
|
1908
|
+
});
|
|
1909
|
+
page.on("close", () => {
|
|
1910
|
+
for (const [request, pending] of this.pendingNavigations) if (pending.page === page) this.settleNavigation(request);
|
|
1911
|
+
});
|
|
1912
|
+
}
|
|
1913
|
+
// Resolves on a download start, a settled navigation, the abort, or after `ms`.
|
|
1914
|
+
async waitForSignal(ms) {
|
|
1915
|
+
let off = () => void 0;
|
|
1916
|
+
await new Promise((resolve) => {
|
|
1917
|
+
const timer = setTimeout(done, ms);
|
|
1918
|
+
function done() {
|
|
1919
|
+
clearTimeout(timer);
|
|
1920
|
+
resolve();
|
|
1921
|
+
}
|
|
1922
|
+
this.notify = done;
|
|
1923
|
+
off = this.onAbort(done);
|
|
1924
|
+
});
|
|
1925
|
+
off();
|
|
1926
|
+
this.notify = null;
|
|
1927
|
+
}
|
|
1928
|
+
async finish(download) {
|
|
1929
|
+
const suggestedFilename = download.suggestedFilename();
|
|
1930
|
+
const base = {
|
|
1931
|
+
suggestedFilename,
|
|
1932
|
+
extension: extname(suggestedFilename)
|
|
1933
|
+
};
|
|
1934
|
+
let timer;
|
|
1935
|
+
let off = () => void 0;
|
|
1936
|
+
const outcome = await Promise.race([
|
|
1937
|
+
download.failure().catch((error) => error instanceof Error ? error.message : String(error)),
|
|
1938
|
+
new Promise((resolve) => {
|
|
1939
|
+
const end = () => {
|
|
1940
|
+
resolve(UNFINISHED);
|
|
1941
|
+
};
|
|
1942
|
+
timer = setTimeout(end, Math.max(0, this.timeLeft()));
|
|
1943
|
+
off = this.onAbort(end);
|
|
1944
|
+
})
|
|
1945
|
+
]);
|
|
1946
|
+
clearTimeout(timer);
|
|
1947
|
+
off();
|
|
1948
|
+
if (outcome === UNFINISHED) {
|
|
1949
|
+
await download.cancel().catch(() => void 0);
|
|
1950
|
+
await download.delete().catch(() => void 0);
|
|
1951
|
+
return { ...base, state: "unfinished" };
|
|
1952
|
+
}
|
|
1953
|
+
if (outcome !== null) {
|
|
1954
|
+
await download.delete().catch(() => void 0);
|
|
1955
|
+
return { ...base, state: outcome === "canceled" ? "canceled" : "failed", failure: outcome };
|
|
1956
|
+
}
|
|
1957
|
+
try {
|
|
1958
|
+
const { size, sha256, bytes } = await digest(download);
|
|
1959
|
+
if (bytes !== void 0) this.holdBytes(sha256, bytes);
|
|
1960
|
+
return { ...base, size, sha256, state: "completed" };
|
|
1961
|
+
} catch (error) {
|
|
1962
|
+
return { ...base, state: "failed", failure: error instanceof Error ? error.message : String(error) };
|
|
1963
|
+
} finally {
|
|
1964
|
+
await download.delete().catch(() => void 0);
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
};
|
|
1968
|
+
async function withTimeout(promise, ms) {
|
|
1969
|
+
let timer;
|
|
1970
|
+
try {
|
|
1971
|
+
return await Promise.race([
|
|
1972
|
+
promise,
|
|
1973
|
+
new Promise((resolve) => {
|
|
1974
|
+
timer = setTimeout(() => resolve(void 0), ms);
|
|
1975
|
+
})
|
|
1976
|
+
]);
|
|
1977
|
+
} finally {
|
|
1978
|
+
clearTimeout(timer);
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
async function digest(download) {
|
|
1982
|
+
const stream = await download.createReadStream();
|
|
1983
|
+
const hash = createHash("sha256");
|
|
1984
|
+
let size = 0;
|
|
1985
|
+
let chunks = [];
|
|
1986
|
+
for await (const chunk of stream) {
|
|
1987
|
+
const bytes = chunk;
|
|
1988
|
+
size += bytes.length;
|
|
1989
|
+
hash.update(bytes);
|
|
1990
|
+
if (size > DOWNLOAD_HOLD_FILE_CAP) chunks = void 0;
|
|
1991
|
+
else chunks?.push(bytes);
|
|
1992
|
+
}
|
|
1993
|
+
const sha256 = hash.digest("hex");
|
|
1994
|
+
return chunks === void 0 ? { size, sha256 } : { size, sha256, bytes: new Uint8Array(Buffer.concat(chunks)) };
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
// src/adapter.ts
|
|
1998
|
+
var OBSERVE_SNAPSHOT_TIMEOUT_MS = 5e3;
|
|
1999
|
+
var OBSERVE_SNAPSHOT_RETRY_TIMEOUT_MS = 3e4;
|
|
2000
|
+
var OBSERVE_SNAPSHOT = {
|
|
2001
|
+
timeoutMs: OBSERVE_SNAPSHOT_TIMEOUT_MS,
|
|
2002
|
+
retryTimeoutMs: OBSERVE_SNAPSHOT_RETRY_TIMEOUT_MS
|
|
2003
|
+
};
|
|
2004
|
+
var SCREENSHOT_TIMEOUT_MS = 5e3;
|
|
2005
|
+
function withOperationMetadata(result, captured) {
|
|
2006
|
+
const download = captured?.downloads?.length ? captured.downloads : void 0;
|
|
2007
|
+
const clipboard = captured?.clipboard?.length ? captured.clipboard : void 0;
|
|
2008
|
+
if (!download && !clipboard) return result;
|
|
2009
|
+
return {
|
|
2010
|
+
...result,
|
|
2011
|
+
metadata: { ...result.metadata, ...download ? { download } : {}, ...clipboard ? { clipboard } : {} }
|
|
2012
|
+
};
|
|
2013
|
+
}
|
|
2014
|
+
function clickDelayOption(clickDelay) {
|
|
2015
|
+
return clickDelay !== void 0 && clickDelay > 0 ? { delay: clickDelay } : {};
|
|
2016
|
+
}
|
|
2017
|
+
function readSeamStorageState(action) {
|
|
2018
|
+
return action.state;
|
|
2019
|
+
}
|
|
2020
|
+
var SCREENCAST_PARAMS = {
|
|
2021
|
+
format: "jpeg",
|
|
2022
|
+
quality: 60,
|
|
2023
|
+
maxWidth: 1280,
|
|
2024
|
+
maxHeight: 720,
|
|
2025
|
+
everyNthFrame: 1
|
|
2026
|
+
};
|
|
2027
|
+
var SCREENCAST_ACK_WINDOW = 64;
|
|
2028
|
+
var KEEPALIVE_ECHO_WINDOW_MS = 250;
|
|
2029
|
+
var SCREENCAST_RECOVERY_MS = 1e4;
|
|
2030
|
+
function decodeBase64(data) {
|
|
2031
|
+
const binary = atob(data);
|
|
2032
|
+
const bytes = new Uint8Array(binary.length);
|
|
2033
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
2034
|
+
return bytes;
|
|
2035
|
+
}
|
|
2036
|
+
var WebPlatformAdapter = class {
|
|
2037
|
+
constructor(options = {}) {
|
|
2038
|
+
this.options = options;
|
|
2039
|
+
}
|
|
2040
|
+
platform = "web";
|
|
2041
|
+
browser = null;
|
|
2042
|
+
context = null;
|
|
2043
|
+
page = null;
|
|
2044
|
+
// The native binding of the last observation and its document (438 D-06). Neither crosses the
|
|
2045
|
+
// port (observe returns only the ScreenState).
|
|
705
2046
|
lastRefs = {};
|
|
2047
|
+
epoch;
|
|
706
2048
|
pageLogBuffers = /* @__PURE__ */ new Map();
|
|
707
2049
|
logCaptureConfig = { console: true, network: true };
|
|
708
2050
|
disconnected = false;
|
|
709
2051
|
intentionalClose = false;
|
|
2052
|
+
// Persistent route interception + one-shot dialog arms live until an explicit unroute or
|
|
2053
|
+
// test teardown (D-12/D-18); cleanup() drains both so nothing bleeds across tests (Pitfall 7).
|
|
2054
|
+
armedRoutes = /* @__PURE__ */ new Set();
|
|
2055
|
+
pendingDialogArm = null;
|
|
2056
|
+
// 403 D-05: the live screencast's CDP session, owned here and never handed out.
|
|
2057
|
+
browserName = "chromium";
|
|
2058
|
+
screencast = null;
|
|
2059
|
+
screencastCapability = null;
|
|
2060
|
+
recordingCapability;
|
|
2061
|
+
recordingPageRevision = 0;
|
|
2062
|
+
recordingConfig;
|
|
2063
|
+
// 403-02: the PRODUCT's frame identity, and the CDP ack token it maps to. See `liveStream`.
|
|
2064
|
+
screencastFrameSeq = 0;
|
|
2065
|
+
screencastAckTokens = /* @__PURE__ */ new Map();
|
|
2066
|
+
// 428 D-15: the running screencast's consumer, its quiet-page keepalive, and the size a keepalive
|
|
2067
|
+
// frame reports (the last screencast frame's device size).
|
|
2068
|
+
screencastOnFrame = null;
|
|
2069
|
+
keepaliveTimer = null;
|
|
2070
|
+
lastScreencastSize = null;
|
|
2071
|
+
keepaliveEchoDeadline = null;
|
|
2072
|
+
screencastChain = Promise.resolve();
|
|
2073
|
+
// 428 D-12: the labelled snapshot preview standing in for a screencast withdrawn mid-stream.
|
|
2074
|
+
withdrawnSnapshot = null;
|
|
2075
|
+
screencastRecoveryTimer = null;
|
|
2076
|
+
// 436 D-19, D-21: download and app-clipboard capture, Chromium only; armed around each execute().
|
|
2077
|
+
operationCapture = null;
|
|
2078
|
+
// 436 D-23: Firefox and WebKit run no capture and name themselves as the reason.
|
|
2079
|
+
operationCaptureSupport;
|
|
710
2080
|
async setup(config) {
|
|
2081
|
+
this.recordingConfig = config.recording;
|
|
711
2082
|
const { chromium, firefox, webkit } = await import("playwright-core");
|
|
712
2083
|
const launchers = { chromium, firefox, webkit };
|
|
713
2084
|
const browserName = config.browser?.name ?? "chromium";
|
|
2085
|
+
this.browserName = browserName;
|
|
714
2086
|
const launcher = launchers[browserName];
|
|
2087
|
+
const headless = config.browser?.headless ?? true;
|
|
715
2088
|
try {
|
|
716
2089
|
this.browser = await launcher.launch({
|
|
717
|
-
headless
|
|
718
|
-
...browserName === "chromium" ? { args: ["--disable-blink-features=AutomationControlled"] } : {}
|
|
2090
|
+
headless,
|
|
2091
|
+
...browserName === "chromium" ? { args: ["--disable-blink-features=AutomationControlled"] } : {},
|
|
2092
|
+
...this.options.hostOwnsSignals === true ? { handleSIGINT: false, handleSIGTERM: false, handleSIGHUP: false } : {}
|
|
719
2093
|
});
|
|
720
2094
|
} catch (error) {
|
|
721
2095
|
if (isPlaywrightMissingBrowserError(error)) {
|
|
@@ -733,12 +2107,6 @@ var WebPlatformAdapter = class {
|
|
|
733
2107
|
const contextOptions = {
|
|
734
2108
|
viewport: config.browser?.viewport ?? { width: 1280, height: 720 }
|
|
735
2109
|
};
|
|
736
|
-
if (config.recording?.enabled) {
|
|
737
|
-
contextOptions.recordVideo = {
|
|
738
|
-
dir: config.recording.videoDir,
|
|
739
|
-
size: config.recording.videoSize ?? config.browser?.viewport ?? { width: 1280, height: 720 }
|
|
740
|
-
};
|
|
741
|
-
}
|
|
742
2110
|
if (config.authState?.storageStatePath) {
|
|
743
2111
|
contextOptions.storageState = config.authState.storageStatePath;
|
|
744
2112
|
}
|
|
@@ -752,19 +2120,367 @@ var WebPlatformAdapter = class {
|
|
|
752
2120
|
});
|
|
753
2121
|
this.page = await this.context.newPage();
|
|
754
2122
|
this.attachLogListeners(this.page);
|
|
2123
|
+
if (browserName === "chromium") {
|
|
2124
|
+
this.operationCapture = new OperationCapture({ headless: isHeadlessLaunch({ headless }, process.env) });
|
|
2125
|
+
await this.operationCapture.attach(this.context);
|
|
2126
|
+
this.operationCaptureSupport = { download: true, clipboard: true };
|
|
2127
|
+
} else {
|
|
2128
|
+
this.operationCaptureSupport = { download: false, clipboard: false, reason: browserName };
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
/**
|
|
2132
|
+
* 403 D-05/LIVE-01: the Chromium screencast producer, or ABSENT when this adapter cannot stream.
|
|
2133
|
+
*
|
|
2134
|
+
* Absence means "no continuous stream", not "no preview": since 428-05 the composition's
|
|
2135
|
+
* `liveStreamFor` wraps this adapter's `screenshot()` in the labelled 1 s snapshot producer, both
|
|
2136
|
+
* as the fallback behind a screencast that fails to start and as the whole preview on a
|
|
2137
|
+
* non-Chromium target. The two gates here (Chromium only, and a page to stream) stay exactly as
|
|
2138
|
+
* they are, so the absence stays honest.
|
|
2139
|
+
*
|
|
2140
|
+
* SCOPE (428 D-18, closes WIN #273): the screencast shows the page the test is acting on. Both
|
|
2141
|
+
* page-switch sites (`tab new`, `tab switch`) go through `setActivePage`, which re-binds the
|
|
2142
|
+
* running screencast to the new page INSIDE this same capability object: no stop, no re-register,
|
|
2143
|
+
* no stream end, so a subscriber only sees the picture change. Start, stop and re-bind run on one
|
|
2144
|
+
* serialised chain. A popup or a closing tab never changes `this.page` in this adapter, so the
|
|
2145
|
+
* preview already shows the acting page in those cases.
|
|
2146
|
+
*/
|
|
2147
|
+
get liveStream() {
|
|
2148
|
+
if (this.browserName !== "chromium") return void 0;
|
|
2149
|
+
if (!this.page || !this.context) return void 0;
|
|
2150
|
+
if (this.screencastCapability) return this.screencastCapability;
|
|
2151
|
+
const capability = {
|
|
2152
|
+
kind: "cdp-screencast",
|
|
2153
|
+
start: (onFrame) => this.onScreencastChain(async () => {
|
|
2154
|
+
if (this.screencastOnFrame !== null) return;
|
|
2155
|
+
const page = this.page;
|
|
2156
|
+
const context = this.context;
|
|
2157
|
+
if (!page || !context) return;
|
|
2158
|
+
const cdp = await context.newCDPSession(page);
|
|
2159
|
+
this.screencast = cdp;
|
|
2160
|
+
this.screencastOnFrame = onFrame;
|
|
2161
|
+
this.screencastAckTokens.clear();
|
|
2162
|
+
this.attachScreencast(cdp, onFrame);
|
|
2163
|
+
try {
|
|
2164
|
+
await cdp.send("Page.startScreencast", { ...SCREENCAST_PARAMS });
|
|
2165
|
+
} catch (error) {
|
|
2166
|
+
this.screencast = null;
|
|
2167
|
+
this.screencastOnFrame = null;
|
|
2168
|
+
try {
|
|
2169
|
+
await cdp.detach();
|
|
2170
|
+
} catch {
|
|
2171
|
+
}
|
|
2172
|
+
throw error;
|
|
2173
|
+
}
|
|
2174
|
+
if (this.screencast === cdp) this.armKeepalive(cdp);
|
|
2175
|
+
}),
|
|
2176
|
+
ack: async (frameId) => {
|
|
2177
|
+
const cdp = this.screencast;
|
|
2178
|
+
if (!cdp) return;
|
|
2179
|
+
const sessionId = this.screencastAckTokens.get(frameId);
|
|
2180
|
+
this.screencastAckTokens.delete(frameId);
|
|
2181
|
+
if (sessionId === void 0) return;
|
|
2182
|
+
try {
|
|
2183
|
+
await cdp.send("Page.screencastFrameAck", { sessionId });
|
|
2184
|
+
} catch {
|
|
2185
|
+
}
|
|
2186
|
+
},
|
|
2187
|
+
stop: () => this.onScreencastChain(async () => {
|
|
2188
|
+
this.screencastOnFrame = null;
|
|
2189
|
+
await this.stopWithdrawnSnapshot();
|
|
2190
|
+
await this.detachScreencast();
|
|
2191
|
+
})
|
|
2192
|
+
};
|
|
2193
|
+
this.screencastCapability = capability;
|
|
2194
|
+
return capability;
|
|
2195
|
+
}
|
|
2196
|
+
/** Run `step` after every earlier start, stop and re-bind has settled (428 D-18). */
|
|
2197
|
+
onScreencastChain(step) {
|
|
2198
|
+
const run = this.screencastChain.then(step);
|
|
2199
|
+
this.screencastChain = run.catch(() => void 0);
|
|
2200
|
+
return run;
|
|
2201
|
+
}
|
|
2202
|
+
/** Stop and detach the current CDP session, best-effort, and forget its tokens and keepalive. */
|
|
2203
|
+
async detachScreencast() {
|
|
2204
|
+
const cdp = this.screencast;
|
|
2205
|
+
this.screencast = null;
|
|
2206
|
+
this.clearKeepalive();
|
|
2207
|
+
this.keepaliveEchoDeadline = null;
|
|
2208
|
+
this.screencastAckTokens.clear();
|
|
2209
|
+
if (!cdp) return;
|
|
2210
|
+
try {
|
|
2211
|
+
await cdp.send("Page.stopScreencast");
|
|
2212
|
+
} catch {
|
|
2213
|
+
}
|
|
2214
|
+
try {
|
|
2215
|
+
await cdp.detach();
|
|
2216
|
+
} catch {
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
/** Wire `cdp`'s screencast frames to `onFrame`; shared by `start` and `rebindScreencast`. */
|
|
2220
|
+
attachScreencast(cdp, onFrame) {
|
|
2221
|
+
cdp.on("Page.screencastFrame", (event) => {
|
|
2222
|
+
if (this.screencast !== cdp) return;
|
|
2223
|
+
const metadata = event.metadata;
|
|
2224
|
+
this.lastScreencastSize = { width: metadata.deviceWidth, height: metadata.deviceHeight };
|
|
2225
|
+
this.armKeepalive(cdp);
|
|
2226
|
+
const echoDeadline = this.keepaliveEchoDeadline;
|
|
2227
|
+
this.keepaliveEchoDeadline = null;
|
|
2228
|
+
if (echoDeadline !== null && Date.now() <= echoDeadline) {
|
|
2229
|
+
void cdp.send("Page.screencastFrameAck", { sessionId: event.sessionId }).catch(() => void 0);
|
|
2230
|
+
return;
|
|
2231
|
+
}
|
|
2232
|
+
const id = ++this.screencastFrameSeq;
|
|
2233
|
+
this.screencastAckTokens.set(id, event.sessionId);
|
|
2234
|
+
while (this.screencastAckTokens.size > SCREENCAST_ACK_WINDOW) {
|
|
2235
|
+
const oldest = this.screencastAckTokens.keys().next();
|
|
2236
|
+
if (oldest.done === true) break;
|
|
2237
|
+
const token = this.screencastAckTokens.get(oldest.value);
|
|
2238
|
+
this.screencastAckTokens.delete(oldest.value);
|
|
2239
|
+
if (token !== void 0) {
|
|
2240
|
+
void cdp.send("Page.screencastFrameAck", { sessionId: token }).catch(() => void 0);
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
onFrame({
|
|
2244
|
+
id,
|
|
2245
|
+
bytes: decodeBase64(event.data),
|
|
2246
|
+
metadata: {
|
|
2247
|
+
width: metadata.deviceWidth,
|
|
2248
|
+
height: metadata.deviceHeight,
|
|
2249
|
+
// CDP's `TimeSinceEpoch` is SECONDS; every other layer reads epoch ms (428 D-15).
|
|
2250
|
+
...metadata.timestamp === void 0 ? {} : { timestamp: Math.round(metadata.timestamp * 1e3) },
|
|
2251
|
+
scrollOffsetX: metadata.scrollOffsetX,
|
|
2252
|
+
scrollOffsetY: metadata.scrollOffsetY
|
|
2253
|
+
}
|
|
2254
|
+
});
|
|
2255
|
+
});
|
|
2256
|
+
}
|
|
2257
|
+
/**
|
|
2258
|
+
* The one place the page the test acts on changes (428 D-18). Recording keeps following its
|
|
2259
|
+
* starting page through the revision counter (424 reconciliation 25(d)); the live screencast,
|
|
2260
|
+
* when running, re-binds to the new page on the serialised chain.
|
|
2261
|
+
*/
|
|
2262
|
+
setActivePage(page) {
|
|
2263
|
+
this.page = page;
|
|
2264
|
+
this.recordingPageRevision++;
|
|
2265
|
+
if (this.screencastOnFrame !== null) {
|
|
2266
|
+
void this.onScreencastChain(() => this.rebindScreencast());
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
/**
|
|
2270
|
+
* Move the running screencast onto `this.page` without its subscribers noticing: the old session
|
|
2271
|
+
* is stopped and detached, a new one is started with the stored `onFrame`, and the id sequence
|
|
2272
|
+
* continues. Ids still pending in the hub settle through its ack bound and are dropped here as
|
|
2273
|
+
* unknown. Best-effort throughout; it never throws into a step.
|
|
2274
|
+
*/
|
|
2275
|
+
async rebindScreencast(options = {}) {
|
|
2276
|
+
const onFrame = this.screencastOnFrame;
|
|
2277
|
+
const page = this.page;
|
|
2278
|
+
const context = this.context;
|
|
2279
|
+
if (onFrame === null || !page || !context) return;
|
|
2280
|
+
await this.detachScreencast();
|
|
2281
|
+
this.lastScreencastSize = null;
|
|
2282
|
+
if (options.focus !== false) {
|
|
2283
|
+
try {
|
|
2284
|
+
await page.bringToFront();
|
|
2285
|
+
} catch {
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
let cdp;
|
|
2289
|
+
try {
|
|
2290
|
+
cdp = await context.newCDPSession(page);
|
|
2291
|
+
} catch {
|
|
2292
|
+
this.handleScreencastWithdrawn(null);
|
|
2293
|
+
return;
|
|
2294
|
+
}
|
|
2295
|
+
this.screencast = cdp;
|
|
2296
|
+
this.attachScreencast(cdp, onFrame);
|
|
2297
|
+
try {
|
|
2298
|
+
await cdp.send("Page.startScreencast", { ...SCREENCAST_PARAMS });
|
|
2299
|
+
} catch {
|
|
2300
|
+
this.handleScreencastWithdrawn(cdp);
|
|
2301
|
+
return;
|
|
2302
|
+
}
|
|
2303
|
+
if (this.screencast !== cdp) return;
|
|
2304
|
+
this.armKeepalive(cdp);
|
|
2305
|
+
await this.stopWithdrawnSnapshot();
|
|
2306
|
+
}
|
|
2307
|
+
/**
|
|
2308
|
+
* 428 D-12's second half: the screencast was withdrawn while the browser is still up (its CDP
|
|
2309
|
+
* session detached or unusable), which only this capability can see. Detected when a keepalive
|
|
2310
|
+
* capture or a re-bind's start rejects on the active session; the capability then serves the
|
|
2311
|
+
* labelled 1 s snapshot preview of the current page through the same `onFrame`, until `stop()`,
|
|
2312
|
+
* `cleanup()` or a successful re-bind. The declared kind stays `cdp-screencast`; the frames carry
|
|
2313
|
+
* `kind: 'snapshot'`, which is what labels the preview. Runs on the serialised chain.
|
|
2314
|
+
*/
|
|
2315
|
+
handleScreencastWithdrawn(cdp) {
|
|
2316
|
+
void this.onScreencastChain(async () => {
|
|
2317
|
+
if (this.screencast !== cdp) return;
|
|
2318
|
+
const onFrame = this.screencastOnFrame;
|
|
2319
|
+
if (onFrame === null || this.withdrawnSnapshot !== null || !this.browserIsLive()) return;
|
|
2320
|
+
await this.detachScreencast();
|
|
2321
|
+
const snapshot = createSnapshotLiveStream({ capture: () => this.screenshot() });
|
|
2322
|
+
this.withdrawnSnapshot = snapshot;
|
|
2323
|
+
await snapshot.start((frame) => {
|
|
2324
|
+
if (this.withdrawnSnapshot !== snapshot) return;
|
|
2325
|
+
onFrame({ ...frame, id: ++this.screencastFrameSeq });
|
|
2326
|
+
});
|
|
2327
|
+
this.armScreencastRecovery();
|
|
2328
|
+
});
|
|
2329
|
+
}
|
|
2330
|
+
/** While the snapshot fallback runs, try the screencast again every SCREENCAST_RECOVERY_MS (428 WR-08). */
|
|
2331
|
+
armScreencastRecovery() {
|
|
2332
|
+
this.clearScreencastRecovery();
|
|
2333
|
+
const timer = setTimeout(() => {
|
|
2334
|
+
this.screencastRecoveryTimer = null;
|
|
2335
|
+
void this.onScreencastChain(async () => {
|
|
2336
|
+
if (this.withdrawnSnapshot === null || this.screencastOnFrame === null || !this.browserIsLive()) return;
|
|
2337
|
+
await this.rebindScreencast({ focus: false });
|
|
2338
|
+
if (this.withdrawnSnapshot !== null) this.armScreencastRecovery();
|
|
2339
|
+
});
|
|
2340
|
+
}, SCREENCAST_RECOVERY_MS);
|
|
2341
|
+
timer.unref?.();
|
|
2342
|
+
this.screencastRecoveryTimer = timer;
|
|
2343
|
+
}
|
|
2344
|
+
clearScreencastRecovery() {
|
|
2345
|
+
if (this.screencastRecoveryTimer !== null) clearTimeout(this.screencastRecoveryTimer);
|
|
2346
|
+
this.screencastRecoveryTimer = null;
|
|
2347
|
+
}
|
|
2348
|
+
browserIsLive() {
|
|
2349
|
+
return !this.intentionalClose && !this.disconnected && this.browser?.isConnected() === true;
|
|
2350
|
+
}
|
|
2351
|
+
async stopWithdrawnSnapshot() {
|
|
2352
|
+
this.clearScreencastRecovery();
|
|
2353
|
+
const snapshot = this.withdrawnSnapshot;
|
|
2354
|
+
this.withdrawnSnapshot = null;
|
|
2355
|
+
try {
|
|
2356
|
+
await snapshot?.stop();
|
|
2357
|
+
} catch {
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
clearKeepalive() {
|
|
2361
|
+
if (this.keepaliveTimer !== null) clearTimeout(this.keepaliveTimer);
|
|
2362
|
+
this.keepaliveTimer = null;
|
|
2363
|
+
}
|
|
2364
|
+
/** (Re-)arm the quiet-page keepalive for `cdp`; every screencast frame pushes it back. */
|
|
2365
|
+
armKeepalive(cdp) {
|
|
2366
|
+
this.clearKeepalive();
|
|
2367
|
+
const timer = setTimeout(() => {
|
|
2368
|
+
void this.fireKeepalive(cdp);
|
|
2369
|
+
}, LIVE_KEEPALIVE_QUIET_MS);
|
|
2370
|
+
timer.unref?.();
|
|
2371
|
+
this.keepaliveTimer = timer;
|
|
2372
|
+
}
|
|
2373
|
+
/**
|
|
2374
|
+
* 428 D-15: a fresh capture on the same CDP session, emitted as a frame with no ack token (the ack
|
|
2375
|
+
* path drops ids it never minted a token for). A capture that rejects emits nothing. While the
|
|
2376
|
+
* capability is started under a live browser, a session that then fails a trivial probe too is a
|
|
2377
|
+
* withdrawn screencast (D-12, see `handleScreencastWithdrawn`); one that still answers only had a
|
|
2378
|
+
* bad moment, such as a cross-process navigation, and re-arms (428 WR-08). One that never settles
|
|
2379
|
+
* emits nothing and arms nothing, which is the honest amber.
|
|
2380
|
+
*/
|
|
2381
|
+
async fireKeepalive(cdp) {
|
|
2382
|
+
this.keepaliveTimer = null;
|
|
2383
|
+
if (this.screencast !== cdp) return;
|
|
2384
|
+
const framesBefore = this.screencastFrameSeq;
|
|
2385
|
+
let data;
|
|
2386
|
+
try {
|
|
2387
|
+
const result = await cdp.send("Page.captureScreenshot", { format: "jpeg", quality: 60 });
|
|
2388
|
+
data = result.data;
|
|
2389
|
+
} catch {
|
|
2390
|
+
if (this.screencast !== cdp) return;
|
|
2391
|
+
const withdrawn = this.screencastOnFrame !== null && this.browserIsLive() && !await this.sessionAnswers(cdp);
|
|
2392
|
+
if (this.screencast !== cdp) return;
|
|
2393
|
+
if (withdrawn) this.handleScreencastWithdrawn(cdp);
|
|
2394
|
+
else this.armKeepalive(cdp);
|
|
2395
|
+
return;
|
|
2396
|
+
}
|
|
2397
|
+
if (this.screencast !== cdp) return;
|
|
2398
|
+
const onFrame = this.screencastOnFrame;
|
|
2399
|
+
if (onFrame === null) return;
|
|
2400
|
+
if (this.screencastFrameSeq !== framesBefore) return;
|
|
2401
|
+
const size = this.lastScreencastSize ?? this.page?.viewportSize() ?? { width: 0, height: 0 };
|
|
2402
|
+
onFrame({
|
|
2403
|
+
id: ++this.screencastFrameSeq,
|
|
2404
|
+
bytes: decodeBase64(data),
|
|
2405
|
+
metadata: { width: size.width, height: size.height, timestamp: Date.now() }
|
|
2406
|
+
});
|
|
2407
|
+
this.keepaliveEchoDeadline = Date.now() + KEEPALIVE_ECHO_WINDOW_MS;
|
|
2408
|
+
this.armKeepalive(cdp);
|
|
2409
|
+
}
|
|
2410
|
+
/** Whether `cdp` still answers a command that cannot fail on a usable session. */
|
|
2411
|
+
async sessionAnswers(cdp) {
|
|
2412
|
+
try {
|
|
2413
|
+
await cdp.send("Runtime.evaluate", { expression: "1" });
|
|
2414
|
+
return true;
|
|
2415
|
+
} catch {
|
|
2416
|
+
return false;
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
get recording() {
|
|
2420
|
+
if (!this.page || !this.context || !this.recordingConfig) return void 0;
|
|
2421
|
+
return this.recordingCapability ??= createScreencastRecording(
|
|
2422
|
+
() => this.page,
|
|
2423
|
+
this.recordingConfig,
|
|
2424
|
+
() => this.recordingPageRevision
|
|
2425
|
+
);
|
|
755
2426
|
}
|
|
756
2427
|
async cleanup() {
|
|
757
2428
|
this.intentionalClose = true;
|
|
2429
|
+
try {
|
|
2430
|
+
await this.recordingCapability?.stop();
|
|
2431
|
+
} catch {
|
|
2432
|
+
}
|
|
2433
|
+
this.recordingCapability = void 0;
|
|
2434
|
+
this.recordingConfig = void 0;
|
|
2435
|
+
this.screencastOnFrame = null;
|
|
2436
|
+
await this.stopWithdrawnSnapshot();
|
|
2437
|
+
try {
|
|
2438
|
+
await this.screencastCapability?.stop();
|
|
2439
|
+
} catch {
|
|
2440
|
+
}
|
|
2441
|
+
this.screencastCapability = null;
|
|
2442
|
+
try {
|
|
2443
|
+
await this.operationCapture?.dispose();
|
|
2444
|
+
} catch {
|
|
2445
|
+
}
|
|
2446
|
+
if (this.context) {
|
|
2447
|
+
try {
|
|
2448
|
+
await this.context.unrouteAll({ behavior: "ignoreErrors" });
|
|
2449
|
+
} catch {
|
|
2450
|
+
}
|
|
2451
|
+
try {
|
|
2452
|
+
await this.context.setOffline(false);
|
|
2453
|
+
} catch {
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
if (this.page && this.pendingDialogArm) {
|
|
2457
|
+
try {
|
|
2458
|
+
this.page.off("dialog", this.pendingDialogArm);
|
|
2459
|
+
} catch {
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
this.armedRoutes.clear();
|
|
2463
|
+
this.pendingDialogArm = null;
|
|
2464
|
+
await disposeEpoch(this.epoch);
|
|
2465
|
+
this.epoch = void 0;
|
|
758
2466
|
if (this.browser) {
|
|
759
2467
|
await this.browser.close();
|
|
760
2468
|
}
|
|
761
2469
|
this.browser = null;
|
|
762
2470
|
this.context = null;
|
|
763
2471
|
this.page = null;
|
|
2472
|
+
this.operationCapture = null;
|
|
2473
|
+
this.operationCaptureSupport = void 0;
|
|
764
2474
|
this.lastRefs = {};
|
|
765
2475
|
this.pageLogBuffers.clear();
|
|
766
2476
|
this.disconnected = false;
|
|
767
2477
|
this.intentionalClose = false;
|
|
2478
|
+
this.screencast = null;
|
|
2479
|
+
this.screencastOnFrame = null;
|
|
2480
|
+
this.clearKeepalive();
|
|
2481
|
+
this.lastScreencastSize = null;
|
|
2482
|
+
this.screencastAckTokens.clear();
|
|
2483
|
+
this.screencastChain = Promise.resolve();
|
|
768
2484
|
}
|
|
769
2485
|
get isBrowserDisconnected() {
|
|
770
2486
|
return this.disconnected;
|
|
@@ -773,14 +2489,63 @@ var WebPlatformAdapter = class {
|
|
|
773
2489
|
if (!this.page) {
|
|
774
2490
|
throw new Error("WebPlatformAdapter not initialized \u2014 call setup() first");
|
|
775
2491
|
}
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
2492
|
+
const page = this.page;
|
|
2493
|
+
await waitForPageReady(page);
|
|
2494
|
+
const epoch = await captureEpoch(page);
|
|
2495
|
+
try {
|
|
2496
|
+
const state = await this.observeDocument(page, options);
|
|
2497
|
+
await disposeEpoch(this.epoch);
|
|
2498
|
+
this.epoch = epoch;
|
|
2499
|
+
return state;
|
|
2500
|
+
} catch (error) {
|
|
2501
|
+
await disposeEpoch(epoch);
|
|
2502
|
+
throw error;
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
async observeDocument(page, options) {
|
|
2506
|
+
const ariaTree = await snapshotRoot(page, OBSERVE_SNAPSHOT);
|
|
2507
|
+
const { nodes, nativeRefs } = parseAriaSnapshot(ariaTree);
|
|
2508
|
+
if (nodes.length > 0) {
|
|
2509
|
+
await enrichWebNodes(page, nodes, nativeRefs);
|
|
2510
|
+
}
|
|
2511
|
+
const { viewport, scroll } = await captureWebScroll(page);
|
|
2512
|
+
let domContext;
|
|
2513
|
+
if (options?.extractDom) {
|
|
2514
|
+
try {
|
|
2515
|
+
domContext = await extractDom(page);
|
|
2516
|
+
} catch {
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
const { state, binding } = buildScreenState3({
|
|
2520
|
+
platform: "web",
|
|
2521
|
+
nodes,
|
|
2522
|
+
viewport,
|
|
2523
|
+
scroll,
|
|
2524
|
+
coordSpace: "viewport",
|
|
2525
|
+
timestamp: Date.now(),
|
|
2526
|
+
url: page.url(),
|
|
2527
|
+
...domContext !== void 0 ? { domContext } : {}
|
|
779
2528
|
});
|
|
780
|
-
this.lastRefs = state.
|
|
2529
|
+
this.lastRefs = buildRefMap(binding, nodes, nativeRefs, state.elements);
|
|
781
2530
|
return state;
|
|
782
2531
|
}
|
|
783
|
-
async execute(action) {
|
|
2532
|
+
async execute(action, options) {
|
|
2533
|
+
if (!this.page) {
|
|
2534
|
+
throw new Error("WebPlatformAdapter not initialized \u2014 call setup() first");
|
|
2535
|
+
}
|
|
2536
|
+
const capture = this.operationCapture;
|
|
2537
|
+
await capture?.arm(action, options, this.page);
|
|
2538
|
+
const result = await this.executeWithErrorResult(action);
|
|
2539
|
+
this.recordingCapability?.endAction();
|
|
2540
|
+
let captured;
|
|
2541
|
+
try {
|
|
2542
|
+
captured = await capture?.collect();
|
|
2543
|
+
} catch {
|
|
2544
|
+
captured = void 0;
|
|
2545
|
+
}
|
|
2546
|
+
return withOperationMetadata(result, captured);
|
|
2547
|
+
}
|
|
2548
|
+
async executeWithErrorResult(action) {
|
|
784
2549
|
if (!this.page) {
|
|
785
2550
|
throw new Error("WebPlatformAdapter not initialized \u2014 call setup() first");
|
|
786
2551
|
}
|
|
@@ -812,7 +2577,7 @@ var WebPlatformAdapter = class {
|
|
|
812
2577
|
async screenshot() {
|
|
813
2578
|
try {
|
|
814
2579
|
if (this.page) {
|
|
815
|
-
return await this.page.screenshot({ scale: "css" });
|
|
2580
|
+
return await this.page.screenshot({ scale: "css", timeout: SCREENSHOT_TIMEOUT_MS });
|
|
816
2581
|
}
|
|
817
2582
|
return void 0;
|
|
818
2583
|
} catch {
|
|
@@ -822,15 +2587,6 @@ var WebPlatformAdapter = class {
|
|
|
822
2587
|
getPage() {
|
|
823
2588
|
return this.page;
|
|
824
2589
|
}
|
|
825
|
-
async getVideoPath() {
|
|
826
|
-
try {
|
|
827
|
-
const video = this.page?.video();
|
|
828
|
-
if (!video) return null;
|
|
829
|
-
return await video.path();
|
|
830
|
-
} catch {
|
|
831
|
-
return null;
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
2590
|
drainConsoleLogs(level) {
|
|
835
2591
|
const all = [];
|
|
836
2592
|
for (const buffers of this.pageLogBuffers.values()) {
|
|
@@ -851,6 +2607,18 @@ var WebPlatformAdapter = class {
|
|
|
851
2607
|
if (!urlPattern) return all;
|
|
852
2608
|
return all.filter((e) => e.url.includes(urlPattern));
|
|
853
2609
|
}
|
|
2610
|
+
/** 439 D-04: a completed download's bytes held in memory, by its sha256. */
|
|
2611
|
+
readDownloadBytes(sha256) {
|
|
2612
|
+
return this.operationCapture?.readDownloadBytes(sha256);
|
|
2613
|
+
}
|
|
2614
|
+
peekNetworkLogs(urlPattern) {
|
|
2615
|
+
const all = [];
|
|
2616
|
+
for (const buffers of this.pageLogBuffers.values()) {
|
|
2617
|
+
all.push(...buffers.networkLogs.slice(buffers.networkDrainIndex));
|
|
2618
|
+
}
|
|
2619
|
+
if (!urlPattern) return all;
|
|
2620
|
+
return all.filter((e) => e.url.includes(urlPattern));
|
|
2621
|
+
}
|
|
854
2622
|
attachLogListeners(page) {
|
|
855
2623
|
const buffers = {
|
|
856
2624
|
consoleLogs: [],
|
|
@@ -938,6 +2706,12 @@ var WebPlatformAdapter = class {
|
|
|
938
2706
|
async executeAction(action) {
|
|
939
2707
|
const page = this.page;
|
|
940
2708
|
const resolver = new ElementResolver(page, this.lastRefs);
|
|
2709
|
+
for (const ref of laneRefs(action)) {
|
|
2710
|
+
const entry = this.lastRefs[ref];
|
|
2711
|
+
if (entry === void 0) continue;
|
|
2712
|
+
const check = action.type === "scroll" ? await checkDocument(page, this.epoch) : await checkBinding(page, this.epoch, entry, OBSERVE_SNAPSHOT);
|
|
2713
|
+
if (check.outcome !== "ok") return bindingRefusal(check.outcome, ref);
|
|
2714
|
+
}
|
|
941
2715
|
if ("ref" in action && typeof action.ref === "string") {
|
|
942
2716
|
const refData = this.lastRefs[action.ref];
|
|
943
2717
|
const role = refData?.role;
|
|
@@ -952,15 +2726,18 @@ var WebPlatformAdapter = class {
|
|
|
952
2726
|
const box = await resolver.getBoundingBox(action.ref);
|
|
953
2727
|
const coordinates = box ? { x: box.x + box.width / 2, y: box.y + box.height / 2 } : void 0;
|
|
954
2728
|
await locator.click({
|
|
955
|
-
...clickDelayOption(action.clickDelay)
|
|
2729
|
+
...clickDelayOption(action.clickDelay),
|
|
2730
|
+
...this.operationCapture ? { noWaitAfter: true } : {}
|
|
956
2731
|
});
|
|
957
2732
|
return { success: true, coordinates, boundingBox: box ?? void 0 };
|
|
958
2733
|
}
|
|
959
2734
|
case "fill": {
|
|
960
2735
|
const locator = resolver.resolve(action.ref);
|
|
961
2736
|
const box = await resolver.getBoundingBox(action.ref);
|
|
962
|
-
|
|
963
|
-
|
|
2737
|
+
return runWebFillCompound(locator, box ?? void 0, action);
|
|
2738
|
+
}
|
|
2739
|
+
case "openApp": {
|
|
2740
|
+
return webOpenAppError();
|
|
964
2741
|
}
|
|
965
2742
|
case "select": {
|
|
966
2743
|
const locator = resolver.resolve(action.ref);
|
|
@@ -969,53 +2746,44 @@ var WebPlatformAdapter = class {
|
|
|
969
2746
|
return { success: true, boundingBox: box ?? void 0 };
|
|
970
2747
|
}
|
|
971
2748
|
case "navigate": {
|
|
972
|
-
|
|
2749
|
+
switch (action.to) {
|
|
2750
|
+
case "url":
|
|
2751
|
+
await page.goto(action.url, { waitUntil: "domcontentloaded" });
|
|
2752
|
+
break;
|
|
2753
|
+
case "back":
|
|
2754
|
+
await page.goBack({ waitUntil: "domcontentloaded" });
|
|
2755
|
+
break;
|
|
2756
|
+
case "forward":
|
|
2757
|
+
await page.goForward({ waitUntil: "domcontentloaded" });
|
|
2758
|
+
break;
|
|
2759
|
+
case "reload":
|
|
2760
|
+
await page.reload({ waitUntil: "domcontentloaded" });
|
|
2761
|
+
break;
|
|
2762
|
+
}
|
|
973
2763
|
await waitForPageReady(page);
|
|
974
2764
|
return { success: true };
|
|
975
2765
|
}
|
|
976
2766
|
case "scroll": {
|
|
977
|
-
|
|
978
|
-
const deltaY = action.scrollType === "vertical" ? action.value : 0;
|
|
979
|
-
let coordinates;
|
|
980
|
-
let boundingBox;
|
|
981
|
-
let scrollData;
|
|
982
|
-
if (action.ref) {
|
|
983
|
-
const refData = this.lastRefs[action.ref];
|
|
984
|
-
if (refData) {
|
|
985
|
-
const scrollAxis = action.scrollType === "horizontal" ? "horizontal" : "vertical";
|
|
986
|
-
const result = await scrollWithRef(page, refData, deltaX, deltaY, scrollAxis);
|
|
987
|
-
if (result.bounds) {
|
|
988
|
-
coordinates = { x: result.bounds.x + result.bounds.width / 2, y: result.bounds.y + result.bounds.height / 2 };
|
|
989
|
-
boundingBox = result.bounds;
|
|
990
|
-
}
|
|
991
|
-
scrollData = { scrolled: result.scrolled, scrolledContainer: result.scrolledContainer };
|
|
992
|
-
} else {
|
|
993
|
-
await page.mouse.wheel(deltaX, deltaY);
|
|
994
|
-
}
|
|
995
|
-
} else {
|
|
996
|
-
await page.mouse.wheel(deltaX, deltaY);
|
|
997
|
-
}
|
|
998
|
-
await page.evaluate(`new Promise(resolve => {
|
|
999
|
-
const t = setTimeout(() => resolve('timeout'), 500);
|
|
1000
|
-
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
1001
|
-
clearTimeout(t);
|
|
1002
|
-
resolve('raf');
|
|
1003
|
-
}));
|
|
1004
|
-
})`);
|
|
1005
|
-
return { success: true, coordinates, boundingBox, data: scrollData };
|
|
2767
|
+
return runWebScrollCompound(page, this.lastRefs, action);
|
|
1006
2768
|
}
|
|
1007
2769
|
case "waitFor": {
|
|
1008
|
-
const timeout = action.
|
|
2770
|
+
const timeout = action.timeoutMs;
|
|
1009
2771
|
try {
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
2772
|
+
switch (action.on) {
|
|
2773
|
+
case "text":
|
|
2774
|
+
await page.getByText(action.text).first().waitFor({ state: "visible", timeout });
|
|
2775
|
+
break;
|
|
2776
|
+
case "gone":
|
|
2777
|
+
await page.getByText(action.text).first().waitFor({ state: "hidden", timeout });
|
|
2778
|
+
break;
|
|
2779
|
+
case "selector":
|
|
2780
|
+
await page.waitForSelector(action.selector, { timeout });
|
|
2781
|
+
break;
|
|
1018
2782
|
}
|
|
2783
|
+
} catch {
|
|
2784
|
+
const what = action.on === "selector" ? `selector "${action.selector}"` : `text "${action.text}"`;
|
|
2785
|
+
const how = action.on === "gone" ? "was still present after" : "did not appear within";
|
|
2786
|
+
return { success: false, error: `waitFor: ${what} ${how} ${timeout}ms.` };
|
|
1019
2787
|
}
|
|
1020
2788
|
return { success: true };
|
|
1021
2789
|
}
|
|
@@ -1040,7 +2808,13 @@ var WebPlatformAdapter = class {
|
|
|
1040
2808
|
isMac: isMacPlatform()
|
|
1041
2809
|
});
|
|
1042
2810
|
for (const key of effectiveKeys) {
|
|
1043
|
-
|
|
2811
|
+
if (action.mode === "down") {
|
|
2812
|
+
await page.keyboard.down(key);
|
|
2813
|
+
} else if (action.mode === "up") {
|
|
2814
|
+
await page.keyboard.up(key);
|
|
2815
|
+
} else {
|
|
2816
|
+
await page.keyboard.press(key);
|
|
2817
|
+
}
|
|
1044
2818
|
}
|
|
1045
2819
|
return { success: true };
|
|
1046
2820
|
}
|
|
@@ -1086,75 +2860,110 @@ var WebPlatformAdapter = class {
|
|
|
1086
2860
|
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(text)})`);
|
|
1087
2861
|
return { success: true, boundingBox: box ?? void 0, data: { copied: text } };
|
|
1088
2862
|
}
|
|
1089
|
-
case "
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
} else {
|
|
1106
|
-
|
|
2863
|
+
case "tab": {
|
|
2864
|
+
if (action.action === "new") {
|
|
2865
|
+
const newPage = await this.context.newPage();
|
|
2866
|
+
this.attachLogListeners(newPage);
|
|
2867
|
+
await newPage.goto(action.url, { waitUntil: "domcontentloaded" });
|
|
2868
|
+
await waitForPageReady(newPage);
|
|
2869
|
+
this.setActivePage(newPage);
|
|
2870
|
+
return { success: true };
|
|
2871
|
+
}
|
|
2872
|
+
const pages = this.context.pages();
|
|
2873
|
+
let target;
|
|
2874
|
+
if (action.index !== void 0) {
|
|
2875
|
+
if (action.index < 0 || action.index >= pages.length) {
|
|
2876
|
+
return { success: false, error: `Tab index ${action.index} out of range (${pages.length} tabs open)` };
|
|
2877
|
+
}
|
|
2878
|
+
target = pages[action.index];
|
|
2879
|
+
} else if (action.title !== void 0) {
|
|
2880
|
+
for (const p of pages) {
|
|
2881
|
+
const t = await p.title();
|
|
2882
|
+
if (t.includes(action.title)) {
|
|
2883
|
+
target = p;
|
|
2884
|
+
break;
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
} else if (action.url !== void 0) {
|
|
2888
|
+
target = pages.find((p) => p.url().includes(action.url));
|
|
1107
2889
|
}
|
|
1108
|
-
|
|
2890
|
+
if (!target) {
|
|
2891
|
+
return { success: false, error: `No tab found matching ${JSON.stringify({ index: action.index, title: action.title, url: action.url })}` };
|
|
2892
|
+
}
|
|
2893
|
+
this.setActivePage(target);
|
|
1109
2894
|
return { success: true };
|
|
1110
2895
|
}
|
|
1111
|
-
case "
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
2896
|
+
case "inspect": {
|
|
2897
|
+
switch (action.what) {
|
|
2898
|
+
case "console": {
|
|
2899
|
+
let entries;
|
|
2900
|
+
if (action.tab) {
|
|
2901
|
+
const targetPage = await this.resolveTabTargetAsync(action.tab);
|
|
2902
|
+
if (!targetPage) {
|
|
2903
|
+
return { success: false, error: `No tab found matching ${JSON.stringify(action.tab)}` };
|
|
2904
|
+
}
|
|
2905
|
+
const buffers = this.pageLogBuffers.get(targetPage);
|
|
2906
|
+
entries = buffers ? [...buffers.consoleLogs] : [];
|
|
2907
|
+
} else {
|
|
2908
|
+
entries = [];
|
|
2909
|
+
for (const buffers of this.pageLogBuffers.values()) {
|
|
2910
|
+
entries.push(...buffers.consoleLogs);
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
if (action.level) {
|
|
2914
|
+
entries = entries.filter((e) => e.level === action.level);
|
|
2915
|
+
}
|
|
2916
|
+
return { success: true, data: entries };
|
|
1117
2917
|
}
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
2918
|
+
case "network": {
|
|
2919
|
+
let entries;
|
|
2920
|
+
if (action.tab) {
|
|
2921
|
+
const targetPage = await this.resolveTabTargetAsync(action.tab);
|
|
2922
|
+
if (!targetPage) {
|
|
2923
|
+
return { success: false, error: `No tab found matching ${JSON.stringify(action.tab)}` };
|
|
2924
|
+
}
|
|
2925
|
+
const buffers = this.pageLogBuffers.get(targetPage);
|
|
2926
|
+
entries = buffers ? [...buffers.networkLogs] : [];
|
|
2927
|
+
} else {
|
|
2928
|
+
entries = [];
|
|
2929
|
+
for (const buffers of this.pageLogBuffers.values()) {
|
|
2930
|
+
entries.push(...buffers.networkLogs);
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
if (action.urlPattern) {
|
|
2934
|
+
entries = entries.filter((e) => e.url.includes(action.urlPattern));
|
|
2935
|
+
}
|
|
2936
|
+
return { success: true, data: entries };
|
|
1124
2937
|
}
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
let entries;
|
|
1133
|
-
if (action.tab) {
|
|
1134
|
-
const targetPage = await this.resolveTabTargetAsync(action.tab);
|
|
1135
|
-
if (!targetPage) {
|
|
1136
|
-
return { success: false, error: `No tab found matching ${JSON.stringify(action.tab)}` };
|
|
2938
|
+
case "cookies": {
|
|
2939
|
+
const cookies = await this.context.cookies();
|
|
2940
|
+
if (action.name) {
|
|
2941
|
+
const cookie = cookies.find((c) => c.name === action.name) ?? null;
|
|
2942
|
+
return { success: true, data: cookie };
|
|
2943
|
+
}
|
|
2944
|
+
return { success: true, data: cookies };
|
|
1137
2945
|
}
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
2946
|
+
case "localStorage": {
|
|
2947
|
+
const pageUrl = page.url();
|
|
2948
|
+
if (pageUrl === "about:blank") {
|
|
2949
|
+
return { success: true, data: action.key ? null : {} };
|
|
2950
|
+
}
|
|
2951
|
+
if (action.key) {
|
|
2952
|
+
const value = await page.evaluate(
|
|
2953
|
+
(k) => localStorage.getItem(k),
|
|
2954
|
+
action.key
|
|
2955
|
+
);
|
|
2956
|
+
return { success: true, data: value };
|
|
2957
|
+
}
|
|
2958
|
+
const all = await page.evaluate(
|
|
2959
|
+
() => Object.fromEntries(
|
|
2960
|
+
Object.keys(localStorage).map((k) => [k, localStorage.getItem(k)])
|
|
2961
|
+
)
|
|
2962
|
+
);
|
|
2963
|
+
return { success: true, data: all };
|
|
1144
2964
|
}
|
|
1145
2965
|
}
|
|
1146
|
-
|
|
1147
|
-
entries = entries.filter((e) => e.url.includes(action.urlPattern));
|
|
1148
|
-
}
|
|
1149
|
-
return { success: true, data: entries };
|
|
1150
|
-
}
|
|
1151
|
-
case "readCookies": {
|
|
1152
|
-
const cookies = await this.context.cookies();
|
|
1153
|
-
if (action.name) {
|
|
1154
|
-
const cookie = cookies.find((c) => c.name === action.name) ?? null;
|
|
1155
|
-
return { success: true, data: cookie };
|
|
1156
|
-
}
|
|
1157
|
-
return { success: true, data: cookies };
|
|
2966
|
+
return { success: true };
|
|
1158
2967
|
}
|
|
1159
2968
|
case "setCookies": {
|
|
1160
2969
|
const cookiesWithDefaults = action.cookies.map((c) => ({
|
|
@@ -1164,25 +2973,6 @@ var WebPlatformAdapter = class {
|
|
|
1164
2973
|
await this.context.addCookies(cookiesWithDefaults);
|
|
1165
2974
|
return { success: true };
|
|
1166
2975
|
}
|
|
1167
|
-
case "readLocalStorage": {
|
|
1168
|
-
const pageUrl = page.url();
|
|
1169
|
-
if (pageUrl === "about:blank") {
|
|
1170
|
-
return { success: true, data: action.key ? null : {} };
|
|
1171
|
-
}
|
|
1172
|
-
if (action.key) {
|
|
1173
|
-
const value = await page.evaluate(
|
|
1174
|
-
(k) => localStorage.getItem(k),
|
|
1175
|
-
action.key
|
|
1176
|
-
);
|
|
1177
|
-
return { success: true, data: value };
|
|
1178
|
-
}
|
|
1179
|
-
const all = await page.evaluate(
|
|
1180
|
-
() => Object.fromEntries(
|
|
1181
|
-
Object.keys(localStorage).map((k) => [k, localStorage.getItem(k)])
|
|
1182
|
-
)
|
|
1183
|
-
);
|
|
1184
|
-
return { success: true, data: all };
|
|
1185
|
-
}
|
|
1186
2976
|
case "setLocalStorage": {
|
|
1187
2977
|
await page.evaluate((entries) => {
|
|
1188
2978
|
for (const { key, value } of entries) {
|
|
@@ -1191,8 +2981,37 @@ var WebPlatformAdapter = class {
|
|
|
1191
2981
|
}, action.entries);
|
|
1192
2982
|
return { success: true };
|
|
1193
2983
|
}
|
|
1194
|
-
case "
|
|
1195
|
-
|
|
2984
|
+
case "focus": {
|
|
2985
|
+
const locator = resolver.resolve(action.ref);
|
|
2986
|
+
const box = await resolver.getBoundingBox(action.ref);
|
|
2987
|
+
await locator.focus();
|
|
2988
|
+
return { success: true, boundingBox: box ?? void 0 };
|
|
2989
|
+
}
|
|
2990
|
+
case "assertValue": {
|
|
2991
|
+
let value;
|
|
2992
|
+
if ("expr" in action) {
|
|
2993
|
+
value = await page.evaluate(action.expr);
|
|
2994
|
+
} else {
|
|
2995
|
+
switch (action.source) {
|
|
2996
|
+
case "url":
|
|
2997
|
+
value = page.url();
|
|
2998
|
+
break;
|
|
2999
|
+
case "storage":
|
|
3000
|
+
value = page.url() === "about:blank" ? {} : await page.evaluate(
|
|
3001
|
+
() => Object.fromEntries(
|
|
3002
|
+
Object.keys(localStorage).map((k) => [k, localStorage.getItem(k)])
|
|
3003
|
+
)
|
|
3004
|
+
);
|
|
3005
|
+
break;
|
|
3006
|
+
case "network":
|
|
3007
|
+
value = this.peekNetworkLogs();
|
|
3008
|
+
break;
|
|
3009
|
+
case "expr-result":
|
|
3010
|
+
value = void 0;
|
|
3011
|
+
break;
|
|
3012
|
+
}
|
|
3013
|
+
}
|
|
3014
|
+
return { success: true, data: value };
|
|
1196
3015
|
}
|
|
1197
3016
|
case "clearText": {
|
|
1198
3017
|
const loc = resolver.resolve(action.ref);
|
|
@@ -1200,18 +3019,6 @@ var WebPlatformAdapter = class {
|
|
|
1200
3019
|
await loc.clear();
|
|
1201
3020
|
return { success: true, boundingBox: clearBox ?? void 0 };
|
|
1202
3021
|
}
|
|
1203
|
-
case "drag": {
|
|
1204
|
-
const fromLoc = resolver.resolve(action.fromRef);
|
|
1205
|
-
const toLoc = resolver.resolve(action.toRef);
|
|
1206
|
-
await fromLoc.dragTo(toLoc);
|
|
1207
|
-
return { success: true };
|
|
1208
|
-
}
|
|
1209
|
-
case "doubleTap": {
|
|
1210
|
-
const dblLoc = resolver.resolve(action.ref);
|
|
1211
|
-
const dblBox = await resolver.getBoundingBox(action.ref);
|
|
1212
|
-
await dblLoc.dblclick();
|
|
1213
|
-
return { success: true, boundingBox: dblBox ?? void 0 };
|
|
1214
|
-
}
|
|
1215
3022
|
case "doubleClick": {
|
|
1216
3023
|
const locator = resolver.resolve(action.ref);
|
|
1217
3024
|
const box = await resolver.getBoundingBox(action.ref);
|
|
@@ -1233,90 +3040,116 @@ var WebPlatformAdapter = class {
|
|
|
1233
3040
|
});
|
|
1234
3041
|
return { success: true, coordinates, boundingBox: box ?? void 0 };
|
|
1235
3042
|
}
|
|
1236
|
-
case "
|
|
1237
|
-
await
|
|
1238
|
-
|
|
3043
|
+
case "route": {
|
|
3044
|
+
await this.context.route(action.urlPattern, async (route) => {
|
|
3045
|
+
await route.fulfill({
|
|
3046
|
+
status: 200,
|
|
3047
|
+
...action.contentType !== void 0 ? { contentType: action.contentType } : {},
|
|
3048
|
+
...action.headers !== void 0 ? { headers: action.headers } : {},
|
|
3049
|
+
...action.body !== void 0 ? { body: action.body } : {}
|
|
3050
|
+
});
|
|
3051
|
+
});
|
|
3052
|
+
this.armedRoutes.add(action.urlPattern);
|
|
1239
3053
|
return { success: true };
|
|
1240
3054
|
}
|
|
1241
|
-
case "
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
3055
|
+
case "unroute": {
|
|
3056
|
+
if (action.urlPattern !== void 0) {
|
|
3057
|
+
await this.context.unroute(action.urlPattern);
|
|
3058
|
+
this.armedRoutes.delete(action.urlPattern);
|
|
3059
|
+
} else {
|
|
3060
|
+
await this.context.unrouteAll({ behavior: "ignoreErrors" });
|
|
3061
|
+
this.armedRoutes.clear();
|
|
3062
|
+
}
|
|
1247
3063
|
return { success: true };
|
|
1248
3064
|
}
|
|
1249
|
-
case "
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
if (action.index < 0 || action.index >= pages.length) {
|
|
1254
|
-
return { success: false, error: `Tab index ${action.index} out of range (${pages.length} tabs open)` };
|
|
1255
|
-
}
|
|
1256
|
-
target = pages[action.index];
|
|
1257
|
-
} else if (action.title) {
|
|
1258
|
-
for (const p of pages) {
|
|
1259
|
-
const t = await p.title();
|
|
1260
|
-
if (t.includes(action.title)) {
|
|
1261
|
-
target = p;
|
|
1262
|
-
break;
|
|
1263
|
-
}
|
|
1264
|
-
}
|
|
1265
|
-
} else if (action.url) {
|
|
1266
|
-
target = pages.find((p) => p.url().includes(action.url));
|
|
1267
|
-
} else {
|
|
1268
|
-
return { success: false, error: "switchTab requires at least one of: index, title, or url" };
|
|
3065
|
+
case "networkState": {
|
|
3066
|
+
if (action.mode === "offline") {
|
|
3067
|
+
await this.context.setOffline(true);
|
|
3068
|
+
return { success: true };
|
|
1269
3069
|
}
|
|
1270
|
-
|
|
1271
|
-
|
|
3070
|
+
try {
|
|
3071
|
+
const cdp = await this.context.newCDPSession(page);
|
|
3072
|
+
await cdp.send("Network.emulateNetworkConditions", {
|
|
3073
|
+
offline: false,
|
|
3074
|
+
latency: action.latencyMs,
|
|
3075
|
+
downloadThroughput: action.downloadKbps * 1e3 / 8,
|
|
3076
|
+
uploadThroughput: action.uploadKbps * 1e3 / 8
|
|
3077
|
+
});
|
|
3078
|
+
return { success: true };
|
|
3079
|
+
} catch (err) {
|
|
3080
|
+
return {
|
|
3081
|
+
success: false,
|
|
3082
|
+
error: `Network throttling is only available on Chromium (via CDP); this context does not support it. ${err instanceof Error ? err.message : String(err)}`
|
|
3083
|
+
};
|
|
1272
3084
|
}
|
|
1273
|
-
|
|
3085
|
+
}
|
|
3086
|
+
case "storageState": {
|
|
3087
|
+
if (action.op === "save") {
|
|
3088
|
+
const state = await this.context.storageState();
|
|
3089
|
+
return { success: true, data: { name: action.name, state } };
|
|
3090
|
+
}
|
|
3091
|
+
await this.context.setStorageState(readSeamStorageState(action));
|
|
1274
3092
|
return { success: true };
|
|
1275
3093
|
}
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
case "pinch":
|
|
1281
|
-
case "multiTap": {
|
|
1282
|
-
return { success: false, error: `${action.type} is a mobile-only gesture` };
|
|
1283
|
-
}
|
|
1284
|
-
case "tapCoordinate": {
|
|
1285
|
-
const vp = page.viewportSize();
|
|
1286
|
-
if (vp) {
|
|
1287
|
-
warnIfOutOfBounds({ x: action.x, y: action.y }, { width: vp.width, height: vp.height }, "tapCoordinate");
|
|
3094
|
+
case "handleDialog": {
|
|
3095
|
+
if (this.pendingDialogArm) {
|
|
3096
|
+
page.off("dialog", this.pendingDialogArm);
|
|
3097
|
+
this.pendingDialogArm = null;
|
|
1288
3098
|
}
|
|
1289
|
-
|
|
1290
|
-
|
|
3099
|
+
const accept = action.accept;
|
|
3100
|
+
const promptText = action.promptText;
|
|
3101
|
+
const handler = (dialog) => {
|
|
3102
|
+
this.pendingDialogArm = null;
|
|
3103
|
+
if (accept) {
|
|
3104
|
+
void dialog.accept(promptText);
|
|
3105
|
+
} else {
|
|
3106
|
+
void dialog.dismiss();
|
|
3107
|
+
}
|
|
3108
|
+
};
|
|
3109
|
+
this.pendingDialogArm = handler;
|
|
3110
|
+
page.once("dialog", handler);
|
|
3111
|
+
return { success: true };
|
|
1291
3112
|
}
|
|
1292
3113
|
case "executeScript": {
|
|
1293
3114
|
return { success: false, error: "executeScript is a mobile-only action" };
|
|
1294
3115
|
}
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
case
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
3116
|
+
// Runner-minted / author-channel verbs (D-01/D-15/D-16): reportValue, uniqueValue, assertAI,
|
|
3117
|
+
// extractAI, and setVariable are intercepted at the dispatch seam and never reach the adapter.
|
|
3118
|
+
// A grouped §7.1 defensive case surfaces a dispatch-routing error if one arrives here;
|
|
3119
|
+
// assertNever remains the backstop for a genuinely unknown schema verb.
|
|
3120
|
+
case "reportValue":
|
|
3121
|
+
case "uniqueValue":
|
|
3122
|
+
case "assertAI":
|
|
3123
|
+
case "extractAI":
|
|
3124
|
+
case "setVariable":
|
|
3125
|
+
return {
|
|
3126
|
+
success: false,
|
|
3127
|
+
error: `Action "${action.type}" is handled by the runner dispatch seam and must not reach the web adapter (runner-minted/author-channel verb). This indicates a dispatch routing error.`
|
|
3128
|
+
};
|
|
3129
|
+
default:
|
|
3130
|
+
return assertNever(action);
|
|
1304
3131
|
}
|
|
1305
3132
|
}
|
|
1306
3133
|
};
|
|
1307
3134
|
|
|
1308
3135
|
// src/farm-adapter.ts
|
|
3136
|
+
import { assertNever as assertNever2, buildScreenState as buildScreenState4 } from "@vostride/agent-qa-runner";
|
|
1309
3137
|
import { getProvider, registerAllProviders } from "@vostride/agent-qa-core";
|
|
1310
|
-
import { convertKeysForPlatform as convertKeysForPlatform2, isMacPlatform as isMacPlatform2 } from "@vostride/agent-qa-
|
|
3138
|
+
import { convertKeysForPlatform as convertKeysForPlatform2, isMacPlatform as isMacPlatform2 } from "@vostride/agent-qa-runner";
|
|
1311
3139
|
function clickDelayOption2(clickDelay) {
|
|
1312
3140
|
return clickDelay !== void 0 && clickDelay > 0 ? { delay: clickDelay } : {};
|
|
1313
3141
|
}
|
|
3142
|
+
function readSeamStorageState2(action) {
|
|
3143
|
+
return action.state;
|
|
3144
|
+
}
|
|
1314
3145
|
var FarmWebAdapter = class {
|
|
1315
3146
|
platform = "web";
|
|
1316
3147
|
browser = null;
|
|
1317
3148
|
context = null;
|
|
1318
3149
|
page = null;
|
|
3150
|
+
// The native binding of the last observation and its document (438 D-06).
|
|
1319
3151
|
lastRefs = {};
|
|
3152
|
+
epoch;
|
|
1320
3153
|
sessionUrl;
|
|
1321
3154
|
sessionId;
|
|
1322
3155
|
testFailed = false;
|
|
@@ -1324,7 +3157,14 @@ var FarmWebAdapter = class {
|
|
|
1324
3157
|
credentials = null;
|
|
1325
3158
|
pageLogBuffers = /* @__PURE__ */ new Map();
|
|
1326
3159
|
logCaptureConfig = { console: true, network: true };
|
|
3160
|
+
// Persistent route interception + one-shot dialog arms live until an explicit unroute or
|
|
3161
|
+
// test teardown (D-12/D-18); cleanup() drains both so nothing bleeds across tests (Pitfall 7).
|
|
3162
|
+
armedRoutes = /* @__PURE__ */ new Set();
|
|
3163
|
+
pendingDialogArm = null;
|
|
3164
|
+
recordingCapability;
|
|
3165
|
+
recordingConfig;
|
|
1327
3166
|
async setup(config) {
|
|
3167
|
+
this.recordingConfig = config.recording;
|
|
1328
3168
|
if (!config.farmSession) {
|
|
1329
3169
|
throw new Error("FarmWebAdapter requires config.farmSession to be set");
|
|
1330
3170
|
}
|
|
@@ -1349,12 +3189,6 @@ var FarmWebAdapter = class {
|
|
|
1349
3189
|
const contextOptions = {
|
|
1350
3190
|
viewport: config.browser?.viewport ?? { width: 1280, height: 720 }
|
|
1351
3191
|
};
|
|
1352
|
-
if (config.recording?.enabled) {
|
|
1353
|
-
contextOptions.recordVideo = {
|
|
1354
|
-
dir: config.recording.videoDir,
|
|
1355
|
-
size: config.recording.videoSize ?? config.browser?.viewport ?? { width: 1280, height: 720 }
|
|
1356
|
-
};
|
|
1357
|
-
}
|
|
1358
3192
|
if (config.authState?.storageStatePath) {
|
|
1359
3193
|
contextOptions.storageState = config.authState.storageStatePath;
|
|
1360
3194
|
}
|
|
@@ -1397,7 +3231,40 @@ var FarmWebAdapter = class {
|
|
|
1397
3231
|
this.logCaptureConfig = { console: config.logCapture?.console !== false, network: config.logCapture?.network !== false };
|
|
1398
3232
|
this.attachLogListeners(this.page);
|
|
1399
3233
|
}
|
|
3234
|
+
get recording() {
|
|
3235
|
+
if (!this.page || !this.context || !this.recordingConfig) return void 0;
|
|
3236
|
+
return this.recordingCapability ??= createScreencastRecording(
|
|
3237
|
+
() => this.page,
|
|
3238
|
+
this.recordingConfig
|
|
3239
|
+
);
|
|
3240
|
+
}
|
|
1400
3241
|
async cleanup() {
|
|
3242
|
+
try {
|
|
3243
|
+
await this.recordingCapability?.stop();
|
|
3244
|
+
} catch {
|
|
3245
|
+
}
|
|
3246
|
+
this.recordingCapability = void 0;
|
|
3247
|
+
this.recordingConfig = void 0;
|
|
3248
|
+
if (this.context) {
|
|
3249
|
+
try {
|
|
3250
|
+
await this.context.unrouteAll({ behavior: "ignoreErrors" });
|
|
3251
|
+
} catch {
|
|
3252
|
+
}
|
|
3253
|
+
try {
|
|
3254
|
+
await this.context.setOffline(false);
|
|
3255
|
+
} catch {
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
3258
|
+
if (this.page && this.pendingDialogArm) {
|
|
3259
|
+
try {
|
|
3260
|
+
this.page.off("dialog", this.pendingDialogArm);
|
|
3261
|
+
} catch {
|
|
3262
|
+
}
|
|
3263
|
+
}
|
|
3264
|
+
this.armedRoutes.clear();
|
|
3265
|
+
this.pendingDialogArm = null;
|
|
3266
|
+
await disposeEpoch(this.epoch);
|
|
3267
|
+
this.epoch = void 0;
|
|
1401
3268
|
if (this.browser) {
|
|
1402
3269
|
await this.browser.close();
|
|
1403
3270
|
}
|
|
@@ -1428,11 +3295,44 @@ var FarmWebAdapter = class {
|
|
|
1428
3295
|
if (!this.page) {
|
|
1429
3296
|
throw new Error("FarmWebAdapter not initialized \u2014 call setup() first");
|
|
1430
3297
|
}
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
3298
|
+
const page = this.page;
|
|
3299
|
+
await waitForPageReady(page);
|
|
3300
|
+
const epoch = await captureEpoch(page);
|
|
3301
|
+
try {
|
|
3302
|
+
const state = await this.observeDocument(page, options);
|
|
3303
|
+
await disposeEpoch(this.epoch);
|
|
3304
|
+
this.epoch = epoch;
|
|
3305
|
+
return state;
|
|
3306
|
+
} catch (error) {
|
|
3307
|
+
await disposeEpoch(epoch);
|
|
3308
|
+
throw error;
|
|
3309
|
+
}
|
|
3310
|
+
}
|
|
3311
|
+
async observeDocument(page, options) {
|
|
3312
|
+
const ariaTree = await snapshotRoot(page);
|
|
3313
|
+
const { nodes, nativeRefs } = parseAriaSnapshot(ariaTree);
|
|
3314
|
+
if (nodes.length > 0) {
|
|
3315
|
+
await enrichWebNodes(page, nodes, nativeRefs);
|
|
3316
|
+
}
|
|
3317
|
+
const { viewport, scroll } = await captureWebScroll(page);
|
|
3318
|
+
let domContext;
|
|
3319
|
+
if (options?.extractDom) {
|
|
3320
|
+
try {
|
|
3321
|
+
domContext = await extractDom(page);
|
|
3322
|
+
} catch {
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3325
|
+
const { state, binding } = buildScreenState4({
|
|
3326
|
+
platform: "web",
|
|
3327
|
+
nodes,
|
|
3328
|
+
viewport,
|
|
3329
|
+
scroll,
|
|
3330
|
+
coordSpace: "viewport",
|
|
3331
|
+
timestamp: Date.now(),
|
|
3332
|
+
url: page.url(),
|
|
3333
|
+
...domContext !== void 0 ? { domContext } : {}
|
|
1434
3334
|
});
|
|
1435
|
-
this.lastRefs = state.
|
|
3335
|
+
this.lastRefs = buildRefMap(binding, nodes, nativeRefs, state.elements);
|
|
1436
3336
|
return state;
|
|
1437
3337
|
}
|
|
1438
3338
|
async execute(action) {
|
|
@@ -1477,15 +3377,6 @@ var FarmWebAdapter = class {
|
|
|
1477
3377
|
getPage() {
|
|
1478
3378
|
return this.page;
|
|
1479
3379
|
}
|
|
1480
|
-
async getVideoPath() {
|
|
1481
|
-
try {
|
|
1482
|
-
const video = this.page?.video();
|
|
1483
|
-
if (!video) return null;
|
|
1484
|
-
return await video.path();
|
|
1485
|
-
} catch {
|
|
1486
|
-
return null;
|
|
1487
|
-
}
|
|
1488
|
-
}
|
|
1489
3380
|
markFailed() {
|
|
1490
3381
|
this.testFailed = true;
|
|
1491
3382
|
}
|
|
@@ -1515,6 +3406,14 @@ var FarmWebAdapter = class {
|
|
|
1515
3406
|
if (!urlPattern) return all;
|
|
1516
3407
|
return all.filter((e) => e.url.includes(urlPattern));
|
|
1517
3408
|
}
|
|
3409
|
+
peekNetworkLogs(urlPattern) {
|
|
3410
|
+
const all = [];
|
|
3411
|
+
for (const buffers of this.pageLogBuffers.values()) {
|
|
3412
|
+
all.push(...buffers.networkLogs.slice(buffers.networkDrainIndex));
|
|
3413
|
+
}
|
|
3414
|
+
if (!urlPattern) return all;
|
|
3415
|
+
return all.filter((e) => e.url.includes(urlPattern));
|
|
3416
|
+
}
|
|
1518
3417
|
attachLogListeners(page) {
|
|
1519
3418
|
const buffers = {
|
|
1520
3419
|
consoleLogs: [],
|
|
@@ -1602,6 +3501,12 @@ var FarmWebAdapter = class {
|
|
|
1602
3501
|
async executeAction(action) {
|
|
1603
3502
|
const page = this.page;
|
|
1604
3503
|
const resolver = new ElementResolver(page, this.lastRefs);
|
|
3504
|
+
for (const ref of laneRefs(action)) {
|
|
3505
|
+
const entry = this.lastRefs[ref];
|
|
3506
|
+
if (entry === void 0) continue;
|
|
3507
|
+
const check = action.type === "scroll" ? await checkDocument(page, this.epoch) : await checkBinding(page, this.epoch, entry);
|
|
3508
|
+
if (check.outcome !== "ok") return bindingRefusal(check.outcome, ref);
|
|
3509
|
+
}
|
|
1605
3510
|
if ("ref" in action && typeof action.ref === "string") {
|
|
1606
3511
|
const refData = this.lastRefs[action.ref];
|
|
1607
3512
|
const role = refData?.role;
|
|
@@ -1623,8 +3528,10 @@ var FarmWebAdapter = class {
|
|
|
1623
3528
|
case "fill": {
|
|
1624
3529
|
const locator = resolver.resolve(action.ref);
|
|
1625
3530
|
const box = await resolver.getBoundingBox(action.ref);
|
|
1626
|
-
|
|
1627
|
-
|
|
3531
|
+
return runWebFillCompound(locator, box ?? void 0, action);
|
|
3532
|
+
}
|
|
3533
|
+
case "openApp": {
|
|
3534
|
+
return webOpenAppError();
|
|
1628
3535
|
}
|
|
1629
3536
|
case "select": {
|
|
1630
3537
|
const locator = resolver.resolve(action.ref);
|
|
@@ -1633,53 +3540,44 @@ var FarmWebAdapter = class {
|
|
|
1633
3540
|
return { success: true, boundingBox: box ?? void 0 };
|
|
1634
3541
|
}
|
|
1635
3542
|
case "navigate": {
|
|
1636
|
-
|
|
3543
|
+
switch (action.to) {
|
|
3544
|
+
case "url":
|
|
3545
|
+
await page.goto(action.url, { waitUntil: "domcontentloaded" });
|
|
3546
|
+
break;
|
|
3547
|
+
case "back":
|
|
3548
|
+
await page.goBack({ waitUntil: "domcontentloaded" });
|
|
3549
|
+
break;
|
|
3550
|
+
case "forward":
|
|
3551
|
+
await page.goForward({ waitUntil: "domcontentloaded" });
|
|
3552
|
+
break;
|
|
3553
|
+
case "reload":
|
|
3554
|
+
await page.reload({ waitUntil: "domcontentloaded" });
|
|
3555
|
+
break;
|
|
3556
|
+
}
|
|
1637
3557
|
await waitForPageReady(page);
|
|
1638
3558
|
return { success: true };
|
|
1639
3559
|
}
|
|
1640
3560
|
case "scroll": {
|
|
1641
|
-
|
|
1642
|
-
const deltaY = action.scrollType === "vertical" ? action.value : 0;
|
|
1643
|
-
let coordinates;
|
|
1644
|
-
let boundingBox;
|
|
1645
|
-
let scrollData;
|
|
1646
|
-
if (action.ref) {
|
|
1647
|
-
const refData = this.lastRefs[action.ref];
|
|
1648
|
-
if (refData) {
|
|
1649
|
-
const scrollAxis = action.scrollType === "horizontal" ? "horizontal" : "vertical";
|
|
1650
|
-
const result = await scrollWithRef(page, refData, deltaX, deltaY, scrollAxis);
|
|
1651
|
-
if (result.bounds) {
|
|
1652
|
-
coordinates = { x: result.bounds.x + result.bounds.width / 2, y: result.bounds.y + result.bounds.height / 2 };
|
|
1653
|
-
boundingBox = result.bounds;
|
|
1654
|
-
}
|
|
1655
|
-
scrollData = { scrolled: result.scrolled, scrolledContainer: result.scrolledContainer };
|
|
1656
|
-
} else {
|
|
1657
|
-
await page.mouse.wheel(deltaX, deltaY);
|
|
1658
|
-
}
|
|
1659
|
-
} else {
|
|
1660
|
-
await page.mouse.wheel(deltaX, deltaY);
|
|
1661
|
-
}
|
|
1662
|
-
await page.evaluate(`new Promise(resolve => {
|
|
1663
|
-
const t = setTimeout(() => resolve('timeout'), 500);
|
|
1664
|
-
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
1665
|
-
clearTimeout(t);
|
|
1666
|
-
resolve('raf');
|
|
1667
|
-
}));
|
|
1668
|
-
})`);
|
|
1669
|
-
return { success: true, coordinates, boundingBox, data: scrollData };
|
|
3561
|
+
return runWebScrollCompound(page, this.lastRefs, action);
|
|
1670
3562
|
}
|
|
1671
3563
|
case "waitFor": {
|
|
1672
|
-
const timeout = action.
|
|
3564
|
+
const timeout = action.timeoutMs;
|
|
1673
3565
|
try {
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
3566
|
+
switch (action.on) {
|
|
3567
|
+
case "text":
|
|
3568
|
+
await page.getByText(action.text).first().waitFor({ state: "visible", timeout });
|
|
3569
|
+
break;
|
|
3570
|
+
case "gone":
|
|
3571
|
+
await page.getByText(action.text).first().waitFor({ state: "hidden", timeout });
|
|
3572
|
+
break;
|
|
3573
|
+
case "selector":
|
|
3574
|
+
await page.waitForSelector(action.selector, { timeout });
|
|
3575
|
+
break;
|
|
1682
3576
|
}
|
|
3577
|
+
} catch {
|
|
3578
|
+
const what = action.on === "selector" ? `selector "${action.selector}"` : `text "${action.text}"`;
|
|
3579
|
+
const how = action.on === "gone" ? "was still present after" : "did not appear within";
|
|
3580
|
+
return { success: false, error: `waitFor: ${what} ${how} ${timeout}ms.` };
|
|
1683
3581
|
}
|
|
1684
3582
|
return { success: true };
|
|
1685
3583
|
}
|
|
@@ -1704,7 +3602,13 @@ var FarmWebAdapter = class {
|
|
|
1704
3602
|
isMac: isMacPlatform2()
|
|
1705
3603
|
});
|
|
1706
3604
|
for (const key of effectiveKeys) {
|
|
1707
|
-
|
|
3605
|
+
if (action.mode === "down") {
|
|
3606
|
+
await page.keyboard.down(key);
|
|
3607
|
+
} else if (action.mode === "up") {
|
|
3608
|
+
await page.keyboard.up(key);
|
|
3609
|
+
} else {
|
|
3610
|
+
await page.keyboard.press(key);
|
|
3611
|
+
}
|
|
1708
3612
|
}
|
|
1709
3613
|
return { success: true };
|
|
1710
3614
|
}
|
|
@@ -1750,75 +3654,110 @@ var FarmWebAdapter = class {
|
|
|
1750
3654
|
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(text)})`);
|
|
1751
3655
|
return { success: true, boundingBox: box ?? void 0, data: { copied: text } };
|
|
1752
3656
|
}
|
|
1753
|
-
case "
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
case "refresh": {
|
|
1762
|
-
await page.reload({ waitUntil: "domcontentloaded" });
|
|
1763
|
-
await waitForPageReady(page);
|
|
1764
|
-
return { success: true };
|
|
1765
|
-
}
|
|
1766
|
-
case "navigateHistory": {
|
|
1767
|
-
if (action.direction === "back") {
|
|
1768
|
-
await page.goBack({ waitUntil: "domcontentloaded" });
|
|
1769
|
-
} else {
|
|
1770
|
-
await page.goForward({ waitUntil: "domcontentloaded" });
|
|
3657
|
+
case "tab": {
|
|
3658
|
+
if (action.action === "new") {
|
|
3659
|
+
const newPage = await this.context.newPage();
|
|
3660
|
+
this.attachLogListeners(newPage);
|
|
3661
|
+
await newPage.goto(action.url, { waitUntil: "domcontentloaded" });
|
|
3662
|
+
await waitForPageReady(newPage);
|
|
3663
|
+
this.page = newPage;
|
|
3664
|
+
return { success: true };
|
|
1771
3665
|
}
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
if (action.tab) {
|
|
1778
|
-
const targetPage = await this.resolveTabTargetAsync(action.tab);
|
|
1779
|
-
if (!targetPage) {
|
|
1780
|
-
return { success: false, error: `No tab found matching ${JSON.stringify(action.tab)}` };
|
|
3666
|
+
const pages = this.context.pages();
|
|
3667
|
+
let target;
|
|
3668
|
+
if (action.index !== void 0) {
|
|
3669
|
+
if (action.index < 0 || action.index >= pages.length) {
|
|
3670
|
+
return { success: false, error: `Tab index ${action.index} out of range (${pages.length} tabs open)` };
|
|
1781
3671
|
}
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
3672
|
+
target = pages[action.index];
|
|
3673
|
+
} else if (action.title !== void 0) {
|
|
3674
|
+
for (const p of pages) {
|
|
3675
|
+
const t = await p.title();
|
|
3676
|
+
if (t.includes(action.title)) {
|
|
3677
|
+
target = p;
|
|
3678
|
+
break;
|
|
3679
|
+
}
|
|
1788
3680
|
}
|
|
3681
|
+
} else if (action.url !== void 0) {
|
|
3682
|
+
target = pages.find((p) => p.url().includes(action.url));
|
|
1789
3683
|
}
|
|
1790
|
-
if (
|
|
1791
|
-
|
|
3684
|
+
if (!target) {
|
|
3685
|
+
return { success: false, error: `No tab found matching ${JSON.stringify({ index: action.index, title: action.title, url: action.url })}` };
|
|
1792
3686
|
}
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
3687
|
+
this.page = target;
|
|
3688
|
+
return { success: true };
|
|
3689
|
+
}
|
|
3690
|
+
case "inspect": {
|
|
3691
|
+
switch (action.what) {
|
|
3692
|
+
case "console": {
|
|
3693
|
+
let entries;
|
|
3694
|
+
if (action.tab) {
|
|
3695
|
+
const targetPage = await this.resolveTabTargetAsync(action.tab);
|
|
3696
|
+
if (!targetPage) {
|
|
3697
|
+
return { success: false, error: `No tab found matching ${JSON.stringify(action.tab)}` };
|
|
3698
|
+
}
|
|
3699
|
+
const buffers = this.pageLogBuffers.get(targetPage);
|
|
3700
|
+
entries = buffers ? [...buffers.consoleLogs] : [];
|
|
3701
|
+
} else {
|
|
3702
|
+
entries = [];
|
|
3703
|
+
for (const buffers of this.pageLogBuffers.values()) {
|
|
3704
|
+
entries.push(...buffers.consoleLogs);
|
|
3705
|
+
}
|
|
3706
|
+
}
|
|
3707
|
+
if (action.level) {
|
|
3708
|
+
entries = entries.filter((e) => e.level === action.level);
|
|
3709
|
+
}
|
|
3710
|
+
return { success: true, data: entries };
|
|
1801
3711
|
}
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
3712
|
+
case "network": {
|
|
3713
|
+
let entries;
|
|
3714
|
+
if (action.tab) {
|
|
3715
|
+
const targetPage = await this.resolveTabTargetAsync(action.tab);
|
|
3716
|
+
if (!targetPage) {
|
|
3717
|
+
return { success: false, error: `No tab found matching ${JSON.stringify(action.tab)}` };
|
|
3718
|
+
}
|
|
3719
|
+
const buffers = this.pageLogBuffers.get(targetPage);
|
|
3720
|
+
entries = buffers ? [...buffers.networkLogs] : [];
|
|
3721
|
+
} else {
|
|
3722
|
+
entries = [];
|
|
3723
|
+
for (const buffers of this.pageLogBuffers.values()) {
|
|
3724
|
+
entries.push(...buffers.networkLogs);
|
|
3725
|
+
}
|
|
3726
|
+
}
|
|
3727
|
+
if (action.urlPattern) {
|
|
3728
|
+
entries = entries.filter((e) => e.url.includes(action.urlPattern));
|
|
3729
|
+
}
|
|
3730
|
+
return { success: true, data: entries };
|
|
3731
|
+
}
|
|
3732
|
+
case "cookies": {
|
|
3733
|
+
const cookies = await this.context.cookies();
|
|
3734
|
+
if (action.name) {
|
|
3735
|
+
const cookie = cookies.find((c) => c.name === action.name) ?? null;
|
|
3736
|
+
return { success: true, data: cookie };
|
|
3737
|
+
}
|
|
3738
|
+
return { success: true, data: cookies };
|
|
3739
|
+
}
|
|
3740
|
+
case "localStorage": {
|
|
3741
|
+
const pageUrl = page.url();
|
|
3742
|
+
if (pageUrl === "about:blank") {
|
|
3743
|
+
return { success: true, data: action.key ? null : {} };
|
|
3744
|
+
}
|
|
3745
|
+
if (action.key) {
|
|
3746
|
+
const value = await page.evaluate(
|
|
3747
|
+
(k) => localStorage.getItem(k),
|
|
3748
|
+
action.key
|
|
3749
|
+
);
|
|
3750
|
+
return { success: true, data: value };
|
|
3751
|
+
}
|
|
3752
|
+
const all = await page.evaluate(
|
|
3753
|
+
() => Object.fromEntries(
|
|
3754
|
+
Object.keys(localStorage).map((k) => [k, localStorage.getItem(k)])
|
|
3755
|
+
)
|
|
3756
|
+
);
|
|
3757
|
+
return { success: true, data: all };
|
|
1808
3758
|
}
|
|
1809
3759
|
}
|
|
1810
|
-
|
|
1811
|
-
entries = entries.filter((e) => e.url.includes(action.urlPattern));
|
|
1812
|
-
}
|
|
1813
|
-
return { success: true, data: entries };
|
|
1814
|
-
}
|
|
1815
|
-
case "readCookies": {
|
|
1816
|
-
const cookies = await this.context.cookies();
|
|
1817
|
-
if (action.name) {
|
|
1818
|
-
const cookie = cookies.find((c) => c.name === action.name) ?? null;
|
|
1819
|
-
return { success: true, data: cookie };
|
|
1820
|
-
}
|
|
1821
|
-
return { success: true, data: cookies };
|
|
3760
|
+
return { success: true };
|
|
1822
3761
|
}
|
|
1823
3762
|
case "setCookies": {
|
|
1824
3763
|
const cookiesWithDefaults = action.cookies.map((c) => ({
|
|
@@ -1828,25 +3767,6 @@ var FarmWebAdapter = class {
|
|
|
1828
3767
|
await this.context.addCookies(cookiesWithDefaults);
|
|
1829
3768
|
return { success: true };
|
|
1830
3769
|
}
|
|
1831
|
-
case "readLocalStorage": {
|
|
1832
|
-
const pageUrl = page.url();
|
|
1833
|
-
if (pageUrl === "about:blank") {
|
|
1834
|
-
return { success: true, data: action.key ? null : {} };
|
|
1835
|
-
}
|
|
1836
|
-
if (action.key) {
|
|
1837
|
-
const value = await page.evaluate(
|
|
1838
|
-
(k) => localStorage.getItem(k),
|
|
1839
|
-
action.key
|
|
1840
|
-
);
|
|
1841
|
-
return { success: true, data: value };
|
|
1842
|
-
}
|
|
1843
|
-
const all = await page.evaluate(
|
|
1844
|
-
() => Object.fromEntries(
|
|
1845
|
-
Object.keys(localStorage).map((k) => [k, localStorage.getItem(k)])
|
|
1846
|
-
)
|
|
1847
|
-
);
|
|
1848
|
-
return { success: true, data: all };
|
|
1849
|
-
}
|
|
1850
3770
|
case "setLocalStorage": {
|
|
1851
3771
|
await page.evaluate((entries) => {
|
|
1852
3772
|
for (const { key, value } of entries) {
|
|
@@ -1855,8 +3775,37 @@ var FarmWebAdapter = class {
|
|
|
1855
3775
|
}, action.entries);
|
|
1856
3776
|
return { success: true };
|
|
1857
3777
|
}
|
|
1858
|
-
case "
|
|
1859
|
-
|
|
3778
|
+
case "focus": {
|
|
3779
|
+
const locator = resolver.resolve(action.ref);
|
|
3780
|
+
const box = await resolver.getBoundingBox(action.ref);
|
|
3781
|
+
await locator.focus();
|
|
3782
|
+
return { success: true, boundingBox: box ?? void 0 };
|
|
3783
|
+
}
|
|
3784
|
+
case "assertValue": {
|
|
3785
|
+
let value;
|
|
3786
|
+
if ("expr" in action) {
|
|
3787
|
+
value = await page.evaluate(action.expr);
|
|
3788
|
+
} else {
|
|
3789
|
+
switch (action.source) {
|
|
3790
|
+
case "url":
|
|
3791
|
+
value = page.url();
|
|
3792
|
+
break;
|
|
3793
|
+
case "storage":
|
|
3794
|
+
value = page.url() === "about:blank" ? {} : await page.evaluate(
|
|
3795
|
+
() => Object.fromEntries(
|
|
3796
|
+
Object.keys(localStorage).map((k) => [k, localStorage.getItem(k)])
|
|
3797
|
+
)
|
|
3798
|
+
);
|
|
3799
|
+
break;
|
|
3800
|
+
case "network":
|
|
3801
|
+
value = this.peekNetworkLogs();
|
|
3802
|
+
break;
|
|
3803
|
+
case "expr-result":
|
|
3804
|
+
value = void 0;
|
|
3805
|
+
break;
|
|
3806
|
+
}
|
|
3807
|
+
}
|
|
3808
|
+
return { success: true, data: value };
|
|
1860
3809
|
}
|
|
1861
3810
|
case "clearText": {
|
|
1862
3811
|
const loc = resolver.resolve(action.ref);
|
|
@@ -1864,18 +3813,6 @@ var FarmWebAdapter = class {
|
|
|
1864
3813
|
await loc.clear();
|
|
1865
3814
|
return { success: true, boundingBox: clearBox ?? void 0 };
|
|
1866
3815
|
}
|
|
1867
|
-
case "drag": {
|
|
1868
|
-
const fromLoc = resolver.resolve(action.fromRef);
|
|
1869
|
-
const toLoc = resolver.resolve(action.toRef);
|
|
1870
|
-
await fromLoc.dragTo(toLoc);
|
|
1871
|
-
return { success: true };
|
|
1872
|
-
}
|
|
1873
|
-
case "doubleTap": {
|
|
1874
|
-
const dblLoc = resolver.resolve(action.ref);
|
|
1875
|
-
const dblBox = await resolver.getBoundingBox(action.ref);
|
|
1876
|
-
await dblLoc.dblclick();
|
|
1877
|
-
return { success: true, boundingBox: dblBox ?? void 0 };
|
|
1878
|
-
}
|
|
1879
3816
|
case "doubleClick": {
|
|
1880
3817
|
const locator = resolver.resolve(action.ref);
|
|
1881
3818
|
const box = await resolver.getBoundingBox(action.ref);
|
|
@@ -1897,66 +3834,94 @@ var FarmWebAdapter = class {
|
|
|
1897
3834
|
});
|
|
1898
3835
|
return { success: true, coordinates, boundingBox: box ?? void 0 };
|
|
1899
3836
|
}
|
|
1900
|
-
case "
|
|
1901
|
-
await
|
|
1902
|
-
|
|
3837
|
+
case "route": {
|
|
3838
|
+
await this.context.route(action.urlPattern, async (route) => {
|
|
3839
|
+
await route.fulfill({
|
|
3840
|
+
status: 200,
|
|
3841
|
+
...action.contentType !== void 0 ? { contentType: action.contentType } : {},
|
|
3842
|
+
...action.headers !== void 0 ? { headers: action.headers } : {},
|
|
3843
|
+
...action.body !== void 0 ? { body: action.body } : {}
|
|
3844
|
+
});
|
|
3845
|
+
});
|
|
3846
|
+
this.armedRoutes.add(action.urlPattern);
|
|
1903
3847
|
return { success: true };
|
|
1904
3848
|
}
|
|
1905
|
-
case "
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
3849
|
+
case "unroute": {
|
|
3850
|
+
if (action.urlPattern !== void 0) {
|
|
3851
|
+
await this.context.unroute(action.urlPattern);
|
|
3852
|
+
this.armedRoutes.delete(action.urlPattern);
|
|
3853
|
+
} else {
|
|
3854
|
+
await this.context.unrouteAll({ behavior: "ignoreErrors" });
|
|
3855
|
+
this.armedRoutes.clear();
|
|
3856
|
+
}
|
|
1911
3857
|
return { success: true };
|
|
1912
3858
|
}
|
|
1913
|
-
case "
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
if (action.index < 0 || action.index >= pages.length) {
|
|
1918
|
-
return { success: false, error: `Tab index ${action.index} out of range (${pages.length} tabs open)` };
|
|
1919
|
-
}
|
|
1920
|
-
target = pages[action.index];
|
|
1921
|
-
} else if (action.title) {
|
|
1922
|
-
for (const p of pages) {
|
|
1923
|
-
const t = await p.title();
|
|
1924
|
-
if (t.includes(action.title)) {
|
|
1925
|
-
target = p;
|
|
1926
|
-
break;
|
|
1927
|
-
}
|
|
1928
|
-
}
|
|
1929
|
-
} else if (action.url) {
|
|
1930
|
-
target = pages.find((p) => p.url().includes(action.url));
|
|
1931
|
-
} else {
|
|
1932
|
-
return { success: false, error: "switchTab requires at least one of: index, title, or url" };
|
|
3859
|
+
case "networkState": {
|
|
3860
|
+
if (action.mode === "offline") {
|
|
3861
|
+
await this.context.setOffline(true);
|
|
3862
|
+
return { success: true };
|
|
1933
3863
|
}
|
|
1934
|
-
|
|
1935
|
-
|
|
3864
|
+
try {
|
|
3865
|
+
const cdp = await this.context.newCDPSession(page);
|
|
3866
|
+
await cdp.send("Network.emulateNetworkConditions", {
|
|
3867
|
+
offline: false,
|
|
3868
|
+
latency: action.latencyMs,
|
|
3869
|
+
downloadThroughput: action.downloadKbps * 1e3 / 8,
|
|
3870
|
+
uploadThroughput: action.uploadKbps * 1e3 / 8
|
|
3871
|
+
});
|
|
3872
|
+
return { success: true };
|
|
3873
|
+
} catch (err) {
|
|
3874
|
+
return {
|
|
3875
|
+
success: false,
|
|
3876
|
+
error: `Network throttling is only available on Chromium (via CDP); this context does not support it. ${err instanceof Error ? err.message : String(err)}`
|
|
3877
|
+
};
|
|
1936
3878
|
}
|
|
1937
|
-
this.page = target;
|
|
1938
|
-
return { success: true };
|
|
1939
3879
|
}
|
|
1940
|
-
case "
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
}
|
|
1947
|
-
case "tapCoordinate": {
|
|
1948
|
-
await page.mouse.click(action.x, action.y);
|
|
1949
|
-
return { success: true, coordinates: { x: action.x, y: action.y } };
|
|
1950
|
-
}
|
|
1951
|
-
case "hideKeyboard":
|
|
1952
|
-
case "launchApp":
|
|
1953
|
-
case "stopApp":
|
|
1954
|
-
case "setOrientation": {
|
|
3880
|
+
case "storageState": {
|
|
3881
|
+
if (action.op === "save") {
|
|
3882
|
+
const state = await this.context.storageState();
|
|
3883
|
+
return { success: true, data: { name: action.name, state } };
|
|
3884
|
+
}
|
|
3885
|
+
await this.context.setStorageState(readSeamStorageState2(action));
|
|
1955
3886
|
return { success: true };
|
|
1956
3887
|
}
|
|
1957
|
-
|
|
1958
|
-
|
|
3888
|
+
case "handleDialog": {
|
|
3889
|
+
if (this.pendingDialogArm) {
|
|
3890
|
+
page.off("dialog", this.pendingDialogArm);
|
|
3891
|
+
this.pendingDialogArm = null;
|
|
3892
|
+
}
|
|
3893
|
+
const accept = action.accept;
|
|
3894
|
+
const promptText = action.promptText;
|
|
3895
|
+
const handler = (dialog) => {
|
|
3896
|
+
this.pendingDialogArm = null;
|
|
3897
|
+
if (accept) {
|
|
3898
|
+
void dialog.accept(promptText);
|
|
3899
|
+
} else {
|
|
3900
|
+
void dialog.dismiss();
|
|
3901
|
+
}
|
|
3902
|
+
};
|
|
3903
|
+
this.pendingDialogArm = handler;
|
|
3904
|
+
page.once("dialog", handler);
|
|
3905
|
+
return { success: true };
|
|
1959
3906
|
}
|
|
3907
|
+
case "executeScript": {
|
|
3908
|
+
return { success: false, error: "executeScript not supported on farm adapter" };
|
|
3909
|
+
}
|
|
3910
|
+
// Runner-minted / author-channel verbs (D-01/D-15/D-16): reportValue, uniqueValue, assertAI,
|
|
3911
|
+
// extractAI, and setVariable are intercepted at the dispatch seam and never reach the adapter.
|
|
3912
|
+
// A grouped §7.1 defensive case surfaces a dispatch-routing error if one arrives here;
|
|
3913
|
+
// assertNever remains the backstop for a genuinely unknown schema verb.
|
|
3914
|
+
case "reportValue":
|
|
3915
|
+
case "uniqueValue":
|
|
3916
|
+
case "assertAI":
|
|
3917
|
+
case "extractAI":
|
|
3918
|
+
case "setVariable":
|
|
3919
|
+
return {
|
|
3920
|
+
success: false,
|
|
3921
|
+
error: `Action "${action.type}" is handled by the runner dispatch seam and must not reach the web adapter (runner-minted/author-channel verb). This indicates a dispatch routing error.`
|
|
3922
|
+
};
|
|
3923
|
+
default:
|
|
3924
|
+
return assertNever2(action);
|
|
1960
3925
|
}
|
|
1961
3926
|
}
|
|
1962
3927
|
};
|