@diegosouzacdv/jev-browser-mcp 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,10 +32,11 @@ const MIME_TYPES = new Map([
32
32
  [".jpg", "image/jpeg"], [".jpeg", "image/jpeg"], [".png", "image/png"],
33
33
  [".txt", "text/plain"], [".webp", "image/webp"],
34
34
  ]);
35
- const FLOW_OPTION_KEYS = new Set([
36
- "block_trackers", "capture_console_errors", "capture_network_errors", "fast_path",
37
- "screenshot_on_failure", "snapshot_scope", "trace_on_failure",
38
- ]);
35
+ const FLOW_OPTION_KEYS = new Set([
36
+ "block_trackers", "capture_console_errors", "capture_network_error_bodies", "capture_network_errors",
37
+ "fast_path", "ready_network_idle", "ready_stable_ms", "ready_text", "ready_timeout_seconds",
38
+ "reuse_page", "screenshot_on_failure", "snapshot_scope", "trace_on_failure",
39
+ ]);
39
40
 
40
41
  function isRecord(value) {
41
42
  return value !== null && typeof value === "object" && !Array.isArray(value);
@@ -79,28 +80,37 @@ export function validateCandidatePlans(candidatePlans, settings) {
79
80
  const rawStep = normalizeStepAliases(unprocessedStep);
80
81
  if (!isRecord(rawStep) || typeof rawStep.action !== "string") throw new JevBrowserError("each browser step must be an object with an action");
81
82
  const { action } = rawStep;
82
- const targetSelection = ["frame", "comment", "within", "index"];
83
- const schemas = {
84
- click: { required: ["role", "name"], optional: [...targetSelection, "expect_download"] },
85
- type: { required: ["role", "name", "text"], optional: targetSelection },
86
- wait_for_text: { required: ["text"], optional: ["frame", "comment"] },
87
- wait_for_condition: { required: ["condition"], optional: ["comment"] },
88
- press_key: { required: ["key"], optional: ["frame", "comment"] },
89
- select_option: { required: ["name", "option"], optional: targetSelection },
90
- hover: { required: ["role", "name"], optional: targetSelection },
91
- assert_text: { required: ["role", "name", "expected"], optional: targetSelection },
92
- assert_value: { required: ["role", "name", "expected"], optional: targetSelection },
93
- assert_visible: { required: ["role"], optional: ["name", ...targetSelection] },
94
- assert_hidden: { required: ["role"], optional: ["name", ...targetSelection] },
95
- upload_file: { required: ["file_paths"], optional: ["target", "label", "role", "name", ...targetSelection] },
96
- audit_accessibility: { required: ["standard"], optional: ["comment"] },
97
- like_comment: { required: ["author", "text", "like_label", "liked_label"], optional: ["frame", "comment"] },
98
- unlike_comment: { required: ["author", "text", "like_label", "liked_label"], optional: ["frame", "comment"] },
83
+ const targetSelection = ["frame", "comment", "within", "index"];
84
+ const schemas = {
85
+ click: { required: ["role", "name"], optional: [...targetSelection, "expect_download", "timeout_seconds"] },
86
+ type: { required: ["role", "name", "text"], optional: [...targetSelection, "blur", "mode", "sensitive", "timeout_seconds"] },
87
+ wait_for_text: { required: ["text"], optional: ["frame", "comment", "timeout_seconds"] },
88
+ wait_for_condition: { required: ["condition"], optional: ["frame", "comment", "name", "role", "text", "timeout_seconds"] },
89
+ press_key: { required: ["key"], optional: ["frame", "comment", "timeout_seconds"] },
90
+ select_option: { required: ["name", "option"], optional: [...targetSelection, "timeout_seconds"] },
91
+ hover: { required: ["role", "name"], optional: [...targetSelection, "timeout_seconds"] },
92
+ assert_text: { required: ["role", "expected"], optional: ["name", ...targetSelection, "timeout_seconds"] },
93
+ assert_value: { required: ["role", "name", "expected"], optional: [...targetSelection, "timeout_seconds"] },
94
+ assert_visible: { required: ["role"], optional: ["name", ...targetSelection, "timeout_seconds"] },
95
+ assert_hidden: { required: ["role"], optional: ["name", ...targetSelection, "timeout_seconds"] },
96
+ upload_file: { required: ["file_paths"], optional: ["target", "label", "role", "name", ...targetSelection, "timeout_seconds"] },
97
+ audit_accessibility: { required: ["standard"], optional: ["comment", "timeout_seconds"] },
98
+ like_comment: { required: ["author", "text", "like_label", "liked_label"], optional: ["frame", "comment", "timeout_seconds"] },
99
+ unlike_comment: { required: ["author", "text", "like_label", "liked_label"], optional: ["frame", "comment", "timeout_seconds"] },
99
100
  };
100
101
  if (!Object.hasOwn(schemas, action)) throw new JevBrowserError("unsupported browser action");
101
102
  exactKeys(rawStep, ["action", ...schemas[action].required], schemas[action].optional, action);
102
103
 
103
- const step = { action };
104
+ const step = { action };
105
+ if (rawStep.timeout_seconds !== undefined) {
106
+ if (typeof rawStep.timeout_seconds !== "number" || !Number.isFinite(rawStep.timeout_seconds) || rawStep.timeout_seconds <= 0) {
107
+ throw new JevBrowserError("timeout_seconds must be a positive number");
108
+ }
109
+ if (rawStep.timeout_seconds > settings.browser.maxStepTimeoutSeconds) {
110
+ throw new JevBrowserError("timeout_seconds exceeds the configured step-timeout limit");
111
+ }
112
+ step.timeoutMs = Math.ceil(rawStep.timeout_seconds * 1000);
113
+ }
104
114
  if (rawStep.comment !== undefined) requiredText(rawStep.comment, "comment", settings.jev.maxActionDescriptionChars);
105
115
  if (rawStep.within !== undefined) {
106
116
  if (!TARGET_SELECTION_ACTIONS.has(action) || !isRecord(rawStep.within)) {
@@ -134,7 +144,7 @@ export function validateCandidatePlans(candidatePlans, settings) {
134
144
  }
135
145
  step.role = rawStep.role;
136
146
  if (rawStep.name !== undefined) step.name = requiredText(rawStep.name, "accessible name", settings.jev.maxActionDescriptionChars);
137
- if (["click", "type", "hover", "assert_text", "assert_value"].includes(action) && !step.name) {
147
+ if (["click", "type", "hover", "assert_value"].includes(action) && !step.name) {
138
148
  throw new JevBrowserError(`${action} step needs an accessible name`);
139
149
  }
140
150
  }
@@ -143,17 +153,38 @@ export function validateCandidatePlans(candidatePlans, settings) {
143
153
  step.name = requiredText(rawStep.name, "accessible name", settings.jev.maxActionDescriptionChars);
144
154
  step.option = requiredText(rawStep.option, "option", settings.jev.maxActionDescriptionChars);
145
155
  }
146
- if (action === "type") {
147
- if (typeof rawStep.text !== "string" || rawStep.text.length === 0) throw new JevBrowserError("type step needs non-empty text");
148
- if (rawStep.text.length > settings.browser.maxTextEntryChars) throw new JevBrowserError("type text exceeds the configured character limit");
149
- step.text = rawStep.text;
156
+ if (action === "type") {
157
+ if (typeof rawStep.text !== "string" || rawStep.text.length === 0) throw new JevBrowserError("type step needs non-empty text");
158
+ if (rawStep.text.length > settings.browser.maxTextEntryChars) throw new JevBrowserError("type text exceeds the configured character limit");
159
+ if (rawStep.mode !== undefined && !new Set(["fill", "keys"]).has(rawStep.mode)) {
160
+ throw new JevBrowserError("type mode must be fill or keys");
161
+ }
162
+ if (rawStep.blur !== undefined && typeof rawStep.blur !== "boolean") throw new JevBrowserError("type blur must be a boolean");
163
+ if (rawStep.sensitive !== undefined && typeof rawStep.sensitive !== "boolean") throw new JevBrowserError("type sensitive must be a boolean");
164
+ step.text = rawStep.text;
165
+ step.mode = rawStep.mode || "fill";
166
+ step.blur = rawStep.blur ?? false;
167
+ step.sensitive = rawStep.sensitive ?? true;
150
168
  }
151
169
  if (action === "wait_for_text") {
152
170
  step.text = requiredText(rawStep.text, "wait text", settings.jev.maxActionDescriptionChars);
153
171
  }
154
- if (action === "wait_for_condition") {
155
- if (rawStep.condition !== "network_idle") throw new JevBrowserError("wait_for_condition supports only network_idle");
156
- step.condition = rawStep.condition;
172
+ if (action === "wait_for_condition") {
173
+ if (!new Set(["network_idle", "hidden", "text_hidden"]).has(rawStep.condition)) {
174
+ throw new JevBrowserError("wait_for_condition supports network_idle, hidden, or text_hidden");
175
+ }
176
+ step.condition = rawStep.condition;
177
+ if (rawStep.condition === "hidden") {
178
+ if (!ASSERT_ROLES.has(rawStep.role)) throw new JevBrowserError("hidden condition needs a supported accessible role");
179
+ step.role = rawStep.role;
180
+ if (rawStep.name !== undefined) step.name = requiredText(rawStep.name, "accessible name", settings.jev.maxActionDescriptionChars);
181
+ if (rawStep.text !== undefined) throw new JevBrowserError("hidden condition does not accept text");
182
+ } else if (rawStep.condition === "text_hidden") {
183
+ step.text = requiredText(rawStep.text, "hidden text", settings.jev.maxActionDescriptionChars);
184
+ if (rawStep.role !== undefined || rawStep.name !== undefined) throw new JevBrowserError("text_hidden condition does not accept role or name");
185
+ } else if (rawStep.role !== undefined || rawStep.name !== undefined || rawStep.text !== undefined) {
186
+ throw new JevBrowserError("network_idle condition does not accept role, name, or text");
187
+ }
157
188
  }
158
189
  if (action === "audit_accessibility") {
159
190
  if (typeof rawStep.standard !== "string" || !Object.hasOwn(ACCESSIBILITY_STANDARDS, rawStep.standard)) {
@@ -240,23 +271,42 @@ function resolveFlowOptions(rawOptions, settings) {
240
271
  if (!isRecord(rawOptions) || Object.keys(rawOptions).some((key) => !FLOW_OPTION_KEYS.has(key))) {
241
272
  throw new JevBrowserError("options contains unsupported flow settings");
242
273
  }
243
- const booleanOptions = [
244
- "block_trackers", "capture_console_errors", "capture_network_errors", "fast_path",
245
- "screenshot_on_failure", "trace_on_failure",
274
+ const booleanOptions = [
275
+ "block_trackers", "capture_console_errors", "capture_network_error_bodies", "capture_network_errors",
276
+ "fast_path", "ready_network_idle", "reuse_page", "screenshot_on_failure", "trace_on_failure",
246
277
  ];
247
278
  for (const key of booleanOptions) {
248
279
  if (rawOptions[key] !== undefined && typeof rawOptions[key] !== "boolean") {
249
280
  throw new JevBrowserError(`options.${key} must be a boolean`);
250
281
  }
251
282
  }
252
- const scope = rawOptions.snapshot_scope ?? settings.browser.defaults.snapshotScope;
253
- if (!new Set(["body", "main", "dialog"]).has(scope)) throw new JevBrowserError("options.snapshot_scope must be body, main, or dialog");
254
- const defaults = settings.browser.defaults;
255
- return Object.freeze({
256
- blockTrackers: rawOptions.block_trackers ?? defaults.blockTrackers,
257
- captureConsoleErrors: rawOptions.capture_console_errors ?? defaults.captureConsoleErrors,
258
- captureNetworkErrors: rawOptions.capture_network_errors ?? defaults.captureNetworkErrors,
259
- fastPath: rawOptions.fast_path ?? defaults.fastPath,
283
+ const scope = rawOptions.snapshot_scope ?? settings.browser.defaults.snapshotScope;
284
+ if (!new Set(["body", "main", "dialog"]).has(scope)) throw new JevBrowserError("options.snapshot_scope must be body, main, or dialog");
285
+ const defaults = settings.browser.defaults;
286
+ const readyTimeoutSeconds = rawOptions.ready_timeout_seconds ?? defaults.readyTimeoutSeconds;
287
+ if (typeof readyTimeoutSeconds !== "number" || !Number.isFinite(readyTimeoutSeconds) || readyTimeoutSeconds <= 0
288
+ || readyTimeoutSeconds > settings.browser.maxReadyTimeoutSeconds) {
289
+ throw new JevBrowserError(`options.ready_timeout_seconds must be positive and at most ${settings.browser.maxReadyTimeoutSeconds}`);
290
+ }
291
+ const readyStableMs = rawOptions.ready_stable_ms ?? defaults.readyStableMs;
292
+ if (!Number.isSafeInteger(readyStableMs) || readyStableMs <= 0 || readyStableMs > settings.browser.maxReadyStableMs
293
+ || readyStableMs > readyTimeoutSeconds * 1000) {
294
+ throw new JevBrowserError("options.ready_stable_ms must be a positive integer within the configured readiness limits");
295
+ }
296
+ const readyText = rawOptions.ready_text === undefined
297
+ ? ""
298
+ : requiredText(rawOptions.ready_text, "ready_text", settings.jev.maxActionDescriptionChars);
299
+ return Object.freeze({
300
+ blockTrackers: rawOptions.block_trackers ?? defaults.blockTrackers,
301
+ captureConsoleErrors: rawOptions.capture_console_errors ?? defaults.captureConsoleErrors,
302
+ captureNetworkErrorBodies: rawOptions.capture_network_error_bodies ?? defaults.captureNetworkErrorBodies,
303
+ captureNetworkErrors: rawOptions.capture_network_errors ?? defaults.captureNetworkErrors,
304
+ fastPath: rawOptions.fast_path ?? defaults.fastPath,
305
+ readyTimeoutMs: Math.ceil(readyTimeoutSeconds * 1000),
306
+ readyNetworkIdle: rawOptions.ready_network_idle ?? defaults.readyNetworkIdle,
307
+ readyStableMs,
308
+ readyText,
309
+ reusePage: rawOptions.reuse_page ?? defaults.reusePage,
260
310
  screenshotOnFailure: rawOptions.screenshot_on_failure ?? defaults.screenshotOnFailure,
261
311
  traceOnFailure: rawOptions.trace_on_failure ?? defaults.traceOnFailure,
262
312
  snapshotScope: scope,
@@ -267,12 +317,11 @@ function escapeRegex(value) {
267
317
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
268
318
  }
269
319
 
270
- function planSecrets(plans) {
320
+ function planSecrets(plans) {
271
321
  const secrets = new Set();
272
322
  for (const plan of Object.values(plans)) {
273
323
  for (const step of plan.steps) {
274
- if (step.action === "type") secrets.add(step.text);
275
- if (step.action === "assert_text" || step.action === "assert_value") secrets.add(step.expected);
324
+ if (step.action === "type" && step.sensitive) secrets.add(step.text);
276
325
  if (step.action === "upload_file") {
277
326
  for (const filePath of step.filePaths) {
278
327
  secrets.add(filePath);
@@ -354,19 +403,24 @@ async function frameDisplayName(frame) {
354
403
  return name;
355
404
  }
356
405
 
357
- async function snapshotPage(page, settings, secrets, snapshotScope = "body") {
406
+ async function snapshotPage(page, settings, secrets, snapshotScope = "body", { allowEmpty = false, timeoutMs } = {}) {
358
407
  let snapshot;
359
408
  let root;
360
409
  try {
361
410
  root = snapshotScope === "body" ? page.locator("body") : page.getByRole(snapshotScope);
362
- const rootCount = snapshotScope === "body" ? 1 : await root.count();
363
- if (rootCount !== 1) throw new JevBrowserError(`snapshot scope ${snapshotScope} must match exactly one accessible region; found ${rootCount}`);
364
- snapshot = (await root.ariaSnapshot()).trim();
365
- } catch (error) {
366
- if (error instanceof JevBrowserError) throw error;
367
- throw new JevBrowserError(`Playwright could not read the accessible page snapshot (${error?.name || "BrowserError"})`);
368
- }
369
- if (!snapshot) throw new JevBrowserError("Playwright returned an empty page snapshot");
411
+ const rootCount = snapshotScope === "body" ? 1 : await root.count();
412
+ if (allowEmpty && rootCount !== 1) return "";
413
+ if (rootCount !== 1) throw new JevBrowserError(`snapshot scope ${snapshotScope} must match exactly one accessible region; found ${rootCount}`);
414
+ snapshot = (await root.ariaSnapshot(timeoutMs ? { timeout: timeoutMs } : undefined)).trim();
415
+ } catch (error) {
416
+ if (error instanceof JevBrowserError) throw error;
417
+ if (allowEmpty) return "";
418
+ throw new JevBrowserError(`Playwright could not read the accessible page snapshot (${error?.name || "BrowserError"})`);
419
+ }
420
+ if (!snapshot) {
421
+ if (allowEmpty) return "";
422
+ throw new JevBrowserError("Playwright returned an empty page snapshot");
423
+ }
370
424
  let scopedFrameNames;
371
425
  if (snapshotScope !== "body") {
372
426
  try {
@@ -389,16 +443,84 @@ async function snapshotPage(page, settings, secrets, snapshotScope = "body") {
389
443
  }
390
444
  }
391
445
  const combined = [snapshot, ...frameSnapshots].join("\n\n");
392
- return compactSnapshot(redact(combined, secrets), settings.jev.maxSnapshotChars);
393
- }
394
-
395
- async function exactlyOne(locator, description) {
396
- const count = await locator.count();
397
- if (count !== 1) throw new JevBrowserError(`${description} must match exactly one visible control; found ${count}`);
398
- return locator;
399
- }
446
+ return compactSnapshot(redact(combined, secrets), settings.jev.maxSnapshotChars);
447
+ }
448
+
449
+ function remainingMs(deadline) {
450
+ return Math.max(1, Math.ceil(deadline - performance.now()));
451
+ }
452
+
453
+ async function waitForPageReady(page, options, settings, secrets) {
454
+ const started = performance.now();
455
+ const deadline = started + options.readyTimeoutMs;
456
+ const bestEffortLoadState = async (state) => {
457
+ try {
458
+ await page.waitForLoadState(state, { timeout: Math.min(500, remainingMs(deadline)) });
459
+ } catch {
460
+ // SPAs may keep requests open; accessible snapshot stability is the final readiness signal.
461
+ }
462
+ };
463
+ await bestEffortLoadState("load");
464
+ if (options.readyNetworkIdle) await bestEffortLoadState("networkidle");
465
+
466
+ let previousSnapshot = "";
467
+ let stableSince = 0;
468
+ let lastSnapshot = "";
469
+ while (performance.now() < deadline) {
470
+ const snapshot = await snapshotPage(page, settings, secrets, options.snapshotScope, {
471
+ allowEmpty: true,
472
+ timeoutMs: remainingMs(deadline),
473
+ });
474
+ lastSnapshot = snapshot;
475
+ const readyTextVisible = !options.readyText || containsPhrase(snapshot, options.readyText);
476
+ const usefulSnapshot = await hasRenderedContent(page, snapshot);
477
+ if (snapshot && readyTextVisible && usefulSnapshot) {
478
+ if (snapshot === previousSnapshot) {
479
+ if (!stableSince) stableSince = performance.now();
480
+ if (performance.now() - stableSince >= options.readyStableMs) {
481
+ return { snapshot, readyMs: performance.now() - started };
482
+ }
483
+ } else {
484
+ previousSnapshot = snapshot;
485
+ stableSince = performance.now();
486
+ }
487
+ } else {
488
+ previousSnapshot = "";
489
+ stableSince = 0;
490
+ }
491
+ await new Promise((resolve) => setTimeout(resolve, Math.min(100, remainingMs(deadline))));
492
+ }
493
+ const seconds = Number((options.readyTimeoutMs / 1000).toFixed(1));
494
+ const error = new JevBrowserError(`page did not render an accessible snapshot within ${seconds}s${lastSnapshot ? " (ready_text was not visible or snapshot did not stabilize)" : " (still loading?)"}`);
495
+ error.readyMs = performance.now() - started;
496
+ throw error;
497
+ }
498
+
499
+ async function hasRenderedContent(page, snapshot) {
500
+ const lines = snapshot.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
501
+ if (!lines.some((line, index) => index > 0 || /:\s+\S|"[^"]+"/.test(line))) return false;
502
+ try {
503
+ if (await page.locator('[aria-busy="true"]').count() > 0) return false;
504
+ } catch {
505
+ // The snapshot itself is the fallback when the DOM is transitioning.
506
+ }
507
+ return true;
508
+ }
509
+
510
+ async function snapshotAfterStep(page, settings, secrets, scope, previousSnapshot, stepNumber, warnings) {
511
+ const snapshot = await snapshotPage(page, settings, secrets, scope, { allowEmpty: true });
512
+ if (snapshot) return snapshot;
513
+ warnings.push(`empty snapshot after step ${stepNumber}; kept the last accessible snapshot while the SPA rendered`);
514
+ const deadline = performance.now() + settings.browser.postStepReadyTimeoutMs;
515
+ while (performance.now() < deadline) {
516
+ await new Promise((resolve) => setTimeout(resolve, Math.min(100, remainingMs(deadline))));
517
+ const recovered = await snapshotPage(page, settings, secrets, scope, { allowEmpty: true, timeoutMs: remainingMs(deadline) });
518
+ if (recovered) return recovered;
519
+ }
520
+ return previousSnapshot;
521
+ }
400
522
 
401
- function normalizedName(value) {
523
+ function normalizedName(value) {
402
524
  return value.toLocaleLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
403
525
  }
404
526
 
@@ -420,40 +542,73 @@ function editDistance(left, right) {
420
542
  return previous[b.length];
421
543
  }
422
544
 
423
- function closestAccessibleNames(snapshot, role, requestedName, secrets) {
424
- const names = new Set();
425
- const pattern = new RegExp(`^\\s*-\\s+${escapeRegex(role)}\\s+"((?:\\\\.|[^"])*)"`, "gmi");
426
- for (const match of snapshot.matchAll(pattern)) {
427
- if (names.size >= 200) break;
428
- if (match[1].length > 160) continue;
429
- try {
430
- names.add(JSON.parse(`"${match[1]}"`));
431
- } catch {
432
- names.add(match[1]);
433
- }
434
- }
435
- const wanted = normalizedName(requestedName);
436
- return [...names]
437
- .map((name) => {
438
- const normalized = normalizedName(name);
439
- const score = normalized && wanted
440
- ? editDistance(normalized, wanted) / Math.max(normalized.length, wanted.length)
441
- : 1;
442
- return { name, score };
443
- })
444
- .filter(({ score }) => score <= 0.75)
445
- .sort((left, right) => left.score - right.score)
446
- .slice(0, 3)
447
- .map(({ name }) => JSON.stringify(redact(name, secrets)));
448
- }
449
-
450
- async function scopedRoot(root, within) {
451
- if (!within) return root;
452
- return exactlyOne(
453
- root.getByRole(within.role, { name: within.name, exact: true }),
454
- "accessible within container",
455
- );
456
- }
545
+ function closestAccessibleNames(snapshot, role, requestedName, secrets) {
546
+ const roles = [...new Set([role, "button", "link", "tab", "menuitem"] )];
547
+ const names = new Map();
548
+ for (const candidateRole of roles) {
549
+ const pattern = new RegExp(`^\\s*-\\s+${escapeRegex(candidateRole)}\\s+"((?:\\\\.|[^"])*)"`, "gmi");
550
+ for (const match of snapshot.matchAll(pattern)) {
551
+ if (names.size >= 200) break;
552
+ if (match[1].length > 160) continue;
553
+ let name;
554
+ try {
555
+ name = JSON.parse(`"${match[1]}"`);
556
+ } catch {
557
+ name = match[1];
558
+ }
559
+ names.set(`${candidateRole}\n${name}`, { role: candidateRole, name });
560
+ }
561
+ }
562
+ const wanted = normalizedName(requestedName);
563
+ return [...names.values()]
564
+ .map(({ role: foundRole, name }) => {
565
+ const normalized = normalizedName(name);
566
+ const score = normalized && wanted
567
+ ? editDistance(normalized, wanted) / Math.max(normalized.length, wanted.length)
568
+ : 1;
569
+ return { role: foundRole, name, score };
570
+ })
571
+ .filter(({ score }) => score <= 0.75)
572
+ .sort((left, right) => left.score - right.score)
573
+ .slice(0, 3)
574
+ .map(({ role: foundRole, name }) => `${foundRole} ${JSON.stringify(redact(name, secrets))}`);
575
+ }
576
+
577
+ function attachStepCause(error, cause) {
578
+ error.stepCause = cause;
579
+ return error;
580
+ }
581
+
582
+ async function exactlyOne(locator, description, timeout) {
583
+ const first = typeof locator.first === "function" ? locator.first() : null;
584
+ if (timeout !== undefined && typeof first?.waitFor === "function") {
585
+ try {
586
+ await first.waitFor({ state: "visible", timeout });
587
+ } catch (cause) {
588
+ const error = new JevBrowserError(`${description} did not become visible before the configured timeout`);
589
+ throw attachStepCause(error, cause);
590
+ }
591
+ }
592
+ const count = await locator.count();
593
+ if (count !== 1) throw new JevBrowserError(`${description} must match exactly one visible control; found ${count}`);
594
+ return locator;
595
+ }
596
+
597
+ async function scopedRoot(root, within, timeout) {
598
+ if (!within) return root;
599
+ try {
600
+ return await exactlyOne(
601
+ root.getByRole(within.role, { name: within.name, exact: true }),
602
+ "accessible within container",
603
+ timeout,
604
+ );
605
+ } catch (error) {
606
+ if (error instanceof JevBrowserError && error.stepCause) {
607
+ throw attachStepCause(new JevBrowserError(`accessible within container ${within.role} ${JSON.stringify(within.name)} was not found`), error.stepCause);
608
+ }
609
+ throw error;
610
+ }
611
+ }
457
612
 
458
613
  function indexedTarget(locator, count, index, description) {
459
614
  if (index !== undefined) {
@@ -464,23 +619,70 @@ function indexedTarget(locator, count, index, description) {
464
619
  return locator;
465
620
  }
466
621
 
467
- async function roleLocator(root, role, name, within) {
468
- const targetRoot = await scopedRoot(root, within);
469
- const locator = name === undefined
470
- ? targetRoot.getByRole(role)
471
- : targetRoot.getByRole(role, { name, exact: true });
472
- return { locator, count: await locator.count() };
473
- }
474
-
475
- async function namedTarget(root, role, name, snapshot, secrets, selection = {}) {
476
- const { locator, count } = await roleLocator(root, role, name, selection.within);
477
- if (count === 0) {
478
- const suggestions = closestAccessibleNames(snapshot, role, name, secrets);
479
- const suffix = suggestions.length ? `; nearby accessible names: ${suggestions.join(", ")}` : "";
480
- throw new JevBrowserError(`accessible target ${role} ${JSON.stringify(redact(name, secrets))} not found${suffix}`);
481
- }
482
- return indexedTarget(locator, count, selection.index, `accessible target ${role} ${JSON.stringify(redact(name, secrets))}`);
483
- }
622
+ async function roleLocator(root, role, name, within, timeout) {
623
+ const targetRoot = await scopedRoot(root, within, timeout);
624
+ const locator = name === undefined
625
+ ? targetRoot.getByRole(role)
626
+ : targetRoot.getByRole(role, { name, exact: true });
627
+ return { locator, count: await locator.count() };
628
+ }
629
+
630
+ async function namedTarget(root, role, name, snapshot, secrets, selection = {}) {
631
+ const { locator } = await roleLocator(root, role, name, selection.within, selection.timeoutMs);
632
+ let count = await locator.count();
633
+ const awaited = selection.index === undefined
634
+ ? typeof locator.first === "function" ? locator.first() : null
635
+ : typeof locator.nth === "function" ? locator.nth(selection.index) : null;
636
+ if ((selection.index === undefined ? count === 0 : selection.index >= count) && typeof awaited?.waitFor === "function") {
637
+ try {
638
+ await awaited.waitFor({ state: "visible", timeout: selection.timeoutMs });
639
+ count = await locator.count();
640
+ } catch (cause) {
641
+ if (count > 0 && selection.index !== undefined) {
642
+ throw new JevBrowserError(`accessible target ${role} ${JSON.stringify(redact(name, secrets))} index ${selection.index} is out of range; found ${count}`);
643
+ }
644
+ const suggestions = closestAccessibleNames(snapshot, role, name, secrets);
645
+ const suffix = suggestions.length ? `; nearby accessible controls: ${suggestions.join(", ")}` : "";
646
+ const error = new JevBrowserError(`accessible target ${role} ${JSON.stringify(redact(name, secrets))} not found${suffix}`);
647
+ throw attachStepCause(error, cause);
648
+ }
649
+ }
650
+ if (count === 0) {
651
+ const suggestions = closestAccessibleNames(snapshot, role, name, secrets);
652
+ const suffix = suggestions.length ? `; nearby accessible controls: ${suggestions.join(", ")}` : "";
653
+ throw new JevBrowserError(`accessible target ${role} ${JSON.stringify(redact(name, secrets))} not found${suffix}`);
654
+ }
655
+ const description = `accessible target ${role} ${JSON.stringify(redact(name, secrets))}`;
656
+ const target = indexedTarget(locator, count, selection.index, description);
657
+ if (typeof target.waitFor === "function") {
658
+ try {
659
+ await target.waitFor({ state: "visible", timeout: selection.timeoutMs });
660
+ } catch (cause) {
661
+ const detail = selection.index !== undefined && selection.index >= count
662
+ ? `${description} index ${selection.index} is out of range; found ${count}`
663
+ : `${description} did not become visible before the configured timeout`;
664
+ throw attachStepCause(new JevBrowserError(detail), cause);
665
+ }
666
+ }
667
+ return target;
668
+ }
669
+
670
+ async function visibleRoleTarget(root, step, timeout, description) {
671
+ const { locator } = await roleLocator(root, step.role, undefined, step.within, timeout);
672
+ const target = step.index === undefined ? locator.first() : locator.nth(step.index);
673
+ if (typeof target?.waitFor === "function") {
674
+ try {
675
+ await target.waitFor({ state: "visible", timeout });
676
+ } catch (cause) {
677
+ throw attachStepCause(new JevBrowserError(`${description} did not become visible before the configured timeout`), cause);
678
+ }
679
+ }
680
+ const count = await locator.count();
681
+ if (count === 0 || (step.index !== undefined && step.index >= count)) {
682
+ throw new JevBrowserError(`${description} has no matching accessible control`);
683
+ }
684
+ return { locator, target: step.index === undefined ? locator.first() : locator.nth(step.index), count };
685
+ }
484
686
 
485
687
  async function stepRoot(page, frameName) {
486
688
  if (!frameName) return page;
@@ -504,8 +706,8 @@ function commentRow(root, author, text) {
504
706
  return root.getByRole("button", { name: new RegExp(`(?=.*${authorPattern})(?=.*${textPattern})`, "isu") });
505
707
  }
506
708
 
507
- async function reactToComment(root, step) {
508
- const row = await exactlyOne(commentRow(root, step.author, step.text), "requested comment row");
709
+ async function reactToComment(root, step, timeout) {
710
+ const row = await exactlyOne(commentRow(root, step.author, step.text), "requested comment row", timeout);
509
711
  const isLike = step.action === "like_comment";
510
712
  const desired = isLike ? step.liked_label : step.like_label;
511
713
  const current = isLike ? step.like_label : step.liked_label;
@@ -519,8 +721,8 @@ async function reactToComment(root, step) {
519
721
  if (desiredCount === 1) return isLike ? "already_liked" : "already_unliked";
520
722
  if (currentCount !== 1) throw new JevBrowserError("comment reaction control is not uniquely available in the targeted comment");
521
723
  const controls = row.getByRole("button").filter({ has: currentImages });
522
- await (await exactlyOne(controls, "comment reaction button")).click();
523
- await desiredImages.waitFor({ state: "visible" });
724
+ await (await exactlyOne(controls, "comment reaction button", timeout)).click({ timeout });
725
+ await desiredImages.waitFor({ state: "visible", timeout });
524
726
  return isLike ? "liked" : "unliked";
525
727
  }
526
728
 
@@ -671,71 +873,100 @@ async function saveDownload(download, settings, ledger) {
671
873
  }
672
874
  }
673
875
 
674
- async function executeStep(page, step, settings, snapshot, secrets, downloadLedger) {
675
- const root = await stepRoot(page, step.frame);
676
- const timeout = settings.browser.actionTimeoutMs;
677
- if (step.action === "wait_for_text") {
678
- await root.getByText(step.text, { exact: false }).first().waitFor({ state: "visible", timeout });
679
- return { action: step.action };
680
- }
681
- if (step.action === "wait_for_condition") {
682
- await root.waitForLoadState("networkidle", { timeout });
683
- return { action: step.action, condition: step.condition };
684
- }
685
- if (step.action === "press_key") {
686
- if (step.frame) await root.locator("body").press(step.key, { timeout });
687
- else await page.keyboard.press(step.key);
688
- return { action: step.action, key: step.key };
689
- }
690
- if (step.action === "like_comment" || step.action === "unlike_comment") {
691
- return { action: step.action, result: await reactToComment(root, step) };
692
- }
693
- if (step.action === "audit_accessibility") {
694
- return {
695
- action: step.action,
696
- accessibility_audit: await runAccessibilityAudit(page, step.standard, settings),
697
- };
698
- }
699
- if (step.action === "upload_file") {
700
- const files = await uploadFiles(step, settings);
701
- if (step.target === "input") {
702
- const targetRoot = await scopedRoot(root, step.within);
703
- const input = targetRoot.getByLabel(step.label, { exact: true });
704
- const count = await input.count();
705
- const control = indexedTarget(input, count, step.index, "accessible file input");
706
- await control.setInputFiles(files, { timeout });
707
- } else {
708
- const control = await namedTarget(root, step.role, step.name, snapshot, secrets, step);
709
- if (step.target === "button") {
876
+ async function executeStep(page, step, settings, snapshot, secrets, downloadLedger) {
877
+ const root = await stepRoot(page, step.frame);
878
+ const timeout = step.timeoutMs ?? settings.browser.actionTimeoutMs;
879
+ if (step.action === "wait_for_text") {
880
+ await root.getByText(step.text, { exact: false }).first().waitFor({ state: "visible", timeout });
881
+ return { action: step.action };
882
+ }
883
+ if (step.action === "wait_for_condition") {
884
+ if (step.condition === "network_idle") {
885
+ await root.waitForLoadState("networkidle", { timeout });
886
+ } else if (step.condition === "text_hidden") {
887
+ await root.getByText(step.text, { exact: false }).first().waitFor({ state: "hidden", timeout });
888
+ } else {
889
+ const { locator, count } = await roleLocator(root, step.role, step.name, undefined, timeout);
890
+ if (count > 1 && step.index === undefined) throw new JevBrowserError("hidden condition target is ambiguous; provide an index");
891
+ if (step.index !== undefined && step.index >= count) return { action: step.action, condition: step.condition };
892
+ await (step.index === undefined ? locator.first() : locator.nth(step.index)).waitFor({ state: "hidden", timeout });
893
+ }
894
+ return { action: step.action, condition: step.condition };
895
+ }
896
+ if (step.action === "press_key") {
897
+ await root.locator("body").press(step.key, { timeout });
898
+ return { action: step.action, key: step.key };
899
+ }
900
+ if (step.action === "like_comment" || step.action === "unlike_comment") {
901
+ return { action: step.action, result: await reactToComment(root, step, timeout) };
902
+ }
903
+ if (step.action === "audit_accessibility") {
904
+ return {
905
+ action: step.action,
906
+ accessibility_audit: await withTimeout(
907
+ runAccessibilityAudit(page, step.standard, settings),
908
+ timeout,
909
+ `accessibility audit exceeded the ${timeout}ms step timeout`,
910
+ ),
911
+ };
912
+ }
913
+ if (step.action === "upload_file") {
914
+ const files = await uploadFiles(step, settings);
915
+ if (step.target === "input") {
916
+ const targetRoot = await scopedRoot(root, step.within, timeout);
917
+ const input = targetRoot.getByLabel(step.label, { exact: true });
918
+ const selected = step.index === undefined ? input.first() : input.nth(step.index);
919
+ if (typeof selected?.waitFor === "function") {
920
+ try {
921
+ await selected.waitFor({ state: "attached", timeout });
922
+ } catch (cause) {
923
+ throw attachStepCause(new JevBrowserError("accessible file input did not appear before the configured timeout"), cause);
924
+ }
925
+ }
926
+ const count = await input.count();
927
+ const control = indexedTarget(input, count, step.index, "accessible file input");
928
+ await control.setInputFiles(files, { timeout });
929
+ } else {
930
+ const control = await namedTarget(root, step.role, step.name, snapshot, secrets, { ...step, timeoutMs: timeout });
931
+ if (step.target === "button") {
710
932
  const [chooser] = await Promise.all([
711
933
  page.waitForEvent("filechooser", { timeout }),
712
934
  control.click({ timeout }),
713
935
  ]);
714
- await chooser.setFiles(files);
936
+ await withTimeout(chooser.setFiles(files), timeout, `file chooser exceeded the ${timeout}ms step timeout`);
715
937
  } else {
716
938
  await control.drop({ files }, { timeout });
717
939
  }
718
940
  }
719
941
  return { action: step.action, target: step.target, file_count: files.length };
720
- }
721
- if (step.action === "assert_hidden") {
722
- const { locator, count } = await roleLocator(root, step.role, step.name, step.within);
723
- if (step.index === undefined ? count > 0 : step.index < count) {
724
- throw new JevBrowserError(`assert_hidden failed for ${step.role}${step.name ? ` ${JSON.stringify(redact(step.name, secrets))}` : ""}`);
725
- }
726
- return { action: step.action, asserted: true };
727
- }
728
- if (step.action === "assert_visible" && step.name === undefined) {
729
- const { locator, count } = await roleLocator(root, step.role, undefined, step.within);
730
- if (count === 0 || (step.index !== undefined && step.index >= count)) {
731
- throw new JevBrowserError(`assert_visible failed for ${step.role}`);
732
- }
733
- if (step.index !== undefined && !(await locator.nth(step.index).isVisible())) {
734
- throw new JevBrowserError(`assert_visible failed for ${step.role}`);
735
- }
736
- return { action: step.action, asserted: true };
737
- }
738
- const control = await namedTarget(root, step.role, step.name, snapshot, secrets, step);
942
+ }
943
+ if (step.action === "assert_hidden") {
944
+ const { locator, count } = await roleLocator(root, step.role, step.name, step.within, timeout);
945
+ if (step.index !== undefined) {
946
+ if (step.index < count) await locator.nth(step.index).waitFor({ state: "hidden", timeout });
947
+ } else {
948
+ for (let index = 0; index < count; index += 1) await locator.nth(index).waitFor({ state: "hidden", timeout });
949
+ }
950
+ return { action: step.action, asserted: true };
951
+ }
952
+ if (step.action === "assert_visible" && step.name === undefined) {
953
+ await visibleRoleTarget(root, step, timeout, `assert_visible ${step.role}`);
954
+ return { action: step.action, asserted: true };
955
+ }
956
+ if (step.action === "assert_text" && step.name === undefined) {
957
+ const { locator, count, target } = await visibleRoleTarget(root, step, timeout, `assert_text ${step.role}`);
958
+ if (step.index === undefined && count !== 1) {
959
+ throw new JevBrowserError(`assert_text ${step.role} must match exactly one accessible control; found ${count}`);
960
+ }
961
+ const control = step.index === undefined ? locator : target;
962
+ const actual = (await control.innerText({ timeout })).replace(/\s+/g, " ").trim();
963
+ const expected = step.expected.replace(/\s+/g, " ").trim();
964
+ if (!actual.toLocaleLowerCase().includes(expected.toLocaleLowerCase())) {
965
+ throw new JevBrowserError(`assert_text failed for ${step.role}`);
966
+ }
967
+ return { action: step.action, asserted: true };
968
+ }
969
+ const control = await namedTarget(root, step.role, step.name, snapshot, secrets, { ...step, timeoutMs: timeout });
739
970
  if (step.action === "click") {
740
971
  if (!step.expectDownload) {
741
972
  await control.click({ timeout });
@@ -756,9 +987,16 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
756
987
  name: step.name,
757
988
  downloaded_file: await saveDownload(download, settings, downloadLedger),
758
989
  };
759
- }
760
- if (step.action === "type") {
761
- await control.fill(step.text, { timeout });
990
+ }
991
+ if (step.action === "type") {
992
+ if (step.mode === "keys") {
993
+ await control.fill("", { timeout });
994
+ await control.pressSequentially(step.text, { delay: settings.browser.keyDelayMs, timeout });
995
+ if (step.blur) await control.press("Tab", { timeout });
996
+ } else {
997
+ await control.fill(step.text, { timeout });
998
+ if (step.blur) await control.press("Tab", { timeout });
999
+ }
762
1000
  return { action: step.action, role: step.role, name: step.name };
763
1001
  }
764
1002
  if (step.action === "select_option") {
@@ -789,11 +1027,12 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
789
1027
  throw new JevBrowserError("unsupported browser action");
790
1028
  }
791
1029
 
792
- function flowTimings(started, browserSessionMs, navigationMs, initialSnapshotMs, jevDecisionMs, browserPlanMs) {
793
- return {
794
- browser_session_ms: Math.round(browserSessionMs * 10) / 10,
795
- navigation_ms: Math.round(navigationMs * 10) / 10,
796
- initial_snapshot_ms: Math.round(initialSnapshotMs * 10) / 10,
1030
+ function flowTimings(started, browserSessionMs, navigationMs, initialSnapshotMs, readyMs, jevDecisionMs, browserPlanMs) {
1031
+ return {
1032
+ browser_session_ms: Math.round(browserSessionMs * 10) / 10,
1033
+ navigation_ms: Math.round(navigationMs * 10) / 10,
1034
+ initial_snapshot_ms: Math.round(initialSnapshotMs * 10) / 10,
1035
+ ready_ms: Math.round(readyMs * 10) / 10,
797
1036
  jev_decision_ms: Math.round(jevDecisionMs * 10) / 10,
798
1037
  browser_plan_ms: Math.round(browserPlanMs * 10) / 10,
799
1038
  total_ms: Math.round((performance.now() - started) * 10) / 10,
@@ -825,10 +1064,11 @@ function sanitizeDiagnosticText(value, secrets, limit) {
825
1064
  return result.slice(0, limit);
826
1065
  }
827
1066
 
828
- function createDiagnostics(page, options, settings, secrets) {
829
- const diagnostics = { console_errors: [], network_failures: [] };
830
- const limit = settings.jev.maxDiagnosticItems;
831
- const messageLimit = settings.jev.maxDiagnosticChars;
1067
+ function createDiagnostics(page, options, settings, secrets) {
1068
+ const diagnostics = { console_errors: [], network_failures: [] };
1069
+ const pendingBodies = [];
1070
+ const limit = settings.jev.maxDiagnosticItems;
1071
+ const messageLimit = settings.jev.maxDiagnosticChars;
832
1072
  const onConsole = (message) => {
833
1073
  if (message.type() !== "error") return;
834
1074
  addDiagnostic(diagnostics.console_errors, sanitizeDiagnosticText(message.text(), secrets, messageLimit), limit);
@@ -836,14 +1076,41 @@ function createDiagnostics(page, options, settings, secrets) {
836
1076
  const onPageError = (error) => {
837
1077
  addDiagnostic(diagnostics.console_errors, sanitizeDiagnosticText(error.message || error.name, secrets, messageLimit), limit);
838
1078
  };
839
- const onResponse = (response) => {
840
- if (response.status() < 400) return;
841
- const request = response.request();
842
- addDiagnostic(diagnostics.network_failures, {
843
- url: evidenceUrl(response.url(), secrets, messageLimit),
844
- method: request.method(),
845
- status: response.status(),
846
- }, limit);
1079
+ const addResponseMessage = async (response, entry) => {
1080
+ try {
1081
+ const responseUrl = new URL(response.url());
1082
+ const pageUrl = new URL(page.url());
1083
+ if (responseUrl.origin !== pageUrl.origin) return;
1084
+ const headers = await response.headers();
1085
+ const contentType = String(headers["content-type"] || "").toLowerCase();
1086
+ const contentLength = Number(headers["content-length"]);
1087
+ if (!contentType.includes("application/json") || !Number.isSafeInteger(contentLength)
1088
+ || contentLength < 0 || contentLength > settings.browser.maxNetworkErrorBodyBytes) return;
1089
+ const body = await withTimeout(
1090
+ response.body(),
1091
+ settings.browser.actionTimeoutMs,
1092
+ "error response body read timed out",
1093
+ );
1094
+ if (body.length > settings.browser.maxNetworkErrorBodyBytes) return;
1095
+ const payload = JSON.parse(body.toString("utf8"));
1096
+ if (!isRecord(payload) || typeof payload.message !== "string") return;
1097
+ entry.message = sanitizeDiagnosticText(payload.message, secrets, settings.browser.maxNetworkErrorMessageChars);
1098
+ } catch {
1099
+ // A diagnostic body is optional; malformed, oversized, or late bodies do not fail the flow.
1100
+ }
1101
+ };
1102
+ const onResponse = (response) => {
1103
+ if (response.status() < 400) return;
1104
+ const request = response.request();
1105
+ const failure = {
1106
+ url: evidenceUrl(response.url(), secrets, messageLimit),
1107
+ method: request.method(),
1108
+ status: response.status(),
1109
+ };
1110
+ addDiagnostic(diagnostics.network_failures, failure, limit);
1111
+ if (options.captureNetworkErrorBodies && diagnostics.network_failures.includes(failure)) {
1112
+ pendingBodies.push(addResponseMessage(response, failure));
1113
+ }
847
1114
  };
848
1115
  const onRequestFailed = (request) => {
849
1116
  addDiagnostic(diagnostics.network_failures, {
@@ -856,20 +1123,41 @@ function createDiagnostics(page, options, settings, secrets) {
856
1123
  page.on("console", onConsole);
857
1124
  page.on("pageerror", onPageError);
858
1125
  }
859
- if (options.captureNetworkErrors) {
860
- page.on("response", onResponse);
861
- page.on("requestfailed", onRequestFailed);
1126
+ if (options.captureNetworkErrors || options.captureNetworkErrorBodies) {
1127
+ page.on("response", onResponse);
1128
+ }
1129
+ if (options.captureNetworkErrors) {
1130
+ page.on("requestfailed", onRequestFailed);
862
1131
  }
863
1132
  return {
864
1133
  diagnostics,
865
- dispose() {
1134
+ async dispose() {
866
1135
  page.off("console", onConsole);
867
1136
  page.off("pageerror", onPageError);
868
- page.off("response", onResponse);
869
- page.off("requestfailed", onRequestFailed);
1137
+ page.off("response", onResponse);
1138
+ page.off("requestfailed", onRequestFailed);
1139
+ await Promise.allSettled(pendingBodies);
870
1140
  },
871
- };
872
- }
1141
+ };
1142
+ }
1143
+
1144
+ function stepFailureText(error, secrets, limit) {
1145
+ const value = error?.message || error?.name || "BrowserError";
1146
+ const timeout = String(value).match(/Timeout\s+\d+ms exceeded\./i);
1147
+ return sanitizeDiagnosticText(timeout ? timeout[0] : String(value).split(/\r?\n/, 1)[0], secrets, limit);
1148
+ }
1149
+
1150
+ function failedStepDetails(step, index, error, settings, secrets) {
1151
+ const details = {
1152
+ index,
1153
+ action: step.action,
1154
+ };
1155
+ if (step.role !== undefined) details.role = step.role;
1156
+ if (step.name !== undefined) details.name = redact(step.name, secrets);
1157
+ details.timeout_ms = step.timeoutMs ?? settings.browser.actionTimeoutMs;
1158
+ details.error = stepFailureText(error?.stepCause || error, secrets, settings.jev.maxDiagnosticChars);
1159
+ return details;
1160
+ }
873
1161
 
874
1162
  function trackerUrl(url, settings) {
875
1163
  let hostname;
@@ -927,7 +1215,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
927
1215
  const url = validateUrl(initialUrl);
928
1216
  const plans = validateCandidatePlans(candidatePlans, settings);
929
1217
  const options = resolveFlowOptions(rawOptions, settings);
930
- const secrets = planSecrets(plans);
1218
+ const secrets = [...planSecrets(plans), settings.env?.[settings.jev.credentialEnv]].filter(Boolean)
1219
+ .sort((left, right) => right.length - left.length);
931
1220
  const started = performance.now();
932
1221
  const browser = await browserPool.getPage();
933
1222
  const browserSessionMs = browser.sessionMs;
@@ -942,11 +1231,12 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
942
1231
  jev_decisions: 0,
943
1232
  steps: [],
944
1233
  downloaded_files: [],
945
- accessibility_audits: [],
946
- final_snapshot: "",
947
- };
1234
+ accessibility_audits: [],
1235
+ warnings: [],
1236
+ final_snapshot: "",
1237
+ };
948
1238
  const downloadLedger = { count: 0, bytes: 0 };
949
- const timings = { navigationMs: 0, initialSnapshotMs: 0, decisionMs: 0, browserPlanMs: 0 };
1239
+ const timings = { navigationMs: 0, initialSnapshotMs: 0, readyMs: 0, decisionMs: 0, browserPlanMs: 0 };
950
1240
  let unroute;
951
1241
  let tracingStarted = false;
952
1242
  let tracePath;
@@ -964,12 +1254,37 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
964
1254
  }
965
1255
  }
966
1256
 
967
- const navigationStarted = performance.now();
968
- await page.goto(url, { waitUntil: "domcontentloaded", timeout: settings.browser.actionTimeoutMs * 4 });
969
- timings.navigationMs = performance.now() - navigationStarted;
970
- const snapshotStarted = performance.now();
971
- result.final_snapshot = await snapshotPage(page, settings, secrets, options.snapshotScope);
972
- timings.initialSnapshotMs = performance.now() - snapshotStarted;
1257
+ let reusedPage = false;
1258
+ if (options.reusePage) {
1259
+ const currentUrl = page.url();
1260
+ if (currentUrl && currentUrl !== "about:blank") {
1261
+ let currentOrigin;
1262
+ try {
1263
+ currentOrigin = new URL(currentUrl).origin;
1264
+ } catch {
1265
+ currentOrigin = "";
1266
+ }
1267
+ if (currentOrigin !== new URL(url).origin) {
1268
+ throw new JevBrowserError("reuse_page requires the current page to be on the same origin as initial_url");
1269
+ }
1270
+ reusedPage = true;
1271
+ }
1272
+ }
1273
+ if (!reusedPage) {
1274
+ const navigationStarted = performance.now();
1275
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: settings.browser.actionTimeoutMs * 4 });
1276
+ timings.navigationMs = performance.now() - navigationStarted;
1277
+ }
1278
+ const snapshotStarted = performance.now();
1279
+ try {
1280
+ const readiness = await waitForPageReady(page, options, settings, secrets);
1281
+ result.final_snapshot = readiness.snapshot;
1282
+ timings.readyMs = readiness.readyMs;
1283
+ } catch (error) {
1284
+ timings.readyMs = error.readyMs || performance.now() - snapshotStarted;
1285
+ throw error;
1286
+ }
1287
+ timings.initialSnapshotMs = performance.now() - snapshotStarted;
973
1288
 
974
1289
  const initialVisible = containsPhrase(result.final_snapshot, outcome);
975
1290
  const allSteps = Object.values(plans).flatMap(({ steps }) => steps);
@@ -1018,16 +1333,17 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
1018
1333
 
1019
1334
  if (planId) {
1020
1335
  const plan = plans[planId];
1021
- const trace = [];
1022
- let failure = "";
1023
- let snapshot = result.final_snapshot;
1336
+ const trace = [];
1337
+ let failure = "";
1338
+ let failedStep;
1339
+ let snapshot = result.final_snapshot;
1024
1340
  const planStarted = performance.now();
1025
1341
  const runAllSteps = plan.steps.some(({ action, expectDownload }) => action.startsWith("assert_")
1026
1342
  || ["upload_file", "select_option", "hover", "like_comment", "unlike_comment", "audit_accessibility"].includes(action)
1027
1343
  || expectDownload === true);
1028
1344
  for (const [index, step] of plan.steps.entries()) {
1029
1345
  try {
1030
- const traceStep = await executeStep(page, step, settings, snapshot, secrets, downloadLedger);
1346
+ const traceStep = await executeStep(page, step, settings, snapshot, secrets, downloadLedger);
1031
1347
  traceStep.step = String(index + 1);
1032
1348
  trace.push(traceStep);
1033
1349
  if (traceStep.downloaded_file) result.downloaded_files.push(traceStep.downloaded_file);
@@ -1037,14 +1353,15 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
1037
1353
  failure = `accessibility audit reported ${traceStep.accessibility_audit.violations_count} violation(s)`;
1038
1354
  }
1039
1355
  }
1040
- snapshot = await snapshotPage(page, settings, secrets, options.snapshotScope);
1356
+ snapshot = await snapshotAfterStep(page, settings, secrets, options.snapshotScope, snapshot, index + 1, result.warnings);
1041
1357
  if (failure) break;
1042
1358
  if (!runAllSteps && containsPhrase(snapshot, outcome)) break;
1043
- } catch (error) {
1044
- failure = error instanceof JevBrowserError
1045
- ? error.message
1046
- : `Playwright action failed (${error?.name || "BrowserError"})`;
1047
- break;
1359
+ } catch (error) {
1360
+ failure = error instanceof JevBrowserError
1361
+ ? error.message
1362
+ : `Playwright action failed (${error?.name || "BrowserError"})`;
1363
+ failedStep = failedStepDetails(step, index, error, settings, secrets);
1364
+ break;
1048
1365
  }
1049
1366
  }
1050
1367
  timings.browserPlanMs = performance.now() - planStarted;
@@ -1063,9 +1380,12 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
1063
1380
  result.acceptance_criteria_met = !failure && (visible || assertionsPassed || auditsPassed || downloadsPassed);
1064
1381
  result.selected_plan = planId;
1065
1382
  result.steps_executed = trace.length;
1066
- result.steps = trace;
1067
- result.final_snapshot = snapshot;
1068
- if (failure) result.reason = failure;
1383
+ result.steps = trace;
1384
+ result.final_snapshot = snapshot;
1385
+ if (failure) {
1386
+ result.reason = failure;
1387
+ result.failed_step = failedStep;
1388
+ }
1069
1389
  if (assertionCount > 0) result.assertions_passed = assertionsPassed;
1070
1390
  if (auditCount > 0) result.accessibility_audit_passed = auditsPassed;
1071
1391
  if (decision) {
@@ -1080,7 +1400,7 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
1080
1400
  ? error.message
1081
1401
  : `Playwright flow failed (${error?.name || "BrowserError"})`;
1082
1402
  try {
1083
- result.final_snapshot = await snapshotPage(page, settings, secrets, options.snapshotScope);
1403
+ result.final_snapshot = await snapshotPage(page, settings, secrets, options.snapshotScope, { allowEmpty: true });
1084
1404
  result.expected_outcome_visible = containsPhrase(result.final_snapshot, outcome);
1085
1405
  } catch {
1086
1406
  // Keep the last accessible snapshot when the page is closing or unavailable.
@@ -1112,13 +1432,13 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
1112
1432
  } catch {
1113
1433
  artifactErrors.push("tracker blocking cleanup failed");
1114
1434
  }
1115
- capture.dispose();
1435
+ await capture.dispose();
1116
1436
  }
1117
1437
  if (tracePath) result.trace_path = tracePath;
1118
1438
  if (artifactErrors.length) result.artifact_errors = artifactErrors;
1119
1439
  result.console_errors = capture.diagnostics.console_errors;
1120
1440
  result.network_failures = capture.diagnostics.network_failures;
1121
- result.timings_ms = flowTimings(started, browserSessionMs, timings.navigationMs, timings.initialSnapshotMs, timings.decisionMs, timings.browserPlanMs);
1441
+ result.timings_ms = flowTimings(started, browserSessionMs, timings.navigationMs, timings.initialSnapshotMs, timings.readyMs, timings.decisionMs, timings.browserPlanMs);
1122
1442
  return redactObject(result, secrets);
1123
1443
  }
1124
1444