@diegosouzacdv/jev-browser-mcp 0.6.1 → 0.6.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.
@@ -6,8 +6,8 @@ import { JevBrowserError } from "./config.mjs";
6
6
  import { buildChoicePayload, COMPLETE_ACTION_ID, parseDecision, requestJev } from "./jev-client.mjs";
7
7
 
8
8
  const PLAN_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,79}$/;
9
- const CLICK_ROLES = new Set(["button", "link", "tab", "menuitem", "checkbox", "radio", "cell", "gridcell", "option"]);
10
- const CHECK_ROLES = new Set(["checkbox", "radio"]);
9
+ const CLICK_ROLES = new Set(["button", "link", "tab", "menuitem", "checkbox", "radio", "switch", "cell", "gridcell", "option"]);
10
+ const CHECK_ROLES = new Set(["checkbox", "radio", "switch"]);
11
11
  const TEXT_ROLES = new Set(["textbox", "searchbox", "combobox"]);
12
12
  const HOVER_ROLES = new Set(["button", "link", "tab", "menuitem", "row", "gridcell", "cell", "region", "group"]);
13
13
  const DROPZONE_ROLES = new Set(["button", "region", "group"]);
@@ -16,22 +16,22 @@ const CONTAINER_ROLES = new Set([
16
16
  "group", "list", "listbox", "listitem", "main", "menu", "menubar", "navigation",
17
17
  "region", "row", "rowgroup", "table", "tablist", "tabpanel", "tree", "treegrid", "treeitem",
18
18
  ]);
