@diegosouzacdv/jev-browser-mcp 0.6.0 → 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.
@@ -1,4 +1,4 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
2
2
  import { lstat, mkdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { ACCESSIBILITY_STANDARDS, runAccessibilityAudit } from "./accessibility.mjs";
@@ -6,7 +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"]);
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"]);
10
11
  const TEXT_ROLES = new Set(["textbox", "searchbox", "combobox"]);
11
12
  const HOVER_ROLES = new Set(["button", "link", "tab", "menuitem", "row", "gridcell", "cell", "region", "group"]);
12
13
  const DROPZONE_ROLES = new Set(["button", "region", "group"]);
@@ -15,22 +16,22 @@ const CONTAINER_ROLES = new Set([
15
16
  "group", "list", "listbox", "listitem", "main", "menu", "menubar", "navigation",
16
17
  "region", "row", "rowgroup", "table", "tablist", "tabpanel", "tree", "treegrid", "treeitem",
17
18
  ]);
18
- const TARGET_SELECTION_ACTIONS = new Set([
19
- "click", "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",
20
21
  "wait_for_enabled", "assert_enabled", "assert_visible", "assert_hidden", "upload_file", "extract",
21
22
  ]);
22
23
  const LOCATOR_FIELDS = ["label", "placeholder", "title", "text", "test_id", "selector"];
23
24
  const TARGET_LOCATOR_FIELDS = [...LOCATOR_FIELDS, "target_text"];
24
25
  const LOCATOR_STEP_KEYS = { label: "label", placeholder: "placeholder", title: "title", text: "targetText", test_id: "testId", selector: "selector", target_text: "typedTargetText" };
25
26
  const ASSERT_ROLES = new Set([
26
- "alert", "button", "cell", "checkbox", "combobox", "dialog", "gridcell", "heading",
27
+ "alert", "button", "cell", "checkbox", "combobox", "dialog", "gridcell", "heading",
27
28
  "img", "link", "listitem", "main", "menuitem", "option", "paragraph", "progressbar",
28
- "radio", "region", "row", "searchbox", "status", "tab", "textbox",
29
+ "radio", "region", "row", "searchbox", "status", "switch", "tab", "textbox",
29
30
  ]);
30
31
  const UNNAMED_ACCESSIBLE_NAME = /^[\s\uE000-\uF8FF]*$/u;
31
32
  const APPROVED_KEYS = new Set([
32
33
  "PageDown", "PageUp", "Home", "End", "ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight",
33
- "Enter", "Escape", "Tab", "Shift+Tab",
34
+ "Enter", "Escape", "Tab", "Shift+Tab", "Space", "Backspace", "Delete",
34
35
  ]);
35
36
  const MIME_TYPES = new Map([
36
37
  [".csv", "text/csv"], [".json", "application/json"], [".pdf", "application/pdf"],
@@ -38,12 +39,68 @@ const MIME_TYPES = new Map([
38
39
  [".txt", "text/plain"], [".webp", "image/webp"],
39
40
  ]);
40
41
  const FLOW_OPTION_KEYS = new Set([
41
- "block_trackers", "capture_console_errors", "capture_network_error_bodies", "capture_network_errors",
42
- "dry_run", "fast_path", "ready_network_idle", "ready_stable_ms", "ready_text", "ready_timeout_seconds",
43
- "report_path",
44
- "continue_from_current_page", "reuse_page", "screenshot_on_failure", "snapshot_include_hidden", "snapshot_scope", "stop_on_expected",
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",
47
+ "continue_from_current_page", "reuse_page", "return_snapshot", "screenshot_on_failure", "snapshot_include_hidden", "snapshot_scope", "step_timeout_seconds", "stop_on_expected",
45
48
  "trace_on_failure",
46
49
  ]);
50
+ const MUTATING_LABEL = /\b(?:save|submit|issue|emit|cancel|delete|remove|void|confirm|approve|pay|send|generate|create|update|finalize|yes|okay|salvar|gravar|emitir|cancelar|excluir|apagar|remover|estornar|confirmar|aprovar|pagar|enviar|gerar|criar|atualizar|finalizar|sim)\b/i;
51
+
52
+ export function createMutationAuthorizationStore() {
53
+ const tokens = new Map();
54
+ const prune = () => {
55
+ const now = Date.now();
56
+ for (const [token, record] of tokens) if (record.expiresAt <= now) tokens.delete(token);
57
+ while (tokens.size > 1024) tokens.delete(tokens.keys().next().value);
58
+ };
59
+ return Object.freeze({
60
+ issue(record, ttlMs) {
61
+ prune();
62
+ const token = randomBytes(32).toString("base64url");
63
+ tokens.set(token, { ...record, expiresAt: Date.now() + ttlMs });
64
+ return token;
65
+ },
66
+ consume(token, fingerprint) {
67
+ prune();
68
+ const record = tokens.get(token);
69
+ if (!record) throw new JevBrowserError("confirmation_token is invalid, expired, or already used; run dry_run again");
70
+ tokens.delete(token);
71
+ if (record.fingerprint !== fingerprint) {
72
+ throw new JevBrowserError("confirmation_token does not match this flow; run dry_run again");
73
+ }
74
+ return record;
75
+ },
76
+ });
77
+ }
78
+
79
+ const defaultMutationAuthorizationStore = createMutationAuthorizationStore();
80
+
81
+ function canonicalValue(value) {
82
+ if (Array.isArray(value)) return value.map(canonicalValue);
83
+ if (!isRecord(value)) return value;
84
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalValue(value[key])]));
85
+ }
86
+
87
+ function flowFingerprint({ flow, initialUrl, expectedOutcome, candidatePlans, params, options }) {
88
+ const stableOptions = { ...options };
89
+ delete stableOptions.dry_run;
90
+ delete stableOptions.confirmation_token;
91
+ delete stableOptions.dryRun;
92
+ delete stableOptions.confirmationToken;
93
+ return createHash("sha256").update(JSON.stringify(canonicalValue({
94
+ flow, initialUrl, expectedOutcome, candidatePlans, params, options: stableOptions,
95
+ }))).digest("hex");
96
+ }
97
+
98
+ function looksMutating(rawStep) {
99
+ if (["confirm_dialog", "like_comment", "unlike_comment", "upload_file"].includes(rawStep.action)) return true;
100
+ if (rawStep.action !== "click") return false;
101
+ return [rawStep.name, rawStep.title, rawStep.text, rawStep.target_text, rawStep.label]
102
+ .some((value) => typeof value === "string" && MUTATING_LABEL.test(value));
103
+ }
47
104
 
