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