19
- const TARGET_SELECTION_ACTIONS = new Set([
20
- "click", "check", "uncheck", "type", "select_option", "hover", "press", "assert_text", "assert_value", "wait_for_value",
19
+ const TARGET_SELECTION_ACTIONS = new Set([
20
+ "click", "check", "uncheck", "type", "clear", "select_option", "hover", "press", "assert_text", "assert_value", "wait_for_value",
21
21
  "wait_for_enabled", "assert_enabled", "assert_visible", "assert_hidden", "upload_file", "extract",
22
22
  ]);
23
23
  const LOCATOR_FIELDS = ["label", "placeholder", "title", "text", "test_id", "selector"];
24
24
  const TARGET_LOCATOR_FIELDS = [...LOCATOR_FIELDS, "target_text"];
25
25
  const LOCATOR_STEP_KEYS = { label: "label", placeholder: "placeholder", title: "title", text: "targetText", test_id: "testId", selector: "selector", target_text: "typedTargetText" };
26
26
  const ASSERT_ROLES = new Set([
27
- "alert", "button", "cell", "checkbox", "combobox", "dialog", "gridcell", "heading",
27
+ "alert", "button", "cell", "checkbox", "combobox", "dialog", "gridcell", "heading",
28
28
  "img", "link", "listitem", "main", "menuitem", "option", "paragraph", "progressbar",
29
- "radio", "region", "row", "searchbox", "status", "tab", "textbox",
29
+ "radio", "region", "row", "searchbox", "status", "switch", "tab", "textbox",
30
30
  ]);
31
31
  const UNNAMED_ACCESSIBLE_NAME = /^[\s\uE000-\uF8FF]*$/u;
32
32
  const APPROVED_KEYS = new Set([
33
33
  "PageDown", "PageUp", "Home", "End", "ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight",
34
- "Enter", "Escape", "Tab", "Shift+Tab",
34
+ "Enter", "Escape", "Tab", "Shift+Tab", "Space", "Backspace", "Delete",
35
35
  ]);
36
36
  const MIME_TYPES = new Map([
37
37
  [".csv", "text/csv"], [".json", "application/json"], [".pdf", "application/pdf"],
@@ -39,10 +39,11 @@ const MIME_TYPES = new Map([
39
39
  [".txt", "text/plain"], [".webp", "image/webp"],
40
40
  ]);
41
41
  const FLOW_OPTION_KEYS = new Set([
42
- "block_trackers", "capture_console_errors", "capture_network_error_bodies", "capture_network_errors",
43
- "auto_angular_idle", "block_fonts", "busy_selectors", "confirmation_token", "dry_run", "fast_path", "local_only",
44
- "login_text", "login_url_contains", "max_flow_steps", "ready_network_idle", "ready_stable_ms", "ready_text", "ready_timeout_seconds",
45
- "report_path",
42
+ "allow_mutations", "block_trackers", "capture_console_errors", "capture_network_error_bodies", "capture_network_errors",
43
+ "auto_angular_idle", "block_fonts", "busy_selectors", "confirmation_token", "dry_run", "fast_path", "local_only",
44
+ "login_text", "login_url_contains", "max_flow_steps", "ready_network_idle", "ready_stable_ms", "ready_text", "ready_timeout_seconds",
45
+ "report_path", "permissions", "fake_media", "viewport", "mobile", "device_scale_factor", "locale", "timezone_id",
46
+ "color_scheme", "geolocation", "screenshot_on_success", "record_video", "trace_on_success",
46
47
  "continue_from_current_page", "reuse_page", "return_snapshot", "screenshot_on_failure", "snapshot_include_hidden", "snapshot_scope", "step_timeout_seconds", "stop_on_expected",
47
48
  "trace_on_failure",
48
49
  ]);
@@ -188,12 +189,14 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
188
189
  if (!isRecord(rawStep) || typeof rawStep.action !== "string") throw new JevBrowserError("each browser step must be an object with an action");
189
190
  const { action } = rawStep;
190
191
  const targetSelection = ["frame", "comment", "within", "index", "near", ...TARGET_LOCATOR_FIELDS];
191
- const semanticTarget = [...targetSelection, "role", "name"];
192
+ const semanticTarget = [...targetSelection, "role", "name", "name_match", "target_page"];
192
193
  const locatorFields = TARGET_LOCATOR_FIELDS.filter((field) => field !== "text"
193
194
  || !["type", "wait_for_text", "wait_for_condition", "upload_file"].includes(action));
194
- const schemas = {
195
- click: { required: [], optional: [...semanticTarget, "expect_download", "expect_popup", "timeout_seconds"] },
196
- type: { required: ["text"], optional: [...semanticTarget, "blur", "mode", "sensitive", "timeout_seconds"] },
195
+ const schemas = {
196
+ click: { required: [], optional: [...semanticTarget, "expect_download", "expect_popup", "timeout_seconds"] },
197
+ type: { required: [], optional: [...semanticTarget, "text", "text_env", "blur", "mode", "sensitive", "timeout_seconds"] },
198
+ clear: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
199
+ navigate: { required: ["url"], optional: ["timeout_seconds", "target_page"] },
197
200
  confirm_dialog: { required: ["expected_text", "button"], optional: ["timeout_seconds"] },
198
201
  check: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
199
202
  uncheck: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
@@ -201,8 +204,8 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
201
204
  assert_network: { required: ["url_contains", "method", "status"], optional: ["message_contains", "timeout_seconds"] },
202
205
  extract: { required: ["as"], optional: [...semanticTarget, "source", "attribute", "sensitive", "timeout_seconds"] },
203
206
  wait: { required: ["ms"], optional: [] },
204
- wait_for_text: { required: ["text"], optional: ["frame", "comment", "fail_on", "timeout_seconds"] },
205
- wait_for_condition: { required: ["condition"], optional: [...semanticTarget, "url_contains", "timeout_seconds"] },
207
+ wait_for_text: { required: ["text"], optional: ["frame", "comment", "fail_on", "target_page", "timeout_seconds"] },
208
+ wait_for_condition: { required: ["condition"], optional: [...semanticTarget, "url_contains", "timeout_seconds"] },
206
209
  wait_for_value: { required: ["value"], optional: [...semanticTarget, "timeout_seconds"] },
207
210
  wait_for_enabled: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
208
211
  press: { required: ["key"], optional: [...semanticTarget, "timeout_seconds"] },
@@ -214,12 +217,12 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
214
217
  assert_visible: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
215
218
  assert_hidden: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
216
219
  upload_file: { required: ["file_paths"], optional: ["target", ...semanticTarget, "timeout_seconds"] },
217
- audit_accessibility: { required: ["standard"], optional: ["comment", "timeout_seconds"] },
220
+ audit_accessibility: { required: ["standard"], optional: ["comment", "scope", "fail_on_impact", "timeout_seconds", "target_page"] },
218
221
  like_comment: { required: ["author", "text", "like_label", "liked_label"], optional: ["frame", "comment", "timeout_seconds"] },
219
222
  unlike_comment: { required: ["author", "text", "like_label", "liked_label"], optional: ["frame", "comment", "timeout_seconds"] },
220
223
  };
221
224
  if (!Object.hasOwn(schemas, action)) throw new JevBrowserError(`unsupported browser action ${JSON.stringify(action)}; supported actions: ${Object.keys(schemas).join(", ")}`);
222
- exactKeys(rawStep, ["action", ...schemas[action].required], [...schemas[action].optional, "mutating", "screenshot"], action);
225
+ exactKeys(rawStep, ["action", ...schemas[action].required], [...schemas[action].optional, "mutating", "screenshot"], action);
223
226
 
224
227
  const step = { action };
225
228
  for (const field of ["mutating", "screenshot"]) {
@@ -255,7 +258,7 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
255
258
  }
256
259
  step.role = rawStep.role;
257
260
  }
258
- if (rawStep.name !== undefined) {
261
+ if (rawStep.name !== undefined) {
259
262
  if (typeof rawStep.name !== "string") throw new JevBrowserError("name must be text");
260
263
  if (rawStep.name.trim()) step.name = requiredText(rawStep.name, "accessible name", settings.jev.maxActionDescriptionChars);
261
264
  else if (rawStep.name === "" && Number.isSafeInteger(rawStep.index) && rawStep.index >= 0) {
@@ -264,8 +267,27 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
264
267
  } else {
265
268
  throw new JevBrowserError("name may be empty only when a non-negative index is provided");
266
269
  }
267
- if (!step.role) throw new JevBrowserError("name requires role; use a locator field such as label, title, placeholder, test_id, text, or selector");
268
- }
270
+ if (!step.role) throw new JevBrowserError("name requires role; use a locator field such as label, title, placeholder, test_id, text, or selector");
271
+ }
272
+ if (rawStep.name_match !== undefined) {
273
+ if (!step.name) throw new JevBrowserError("name_match requires a non-empty name");
274
+ if (!new Set(["exact", "contains", "regex"]).has(rawStep.name_match)) {
275
+ throw new JevBrowserError("name_match must be exact, contains, or regex");
276
+ }
277
+ if (rawStep.name_match === "regex") {
278
+ if (step.name.length > 128 || /\([^)]*[+*][^)]*\)[+*{]|\\[1-9]|\(\?[=!<]/.test(step.name)) {
279
+ throw new JevBrowserError("name_match regex is too long or uses an unsafe construct");
280
+ }
281
+ try { new RegExp(step.name, "u"); } catch { throw new JevBrowserError("name_match regex is invalid"); }
282
+ }
283
+ step.nameMatch = rawStep.name_match;
284
+ }
285
+ if (rawStep.target_page !== undefined) {
286
+ if (!new Set(["current", "popup"]).has(rawStep.target_page)) {
287
+ throw new JevBrowserError("target_page must be current or popup");
288
+ }
289
+ step.targetPage = rawStep.target_page;
290
+ }
269
291
  if (action === "select_option" && !step.role) step.role = "combobox";
270
292
  if (TARGET_SELECTION_ACTIONS.has(action) && action !== "press"
271
293
  && !step.role && step.name === undefined && selectorFields.length === 0 && !hasNear) {
@@ -354,7 +376,7 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
354
376
  if (!step.role) step.role = "checkbox";
355
377
  if (action === "uncheck" && step.role === "radio") throw new JevBrowserError("uncheck does not support radio controls");
356
378
  }
357
- if (action === "navigate_menu") {
379
+ if (action === "navigate_menu") {
358
380
  if (!Array.isArray(rawStep.path) || rawStep.path.length === 0 || rawStep.path.length > 8) {
359
381
  throw new JevBrowserError("navigate_menu path must contain between 1 and 8 menu labels");
360
382
  }
@@ -362,10 +384,19 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
362
384
  step.toggleSelector = rawStep.toggle_selector === undefined
363
385
  ? "#open_btn"
364
386
  : requiredText(rawStep.toggle_selector, "toggle_selector", settings.jev.maxActionDescriptionChars);
365
- }
366
- if (action === "type") {
367
- if (typeof rawStep.text !== "string" || rawStep.text.length === 0) throw new JevBrowserError("type step needs non-empty text");
368
- if (rawStep.text.length > settings.browser.maxTextEntryChars) throw new JevBrowserError("type text exceeds the configured character limit");
387
+ }
388
+ if (action === "navigate") {
389
+ step.url = requiredText(rawStep.url, "navigation URL", settings.jev.maxActionDescriptionChars);
390
+ }
391
+ if (action === "type") {
392
+ const hasText = typeof rawStep.text === "string";
393
+ const hasTextEnv = rawStep.text_env !== undefined;
394
+ if (hasText === hasTextEnv) throw new JevBrowserError("type step needs exactly one of text or text_env");
395
+ if (hasText && rawStep.text.length === 0) throw new JevBrowserError("type text must not be empty; use action clear to empty a field");
396
+ if (hasText && rawStep.text.length > settings.browser.maxTextEntryChars) throw new JevBrowserError("type text exceeds the configured character limit");
397
+ if (hasTextEnv && (typeof rawStep.text_env !== "string" || !/^[A-Z][A-Z0-9_]*$/.test(rawStep.text_env))) {
398
+ throw new JevBrowserError("type text_env must name an environment variable such as APP_PASSWORD");
399
+ }
369
400
  if (rawStep.mode !== undefined && !new Set(["fill", "keys"]).has(rawStep.mode)) {
370
401
  throw new JevBrowserError("type mode must be fill or keys");
371
402
  }
@@ -374,11 +405,16 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
374
405
  if (step.typedTargetText !== undefined && (!step.role || !TEXT_ROLES.has(step.role))) {
375
406
  throw new JevBrowserError("type target_text needs role textbox, searchbox, or combobox");
376
407
  }
377
- step.text = rawStep.text;
378
- step.mode = rawStep.mode || "fill";
379
- step.blur = rawStep.blur ?? false;
380
- step.sensitive = rawStep.sensitive ?? true;
381
- }
408
+ if (hasText) step.text = rawStep.text;
409
+ if (hasTextEnv) step.textEnv = rawStep.text_env;
410
+ step.mode = rawStep.mode || "fill";
411
+ step.blur = rawStep.blur ?? false;
412
+ step.sensitive = rawStep.sensitive ?? true;
413
+ if (hasTextEnv && rawStep.sensitive === false) throw new JevBrowserError("type text_env is always sensitive");
414
+ }
415
+ if (action === "clear" && step.role && !TEXT_ROLES.has(step.role)) {
416
+ throw new JevBrowserError("clear requires role textbox, searchbox, or combobox");
417
+ }
382
418
  if (action === "wait_for_text") {
383
419
  step.text = requiredText(rawStep.text, "wait text", settings.jev.maxActionDescriptionChars);
384
420
  if (rawStep.fail_on !== undefined) {
@@ -435,9 +471,9 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
435
471
  }
436
472
  step.sensitive = rawStep.sensitive ?? true;
437
473
  }
438
- if (action === "wait_for_condition") {
439
- if (!new Set(["network_idle", "hidden", "text_hidden", "angular_idle"]).has(rawStep.condition)) {
440
- throw new JevBrowserError("wait_for_condition condition must be network_idle, hidden, text_hidden, or angular_idle");
474
+ if (action === "wait_for_condition") {
475
+ if (!new Set(["network_idle", "url", "hidden", "text_hidden", "angular_idle"]).has(rawStep.condition)) {
476
+ throw new JevBrowserError("wait_for_condition condition must be network_idle, url, hidden, text_hidden, or angular_idle");
441
477
  }
442
478
  step.condition = rawStep.condition;
443
479
  if (rawStep.condition === "hidden") {
@@ -456,26 +492,40 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
456
492
  }
457
493
  step.text = requiredText(rawStep.text, "hidden text", settings.jev.maxActionDescriptionChars);
458
494
  step.targetText = step.text;
459
- } else if (rawStep.condition === "angular_idle") {
495
+ } else if (rawStep.condition === "url") {
496
+ if (step.role || step.name !== undefined || rawStep.text !== undefined || hasLocatorField(step)
497
+ || step.near || step.within || rawStep.index !== undefined) {
498
+ throw new JevBrowserError("url condition accepts only url_contains and timeout_seconds");
499
+ }
500
+ step.urlContains = requiredText(rawStep.url_contains, "url_contains", settings.jev.maxActionDescriptionChars);
501
+ } else if (rawStep.condition === "angular_idle") {
460
502
  if (rawStep.url_contains !== undefined || step.role || step.name !== undefined || rawStep.text !== undefined
461
503
  || hasLocatorField(step) || step.near || step.within || rawStep.index !== undefined) {
462
504
  throw new JevBrowserError("angular_idle condition accepts only condition and timeout_seconds");
463
505
  }
464
- } else if (step.role || step.name !== undefined || rawStep.text !== undefined || hasLocatorField(step)
465
- || step.near || step.within || rawStep.index !== undefined) {
466
- throw new JevBrowserError("network_idle condition accepts only url_contains as an optional filter");
467
- }
506
+ } else if (rawStep.condition === "network_idle" && (step.role || step.name !== undefined || rawStep.text !== undefined || hasLocatorField(step)
507
+ || step.near || step.within || rawStep.index !== undefined)) {
508
+ throw new JevBrowserError("network_idle condition accepts only url_contains as an optional filter");
509
+ }
468
510
  if (rawStep.condition === "network_idle" && rawStep.url_contains !== undefined) {
469
511
  step.urlContains = requiredText(rawStep.url_contains, "url_contains", settings.jev.maxActionDescriptionChars);
470
- } else if (rawStep.url_contains !== undefined) {
471
- throw new JevBrowserError("url_contains is only supported by the network_idle condition");
512
+ } else if (rawStep.condition !== "url" && rawStep.url_contains !== undefined) {
513
+ throw new JevBrowserError("url_contains is only supported by the network_idle and url conditions");
472
514
  }
473
515
  }
474
- if (action === "audit_accessibility") {
516
+ if (action === "audit_accessibility") {
475
517
  if (typeof rawStep.standard !== "string" || !Object.hasOwn(ACCESSIBILITY_STANDARDS, rawStep.standard)) {
476
518
  throw new JevBrowserError("audit_accessibility standard must be wcag2aa");
477
519
  }
478
- step.standard = rawStep.standard;
520
+ step.standard = rawStep.standard;
521
+ if (rawStep.scope !== undefined) {
522
+ step.scope = requiredText(rawStep.scope, "accessibility scope", settings.jev.maxActionDescriptionChars);
523
+ if (/^xpath(?:=|:)/i.test(step.scope)) throw new JevBrowserError("audit_accessibility scope must be a CSS selector");
524
+ }
525
+ step.failOnImpact = rawStep.fail_on_impact ?? settings.browser.accessibilityFailOnImpactDefault;
526
+ if (!new Set(["minor", "moderate", "serious", "critical"]).has(step.failOnImpact)) {
527
+ throw new JevBrowserError("audit_accessibility fail_on_impact must be minor, moderate, serious, or critical");
528
+ }
479
529
  }
480
530
  if (action === "press") {
481
531
  if (typeof rawStep.key !== "string" || !APPROVED_KEYS.has(rawStep.key)) {
@@ -554,7 +604,7 @@ function normalizeStepAliases(rawStep) {
554
604
  return step;
555
605
  }
556
606
 
557
- function resolveFlowOptions(rawOptions, settings) {
607
+ function resolveFlowOptions(rawOptions, settings) {
558
608
  if (rawOptions === undefined) rawOptions = {};
559
609
  if (!isRecord(rawOptions) || Object.keys(rawOptions).some((key) => !FLOW_OPTION_KEYS.has(key))) {
560
610
  throw new JevBrowserError("options contains unsupported flow settings");
@@ -563,10 +613,11 @@ function resolveFlowOptions(rawOptions, settings) {
563
613
  && rawOptions.continue_from_current_page !== rawOptions.reuse_page) {
564
614
  throw new JevBrowserError("options.continue_from_current_page and options.reuse_page cannot disagree");
565
615
  }
566
- const booleanOptions = [
567
- "block_trackers", "capture_console_errors", "capture_network_error_bodies", "capture_network_errors",
568
- "auto_angular_idle", "block_fonts", "continue_from_current_page", "dry_run", "fast_path", "local_only",
569
- "ready_network_idle", "reuse_page", "screenshot_on_failure", "snapshot_include_hidden", "stop_on_expected", "trace_on_failure",
616
+ const booleanOptions = [
617
+ "allow_mutations", "block_trackers", "capture_console_errors", "capture_network_error_bodies", "capture_network_errors",
618
+ "auto_angular_idle", "block_fonts", "continue_from_current_page", "dry_run", "fast_path", "local_only",
619
+ "ready_network_idle", "reuse_page", "screenshot_on_failure", "screenshot_on_success", "record_video", "mobile",
620
+ "snapshot_include_hidden", "stop_on_expected", "trace_on_failure", "trace_on_success",
570
621
  ];
571
622
  for (const key of booleanOptions) {
572
623
  if (rawOptions[key] !== undefined && typeof rawOptions[key] !== "boolean") {
@@ -592,9 +643,60 @@ function resolveFlowOptions(rawOptions, settings) {
592
643
  if (!Array.isArray(raw) || raw.length > maxItems) throw new JevBrowserError(`options.${key} must be an array with at most ${maxItems} entries`);
593
644
  return raw.map((value) => requiredText(value, key, settings.jev.maxActionDescriptionChars));
594
645
  };
595
- const busySelectors = normalizeListOption("busy_selectors", settings.browser.busySelectorsDefault);
646
+ const busySelectors = normalizeListOption("busy_selectors", settings.browser.busySelectorsDefault);
596
647
  const loginUrlContains = normalizeListOption("login_url_contains", settings.browser.loginUrlContainsDefault);
597
- const loginText = normalizeListOption("login_text", settings.browser.loginTextDefault);
648
+ const loginText = normalizeListOption("login_text", settings.browser.loginTextDefault);
649
+ const rawPermissions = rawOptions.permissions;
650
+ if (rawPermissions !== undefined && !Array.isArray(rawPermissions)) {
651
+ throw new JevBrowserError("options.permissions must contain unique supported values: microphone, geolocation");
652
+ }
653
+ const permissions = rawPermissions === undefined ? [] : [...rawPermissions];
654
+ if (permissions.length > 2
655
+ || permissions.some((permission) => !new Set(["microphone", "geolocation"]).has(permission))
656
+ || new Set(permissions).size !== permissions.length) {
657
+ throw new JevBrowserError("options.permissions must contain unique supported values: microphone, geolocation");
658
+ }
659
+ const viewport = rawOptions.viewport;
660
+ if (viewport !== undefined) {
661
+ if (!isRecord(viewport)) throw new JevBrowserError("options.viewport must contain width and height");
662
+ exactKeys(viewport, ["width", "height"], [], "options.viewport");
663
+ if (!Number.isSafeInteger(viewport.width) || viewport.width < 320 || viewport.width > settings.browser.maxViewportWidth
664
+ || !Number.isSafeInteger(viewport.height) || viewport.height < 240 || viewport.height > settings.browser.maxViewportHeight) {
665
+ throw new JevBrowserError(`options.viewport dimensions must be within 320x240 and ${settings.browser.maxViewportWidth}x${settings.browser.maxViewportHeight}`);
666
+ }
667
+ }
668
+ if (rawOptions.mobile === true && !viewport) throw new JevBrowserError("options.viewport is required when options.mobile is true");
669
+ const deviceScaleFactor = rawOptions.device_scale_factor;
670
+ if (deviceScaleFactor !== undefined && (typeof deviceScaleFactor !== "number" || !Number.isFinite(deviceScaleFactor)
671
+ || deviceScaleFactor < 0.5 || deviceScaleFactor > 3)) {
672
+ throw new JevBrowserError("options.device_scale_factor must be between 0.5 and 3");
673
+ }
674
+ const locale = rawOptions.locale === undefined ? undefined : requiredText(rawOptions.locale, "locale", 64);
675
+ if (locale !== undefined) {
676
+ try { new Intl.Locale(locale); } catch { throw new JevBrowserError("options.locale must be a valid BCP 47 locale"); }
677
+ }
678
+ const timezoneId = rawOptions.timezone_id === undefined ? undefined : requiredText(rawOptions.timezone_id, "timezone_id", 64);
679
+ if (timezoneId !== undefined) {
680
+ try { new Intl.DateTimeFormat("en", { timeZone: timezoneId }); } catch { throw new JevBrowserError("options.timezone_id must be a valid IANA time zone"); }
681
+ }
682
+ const colorScheme = rawOptions.color_scheme;
683
+ if (colorScheme !== undefined && !new Set(["light", "dark", "no-preference"]).has(colorScheme)) {
684
+ throw new JevBrowserError("options.color_scheme must be light, dark, or no-preference");
685
+ }
686
+ const geolocation = rawOptions.geolocation;
687
+ if (geolocation !== undefined) {
688
+ if (!isRecord(geolocation)) throw new JevBrowserError("options.geolocation must contain latitude and longitude");
689
+ exactKeys(geolocation, ["latitude", "longitude"], ["accuracy"], "options.geolocation");
690
+ if (![geolocation.latitude, geolocation.longitude].every((value) => typeof value === "number" && Number.isFinite(value))
691
+ || geolocation.latitude < -90 || geolocation.latitude > 90 || geolocation.longitude < -180 || geolocation.longitude > 180
692
+ || (geolocation.accuracy !== undefined && (typeof geolocation.accuracy !== "number" || !Number.isFinite(geolocation.accuracy)
693
+ || geolocation.accuracy < 0 || geolocation.accuracy > 10000))) {
694
+ throw new JevBrowserError("options.geolocation coordinates or accuracy are outside valid ranges");
695
+ }
696
+ if (!permissions.includes("geolocation")) permissions.push("geolocation");
697
+ }
698
+ const fakeMedia = rawOptions.fake_media === undefined ? undefined : requiredText(rawOptions.fake_media, "fake_media", settings.browser.maxUploadPathChars);
699
+ if (fakeMedia && !permissions.includes("microphone")) permissions.push("microphone");
598
700
  let confirmationToken;
599
701
  if (rawOptions.confirmation_token !== undefined) {
600
702
  confirmationToken = requiredText(rawOptions.confirmation_token, "confirmation_token", 128);
@@ -604,7 +706,7 @@ function resolveFlowOptions(rawOptions, settings) {
604
706
  || readyTimeoutSeconds > settings.browser.maxReadyTimeoutSeconds) {
605
707
  throw new JevBrowserError(`options.ready_timeout_seconds must be positive and at most ${settings.browser.maxReadyTimeoutSeconds}`);
606
708
  }
607
- const readyStableMs = rawOptions.ready_stable_ms ?? defaults.readyStableMs;
709
+ const readyStableMs = rawOptions.ready_stable_ms ?? defaults.readyStableMs;
608
710
  if (!Number.isSafeInteger(readyStableMs) || readyStableMs <= 0 || readyStableMs > settings.browser.maxReadyStableMs
609
711
  || readyStableMs > readyTimeoutSeconds * 1000) {
610
712
  throw new JevBrowserError("options.ready_stable_ms must be a positive integer within the configured readiness limits");
@@ -621,7 +723,8 @@ function resolveFlowOptions(rawOptions, settings) {
621
723
  if (!new Set([".md", ".xml"]).has(extension)) throw new JevBrowserError("options.report_path must end in .md or .xml (JUnit)");
622
724
  reportPath = rawOptions.report_path;
623
725
  }
624
- return Object.freeze({
726
+ return Object.freeze({
727
+ allowMutations: rawOptions.allow_mutations ?? false,
625
728
  autoAngularIdle: rawOptions.auto_angular_idle ?? defaults.autoAngularIdle,
626
729
  blockTrackers: rawOptions.block_trackers ?? defaults.blockTrackers,
627
730
  blockFonts: rawOptions.block_fonts ?? defaults.blockFonts,
@@ -633,7 +736,19 @@ function resolveFlowOptions(rawOptions, settings) {
633
736
  fastPath: rawOptions.fast_path ?? defaults.fastPath,
634
737
  localOnly: rawOptions.local_only ?? defaults.localOnly,
635
738
  loginText: Object.freeze(loginText),
636
- loginUrlContains: Object.freeze(loginUrlContains),
739
+ loginUrlContains: Object.freeze(loginUrlContains),
740
+ permissions: Object.freeze([...permissions]),
741
+ fakeMedia,
742
+ viewport: viewport ? Object.freeze({ width: viewport.width, height: viewport.height }) : undefined,
743
+ mobile: rawOptions.mobile ?? false,
744
+ deviceScaleFactor,
745
+ locale,
746
+ timezoneId,
747
+ colorScheme,
748
+ geolocation: geolocation ? Object.freeze({ ...geolocation }) : undefined,
749
+ screenshotOnSuccess: rawOptions.screenshot_on_success ?? false,
750
+ recordVideo: rawOptions.record_video ?? false,
751
+ traceOnSuccess: rawOptions.trace_on_success ?? settings.browser.defaults.traceOnSuccess,
637
752
  maxFlowSteps,
638
753
  readyTimeoutMs: Math.ceil(readyTimeoutSeconds * 1000),
639
754
  readyNetworkIdle: rawOptions.ready_network_idle ?? defaults.readyNetworkIdle,
@@ -676,12 +791,12 @@ function planSecrets(plans) {
676
791
  return [...secrets].filter(Boolean).sort((left, right) => right.length - left.length);
677
792
  }
678
793
 
679
- function redact(value, secrets) {
794
+ function redact(value, secrets) {
680
795
  let result = value;
681
796
  for (const secret of secrets) {
682
797
  const pattern = secret.length < 4
683
- ? new RegExp(`(?<![\\p{L}\\p{N}_])${escapeRegex(secret)}(?![\\p{L}\\p{N}_])`, "giu")
684
- : new RegExp(escapeRegex(secret), "giu");
798
+ ? new RegExp(`(?<![\\p{L}\\p{N}_])${escapeRegex(secret)}(?![\\p{L}\\p{N}_])`, "gu")
799
+ : new RegExp(escapeRegex(secret), "gu");
685
800
  result = result.replace(pattern, "[provided form text]");
686
801
  }
687
802
  return result;
@@ -748,11 +863,110 @@ function planContentSecrets(plans) {
748
863
  .filter(Boolean).sort((left, right) => right.length - left.length);
749
864
  }
750
865
 
751
- function redactSnapshotFieldValues(snapshot, secrets) {
752
- return snapshot.split(/(\r?\n)/).map((line) => /^\s*-\s*(?:textbox|searchbox|combobox)\b/i.test(line)
753
- ? redact(line, secrets)
754
- : line).join("");
755
- }
866
+ function redactSnapshotFieldValues(snapshot, secrets) {
867
+ return snapshot.split(/(\r?\n)/).map((line) => /^\s*-\s*(?:textbox|searchbox|combobox)\b/i.test(line)
868
+ ? redact(line, secrets)
869
+ : line).join("");
870
+ }
871
+
872
+ function providerSafeSnapshot(snapshot, secrets) {
873
+ return redactSnapshotFieldValues(redact(snapshot, secrets), secrets)
874
+ .split(/(\r?\n)/).map((line) => {
875
+ if (/^\s*-\s*(?:textbox|searchbox|combobox)\b/i.test(line)) {
876
+ return line.replace(/(:\s*).+$/, "$1[form value omitted]");
877
+ }
878
+ if (/^\s*-\s*role\b/i.test(line) || /^\s*-\s*\[form controls:/i.test(line)) {
879
+ return line.replace(/\bvalue=(?:"[^"]*"|'[^']*'|\S+)/gi, 'value="[form value omitted]"');
880
+ }
881
+ return line;
882
+ }).join("")
883
+ .replace(/https?:\/\/[^\s"'<>]+/gi, (value) => {
884
+ try {
885
+ const url = new URL(value);
886
+ return `${url.origin}${url.pathname}`;
887
+ } catch {
888
+ return value.split(/[?#]/, 1)[0];
889
+ }
890
+ });
891
+ }
892
+
893
+ function localHostOnly(url) {
894
+ return new Set(["localhost", "127.0.0.1", "::1", "[::1]"]).has(new URL(url).hostname.toLowerCase());
895
+ }
896
+
897
+ async function resolveFakeMediaPath(fakeMedia, settings) {
898
+ if (!fakeMedia) return undefined;
899
+ if (!settings.browser.uploadRoot) {
900
+ throw new JevBrowserError("fake_media is disabled; configure JEV_BROWSER_UPLOAD_ROOT and keep the WAV file inside it");
901
+ }
902
+ let root;
903
+ let audio;
904
+ try {
905
+ root = await realpath(settings.browser.uploadRoot);
906
+ const requested = path.isAbsolute(fakeMedia) ? fakeMedia : path.resolve(root, fakeMedia);
907
+ audio = await realpath(requested);
908
+ } catch {
909
+ throw new JevBrowserError("fake_media could not read the configured WAV file");
910
+ }
911
+ const relative = path.relative(root, audio);
912
+ if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
913
+ throw new JevBrowserError("fake_media path must stay inside JEV_BROWSER_UPLOAD_ROOT");
914
+ }
915
+ if (path.extname(audio).toLowerCase() !== ".wav") throw new JevBrowserError("fake_media accepts WAV files only");
916
+ const file = await stat(audio);
917
+ if (!file.isFile() || file.size > settings.browser.maxUploadFileBytes) {
918
+ throw new JevBrowserError("fake_media must be a regular WAV file within the configured file-size limit");
919
+ }
920
+ const header = await readFile(audio, { encoding: null, flag: "r" });
921
+ if (header.length < 12 || header.toString("ascii", 0, 4) !== "RIFF" || header.toString("ascii", 8, 12) !== "WAVE") {
922
+ throw new JevBrowserError("fake_media file does not have a valid RIFF/WAVE header");
923
+ }
924
+ return audio;
925
+ }
926
+
927
+ async function browserSessionConfiguration(options, settings) {
928
+ const audioPath = await resolveFakeMediaPath(options.fakeMedia, settings);
929
+ const contextOptions = {};
930
+ if (options.viewport) contextOptions.viewport = options.viewport;
931
+ if (options.mobile) contextOptions.isMobile = true;
932
+ if (options.mobile) contextOptions.hasTouch = true;
933
+ if (options.deviceScaleFactor !== undefined) contextOptions.deviceScaleFactor = options.deviceScaleFactor;
934
+ if (options.locale) contextOptions.locale = options.locale;
935
+ if (options.timezoneId) contextOptions.timezoneId = options.timezoneId;
936
+ if (options.colorScheme) contextOptions.colorScheme = options.colorScheme;
937
+ if (options.geolocation) contextOptions.geolocation = options.geolocation;
938
+ if (options.permissions.length) contextOptions.permissions = options.permissions;
939
+ if (options.recordVideo) {
940
+ contextOptions.recordVideo = {
941
+ dir: settings.browser.artifactDirectory,
942
+ size: options.viewport || settings.browser.recordVideoSize,
943
+ };
944
+ }
945
+ const launchArgs = audioPath ? [
946
+ "--use-fake-device-for-media-stream",
947
+ "--use-fake-ui-for-media-stream",
948
+ `--use-file-for-fake-audio-capture=${audioPath}`,
949
+ ] : [];
950
+ return { contextOptions, launchArgs };
951
+ }
952
+
953
+ function resolveEnvironmentText(plans, settings) {
954
+ for (const plan of Object.values(plans)) {
955
+ for (const step of plan.steps) {
956
+ if (step.action !== "type" || !step.textEnv) continue;
957
+ const value = settings.env?.[step.textEnv];
958
+ if (typeof value !== "string" || value.length === 0) {
959
+ throw new JevBrowserError(`type text_env references missing or empty environment variable ${step.textEnv}`);
960
+ }
961
+ if (value.length > settings.browser.maxTextEntryChars) {
962
+ throw new JevBrowserError(`environment value ${step.textEnv} exceeds the configured character limit`);
963
+ }
964
+ step.text = value;
965
+ step.sensitive = true;
966
+ delete step.textEnv;
967
+ }
968
+ }
969
+ }
756
970
 
757
971
  async function restoreHiddenDialogs(hiddenDialogs) {
758
972
  for (const { dialog, previous } of hiddenDialogs.reverse()) {
@@ -1018,70 +1232,86 @@ function containsExpectedOutcome(snapshot, phrase) {
1018
1232
  return containsPhrase(expectedOutcomeText(snapshot), phrase);
1019
1233
  }
1020
1234
 
1021
- function createNetworkTracker(page, settings, loginUrlContains = []) {
1022
- const active = new Map();
1023
- const history = [];
1024
- let sequence = 0;
1025
- const record = (type, request) => {
1026
- const entry = { sequence: ++sequence, type, url: request.url(), method: request.method(), at: performance.now(), request };
1235
+ function createNetworkTracker(page, settings, loginUrlContains = []) {
1236
+ const active = new Map();
1237
+ const history = [];
1238
+ const ignored = new Set();
1239
+ let sequence = 0;
1240
+ const shouldIgnore = (request) => {
1241
+ const type = request.resourceType?.();
1242
+ return type === "websocket" || type === "eventsource" || /^wss?:/i.test(request.url());
1243
+ };
1244
+ const record = (type, request) => {
1245
+ if (shouldIgnore(request)) {
1246
+ if (type === "request") ignored.add(request);
1247
+ if (type === "finished" || type === "failed") ignored.delete(request);
1248
+ return undefined;
1249
+ }
1250
+ if (ignored.has(request)) return undefined;
1251
+ const entry = { sequence: ++sequence, type, url: request.url(), method: request.method(), at: performance.now(), request };
1027
1252
  if (type === "request") active.set(request, entry);
1028
1253
  else if (type === "finished" || type === "failed") active.delete(request);
1029
1254
  history.push(entry);
1030
- const historyLimit = settings.browser.maxFlowSteps * 8;
1255
+ const historyLimit = settings.browser.maxNetworkHistoryEvents;
1031
1256
  if (history.length > historyLimit) history.splice(0, history.length - historyLimit);
1032
1257
  return entry;
1033
1258
  };
1034
1259
  const onRequest = (request) => record("request", request);
1035
- const onResponse = (response) => {
1036
- const entry = record("response", response.request());
1260
+ const onResponse = (response) => {
1261
+ const entry = record("response", response.request());
1262
+ if (!entry) return;
1037
1263
  entry.response = response;
1038
1264
  entry.status = response.status();
1039
- };
1040
- const onFinished = (request) => record("finished", request);
1041
- const onFailed = (request) => record("failed", request);
1042
- page.on("request", onRequest);
1043
- page.on("response", onResponse);
1044
- page.on("requestfinished", onFinished);
1045
- page.on("requestfailed", onFailed);
1046
- return {
1047
- mark: () => sequence,
1048
- since: (marker, urlContains) => history.filter((entry) => entry.sequence > marker && entry.url.includes(urlContains)),
1265
+ };
1266
+ const onFinished = (request) => record("finished", request);
1267
+ const onFailed = (request) => record("failed", request);
1268
+ const trackedPages = new Set();
1269
+ const attachPage = (trackedPage) => {
1270
+ if (trackedPages.has(trackedPage)) return;
1271
+ trackedPages.add(trackedPage);
1272
+ trackedPage.on("request", onRequest);
1273
+ trackedPage.on("response", onResponse);
1274
+ trackedPage.on("requestfinished", onFinished);
1275
+ trackedPage.on("requestfailed", onFailed);
1276
+ };
1277
+ attachPage(page);
1278
+ return {
1279
+ mark: () => sequence,
1280
+ attachPage,
1281
+ since: (marker, urlContains) => history.filter((entry) => entry.sequence > marker
1282
+ && (urlContains === undefined || entry.url.includes(urlContains))),
1049
1283
  responsesSince: (marker, urlContains, method) => history.filter((entry) => entry.sequence > marker
1050
1284
  && entry.type === "response" && entry.url.includes(urlContains) && entry.method === method),
1051
1285
  authenticationFailureSince: (marker) => history.find((entry) => {
1052
1286
  if (entry.sequence <= marker || entry.type !== "response" || ![401, 403].includes(entry.status)) return false;
1053
1287
  return loginUrlContains.some((needle) => entry.url.toLocaleLowerCase().includes(needle.toLocaleLowerCase()));
1054
1288
  }),
1055
- activeFor: (urlContains) => [...active.values()].filter((entry) => entry.url.includes(urlContains)),
1056
- dispose: () => {
1057
- page.off("request", onRequest);
1058
- page.off("response", onResponse);
1059
- page.off("requestfinished", onFinished);
1060
- page.off("requestfailed", onFailed);
1061
- active.clear();
1062
- history.length = 0;
1289
+ activeFor: (urlContains) => [...active.values()].filter((entry) => entry.url.includes(urlContains)),
1290
+ dispose: () => {
1291
+ for (const trackedPage of trackedPages) {
1292
+ trackedPage.off("request", onRequest);
1293
+ trackedPage.off("response", onResponse);
1294
+ trackedPage.off("requestfinished", onFinished);
1295
+ trackedPage.off("requestfailed", onFailed);
1296
+ }
1297
+ trackedPages.clear();
1298
+ active.clear();
1299
+ ignored.clear();
1300
+ history.length = 0;
1063
1301
  },
1064
1302
  };
1065
1303
  }
1066
1304
 
1067
- async function waitForFilteredNetworkIdle(tracker, urlContains, marker, timeout, settings) {
1068
- const started = performance.now();
1069
- let sawRequest = false;
1070
- let lastMatchingActivity = started;
1071
- const quietMs = settings.browser.defaults.readyStableMs;
1072
- while (performance.now() - started < timeout) {
1073
- const events = tracker.since(marker, urlContains);
1074
- if (events.length) {
1075
- sawRequest = sawRequest || events.some(({ type }) => type === "request");
1076
- lastMatchingActivity = events.at(-1).at;
1077
- }
1078
- const active = tracker.activeFor(urlContains);
1079
- if (active.length) sawRequest = true;
1080
- if (sawRequest && active.length === 0 && performance.now() - lastMatchingActivity >= quietMs) return;
1081
- await new Promise((resolve) => setTimeout(resolve, Math.min(settings.browser.visibilityPollMs, remainingMs(started + timeout))));
1082
- }
1083
- if (!sawRequest) throw new JevBrowserError(`network_idle did not observe a request matching url_contains ${JSON.stringify(urlContains)} within ${timeout}ms`);
1084
- throw new JevBrowserError(`requests matching url_contains ${JSON.stringify(urlContains)} did not become idle within ${timeout}ms`);
1305
+ async function waitForFilteredNetworkIdle(tracker, urlContains, marker, timeout, quietMs, settings) {
1306
+ const started = performance.now();
1307
+ let lastMatchingActivity = started;
1308
+ while (performance.now() - started < timeout) {
1309
+ const events = tracker.since(marker, urlContains);
1310
+ if (events.length) lastMatchingActivity = Math.max(lastMatchingActivity, events.at(-1).at);
1311
+ if (performance.now() - lastMatchingActivity >= quietMs) return;
1312
+ await new Promise((resolve) => setTimeout(resolve, Math.min(settings.browser.visibilityPollMs, remainingMs(started + timeout))));
1313
+ }
1314
+ throw new JevBrowserError(`network activity${urlContains ? ` matching url_contains ${JSON.stringify(urlContains)}` : ""} did not become idle within ${timeout}ms`);
1085
1315
  }
1086
1316
 
1087
1317
  async function waitForAngularIdle(page, timeout, settings, optional = false) {
@@ -1123,7 +1353,7 @@ async function waitForAngularIdle(page, timeout, settings, optional = false) {
1123
1353
  throw new JevBrowserError(`AngularJS still had pending requests or digest work after ${timeout}ms`);
1124
1354
  }
1125
1355
 
1126
- async function waitForValue(locator, expected, timeout, description, settings) {
1356
+ async function waitForValue(locator, expected, timeout, description, settings) {
1127
1357
  const deadline = performance.now() + timeout;
1128
1358
  while (performance.now() < deadline) {
1129
1359
  try {
@@ -1222,12 +1452,46 @@ function remainingMs(deadline) {
1222
1452
  return Math.max(1, Math.ceil(deadline - performance.now()));
1223
1453
  }
1224
1454
 
1225
- function browserDisconnected(error) {
1455
+ function browserDisconnected(error) {
1226
1456
  return /target (?:page, context or browser )?has been closed|browser (?:has been closed|disconnected)|context has been closed|connection closed/i
1227
1457
  .test(String(error?.message || error?.name || ""));
1228
- }
1229
-
1230
- async function waitForPageReady(page, options, settings, secrets, continued = false) {
1458
+ }
1459
+
1460
+ function snapshotStabilityKey(snapshot) {
1461
+ return snapshot.split(/\r?\n/)
1462
+ .filter((line) => !/^\s*-\s*text:/i.test(line))
1463
+ .map((line) => line.replace(/"(?:\\.|[^"\\])*"/g, '""').replace(/\bvalue=(?:"[^"]*"|'[^']*'|\S+)/gi, 'value=""'))
1464
+ .join("\n");
1465
+ }
1466
+
1467
+ async function pageStructureSignature(page, snapshot) {
1468
+ try {
1469
+ const structure = await page.evaluate(() => {
1470
+ const elements = [];
1471
+ const visit = (root) => {
1472
+ for (const element of root.querySelectorAll("*")) {
1473
+ elements.push([
1474
+ element.localName,
1475
+ element.getAttribute("role") || "",
1476
+ element.id || "",
1477
+ element.getAttribute("type") || "",
1478
+ element.getAttribute("aria-busy") || "",
1479
+ Boolean(element.disabled),
1480
+ ]);
1481
+ if (element.shadowRoot) visit(element.shadowRoot);
1482
+ if (elements.length >= 5000) return;
1483
+ }
1484
+ };
1485
+ visit(document);
1486
+ return elements;
1487
+ });
1488
+ return JSON.stringify(structure);
1489
+ } catch {
1490
+ return snapshotStabilityKey(snapshot);
1491
+ }
1492
+ }
1493
+
1494
+ async function waitForPageReady(page, options, settings, secrets, continued = false) {
1231
1495
  const started = performance.now();
1232
1496
  const deadline = started + options.readyTimeoutMs;
1233
1497
  const bestEffortLoadState = async (state) => {
@@ -1242,7 +1506,7 @@ async function waitForPageReady(page, options, settings, secrets, continued = fa
1242
1506
  if (options.readyNetworkIdle) await bestEffortLoadState("networkidle");
1243
1507
  }
1244
1508
 
1245
- let previousSnapshot = "";
1509
+ let previousStabilityKey = "";
1246
1510
  let stableSince = 0;
1247
1511
  let lastSnapshot = "";
1248
1512
  while (performance.now() < deadline) {
@@ -1251,24 +1515,25 @@ async function waitForPageReady(page, options, settings, secrets, continued = fa
1251
1515
  timeoutMs: remainingMs(deadline),
1252
1516
  includeHidden: options.snapshotIncludeHidden,
1253
1517
  });
1254
- lastSnapshot = snapshot;
1518
+ lastSnapshot = snapshot;
1519
+ const stabilityKey = await pageStructureSignature(page, snapshot);
1255
1520
  const readyTextVisible = !options.readyText || containsPhrase(snapshot, options.readyText);
1256
1521
  const usefulSnapshot = await hasRenderedContent(page, snapshot, options.busySelectors);
1257
1522
  if (continued && snapshot && readyTextVisible && usefulSnapshot) {
1258
1523
  return { snapshot, readyMs: performance.now() - started };
1259
1524
  }
1260
1525
  if (snapshot && readyTextVisible && usefulSnapshot) {
1261
- if (snapshot === previousSnapshot) {
1526
+ if (stabilityKey === previousStabilityKey) {
1262
1527
  if (!stableSince) stableSince = performance.now();
1263
1528
  if (performance.now() - stableSince >= options.readyStableMs) {
1264
1529
  return { snapshot, readyMs: performance.now() - started };
1265
1530
  }
1266
1531
  } else {
1267
- previousSnapshot = snapshot;
1268
- stableSince = performance.now();
1269
- }
1270
- } else {
1271
- previousSnapshot = "";
1532
+ previousStabilityKey = stabilityKey;
1533
+ stableSince = performance.now();
1534
+ }
1535
+ } else {
1536
+ previousStabilityKey = "";
1272
1537
  stableSince = 0;
1273
1538
  }
1274
1539
  await new Promise((resolve) => setTimeout(resolve, Math.min(100, remainingMs(deadline))));
@@ -1282,8 +1547,12 @@ async function waitForPageReady(page, options, settings, secrets, continued = fa
1282
1547
  async function hasRenderedContent(page, snapshot, busySelectors = []) {
1283
1548
  const lines = snapshot.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
1284
1549
  if (!lines.some((line, index) => index > 0 || /:\s+\S|"[^"]+"/.test(line))) return false;
1285
- try {
1286
- if (await page.locator('[aria-busy="true"]').count() > 0) return false;
1550
+ try {
1551
+ const busy = page.locator('[aria-busy="true"]');
1552
+ const count = await busy.count();
1553
+ for (let index = 0; index < count; index += 1) {
1554
+ if (await isActuallyVisible(locatorAt(busy, index))) return false;
1555
+ }
1287
1556
  } catch {
1288
1557
  // The snapshot itself is the fallback when the DOM is transitioning.
1289
1558
  }
@@ -1322,29 +1591,45 @@ async function waitForText(locator, expected, timeout, description, settings) {
1322
1591
  throw new JevBrowserError(`${description} did not contain the expected text within ${timeout}ms`);
1323
1592
  }
1324
1593
 
1325
- async function pageFingerprint(page, snapshot) {
1326
- let controls = [];
1327
- let visibleText = "";
1328
- try {
1329
- ({ controls, visibleText } = await page.evaluate(() => ({
1330
- visibleText: String(document.body?.innerText || "").slice(0, 30000),
1331
- controls: [...document.querySelectorAll("input,textarea,select")].slice(0, 1000).map((element) => ({
1332
- tag: element.localName,
1333
- type: element.getAttribute("type") || "",
1334
- name: element.getAttribute("name") || "",
1335
- id: element.id || "",
1336
- value: String(element.value ?? "").slice(0, 4000),
1337
- checked: "checked" in element ? Boolean(element.checked) : undefined,
1338
- disabled: Boolean(element.disabled),
1339
- })),
1340
- })));
1341
- } catch {
1342
- // The page may close between a browser event and an authorization check.
1343
- }
1344
- return createHash("sha256").update(JSON.stringify({
1345
- url: typeof page.url === "function" ? page.url() : "unavailable", snapshot, visibleText, controls,
1346
- })).digest("hex");
1347
- }
1594
+ async function pageFingerprint(page) {
1595
+ let structure = [];
1596
+ try {
1597
+ structure = await page.evaluate(() => {
1598
+ const controls = [];
1599
+ const visit = (root) => {
1600
+ for (const element of root.querySelectorAll("a[href],button,input,textarea,select,[role]")) {
1601
+ const style = getComputedStyle(element);
1602
+ if (style.display === "none" || style.visibility === "hidden" || element.getClientRects().length === 0) continue;
1603
+ controls.push({
1604
+ tag: element.localName,
1605
+ role: element.getAttribute("role") || "",
1606
+ type: element.getAttribute("type") || "",
1607
+ name: element.getAttribute("name") || "",
1608
+ id: element.id || "",
1609
+ href: element instanceof HTMLAnchorElement ? `${element.origin}${element.pathname}` : "",
1610
+ disabled: Boolean(element.disabled || element.getAttribute("aria-disabled") === "true"),
1611
+ checked: "checked" in element ? Boolean(element.checked) : undefined,
1612
+ expanded: element.getAttribute("aria-expanded") || "",
1613
+ });
1614
+ if (element.shadowRoot) visit(element.shadowRoot);
1615
+ }
1616
+ };
1617
+ visit(document);
1618
+ return controls.slice(0, 2000);
1619
+ });
1620
+ } catch {
1621
+ // The page may close between a browser event and an authorization check.
1622
+ }
1623
+ let route = "unavailable";
1624
+ try {
1625
+ const url = new URL(page.url());
1626
+ route = `${url.origin}${url.pathname}`;
1627
+ } catch { /* Keep the unavailable marker. */ }
1628
+ return createHash("sha256").update(JSON.stringify({
1629
+ route,
1630
+ structure,
1631
+ })).digest("hex");
1632
+ }
1348
1633
 
1349
1634
  async function snapshotAfterStep(page, settings, secrets, scope, previousSnapshot, stepNumber, warnings, includeHidden) {
1350
1635
  const snapshot = await snapshotPage(page, settings, secrets, scope, { allowEmpty: true, includeHidden });
@@ -1549,11 +1834,40 @@ function accessibleNamePattern(name) {
1549
1834
  return new RegExp(`^[\\s\\uE000-\\uF8FF]*${normalized}[\\s\\uE000-\\uF8FF]*$`, "u");
1550
1835
  }
1551
1836
 
1552
- function roleOptions(step) {
1553
- if (step.name === "") return { name: UNNAMED_ACCESSIBLE_NAME };
1554
- if (step.name === undefined) return {};
1555
- return { name: step.name, exact: true };
1556
- }
1837
+ function roleOptions(step) {
1838
+ if (step.name === "") return { name: UNNAMED_ACCESSIBLE_NAME };
1839
+ if (step.name === undefined) return {};
1840
+ if (step.nameMatch === "contains") return { name: step.name, exact: false };
1841
+ if (step.nameMatch === "regex") return { name: new RegExp(step.name, "u") };
1842
+ return { name: step.name, exact: true };
1843
+ }
1844
+
1845
+ async function waitForUrlContains(page, fragment, timeout, settings) {
1846
+ const deadline = performance.now() + timeout;
1847
+ while (performance.now() < deadline) {
1848
+ if (page.url().includes(fragment)) return;
1849
+ await new Promise((resolve) => setTimeout(resolve, Math.min(settings.browser.visibilityPollMs, remainingMs(deadline))));
1850
+ }
1851
+ throw new JevBrowserError(`page URL did not contain ${JSON.stringify(fragment)} within ${timeout}ms`);
1852
+ }
1853
+
1854
+ async function clickAndWaitForLoad(page, control, timeout) {
1855
+ const isPageClosed = () => typeof page.isClosed === "function" && page.isClosed();
1856
+ const waitForDocument = async () => {
1857
+ if (!isPageClosed() && typeof page.waitForLoadState === "function") {
1858
+ await page.waitForLoadState("domcontentloaded", { timeout });
1859
+ }
1860
+ };
1861
+ try {
1862
+ await control.click({ timeout });
1863
+ } catch (error) {
1864
+ if (!/execution context was destroyed|frame was detached/i.test(String(error?.message || ""))) throw error;
1865
+ if (isPageClosed()) throw error;
1866
+ await waitForDocument();
1867
+ return;
1868
+ }
1869
+ await waitForDocument();
1870
+ }
1557
1871
 
1558
1872
  function xpathRolePredicate(role) {
1559
1873
  const nativePredicates = {
@@ -1614,10 +1928,10 @@ async function targetLocator(root, step, timeout, settings) {
1614
1928
  locator = targetRoot.getByTestId(step.testId);
1615
1929
  } else if (step.near) {
1616
1930
  locator = relativeTarget(targetRoot.getByText(step.near.text, { exact: true }).first(), step.role);
1617
- } else if (step.role) {
1618
- locator = targetRoot.getByRole(step.role, roleOptions(step));
1619
- if (step.name && await locator.count() === 0) {
1620
- locator = targetRoot.getByRole(step.role, { name: accessibleNamePattern(step.name) });
1931
+ } else if (step.role) {
1932
+ locator = targetRoot.getByRole(step.role, roleOptions(step));
1933
+ if (step.name && (!step.nameMatch || step.nameMatch === "exact") && await locator.count() === 0) {
1934
+ locator = targetRoot.getByRole(step.role, { name: accessibleNamePattern(step.name) });
1621
1935
  }
1622
1936
  } else {
1623
1937
  throw new JevBrowserError("step has no resolvable target; use role/name, label, placeholder, title, text, target_text, test_id, selector, or near");
@@ -1966,9 +2280,22 @@ async function navigateMenu(page, root, step, settings, execution) {
1966
2280
  return { action: step.action, navigated_menu: step.path };
1967
2281
  }
1968
2282
 
1969
- async function executeStep(page, step, settings, snapshot, secrets, downloadLedger, execution = {}) {
1970
- const root = await stepRoot(page, step.frame);
1971
- const timeout = step.timeoutMs ?? settings.browser.actionTimeoutMs;
2283
+ async function executeStep(page, step, settings, snapshot, secrets, downloadLedger, execution = {}) {
2284
+ const root = await stepRoot(page, step.frame);
2285
+ const timeout = step.timeoutMs ?? settings.browser.actionTimeoutMs;
2286
+ if (step.action === "navigate") {
2287
+ let destination;
2288
+ try { destination = new URL(step.url, page.url()).toString(); } catch {
2289
+ throw new JevBrowserError("navigate URL must be absolute or relative to the current page");
2290
+ }
2291
+ destination = validateUrl(destination);
2292
+ if (execution.allowMutations && !localHostOnly(destination)) {
2293
+ throw new JevBrowserError("allow_mutations only permits navigation to localhost, 127.0.0.1, or ::1");
2294
+ }
2295
+ await page.goto(destination, { waitUntil: "domcontentloaded", timeout });
2296
+ execution.browserPool?.setNavigationError?.(null);
2297
+ return { action: step.action, url: evidenceUrl(destination, secrets, settings.jev.maxDiagnosticChars) };
2298
+ }
1972
2299
  if (step.action === "assert_network") {
1973
2300
  const response = await waitForNetworkAssertion(
1974
2301
  execution.networkTracker,
@@ -2046,29 +2373,29 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
2046
2373
  await waitForAngularIdle(page, timeout, settings);
2047
2374
  return { action: step.action, condition: step.condition };
2048
2375
  }
2049
- if (step.action === "wait_for_condition") {
2050
- if (step.condition === "network_idle") {
2051
- if (step.urlContains) {
2052
- await waitForFilteredNetworkIdle(
2053
- execution.networkTracker,
2054
- step.urlContains,
2055
- execution.networkMarker,
2056
- timeout,
2057
- settings,
2058
- );
2059
- } else {
2060
- await root.waitForLoadState("networkidle", { timeout });
2061
- }
2062
- } else {
2376
+ if (step.action === "wait_for_condition") {
2377
+ if (step.condition === "network_idle") {
2378
+ await waitForFilteredNetworkIdle(
2379
+ execution.networkTracker,
2380
+ step.urlContains,
2381
+ execution.networkMarker,
2382
+ timeout,
2383
+ execution.readyStableMs,
2384
+ settings,
2385
+ );
2386
+ } else if (step.condition === "url") {
2387
+ await waitForUrlContains(page, step.urlContains, timeout, settings);
2388
+ } else {
2063
2389
  const locator = await targetLocator(root, step, timeout, settings);
2064
2390
  await waitForLocatorHidden(locator, timeout, `hidden condition ${describeTarget(step, secrets)}`, settings, step.index);
2065
2391
  }
2066
2392
  return {
2067
2393
  action: step.action,
2068
- condition: step.condition,
2069
- ...(step.condition === "network_idle"
2070
- ? (step.urlContains ? { url_contains: redact(step.urlContains, secrets) } : {})
2071
- : { target: describeTarget(step, secrets) }),
2394
+ condition: step.condition,
2395
+ ...(step.condition === "network_idle"
2396
+ ? (step.urlContains ? { url_contains: redact(step.urlContains, secrets) } : {})
2397
+ : step.condition === "url" ? { url_contains: redact(step.urlContains, secrets) }
2398
+ : { target: describeTarget(step, secrets) }),
2072
2399
  };
2073
2400
  }
2074
2401
  if (step.action === "press") {
@@ -2085,14 +2412,18 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
2085
2412
  if (step.action === "like_comment" || step.action === "unlike_comment") {
2086
2413
  return { action: step.action, result: await reactToComment(root, step, timeout, settings) };
2087
2414
  }
2088
- if (step.action === "audit_accessibility") {
2089
- return {
2090
- action: step.action,
2091
- accessibility_audit: await withTimeout(
2092
- runAccessibilityAudit(page, step.standard, settings),
2093
- timeout,
2094
- `accessibility audit exceeded the ${timeout}ms step timeout`,
2095
- ),
2415
+ if (step.action === "audit_accessibility") {
2416
+ const accessibilityAudit = await withTimeout(
2417
+ runAccessibilityAudit(page, step.standard, settings, {
2418
+ scope: step.scope,
2419
+ failOnImpact: step.failOnImpact,
2420
+ }),
2421
+ timeout,
2422
+ `accessibility audit exceeded the ${timeout}ms step timeout`,
2423
+ );
2424
+ return {
2425
+ action: step.action,
2426
+ accessibility_audit: accessibilityAudit,
2096
2427
  };
2097
2428
  }
2098
2429
  if (step.action === "upload_file") {
@@ -2154,35 +2485,33 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
2154
2485
  }
2155
2486
  if (step.action === "click") {
2156
2487
  const resolvedTarget = await describeResolvedTarget(control, step, secrets, settings);
2157
- if (step.expectPopup) {
2158
- let popup;
2159
- try {
2160
- [popup] = await Promise.all([
2161
- page.waitForEvent("popup", { timeout: settings.browser.downloadTimeoutMs }),
2162
- control.click({ timeout }),
2163
- ]);
2164
- } catch {
2165
- throw new JevBrowserError("click did not open the expected popup before the configured timeout");
2166
- }
2167
- try {
2168
- await popup.waitForLoadState("domcontentloaded", { timeout: settings.browser.downloadTimeoutMs }).catch(() => {});
2169
- const popupUrl = popup.url();
2170
- const popupTitle = await popup.title().catch(() => "");
2171
- const isPdf = /\.pdf(?:$|[?#])/i.test(popupUrl)
2172
- || await popup.locator('embed[type="application/pdf"], iframe[src*=".pdf" i]').count().then((count) => count > 0).catch(() => false);
2173
- return {
2174
- action: step.action,
2175
- ...targetEvidence(step, secrets, resolvedTarget),
2176
- popup_url: evidenceUrl(popupUrl, secrets, settings.jev.maxDiagnosticChars),
2177
- ...(popupTitle ? { popup_title: redact(popupTitle, secrets).slice(0, settings.jev.maxDiagnosticChars) } : {}),
2178
- popup_document_type: isPdf ? "pdf" : "page",
2179
- };
2180
- } finally {
2181
- await popup.close().catch(() => {});
2182
- }
2183
- }
2184
- if (!step.expectDownload) {
2185
- await control.click({ timeout });
2488
+ if (step.expectPopup) {
2489
+ let popup;
2490
+ try {
2491
+ [popup] = await Promise.all([
2492
+ page.waitForEvent("popup", { timeout: settings.browser.downloadTimeoutMs }),
2493
+ clickAndWaitForLoad(page, control, timeout),
2494
+ ]);
2495
+ } catch {
2496
+ throw new JevBrowserError("click did not open the expected popup before the configured timeout");
2497
+ }
2498
+ execution.popupPage = popup;
2499
+ execution.networkTracker?.attachPage(popup);
2500
+ await popup.waitForLoadState("domcontentloaded", { timeout: settings.browser.downloadTimeoutMs }).catch(() => {});
2501
+ const popupUrl = popup.url();
2502
+ const popupTitle = await popup.title().catch(() => "");
2503
+ const isPdf = /\.pdf(?:$|[?#])/i.test(popupUrl)
2504
+ || await popup.locator('embed[type="application/pdf"], iframe[src*=".pdf" i]').count().then((count) => count > 0).catch(() => false);
2505
+ return {
2506
+ action: step.action,
2507
+ ...targetEvidence(step, secrets, resolvedTarget),
2508
+ popup_url: evidenceUrl(popupUrl, secrets, settings.jev.maxDiagnosticChars),
2509
+ ...(popupTitle ? { popup_title: redact(popupTitle, secrets).slice(0, settings.jev.maxDiagnosticChars) } : {}),
2510
+ popup_document_type: isPdf ? "pdf" : "page",
2511
+ };
2512
+ }
2513
+ if (!step.expectDownload) {
2514
+ await clickAndWaitForLoad(page, control, timeout);
2186
2515
  if (execution.nativeDialog?.result && !execution.nativeDialog.result.matched) {
2187
2516
  throw new JevBrowserError(`unexpected native ${execution.nativeDialog.result.type} dialog was dismissed: ${JSON.stringify(sanitizeDiagnosticText(execution.nativeDialog.result.message, secrets, settings.jev.maxDiagnosticChars))}`);
2188
2517
  }
@@ -2203,7 +2532,7 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
2203
2532
  downloaded_file: await saveDownload(download, settings, downloadLedger),
2204
2533
  };
2205
2534
  }
2206
- if (step.action === "type") {
2535
+ if (step.action === "type") {
2207
2536
  const resolvedTarget = await describeResolvedTarget(control, step, secrets, settings);
2208
2537
  if (step.mode === "keys") {
2209
2538
  await control.fill("", { timeout });
@@ -2228,7 +2557,12 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
2228
2557
  name: step.name,
2229
2558
  ...targetEvidence(step, secrets, resolvedTarget),
2230
2559
  };
2231
- }
2560
+ }
2561
+ if (step.action === "clear") {
2562
+ const resolvedTarget = await describeResolvedTarget(control, step, secrets, settings);
2563
+ await control.fill("", { timeout });
2564
+ return { action: step.action, cleared: true, ...targetEvidence(step, secrets, resolvedTarget) };
2565
+ }
2232
2566
  if (step.action === "select_option") {
2233
2567
  const resolvedTarget = await describeResolvedTarget(control, step, secrets, settings);
2234
2568
  if (step.optionMatch === "value") {
@@ -2310,9 +2644,20 @@ function flowTimings(started, browserSessionMs, navigationMs, initialSnapshotMs,
2310
2644
  };
2311
2645
  }
2312
2646
 
2313
- function addDiagnostic(collection, value, limit) {
2314
- if (collection.length < limit) collection.push(value);
2315
- }
2647
+ function addDiagnostic(collection, value, limit) {
2648
+ if (collection.length < limit) collection.push(value);
2649
+ }
2650
+
2651
+ function groupConsoleErrors(errors) {
2652
+ const grouped = new Map();
2653
+ for (const entry of errors) {
2654
+ const message = typeof entry === "string" ? entry : String(entry?.message || "");
2655
+ if (!message) continue;
2656
+ const count = typeof entry === "object" ? Math.max(1, Number(entry.count) || 1) : 1;
2657
+ grouped.set(message, (grouped.get(message) || 0) + count);
2658
+ }
2659
+ return [...grouped].map(([message, count]) => ({ message, count }));
2660
+ }
2316
2661
 
2317
2662
  function evidenceUrl(value, secrets, limit) {
2318
2663
  try {
@@ -2335,17 +2680,22 @@ function sanitizeDiagnosticText(value, secrets, limit) {
2335
2680
  return result.slice(0, limit);
2336
2681
  }
2337
2682
 
2338
- function createDiagnostics(page, options, settings, secrets) {
2683
+ function createDiagnostics(page, options, settings, secrets) {
2339
2684
  const diagnostics = { console_errors: [], network_failures: [], warnings: [] };
2340
2685
  const pendingBodies = [];
2341
2686
  const limit = settings.jev.maxDiagnosticItems;
2342
- const messageLimit = settings.jev.maxDiagnosticChars;
2343
- const onConsole = (message) => {
2344
- if (message.type() !== "error") return;
2345
- addDiagnostic(diagnostics.console_errors, sanitizeDiagnosticText(message.text(), secrets, messageLimit), limit);
2346
- };
2347
- const onPageError = (error) => {
2348
- addDiagnostic(diagnostics.console_errors, sanitizeDiagnosticText(error.message || error.name, secrets, messageLimit), limit);
2687
+ const messageLimit = settings.jev.maxDiagnosticChars;
2688
+ const addConsoleError = (value) => {
2689
+ const existing = diagnostics.console_errors.find((entry) => entry.message === value);
2690
+ if (existing) existing.count += 1;
2691
+ else if (diagnostics.console_errors.length < limit) diagnostics.console_errors.push({ message: value, count: 1 });
2692
+ };
2693
+ const onConsole = (message) => {
2694
+ if (message.type() !== "error") return;
2695
+ addConsoleError(sanitizeDiagnosticText(message.text(), secrets, messageLimit));
2696
+ };
2697
+ const onPageError = (error) => {
2698
+ addConsoleError(sanitizeDiagnosticText(error.message || error.name, secrets, messageLimit));
2349
2699
  };
2350
2700
  const addResponseMessage = async (response, entry) => {
2351
2701
  const warn = (reason) => addDiagnostic(
@@ -2470,13 +2820,14 @@ function trackerUrl(url, settings) {
2470
2820
  return settings.browser.trackerHostSuffixes.some((suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`));
2471
2821
  }
2472
2822
 
2473
- async function installTrackerBlocking(context, settings, enabled, blockFonts = false) {
2823
+ async function installTrackerBlocking(context, settings, enabled, blockFonts = false, allowMedia = false) {
2474
2824
  if (!enabled && !blockFonts) return undefined;
2475
2825
  const handler = async (route) => {
2476
2826
  const request = route.request();
2477
2827
  const resourceType = request.resourceType();
2478
2828
  if ((enabled && trackerUrl(request.url(), settings))
2479
- || (enabled && settings.browser.blockedResourceTypes.includes(resourceType) && (resourceType !== "font" || blockFonts))
2829
+ || (enabled && settings.browser.blockedResourceTypes.includes(resourceType)
2830
+ && (resourceType !== "font" || blockFonts) && (resourceType !== "media" || !allowMedia))
2480
2831
  || (blockFonts && resourceType === "font")) {
2481
2832
  await route.abort("blockedbyclient");
2482
2833
  return;
@@ -2563,20 +2914,40 @@ function reportContent(result, extension) {
2563
2914
  ].join("\n");
2564
2915
  }
2565
2916
 
2566
- async function writeFlowReport(result, reportPath, settings) {
2917
+ async function writeFlowReport(result, reportPath, settings) {
2567
2918
  const outputPath = await safeReportPath(reportPath, settings);
2568
2919
  await writeFile(outputPath, reportContent(result, path.extname(outputPath).toLowerCase()), { encoding: "utf8", flag: "w" });
2569
2920
  return outputPath;
2570
- }
2921
+ }
2922
+
2923
+ async function collectPerformanceMetrics(page) {
2924
+ try {
2925
+ return await page.evaluate(() => {
2926
+ const navigation = performance.getEntriesByType("navigation")[0];
2927
+ const paints = performance.getEntriesByType("paint");
2928
+ const firstContentfulPaint = paints.find(({ name }) => name === "first-contentful-paint")?.startTime;
2929
+ return {
2930
+ dom_interactive_ms: navigation ? Math.round(navigation.domInteractive * 10) / 10 : null,
2931
+ dom_content_loaded_ms: navigation ? Math.round(navigation.domContentLoadedEventEnd * 10) / 10 : null,
2932
+ load_event_ms: navigation ? Math.round(navigation.loadEventEnd * 10) / 10 : null,
2933
+ first_contentful_paint_ms: Number.isFinite(firstContentfulPaint) ? Math.round(firstContentfulPaint * 10) / 10 : null,
2934
+ resource_count: performance.getEntriesByType("resource").length,
2935
+ transfer_size_bytes: performance.getEntriesByType("resource").reduce((sum, entry) => sum + Number(entry.transferSize || 0), 0),
2936
+ };
2937
+ });
2938
+ } catch {
2939
+ return undefined;
2940
+ }
2941
+ }
2571
2942
 
2572
2943
  async function redactObject(value, secrets) {
2573
2944
  if (typeof value === "string") return redact(value, secrets);
2574
2945
  if (Array.isArray(value)) return Promise.all(value.map((item) => redactObject(item, secrets)));
2575
2946
  if (isRecord(value)) {
2576
2947
  return Object.fromEntries(await Promise.all(Object.entries(value).map(async ([key, item]) => [
2577
- key,
2578
- key === "final_snapshot" && typeof item === "string"
2579
- ? redactSnapshotFieldValues(item, secrets)
2948
+ key,
2949
+ key === "final_snapshot" && typeof item === "string"
2950
+ ? redactSnapshotFieldValues(redact(item, secrets), secrets)
2580
2951
  : await redactObject(item, secrets),
2581
2952
  ])));
2582
2953
  }
@@ -2599,7 +2970,7 @@ function snapshotDiff(initialSnapshot, finalSnapshot) {
2599
2970
  return added.length ? added.join("\n") : "[no new snapshot lines]";
2600
2971
  }
2601
2972
 
2602
- export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, candidatePlans, params: rawParams, options: rawOptions, settings, browserPool, fetchImpl = fetch, serverVersion, mutationAuthorizationStore = defaultMutationAuthorizationStore }) {
2973
+ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, candidatePlans, params: rawParams, options: rawOptions, settings, browserPool, fetchImpl = fetch, serverVersion, mutationAuthorizationStore = defaultMutationAuthorizationStore }) {
2603
2974
  if (typeof flow !== "string" || typeof expectedOutcome !== "string") throw new JevBrowserError("flow and expected_outcome must be text");
2604
2975
  let variables = validateParams(rawParams, settings);
2605
2976
  const requestedFlow = replaceKnownParams(flow, variables).trim();
@@ -2608,20 +2979,29 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2608
2979
  if (requestedFlow.length > settings.jev.maxFlowChars) throw new JevBrowserError("flow exceeds the configured character limit");
2609
2980
  if (outcome.length > settings.jev.maxActionDescriptionChars) throw new JevBrowserError("expected_outcome exceeds the configured character limit");
2610
2981
  const url = validateUrl(initialUrl);
2611
- const options = resolveFlowOptions(rawOptions, settings);
2612
- const plans = validateCandidatePlans(replaceKnownParams(candidatePlans, variables), settings, options.maxFlowSteps);
2613
- if (options.localOnly && Object.keys(plans).length > 0 && !(options.fastPath && Object.keys(plans).length === 1)) {
2614
- throw new JevBrowserError("local_only does not call Jev; provide exactly one candidate plan and enable fast_path");
2615
- }
2982
+ const options = resolveFlowOptions(rawOptions, settings);
2983
+ const plans = validateCandidatePlans(replaceKnownParams(candidatePlans, variables), settings, options.maxFlowSteps);
2984
+ resolveEnvironmentText(plans, settings);
2985
+ if (options.localOnly && Object.keys(plans).length > 0 && !(options.fastPath && Object.keys(plans).length === 1)) {
2986
+ throw new JevBrowserError("local_only does not call Jev; provide exactly one candidate plan and enable fast_path");
2987
+ }
2988
+ if (options.allowMutations && !localHostOnly(url)) {
2989
+ throw new JevBrowserError("allow_mutations is limited to localhost, 127.0.0.1, and ::1 URLs");
2990
+ }
2991
+ const secretBearingFlow = Object.values(plans).some((plan) => plan.steps.some((step) => step.action === "type" && step.sensitive));
2992
+ if (options.recordVideo && secretBearingFlow) {
2993
+ throw new JevBrowserError("record_video is disabled for flows that type sensitive text");
2994
+ }
2616
2995
  const fingerprint = flowFingerprint({ flow, initialUrl: url, expectedOutcome, candidatePlans, params: rawParams, options });
2617
2996
  const continuation = options.confirmationToken
2618
2997
  ? mutationAuthorizationStore.consume(options.confirmationToken, fingerprint)
2619
2998
  : undefined;
2620
2999
  const secrets = [...planSecrets(plans), ...sensitiveParamSecrets(variables), settings.env?.[settings.jev.credentialEnv]].filter(Boolean)
2621
3000
  .sort((left, right) => right.length - left.length);
2622
- const contentSecrets = planContentSecrets(plans);
2623
- const started = performance.now();
2624
- const browser = await browserPool.getPage();
3001
+ const contentSecrets = planContentSecrets(plans);
3002
+ const started = performance.now();
3003
+ const sessionConfiguration = await browserSessionConfiguration(options, settings);
3004
+ const browser = await browserPool.getPage(sessionConfiguration);
2625
3005
  const browserSessionMs = browser.sessionMs;
2626
3006
  const page = browser.page;
2627
3007
  const context = browser.context;
@@ -2642,7 +3022,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2642
3022
  warnings: continuation?.warnings || [],
2643
3023
  final_snapshot: "",
2644
3024
  };
2645
- result.snapshot_mode = options.returnSnapshot;
3025
+ result.snapshot_mode = options.returnSnapshot;
3026
+ if (options.allowMutations) result.mutation_authorization = "allowed_on_localhost";
2646
3027
  const downloadLedger = { count: 0, bytes: 0 };
2647
3028
  const networkTracker = createNetworkTracker(page, settings, options.loginUrlContains);
2648
3029
  const initialNetworkMarker = networkTracker.mark();
@@ -2650,9 +3031,11 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2650
3031
  let unroute;
2651
3032
  let tracingStarted = false;
2652
3033
  let tracePath;
2653
- const artifactErrors = [];
2654
- let navigationError;
2655
- let initialSnapshot = "";
3034
+ const artifactErrors = [];
3035
+ let navigationError;
3036
+ let initialSnapshot = "";
3037
+ let popupPage;
3038
+ let activePage = page;
2656
3039
  const nativeDialog = { expectedStep: undefined, result: undefined };
2657
3040
  const onNativeDialog = async (dialog) => {
2658
3041
  const expectedStep = nativeDialog.expectedStep;
@@ -2678,14 +3061,22 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2678
3061
  try {
2679
3062
  page.setDefaultTimeout(settings.browser.actionTimeoutMs);
2680
3063
  context.setDefaultTimeout(settings.browser.actionTimeoutMs);
2681
- unroute = await installTrackerBlocking(context, settings, options.blockTrackers, options.blockFonts);
2682
- if (options.traceOnFailure) {
2683
- try {
3064
+ unroute = await installTrackerBlocking(
3065
+ context,
3066
+ settings,
3067
+ options.blockTrackers,
3068
+ options.blockFonts,
3069
+ options.permissions.includes("microphone") || Boolean(options.fakeMedia),
3070
+ );
3071
+ if ((options.traceOnFailure || options.traceOnSuccess) && !secretBearingFlow) {
3072
+ try {
2684
3073
  await context.tracing.start({ screenshots: true, snapshots: true });
2685
3074
  tracingStarted = true;
2686
3075
  } catch {
2687
- artifactErrors.push("trace recording could not be started");
2688
- }
3076
+ artifactErrors.push("trace recording could not be started");
3077
+ }
3078
+ } else if ((options.traceOnFailure || options.traceOnSuccess || options.recordVideo) && secretBearingFlow) {
3079
+ addDiagnostic(result.warnings, "trace, video, and screenshots were suppressed because this flow contains sensitive text", settings.jev.maxDiagnosticItems);
2689
3080
  }
2690
3081
 
2691
3082
  let reusedPage = false;
@@ -2709,8 +3100,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2709
3100
  } else {
2710
3101
  reusedPage = true;
2711
3102
  result.navigation_strategy = "continued_current_page";
2712
- if (new URL(currentUrl).href !== new URL(url).href) {
2713
- addDiagnostic(result.warnings, "initial_url differs from the current same-origin URL; continued from the current page", settings.jev.maxDiagnosticItems);
3103
+ if (new URL(currentUrl).href !== new URL(url).href) {
3104
+ throw new JevBrowserError("reuse_page found a different same-origin URL; add a navigate step or set continue_from_current_page: false");
2714
3105
  }
2715
3106
  }
2716
3107
  }
@@ -2742,18 +3133,17 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2742
3133
  }
2743
3134
  for (const control of enriched.unnamedControls) {
2744
3135
  addDiagnostic(
2745
- result.warnings,
2746
- `interactive ${control.role} has no accessible name; index ${control.index}; nearest label ${JSON.stringify(control.nearest_label)}; inspect HTML ${control.html}`,
3136
+ result.warnings,
3137
+ `interactive ${control.role} has no accessible name; index ${control.index}; nearest label ${JSON.stringify(control.nearest_label)}; HTML is available in unnamed_controls_initial/final`,
2747
3138
  settings.jev.maxDiagnosticItems,
2748
3139
  );
2749
3140
  }
2750
3141
  timings.readyMs = readiness.readyMs;
2751
3142
  browserPool.setNavigationError?.(null);
2752
3143
  if (continuation) {
2753
- const currentUrl = typeof page.url === "function" ? page.url() : "unavailable";
2754
- const currentFingerprint = await pageFingerprint(page, result.final_snapshot);
2755
- if (continuation.pageUrl !== currentUrl || continuation.pageFingerprint !== currentFingerprint) {
2756
- throw new JevBrowserError("the page changed after dry_run; run dry_run again before confirming a mutation");
3144
+ const currentFingerprint = await pageFingerprint(page);
3145
+ if (continuation.pageFingerprint !== currentFingerprint) {
3146
+ throw new JevBrowserError("the page structure or route changed after dry_run; run dry_run again before confirming a mutation");
2757
3147
  }
2758
3148
  if (!Object.hasOwn(plans, continuation.planId)) {
2759
3149
  throw new JevBrowserError("confirmation_token refers to a candidate plan that is no longer available");
@@ -2788,7 +3178,7 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2788
3178
  const decisionFlow = redact(`${requestedFlow}\nExpected outcome: ${outcome}`, secrets);
2789
3179
  const { request, criteria } = buildChoicePayload({
2790
3180
  flow: decisionFlow,
2791
- pageSnapshot: redact(result.final_snapshot, contentSecrets),
3181
+ pageSnapshot: providerSafeSnapshot(result.final_snapshot, [...secrets, ...contentSecrets]),
2792
3182
  actions: descriptions,
2793
3183
  answerName: "browser_plan",
2794
3184
  allowComplete: options.stopOnExpected,
@@ -2824,23 +3214,29 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2824
3214
  result.accessibility_audits = continuation?.accessibilityAudits || [];
2825
3215
  if (continuation?.downloadLedger) Object.assign(downloadLedger, continuation.downloadLedger);
2826
3216
  const planStarted = performance.now();
2827
- const runAllSteps = plan.steps.some(({ action, expectDownload }) => action.startsWith("assert_")
2828
- || ["upload_file", "select_option", "hover", "like_comment", "unlike_comment", "audit_accessibility"].includes(action)
3217
+ const runAllSteps = plan.steps.some(({ action, expectDownload }) => action.startsWith("assert_")
3218
+ || ["navigate", "clear", "upload_file", "select_option", "hover", "like_comment", "unlike_comment", "audit_accessibility"].includes(action)
2829
3219
  || expectDownload === true);
2830
3220
  let approvedMutationIndex = continuation?.nextStep;
2831
3221
  for (let index = continuation?.nextStep || 0; index < plan.steps.length; index += 1) {
2832
3222
  const templateStep = plan.steps[index];
2833
3223
  const stepStarted = performance.now();
2834
3224
  let step = templateStep;
2835
- try {
2836
- step = interpolateStep(templateStep, variables);
2837
- step.timeoutMs ??= options.stepTimeoutMs;
2838
- const mutationInspection = await inferTargetMutation(page, step, snapshot, settings, secrets);
3225
+ try {
3226
+ step = interpolateStep(templateStep, variables);
3227
+ step.timeoutMs ??= options.stepTimeoutMs;
3228
+ const stepPage = step.targetPage === "popup" ? popupPage : page;
3229
+ if (step.targetPage === "popup" && (!popupPage || popupPage.isClosed())) {
3230
+ throw new JevBrowserError("target_page popup requires a preceding click with expect_popup: true");
3231
+ }
3232
+ activePage = stepPage;
3233
+ const mutationInspection = await inferTargetMutation(stepPage, step, snapshot, settings, secrets);
2839
3234
  const dynamicallyMutating = mutationInspection.mutating;
2840
3235
  const pairedNativeConfirmation = step.action === "confirm_dialog" && nativeDialog.result?.matched === true;
2841
- if (dynamicallyMutating && !pairedNativeConfirmation && index !== approvedMutationIndex) {
3236
+ if (dynamicallyMutating && !pairedNativeConfirmation && index !== approvedMutationIndex
3237
+ && !(options.allowMutations && !options.dryRun)) {
2842
3238
  mutationStop = { step: String(index + 1), action: step.action };
2843
- const enriched = await enrichSnapshot(page, snapshot, settings, secrets, options.snapshotScope, options.snapshotIncludeHidden);
3239
+ const enriched = await enrichSnapshot(stepPage, snapshot, settings, secrets, options.snapshotScope, options.snapshotIncludeHidden);
2844
3240
  snapshot = enriched.snapshot;
2845
3241
  issuedToken = mutationAuthorizationStore.issue({
2846
3242
  fingerprint,
@@ -2851,7 +3247,7 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2851
3247
  snapshot,
2852
3248
  initialSnapshot,
2853
3249
  pageUrl: typeof page.url === "function" ? page.url() : "unavailable",
2854
- pageFingerprint: await pageFingerprint(page, snapshot),
3250
+ pageFingerprint: await pageFingerprint(page),
2855
3251
  downloadedFiles: [...result.downloaded_files],
2856
3252
  accessibilityAudits: [...result.accessibility_audits],
2857
3253
  warnings: [...result.warnings],
@@ -2871,17 +3267,23 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2871
3267
  nativeDialog.expectedStep = undefined;
2872
3268
  nativeDialog.result = undefined;
2873
3269
  }
2874
- const actionMarker = networkTracker.mark();
2875
- const traceStep = await executeStep(page, step, settings, snapshot, secrets, downloadLedger, {
2876
- networkTracker,
2877
- networkMarker,
2878
- autoAngularIdle: options.autoAngularIdle,
2879
- nativeDialog,
2880
- preResolvedTarget: mutationInspection.target,
2881
- variables,
2882
- });
2883
- if (options.autoAngularIdle && ["click", "type"].includes(step.action)) {
2884
- await waitForAngularIdle(page, step.timeoutMs ?? options.stepTimeoutMs, settings, true);
3270
+ const actionMarker = networkTracker.mark();
3271
+ const execution = {
3272
+ networkTracker,
3273
+ networkMarker,
3274
+ autoAngularIdle: options.autoAngularIdle,
3275
+ readyStableMs: options.readyStableMs,
3276
+ allowMutations: options.allowMutations,
3277
+ browserPool,
3278
+ nativeDialog,
3279
+ preResolvedTarget: mutationInspection.target,
3280
+ popupPage,
3281
+ variables,
3282
+ };
3283
+ const traceStep = await executeStep(stepPage, step, settings, snapshot, secrets, downloadLedger, execution);
3284
+ popupPage = execution.popupPage;
3285
+ if (options.autoAngularIdle && ["click", "type"].includes(step.action)) {
3286
+ await waitForAngularIdle(stepPage, step.timeoutMs ?? options.stepTimeoutMs, settings, true);
2885
3287
  }
2886
3288
  if (!["wait_for_text", "wait_for_condition", "assert_text", "assert_value", "assert_visible", "assert_hidden", "audit_accessibility"].includes(step.action)) {
2887
3289
  networkMarker = actionMarker;
@@ -2889,10 +3291,10 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2889
3291
  traceStep.step = String(index + 1);
2890
3292
  traceStep.status = "passed";
2891
3293
  if (step.mutating === true) traceStep.mutating = true;
2892
- if (step.screenshot === true) {
2893
- try {
2894
- traceStep.screenshot_path = await artifactPath(settings, "png");
2895
- await page.screenshot({ path: traceStep.screenshot_path, fullPage: false });
3294
+ if (step.screenshot === true && !secretBearingFlow) {
3295
+ try {
3296
+ traceStep.screenshot_path = await artifactPath(settings, "png");
3297
+ await stepPage.screenshot({ path: traceStep.screenshot_path, fullPage: false });
2896
3298
  } catch {
2897
3299
  delete traceStep.screenshot_path;
2898
3300
  artifactErrors.push(`step ${index + 1} screenshot could not be saved`);
@@ -2901,14 +3303,14 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2901
3303
  traceStep.duration_ms = Math.round((performance.now() - stepStarted) * 10) / 10;
2902
3304
  trace.push(traceStep);
2903
3305
  if (traceStep.downloaded_file) result.downloaded_files.push(traceStep.downloaded_file);
2904
- if (traceStep.accessibility_audit) {
2905
- result.accessibility_audits.push(traceStep.accessibility_audit);
2906
- if (traceStep.accessibility_audit.violations_count > 0) {
2907
- failure = `accessibility audit reported ${traceStep.accessibility_audit.violations_count} violation(s)`;
3306
+ if (traceStep.accessibility_audit) {
3307
+ result.accessibility_audits.push(traceStep.accessibility_audit);
3308
+ if (traceStep.accessibility_audit.blocking_violations_count > 0) {
3309
+ failure = `accessibility audit reported ${traceStep.accessibility_audit.blocking_violations_count} violation(s) at or above ${traceStep.accessibility_audit.fail_on_impact} impact`;
2908
3310
  }
2909
3311
  }
2910
- snapshot = await snapshotAfterStep(
2911
- page,
3312
+ snapshot = await snapshotAfterStep(
3313
+ stepPage,
2912
3314
  settings,
2913
3315
  secrets,
2914
3316
  options.snapshotScope,
@@ -2937,9 +3339,9 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2937
3339
  break;
2938
3340
  }
2939
3341
  }
2940
- if (failure || trace.length === 0) {
2941
- snapshot = await snapshotAfterStep(
2942
- page,
3342
+ if (failure || trace.length === 0) {
3343
+ snapshot = await snapshotAfterStep(
3344
+ activePage,
2943
3345
  settings,
2944
3346
  secrets,
2945
3347
  options.snapshotScope,
@@ -2949,8 +3351,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2949
3351
  options.snapshotIncludeHidden,
2950
3352
  );
2951
3353
  }
2952
- const finalDetails = await enrichSnapshot(
2953
- page,
3354
+ const finalDetails = await enrichSnapshot(
3355
+ activePage,
2954
3356
  snapshot,
2955
3357
  settings,
2956
3358
  secrets,
@@ -2965,8 +3367,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2965
3367
  const assertionCount = plan.steps.filter(({ action }) => action.startsWith("assert_")).length;
2966
3368
  const assertionsPassed = assertionCount > 0 && !failure && trace.length === plan.steps.length;
2967
3369
  const auditCount = plan.steps.filter(({ action }) => action === "audit_accessibility").length;
2968
- const auditsPassed = auditCount > 0 && !failure && result.accessibility_audits.length === auditCount
2969
- && result.accessibility_audits.every(({ violations_count: count }) => count === 0)
3370
+ const auditsPassed = auditCount > 0 && !failure && result.accessibility_audits.length === auditCount
3371
+ && result.accessibility_audits.every(({ blocking_violations_count: count }) => count === 0)
2970
3372
  && trace.length === plan.steps.length;
2971
3373
  const expectedDownloadCount = plan.steps.filter(({ action, expectDownload }) => action === "click" && expectDownload).length;
2972
3374
  const downloadsPassed = expectedDownloadCount > 0 && !failure
@@ -3047,12 +3449,13 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
3047
3449
  } catch {
3048
3450
  // Keep the last accessible snapshot when the page is closing or unavailable.
3049
3451
  }
3050
- } finally {
3051
- if (tracingStarted) {
3052
- try {
3053
- if (result.status !== "passed" && options.traceOnFailure) {
3054
- tracePath = await artifactPath(settings, "zip");
3055
- await context.tracing.stop({ path: tracePath });
3452
+ } finally {
3453
+ if (tracingStarted) {
3454
+ try {
3455
+ if ((result.status !== "passed" && options.traceOnFailure)
3456
+ || (result.status === "passed" && options.traceOnSuccess)) {
3457
+ tracePath = await artifactPath(settings, "zip");
3458
+ await context.tracing.stop({ path: tracePath });
3056
3459
  } else {
3057
3460
  await context.tracing.stop();
3058
3461
  }
@@ -3060,15 +3463,34 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
3060
3463
  artifactErrors.push("trace recording could not be saved");
3061
3464
  }
3062
3465
  }
3063
- if (result.status !== "passed" && options.screenshotOnFailure) {
3064
- try {
3065
- result.screenshot_path = await artifactPath(settings, "png");
3066
- await page.screenshot({ path: result.screenshot_path, fullPage: false });
3067
- } catch {
3466
+ const shouldScreenshot = result.status === "passed" ? options.screenshotOnSuccess : options.screenshotOnFailure;
3467
+ if (shouldScreenshot && !secretBearingFlow) {
3468
+ try {
3469
+ result.screenshot_path = await artifactPath(settings, "png");
3470
+ await activePage.screenshot({ path: result.screenshot_path, fullPage: false });
3471
+ } catch {
3068
3472
  delete result.screenshot_path;
3069
- artifactErrors.push("failure screenshot could not be saved");
3070
- }
3071
- }
3473
+ artifactErrors.push("failure screenshot could not be saved");
3474
+ }
3475
+ }
3476
+ const metrics = await collectPerformanceMetrics(activePage);
3477
+ if (metrics) result.performance_metrics = metrics;
3478
+ if (options.recordVideo && !secretBearingFlow) {
3479
+ try {
3480
+ const video = activePage.video?.();
3481
+ if (!video) throw new Error("video capture was unavailable");
3482
+ result.video_path = await artifactPath(settings, "webm");
3483
+ await activePage.close();
3484
+ await video.saveAs(result.video_path);
3485
+ const nextContextOptions = { ...sessionConfiguration.contextOptions };
3486
+ delete nextContextOptions.recordVideo;
3487
+ await browserPool.getPage?.({ contextOptions: nextContextOptions, launchArgs: sessionConfiguration.launchArgs });
3488
+ } catch {
3489
+ delete result.video_path;
3490
+ artifactErrors.push("video recording could not be saved");
3491
+ }
3492
+ }
3493
+ if (popupPage && !popupPage.isClosed()) await popupPage.close().catch(() => {});
3072
3494
  try {
3073
3495
  await unroute?.();
3074
3496
  } catch {
@@ -3080,13 +3502,13 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
3080
3502
  }
3081
3503
  if (tracePath) result.trace_path = tracePath;
3082
3504
  if (artifactErrors.length) result.artifact_errors = artifactErrors;
3083
- result.console_errors = [...(continuation?.consoleErrors || []), ...capture.diagnostics.console_errors];
3505
+ result.console_errors = groupConsoleErrors([...(continuation?.consoleErrors || []), ...capture.diagnostics.console_errors]);
3084
3506
  result.network_failures = [...(continuation?.networkFailures || []), ...capture.diagnostics.network_failures];
3085
3507
  result.warnings.push(...capture.diagnostics.warnings);
3086
3508
  result.timings_ms = flowTimings(started, browserSessionMs, timings.navigationMs, timings.initialSnapshotMs, timings.readyMs, timings.decisionMs, timings.browserPlanMs);
3087
- const safeResult = await redactObject(result, secrets);
3088
- if (typeof safeResult.final_snapshot === "string") {
3089
- safeResult.final_snapshot = redact(safeResult.final_snapshot, contentSecrets);
3509
+ const safeResult = await redactObject(result, secrets);
3510
+ if (typeof safeResult.final_snapshot === "string") {
3511
+ safeResult.final_snapshot = redact(safeResult.final_snapshot, contentSecrets);
3090
3512
  }
3091
3513
  if (options.returnSnapshot === "diff" && typeof safeResult.final_snapshot === "string") {
3092
3514
  safeResult.final_snapshot = snapshotDiff(initialSnapshot, safeResult.final_snapshot);