48
105
  function isRecord(value) {
49
106
  return value !== null && typeof value === "object" && !Array.isArray(value);
@@ -109,13 +166,12 @@ function interpolateStep(value, variables) {
109
166
  return value;
110
167
  }
111
168
 
112
- export function validateCandidatePlans(candidatePlans, settings) {
169
+ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps = settings.browser.maxFlowSteps) {
113
170
  if (!isRecord(candidatePlans)) throw new JevBrowserError("candidate_plans must be an object");
114
171
  if (Object.keys(candidatePlans).length > settings.jev.maxActionCount) {
115
172
  throw new JevBrowserError("candidate_plans exceeds the configured candidate limit");
116
173
  }
117
174
  const plans = {};
118
- let totalSteps = 0;
119
175
  for (const [rawId, rawPlan] of Object.entries(candidatePlans)) {
120
176
  const id = rawId.trim();
121
177
  if (!PLAN_ID.test(id) || id === COMPLETE_ACTION_ID) throw new JevBrowserError("each plan needs a valid, non-reserved plan ID");
@@ -126,30 +182,34 @@ export function validateCandidatePlans(candidatePlans, settings) {
126
182
  if (!Array.isArray(rawPlan.steps) || rawPlan.steps.length === 0) {
127
183
  throw new JevBrowserError("each candidate plan needs at least one browser step");
128
184
  }
129
- totalSteps += rawPlan.steps.length;
130
- if (totalSteps > settings.browser.maxFlowSteps) throw new JevBrowserError("candidate plans exceed the configured total browser-step limit");
185
+ if (rawPlan.steps.length > maxFlowSteps) throw new JevBrowserError(`candidate plan ${id} exceeds the per-plan browser-step limit of ${maxFlowSteps}`);
131
186
 
132
187
  const steps = rawPlan.steps.map((unprocessedStep) => {
133
188
  const rawStep = normalizeStepAliases(unprocessedStep);
134
189
  if (!isRecord(rawStep) || typeof rawStep.action !== "string") throw new JevBrowserError("each browser step must be an object with an action");
135
190
  const { action } = rawStep;
136
191
  const targetSelection = ["frame", "comment", "within", "index", "near", ...TARGET_LOCATOR_FIELDS];
137
- const semanticTarget = [...targetSelection, "role", "name"];
192
+ const semanticTarget = [...targetSelection, "role", "name", "name_match", "target_page"];
138
193
  const locatorFields = TARGET_LOCATOR_FIELDS.filter((field) => field !== "text"
139
194
  || !["type", "wait_for_text", "wait_for_condition", "upload_file"].includes(action));
140
- const schemas = {
141
- click: { required: [], optional: [...semanticTarget, "expect_download", "timeout_seconds"] },
142
- 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"] },
143
200
  confirm_dialog: { required: ["expected_text", "button"], optional: ["timeout_seconds"] },
201
+ check: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
202
+ uncheck: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
203
+ navigate_menu: { required: ["path"], optional: ["toggle_selector", "timeout_seconds"] },
144
204
  assert_network: { required: ["url_contains", "method", "status"], optional: ["message_contains", "timeout_seconds"] },
145
205
  extract: { required: ["as"], optional: [...semanticTarget, "source", "attribute", "sensitive", "timeout_seconds"] },
146
206
  wait: { required: ["ms"], optional: [] },
147
- wait_for_text: { required: ["text"], optional: ["frame", "comment", "fail_on", "timeout_seconds"] },
148
- 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"] },
149
209
  wait_for_value: { required: ["value"], optional: [...semanticTarget, "timeout_seconds"] },
150
210
  wait_for_enabled: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
151
211
  press: { required: ["key"], optional: [...semanticTarget, "timeout_seconds"] },
152
- select_option: { required: ["option"], optional: [...semanticTarget, "timeout_seconds"] },
212
+ select_option: { required: [], optional: [...semanticTarget, "option", "value", "label_contains", "timeout_seconds"] },
153
213
  hover: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
154
214
  assert_text: { required: ["expected"], optional: [...semanticTarget, "timeout_seconds"] },
155
215
  assert_value: { required: ["expected"], optional: [...semanticTarget, "timeout_seconds"] },
@@ -157,12 +217,12 @@ export function validateCandidatePlans(candidatePlans, settings) {
157
217
  assert_visible: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
158
218
  assert_hidden: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
159
219
  upload_file: { required: ["file_paths"], optional: ["target", ...semanticTarget, "timeout_seconds"] },
160
- audit_accessibility: { required: ["standard"], optional: ["comment", "timeout_seconds"] },
220
+ audit_accessibility: { required: ["standard"], optional: ["comment", "scope", "fail_on_impact", "timeout_seconds", "target_page"] },
161
221
  like_comment: { required: ["author", "text", "like_label", "liked_label"], optional: ["frame", "comment", "timeout_seconds"] },
162
222
  unlike_comment: { required: ["author", "text", "like_label", "liked_label"], optional: ["frame", "comment", "timeout_seconds"] },
163
223
  };
164
224
  if (!Object.hasOwn(schemas, action)) throw new JevBrowserError(`unsupported browser action ${JSON.stringify(action)}; supported actions: ${Object.keys(schemas).join(", ")}`);
165
- 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);
166
226
 
167
227
  const step = { action };
168
228
  for (const field of ["mutating", "screenshot"]) {
@@ -171,6 +231,11 @@ export function validateCandidatePlans(candidatePlans, settings) {
171
231
  }
172
232
  if (rawStep[field] !== undefined) step[field] = rawStep[field];
173
233
  }
234
+ const inferredMutating = looksMutating(rawStep);
235
+ if (inferredMutating && rawStep.mutating === false) {
236
+ throw new JevBrowserError(`${action} cannot override the default mutating classification for this action`);
237
+ }
238
+ if (inferredMutating) step.mutating = true;
174
239
  const selectorFields = locatorFields.filter((field) => rawStep[field] !== undefined);
175
240
  const hasNear = rawStep.near !== undefined;
176
241
  if (selectorFields.length + Number(hasNear) > 1) {
@@ -183,7 +248,8 @@ export function validateCandidatePlans(candidatePlans, settings) {
183
248
  }
184
249
  if (rawStep.role !== undefined) {
185
250
  const allowedRoles = action === "click" ? CLICK_ROLES
186
- : action === "type" || action === "select_option" || action === "assert_value" || action === "wait_for_value" ? TEXT_ROLES
251
+ : action === "check" || action === "uncheck" ? CHECK_ROLES
252
+ : action === "type" || action === "select_option" || action === "assert_value" || action === "wait_for_value" ? TEXT_ROLES
187
253
  : action === "hover" ? HOVER_ROLES
188
254
  : action === "upload_file" ? (rawStep.target === "button" ? new Set(["button"]) : DROPZONE_ROLES)
189
255
  : ASSERT_ROLES;
@@ -192,7 +258,7 @@ export function validateCandidatePlans(candidatePlans, settings) {
192
258
  }
193
259
  step.role = rawStep.role;
194
260
  }
195
- if (rawStep.name !== undefined) {
261
+ if (rawStep.name !== undefined) {
196
262
  if (typeof rawStep.name !== "string") throw new JevBrowserError("name must be text");
197
263
  if (rawStep.name.trim()) step.name = requiredText(rawStep.name, "accessible name", settings.jev.maxActionDescriptionChars);
198
264
  else if (rawStep.name === "" && Number.isSafeInteger(rawStep.index) && rawStep.index >= 0) {
@@ -201,8 +267,27 @@ export function validateCandidatePlans(candidatePlans, settings) {
201
267
  } else {
202
268
  throw new JevBrowserError("name may be empty only when a non-negative index is provided");
203
269
  }
204
- if (!step.role) throw new JevBrowserError("name requires role; use a locator field such as label, title, placeholder, test_id, text, or selector");
205
- }
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
+ }
206
291
  if (action === "select_option" && !step.role) step.role = "combobox";
207
292
  if (TARGET_SELECTION_ACTIONS.has(action) && action !== "press"
208
293
  && !step.role && step.name === undefined && selectorFields.length === 0 && !hasNear) {
@@ -236,22 +321,27 @@ export function validateCandidatePlans(candidatePlans, settings) {
236
321
  if ((!TARGET_SELECTION_ACTIONS.has(action) && !hiddenWait) || !isRecord(rawStep.within)) {
237
322
  throw new JevBrowserError("within must be a semantic container on a target-based browser action");
238
323
  }
239
- if (rawStep.within.row_containing !== undefined || rawStep.within.row_containing_exact !== undefined) {
240
- const exact = rawStep.within.row_containing_exact !== undefined;
241
- const key = exact ? "row_containing_exact" : "row_containing";
242
- exactKeys(rawStep.within, [key], [], "within");
243
- step.within = exact
244
- ? { rowContainingExact: requiredText(rawStep.within.row_containing_exact, "within exact row text", settings.jev.maxActionDescriptionChars) }
245
- : { rowContaining: requiredText(rawStep.within.row_containing, "within row text", settings.jev.maxActionDescriptionChars) };
246
- } else {
247
- exactKeys(rawStep.within, ["role"], ["name"], "within");
248
- if (typeof rawStep.within.role !== "string" || !CONTAINER_ROLES.has(rawStep.within.role)) {
249
- throw new JevBrowserError(`within role must be one of: ${[...CONTAINER_ROLES].join(", ")}`);
250
- }
251
- step.within = { role: rawStep.within.role };
252
- if (rawStep.within.name !== undefined && String(rawStep.within.name).trim()) {
253
- step.within.name = requiredText(rawStep.within.name, "within accessible name", settings.jev.maxActionDescriptionChars);
254
- }
324
+ const rowKeys = ["row_containing", "row_containing_exact", "row_containing_word"]
325
+ .filter((key) => rawStep.within[key] !== undefined);
326
+ if (rowKeys.length > 1) throw new JevBrowserError("within accepts only one row_containing match mode");
327
+ exactKeys(rawStep.within, [...(rawStep.within.role !== undefined ? ["role"] : []), ...rowKeys], ["name"], "within");
328
+ if (rawStep.within.role !== undefined && (typeof rawStep.within.role !== "string" || !CONTAINER_ROLES.has(rawStep.within.role))) {
329
+ throw new JevBrowserError(`within role must be one of: ${[...CONTAINER_ROLES].join(", ")}`);
330
+ }
331
+ if (rawStep.within.name !== undefined && rawStep.within.role === undefined) {
332
+ throw new JevBrowserError("within.name requires within.role");
333
+ }
334
+ if (!rawStep.within.role && rowKeys.length === 0) {
335
+ throw new JevBrowserError("within needs a semantic container role or row_containing text");
336
+ }
337
+ step.within = {};
338
+ if (rawStep.within.role !== undefined) step.within.role = rawStep.within.role;
339
+ if (rawStep.within.name !== undefined) step.within.name = requiredText(rawStep.within.name, "within accessible name", settings.jev.maxActionDescriptionChars);
340
+ if (rowKeys.length) {
341
+ const key = rowKeys[0];
342
+ const normalizedKey = key === "row_containing_exact" ? "rowContainingExact"
343
+ : key === "row_containing_word" ? "rowContainingWord" : "rowContaining";
344
+ step.within[normalizedKey] = requiredText(rawStep.within[key], `within ${key}`, settings.jev.maxActionDescriptionChars);
255
345
  }
256
346
  }
257
347
  if (rawStep.index !== undefined) {
@@ -266,14 +356,47 @@ export function validateCandidatePlans(candidatePlans, settings) {
266
356
  if (typeof rawStep.expect_download !== "boolean") throw new JevBrowserError("click expect_download must be a boolean");
267
357
  step.expectDownload = rawStep.expect_download;
268
358
  }
359
+ if (action === "click" && rawStep.expect_popup !== undefined) {
360
+ if (typeof rawStep.expect_popup !== "boolean") throw new JevBrowserError("click expect_popup must be a boolean");
361
+ step.expectPopup = rawStep.expect_popup;
362
+ }
363
+ if (action === "click" && step.expectDownload && step.expectPopup) {
364
+ throw new JevBrowserError("click cannot expect both a download and a popup");
365
+ }
269
366
  if (action === "select_option") {
367
+ const choices = ["option", "value", "label_contains"].filter((key) => rawStep[key] !== undefined);
368
+ if (choices.length !== 1) throw new JevBrowserError("select_option needs exactly one of option, value, or label_contains");
270
369
  if (step.role && step.role !== "combobox") throw new JevBrowserError("select_option role must be combobox");
271
370
  step.role = "combobox";
272
- step.option = requiredText(rawStep.option, "option", settings.jev.maxActionDescriptionChars);
371
+ step.option = requiredText(rawStep[choices[0]], choices[0], settings.jev.maxActionDescriptionChars);
372
+ step.optionMatch = choices[0];
273
373
  }
274
- if (action === "type") {
275
- if (typeof rawStep.text !== "string" || rawStep.text.length === 0) throw new JevBrowserError("type step needs non-empty text");
276
- if (rawStep.text.length > settings.browser.maxTextEntryChars) throw new JevBrowserError("type text exceeds the configured character limit");
374
+ if (action === "check" || action === "uncheck") {
375
+ if (step.role && !CHECK_ROLES.has(step.role)) throw new JevBrowserError(`${action} role must be checkbox or radio`);
376
+ if (!step.role) step.role = "checkbox";
377
+ if (action === "uncheck" && step.role === "radio") throw new JevBrowserError("uncheck does not support radio controls");
378
+ }
379
+ if (action === "navigate_menu") {
380
+ if (!Array.isArray(rawStep.path) || rawStep.path.length === 0 || rawStep.path.length > 8) {
381
+ throw new JevBrowserError("navigate_menu path must contain between 1 and 8 menu labels");
382
+ }
383
+ step.path = rawStep.path.map((label) => requiredText(label, "menu path label", settings.jev.maxActionDescriptionChars));
384
+ step.toggleSelector = rawStep.toggle_selector === undefined
385
+ ? "#open_btn"
386
+ : requiredText(rawStep.toggle_selector, "toggle_selector", settings.jev.maxActionDescriptionChars);
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
+ }
277
400
  if (rawStep.mode !== undefined && !new Set(["fill", "keys"]).has(rawStep.mode)) {
278
401
  throw new JevBrowserError("type mode must be fill or keys");
279
402
  }
@@ -282,11 +405,16 @@ export function validateCandidatePlans(candidatePlans, settings) {
282
405
  if (step.typedTargetText !== undefined && (!step.role || !TEXT_ROLES.has(step.role))) {
283
406
  throw new JevBrowserError("type target_text needs role textbox, searchbox, or combobox");
284
407
  }
285
- step.text = rawStep.text;
286
- step.mode = rawStep.mode || "fill";
287
- step.blur = rawStep.blur ?? false;
288
- step.sensitive = rawStep.sensitive ?? true;
289
- }
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
+ }
290
418
  if (action === "wait_for_text") {
291
419
  step.text = requiredText(rawStep.text, "wait text", settings.jev.maxActionDescriptionChars);
292
420
  if (rawStep.fail_on !== undefined) {
@@ -343,9 +471,9 @@ export function validateCandidatePlans(candidatePlans, settings) {
343
471
  }
344
472
  step.sensitive = rawStep.sensitive ?? true;
345
473
  }
346
- if (action === "wait_for_condition") {
347
- if (!new Set(["network_idle", "hidden", "text_hidden", "angular_idle"]).has(rawStep.condition)) {
348
- 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");
349
477
  }
350
478
  step.condition = rawStep.condition;
351
479
  if (rawStep.condition === "hidden") {
@@ -364,26 +492,40 @@ export function validateCandidatePlans(candidatePlans, settings) {
364
492
  }
365
493
  step.text = requiredText(rawStep.text, "hidden text", settings.jev.maxActionDescriptionChars);
366
494
  step.targetText = step.text;
367
- } 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") {
368
502
  if (rawStep.url_contains !== undefined || step.role || step.name !== undefined || rawStep.text !== undefined
369
503
  || hasLocatorField(step) || step.near || step.within || rawStep.index !== undefined) {
370
504
  throw new JevBrowserError("angular_idle condition accepts only condition and timeout_seconds");
371
505
  }
372
- } else if (step.role || step.name !== undefined || rawStep.text !== undefined || hasLocatorField(step)
373
- || step.near || step.within || rawStep.index !== undefined) {
374
- throw new JevBrowserError("network_idle condition accepts only url_contains as an optional filter");
375
- }
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
+ }
376
510
  if (rawStep.condition === "network_idle" && rawStep.url_contains !== undefined) {
377
511
  step.urlContains = requiredText(rawStep.url_contains, "url_contains", settings.jev.maxActionDescriptionChars);
378
- } else if (rawStep.url_contains !== undefined) {
379
- 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");
380
514
  }
381
515
  }
382
- if (action === "audit_accessibility") {
516
+ if (action === "audit_accessibility") {
383
517
  if (typeof rawStep.standard !== "string" || !Object.hasOwn(ACCESSIBILITY_STANDARDS, rawStep.standard)) {
384
518
  throw new JevBrowserError("audit_accessibility standard must be wcag2aa");
385
519
  }
386
- 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
+ }
387
529
  }
388
530
  if (action === "press") {
389
531
  if (typeof rawStep.key !== "string" || !APPROVED_KEYS.has(rawStep.key)) {
@@ -462,7 +604,7 @@ function normalizeStepAliases(rawStep) {
462
604
  return step;
463
605
  }
464
606
 
465
- function resolveFlowOptions(rawOptions, settings) {
607
+ function resolveFlowOptions(rawOptions, settings) {
466
608
  if (rawOptions === undefined) rawOptions = {};
467
609
  if (!isRecord(rawOptions) || Object.keys(rawOptions).some((key) => !FLOW_OPTION_KEYS.has(key))) {
468
610
  throw new JevBrowserError("options contains unsupported flow settings");
@@ -471,10 +613,11 @@ function resolveFlowOptions(rawOptions, settings) {
471
613
  && rawOptions.continue_from_current_page !== rawOptions.reuse_page) {
472
614
  throw new JevBrowserError("options.continue_from_current_page and options.reuse_page cannot disagree");
473
615
  }
474
- const booleanOptions = [
475
- "block_trackers", "capture_console_errors", "capture_network_error_bodies", "capture_network_errors",
476
- "continue_from_current_page", "dry_run", "fast_path", "ready_network_idle", "reuse_page", "screenshot_on_failure", "snapshot_include_hidden",
477
- "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",
478
621
  ];
479
622
  for (const key of booleanOptions) {
480
623
  if (rawOptions[key] !== undefined && typeof rawOptions[key] !== "boolean") {
@@ -484,12 +627,86 @@ function resolveFlowOptions(rawOptions, settings) {
484
627
  const scope = rawOptions.snapshot_scope ?? settings.browser.defaults.snapshotScope;
485
628
  if (!new Set(["body", "main", "dialog"]).has(scope)) throw new JevBrowserError("options.snapshot_scope must be body, main, or dialog");
486
629
  const defaults = settings.browser.defaults;
630
+ const stepTimeoutSeconds = rawOptions.step_timeout_seconds ?? settings.browser.actionTimeoutMs / 1000;
631
+ if (typeof stepTimeoutSeconds !== "number" || !Number.isFinite(stepTimeoutSeconds) || stepTimeoutSeconds <= 0
632
+ || stepTimeoutSeconds > settings.browser.maxStepTimeoutSeconds) {
633
+ throw new JevBrowserError(`options.step_timeout_seconds must be positive and at most ${settings.browser.maxStepTimeoutSeconds}`);
634
+ }
635
+ const maxFlowSteps = rawOptions.max_flow_steps ?? settings.browser.maxFlowSteps;
636
+ if (!Number.isSafeInteger(maxFlowSteps) || maxFlowSteps <= 0 || maxFlowSteps > settings.browser.maxFlowSteps) {
637
+ throw new JevBrowserError(`options.max_flow_steps must be a positive integer no greater than ${settings.browser.maxFlowSteps}`);
638
+ }
639
+ const returnSnapshot = rawOptions.return_snapshot ?? defaults.returnSnapshot;
640
+ if (!new Set(["none", "diff", "full"]).has(returnSnapshot)) throw new JevBrowserError("options.return_snapshot must be none, diff, or full");
641
+ const normalizeListOption = (key, fallback, maxItems = settings.browser.maxBusySelectors) => {
642
+ const raw = rawOptions[key] ?? fallback;
643
+ if (!Array.isArray(raw) || raw.length > maxItems) throw new JevBrowserError(`options.${key} must be an array with at most ${maxItems} entries`);
644
+ return raw.map((value) => requiredText(value, key, settings.jev.maxActionDescriptionChars));
645
+ };
646
+ const busySelectors = normalizeListOption("busy_selectors", settings.browser.busySelectorsDefault);
647
+ const loginUrlContains = normalizeListOption("login_url_contains", settings.browser.loginUrlContainsDefault);
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");
700
+ let confirmationToken;
701
+ if (rawOptions.confirmation_token !== undefined) {
702
+ confirmationToken = requiredText(rawOptions.confirmation_token, "confirmation_token", 128);
703
+ }
487
704
  const readyTimeoutSeconds = rawOptions.ready_timeout_seconds ?? defaults.readyTimeoutSeconds;
488
705
  if (typeof readyTimeoutSeconds !== "number" || !Number.isFinite(readyTimeoutSeconds) || readyTimeoutSeconds <= 0
489
706
  || readyTimeoutSeconds > settings.browser.maxReadyTimeoutSeconds) {
490
707
  throw new JevBrowserError(`options.ready_timeout_seconds must be positive and at most ${settings.browser.maxReadyTimeoutSeconds}`);
491
708
  }
492
- const readyStableMs = rawOptions.ready_stable_ms ?? defaults.readyStableMs;
709
+ const readyStableMs = rawOptions.ready_stable_ms ?? defaults.readyStableMs;
493
710
  if (!Number.isSafeInteger(readyStableMs) || readyStableMs <= 0 || readyStableMs > settings.browser.maxReadyStableMs
494
711
  || readyStableMs > readyTimeoutSeconds * 1000) {
495
712
  throw new JevBrowserError("options.ready_stable_ms must be a positive integer within the configured readiness limits");
@@ -506,22 +723,45 @@ function resolveFlowOptions(rawOptions, settings) {
506
723
  if (!new Set([".md", ".xml"]).has(extension)) throw new JevBrowserError("options.report_path must end in .md or .xml (JUnit)");
507
724
  reportPath = rawOptions.report_path;
508
725
  }
509
- return Object.freeze({
726
+ return Object.freeze({
727
+ allowMutations: rawOptions.allow_mutations ?? false,
728
+ autoAngularIdle: rawOptions.auto_angular_idle ?? defaults.autoAngularIdle,
510
729
  blockTrackers: rawOptions.block_trackers ?? defaults.blockTrackers,
730
+ blockFonts: rawOptions.block_fonts ?? defaults.blockFonts,
731
+ busySelectors: Object.freeze(busySelectors),
511
732
  captureConsoleErrors: rawOptions.capture_console_errors ?? defaults.captureConsoleErrors,
512
733
  captureNetworkErrorBodies: rawOptions.capture_network_error_bodies ?? defaults.captureNetworkErrorBodies,
513
734
  captureNetworkErrors: rawOptions.capture_network_errors ?? defaults.captureNetworkErrors,
514
735
  dryRun: rawOptions.dry_run ?? false,
515
736
  fastPath: rawOptions.fast_path ?? defaults.fastPath,
737
+ localOnly: rawOptions.local_only ?? defaults.localOnly,
738
+ loginText: Object.freeze(loginText),
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,
752
+ maxFlowSteps,
516
753
  readyTimeoutMs: Math.ceil(readyTimeoutSeconds * 1000),
517
754
  readyNetworkIdle: rawOptions.ready_network_idle ?? defaults.readyNetworkIdle,
518
755
  readyStableMs,
519
756
  readyText,
520
757
  reportPath,
758
+ returnSnapshot,
521
759
  reusePage: rawOptions.continue_from_current_page ?? rawOptions.reuse_page ?? defaults.reusePage,
522
760
  screenshotOnFailure: rawOptions.screenshot_on_failure ?? defaults.screenshotOnFailure,
523
761
  snapshotIncludeHidden: rawOptions.snapshot_include_hidden ?? defaults.snapshotIncludeHidden,
524
762
  stopOnExpected: rawOptions.stop_on_expected ?? defaults.stopOnExpected,
763
+ stepTimeoutMs: Math.ceil(stepTimeoutSeconds * 1000),
764
+ confirmationToken,
525
765
  traceOnFailure: rawOptions.trace_on_failure ?? defaults.traceOnFailure,
526
766
  snapshotScope: scope,
527
767
  });
@@ -551,12 +791,12 @@ function planSecrets(plans) {
551
791
  return [...secrets].filter(Boolean).sort((left, right) => right.length - left.length);
552
792
  }
553
793
 
554
- function redact(value, secrets) {
794
+ function redact(value, secrets) {
555
795
  let result = value;
556
796
  for (const secret of secrets) {
557
797
  const pattern = secret.length < 4
558
- ? new RegExp(`(?<![\\p{L}\\p{N}_])${escapeRegex(secret)}(?![\\p{L}\\p{N}_])`, "giu")
559
- : 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");
560
800
  result = result.replace(pattern, "[provided form text]");
561
801
  }
562
802
  return result;
@@ -623,11 +863,110 @@ function planContentSecrets(plans) {
623
863
  .filter(Boolean).sort((left, right) => right.length - left.length);
624
864
  }
625
865
 
626
- function redactSnapshotFieldValues(snapshot, secrets) {
627
- return snapshot.split(/(\r?\n)/).map((line) => /^\s*-\s*(?:textbox|searchbox|combobox)\b/i.test(line)
628
- ? redact(line, secrets)
629
- : line).join("");
630
- }
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
+ }
631
970
 
632
971
  async function restoreHiddenDialogs(hiddenDialogs) {
633
972
  for (const { dialog, previous } of hiddenDialogs.reverse()) {
@@ -893,71 +1232,89 @@ function containsExpectedOutcome(snapshot, phrase) {
893
1232
  return containsPhrase(expectedOutcomeText(snapshot), phrase);
894
1233
  }
895
1234
 
896
- function createNetworkTracker(page, settings) {
897
- const active = new Map();
898
- const history = [];
899
- let sequence = 0;
900
- const record = (type, request) => {
901
- 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 };
902
1252
  if (type === "request") active.set(request, entry);
903
1253
  else if (type === "finished" || type === "failed") active.delete(request);
904
1254
  history.push(entry);
905
- const historyLimit = settings.browser.maxFlowSteps * 8;
1255
+ const historyLimit = settings.browser.maxNetworkHistoryEvents;
906
1256
  if (history.length > historyLimit) history.splice(0, history.length - historyLimit);
907
1257
  return entry;
908
1258
  };
909
1259
  const onRequest = (request) => record("request", request);
910
- const onResponse = (response) => {
911
- const entry = record("response", response.request());
1260
+ const onResponse = (response) => {
1261
+ const entry = record("response", response.request());
1262
+ if (!entry) return;
912
1263
  entry.response = response;
913
1264
  entry.status = response.status();
914
- };
915
- const onFinished = (request) => record("finished", request);
916
- const onFailed = (request) => record("failed", request);
917
- page.on("request", onRequest);
918
- page.on("response", onResponse);
919
- page.on("requestfinished", onFinished);
920
- page.on("requestfailed", onFailed);
921
- return {
922
- mark: () => sequence,
923
- 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))),
924
1283
  responsesSince: (marker, urlContains, method) => history.filter((entry) => entry.sequence > marker
925
1284
  && entry.type === "response" && entry.url.includes(urlContains) && entry.method === method),
926
- authenticationFailureSince: (marker) => history.find((entry) => entry.sequence > marker && entry.type === "response"
927
- && (entry.status === 401 || entry.status === 403) && /\/auth(?:\/|\?|$)/i.test(new URL(entry.url).pathname)),
928
- activeFor: (urlContains) => [...active.values()].filter((entry) => entry.url.includes(urlContains)),
929
- dispose: () => {
930
- page.off("request", onRequest);
931
- page.off("response", onResponse);
932
- page.off("requestfinished", onFinished);
933
- page.off("requestfailed", onFailed);
934
- active.clear();
935
- history.length = 0;
1285
+ authenticationFailureSince: (marker) => history.find((entry) => {
1286
+ if (entry.sequence <= marker || entry.type !== "response" || ![401, 403].includes(entry.status)) return false;
1287
+ return loginUrlContains.some((needle) => entry.url.toLocaleLowerCase().includes(needle.toLocaleLowerCase()));
1288
+ }),
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;
936
1301
  },
937
1302
  };
938
1303
  }
939
1304
 
940
- async function waitForFilteredNetworkIdle(tracker, urlContains, marker, timeout, settings) {
941
- const started = performance.now();
942
- let sawRequest = false;
943
- let lastMatchingActivity = started;
944
- const quietMs = settings.browser.defaults.readyStableMs;
945
- while (performance.now() - started < timeout) {
946
- const events = tracker.since(marker, urlContains);
947
- if (events.length) {
948
- sawRequest = sawRequest || events.some(({ type }) => type === "request");
949
- lastMatchingActivity = events.at(-1).at;
950
- }
951
- const active = tracker.activeFor(urlContains);
952
- if (active.length) sawRequest = true;
953
- if (sawRequest && active.length === 0 && performance.now() - lastMatchingActivity >= quietMs) return;
954
- await new Promise((resolve) => setTimeout(resolve, Math.min(settings.browser.visibilityPollMs, remainingMs(started + timeout))));
955
- }
956
- if (!sawRequest) throw new JevBrowserError(`network_idle did not observe a request matching url_contains ${JSON.stringify(urlContains)} within ${timeout}ms`);
957
- 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`);
958
1315
  }
959
1316
 
960
- async function waitForAngularIdle(page, timeout, settings) {
1317
+ async function waitForAngularIdle(page, timeout, settings, optional = false) {
961
1318
  const deadline = performance.now() + timeout;
962
1319
  let consecutiveIdle = 0;
963
1320
  while (performance.now() < deadline) {
@@ -981,17 +1338,22 @@ async function waitForAngularIdle(page, timeout, settings) {
981
1338
  };
982
1339
  });
983
1340
  } catch {
1341
+ if (optional) return false;
984
1342
  throw new JevBrowserError("angular_idle could not inspect the page; the AngularJS application may have navigated or closed");
985
1343
  }
986
- if (!state) throw new JevBrowserError("angular_idle requires an AngularJS 1.x page with an accessible injector");
1344
+ if (!state) {
1345
+ if (optional) return false;
1346
+ throw new JevBrowserError("angular_idle requires an AngularJS 1.x page with an accessible injector");
1347
+ }
987
1348
  consecutiveIdle = state.pending === 0 && !state.digesting && state.outstanding === 0 ? consecutiveIdle + 1 : 0;
988
1349
  if (consecutiveIdle >= 2) return;
989
1350
  await new Promise((resolve) => setTimeout(resolve, Math.min(50, remainingMs(deadline))));
990
1351
  }
1352
+ if (optional) return false;
991
1353
  throw new JevBrowserError(`AngularJS still had pending requests or digest work after ${timeout}ms`);
992
1354
  }
993
1355
 
994
- async function waitForValue(locator, expected, timeout, description, settings) {
1356
+ async function waitForValue(locator, expected, timeout, description, settings) {
995
1357
  const deadline = performance.now() + timeout;
996
1358
  while (performance.now() < deadline) {
997
1359
  try {
@@ -1090,12 +1452,46 @@ function remainingMs(deadline) {
1090
1452
  return Math.max(1, Math.ceil(deadline - performance.now()));
1091
1453
  }
1092
1454
 
1093
- function browserDisconnected(error) {
1455
+ function browserDisconnected(error) {
1094
1456
  return /target (?:page, context or browser )?has been closed|browser (?:has been closed|disconnected)|context has been closed|connection closed/i
1095
1457
  .test(String(error?.message || error?.name || ""));
1096
- }
1097
-
1098
- 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) {
1099
1495
  const started = performance.now();
1100
1496
  const deadline = started + options.readyTimeoutMs;
1101
1497
  const bestEffortLoadState = async (state) => {
@@ -1110,7 +1506,7 @@ async function waitForPageReady(page, options, settings, secrets, continued = fa
1110
1506
  if (options.readyNetworkIdle) await bestEffortLoadState("networkidle");
1111
1507
  }
1112
1508
 
1113
- let previousSnapshot = "";
1509
+ let previousStabilityKey = "";
1114
1510
  let stableSince = 0;
1115
1511
  let lastSnapshot = "";
1116
1512
  while (performance.now() < deadline) {
@@ -1119,24 +1515,25 @@ async function waitForPageReady(page, options, settings, secrets, continued = fa
1119
1515
  timeoutMs: remainingMs(deadline),
1120
1516
  includeHidden: options.snapshotIncludeHidden,
1121
1517
  });
1122
- lastSnapshot = snapshot;
1518
+ lastSnapshot = snapshot;
1519
+ const stabilityKey = await pageStructureSignature(page, snapshot);
1123
1520
  const readyTextVisible = !options.readyText || containsPhrase(snapshot, options.readyText);
1124
- const usefulSnapshot = await hasRenderedContent(page, snapshot);
1521
+ const usefulSnapshot = await hasRenderedContent(page, snapshot, options.busySelectors);
1125
1522
  if (continued && snapshot && readyTextVisible && usefulSnapshot) {
1126
1523
  return { snapshot, readyMs: performance.now() - started };
1127
1524
  }
1128
1525
  if (snapshot && readyTextVisible && usefulSnapshot) {
1129
- if (snapshot === previousSnapshot) {
1526
+ if (stabilityKey === previousStabilityKey) {
1130
1527
  if (!stableSince) stableSince = performance.now();
1131
1528
  if (performance.now() - stableSince >= options.readyStableMs) {
1132
1529
  return { snapshot, readyMs: performance.now() - started };
1133
1530
  }
1134
1531
  } else {
1135
- previousSnapshot = snapshot;
1136
- stableSince = performance.now();
1137
- }
1138
- } else {
1139
- previousSnapshot = "";
1532
+ previousStabilityKey = stabilityKey;
1533
+ stableSince = performance.now();
1534
+ }
1535
+ } else {
1536
+ previousStabilityKey = "";
1140
1537
  stableSince = 0;
1141
1538
  }
1142
1539
  await new Promise((resolve) => setTimeout(resolve, Math.min(100, remainingMs(deadline))));
@@ -1147,17 +1544,93 @@ async function waitForPageReady(page, options, settings, secrets, continued = fa
1147
1544
  throw error;
1148
1545
  }
1149
1546
 
1150
- async function hasRenderedContent(page, snapshot) {
1547
+ async function hasRenderedContent(page, snapshot, busySelectors = []) {
1151
1548
  const lines = snapshot.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
1152
1549
  if (!lines.some((line, index) => index > 0 || /:\s+\S|"[^"]+"/.test(line))) return false;
1153
- try {
1154
- 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
+ }
1155
1556
  } catch {
1156
1557
  // The snapshot itself is the fallback when the DOM is transitioning.
1157
1558
  }
1559
+ for (const selector of busySelectors) {
1560
+ try {
1561
+ const busy = page.locator(selector);
1562
+ const count = await busy.count();
1563
+ for (let index = 0; index < count; index += 1) {
1564
+ if (await isActuallyVisible(locatorAt(busy, index))) return false;
1565
+ }
1566
+ } catch {
1567
+ // A malformed or transient selector does not replace the accessible snapshot readiness signal.
1568
+ }
1569
+ }
1158
1570
  return true;
1159
1571
  }
1160
1572
 
1573
+ function sensitiveParamSecrets(variables) {
1574
+ const sensitiveName = /(?:cpf|cnpj|e?mail|telefone|celular|cliente|nome|sacado|pagador|cedente|senha|password|token|api[_-]?key|valor|amount|pre[cç]o)/i;
1575
+ return Object.entries(variables).filter(([name]) => sensitiveName.test(name)).map(([, value]) => value);
1576
+ }
1577
+
1578
+ async function waitForText(locator, expected, timeout, description, settings) {
1579
+ const normalized = expected.replace(/\s+/g, " ").trim().toLocaleLowerCase();
1580
+ const deadline = performance.now() + timeout;
1581
+ while (performance.now() < deadline) {
1582
+ try {
1583
+ const actual = (await locator.innerText({ timeout: Math.min(250, remainingMs(deadline)) }))
1584
+ .replace(/\s+/g, " ").trim().toLocaleLowerCase();
1585
+ if (actual.includes(normalized)) return;
1586
+ } catch {
1587
+ // SPA rerenders may briefly detach the target.
1588
+ }
1589
+ await new Promise((resolve) => setTimeout(resolve, Math.min(settings.browser.visibilityPollMs, remainingMs(deadline))));
1590
+ }
1591
+ throw new JevBrowserError(`${description} did not contain the expected text within ${timeout}ms`);
1592
+ }
1593
+
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
+ }
1633
+
1161
1634
  async function snapshotAfterStep(page, settings, secrets, scope, previousSnapshot, stepNumber, warnings, includeHidden) {
1162
1635
  const snapshot = await snapshotPage(page, settings, secrets, scope, { allowEmpty: true, includeHidden });
1163
1636
  if (snapshot) return snapshot;
@@ -1315,22 +1788,45 @@ async function exactlyOne(locator, description, timeout, settings) {
1315
1788
 
1316
1789
  async function scopedRoot(root, within, timeout, settings) {
1317
1790
  if (!within) return root;
1318
- let locator;
1319
- let description;
1320
- if (within.rowContaining) {
1321
- locator = root.getByRole("row").filter({ hasText: new RegExp(escapeRegex(within.rowContaining), "iu") });
1322
- description = `row containing ${JSON.stringify(within.rowContaining)}`;
1323
- } else if (within.rowContainingExact) {
1324
- locator = root.getByRole("row").filter({ has: root.getByText(within.rowContainingExact, { exact: true }) });
1325
- description = `row containing exact text ${JSON.stringify(within.rowContainingExact)}`;
1326
- } else {
1791
+ let scope = root;
1792
+ const descriptions = [];
1793
+ if (within.role) {
1327
1794
  const options = within.name === undefined ? {} : { name: within.name, exact: true };
1328
- locator = root.getByRole(within.role, options);
1329
- description = `visible ${within.role} container${within.name === undefined ? "" : ` ${JSON.stringify(within.name)}`}`;
1795
+ const container = root.getByRole(within.role, options);
1796
+ const description = `visible ${within.role} container${within.name === undefined ? "" : ` ${JSON.stringify(within.name)}`}`;
1797
+ const { visible } = await visibleIndexes(container, timeout, `within ${description}`, settings);
1798
+ if (visible.length !== 1) throw new JevBrowserError(`within ${description} must match exactly one visible container; found ${visible.length}`);
1799
+ scope = locatorAt(container, visible[0]);
1800
+ descriptions.push(description);
1801
+ }
1802
+ const rowText = within.rowContainingExact ?? within.rowContainingWord ?? within.rowContaining;
1803
+ if (rowText !== undefined) {
1804
+ const rows = scope.getByRole("row");
1805
+ const mode = within.rowContainingExact !== undefined ? "exact"
1806
+ : within.rowContainingWord !== undefined ? "whole word" : "substring";
1807
+ if (within.rowContainingWord !== undefined) {
1808
+ const pattern = new RegExp(`(?:^|[^\\p{L}\\p{N}])${escapeRegex(rowText)}(?:$|[^\\p{L}\\p{N}])`, "iu");
1809
+ const count = await rows.count();
1810
+ const matches = [];
1811
+ for (let index = 0; index < count; index += 1) {
1812
+ const row = locatorAt(rows, index);
1813
+ if (await isActuallyVisible(row) && pattern.test(await row.innerText())) matches.push(index);
1814
+ }
1815
+ const description = `row containing ${mode} text ${JSON.stringify(rowText)}`;
1816
+ if (matches.length !== 1) throw new JevBrowserError(`within ${description} must match exactly one visible row; found ${matches.length}`);
1817
+ return locatorAt(rows, matches[0]);
1818
+ }
1819
+ const matcher = within.rowContainingExact !== undefined
1820
+ ? { has: scope.getByText(rowText, { exact: true }) }
1821
+ : { hasText: new RegExp(escapeRegex(rowText), "iu") };
1822
+ const rowLocator = rows.filter(matcher);
1823
+ const description = `row containing ${mode} text ${JSON.stringify(rowText)}`;
1824
+ const { visible } = await visibleIndexes(rowLocator, timeout, `within ${description}`, settings);
1825
+ if (visible.length !== 1) throw new JevBrowserError(`within ${description} must match exactly one visible row; found ${visible.length}`);
1826
+ scope = locatorAt(rowLocator, visible[0]);
1827
+ descriptions.push(description);
1330
1828
  }
1331
- const { visible } = await visibleIndexes(locator, timeout, `within ${description}`, settings);
1332
- if (visible.length !== 1) throw new JevBrowserError(`within ${description} must match exactly one visible container; found ${visible.length}`);
1333
- return locatorAt(locator, visible[0]);
1829
+ return scope;
1334
1830
  }
1335
1831
 
1336
1832
  function accessibleNamePattern(name) {
@@ -1338,11 +1834,40 @@ function accessibleNamePattern(name) {
1338
1834
  return new RegExp(`^[\\s\\uE000-\\uF8FF]*${normalized}[\\s\\uE000-\\uF8FF]*$`, "u");
1339
1835
  }
1340
1836
 
1341
- function roleOptions(step) {
1342
- if (step.name === "") return { name: UNNAMED_ACCESSIBLE_NAME };
1343
- if (step.name === undefined) return {};
1344
- return { name: step.name, exact: true };
1345
- }
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
+ }
1346
1871
 
1347
1872
  function xpathRolePredicate(role) {
1348
1873
  const nativePredicates = {
@@ -1403,10 +1928,10 @@ async function targetLocator(root, step, timeout, settings) {
1403
1928
  locator = targetRoot.getByTestId(step.testId);
1404
1929
  } else if (step.near) {
1405
1930
  locator = relativeTarget(targetRoot.getByText(step.near.text, { exact: true }).first(), step.role);
1406
- } else if (step.role) {
1407
- locator = targetRoot.getByRole(step.role, roleOptions(step));
1408
- if (step.name && await locator.count() === 0) {
1409
- 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) });
1410
1935
  }
1411
1936
  } else {
1412
1937
  throw new JevBrowserError("step has no resolvable target; use role/name, label, placeholder, title, text, target_text, test_id, selector, or near");
@@ -1429,11 +1954,11 @@ function describeTarget(step, secrets) {
1429
1954
  }
1430
1955
  if (step.near) parts.push(`near text ${JSON.stringify(redact(step.near.text, secrets))}`);
1431
1956
  if (step.within) {
1432
- parts.push(step.within.rowContainingExact !== undefined
1433
- ? `within row_containing_exact ${JSON.stringify(redact(step.within.rowContainingExact, secrets))}`
1434
- : step.within.rowContaining !== undefined
1435
- ? `within row_containing ${JSON.stringify(redact(step.within.rowContaining, secrets))}`
1436
- : `within ${step.within.role}${step.within.name === undefined ? "" : ` ${JSON.stringify(redact(step.within.name, secrets))}`}`);
1957
+ if (step.within.role) parts.push(`within ${step.within.role}${step.within.name === undefined ? "" : ` ${JSON.stringify(redact(step.within.name, secrets))}`}`);
1958
+ const rowPrefix = step.within.role ? "" : "within ";
1959
+ if (step.within.rowContainingExact !== undefined) parts.push(`${rowPrefix}row_containing_exact ${JSON.stringify(redact(step.within.rowContainingExact, secrets))}`);
1960
+ else if (step.within.rowContainingWord !== undefined) parts.push(`${rowPrefix}row_containing_word ${JSON.stringify(redact(step.within.rowContainingWord, secrets))}`);
1961
+ else if (step.within.rowContaining !== undefined) parts.push(`${rowPrefix}row_containing ${JSON.stringify(redact(step.within.rowContaining, secrets))}`);
1437
1962
  }
1438
1963
  if (step.index !== undefined) parts.push(`index ${step.index}`);
1439
1964
  return parts.length ? parts.join(" + ") : "focused element";
@@ -1515,8 +2040,8 @@ async function namedTarget(root, step, snapshot, secrets, timeout, settings) {
1515
2040
  if (step.fragileName && step.action === "click") {
1516
2041
  const title = await selected.evaluate((element) => [element, ...element.querySelectorAll("[title]")]
1517
2042
  .map((candidate) => candidate.getAttribute("title") || "").find(Boolean) || "").catch(() => "");
1518
- if (/\b(cancel|delete|remove|void|cancelar|excluir|apagar|remover|estornar)\b/i.test(title)) {
1519
- throw new JevBrowserError(`refused index-only click on a destructive control titled ${JSON.stringify(redact(title, secrets))}; provide a stable locator`);
2043
+ if (MUTATING_LABEL.test(title)) {
2044
+ throw new JevBrowserError(`refused index-only click on a mutating control titled ${JSON.stringify(redact(title, secrets))}; provide a stable locator`);
1520
2045
  }
1521
2046
  }
1522
2047
  return selected;
@@ -1716,9 +2241,61 @@ async function saveDownload(download, settings, ledger) {
1716
2241
  }
1717
2242
  }
1718
2243
 
1719
- async function executeStep(page, step, settings, snapshot, secrets, downloadLedger, execution = {}) {
1720
- const root = await stepRoot(page, step.frame);
1721
- const timeout = step.timeoutMs ?? settings.browser.actionTimeoutMs;
2244
+ async function visibleMenuItem(root, label, timeout, settings) {
2245
+ for (const role of ["menuitem", "link", "button"]) {
2246
+ const locator = root.getByRole(role, { name: label, exact: true });
2247
+ try {
2248
+ const { visible } = await visibleIndexes(locator, Math.min(timeout, 250), `menu ${role} ${JSON.stringify(label)}`, settings);
2249
+ if (visible.length > 0) {
2250
+ // Nested application menus often reuse the same label for a parent and its newly opened leaf.
2251
+ return locatorAt(locator, visible.at(-1));
2252
+ }
2253
+ } catch {
2254
+ // Try the next semantic role before falling back to exact visible text.
2255
+ }
2256
+ }
2257
+ const textLocator = root.getByText(label, { exact: true });
2258
+ const { visible } = await visibleIndexes(textLocator, Math.min(timeout, 250), `menu text ${JSON.stringify(label)}`, settings);
2259
+ if (visible.length === 0) throw new JevBrowserError(`menu item ${JSON.stringify(label)} must match a visible control; found 0`);
2260
+ return locatorAt(textLocator, visible.at(-1));
2261
+ }
2262
+
2263
+ async function navigateMenu(page, root, step, settings, execution) {
2264
+ let item;
2265
+ try {
2266
+ item = await visibleMenuItem(root, step.path[0], 250, settings);
2267
+ } catch {
2268
+ const toggle = page.locator(step.toggleSelector);
2269
+ const { visible } = await visibleIndexes(toggle, 250, `menu toggle ${step.toggleSelector}`, settings);
2270
+ if (visible.length !== 1) throw new JevBrowserError(`menu toggle ${step.toggleSelector} must match exactly one visible control; found ${visible.length}`);
2271
+ await locatorAt(toggle, visible[0]).click({ timeout: step.timeoutMs ?? settings.browser.actionTimeoutMs });
2272
+ if (execution.autoAngularIdle) await waitForAngularIdle(page, step.timeoutMs ?? settings.browser.actionTimeoutMs, settings, true);
2273
+ item = await visibleMenuItem(root, step.path[0], step.timeoutMs ?? settings.browser.actionTimeoutMs, settings);
2274
+ }
2275
+ for (let index = 0; index < step.path.length; index += 1) {
2276
+ if (index > 0) item = await visibleMenuItem(root, step.path[index], step.timeoutMs ?? settings.browser.actionTimeoutMs, settings);
2277
+ await item.click({ timeout: step.timeoutMs ?? settings.browser.actionTimeoutMs });
2278
+ if (execution.autoAngularIdle) await waitForAngularIdle(page, step.timeoutMs ?? settings.browser.actionTimeoutMs, settings, true);
2279
+ }
2280
+ return { action: step.action, navigated_menu: step.path };
2281
+ }
2282
+
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
+ }
1722
2299
  if (step.action === "assert_network") {
1723
2300
  const response = await waitForNetworkAssertion(
1724
2301
  execution.networkTracker,
@@ -1732,6 +2309,19 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
1732
2309
  return { action: step.action, asserted: true, url: evidenceUrl(response.url, secrets, settings.jev.maxDiagnosticChars), method: response.method, response_status: response.status, ...(response.message ? { message: response.message } : {}) };
1733
2310
  }
1734
2311
  if (step.action === "confirm_dialog") {
2312
+ const native = execution.nativeDialog?.result;
2313
+ if (native) {
2314
+ const expectedText = step.expectedText.replace(/\s+/g, " ").trim();
2315
+ if (!native.matched || native.handlerError || !native.message.toLocaleLowerCase().includes(expectedText.toLocaleLowerCase())) {
2316
+ throw new JevBrowserError(`confirm_dialog refused to act: native dialog text did not match ${JSON.stringify(redact(expectedText, secrets))}`);
2317
+ }
2318
+ return {
2319
+ action: step.action,
2320
+ confirmed_text: redact(expectedText, secrets),
2321
+ native_dialog: true,
2322
+ button: redact(step.button, secrets),
2323
+ };
2324
+ }
1735
2325
  const dialog = await exactlyOne(root.getByRole("dialog"), "confirmation dialog", timeout, settings);
1736
2326
  const actualText = (await dialog.innerText({ timeout })).replace(/\s+/g, " ").trim();
1737
2327
  const expectedText = step.expectedText.replace(/\s+/g, " ").trim();
@@ -1783,29 +2373,29 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
1783
2373
  await waitForAngularIdle(page, timeout, settings);
1784
2374
  return { action: step.action, condition: step.condition };
1785
2375
  }
1786
- if (step.action === "wait_for_condition") {
1787
- if (step.condition === "network_idle") {
1788
- if (step.urlContains) {
1789
- await waitForFilteredNetworkIdle(
1790
- execution.networkTracker,
1791
- step.urlContains,
1792
- execution.networkMarker,
1793
- timeout,
1794
- settings,
1795
- );
1796
- } else {
1797
- await root.waitForLoadState("networkidle", { timeout });
1798
- }
1799
- } 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 {
1800
2389
  const locator = await targetLocator(root, step, timeout, settings);
1801
2390
  await waitForLocatorHidden(locator, timeout, `hidden condition ${describeTarget(step, secrets)}`, settings, step.index);
1802
2391
  }
1803
2392
  return {
1804
2393
  action: step.action,
1805
- condition: step.condition,
1806
- ...(step.condition === "network_idle"
1807
- ? (step.urlContains ? { url_contains: redact(step.urlContains, secrets) } : {})
1808
- : { 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) }),
1809
2399
  };
1810
2400
  }
1811
2401
  if (step.action === "press") {
@@ -1822,14 +2412,18 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
1822
2412
  if (step.action === "like_comment" || step.action === "unlike_comment") {
1823
2413
  return { action: step.action, result: await reactToComment(root, step, timeout, settings) };
1824
2414
  }
1825
- if (step.action === "audit_accessibility") {
1826
- return {
1827
- action: step.action,
1828
- accessibility_audit: await withTimeout(
1829
- runAccessibilityAudit(page, step.standard, settings),
1830
- timeout,
1831
- `accessibility audit exceeded the ${timeout}ms step timeout`,
1832
- ),
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,
1833
2427
  };
1834
2428
  }
1835
2429
  if (step.action === "upload_file") {
@@ -1881,11 +2475,46 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
1881
2475
  await waitForEnabled(control, timeout, describeTarget(step, secrets), settings);
1882
2476
  return { action: step.action, enabled: true, ...targetEvidence(step, secrets, await describeResolvedTarget(control, step, secrets, settings)) };
1883
2477
  }
1884
- const control = await namedTarget(root, step, snapshot, secrets, timeout, settings);
2478
+ if (step.action === "navigate_menu") return navigateMenu(page, root, step, settings, execution);
2479
+ const control = execution.preResolvedTarget || await namedTarget(root, step, snapshot, secrets, timeout, settings);
2480
+ if (step.action === "check" || step.action === "uncheck") {
2481
+ const resolvedTarget = await describeResolvedTarget(control, step, secrets, settings);
2482
+ if (step.action === "check") await control.check({ timeout });
2483
+ else await control.uncheck({ timeout });
2484
+ return { action: step.action, checked: step.action === "check", ...targetEvidence(step, secrets, resolvedTarget) };
2485
+ }
1885
2486
  if (step.action === "click") {
1886
2487
  const resolvedTarget = await describeResolvedTarget(control, step, secrets, settings);
1887
- if (!step.expectDownload) {
1888
- 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);
2515
+ if (execution.nativeDialog?.result && !execution.nativeDialog.result.matched) {
2516
+ throw new JevBrowserError(`unexpected native ${execution.nativeDialog.result.type} dialog was dismissed: ${JSON.stringify(sanitizeDiagnosticText(execution.nativeDialog.result.message, secrets, settings.jev.maxDiagnosticChars))}`);
2517
+ }
1889
2518
  return { action: step.action, ...targetEvidence(step, secrets, resolvedTarget) };
1890
2519
  }
1891
2520
  let download;
@@ -1903,7 +2532,7 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
1903
2532
  downloaded_file: await saveDownload(download, settings, downloadLedger),
1904
2533
  };
1905
2534
  }
1906
- if (step.action === "type") {
2535
+ if (step.action === "type") {
1907
2536
  const resolvedTarget = await describeResolvedTarget(control, step, secrets, settings);
1908
2537
  if (step.mode === "keys") {
1909
2538
  await control.fill("", { timeout });
@@ -1928,10 +2557,25 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
1928
2557
  name: step.name,
1929
2558
  ...targetEvidence(step, secrets, resolvedTarget),
1930
2559
  };
1931
- }
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
+ }
1932
2566
  if (step.action === "select_option") {
1933
2567
  const resolvedTarget = await describeResolvedTarget(control, step, secrets, settings);
1934
- await control.selectOption({ label: step.option }, { timeout });
2568
+ if (step.optionMatch === "value") {
2569
+ await control.selectOption(step.option, { timeout });
2570
+ } else if (step.optionMatch === "label_contains") {
2571
+ const matches = await control.locator("option").evaluateAll((options, text) => options
2572
+ .filter((option) => String(option.label || option.textContent || "").toLocaleLowerCase().includes(text.toLocaleLowerCase()))
2573
+ .map((option) => ({ label: String(option.label || option.textContent || "").trim(), value: String(option.value) })), step.option);
2574
+ if (matches.length !== 1) throw new JevBrowserError(`select_option label_contains must match one option; found ${matches.length}`);
2575
+ await control.selectOption(matches[0].value, { timeout });
2576
+ } else {
2577
+ await control.selectOption({ label: step.option }, { timeout });
2578
+ }
1935
2579
  return { action: step.action, ...targetEvidence(step, secrets, resolvedTarget) };
1936
2580
  }
1937
2581
  if (step.action === "hover") {
@@ -1948,11 +2592,7 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
1948
2592
  };
1949
2593
  }
1950
2594
  if (step.action === "assert_text") {
1951
- const actual = (await control.innerText({ timeout })).replace(/\s+/g, " ").trim();
1952
- const expected = step.expected.replace(/\s+/g, " ").trim();
1953
- if (!actual.toLocaleLowerCase().includes(expected.toLocaleLowerCase())) {
1954
- throw new JevBrowserError(`assert_text failed for ${describeTarget(step, secrets)}`);
1955
- }
2595
+ await waitForText(control, step.expected, timeout, `assert_text for ${describeTarget(step, secrets)}`, settings);
1956
2596
  return {
1957
2597
  action: step.action,
1958
2598
  asserted: true,
@@ -1960,8 +2600,7 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
1960
2600
  };
1961
2601
  }
1962
2602
  if (step.action === "assert_value") {
1963
- const actual = await control.inputValue({ timeout });
1964
- if (actual !== step.expected) throw new JevBrowserError(`assert_value failed for ${describeTarget(step, secrets)}`);
2603
+ await waitForValue(control, step.expected, timeout, `assert_value for ${describeTarget(step, secrets)}`, settings);
1965
2604
  return {
1966
2605
  action: step.action,
1967
2606
  asserted: true,
@@ -1975,6 +2614,24 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
1975
2614
  throw new JevBrowserError("unsupported browser action");
1976
2615
  }
1977
2616
 
2617
+ async function inferTargetMutation(page, step, snapshot, settings, secrets) {
2618
+ if (step.action !== "click") return { mutating: step.mutating === true };
2619
+ const root = await stepRoot(page, step.frame);
2620
+ const target = await namedTarget(root, step, snapshot, secrets, step.timeoutMs ?? settings.browser.actionTimeoutMs, settings);
2621
+ if (step.mutating === true) return { mutating: true, target };
2622
+ if (typeof target?.evaluate !== "function") return { mutating: false, target };
2623
+ const label = await target.evaluate((element) => [
2624
+ element.getAttribute("aria-label"),
2625
+ element.getAttribute("title"),
2626
+ element.getAttribute("value"),
2627
+ element.innerText,
2628
+ element.textContent,
2629
+ ...[...element.querySelectorAll("[title]")].map((child) => child.getAttribute("title")),
2630
+ ].filter(Boolean).join(" "));
2631
+ if (MUTATING_LABEL.test(label)) step.mutating = true;
2632
+ return { mutating: step.mutating === true, target };
2633
+ }
2634
+
1978
2635
  function flowTimings(started, browserSessionMs, navigationMs, initialSnapshotMs, readyMs, jevDecisionMs, browserPlanMs) {
1979
2636
  return {
1980
2637
  browser_session_ms: Math.round(browserSessionMs * 10) / 10,
@@ -1987,9 +2644,20 @@ function flowTimings(started, browserSessionMs, navigationMs, initialSnapshotMs,
1987
2644
  };
1988
2645
  }
1989
2646
 
1990
- function addDiagnostic(collection, value, limit) {
1991
- if (collection.length < limit) collection.push(value);
1992
- }
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
+ }
1993
2661
 
1994
2662
  function evidenceUrl(value, secrets, limit) {
1995
2663
  try {
@@ -2012,17 +2680,22 @@ function sanitizeDiagnosticText(value, secrets, limit) {
2012
2680
  return result.slice(0, limit);
2013
2681
  }
2014
2682
 
2015
- function createDiagnostics(page, options, settings, secrets) {
2683
+ function createDiagnostics(page, options, settings, secrets) {
2016
2684
  const diagnostics = { console_errors: [], network_failures: [], warnings: [] };
2017
2685
  const pendingBodies = [];
2018
2686
  const limit = settings.jev.maxDiagnosticItems;
2019
- const messageLimit = settings.jev.maxDiagnosticChars;
2020
- const onConsole = (message) => {
2021
- if (message.type() !== "error") return;
2022
- addDiagnostic(diagnostics.console_errors, sanitizeDiagnosticText(message.text(), secrets, messageLimit), limit);
2023
- };
2024
- const onPageError = (error) => {
2025
- 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));
2026
2699
  };
2027
2700
  const addResponseMessage = async (response, entry) => {
2028
2701
  const warn = (reason) => addDiagnostic(
@@ -2147,11 +2820,15 @@ function trackerUrl(url, settings) {
2147
2820
  return settings.browser.trackerHostSuffixes.some((suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`));
2148
2821
  }
2149
2822
 
2150
- async function installTrackerBlocking(context, settings, enabled) {
2151
- if (!enabled) return undefined;
2823
+ async function installTrackerBlocking(context, settings, enabled, blockFonts = false, allowMedia = false) {
2824
+ if (!enabled && !blockFonts) return undefined;
2152
2825
  const handler = async (route) => {
2153
2826
  const request = route.request();
2154
- if (trackerUrl(request.url(), settings) || settings.browser.blockedResourceTypes.includes(request.resourceType())) {
2827
+ const resourceType = request.resourceType();
2828
+ if ((enabled && trackerUrl(request.url(), settings))
2829
+ || (enabled && settings.browser.blockedResourceTypes.includes(resourceType)
2830
+ && (resourceType !== "font" || blockFonts) && (resourceType !== "media" || !allowMedia))
2831
+ || (blockFonts && resourceType === "font")) {
2155
2832
  await route.abort("blockedbyclient");
2156
2833
  return;
2157
2834
  }
@@ -2237,20 +2914,40 @@ function reportContent(result, extension) {
2237
2914
  ].join("\n");
2238
2915
  }
2239
2916
 
2240
- async function writeFlowReport(result, reportPath, settings) {
2917
+ async function writeFlowReport(result, reportPath, settings) {
2241
2918
  const outputPath = await safeReportPath(reportPath, settings);
2242
2919
  await writeFile(outputPath, reportContent(result, path.extname(outputPath).toLowerCase()), { encoding: "utf8", flag: "w" });
2243
2920
  return outputPath;
2244
- }
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
+ }
2245
2942
 
2246
2943
  async function redactObject(value, secrets) {
2247
2944
  if (typeof value === "string") return redact(value, secrets);
2248
2945
  if (Array.isArray(value)) return Promise.all(value.map((item) => redactObject(item, secrets)));
2249
2946
  if (isRecord(value)) {
2250
2947
  return Object.fromEntries(await Promise.all(Object.entries(value).map(async ([key, item]) => [
2251
- key,
2252
- key === "final_snapshot" && typeof item === "string"
2253
- ? redactSnapshotFieldValues(item, secrets)
2948
+ key,
2949
+ key === "final_snapshot" && typeof item === "string"
2950
+ ? redactSnapshotFieldValues(redact(item, secrets), secrets)
2254
2951
  : await redactObject(item, secrets),
2255
2952
  ])));
2256
2953
  }
@@ -2261,22 +2958,50 @@ function hasAssertions(steps) {
2261
2958
  return steps.some(({ action }) => action.startsWith("assert_"));
2262
2959
  }
2263
2960
 
2264
- export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, candidatePlans, params: rawParams, options: rawOptions, settings, browserPool, fetchImpl = fetch, serverVersion }) {
2961
+ function snapshotDiff(initialSnapshot, finalSnapshot) {
2962
+ const remaining = new Map();
2963
+ for (const line of initialSnapshot.split(/\r?\n/).filter(Boolean)) remaining.set(line, (remaining.get(line) || 0) + 1);
2964
+ const added = [];
2965
+ for (const line of finalSnapshot.split(/\r?\n/).filter(Boolean)) {
2966
+ const count = remaining.get(line) || 0;
2967
+ if (count > 0) remaining.set(line, count - 1);
2968
+ else added.push(line);
2969
+ }
2970
+ return added.length ? added.join("\n") : "[no new snapshot lines]";
2971
+ }
2972
+
2973
+ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, candidatePlans, params: rawParams, options: rawOptions, settings, browserPool, fetchImpl = fetch, serverVersion, mutationAuthorizationStore = defaultMutationAuthorizationStore }) {
2265
2974
  if (typeof flow !== "string" || typeof expectedOutcome !== "string") throw new JevBrowserError("flow and expected_outcome must be text");
2266
- const variables = validateParams(rawParams, settings);
2975
+ let variables = validateParams(rawParams, settings);
2267
2976
  const requestedFlow = replaceKnownParams(flow, variables).trim();
2268
2977
  const outcome = replaceKnownParams(expectedOutcome, variables).trim();
2269
2978
  if (!requestedFlow || !outcome) throw new JevBrowserError("flow and expected_outcome must not be empty");
2270
2979
  if (requestedFlow.length > settings.jev.maxFlowChars) throw new JevBrowserError("flow exceeds the configured character limit");
2271
2980
  if (outcome.length > settings.jev.maxActionDescriptionChars) throw new JevBrowserError("expected_outcome exceeds the configured character limit");
2272
2981
  const url = validateUrl(initialUrl);
2273
- const plans = validateCandidatePlans(replaceKnownParams(candidatePlans, variables), settings);
2274
- const options = resolveFlowOptions(rawOptions, settings);
2275
- const secrets = [...planSecrets(plans), settings.env?.[settings.jev.credentialEnv]].filter(Boolean)
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
+ }
2995
+ const fingerprint = flowFingerprint({ flow, initialUrl: url, expectedOutcome, candidatePlans, params: rawParams, options });
2996
+ const continuation = options.confirmationToken
2997
+ ? mutationAuthorizationStore.consume(options.confirmationToken, fingerprint)
2998
+ : undefined;
2999
+ const secrets = [...planSecrets(plans), ...sensitiveParamSecrets(variables), settings.env?.[settings.jev.credentialEnv]].filter(Boolean)
2276
3000
  .sort((left, right) => right.length - left.length);
2277
- const contentSecrets = planContentSecrets(plans);
2278
- const started = performance.now();
2279
- 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);
2280
3005
  const browserSessionMs = browser.sessionMs;
2281
3006
  const page = browser.page;
2282
3007
  const context = browser.context;
@@ -2294,29 +3019,64 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2294
3019
  unnamed_controls: [],
2295
3020
  unnamed_controls_initial: [],
2296
3021
  unnamed_controls_final: [],
2297
- warnings: [],
3022
+ warnings: continuation?.warnings || [],
2298
3023
  final_snapshot: "",
2299
3024
  };
3025
+ result.snapshot_mode = options.returnSnapshot;
3026
+ if (options.allowMutations) result.mutation_authorization = "allowed_on_localhost";
2300
3027
  const downloadLedger = { count: 0, bytes: 0 };
2301
- const networkTracker = createNetworkTracker(page, settings);
3028
+ const networkTracker = createNetworkTracker(page, settings, options.loginUrlContains);
2302
3029
  const initialNetworkMarker = networkTracker.mark();
2303
3030
  const timings = { navigationMs: 0, initialSnapshotMs: 0, readyMs: 0, decisionMs: 0, browserPlanMs: 0 };
2304
3031
  let unroute;
2305
3032
  let tracingStarted = false;
2306
3033
  let tracePath;
2307
- const artifactErrors = [];
2308
- let navigationError;
3034
+ const artifactErrors = [];
3035
+ let navigationError;
3036
+ let initialSnapshot = "";
3037
+ let popupPage;
3038
+ let activePage = page;
3039
+ const nativeDialog = { expectedStep: undefined, result: undefined };
3040
+ const onNativeDialog = async (dialog) => {
3041
+ const expectedStep = nativeDialog.expectedStep;
3042
+ const message = String(dialog.message() || "").replace(/\s+/g, " ").trim();
3043
+ const expectedText = expectedStep?.expectedText?.replace(/\s+/g, " ").trim();
3044
+ const matches = Boolean(expectedText && message.toLocaleLowerCase().includes(expectedText.toLocaleLowerCase()));
3045
+ const affirmative = /^(?:sim|yes|ok|okay|confirm|confirmar|aceitar)$/i.test(expectedStep?.button || "");
3046
+ nativeDialog.result = {
3047
+ message,
3048
+ expectedText,
3049
+ matched: matches,
3050
+ button: expectedStep?.button,
3051
+ type: dialog.type(),
3052
+ };
3053
+ try {
3054
+ if (matches && (dialog.type() === "alert" || affirmative)) await dialog.accept();
3055
+ else await dialog.dismiss();
3056
+ } catch {
3057
+ nativeDialog.result.handlerError = true;
3058
+ }
3059
+ };
3060
+ page.on("dialog", onNativeDialog);
2309
3061
  try {
2310
3062
  page.setDefaultTimeout(settings.browser.actionTimeoutMs);
2311
3063
  context.setDefaultTimeout(settings.browser.actionTimeoutMs);
2312
- unroute = await installTrackerBlocking(context, settings, options.blockTrackers);
2313
- if (options.traceOnFailure) {
2314
- 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 {
2315
3073
  await context.tracing.start({ screenshots: true, snapshots: true });
2316
3074
  tracingStarted = true;
2317
3075
  } catch {
2318
- artifactErrors.push("trace recording could not be started");
2319
- }
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);
2320
3080
  }
2321
3081
 
2322
3082
  let reusedPage = false;
@@ -2340,6 +3100,9 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2340
3100
  } else {
2341
3101
  reusedPage = true;
2342
3102
  result.navigation_strategy = "continued_current_page";
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");
3105
+ }
2343
3106
  }
2344
3107
  }
2345
3108
  }
@@ -2360,18 +3123,33 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2360
3123
  const readiness = await waitForPageReady(page, options, settings, secrets, reusedPage);
2361
3124
  const enriched = await enrichSnapshot(page, readiness.snapshot, settings, secrets, options.snapshotScope, options.snapshotIncludeHidden);
2362
3125
  result.final_snapshot = enriched.snapshot;
3126
+ initialSnapshot = continuation?.initialSnapshot || enriched.snapshot;
2363
3127
  result.unnamed_controls = enriched.unnamedControls;
2364
3128
  result.unnamed_controls_initial = enriched.unnamedControls;
2365
3129
  result.unnamed_controls_final = enriched.unnamedControls;
3130
+ if (options.loginText.some((needle) => result.final_snapshot.toLocaleLowerCase().includes(needle.toLocaleLowerCase()))) {
3131
+ navigationError = "AUTHENTICATION_REQUIRED";
3132
+ throw new JevBrowserError("the configured login text is visible; sign in to the application, then run the flow again");
3133
+ }
2366
3134
  for (const control of enriched.unnamedControls) {
2367
3135
  addDiagnostic(
2368
- result.warnings,
2369
- `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`,
2370
3138
  settings.jev.maxDiagnosticItems,
2371
3139
  );
2372
3140
  }
2373
3141
  timings.readyMs = readiness.readyMs;
2374
3142
  browserPool.setNavigationError?.(null);
3143
+ if (continuation) {
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");
3147
+ }
3148
+ if (!Object.hasOwn(plans, continuation.planId)) {
3149
+ throw new JevBrowserError("confirmation_token refers to a candidate plan that is no longer available");
3150
+ }
3151
+ variables = { ...continuation.variables };
3152
+ }
2375
3153
  } catch (error) {
2376
3154
  timings.readyMs = error.readyMs || performance.now() - snapshotStarted;
2377
3155
  throw error;
@@ -2386,7 +3164,9 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2386
3164
  } else {
2387
3165
  let planId;
2388
3166
  let decision;
2389
- if (options.fastPath && Object.keys(plans).length === 1) {
3167
+ if (continuation) {
3168
+ planId = continuation.planId;
3169
+ } else if (options.fastPath && Object.keys(plans).length === 1) {
2390
3170
  [planId] = Object.keys(plans);
2391
3171
  } else {
2392
3172
  const planIdByDecisionId = new Map();
@@ -2398,7 +3178,7 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2398
3178
  const decisionFlow = redact(`${requestedFlow}\nExpected outcome: ${outcome}`, secrets);
2399
3179
  const { request, criteria } = buildChoicePayload({
2400
3180
  flow: decisionFlow,
2401
- pageSnapshot: redact(result.final_snapshot, contentSecrets),
3181
+ pageSnapshot: providerSafeSnapshot(result.final_snapshot, [...secrets, ...contentSecrets]),
2402
3182
  actions: descriptions,
2403
3183
  answerName: "browser_plan",
2404
3184
  allowComplete: options.stopOnExpected,
@@ -2422,44 +3202,99 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2422
3202
 
2423
3203
  if (planId) {
2424
3204
  const plan = plans[planId];
2425
- const trace = [];
3205
+ const trace = continuation ? [...continuation.trace] : [];
2426
3206
  let failure = "";
2427
3207
  let environmentFailure = false;
2428
3208
  let failedStep;
2429
- let dryRunStop;
3209
+ let mutationStop;
3210
+ let issuedToken;
2430
3211
  let snapshot = result.final_snapshot;
2431
3212
  let networkMarker = networkTracker.mark();
3213
+ result.downloaded_files = continuation?.downloadedFiles || [];
3214
+ result.accessibility_audits = continuation?.accessibilityAudits || [];
3215
+ if (continuation?.downloadLedger) Object.assign(downloadLedger, continuation.downloadLedger);
2432
3216
  const planStarted = performance.now();
2433
- const runAllSteps = plan.steps.some(({ action, expectDownload }) => action.startsWith("assert_")
2434
- || ["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)
2435
3219
  || expectDownload === true);
2436
- for (const [index, templateStep] of plan.steps.entries()) {
3220
+ let approvedMutationIndex = continuation?.nextStep;
3221
+ for (let index = continuation?.nextStep || 0; index < plan.steps.length; index += 1) {
3222
+ const templateStep = plan.steps[index];
2437
3223
  const stepStarted = performance.now();
2438
3224
  let step = templateStep;
2439
- try {
2440
- step = interpolateStep(templateStep, variables);
2441
- if (options.dryRun && step.mutating === true) {
2442
- dryRunStop = { step: String(index + 1), action: step.action };
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);
3234
+ const dynamicallyMutating = mutationInspection.mutating;
3235
+ const pairedNativeConfirmation = step.action === "confirm_dialog" && nativeDialog.result?.matched === true;
3236
+ if (dynamicallyMutating && !pairedNativeConfirmation && index !== approvedMutationIndex
3237
+ && !(options.allowMutations && !options.dryRun)) {
3238
+ mutationStop = { step: String(index + 1), action: step.action };
3239
+ const enriched = await enrichSnapshot(stepPage, snapshot, settings, secrets, options.snapshotScope, options.snapshotIncludeHidden);
3240
+ snapshot = enriched.snapshot;
3241
+ issuedToken = mutationAuthorizationStore.issue({
3242
+ fingerprint,
3243
+ planId,
3244
+ nextStep: index,
3245
+ variables: { ...variables },
3246
+ trace: [...trace],
3247
+ snapshot,
3248
+ initialSnapshot,
3249
+ pageUrl: typeof page.url === "function" ? page.url() : "unavailable",
3250
+ pageFingerprint: await pageFingerprint(page),
3251
+ downloadedFiles: [...result.downloaded_files],
3252
+ accessibilityAudits: [...result.accessibility_audits],
3253
+ warnings: [...result.warnings],
3254
+ consoleErrors: [...(continuation?.consoleErrors || []), ...capture.diagnostics.console_errors],
3255
+ networkFailures: [...(continuation?.networkFailures || []), ...capture.diagnostics.network_failures],
3256
+ downloadLedger: { ...downloadLedger },
3257
+ }, settings.browser.mutationConfirmationTtlMs);
2443
3258
  break;
2444
3259
  }
3260
+ if (index === approvedMutationIndex) approvedMutationIndex = undefined;
2445
3261
  if (step.fragileName) addDiagnostic(result.warnings, `step ${index + 1} uses an empty accessible name with index ${step.index}; this target is fragile`, settings.jev.maxDiagnosticItems);
2446
3262
  if (step.fragileSelector) addDiagnostic(result.warnings, `step ${index + 1} uses an explicit CSS/XPath selector as a last resort`, settings.jev.maxDiagnosticItems);
2447
- const actionMarker = networkTracker.mark();
2448
- const traceStep = await executeStep(page, step, settings, snapshot, secrets, downloadLedger, {
2449
- networkTracker,
2450
- networkMarker,
2451
- variables,
2452
- });
3263
+ if (step.action === "click" && plan.steps[index + 1]?.action === "confirm_dialog") {
3264
+ nativeDialog.expectedStep = interpolateStep(plan.steps[index + 1], variables);
3265
+ nativeDialog.result = undefined;
3266
+ } else if (step.action !== "confirm_dialog") {
3267
+ nativeDialog.expectedStep = undefined;
3268
+ nativeDialog.result = undefined;
3269
+ }
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);
3287
+ }
2453
3288
  if (!["wait_for_text", "wait_for_condition", "assert_text", "assert_value", "assert_visible", "assert_hidden", "audit_accessibility"].includes(step.action)) {
2454
3289
  networkMarker = actionMarker;
2455
3290
  }
2456
3291
  traceStep.step = String(index + 1);
2457
3292
  traceStep.status = "passed";
2458
3293
  if (step.mutating === true) traceStep.mutating = true;
2459
- if (step.screenshot === true) {
2460
- try {
2461
- traceStep.screenshot_path = await artifactPath(settings, "png");
2462
- 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 });
2463
3298
  } catch {
2464
3299
  delete traceStep.screenshot_path;
2465
3300
  artifactErrors.push(`step ${index + 1} screenshot could not be saved`);
@@ -2468,14 +3303,14 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2468
3303
  traceStep.duration_ms = Math.round((performance.now() - stepStarted) * 10) / 10;
2469
3304
  trace.push(traceStep);
2470
3305
  if (traceStep.downloaded_file) result.downloaded_files.push(traceStep.downloaded_file);
2471
- if (traceStep.accessibility_audit) {
2472
- result.accessibility_audits.push(traceStep.accessibility_audit);
2473
- if (traceStep.accessibility_audit.violations_count > 0) {
2474
- 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`;
2475
3310
  }
2476
3311
  }
2477
- snapshot = await snapshotAfterStep(
2478
- page,
3312
+ snapshot = await snapshotAfterStep(
3313
+ stepPage,
2479
3314
  settings,
2480
3315
  secrets,
2481
3316
  options.snapshotScope,
@@ -2504,9 +3339,9 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2504
3339
  break;
2505
3340
  }
2506
3341
  }
2507
- if (failure || trace.length === 0) {
2508
- snapshot = await snapshotAfterStep(
2509
- page,
3342
+ if (failure || trace.length === 0) {
3343
+ snapshot = await snapshotAfterStep(
3344
+ activePage,
2510
3345
  settings,
2511
3346
  secrets,
2512
3347
  options.snapshotScope,
@@ -2516,8 +3351,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2516
3351
  options.snapshotIncludeHidden,
2517
3352
  );
2518
3353
  }
2519
- const finalDetails = await enrichSnapshot(
2520
- page,
3354
+ const finalDetails = await enrichSnapshot(
3355
+ activePage,
2521
3356
  snapshot,
2522
3357
  settings,
2523
3358
  secrets,
@@ -2532,24 +3367,26 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2532
3367
  const assertionCount = plan.steps.filter(({ action }) => action.startsWith("assert_")).length;
2533
3368
  const assertionsPassed = assertionCount > 0 && !failure && trace.length === plan.steps.length;
2534
3369
  const auditCount = plan.steps.filter(({ action }) => action === "audit_accessibility").length;
2535
- const auditsPassed = auditCount > 0 && !failure && result.accessibility_audits.length === auditCount
2536
- && 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)
2537
3372
  && trace.length === plan.steps.length;
2538
3373
  const expectedDownloadCount = plan.steps.filter(({ action, expectDownload }) => action === "click" && expectDownload).length;
2539
3374
  const downloadsPassed = expectedDownloadCount > 0 && !failure
2540
3375
  && result.downloaded_files.length === expectedDownloadCount && trace.length === plan.steps.length;
2541
3376
  result.status = failure ? environmentFailure ? "environment_error" : "failed"
2542
- : dryRunStop ? "dry_run" : visible || assertionsPassed || auditsPassed || downloadsPassed ? "passed" : "incomplete";
3377
+ : mutationStop ? options.dryRun ? "dry_run" : "confirmation_required" : visible || assertionsPassed || auditsPassed || downloadsPassed ? "passed" : "incomplete";
2543
3378
  if (environmentFailure) result.navigation_error = "BROWSER_DISCONNECTED";
2544
3379
  result.expected_outcome_visible = visible;
2545
- result.acceptance_criteria_met = !failure && !dryRunStop && (visible || assertionsPassed || auditsPassed || downloadsPassed);
3380
+ result.acceptance_criteria_met = !failure && !mutationStop && (visible || assertionsPassed || auditsPassed || downloadsPassed);
2546
3381
  result.selected_plan = planId;
2547
3382
  result.steps_executed = trace.length;
2548
3383
  result.steps = trace;
2549
3384
  result.mutating_steps = trace.filter(({ mutating }) => mutating === true).map(({ step, action, duration_ms, status }) => ({ step, action, duration_ms, status }));
2550
- if (dryRunStop) {
2551
- result.dry_run_stopped_before_step = dryRunStop;
2552
- result.reason = `dry_run stopped before mutating step ${dryRunStop.step} (${dryRunStop.action})`;
3385
+ if (mutationStop) {
3386
+ if (options.dryRun) result.dry_run_stopped_before_step = mutationStop;
3387
+ else result.confirmation_required_before_step = mutationStop;
3388
+ result.confirmation_token = issuedToken;
3389
+ result.reason = `${options.dryRun ? "dry_run stopped" : "confirmation required"} before mutating step ${mutationStop.step} (${mutationStop.action}); rerun the same flow with options.confirmation_token to authorize this one step`;
2553
3390
  }
2554
3391
  result.final_snapshot = snapshot;
2555
3392
  if (failure) {
@@ -2581,6 +3418,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2581
3418
  if (navigationError) result.navigation_error = navigationError;
2582
3419
  result.reason = authenticationFailure
2583
3420
  ? `The application rejected its authentication request with HTTP ${authenticationFailure.status}; the browser session may have expired. Sign in to the application, then run the flow again.`
3421
+ : navigationError === "AUTHENTICATION_REQUIRED"
3422
+ ? "The configured login URL or login text is visible; sign in to the application, then run the flow again."
2584
3423
  : disconnected ? "The browser session disconnected; run browser_health to reconnect, then retry the flow."
2585
3424
  : error instanceof JevBrowserError
2586
3425
  ? error.message
@@ -2610,12 +3449,13 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2610
3449
  } catch {
2611
3450
  // Keep the last accessible snapshot when the page is closing or unavailable.
2612
3451
  }
2613
- } finally {
2614
- if (tracingStarted) {
2615
- try {
2616
- if (result.status !== "passed" && options.traceOnFailure) {
2617
- tracePath = await artifactPath(settings, "zip");
2618
- 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 });
2619
3459
  } else {
2620
3460
  await context.tracing.stop();
2621
3461
  }
@@ -2623,15 +3463,34 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2623
3463
  artifactErrors.push("trace recording could not be saved");
2624
3464
  }
2625
3465
  }
2626
- if (result.status !== "passed" && options.screenshotOnFailure) {
2627
- try {
2628
- result.screenshot_path = await artifactPath(settings, "png");
2629
- await page.screenshot({ path: result.screenshot_path, fullPage: false });
2630
- } 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 {
2631
3472
  delete result.screenshot_path;
2632
- artifactErrors.push("failure screenshot could not be saved");
2633
- }
2634
- }
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(() => {});
2635
3494
  try {
2636
3495
  await unroute?.();
2637
3496
  } catch {
@@ -2639,16 +3498,22 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2639
3498
  }
2640
3499
  networkTracker.dispose();
2641
3500
  await capture.dispose();
3501
+ page.off("dialog", onNativeDialog);
2642
3502
  }
2643
3503
  if (tracePath) result.trace_path = tracePath;
2644
3504
  if (artifactErrors.length) result.artifact_errors = artifactErrors;
2645
- result.console_errors = capture.diagnostics.console_errors;
2646
- result.network_failures = capture.diagnostics.network_failures;
3505
+ result.console_errors = groupConsoleErrors([...(continuation?.consoleErrors || []), ...capture.diagnostics.console_errors]);
3506
+ result.network_failures = [...(continuation?.networkFailures || []), ...capture.diagnostics.network_failures];
2647
3507
  result.warnings.push(...capture.diagnostics.warnings);
2648
3508
  result.timings_ms = flowTimings(started, browserSessionMs, timings.navigationMs, timings.initialSnapshotMs, timings.readyMs, timings.decisionMs, timings.browserPlanMs);
2649
- const safeResult = await redactObject(result, secrets);
2650
- if (typeof safeResult.final_snapshot === "string") {
2651
- 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);
3512
+ }
3513
+ if (options.returnSnapshot === "diff" && typeof safeResult.final_snapshot === "string") {
3514
+ safeResult.final_snapshot = snapshotDiff(initialSnapshot, safeResult.final_snapshot);
3515
+ } else if (options.returnSnapshot === "none") {
3516
+ delete safeResult.final_snapshot;
2652
3517
  }
2653
3518
  if (options.reportPath) {
2654
3519
  try {
@@ -2660,7 +3525,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2660
3525
  return safeResult;
2661
3526
  }
2662
3527
 
2663
- export async function chooseNextAction({ flow, pageSnapshot, actions, completedSteps = [], settings, fetchImpl = fetch }) {
3528
+ export async function chooseNextAction({ flow, pageSnapshot, actions, completedSteps = [], localOnly = false, settings, fetchImpl = fetch }) {
3529
+ if (localOnly) throw new JevBrowserError("local_only is enabled; choose_next_action cannot call the Jev provider");
2664
3530
  const { request, criteria } = buildChoicePayload({ flow, pageSnapshot, actions, completedSteps }, settings.jev);
2665
3531
  return parseDecision(await requestJev(request, settings, fetchImpl), criteria);
2666
3532
  }