@diegosouzacdv/jev-browser-mcp 0.6.0 → 0.6.1

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", "cell", "gridcell", "option"]);
10
+ const CHECK_ROLES = new Set(["checkbox", "radio"]);
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"]);
@@ -16,7 +17,7 @@ const CONTAINER_ROLES = new Set([
16
17
  "region", "row", "rowgroup", "table", "tablist", "tabpanel", "tree", "treegrid", "treeitem",
17
18
  ]);
18
19
  const TARGET_SELECTION_ACTIONS = new Set([
19
- "click", "type", "select_option", "hover", "press", "assert_text", "assert_value", "wait_for_value",
20
+ "click", "check", "uncheck", "type", "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"];
@@ -39,11 +40,66 @@ const MIME_TYPES = new Map([
39
40
  ]);
40
41
  const FLOW_OPTION_KEYS = new Set([
41
42
  "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
+ "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",
43
45
  "report_path",
44
- "continue_from_current_page", "reuse_page", "screenshot_on_failure", "snapshot_include_hidden", "snapshot_scope", "stop_on_expected",
46
+ "continue_from_current_page", "reuse_page", "return_snapshot", "screenshot_on_failure", "snapshot_include_hidden", "snapshot_scope", "step_timeout_seconds", "stop_on_expected",
45
47
  "trace_on_failure",
46
48
  ]);
49
+ 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;
50
+
51
+ export function createMutationAuthorizationStore() {
52
+ const tokens = new Map();
53
+ const prune = () => {
54
+ const now = Date.now();
55
+ for (const [token, record] of tokens) if (record.expiresAt <= now) tokens.delete(token);
56
+ while (tokens.size > 1024) tokens.delete(tokens.keys().next().value);
57
+ };
58
+ return Object.freeze({
59
+ issue(record, ttlMs) {
60
+ prune();
61
+ const token = randomBytes(32).toString("base64url");
62
+ tokens.set(token, { ...record, expiresAt: Date.now() + ttlMs });
63
+ return token;
64
+ },
65
+ consume(token, fingerprint) {
66
+ prune();
67
+ const record = tokens.get(token);
68
+ if (!record) throw new JevBrowserError("confirmation_token is invalid, expired, or already used; run dry_run again");
69
+ tokens.delete(token);
70
+ if (record.fingerprint !== fingerprint) {
71
+ throw new JevBrowserError("confirmation_token does not match this flow; run dry_run again");
72
+ }
73
+ return record;
74
+ },
75
+ });
76
+ }
77
+
78
+ const defaultMutationAuthorizationStore = createMutationAuthorizationStore();
79
+
80
+ function canonicalValue(value) {
81
+ if (Array.isArray(value)) return value.map(canonicalValue);
82
+ if (!isRecord(value)) return value;
83
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalValue(value[key])]));
84
+ }
85
+
86
+ function flowFingerprint({ flow, initialUrl, expectedOutcome, candidatePlans, params, options }) {
87
+ const stableOptions = { ...options };
88
+ delete stableOptions.dry_run;
89
+ delete stableOptions.confirmation_token;
90
+ delete stableOptions.dryRun;
91
+ delete stableOptions.confirmationToken;
92
+ return createHash("sha256").update(JSON.stringify(canonicalValue({
93
+ flow, initialUrl, expectedOutcome, candidatePlans, params, options: stableOptions,
94
+ }))).digest("hex");
95
+ }
96
+
97
+ function looksMutating(rawStep) {
98
+ if (["confirm_dialog", "like_comment", "unlike_comment", "upload_file"].includes(rawStep.action)) return true;
99
+ if (rawStep.action !== "click") return false;
100
+ return [rawStep.name, rawStep.title, rawStep.text, rawStep.target_text, rawStep.label]
101
+ .some((value) => typeof value === "string" && MUTATING_LABEL.test(value));
102
+ }
47
103
 
