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