48
104
  function isRecord(value) {
49
105
  return value !== null && typeof value === "object" && !Array.isArray(value);
@@ -109,13 +165,12 @@ function interpolateStep(value, variables) {
109
165
  return value;
110
166
  }
111
167
 
112
- export function validateCandidatePlans(candidatePlans, settings) {
168
+ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps = settings.browser.maxFlowSteps) {
113
169
  if (!isRecord(candidatePlans)) throw new JevBrowserError("candidate_plans must be an object");
114
170
  if (Object.keys(candidatePlans).length > settings.jev.maxActionCount) {
115
171
  throw new JevBrowserError("candidate_plans exceeds the configured candidate limit");
116
172
  }
117
173
  const plans = {};
118
- let totalSteps = 0;
119
174
  for (const [rawId, rawPlan] of Object.entries(candidatePlans)) {
120
175
  const id = rawId.trim();
121
176
  if (!PLAN_ID.test(id) || id === COMPLETE_ACTION_ID) throw new JevBrowserError("each plan needs a valid, non-reserved plan ID");
@@ -126,8 +181,7 @@ export function validateCandidatePlans(candidatePlans, settings) {
126
181
  if (!Array.isArray(rawPlan.steps) || rawPlan.steps.length === 0) {
127
182
  throw new JevBrowserError("each candidate plan needs at least one browser step");
128
183
  }
129
- totalSteps += rawPlan.steps.length;
130
- if (totalSteps > settings.browser.maxFlowSteps) throw new JevBrowserError("candidate plans exceed the configured total browser-step limit");
184
+ if (rawPlan.steps.length > maxFlowSteps) throw new JevBrowserError(`candidate plan ${id} exceeds the per-plan browser-step limit of ${maxFlowSteps}`);
131
185
 
132
186
  const steps = rawPlan.steps.map((unprocessedStep) => {
133
187
  const rawStep = normalizeStepAliases(unprocessedStep);
@@ -138,9 +192,12 @@ export function validateCandidatePlans(candidatePlans, settings) {
138
192
  const locatorFields = TARGET_LOCATOR_FIELDS.filter((field) => field !== "text"
139
193
  || !["type", "wait_for_text", "wait_for_condition", "upload_file"].includes(action));
140
194
  const schemas = {
141
- click: { required: [], optional: [...semanticTarget, "expect_download", "timeout_seconds"] },
195
+ click: { required: [], optional: [...semanticTarget, "expect_download", "expect_popup", "timeout_seconds"] },
142
196
  type: { required: ["text"], optional: [...semanticTarget, "blur", "mode", "sensitive", "timeout_seconds"] },
143
197
  confirm_dialog: { required: ["expected_text", "button"], optional: ["timeout_seconds"] },
198
+ check: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
199
+ uncheck: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
200
+ navigate_menu: { required: ["path"], optional: ["toggle_selector", "timeout_seconds"] },
144
201
  assert_network: { required: ["url_contains", "method", "status"], optional: ["message_contains", "timeout_seconds"] },
145
202
  extract: { required: ["as"], optional: [...semanticTarget, "source", "attribute", "sensitive", "timeout_seconds"] },
146
203
  wait: { required: ["ms"], optional: [] },
@@ -149,7 +206,7 @@ export function validateCandidatePlans(candidatePlans, settings) {
149
206
  wait_for_value: { required: ["value"], optional: [...semanticTarget, "timeout_seconds"] },
150
207
  wait_for_enabled: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
151
208
  press: { required: ["key"], optional: [...semanticTarget, "timeout_seconds"] },
152
- select_option: { required: ["option"], optional: [...semanticTarget, "timeout_seconds"] },
209
+ select_option: { required: [], optional: [...semanticTarget, "option", "value", "label_contains", "timeout_seconds"] },
153
210
  hover: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
154
211
  assert_text: { required: ["expected"], optional: [...semanticTarget, "timeout_seconds"] },
155
212
  assert_value: { required: ["expected"], optional: [...semanticTarget, "timeout_seconds"] },
@@ -171,6 +228,11 @@ export function validateCandidatePlans(candidatePlans, settings) {
171
228
  }
172
229
  if (rawStep[field] !== undefined) step[field] = rawStep[field];
173
230
  }
231
+ const inferredMutating = looksMutating(rawStep);
232
+ if (inferredMutating && rawStep.mutating === false) {
233
+ throw new JevBrowserError(`${action} cannot override the default mutating classification for this action`);
234
+ }
235
+ if (inferredMutating) step.mutating = true;
174
236
  const selectorFields = locatorFields.filter((field) => rawStep[field] !== undefined);
175
237
  const hasNear = rawStep.near !== undefined;
176
238
  if (selectorFields.length + Number(hasNear) > 1) {
@@ -183,7 +245,8 @@ export function validateCandidatePlans(candidatePlans, settings) {
183
245
  }
184
246
  if (rawStep.role !== undefined) {
185
247
  const allowedRoles = action === "click" ? CLICK_ROLES
186
- : action === "type" || action === "select_option" || action === "assert_value" || action === "wait_for_value" ? TEXT_ROLES
248
+ : action === "check" || action === "uncheck" ? CHECK_ROLES
249
+ : action === "type" || action === "select_option" || action === "assert_value" || action === "wait_for_value" ? TEXT_ROLES
187
250
  : action === "hover" ? HOVER_ROLES
188
251
  : action === "upload_file" ? (rawStep.target === "button" ? new Set(["button"]) : DROPZONE_ROLES)
189
252
  : ASSERT_ROLES;
@@ -236,22 +299,27 @@ export function validateCandidatePlans(candidatePlans, settings) {
236
299
  if ((!TARGET_SELECTION_ACTIONS.has(action) && !hiddenWait) || !isRecord(rawStep.within)) {
237
300
  throw new JevBrowserError("within must be a semantic container on a target-based browser action");
238
301
  }
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
- }
302
+ const rowKeys = ["row_containing", "row_containing_exact", "row_containing_word"]
303
+ .filter((key) => rawStep.within[key] !== undefined);
304
+ if (rowKeys.length > 1) throw new JevBrowserError("within accepts only one row_containing match mode");
305
+ exactKeys(rawStep.within, [...(rawStep.within.role !== undefined ? ["role"] : []), ...rowKeys], ["name"], "within");
306
+ if (rawStep.within.role !== undefined && (typeof rawStep.within.role !== "string" || !CONTAINER_ROLES.has(rawStep.within.role))) {
307
+ throw new JevBrowserError(`within role must be one of: ${[...CONTAINER_ROLES].join(", ")}`);
308
+ }
309
+ if (rawStep.within.name !== undefined && rawStep.within.role === undefined) {
310
+ throw new JevBrowserError("within.name requires within.role");
311
+ }
312
+ if (!rawStep.within.role && rowKeys.length === 0) {
313
+ throw new JevBrowserError("within needs a semantic container role or row_containing text");
314
+ }
315
+ step.within = {};
316
+ if (rawStep.within.role !== undefined) step.within.role = rawStep.within.role;
317
+ if (rawStep.within.name !== undefined) step.within.name = requiredText(rawStep.within.name, "within accessible name", settings.jev.maxActionDescriptionChars);
318
+ if (rowKeys.length) {
319
+ const key = rowKeys[0];
320
+ const normalizedKey = key === "row_containing_exact" ? "rowContainingExact"
321
+ : key === "row_containing_word" ? "rowContainingWord" : "rowContaining";
322
+ step.within[normalizedKey] = requiredText(rawStep.within[key], `within ${key}`, settings.jev.maxActionDescriptionChars);
255
323
  }
256
324
  }
257
325
  if (rawStep.index !== undefined) {
@@ -266,10 +334,34 @@ export function validateCandidatePlans(candidatePlans, settings) {
266
334
  if (typeof rawStep.expect_download !== "boolean") throw new JevBrowserError("click expect_download must be a boolean");
267
335
  step.expectDownload = rawStep.expect_download;
268
336
  }
337
+ if (action === "click" && rawStep.expect_popup !== undefined) {
338
+ if (typeof rawStep.expect_popup !== "boolean") throw new JevBrowserError("click expect_popup must be a boolean");
339
+ step.expectPopup = rawStep.expect_popup;
340
+ }
341
+ if (action === "click" && step.expectDownload && step.expectPopup) {
342
+ throw new JevBrowserError("click cannot expect both a download and a popup");
343
+ }
269
344
  if (action === "select_option") {
345
+ const choices = ["option", "value", "label_contains"].filter((key) => rawStep[key] !== undefined);
346
+ if (choices.length !== 1) throw new JevBrowserError("select_option needs exactly one of option, value, or label_contains");
270
347
  if (step.role && step.role !== "combobox") throw new JevBrowserError("select_option role must be combobox");
271
348
  step.role = "combobox";
272
- step.option = requiredText(rawStep.option, "option", settings.jev.maxActionDescriptionChars);
349
+ step.option = requiredText(rawStep[choices[0]], choices[0], settings.jev.maxActionDescriptionChars);
350
+ step.optionMatch = choices[0];
351
+ }
352
+ if (action === "check" || action === "uncheck") {
353
+ if (step.role && !CHECK_ROLES.has(step.role)) throw new JevBrowserError(`${action} role must be checkbox or radio`);
354
+ if (!step.role) step.role = "checkbox";
355
+ if (action === "uncheck" && step.role === "radio") throw new JevBrowserError("uncheck does not support radio controls");
356
+ }
357
+ if (action === "navigate_menu") {
358
+ if (!Array.isArray(rawStep.path) || rawStep.path.length === 0 || rawStep.path.length > 8) {
359
+ throw new JevBrowserError("navigate_menu path must contain between 1 and 8 menu labels");
360
+ }
361
+ step.path = rawStep.path.map((label) => requiredText(label, "menu path label", settings.jev.maxActionDescriptionChars));
362
+ step.toggleSelector = rawStep.toggle_selector === undefined
363
+ ? "#open_btn"
364
+ : requiredText(rawStep.toggle_selector, "toggle_selector", settings.jev.maxActionDescriptionChars);
273
365
  }
274
366
  if (action === "type") {
275
367
  if (typeof rawStep.text !== "string" || rawStep.text.length === 0) throw new JevBrowserError("type step needs non-empty text");
@@ -473,8 +565,8 @@ function resolveFlowOptions(rawOptions, settings) {
473
565
  }
474
566
  const booleanOptions = [
475
567
  "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",
568
+ "auto_angular_idle", "block_fonts", "continue_from_current_page", "dry_run", "fast_path", "local_only",
569
+ "ready_network_idle", "reuse_page", "screenshot_on_failure", "snapshot_include_hidden", "stop_on_expected", "trace_on_failure",
478
570
  ];
479
571
  for (const key of booleanOptions) {
480
572
  if (rawOptions[key] !== undefined && typeof rawOptions[key] !== "boolean") {
@@ -484,6 +576,29 @@ function resolveFlowOptions(rawOptions, settings) {
484
576
  const scope = rawOptions.snapshot_scope ?? settings.browser.defaults.snapshotScope;
485
577
  if (!new Set(["body", "main", "dialog"]).has(scope)) throw new JevBrowserError("options.snapshot_scope must be body, main, or dialog");
486
578
  const defaults = settings.browser.defaults;
579
+ const stepTimeoutSeconds = rawOptions.step_timeout_seconds ?? settings.browser.actionTimeoutMs / 1000;
580
+ if (typeof stepTimeoutSeconds !== "number" || !Number.isFinite(stepTimeoutSeconds) || stepTimeoutSeconds <= 0
581
+ || stepTimeoutSeconds > settings.browser.maxStepTimeoutSeconds) {
582
+ throw new JevBrowserError(`options.step_timeout_seconds must be positive and at most ${settings.browser.maxStepTimeoutSeconds}`);
583
+ }
584
+ const maxFlowSteps = rawOptions.max_flow_steps ?? settings.browser.maxFlowSteps;
585
+ if (!Number.isSafeInteger(maxFlowSteps) || maxFlowSteps <= 0 || maxFlowSteps > settings.browser.maxFlowSteps) {
586
+ throw new JevBrowserError(`options.max_flow_steps must be a positive integer no greater than ${settings.browser.maxFlowSteps}`);
587
+ }
588
+ const returnSnapshot = rawOptions.return_snapshot ?? defaults.returnSnapshot;
589
+ if (!new Set(["none", "diff", "full"]).has(returnSnapshot)) throw new JevBrowserError("options.return_snapshot must be none, diff, or full");
590
+ const normalizeListOption = (key, fallback, maxItems = settings.browser.maxBusySelectors) => {
591
+ const raw = rawOptions[key] ?? fallback;
592
+ if (!Array.isArray(raw) || raw.length > maxItems) throw new JevBrowserError(`options.${key} must be an array with at most ${maxItems} entries`);
593
+ return raw.map((value) => requiredText(value, key, settings.jev.maxActionDescriptionChars));
594
+ };
595
+ const busySelectors = normalizeListOption("busy_selectors", settings.browser.busySelectorsDefault);
596
+ const loginUrlContains = normalizeListOption("login_url_contains", settings.browser.loginUrlContainsDefault);
597
+ const loginText = normalizeListOption("login_text", settings.browser.loginTextDefault);
598
+ let confirmationToken;
599
+ if (rawOptions.confirmation_token !== undefined) {
600
+ confirmationToken = requiredText(rawOptions.confirmation_token, "confirmation_token", 128);
601
+ }
487
602
  const readyTimeoutSeconds = rawOptions.ready_timeout_seconds ?? defaults.readyTimeoutSeconds;
488
603
  if (typeof readyTimeoutSeconds !== "number" || !Number.isFinite(readyTimeoutSeconds) || readyTimeoutSeconds <= 0
489
604
  || readyTimeoutSeconds > settings.browser.maxReadyTimeoutSeconds) {
@@ -507,21 +622,31 @@ function resolveFlowOptions(rawOptions, settings) {
507
622
  reportPath = rawOptions.report_path;
508
623
  }
509
624
  return Object.freeze({
625
+ autoAngularIdle: rawOptions.auto_angular_idle ?? defaults.autoAngularIdle,
510
626
  blockTrackers: rawOptions.block_trackers ?? defaults.blockTrackers,
627
+ blockFonts: rawOptions.block_fonts ?? defaults.blockFonts,
628
+ busySelectors: Object.freeze(busySelectors),
511
629
  captureConsoleErrors: rawOptions.capture_console_errors ?? defaults.captureConsoleErrors,
512
630
  captureNetworkErrorBodies: rawOptions.capture_network_error_bodies ?? defaults.captureNetworkErrorBodies,
513
631
  captureNetworkErrors: rawOptions.capture_network_errors ?? defaults.captureNetworkErrors,
514
632
  dryRun: rawOptions.dry_run ?? false,
515
633
  fastPath: rawOptions.fast_path ?? defaults.fastPath,
634
+ localOnly: rawOptions.local_only ?? defaults.localOnly,
635
+ loginText: Object.freeze(loginText),
636
+ loginUrlContains: Object.freeze(loginUrlContains),
637
+ maxFlowSteps,
516
638
  readyTimeoutMs: Math.ceil(readyTimeoutSeconds * 1000),
517
639
  readyNetworkIdle: rawOptions.ready_network_idle ?? defaults.readyNetworkIdle,
518
640
  readyStableMs,
519
641
  readyText,
520
642
  reportPath,
643
+ returnSnapshot,
521
644
  reusePage: rawOptions.continue_from_current_page ?? rawOptions.reuse_page ?? defaults.reusePage,
522
645
  screenshotOnFailure: rawOptions.screenshot_on_failure ?? defaults.screenshotOnFailure,
523
646
  snapshotIncludeHidden: rawOptions.snapshot_include_hidden ?? defaults.snapshotIncludeHidden,
524
647
  stopOnExpected: rawOptions.stop_on_expected ?? defaults.stopOnExpected,
648
+ stepTimeoutMs: Math.ceil(stepTimeoutSeconds * 1000),
649
+ confirmationToken,
525
650
  traceOnFailure: rawOptions.trace_on_failure ?? defaults.traceOnFailure,
526
651
  snapshotScope: scope,
527
652
  });
@@ -893,7 +1018,7 @@ function containsExpectedOutcome(snapshot, phrase) {
893
1018
  return containsPhrase(expectedOutcomeText(snapshot), phrase);
894
1019
  }
895
1020
 
896
- function createNetworkTracker(page, settings) {
1021
+ function createNetworkTracker(page, settings, loginUrlContains = []) {
897
1022
  const active = new Map();
898
1023
  const history = [];
899
1024
  let sequence = 0;
@@ -923,8 +1048,10 @@ function createNetworkTracker(page, settings) {
923
1048
  since: (marker, urlContains) => history.filter((entry) => entry.sequence > marker && entry.url.includes(urlContains)),
924
1049
  responsesSince: (marker, urlContains, method) => history.filter((entry) => entry.sequence > marker
925
1050
  && 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)),
1051
+ authenticationFailureSince: (marker) => history.find((entry) => {
1052
+ if (entry.sequence <= marker || entry.type !== "response" || ![401, 403].includes(entry.status)) return false;
1053
+ return loginUrlContains.some((needle) => entry.url.toLocaleLowerCase().includes(needle.toLocaleLowerCase()));
1054
+ }),
928
1055
  activeFor: (urlContains) => [...active.values()].filter((entry) => entry.url.includes(urlContains)),
929
1056
  dispose: () => {
930
1057
  page.off("request", onRequest);
@@ -957,7 +1084,7 @@ async function waitForFilteredNetworkIdle(tracker, urlContains, marker, timeout,
957
1084
  throw new JevBrowserError(`requests matching url_contains ${JSON.stringify(urlContains)} did not become idle within ${timeout}ms`);
958
1085
  }
959
1086
 
960
- async function waitForAngularIdle(page, timeout, settings) {
1087
+ async function waitForAngularIdle(page, timeout, settings, optional = false) {
961
1088
  const deadline = performance.now() + timeout;
962
1089
  let consecutiveIdle = 0;
963
1090
  while (performance.now() < deadline) {
@@ -981,13 +1108,18 @@ async function waitForAngularIdle(page, timeout, settings) {
981
1108
  };
982
1109
  });
983
1110
  } catch {
1111
+ if (optional) return false;
984
1112
  throw new JevBrowserError("angular_idle could not inspect the page; the AngularJS application may have navigated or closed");
985
1113
  }
986
- if (!state) throw new JevBrowserError("angular_idle requires an AngularJS 1.x page with an accessible injector");
1114
+ if (!state) {
1115
+ if (optional) return false;
1116
+ throw new JevBrowserError("angular_idle requires an AngularJS 1.x page with an accessible injector");
1117
+ }
987
1118
  consecutiveIdle = state.pending === 0 && !state.digesting && state.outstanding === 0 ? consecutiveIdle + 1 : 0;
988
1119
  if (consecutiveIdle >= 2) return;
989
1120
  await new Promise((resolve) => setTimeout(resolve, Math.min(50, remainingMs(deadline))));
990
1121
  }
1122
+ if (optional) return false;
991
1123
  throw new JevBrowserError(`AngularJS still had pending requests or digest work after ${timeout}ms`);
992
1124
  }
993
1125
 
@@ -1121,7 +1253,7 @@ async function waitForPageReady(page, options, settings, secrets, continued = fa
1121
1253
  });
1122
1254
  lastSnapshot = snapshot;
1123
1255
  const readyTextVisible = !options.readyText || containsPhrase(snapshot, options.readyText);
1124
- const usefulSnapshot = await hasRenderedContent(page, snapshot);
1256
+ const usefulSnapshot = await hasRenderedContent(page, snapshot, options.busySelectors);
1125
1257
  if (continued && snapshot && readyTextVisible && usefulSnapshot) {
1126
1258
  return { snapshot, readyMs: performance.now() - started };
1127
1259
  }
@@ -1147,7 +1279,7 @@ async function waitForPageReady(page, options, settings, secrets, continued = fa
1147
1279
  throw error;
1148
1280
  }
1149
1281
 
1150
- async function hasRenderedContent(page, snapshot) {
1282
+ async function hasRenderedContent(page, snapshot, busySelectors = []) {
1151
1283
  const lines = snapshot.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
1152
1284
  if (!lines.some((line, index) => index > 0 || /:\s+\S|"[^"]+"/.test(line))) return false;
1153
1285
  try {
@@ -1155,9 +1287,65 @@ async function hasRenderedContent(page, snapshot) {
1155
1287
  } catch {
1156
1288
  // The snapshot itself is the fallback when the DOM is transitioning.
1157
1289
  }
1290
+ for (const selector of busySelectors) {
1291
+ try {
1292
+ const busy = page.locator(selector);
1293
+ const count = await busy.count();
1294
+ for (let index = 0; index < count; index += 1) {
1295
+ if (await isActuallyVisible(locatorAt(busy, index))) return false;
1296
+ }
1297
+ } catch {
1298
+ // A malformed or transient selector does not replace the accessible snapshot readiness signal.
1299
+ }
1300
+ }
1158
1301
  return true;
1159
1302
  }
1160
1303
 
1304
+ function sensitiveParamSecrets(variables) {
1305
+ const sensitiveName = /(?:cpf|cnpj|e?mail|telefone|celular|cliente|nome|sacado|pagador|cedente|senha|password|token|api[_-]?key|valor|amount|pre[cç]o)/i;
1306
+ return Object.entries(variables).filter(([name]) => sensitiveName.test(name)).map(([, value]) => value);
1307
+ }
1308
+
1309
+ async function waitForText(locator, expected, timeout, description, settings) {
1310
+ const normalized = expected.replace(/\s+/g, " ").trim().toLocaleLowerCase();
1311
+ const deadline = performance.now() + timeout;
1312
+ while (performance.now() < deadline) {
1313
+ try {
1314
+ const actual = (await locator.innerText({ timeout: Math.min(250, remainingMs(deadline)) }))
1315
+ .replace(/\s+/g, " ").trim().toLocaleLowerCase();
1316
+ if (actual.includes(normalized)) return;
1317
+ } catch {
1318
+ // SPA rerenders may briefly detach the target.
1319
+ }
1320
+ await new Promise((resolve) => setTimeout(resolve, Math.min(settings.browser.visibilityPollMs, remainingMs(deadline))));
1321
+ }
1322
+ throw new JevBrowserError(`${description} did not contain the expected text within ${timeout}ms`);
1323
+ }
1324
+
1325
+ async function pageFingerprint(page, snapshot) {
1326
+ let controls = [];
1327
+ let visibleText = "";
1328
+ try {
1329
+ ({ controls, visibleText } = await page.evaluate(() => ({
1330
+ visibleText: String(document.body?.innerText || "").slice(0, 30000),
1331
+ controls: [...document.querySelectorAll("input,textarea,select")].slice(0, 1000).map((element) => ({
1332
+ tag: element.localName,
1333
+ type: element.getAttribute("type") || "",
1334
+ name: element.getAttribute("name") || "",
1335
+ id: element.id || "",
1336
+ value: String(element.value ?? "").slice(0, 4000),
1337
+ checked: "checked" in element ? Boolean(element.checked) : undefined,
1338
+ disabled: Boolean(element.disabled),
1339
+ })),
1340
+ })));
1341
+ } catch {
1342
+ // The page may close between a browser event and an authorization check.
1343
+ }
1344
+ return createHash("sha256").update(JSON.stringify({
1345
+ url: typeof page.url === "function" ? page.url() : "unavailable", snapshot, visibleText, controls,
1346
+ })).digest("hex");
1347
+ }
1348
+
1161
1349
  async function snapshotAfterStep(page, settings, secrets, scope, previousSnapshot, stepNumber, warnings, includeHidden) {
1162
1350
  const snapshot = await snapshotPage(page, settings, secrets, scope, { allowEmpty: true, includeHidden });
1163
1351
  if (snapshot) return snapshot;
@@ -1315,22 +1503,45 @@ async function exactlyOne(locator, description, timeout, settings) {
1315
1503
 
1316
1504
  async function scopedRoot(root, within, timeout, settings) {
1317
1505
  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 {
1506
+ let scope = root;
1507
+ const descriptions = [];
1508
+ if (within.role) {
1327
1509
  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)}`}`;
1510
+ const container = root.getByRole(within.role, options);
1511
+ const description = `visible ${within.role} container${within.name === undefined ? "" : ` ${JSON.stringify(within.name)}`}`;
1512
+ const { visible } = await visibleIndexes(container, timeout, `within ${description}`, settings);
1513
+ if (visible.length !== 1) throw new JevBrowserError(`within ${description} must match exactly one visible container; found ${visible.length}`);
1514
+ scope = locatorAt(container, visible[0]);
1515
+ descriptions.push(description);
1516
+ }
1517
+ const rowText = within.rowContainingExact ?? within.rowContainingWord ?? within.rowContaining;
1518
+ if (rowText !== undefined) {
1519
+ const rows = scope.getByRole("row");
1520
+ const mode = within.rowContainingExact !== undefined ? "exact"
1521
+ : within.rowContainingWord !== undefined ? "whole word" : "substring";
1522
+ if (within.rowContainingWord !== undefined) {
1523
+ const pattern = new RegExp(`(?:^|[^\\p{L}\\p{N}])${escapeRegex(rowText)}(?:$|[^\\p{L}\\p{N}])`, "iu");
1524
+ const count = await rows.count();
1525
+ const matches = [];
1526
+ for (let index = 0; index < count; index += 1) {
1527
+ const row = locatorAt(rows, index);
1528
+ if (await isActuallyVisible(row) && pattern.test(await row.innerText())) matches.push(index);
1529
+ }
1530
+ const description = `row containing ${mode} text ${JSON.stringify(rowText)}`;
1531
+ if (matches.length !== 1) throw new JevBrowserError(`within ${description} must match exactly one visible row; found ${matches.length}`);
1532
+ return locatorAt(rows, matches[0]);
1533
+ }
1534
+ const matcher = within.rowContainingExact !== undefined
1535
+ ? { has: scope.getByText(rowText, { exact: true }) }
1536
+ : { hasText: new RegExp(escapeRegex(rowText), "iu") };
1537
+ const rowLocator = rows.filter(matcher);
1538
+ const description = `row containing ${mode} text ${JSON.stringify(rowText)}`;
1539
+ const { visible } = await visibleIndexes(rowLocator, timeout, `within ${description}`, settings);
1540
+ if (visible.length !== 1) throw new JevBrowserError(`within ${description} must match exactly one visible row; found ${visible.length}`);
1541
+ scope = locatorAt(rowLocator, visible[0]);
1542
+ descriptions.push(description);
1330
1543
  }
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]);
1544
+ return scope;
1334
1545
  }
1335
1546
 
1336
1547
  function accessibleNamePattern(name) {
@@ -1429,11 +1640,11 @@ function describeTarget(step, secrets) {
1429
1640
  }
1430
1641
  if (step.near) parts.push(`near text ${JSON.stringify(redact(step.near.text, secrets))}`);
1431
1642
  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))}`}`);
1643
+ if (step.within.role) parts.push(`within ${step.within.role}${step.within.name === undefined ? "" : ` ${JSON.stringify(redact(step.within.name, secrets))}`}`);
1644
+ const rowPrefix = step.within.role ? "" : "within ";
1645
+ if (step.within.rowContainingExact !== undefined) parts.push(`${rowPrefix}row_containing_exact ${JSON.stringify(redact(step.within.rowContainingExact, secrets))}`);
1646
+ else if (step.within.rowContainingWord !== undefined) parts.push(`${rowPrefix}row_containing_word ${JSON.stringify(redact(step.within.rowContainingWord, secrets))}`);
1647
+ else if (step.within.rowContaining !== undefined) parts.push(`${rowPrefix}row_containing ${JSON.stringify(redact(step.within.rowContaining, secrets))}`);
1437
1648
  }
1438
1649
  if (step.index !== undefined) parts.push(`index ${step.index}`);
1439
1650
  return parts.length ? parts.join(" + ") : "focused element";
@@ -1515,8 +1726,8 @@ async function namedTarget(root, step, snapshot, secrets, timeout, settings) {
1515
1726
  if (step.fragileName && step.action === "click") {
1516
1727
  const title = await selected.evaluate((element) => [element, ...element.querySelectorAll("[title]")]
1517
1728
  .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`);
1729
+ if (MUTATING_LABEL.test(title)) {
1730
+ throw new JevBrowserError(`refused index-only click on a mutating control titled ${JSON.stringify(redact(title, secrets))}; provide a stable locator`);
1520
1731
  }
1521
1732
  }
1522
1733
  return selected;
@@ -1716,6 +1927,45 @@ async function saveDownload(download, settings, ledger) {
1716
1927
  }
1717
1928
  }
1718
1929
 
1930
+ async function visibleMenuItem(root, label, timeout, settings) {
1931
+ for (const role of ["menuitem", "link", "button"]) {
1932
+ const locator = root.getByRole(role, { name: label, exact: true });
1933
+ try {
1934
+ const { visible } = await visibleIndexes(locator, Math.min(timeout, 250), `menu ${role} ${JSON.stringify(label)}`, settings);
1935
+ if (visible.length > 0) {
1936
+ // Nested application menus often reuse the same label for a parent and its newly opened leaf.
1937
+ return locatorAt(locator, visible.at(-1));
1938
+ }
1939
+ } catch {
1940
+ // Try the next semantic role before falling back to exact visible text.
1941
+ }
1942
+ }
1943
+ const textLocator = root.getByText(label, { exact: true });
1944
+ const { visible } = await visibleIndexes(textLocator, Math.min(timeout, 250), `menu text ${JSON.stringify(label)}`, settings);
1945
+ if (visible.length === 0) throw new JevBrowserError(`menu item ${JSON.stringify(label)} must match a visible control; found 0`);
1946
+ return locatorAt(textLocator, visible.at(-1));
1947
+ }
1948
+
1949
+ async function navigateMenu(page, root, step, settings, execution) {
1950
+ let item;
1951
+ try {
1952
+ item = await visibleMenuItem(root, step.path[0], 250, settings);
1953
+ } catch {
1954
+ const toggle = page.locator(step.toggleSelector);
1955
+ const { visible } = await visibleIndexes(toggle, 250, `menu toggle ${step.toggleSelector}`, settings);
1956
+ if (visible.length !== 1) throw new JevBrowserError(`menu toggle ${step.toggleSelector} must match exactly one visible control; found ${visible.length}`);
1957
+ await locatorAt(toggle, visible[0]).click({ timeout: step.timeoutMs ?? settings.browser.actionTimeoutMs });
1958
+ if (execution.autoAngularIdle) await waitForAngularIdle(page, step.timeoutMs ?? settings.browser.actionTimeoutMs, settings, true);
1959
+ item = await visibleMenuItem(root, step.path[0], step.timeoutMs ?? settings.browser.actionTimeoutMs, settings);
1960
+ }
1961
+ for (let index = 0; index < step.path.length; index += 1) {
1962
+ if (index > 0) item = await visibleMenuItem(root, step.path[index], step.timeoutMs ?? settings.browser.actionTimeoutMs, settings);
1963
+ await item.click({ timeout: step.timeoutMs ?? settings.browser.actionTimeoutMs });
1964
+ if (execution.autoAngularIdle) await waitForAngularIdle(page, step.timeoutMs ?? settings.browser.actionTimeoutMs, settings, true);
1965
+ }
1966
+ return { action: step.action, navigated_menu: step.path };
1967
+ }
1968
+
1719
1969
  async function executeStep(page, step, settings, snapshot, secrets, downloadLedger, execution = {}) {
1720
1970
  const root = await stepRoot(page, step.frame);
1721
1971
  const timeout = step.timeoutMs ?? settings.browser.actionTimeoutMs;
@@ -1732,6 +1982,19 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
1732
1982
  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
1983
  }
1734
1984
  if (step.action === "confirm_dialog") {
1985
+ const native = execution.nativeDialog?.result;
1986
+ if (native) {
1987
+ const expectedText = step.expectedText.replace(/\s+/g, " ").trim();
1988
+ if (!native.matched || native.handlerError || !native.message.toLocaleLowerCase().includes(expectedText.toLocaleLowerCase())) {
1989
+ throw new JevBrowserError(`confirm_dialog refused to act: native dialog text did not match ${JSON.stringify(redact(expectedText, secrets))}`);
1990
+ }
1991
+ return {
1992
+ action: step.action,
1993
+ confirmed_text: redact(expectedText, secrets),
1994
+ native_dialog: true,
1995
+ button: redact(step.button, secrets),
1996
+ };
1997
+ }
1735
1998
  const dialog = await exactlyOne(root.getByRole("dialog"), "confirmation dialog", timeout, settings);
1736
1999
  const actualText = (await dialog.innerText({ timeout })).replace(/\s+/g, " ").trim();
1737
2000
  const expectedText = step.expectedText.replace(/\s+/g, " ").trim();
@@ -1881,11 +2144,48 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
1881
2144
  await waitForEnabled(control, timeout, describeTarget(step, secrets), settings);
1882
2145
  return { action: step.action, enabled: true, ...targetEvidence(step, secrets, await describeResolvedTarget(control, step, secrets, settings)) };
1883
2146
  }
1884
- const control = await namedTarget(root, step, snapshot, secrets, timeout, settings);
2147
+ if (step.action === "navigate_menu") return navigateMenu(page, root, step, settings, execution);
2148
+ const control = execution.preResolvedTarget || await namedTarget(root, step, snapshot, secrets, timeout, settings);
2149
+ if (step.action === "check" || step.action === "uncheck") {
2150
+ const resolvedTarget = await describeResolvedTarget(control, step, secrets, settings);
2151
+ if (step.action === "check") await control.check({ timeout });
2152
+ else await control.uncheck({ timeout });
2153
+ return { action: step.action, checked: step.action === "check", ...targetEvidence(step, secrets, resolvedTarget) };
2154
+ }
1885
2155
  if (step.action === "click") {
1886
2156
  const resolvedTarget = await describeResolvedTarget(control, step, secrets, settings);
2157
+ if (step.expectPopup) {
2158
+ let popup;
2159
+ try {
2160
+ [popup] = await Promise.all([
2161
+ page.waitForEvent("popup", { timeout: settings.browser.downloadTimeoutMs }),
2162
+ control.click({ timeout }),
2163
+ ]);
2164
+ } catch {
2165
+ throw new JevBrowserError("click did not open the expected popup before the configured timeout");
2166
+ }
2167
+ try {
2168
+ await popup.waitForLoadState("domcontentloaded", { timeout: settings.browser.downloadTimeoutMs }).catch(() => {});
2169
+ const popupUrl = popup.url();
2170
+ const popupTitle = await popup.title().catch(() => "");
2171
+ const isPdf = /\.pdf(?:$|[?#])/i.test(popupUrl)
2172
+ || await popup.locator('embed[type="application/pdf"], iframe[src*=".pdf" i]').count().then((count) => count > 0).catch(() => false);
2173
+ return {
2174
+ action: step.action,
2175
+ ...targetEvidence(step, secrets, resolvedTarget),
2176
+ popup_url: evidenceUrl(popupUrl, secrets, settings.jev.maxDiagnosticChars),
2177
+ ...(popupTitle ? { popup_title: redact(popupTitle, secrets).slice(0, settings.jev.maxDiagnosticChars) } : {}),
2178
+ popup_document_type: isPdf ? "pdf" : "page",
2179
+ };
2180
+ } finally {
2181
+ await popup.close().catch(() => {});
2182
+ }
2183
+ }
1887
2184
  if (!step.expectDownload) {
1888
2185
  await control.click({ timeout });
2186
+ if (execution.nativeDialog?.result && !execution.nativeDialog.result.matched) {
2187
+ throw new JevBrowserError(`unexpected native ${execution.nativeDialog.result.type} dialog was dismissed: ${JSON.stringify(sanitizeDiagnosticText(execution.nativeDialog.result.message, secrets, settings.jev.maxDiagnosticChars))}`);
2188
+ }
1889
2189
  return { action: step.action, ...targetEvidence(step, secrets, resolvedTarget) };
1890
2190
  }
1891
2191
  let download;
@@ -1931,7 +2231,17 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
1931
2231
  }
1932
2232
  if (step.action === "select_option") {
1933
2233
  const resolvedTarget = await describeResolvedTarget(control, step, secrets, settings);
1934
- await control.selectOption({ label: step.option }, { timeout });
2234
+ if (step.optionMatch === "value") {
2235
+ await control.selectOption(step.option, { timeout });
2236
+ } else if (step.optionMatch === "label_contains") {
2237
+ const matches = await control.locator("option").evaluateAll((options, text) => options
2238
+ .filter((option) => String(option.label || option.textContent || "").toLocaleLowerCase().includes(text.toLocaleLowerCase()))
2239
+ .map((option) => ({ label: String(option.label || option.textContent || "").trim(), value: String(option.value) })), step.option);
2240
+ if (matches.length !== 1) throw new JevBrowserError(`select_option label_contains must match one option; found ${matches.length}`);
2241
+ await control.selectOption(matches[0].value, { timeout });
2242
+ } else {
2243
+ await control.selectOption({ label: step.option }, { timeout });
2244
+ }
1935
2245
  return { action: step.action, ...targetEvidence(step, secrets, resolvedTarget) };
1936
2246
  }
1937
2247
  if (step.action === "hover") {
@@ -1948,11 +2258,7 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
1948
2258
  };
1949
2259
  }
1950
2260
  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
- }
2261
+ await waitForText(control, step.expected, timeout, `assert_text for ${describeTarget(step, secrets)}`, settings);
1956
2262
  return {
1957
2263
  action: step.action,
1958
2264
  asserted: true,
@@ -1960,8 +2266,7 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
1960
2266
  };
1961
2267
  }
1962
2268
  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)}`);
2269
+ await waitForValue(control, step.expected, timeout, `assert_value for ${describeTarget(step, secrets)}`, settings);
1965
2270
  return {
1966
2271
  action: step.action,
1967
2272
  asserted: true,
@@ -1975,6 +2280,24 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
1975
2280
  throw new JevBrowserError("unsupported browser action");
1976
2281
  }
1977
2282
 
2283
+ async function inferTargetMutation(page, step, snapshot, settings, secrets) {
2284
+ if (step.action !== "click") return { mutating: step.mutating === true };
2285
+ const root = await stepRoot(page, step.frame);
2286
+ const target = await namedTarget(root, step, snapshot, secrets, step.timeoutMs ?? settings.browser.actionTimeoutMs, settings);
2287
+ if (step.mutating === true) return { mutating: true, target };
2288
+ if (typeof target?.evaluate !== "function") return { mutating: false, target };
2289
+ const label = await target.evaluate((element) => [
2290
+ element.getAttribute("aria-label"),
2291
+ element.getAttribute("title"),
2292
+ element.getAttribute("value"),
2293
+ element.innerText,
2294
+ element.textContent,
2295
+ ...[...element.querySelectorAll("[title]")].map((child) => child.getAttribute("title")),
2296
+ ].filter(Boolean).join(" "));
2297
+ if (MUTATING_LABEL.test(label)) step.mutating = true;
2298
+ return { mutating: step.mutating === true, target };
2299
+ }
2300
+
1978
2301
  function flowTimings(started, browserSessionMs, navigationMs, initialSnapshotMs, readyMs, jevDecisionMs, browserPlanMs) {
1979
2302
  return {
1980
2303
  browser_session_ms: Math.round(browserSessionMs * 10) / 10,
@@ -2147,11 +2470,14 @@ function trackerUrl(url, settings) {
2147
2470
  return settings.browser.trackerHostSuffixes.some((suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`));
2148
2471
  }
2149
2472
 
2150
- async function installTrackerBlocking(context, settings, enabled) {
2151
- if (!enabled) return undefined;
2473
+ async function installTrackerBlocking(context, settings, enabled, blockFonts = false) {
2474
+ if (!enabled && !blockFonts) return undefined;
2152
2475
  const handler = async (route) => {
2153
2476
  const request = route.request();
2154
- if (trackerUrl(request.url(), settings) || settings.browser.blockedResourceTypes.includes(request.resourceType())) {
2477
+ const resourceType = request.resourceType();
2478
+ if ((enabled && trackerUrl(request.url(), settings))
2479
+ || (enabled && settings.browser.blockedResourceTypes.includes(resourceType) && (resourceType !== "font" || blockFonts))
2480
+ || (blockFonts && resourceType === "font")) {
2155
2481
  await route.abort("blockedbyclient");
2156
2482
  return;
2157
2483
  }
@@ -2261,18 +2587,37 @@ function hasAssertions(steps) {
2261
2587
  return steps.some(({ action }) => action.startsWith("assert_"));
2262
2588
  }
2263
2589
 
2264
- export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, candidatePlans, params: rawParams, options: rawOptions, settings, browserPool, fetchImpl = fetch, serverVersion }) {
2590
+ function snapshotDiff(initialSnapshot, finalSnapshot) {
2591
+ const remaining = new Map();
2592
+ for (const line of initialSnapshot.split(/\r?\n/).filter(Boolean)) remaining.set(line, (remaining.get(line) || 0) + 1);
2593
+ const added = [];
2594
+ for (const line of finalSnapshot.split(/\r?\n/).filter(Boolean)) {
2595
+ const count = remaining.get(line) || 0;
2596
+ if (count > 0) remaining.set(line, count - 1);
2597
+ else added.push(line);
2598
+ }
2599
+ return added.length ? added.join("\n") : "[no new snapshot lines]";
2600
+ }
2601
+
2602
+ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, candidatePlans, params: rawParams, options: rawOptions, settings, browserPool, fetchImpl = fetch, serverVersion, mutationAuthorizationStore = defaultMutationAuthorizationStore }) {
2265
2603
  if (typeof flow !== "string" || typeof expectedOutcome !== "string") throw new JevBrowserError("flow and expected_outcome must be text");
2266
- const variables = validateParams(rawParams, settings);
2604
+ let variables = validateParams(rawParams, settings);
2267
2605
  const requestedFlow = replaceKnownParams(flow, variables).trim();
2268
2606
  const outcome = replaceKnownParams(expectedOutcome, variables).trim();
2269
2607
  if (!requestedFlow || !outcome) throw new JevBrowserError("flow and expected_outcome must not be empty");
2270
2608
  if (requestedFlow.length > settings.jev.maxFlowChars) throw new JevBrowserError("flow exceeds the configured character limit");
2271
2609
  if (outcome.length > settings.jev.maxActionDescriptionChars) throw new JevBrowserError("expected_outcome exceeds the configured character limit");
2272
2610
  const url = validateUrl(initialUrl);
2273
- const plans = validateCandidatePlans(replaceKnownParams(candidatePlans, variables), settings);
2274
2611
  const options = resolveFlowOptions(rawOptions, settings);
2275
- const secrets = [...planSecrets(plans), settings.env?.[settings.jev.credentialEnv]].filter(Boolean)
2612
+ const plans = validateCandidatePlans(replaceKnownParams(candidatePlans, variables), settings, options.maxFlowSteps);
2613
+ if (options.localOnly && Object.keys(plans).length > 0 && !(options.fastPath && Object.keys(plans).length === 1)) {
2614
+ throw new JevBrowserError("local_only does not call Jev; provide exactly one candidate plan and enable fast_path");
2615
+ }
2616
+ const fingerprint = flowFingerprint({ flow, initialUrl: url, expectedOutcome, candidatePlans, params: rawParams, options });
2617
+ const continuation = options.confirmationToken
2618
+ ? mutationAuthorizationStore.consume(options.confirmationToken, fingerprint)
2619
+ : undefined;
2620
+ const secrets = [...planSecrets(plans), ...sensitiveParamSecrets(variables), settings.env?.[settings.jev.credentialEnv]].filter(Boolean)
2276
2621
  .sort((left, right) => right.length - left.length);
2277
2622
  const contentSecrets = planContentSecrets(plans);
2278
2623
  const started = performance.now();
@@ -2294,11 +2639,12 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2294
2639
  unnamed_controls: [],
2295
2640
  unnamed_controls_initial: [],
2296
2641
  unnamed_controls_final: [],
2297
- warnings: [],
2642
+ warnings: continuation?.warnings || [],
2298
2643
  final_snapshot: "",
2299
2644
  };
2645
+ result.snapshot_mode = options.returnSnapshot;
2300
2646
  const downloadLedger = { count: 0, bytes: 0 };
2301
- const networkTracker = createNetworkTracker(page, settings);
2647
+ const networkTracker = createNetworkTracker(page, settings, options.loginUrlContains);
2302
2648
  const initialNetworkMarker = networkTracker.mark();
2303
2649
  const timings = { navigationMs: 0, initialSnapshotMs: 0, readyMs: 0, decisionMs: 0, browserPlanMs: 0 };
2304
2650
  let unroute;
@@ -2306,10 +2652,33 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2306
2652
  let tracePath;
2307
2653
  const artifactErrors = [];
2308
2654
  let navigationError;
2655
+ let initialSnapshot = "";
2656
+ const nativeDialog = { expectedStep: undefined, result: undefined };
2657
+ const onNativeDialog = async (dialog) => {
2658
+ const expectedStep = nativeDialog.expectedStep;
2659
+ const message = String(dialog.message() || "").replace(/\s+/g, " ").trim();
2660
+ const expectedText = expectedStep?.expectedText?.replace(/\s+/g, " ").trim();
2661
+ const matches = Boolean(expectedText && message.toLocaleLowerCase().includes(expectedText.toLocaleLowerCase()));
2662
+ const affirmative = /^(?:sim|yes|ok|okay|confirm|confirmar|aceitar)$/i.test(expectedStep?.button || "");
2663
+ nativeDialog.result = {
2664
+ message,
2665
+ expectedText,
2666
+ matched: matches,
2667
+ button: expectedStep?.button,
2668
+ type: dialog.type(),
2669
+ };
2670
+ try {
2671
+ if (matches && (dialog.type() === "alert" || affirmative)) await dialog.accept();
2672
+ else await dialog.dismiss();
2673
+ } catch {
2674
+ nativeDialog.result.handlerError = true;
2675
+ }
2676
+ };
2677
+ page.on("dialog", onNativeDialog);
2309
2678
  try {
2310
2679
  page.setDefaultTimeout(settings.browser.actionTimeoutMs);
2311
2680
  context.setDefaultTimeout(settings.browser.actionTimeoutMs);
2312
- unroute = await installTrackerBlocking(context, settings, options.blockTrackers);
2681
+ unroute = await installTrackerBlocking(context, settings, options.blockTrackers, options.blockFonts);
2313
2682
  if (options.traceOnFailure) {
2314
2683
  try {
2315
2684
  await context.tracing.start({ screenshots: true, snapshots: true });
@@ -2340,6 +2709,9 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2340
2709
  } else {
2341
2710
  reusedPage = true;
2342
2711
  result.navigation_strategy = "continued_current_page";
2712
+ if (new URL(currentUrl).href !== new URL(url).href) {
2713
+ addDiagnostic(result.warnings, "initial_url differs from the current same-origin URL; continued from the current page", settings.jev.maxDiagnosticItems);
2714
+ }
2343
2715
  }
2344
2716
  }
2345
2717
  }
@@ -2360,9 +2732,14 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2360
2732
  const readiness = await waitForPageReady(page, options, settings, secrets, reusedPage);
2361
2733
  const enriched = await enrichSnapshot(page, readiness.snapshot, settings, secrets, options.snapshotScope, options.snapshotIncludeHidden);
2362
2734
  result.final_snapshot = enriched.snapshot;
2735
+ initialSnapshot = continuation?.initialSnapshot || enriched.snapshot;
2363
2736
  result.unnamed_controls = enriched.unnamedControls;
2364
2737
  result.unnamed_controls_initial = enriched.unnamedControls;
2365
2738
  result.unnamed_controls_final = enriched.unnamedControls;
2739
+ if (options.loginText.some((needle) => result.final_snapshot.toLocaleLowerCase().includes(needle.toLocaleLowerCase()))) {
2740
+ navigationError = "AUTHENTICATION_REQUIRED";
2741
+ throw new JevBrowserError("the configured login text is visible; sign in to the application, then run the flow again");
2742
+ }
2366
2743
  for (const control of enriched.unnamedControls) {
2367
2744
  addDiagnostic(
2368
2745
  result.warnings,
@@ -2372,6 +2749,17 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2372
2749
  }
2373
2750
  timings.readyMs = readiness.readyMs;
2374
2751
  browserPool.setNavigationError?.(null);
2752
+ if (continuation) {
2753
+ const currentUrl = typeof page.url === "function" ? page.url() : "unavailable";
2754
+ const currentFingerprint = await pageFingerprint(page, result.final_snapshot);
2755
+ if (continuation.pageUrl !== currentUrl || continuation.pageFingerprint !== currentFingerprint) {
2756
+ throw new JevBrowserError("the page changed after dry_run; run dry_run again before confirming a mutation");
2757
+ }
2758
+ if (!Object.hasOwn(plans, continuation.planId)) {
2759
+ throw new JevBrowserError("confirmation_token refers to a candidate plan that is no longer available");
2760
+ }
2761
+ variables = { ...continuation.variables };
2762
+ }
2375
2763
  } catch (error) {
2376
2764
  timings.readyMs = error.readyMs || performance.now() - snapshotStarted;
2377
2765
  throw error;
@@ -2386,7 +2774,9 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2386
2774
  } else {
2387
2775
  let planId;
2388
2776
  let decision;
2389
- if (options.fastPath && Object.keys(plans).length === 1) {
2777
+ if (continuation) {
2778
+ planId = continuation.planId;
2779
+ } else if (options.fastPath && Object.keys(plans).length === 1) {
2390
2780
  [planId] = Object.keys(plans);
2391
2781
  } else {
2392
2782
  const planIdByDecisionId = new Map();
@@ -2422,34 +2812,77 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2422
2812
 
2423
2813
  if (planId) {
2424
2814
  const plan = plans[planId];
2425
- const trace = [];
2815
+ const trace = continuation ? [...continuation.trace] : [];
2426
2816
  let failure = "";
2427
2817
  let environmentFailure = false;
2428
2818
  let failedStep;
2429
- let dryRunStop;
2819
+ let mutationStop;
2820
+ let issuedToken;
2430
2821
  let snapshot = result.final_snapshot;
2431
2822
  let networkMarker = networkTracker.mark();
2823
+ result.downloaded_files = continuation?.downloadedFiles || [];
2824
+ result.accessibility_audits = continuation?.accessibilityAudits || [];
2825
+ if (continuation?.downloadLedger) Object.assign(downloadLedger, continuation.downloadLedger);
2432
2826
  const planStarted = performance.now();
2433
2827
  const runAllSteps = plan.steps.some(({ action, expectDownload }) => action.startsWith("assert_")
2434
2828
  || ["upload_file", "select_option", "hover", "like_comment", "unlike_comment", "audit_accessibility"].includes(action)
2435
2829
  || expectDownload === true);
2436
- for (const [index, templateStep] of plan.steps.entries()) {
2830
+ let approvedMutationIndex = continuation?.nextStep;
2831
+ for (let index = continuation?.nextStep || 0; index < plan.steps.length; index += 1) {
2832
+ const templateStep = plan.steps[index];
2437
2833
  const stepStarted = performance.now();
2438
2834
  let step = templateStep;
2439
2835
  try {
2440
2836
  step = interpolateStep(templateStep, variables);
2441
- if (options.dryRun && step.mutating === true) {
2442
- dryRunStop = { step: String(index + 1), action: step.action };
2837
+ step.timeoutMs ??= options.stepTimeoutMs;
2838
+ const mutationInspection = await inferTargetMutation(page, step, snapshot, settings, secrets);
2839
+ const dynamicallyMutating = mutationInspection.mutating;
2840
+ const pairedNativeConfirmation = step.action === "confirm_dialog" && nativeDialog.result?.matched === true;
2841
+ if (dynamicallyMutating && !pairedNativeConfirmation && index !== approvedMutationIndex) {
2842
+ mutationStop = { step: String(index + 1), action: step.action };
2843
+ const enriched = await enrichSnapshot(page, snapshot, settings, secrets, options.snapshotScope, options.snapshotIncludeHidden);
2844
+ snapshot = enriched.snapshot;
2845
+ issuedToken = mutationAuthorizationStore.issue({
2846
+ fingerprint,
2847
+ planId,
2848
+ nextStep: index,
2849
+ variables: { ...variables },
2850
+ trace: [...trace],
2851
+ snapshot,
2852
+ initialSnapshot,
2853
+ pageUrl: typeof page.url === "function" ? page.url() : "unavailable",
2854
+ pageFingerprint: await pageFingerprint(page, snapshot),
2855
+ downloadedFiles: [...result.downloaded_files],
2856
+ accessibilityAudits: [...result.accessibility_audits],
2857
+ warnings: [...result.warnings],
2858
+ consoleErrors: [...(continuation?.consoleErrors || []), ...capture.diagnostics.console_errors],
2859
+ networkFailures: [...(continuation?.networkFailures || []), ...capture.diagnostics.network_failures],
2860
+ downloadLedger: { ...downloadLedger },
2861
+ }, settings.browser.mutationConfirmationTtlMs);
2443
2862
  break;
2444
2863
  }
2864
+ if (index === approvedMutationIndex) approvedMutationIndex = undefined;
2445
2865
  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
2866
  if (step.fragileSelector) addDiagnostic(result.warnings, `step ${index + 1} uses an explicit CSS/XPath selector as a last resort`, settings.jev.maxDiagnosticItems);
2867
+ if (step.action === "click" && plan.steps[index + 1]?.action === "confirm_dialog") {
2868
+ nativeDialog.expectedStep = interpolateStep(plan.steps[index + 1], variables);
2869
+ nativeDialog.result = undefined;
2870
+ } else if (step.action !== "confirm_dialog") {
2871
+ nativeDialog.expectedStep = undefined;
2872
+ nativeDialog.result = undefined;
2873
+ }
2447
2874
  const actionMarker = networkTracker.mark();
2448
2875
  const traceStep = await executeStep(page, step, settings, snapshot, secrets, downloadLedger, {
2449
2876
  networkTracker,
2450
2877
  networkMarker,
2878
+ autoAngularIdle: options.autoAngularIdle,
2879
+ nativeDialog,
2880
+ preResolvedTarget: mutationInspection.target,
2451
2881
  variables,
2452
2882
  });
2883
+ if (options.autoAngularIdle && ["click", "type"].includes(step.action)) {
2884
+ await waitForAngularIdle(page, step.timeoutMs ?? options.stepTimeoutMs, settings, true);
2885
+ }
2453
2886
  if (!["wait_for_text", "wait_for_condition", "assert_text", "assert_value", "assert_visible", "assert_hidden", "audit_accessibility"].includes(step.action)) {
2454
2887
  networkMarker = actionMarker;
2455
2888
  }
@@ -2539,17 +2972,19 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2539
2972
  const downloadsPassed = expectedDownloadCount > 0 && !failure
2540
2973
  && result.downloaded_files.length === expectedDownloadCount && trace.length === plan.steps.length;
2541
2974
  result.status = failure ? environmentFailure ? "environment_error" : "failed"
2542
- : dryRunStop ? "dry_run" : visible || assertionsPassed || auditsPassed || downloadsPassed ? "passed" : "incomplete";
2975
+ : mutationStop ? options.dryRun ? "dry_run" : "confirmation_required" : visible || assertionsPassed || auditsPassed || downloadsPassed ? "passed" : "incomplete";
2543
2976
  if (environmentFailure) result.navigation_error = "BROWSER_DISCONNECTED";
2544
2977
  result.expected_outcome_visible = visible;
2545
- result.acceptance_criteria_met = !failure && !dryRunStop && (visible || assertionsPassed || auditsPassed || downloadsPassed);
2978
+ result.acceptance_criteria_met = !failure && !mutationStop && (visible || assertionsPassed || auditsPassed || downloadsPassed);
2546
2979
  result.selected_plan = planId;
2547
2980
  result.steps_executed = trace.length;
2548
2981
  result.steps = trace;
2549
2982
  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})`;
2983
+ if (mutationStop) {
2984
+ if (options.dryRun) result.dry_run_stopped_before_step = mutationStop;
2985
+ else result.confirmation_required_before_step = mutationStop;
2986
+ result.confirmation_token = issuedToken;
2987
+ 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
2988
  }
2554
2989
  result.final_snapshot = snapshot;
2555
2990
  if (failure) {
@@ -2581,6 +3016,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2581
3016
  if (navigationError) result.navigation_error = navigationError;
2582
3017
  result.reason = authenticationFailure
2583
3018
  ? `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.`
3019
+ : navigationError === "AUTHENTICATION_REQUIRED"
3020
+ ? "The configured login URL or login text is visible; sign in to the application, then run the flow again."
2584
3021
  : disconnected ? "The browser session disconnected; run browser_health to reconnect, then retry the flow."
2585
3022
  : error instanceof JevBrowserError
2586
3023
  ? error.message
@@ -2639,17 +3076,23 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2639
3076
  }
2640
3077
  networkTracker.dispose();
2641
3078
  await capture.dispose();
3079
+ page.off("dialog", onNativeDialog);
2642
3080
  }
2643
3081
  if (tracePath) result.trace_path = tracePath;
2644
3082
  if (artifactErrors.length) result.artifact_errors = artifactErrors;
2645
- result.console_errors = capture.diagnostics.console_errors;
2646
- result.network_failures = capture.diagnostics.network_failures;
3083
+ result.console_errors = [...(continuation?.consoleErrors || []), ...capture.diagnostics.console_errors];
3084
+ result.network_failures = [...(continuation?.networkFailures || []), ...capture.diagnostics.network_failures];
2647
3085
  result.warnings.push(...capture.diagnostics.warnings);
2648
3086
  result.timings_ms = flowTimings(started, browserSessionMs, timings.navigationMs, timings.initialSnapshotMs, timings.readyMs, timings.decisionMs, timings.browserPlanMs);
2649
3087
  const safeResult = await redactObject(result, secrets);
2650
3088
  if (typeof safeResult.final_snapshot === "string") {
2651
3089
  safeResult.final_snapshot = redact(safeResult.final_snapshot, contentSecrets);
2652
3090
  }
3091
+ if (options.returnSnapshot === "diff" && typeof safeResult.final_snapshot === "string") {
3092
+ safeResult.final_snapshot = snapshotDiff(initialSnapshot, safeResult.final_snapshot);
3093
+ } else if (options.returnSnapshot === "none") {
3094
+ delete safeResult.final_snapshot;
3095
+ }
2653
3096
  if (options.reportPath) {
2654
3097
  try {
2655
3098
  safeResult.report_path = await writeFlowReport(safeResult, options.reportPath, settings);
@@ -2660,7 +3103,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2660
3103
  return safeResult;
2661
3104
  }
2662
3105
 
2663
- export async function chooseNextAction({ flow, pageSnapshot, actions, completedSteps = [], settings, fetchImpl = fetch }) {
3106
+ export async function chooseNextAction({ flow, pageSnapshot, actions, completedSteps = [], localOnly = false, settings, fetchImpl = fetch }) {
3107
+ if (localOnly) throw new JevBrowserError("local_only is enabled; choose_next_action cannot call the Jev provider");
2664
3108
  const { request, criteria } = buildChoicePayload({ flow, pageSnapshot, actions, completedSteps }, settings.jev);
2665
3109
  return parseDecision(await requestJev(request, settings, fetchImpl), criteria);
2666
3110
  }