@diegosouzacdv/jev-browser-mcp 0.7.0 → 0.7.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.
@@ -64,7 +64,7 @@ const FLOW_OPTION_KEYS = new Set([
64
64
  "color_scheme", "geolocation", "screenshot_on_success", "record_video", "trace_on_success",
65
65
  "continue_from_current_page", "reuse_page", "reuse_page_match", "return_snapshot", "screenshot_on_failure", "snapshot_include_hidden", "include_unnamed_controls", "snapshot_scope", "step_timeout_seconds", "stop_on_expected",
66
66
  "trace_on_failure", "console_levels", "fresh_context", "clear_storage", "wait_for_http", "storage_state",
67
- "ready",
67
+ "ready", "capture_network", "capture_network_url_contains",
68
68
  ]);
69
69
  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;
70
70
 
@@ -226,10 +226,13 @@ function actionSchemas(semanticTarget) {
226
226
  confirm_modal: { required: ["expected_text", "button"], optional: [...semanticTarget, "trigger", "confirm", "timeout_seconds"] },
227
227
  check: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
228
228
  uncheck: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
229
- navigate_menu: { required: ["path"], optional: ["toggle_selector", "timeout_seconds"] },
230
- assert_network: { required: ["url_contains", "method", "status"], optional: ["message_contains", "timeout_seconds", "timeout_ms"] },
231
- assert_ws: { required: ["url_contains"], optional: ["message_contains", "json", "close_code", "timeout_seconds", "timeout_ms"] },
232
- wait_for_request: { required: ["url"], optional: ["method", "status", "timeout_seconds", "timeout_ms"] },
229
+ navigate_menu: { required: [], optional: ["path", "screen", "toggle_selector", "timeout_seconds"] },
230
+ assert_network: { required: ["url_contains", "method", "status"], optional: ["message_contains", "timeout_seconds", "timeout_ms"] },
231
+ assert_ws: { required: ["url_contains"], optional: ["message_contains", "json", "close_code", "timeout_seconds", "timeout_ms"] },
232
+ wait_for_request: { required: ["url"], optional: ["method", "status", "timeout_seconds", "timeout_ms"] },
233
+ http_request: { required: ["url"], optional: ["method", "include_body", "timeout_seconds", "timeout_ms"] },
234
+ inspect_cookies: { required: [], optional: ["timeout_seconds", "timeout_ms"] },
235
+ read_angular_state: { required: [], optional: ["timeout_seconds", "timeout_ms"] },
233
236
  extract: { required: ["as"], optional: [...semanticTarget, "source", "attribute", "sensitive", "timeout_seconds", "timeout_ms"] },
234
237
  evaluate: { required: ["expression"], optional: ["as", "timeout_seconds", "timeout_ms"] },
235
238
  screenshot: { required: [], optional: ["full_page", "timeout_seconds", "timeout_ms"] },
@@ -257,21 +260,37 @@ function actionSchemas(semanticTarget) {
257
260
  };
258
261
  }
259
262
 
260
- export function describeBrowserActions() {
263
+ export function describeBrowserActions() {
261
264
  const semanticTarget = ["frame", "comment", "within", "index", "near", "label", "placeholder", "title", "text", "test_id", "selector", "target_text", "role", "name", "name_match", "target_page"];
262
265
  const schemas = actionSchemas(semanticTarget);
263
- return {
264
- actions: Object.fromEntries(Object.entries(schemas).map(([action, schema]) => [action, {
265
- required: [...schema.required],
266
- optional: [...new Set([...schema.optional, "timeout_ms", "mutating", "screenshot"])],
267
- }])),
266
+ return {
267
+ required_flow_fields: ["flow", "candidate_plans"],
268
+ candidate_plan: { required: ["description", "steps"], optional: ["comment", "when"] },
269
+ actions: Object.fromEntries(Object.entries(schemas).map(([action, schema]) => {
270
+ const contract = {
271
+ required: [...schema.required],
272
+ optional: [...new Set([...schema.optional, "timeout_ms", "mutating", "screenshot"])],
273
+ };
274
+ if (action === "extract") contract.conditional_required = { attribute: "required when source is attribute" };
275
+ if (action === "type") contract.conditional_required = { text: "exactly one of text or text_env is required" };
276
+ if (action === "navigate_menu") contract.conditional_required = { path: "exactly one of path or screen is required" };
277
+ if (action === "select_option") contract.conditional_required = { option: "exactly one of option, value or label_contains is required" };
278
+ return [action, contract];
279
+ })),
268
280
  options: [...FLOW_OPTION_KEYS],
269
281
  option_shapes: {
282
+ capture_network: ["errors", "all", "none"],
283
+ capture_network_url_contains: "optional URL substring filter for capture_network=all",
284
+ local_only: "required for http_request, read_angular_state and inspect_cookies; only localhost loopback is allowed",
270
285
  ready: ["network_idle", { hidden_text: "text that must disappear" }, { selector: "CSS selector", has_value: true }],
271
286
  return_snapshot: ["diff", "full", "none"],
272
287
  include_unnamed_controls: "boolean; false by default",
273
288
  reuse_page_match: ["url", "path"],
274
289
  },
290
+ step_shapes: {
291
+ frame: ["first", { url_contains: "iframe URL substring" }, "exact iframe name or title"],
292
+ navigate_menu: ["path: menu label array", "screen: SAFI Acesso Rápido name or screen code"],
293
+ },
275
294
  expected_outcome: {
276
295
  text: "string description or {text: string} assertion",
277
296
  request: "{request: 'PUT */path', status: 200, message_contains?: string}; request may include a method prefix",
@@ -285,10 +304,71 @@ export function describeBrowserActions() {
285
304
  confirm_modal: { trigger: "button accessible name", confirm: "button", expected_text: "required exact safety phrase" },
286
305
  },
287
306
  placeholders: "Use {name}; declare params.name or extract it before use. Unknown and {{double-brace}} placeholders are rejected.",
288
- };
289
- }
290
-
291
- export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps = settings.browser.maxFlowSteps) {
307
+ };
308
+ }
309
+
310
+ export function collectFlowContractIssues({ flow, candidatePlans }) {
311
+ const issues = [];
312
+ if (typeof flow !== "string" || !flow.trim()) issues.push("flow: required non-empty string");
313
+ if (!isRecord(candidatePlans) || !Object.keys(candidatePlans).length) {
314
+ issues.push("candidate_plans: required non-empty object");
315
+ return issues;
316
+ }
317
+ const schemas = actionSchemas(["frame", "comment", "within", "index", "near", ...LOCATOR_FIELDS, "target_text", "role", "name", "name_match", "target_page"]);
318
+ for (const [planId, plan] of Object.entries(candidatePlans)) {
319
+ if (!isRecord(plan)) {
320
+ issues.push(`candidate_plans.${planId}: must be an object`);
321
+ continue;
322
+ }
323
+ if (typeof plan.description !== "string" || !plan.description.trim()) issues.push(`candidate_plans.${planId}.description: required non-empty string`);
324
+ if (plan.when !== undefined && (!isRecord(plan.when) || typeof plan.when.visible !== "string" || !plan.when.visible.trim())) {
325
+ issues.push(`candidate_plans.${planId}.when.visible: required non-empty string when is provided`);
326
+ }
327
+ if (!Array.isArray(plan.steps) || !plan.steps.length) {
328
+ issues.push(`candidate_plans.${planId}.steps: required non-empty array`);
329
+ continue;
330
+ }
331
+ for (const [index, step] of plan.steps.entries()) {
332
+ const prefix = `candidate_plans.${planId}.steps[${index}]`;
333
+ if (!isRecord(step) || typeof step.action !== "string") {
334
+ issues.push(`${prefix}.action: required supported action`);
335
+ continue;
336
+ }
337
+ let normalizedStep;
338
+ try { normalizedStep = normalizeStepAliases(step); }
339
+ catch (error) {
340
+ issues.push(`${prefix}: ${error instanceof Error ? error.message : "invalid aliases"}`);
341
+ normalizedStep = step;
342
+ }
343
+ const schema = schemas[normalizedStep.action];
344
+ if (!schema) {
345
+ issues.push(`${prefix}.action: unsupported action ${JSON.stringify(normalizedStep.action)}`);
346
+ continue;
347
+ }
348
+ for (const field of schema.required) {
349
+ if (!Object.hasOwn(normalizedStep, field)) issues.push(`${prefix}.${field}: required for ${normalizedStep.action}`);
350
+ }
351
+ if (normalizedStep.action === "extract" && normalizedStep.source === "attribute" && !Object.hasOwn(normalizedStep, "attribute")) {
352
+ issues.push(`${prefix}.attribute: required when extract.source is attribute`);
353
+ }
354
+ if (normalizedStep.action === "type"
355
+ && (Object.hasOwn(normalizedStep, "text") === Object.hasOwn(normalizedStep, "text_env"))) {
356
+ issues.push(`${prefix}: exactly one of text or text_env is required for type`);
357
+ }
358
+ if (normalizedStep.action === "navigate_menu"
359
+ && (Object.hasOwn(normalizedStep, "path") === Object.hasOwn(normalizedStep, "screen"))) {
360
+ issues.push(`${prefix}: exactly one of path or screen is required for navigate_menu`);
361
+ }
362
+ if (normalizedStep.action === "select_option"
363
+ && ["option", "value", "label_contains"].filter((field) => Object.hasOwn(normalizedStep, field)).length !== 1) {
364
+ issues.push(`${prefix}: exactly one of option, value or label_contains is required for select_option`);
365
+ }
366
+ }
367
+ }
368
+ return issues;
369
+ }
370
+
371
+ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps = settings.browser.maxFlowSteps) {
292
372
  if (!isRecord(candidatePlans)) throw new JevBrowserError("candidate_plans must be an object");
293
373
  if (Object.keys(candidatePlans).length > settings.jev.maxActionCount) {
294
374
  throw new JevBrowserError("candidate_plans exceeds the configured candidate limit");
@@ -482,12 +562,18 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
482
562
  if (!step.role) step.role = "checkbox";
483
563
  if (action === "uncheck" && step.role === "radio") throw new JevBrowserError("uncheck does not support radio controls");
484
564
  }
485
- if (action === "navigate_menu") {
486
- if (!Array.isArray(rawStep.path) || rawStep.path.length === 0 || rawStep.path.length > 8) {
487
- throw new JevBrowserError("navigate_menu path must contain between 1 and 8 menu labels");
488
- }
489
- step.path = rawStep.path.map((label) => requiredText(label, "menu path label", settings.jev.maxActionDescriptionChars));
490
- step.toggleSelector = rawStep.toggle_selector === undefined
565
+ if (action === "navigate_menu") {
566
+ if ((rawStep.path === undefined) === (rawStep.screen === undefined)) {
567
+ throw new JevBrowserError("navigate_menu needs exactly one of path or screen");
568
+ }
569
+ if (rawStep.path !== undefined) {
570
+ if (!Array.isArray(rawStep.path) || rawStep.path.length === 0 || rawStep.path.length > 8) {
571
+ throw new JevBrowserError("navigate_menu path must contain between 1 and 8 menu labels");
572
+ }
573
+ step.path = rawStep.path.map((label) => requiredText(label, "menu path label", settings.jev.maxActionDescriptionChars));
574
+ }
575
+ if (rawStep.screen !== undefined) step.screen = requiredText(rawStep.screen, "quick access screen name or code", settings.jev.maxActionDescriptionChars);
576
+ step.toggleSelector = rawStep.toggle_selector === undefined
491
577
  ? "#open_btn"
492
578
  : requiredText(rawStep.toggle_selector, "toggle_selector", settings.jev.maxActionDescriptionChars);
493
579
  }
@@ -577,15 +663,24 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
577
663
  if (!step.messageContains && !step.json && rawStep.close_code === undefined) throw new JevBrowserError("assert_ws needs message_contains, json, or close_code");
578
664
  step.closeCode = rawStep.close_code;
579
665
  }
580
- if (action === "wait_for_request") {
581
- step.urlPattern = requiredText(rawStep.url, "wait_for_request url", settings.jev.maxActionDescriptionChars);
666
+ if (action === "wait_for_request") {
667
+ step.urlPattern = requiredText(rawStep.url, "wait_for_request url", settings.jev.maxActionDescriptionChars);
582
668
  step.method = rawStep.method === undefined ? undefined : requiredText(rawStep.method, "request method", 16).toUpperCase();
583
669
  if (step.method && !/^[A-Z]+$/.test(step.method)) throw new JevBrowserError("wait_for_request method must contain letters only");
584
670
  if (rawStep.status !== undefined && (!Number.isSafeInteger(rawStep.status) || rawStep.status < 100 || rawStep.status > 599)) {
585
671
  throw new JevBrowserError("wait_for_request status must be an HTTP status from 100 to 599");
586
- }
587
- step.status = rawStep.status;
588
- }
672
+ }
673
+ step.status = rawStep.status;
674
+ }
675
+ if (action === "http_request") {
676
+ step.url = requiredText(rawStep.url, "http_request URL", settings.jev.maxActionDescriptionChars);
677
+ step.method = rawStep.method === undefined ? "GET" : requiredText(rawStep.method, "http_request method", 16).toUpperCase();
678
+ if (step.method !== "GET") throw new JevBrowserError("http_request supports GET only");
679
+ if (rawStep.include_body !== undefined && typeof rawStep.include_body !== "boolean") {
680
+ throw new JevBrowserError("http_request include_body must be a boolean");
681
+ }
682
+ step.includeBody = rawStep.include_body ?? false;
683
+ }
589
684
  if (action === "evaluate") {
590
685
  step.expression = requiredText(rawStep.expression, "read-only expression", 256);
591
686
  if (!/^(?:window|document|navigator|location)(?:\.[A-Za-z_$][A-Za-z0-9_$]*){1,8}$/.test(step.expression)) {
@@ -744,7 +839,13 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
744
839
  throw new JevBrowserError(`${action} labels must distinguish liked and unliked state`);
745
840
  }
746
841
  }
747
- if (rawStep.frame !== undefined) step.frame = requiredText(rawStep.frame, "frame name", settings.jev.maxActionDescriptionChars);
842
+ if (rawStep.frame !== undefined) {
843
+ if (rawStep.frame === "first") step.frame = "first";
844
+ else if (isRecord(rawStep.frame)) {
845
+ exactKeys(rawStep.frame, ["url_contains"], [], "frame");
846
+ step.frame = { urlContains: requiredText(rawStep.frame.url_contains, "frame URL substring", settings.jev.maxActionDescriptionChars) };
847
+ } else step.frame = requiredText(rawStep.frame, "frame name", settings.jev.maxActionDescriptionChars);
848
+ }
748
849
  return step;
749
850
  });
750
851
  plans[id] = { description, steps, ...(rawPlan.when ? { when: { visible: rawPlan.when.visible } } : {}) };
@@ -958,10 +1059,18 @@ function resolveFlowOptions(rawOptions, settings) {
958
1059
  if (storageState !== undefined && !/^[A-Za-z0-9_-]+$/.test(storageState)) {
959
1060
  throw new JevBrowserError("options.storage_state must be a simple account name using letters, numbers, _ or -");
960
1061
  }
961
- if (storageState && (rawOptions.fresh_context || rawOptions.clear_storage)) {
962
- throw new JevBrowserError("options.storage_state cannot be combined with fresh_context or clear_storage");
963
- }
964
- let reportPath;
1062
+ if (storageState && (rawOptions.fresh_context || rawOptions.clear_storage)) {
1063
+ throw new JevBrowserError("options.storage_state cannot be combined with fresh_context or clear_storage");
1064
+ }
1065
+ const configuredNetworkErrors = rawOptions.capture_network_errors ?? defaults.captureNetworkErrors;
1066
+ const configuredNetworkErrorBodies = rawOptions.capture_network_error_bodies ?? defaults.captureNetworkErrorBodies;
1067
+ const captureNetwork = rawOptions.capture_network ?? (configuredNetworkErrors || configuredNetworkErrorBodies ? "errors" : "none");
1068
+ if (!new Set(["none", "errors", "all"]).has(captureNetwork)) {
1069
+ throw new JevBrowserError("options.capture_network must be none, errors, or all");
1070
+ }
1071
+ const captureNetworkUrlContains = rawOptions.capture_network_url_contains === undefined
1072
+ ? undefined : requiredText(rawOptions.capture_network_url_contains, "capture_network_url_contains", settings.jev.maxActionDescriptionChars);
1073
+ let reportPath;
965
1074
  if (rawOptions.report_path !== undefined) {
966
1075
  if (typeof rawOptions.report_path !== "string" || !path.isAbsolute(rawOptions.report_path)) {
967
1076
  throw new JevBrowserError("options.report_path must be an absolute path inside the configured artifact directory");
@@ -977,8 +1086,10 @@ function resolveFlowOptions(rawOptions, settings) {
977
1086
  blockFonts: rawOptions.block_fonts ?? defaults.blockFonts,
978
1087
  busySelectors: Object.freeze(busySelectors),
979
1088
  captureConsoleErrors: rawOptions.capture_console_errors ?? defaults.captureConsoleErrors,
980
- captureNetworkErrorBodies: rawOptions.capture_network_error_bodies ?? defaults.captureNetworkErrorBodies,
981
- captureNetworkErrors: rawOptions.capture_network_errors ?? defaults.captureNetworkErrors,
1089
+ captureNetworkErrorBodies: captureNetwork !== "none" && configuredNetworkErrorBodies,
1090
+ captureNetworkErrors: captureNetwork === "errors" || captureNetwork === "all",
1091
+ captureNetwork,
1092
+ captureNetworkUrlContains,
982
1093
  dryRun: rawOptions.dry_run ?? false,
983
1094
  fastPath: rawOptions.fast_path ?? defaults.fastPath,
984
1095
  localOnly: rawOptions.local_only ?? defaults.localOnly,
@@ -1145,9 +1256,36 @@ function providerSafeSnapshot(snapshot, secrets) {
1145
1256
  });
1146
1257
  }
1147
1258
 
1148
- function localHostOnly(url) {
1149
- return new Set(["localhost", "127.0.0.1", "::1", "[::1]"]).has(new URL(url).hostname.toLowerCase());
1150
- }
1259
+ function localHostOnly(url) {
1260
+ return new Set(["localhost", "127.0.0.1", "::1", "[::1]"]).has(new URL(url).hostname.toLowerCase());
1261
+ }
1262
+
1263
+ const SAFE_RESPONSE_HEADER_NAMES = new Set([
1264
+ "cache-control", "content-length", "content-type", "date", "etag", "last-modified", "retry-after", "vary",
1265
+ ]);
1266
+
1267
+ function safeResponseHeaders(headers) {
1268
+ return Object.fromEntries(Object.entries(headers || {})
1269
+ .filter(([name, value]) => SAFE_RESPONSE_HEADER_NAMES.has(name.toLowerCase()) && typeof value === "string")
1270
+ .map(([name, value]) => [name.toLowerCase(), value.slice(0, 256)]));
1271
+ }
1272
+
1273
+ function assertLocalDebugPage(page, localOnly, action) {
1274
+ const currentUrl = page.url();
1275
+ if (!localOnly || !currentUrl || currentUrl === "about:blank" || !localHostOnly(currentUrl)) {
1276
+ throw new JevBrowserError(`${action} requires options.local_only: true and a localhost page`);
1277
+ }
1278
+ }
1279
+
1280
+ function redactLocalPayload(value) {
1281
+ if (Array.isArray(value)) return value.map(redactLocalPayload);
1282
+ if (!isRecord(value)) return value;
1283
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
1284
+ key,
1285
+ /(?:auth|cookie|credential|secret|password|token|session|user|usuario|nome|email|cpf|cnpj|matricula|identity)/i.test(key)
1286
+ ? "[redacted]" : redactLocalPayload(item),
1287
+ ]));
1288
+ }
1151
1289
 
1152
1290
  async function resolveFakeMediaPath(fakeMedia, settings) {
1153
1291
  if (!fakeMedia) return undefined;
@@ -1665,12 +1803,17 @@ async function evaluateExpectedOutcome(expected, snapshot, networkTracker, marke
1665
1803
  return checks;
1666
1804
  }
1667
1805
 
1668
- function createNetworkTracker(page, settings, loginUrlContains = []) {
1669
- const active = new Map();
1670
- const history = [];
1806
+ function createNetworkTracker(page, settings, loginUrlContains = [], watchPatterns = []) {
1807
+ const active = new Map();
1808
+ const history = [];
1809
+ const watchedHistory = [];
1671
1810
  const websocketHistory = [];
1672
1811
  const websocketUrls = new Map();
1673
1812
  const websocketObserverPromises = new Set();
1813
+ const blockedCookieEvents = [];
1814
+ const cdpRequestUrls = new Map();
1815
+ const cdpSessions = new Set();
1816
+ const pendingHeaderReads = new Set();
1674
1817
  const ignored = new Set();
1675
1818
  let sequence = 0;
1676
1819
  const shouldIgnore = (request) => {
@@ -1684,21 +1827,41 @@ function createNetworkTracker(page, settings, loginUrlContains = []) {
1684
1827
  return undefined;
1685
1828
  }
1686
1829
  if (ignored.has(request)) return undefined;
1687
- const entry = { sequence: ++sequence, type, url: request.url(), method: request.method(), at: performance.now(), request };
1688
- if (type === "request") active.set(request, entry);
1689
- else if (type === "finished" || type === "failed") active.delete(request);
1690
- history.push(entry);
1691
- const historyLimit = settings.browser.maxNetworkHistoryEvents;
1692
- if (history.length > historyLimit) history.splice(0, history.length - historyLimit);
1830
+ const startedAt = active.get(request)?.at;
1831
+ const entry = {
1832
+ sequence: ++sequence, type, url: request.url(), method: request.method(),
1833
+ resourceType: request.resourceType(), at: performance.now(), request,
1834
+ };
1835
+ if (startedAt !== undefined && type !== "request") entry.durationMs = Math.max(0, entry.at - startedAt);
1836
+ if (type === "request") active.set(request, entry);
1837
+ else if (type === "finished" || type === "failed") {
1838
+ const original = active.get(request);
1839
+ if (original && entry.durationMs !== undefined) original.durationMs = entry.durationMs;
1840
+ active.delete(request);
1841
+ }
1842
+ history.push(entry);
1843
+ const historyLimit = settings.browser.maxNetworkHistoryEvents;
1844
+ if (history.length > historyLimit) history.splice(0, history.length - historyLimit);
1845
+ if (watchPatterns.some((pattern) => pattern.includes("*")
1846
+ ? urlPatternMatcher(pattern).test(entry.url) : entry.url.includes(pattern))) {
1847
+ watchedHistory.push(entry);
1848
+ if (watchedHistory.length > historyLimit) watchedHistory.splice(0, watchedHistory.length - historyLimit);
1849
+ }
1693
1850
  return entry;
1694
1851
  };
1695
1852
  const onRequest = (request) => record("request", request);
1696
- const onResponse = (response) => {
1697
- const entry = record("response", response.request());
1698
- if (!entry) return;
1699
- entry.response = response;
1700
- entry.status = response.status();
1701
- };
1853
+ const onResponse = (response) => {
1854
+ const entry = record("response", response.request());
1855
+ if (!entry) return;
1856
+ entry.response = response;
1857
+ entry.status = response.status();
1858
+ const requestEntry = active.get(response.request());
1859
+ if (requestEntry && entry.durationMs !== undefined) requestEntry.durationMs = entry.durationMs;
1860
+ const headerRead = Promise.resolve().then(() => response.allHeaders())
1861
+ .then((headers) => { entry.responseHeaders = safeResponseHeaders(headers); }).catch(() => {});
1862
+ pendingHeaderReads.add(headerRead);
1863
+ void headerRead.finally(() => pendingHeaderReads.delete(headerRead));
1864
+ };
1702
1865
  const onFinished = (request) => record("finished", request);
1703
1866
  const onFailed = (request) => record("failed", request);
1704
1867
  const trackedPages = new Set();
@@ -1717,9 +1880,41 @@ function createNetworkTracker(page, settings, loginUrlContains = []) {
1717
1880
  };
1718
1881
  const attachCdpWebSocketObserver = async (trackedPage) => {
1719
1882
  try {
1720
- const session = await trackedPage.context().newCDPSession(trackedPage);
1721
- await session.send("Network.enable");
1722
- session.on("Network.webSocketCreated", ({ requestId, url }) => websocketUrls.set(requestId, url));
1883
+ const session = await trackedPage.context().newCDPSession(trackedPage);
1884
+ cdpSessions.add(session);
1885
+ await session.send("Network.enable");
1886
+ session.on("Network.requestWillBeSent", ({ requestId, request }) => {
1887
+ if (request?.url) {
1888
+ cdpRequestUrls.set(requestId, request.url);
1889
+ while (cdpRequestUrls.size > settings.browser.maxNetworkHistoryEvents) {
1890
+ cdpRequestUrls.delete(cdpRequestUrls.keys().next().value);
1891
+ }
1892
+ }
1893
+ });
1894
+ session.on("Network.responseReceivedExtraInfo", ({ requestId, statusCode, headers, blockedCookies }) => {
1895
+ const url = cdpRequestUrls.get(requestId);
1896
+ if (!url) return;
1897
+ const blocked = Array.isArray(blockedCookies) ? blockedCookies.map(({ cookie, blockedReasons }) => ({
1898
+ name: String(cookie?.name || "").slice(0, 128),
1899
+ domain: String(cookie?.domain || "").slice(0, 255),
1900
+ path: String(cookie?.path || "").slice(0, 256),
1901
+ secure: Boolean(cookie?.secure),
1902
+ http_only: Boolean(cookie?.httpOnly),
1903
+ same_site: cookie?.sameSite,
1904
+ blocked_reasons: Array.isArray(blockedReasons) ? blockedReasons.map((reason) => String(reason).slice(0, 128)) : [],
1905
+ })) : [];
1906
+ if (blocked.length) {
1907
+ blockedCookieEvents.push({ sequence: ++sequence, url, status: statusCode, cookies: blocked });
1908
+ if (blockedCookieEvents.length > settings.browser.maxNetworkHistoryEvents) {
1909
+ blockedCookieEvents.splice(0, blockedCookieEvents.length - settings.browser.maxNetworkHistoryEvents);
1910
+ }
1911
+ }
1912
+ const safeHeaders = safeResponseHeaders(headers);
1913
+ const matching = [...history, ...watchedHistory].reverse().find((event) => event.type === "response" && event.url === url);
1914
+ if (matching && Object.keys(safeHeaders).length) matching.responseHeaders = safeHeaders;
1915
+ cdpRequestUrls.delete(requestId);
1916
+ });
1917
+ session.on("Network.webSocketCreated", ({ requestId, url }) => websocketUrls.set(requestId, url));
1723
1918
  session.on("Network.webSocketFrameReceived", ({ requestId, response }) => {
1724
1919
  if (response?.opcode !== 8 || typeof response.payloadData !== "string") return;
1725
1920
  try {
@@ -1732,7 +1927,10 @@ function createNetworkTracker(page, settings, loginUrlContains = []) {
1732
1927
  } catch { /* A non-binary close payload has no status code. */ }
1733
1928
  });
1734
1929
  session.on("Network.webSocketClosed", ({ requestId }) => websocketUrls.delete(requestId));
1735
- trackedPage.once("close", () => { void session.detach().catch(() => {}); });
1930
+ trackedPage.once("close", () => {
1931
+ cdpSessions.delete(session);
1932
+ void session.detach().catch(() => {});
1933
+ });
1736
1934
  } catch {
1737
1935
  // WebSocket close-code assertions are available only on Chromium CDP sessions.
1738
1936
  }
@@ -1760,15 +1958,18 @@ function createNetworkTracker(page, settings, loginUrlContains = []) {
1760
1958
  websocketObserverPromises.add(ready);
1761
1959
  return ready;
1762
1960
  };
1763
- attachPage(page);
1961
+ const historySince = (marker) => [...new Map([...history, ...watchedHistory]
1962
+ .filter((entry) => entry.sequence > marker)
1963
+ .map((entry) => [entry.sequence, entry])).values()].sort((left, right) => left.sequence - right.sequence);
1964
+ attachPage(page);
1764
1965
  return {
1765
1966
  mark: () => sequence,
1766
1967
  attachPage,
1767
1968
  waitForWebSocketObservers: async () => Promise.allSettled([...websocketObserverPromises]),
1768
1969
  since: (marker, urlContains) => history.filter((entry) => entry.sequence > marker
1769
1970
  && (urlContains === undefined || entry.url.includes(urlContains))),
1770
- responsesSince: (marker, urlContains, method) => history.filter((entry) => entry.sequence > marker
1771
- && entry.type === "response" && entry.url.includes(urlContains) && entry.method === method),
1971
+ responsesSince: (marker, urlContains, method) => historySince(marker).filter((entry) =>
1972
+ entry.type === "response" && entry.url.includes(urlContains) && entry.method === method),
1772
1973
  websocketSince: (marker, urlContains) => websocketHistory.filter((entry) => entry.sequence > marker && entry.url.includes(urlContains)),
1773
1974
  collectWebSocketCloseCodes: async () => {
1774
1975
  for (const trackedPage of trackedPages) {
@@ -1791,13 +1992,35 @@ function createNetworkTracker(page, settings, loginUrlContains = []) {
1791
1992
  }
1792
1993
  }
1793
1994
  },
1794
- historySince: (marker) => history.filter((entry) => entry.sequence > marker),
1995
+ historySince,
1996
+ networkEventsSince: (marker, urlContains, limit = settings.jev.maxDiagnosticItems) => {
1997
+ const events = historySince(marker);
1998
+ const entries = events.filter((entry) => entry.type === "request"
1999
+ && (urlContains === undefined || entry.url.includes(urlContains)));
2000
+ return entries.slice(-limit).map((requestEntry) => {
2001
+ const response = events.find((event) => event.type === "response" && event.request === requestEntry.request);
2002
+ const terminal = events.find((event) => (event.type === "finished" || event.type === "failed")
2003
+ && event.request === requestEntry.request);
2004
+ return {
2005
+ url: requestEntry.url,
2006
+ method: requestEntry.method,
2007
+ resource_type: requestEntry.resourceType,
2008
+ ...(response ? { status: response.status } : {}),
2009
+ ...(requestEntry.durationMs !== undefined ? { duration_ms: Math.round(requestEntry.durationMs * 10) / 10 }
2010
+ : terminal?.durationMs !== undefined ? { duration_ms: Math.round(terminal.durationMs * 10) / 10 } : {}),
2011
+ ...(response?.responseHeaders ? { response_headers: response.responseHeaders } : {}),
2012
+ };
2013
+ });
2014
+ },
2015
+ blockedCookiesSince: (marker, urlContains, limit = settings.jev.maxDiagnosticItems) => blockedCookieEvents
2016
+ .filter((event) => event.sequence > marker && (urlContains === undefined || event.url.includes(urlContains)))
2017
+ .slice(-limit),
1795
2018
  authenticationFailureSince: (marker) => history.find((entry) => {
1796
2019
  if (entry.sequence <= marker || entry.type !== "response" || ![401, 403].includes(entry.status)) return false;
1797
2020
  return loginUrlContains.some((needle) => entry.url.toLocaleLowerCase().includes(needle.toLocaleLowerCase()));
1798
2021
  }),
1799
2022
  activeFor: (urlContains) => [...active.values()].filter((entry) => entry.url.includes(urlContains)),
1800
- dispose: () => {
2023
+ dispose: async () => {
1801
2024
  for (const trackedPage of trackedPages) {
1802
2025
  trackedPage.off("request", onRequest);
1803
2026
  trackedPage.off("response", onResponse);
@@ -1805,11 +2028,14 @@ function createNetworkTracker(page, settings, loginUrlContains = []) {
1805
2028
  trackedPage.off("requestfailed", onFailed);
1806
2029
  trackedPage.off("websocket", onWebSocket);
1807
2030
  }
1808
- trackedPages.clear();
1809
- active.clear();
1810
- ignored.clear();
1811
- history.length = 0;
1812
- websocketHistory.length = 0;
2031
+ trackedPages.clear();
2032
+ active.clear();
2033
+ ignored.clear();
2034
+ await Promise.allSettled([...pendingHeaderReads]);
2035
+ pendingHeaderReads.clear();
2036
+ cdpRequestUrls.clear();
2037
+ await Promise.allSettled([...cdpSessions].map((session) => session.detach()));
2038
+ cdpSessions.clear();
1813
2039
  },
1814
2040
  };
1815
2041
  }
@@ -1922,10 +2148,13 @@ async function networkResponseMessage(response, page, settings, secrets) {
1922
2148
  }
1923
2149
  }
1924
2150
 
1925
- async function waitForNetworkAssertion(tracker, step, marker, timeout, page, settings, secrets) {
1926
- const started = performance.now();
1927
- while (performance.now() - started < timeout) {
1928
- const [entry] = tracker.responsesSince(marker, step.urlContains, step.method);
2151
+ async function waitForNetworkAssertion(tracker, step, marker, initialNavigationMarker, timeout, page, settings, secrets) {
2152
+ const started = performance.now();
2153
+ while (performance.now() - started < timeout) {
2154
+ const [stepResponse] = tracker.responsesSince(marker, step.urlContains, step.method);
2155
+ const entry = stepResponse || (initialNavigationMarker === undefined
2156
+ ? undefined
2157
+ : tracker.responsesSince(initialNavigationMarker, step.urlContains, step.method).at(-1));
1929
2158
  if (entry) {
1930
2159
  if (entry.status !== step.status) {
1931
2160
  throw new JevBrowserError(`assert_network expected ${step.method} ${JSON.stringify(redact(step.urlContains, secrets))} status ${step.status}, received ${entry.status}`);
@@ -2651,19 +2880,29 @@ async function namedTarget(root, step, snapshot, secrets, timeout, settings) {
2651
2880
  return locatorAt(locator, match.visible[0]);
2652
2881
  }
2653
2882
 
2654
- async function stepRoot(page, frameName) {
2655
- if (!frameName) return page;
2656
- const frames = [];
2657
- for (const frame of page.frames().filter((candidate) => candidate !== page.mainFrame())) {
2658
- const name = await frameDisplayName(frame);
2659
- if (name) frames.push({ frame, name });
2660
- }
2661
- const matches = frames.filter(({ name }) => name === frameName);
2662
- if (matches.length !== 1) {
2663
- const available = frames.map(({ name }) => name).slice(0, 5);
2664
- const suffix = available.length ? `; available frames: ${available.map((name) => JSON.stringify(name)).join(", ")}` : "";
2665
- throw new JevBrowserError(`frame ${JSON.stringify(frameName)} must match exactly one named iframe; found ${matches.length}${suffix}`);
2666
- }
2883
+ async function stepRoot(page, frameName) {
2884
+ if (!frameName) return page;
2885
+ const frames = page.frames().filter((candidate) => candidate !== page.mainFrame());
2886
+ if (frameName === "first") {
2887
+ if (frames.length) return frames[0];
2888
+ throw new JevBrowserError("frame first found no iframe on the page");
2889
+ }
2890
+ if (isRecord(frameName) && frameName.urlContains) {
2891
+ const matches = frames.filter((frame) => frame.url().includes(frameName.urlContains));
2892
+ if (matches.length === 1) return matches[0];
2893
+ throw new JevBrowserError(`frame URL substring ${JSON.stringify(frameName.urlContains)} must match exactly one iframe; found ${matches.length}`);
2894
+ }
2895
+ const namedFrames = [];
2896
+ for (const frame of frames) {
2897
+ const name = await frameDisplayName(frame);
2898
+ if (name) namedFrames.push({ frame, name });
2899
+ }
2900
+ const matches = namedFrames.filter(({ name }) => name === frameName);
2901
+ if (matches.length !== 1) {
2902
+ const available = namedFrames.map(({ name }) => name).slice(0, 5);
2903
+ const suffix = available.length ? `; available frames: ${available.map((name) => JSON.stringify(name)).join(", ")}` : "";
2904
+ throw new JevBrowserError(`frame ${JSON.stringify(frameName)} must match exactly one named iframe; found ${matches.length}${suffix}; use "first" or {url_contains: "..."} for unnamed frames`);
2905
+ }
2667
2906
  return matches[0].frame;
2668
2907
  }
2669
2908
 
@@ -2859,8 +3098,26 @@ async function visibleMenuItem(root, label, timeout, settings) {
2859
3098
  return locatorAt(textLocator, visible.at(-1));
2860
3099
  }
2861
3100
 
2862
- async function navigateMenu(page, root, step, settings, execution) {
2863
- let item;
3101
+ async function navigateMenu(page, root, step, settings, execution) {
3102
+ if (step.screen) {
3103
+ const search = page.locator("#pesquisa");
3104
+ const { visible } = await visibleIndexes(search, step.timeoutMs ?? settings.browser.actionTimeoutMs, "SAFI Acesso Rápido search", settings);
3105
+ if (visible.length !== 1) throw new JevBrowserError(`SAFI Acesso Rápido search must match exactly one visible input; found ${visible.length}`);
3106
+ await locatorAt(search, visible[0]).fill(step.screen, { timeout: step.timeoutMs ?? settings.browser.actionTimeoutMs });
3107
+ const timeout = step.timeoutMs ?? settings.browser.actionTimeoutMs;
3108
+ const allRows = page.locator("#ConsultaTelas tbody tr");
3109
+ const exactRows = allRows.filter({ has: page.getByText(step.screen, { exact: true }) });
3110
+ const exactMatches = await visibleIndexes(exactRows, timeout, "exact SAFI Acesso Rápido result", settings, { allowNone: true });
3111
+ const rows = exactMatches.visible.length
3112
+ ? exactRows
3113
+ : allRows.filter({ has: page.getByText(step.screen, { exact: false }) });
3114
+ const row = await exactlyOne(rows, `SAFI Acesso Rápido result ${JSON.stringify(step.screen)}`, timeout, settings);
3115
+ const link = await exactlyOne(row.getByRole("link", { name: new RegExp("Acessar", "i") }), "SAFI Acesso Rápido result link", step.timeoutMs ?? settings.browser.actionTimeoutMs, settings);
3116
+ await link.click({ timeout: step.timeoutMs ?? settings.browser.actionTimeoutMs });
3117
+ await page.locator("#pesquisa").waitFor({ state: "hidden", timeout: step.timeoutMs ?? settings.browser.actionTimeoutMs });
3118
+ return { action: step.action, navigated_screen: step.screen, navigation_source: "safi_quick_access" };
3119
+ }
3120
+ let item;
2864
3121
  try {
2865
3122
  item = await visibleMenuItem(root, step.path[0], 250, settings);
2866
3123
  } catch {
@@ -2879,13 +3136,13 @@ async function navigateMenu(page, root, step, settings, execution) {
2879
3136
  return { action: step.action, navigated_menu: step.path };
2880
3137
  }
2881
3138
 
2882
- async function executeStep(page, step, settings, snapshot, secrets, downloadLedger, execution = {}) {
2883
- const root = await stepRoot(page, step.frame);
2884
- const timeout = step.timeoutMs ?? settings.browser.actionTimeoutMs;
3139
+ async function executeStep(page, step, settings, snapshot, secrets, downloadLedger, execution = {}) {
3140
+ const root = await stepRoot(page, step.frame);
3141
+ const timeout = step.timeoutMs ?? settings.browser.actionTimeoutMs;
2885
3142
  if (step.action === "click" && step.ifVisible && !await conditionIsVisible(root, step.ifVisible)) {
2886
3143
  return { action: step.action, skipped: true, skip_reason: "if_visible condition was not visible" };
2887
3144
  }
2888
- if (step.action === "navigate") {
3145
+ if (step.action === "navigate") {
2889
3146
  let destination;
2890
3147
  try { destination = new URL(step.url, page.url()).toString(); } catch {
2891
3148
  throw new JevBrowserError("navigate URL must be absolute or relative to the current page");
@@ -2896,9 +3153,106 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
2896
3153
  }
2897
3154
  await page.goto(destination, { waitUntil: "domcontentloaded", timeout });
2898
3155
  execution.browserPool?.setNavigationError?.(null);
2899
- return { action: step.action, url: evidenceUrl(destination, secrets, settings.jev.maxDiagnosticChars) };
2900
- }
2901
- if (step.action === "evaluate") {
3156
+ return { action: step.action, url: evidenceUrl(destination, secrets, settings.jev.maxDiagnosticChars) };
3157
+ }
3158
+ if (step.action === "http_request") {
3159
+ assertLocalDebugPage(page, execution.localOnly, "http_request");
3160
+ const pageUrl = new URL(page.url());
3161
+ const requestUrl = new URL(step.url, pageUrl);
3162
+ if (requestUrl.origin !== pageUrl.origin || !localHostOnly(requestUrl.toString())) {
3163
+ throw new JevBrowserError("http_request must target the current localhost origin");
3164
+ }
3165
+ const reply = await page.evaluate(async ({ url, includeBody, maxBytes }) => {
3166
+ const response = await fetch(url, {
3167
+ method: "GET", credentials: "same-origin", redirect: "error", cache: "no-store",
3168
+ });
3169
+ const headers = {};
3170
+ for (const name of ["cache-control", "content-length", "content-type", "date", "etag", "last-modified", "retry-after", "vary"]) {
3171
+ const value = response.headers.get(name);
3172
+ if (value !== null) headers[name] = value.slice(0, 256);
3173
+ }
3174
+ let body = "";
3175
+ let truncated = false;
3176
+ if (includeBody && response.body) {
3177
+ const reader = response.body.getReader();
3178
+ const chunks = [];
3179
+ let size = 0;
3180
+ while (size <= maxBytes) {
3181
+ const { done, value } = await reader.read();
3182
+ if (done) break;
3183
+ const chunk = value.slice(0, Math.max(0, maxBytes + 1 - size));
3184
+ chunks.push(chunk);
3185
+ size += chunk.length;
3186
+ if (size > maxBytes || chunk.length < value.length) {
3187
+ truncated = true;
3188
+ await reader.cancel();
3189
+ break;
3190
+ }
3191
+ }
3192
+ const bytes = new Uint8Array(size);
3193
+ let offset = 0;
3194
+ for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }
3195
+ body = new TextDecoder().decode(bytes.slice(0, maxBytes));
3196
+ }
3197
+ return { status: response.status, ok: response.ok, headers, body, body_truncated: truncated };
3198
+ }, { url: requestUrl.toString(), includeBody: step.includeBody, maxBytes: settings.browser.maxNetworkErrorBodyBytes });
3199
+ let responseBody;
3200
+ if (step.includeBody && reply.body) {
3201
+ try { responseBody = redactLocalPayload(JSON.parse(reply.body)); }
3202
+ catch { responseBody = sanitizeDiagnosticText(reply.body, secrets, settings.browser.maxNetworkErrorMessageChars); }
3203
+ }
3204
+ return {
3205
+ action: step.action,
3206
+ method: "GET",
3207
+ url: evidenceUrl(requestUrl.toString(), secrets, settings.jev.maxDiagnosticChars),
3208
+ status: reply.status,
3209
+ ok: reply.ok,
3210
+ response_headers: reply.headers,
3211
+ ...(step.includeBody ? { response_body: responseBody ?? "", body_truncated: reply.body_truncated } : {}),
3212
+ };
3213
+ }
3214
+ if (step.action === "inspect_cookies") {
3215
+ assertLocalDebugPage(page, execution.localOnly, "inspect_cookies");
3216
+ const cookies = await execution.context.cookies([page.url()]);
3217
+ return {
3218
+ action: step.action,
3219
+ cookies: cookies.map(({ name, domain, path: cookiePath, expires, httpOnly, secure, sameSite }) => ({
3220
+ name: String(name).slice(0, 128), domain: String(domain).slice(0, 255), path: String(cookiePath).slice(0, 256),
3221
+ expires, http_only: Boolean(httpOnly), secure: Boolean(secure), same_site: sameSite,
3222
+ value: "[redacted]",
3223
+ })),
3224
+ };
3225
+ }
3226
+ if (step.action === "read_angular_state") {
3227
+ assertLocalDebugPage(page, execution.localOnly, "read_angular_state");
3228
+ const state = await page.evaluate(() => {
3229
+ const angular = window.angular;
3230
+ if (!angular) return { angular_present: false };
3231
+ const root = document.querySelector("[ng-app], [data-ng-app]") || document.body;
3232
+ let injector;
3233
+ try { injector = angular.element(root).injector(); } catch { injector = undefined; }
3234
+ if (!injector) return { angular_present: true, angular_version: angular.version?.full || null, injector_ready: false };
3235
+ let currentState = null;
3236
+ let parameterNames = [];
3237
+ try {
3238
+ const router = injector.get("$state");
3239
+ currentState = router.current?.name || null;
3240
+ parameterNames = Object.keys(router.params || {}).slice(0, 32);
3241
+ } catch { /* ui-router may not be registered on this page. */ }
3242
+ let authenticated = null;
3243
+ try { authenticated = Boolean(injector.get("$rootScope").usuario); } catch { /* no app-specific auth scope */ }
3244
+ return {
3245
+ angular_present: true,
3246
+ angular_version: angular.version?.full || null,
3247
+ injector_ready: true,
3248
+ state: currentState,
3249
+ parameter_names: parameterNames,
3250
+ authenticated,
3251
+ };
3252
+ });
3253
+ return { action: step.action, ...state };
3254
+ }
3255
+ if (step.action === "evaluate") {
2902
3256
  const value = await page.evaluate((expression) => {
2903
3257
  const parts = expression.split(".");
2904
3258
  let current = globalThis;
@@ -2956,9 +3310,14 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
2956
3310
  if (!tabs[step.index]) throw new JevBrowserError(`switch_tab index ${step.index} does not exist; open tabs: ${tabs.length}`);
2957
3311
  return { action: step.action, tab_index: step.index, activePage: tabs[step.index], activeContext: selectedContext };
2958
3312
  }
2959
- if (step.action === "wait_for_request") {
2960
- const request = await waitForRequestAssertion(execution.networkTracker, step, execution.networkMarker, timeout, settings);
2961
- return { action: step.action, asserted: true, method: request.method, status: request.status, url: evidenceUrl(request.url, secrets, settings.jev.maxDiagnosticChars) };
3313
+ if (step.action === "wait_for_request") {
3314
+ const request = await waitForRequestAssertion(execution.networkTracker, step, execution.flowNetworkMarker ?? execution.networkMarker, timeout, settings);
3315
+ return {
3316
+ action: step.action, asserted: true, method: request.method, status: request.status,
3317
+ ...(request.durationMs === undefined ? {} : { duration_ms: Math.round(request.durationMs * 10) / 10 }),
3318
+ ...(request.responseHeaders ? { response_headers: request.responseHeaders } : {}),
3319
+ url: evidenceUrl(request.url, secrets, settings.jev.maxDiagnosticChars),
3320
+ };
2962
3321
  }
2963
3322
  if (step.action === "assert_ws") {
2964
3323
  const frame = await waitForWebSocketAssertion(execution.networkTracker, step, execution.networkMarker, timeout, settings);
@@ -2969,14 +3328,21 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
2969
3328
  if (step.action === "assert_network") {
2970
3329
  const response = await waitForNetworkAssertion(
2971
3330
  execution.networkTracker,
2972
- step,
2973
- execution.networkMarker,
2974
- timeout,
2975
- page,
3331
+ step,
3332
+ execution.networkMarker,
3333
+ execution.initialNavigationMarker,
3334
+ timeout,
3335
+ page,
2976
3336
  settings,
2977
3337
  secrets,
2978
3338
  );
2979
- 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 } : {}) };
3339
+ return {
3340
+ action: step.action, asserted: true, url: evidenceUrl(response.url, secrets, settings.jev.maxDiagnosticChars),
3341
+ method: response.method, response_status: response.status,
3342
+ ...(response.durationMs === undefined ? {} : { duration_ms: Math.round(response.durationMs * 10) / 10 }),
3343
+ ...(response.responseHeaders ? { response_headers: response.responseHeaders } : {}),
3344
+ ...(response.message ? { message: response.message } : {}),
3345
+ };
2980
3346
  }
2981
3347
  if (step.action === "confirm_modal") {
2982
3348
  const trigger = await namedTarget(root, step, snapshot, secrets, timeout, settings);
@@ -3725,9 +4091,12 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
3725
4091
  if (!requestedFlow) throw new JevBrowserError("flow must not be empty");
3726
4092
  if (requestedFlow.length > settings.jev.maxFlowChars) throw new JevBrowserError("flow exceeds the configured character limit");
3727
4093
  if (outcome.length > settings.jev.maxActionDescriptionChars) throw new JevBrowserError("expected_outcome exceeds the configured character limit");
3728
- const url = initialUrl === undefined ? undefined : validateUrl(initialUrl);
4094
+ const url = initialUrl === undefined ? undefined : validateUrl(initialUrl);
3729
4095
  const options = resolveFlowOptions(rawOptions, settings);
3730
4096
  const plans = validateCandidatePlans(replaceKnownParams(candidatePlans, variables, true), settings, options.maxFlowSteps);
4097
+ const networkOnlyFastPath = options.fastPath && Object.keys(plans).length === 1
4098
+ && !Object.values(plans)[0].when
4099
+ && Object.values(plans)[0].steps.every(({ action }) => ["assert_network", "wait_for_request"].includes(action));
3731
4100
  assertPlanPlaceholders(plans, variables);
3732
4101
  if (!url && !options.reusePage) throw new JevBrowserError("initial_url is required when continue_from_current_page is false");
3733
4102
  if (options.waitForHttp && !localHostOnly(options.waitForHttp.url)
@@ -3778,10 +4147,17 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
3778
4147
  browserSessionMs = browser.sessionMs;
3779
4148
  page = browser.page;
3780
4149
  context = browser.context;
4150
+ if (browser.recovered && options.reusePage && !url) browserPool.setNavigationError?.("BROWSER_PAGE_NOT_RESTORED");
3781
4151
  }
3782
4152
  const initialPage = page;
3783
4153
  const initialContext = context;
3784
- const capture = createDiagnostics(page, options, settings, secrets);
4154
+ const networkWatchPatterns = Object.values(plans).flatMap(({ steps }) => steps.flatMap((step) => {
4155
+ if (step.action === "wait_for_request") return [step.urlPattern];
4156
+ if (step.action === "assert_network") return [step.urlContains];
4157
+ return [];
4158
+ })).concat(expected.assertions.filter(({ kind }) => kind === "request").map(({ request }) => request));
4159
+ const networkTracker = createNetworkTracker(page, settings, options.loginUrlContains, networkWatchPatterns);
4160
+ const capture = createDiagnostics(page, options, settings, secrets);
3785
4161
  const result = {
3786
4162
  ...(serverVersion ? { server_version: serverVersion } : {}),
3787
4163
  status: "failed",
@@ -3798,9 +4174,9 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
3798
4174
  result.snapshot_mode = options.returnSnapshot;
3799
4175
  if (options.allowMutations) result.mutation_authorization = "allowed_on_localhost";
3800
4176
  const downloadLedger = { count: 0, bytes: 0 };
3801
- const networkTracker = createNetworkTracker(page, settings, options.loginUrlContains);
3802
4177
  await networkTracker.waitForWebSocketObservers();
3803
- const initialNetworkMarker = networkTracker.mark();
4178
+ const initialNetworkMarker = networkTracker.mark();
4179
+ let initialNavigationMarker;
3804
4180
  const timings = { navigationMs: 0, initialSnapshotMs: 0, readyMs: 0, decisionMs: 0, browserPlanMs: 0 };
3805
4181
  const routeCleanups = [];
3806
4182
  const contextStack = [context];
@@ -3855,17 +4231,24 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
3855
4231
  addDiagnostic(result.warnings, "trace, video, and screenshots were suppressed because this flow contains sensitive text", settings.jev.maxDiagnosticItems);
3856
4232
  }
3857
4233
 
3858
- let reusedPage = false;
4234
+ let reusedPage = false;
3859
4235
  result.navigation_strategy = options.freshContext ? "fresh_isolated_context" : "navigated_to_initial_url";
3860
- const plansBeginWithNavigation = Object.values(plans).every(({ steps }) => steps[0]?.action === "navigate");
4236
+ const plansBeginWithNavigation = Object.values(plans).every(({ steps }) => steps[0]?.action === "navigate");
3861
4237
  if (options.reusePage && !options.freshContext) {
3862
- navigationError = browserPool.getNavigationError?.();
3863
- if (navigationError) {
3864
- result.navigation_error = navigationError;
3865
- throw new JevBrowserError(`reuse_page cannot continue because the previous navigation failed with ${navigationError}; run once with reuse_page: false to navigate again`);
3866
- }
3867
- const currentUrl = typeof page.url === "function" ? page.url() : "about:blank";
3868
- if (currentUrl && currentUrl !== "about:blank") {
4238
+ navigationError = browserPool.getNavigationError?.();
4239
+ if (navigationError) {
4240
+ if (navigationError === "BROWSER_PAGE_NOT_RESTORED" && url) {
4241
+ result.navigation_strategy = "recovered_with_initial_url";
4242
+ } else {
4243
+ result.navigation_error = navigationError;
4244
+ const detail = navigationError === "BROWSER_PAGE_NOT_RESTORED"
4245
+ ? "the previous page was not restored after reconnecting; provide initial_url to navigate again"
4246
+ : `the previous navigation failed with ${navigationError}; run once with reuse_page: false to navigate again`;
4247
+ throw new JevBrowserError(`reuse_page cannot continue because ${detail}`);
4248
+ }
4249
+ }
4250
+ const currentUrl = typeof page.url === "function" ? page.url() : "about:blank";
4251
+ if (!navigationError && currentUrl && currentUrl !== "about:blank") {
3869
4252
  let currentRoute;
3870
4253
  try {
3871
4254
  currentRoute = normalizedRouteUrl(currentUrl);
@@ -3888,18 +4271,30 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
3888
4271
  }
3889
4272
  }
3890
4273
  }
3891
- if (!reusedPage) {
4274
+ if (!reusedPage) {
3892
4275
  const destinationUrl = url;
3893
4276
  if (!destinationUrl) throw new JevBrowserError("initial_url is required because there is no open page to continue");
3894
4277
  const navigationStarted = performance.now();
4278
+ initialNavigationMarker = networkTracker.mark();
3895
4279
  try {
3896
- await page.goto(destinationUrl, { waitUntil: "domcontentloaded", timeout: settings.browser.actionTimeoutMs * 4 });
4280
+ const response = await page.goto(destinationUrl, { waitUntil: "domcontentloaded", timeout: settings.browser.actionTimeoutMs * 4 });
4281
+ if (response?.status() >= 400) result.navigation_http_status = response.status();
3897
4282
  if (!options.freshContext) browserPool.setNavigationError?.(null);
3898
4283
  } catch (error) {
3899
- navigationError = String(error?.message || "").match(/\b(?:net::)?(ERR_[A-Z0-9_]+)\b/)?.[1] || "NAVIGATION_FAILED";
3900
- if (navigationError && !options.freshContext) browserPool.setNavigationError?.(navigationError);
3901
- throw error;
3902
- }
4284
+ const failedDocument = networkOnlyFastPath
4285
+ ? networkTracker.historySince(initialNavigationMarker)
4286
+ .filter((entry) => entry.type === "response" && entry.resourceType === "document" && entry.status >= 400)
4287
+ .at(-1)
4288
+ : undefined;
4289
+ if (failedDocument) {
4290
+ result.navigation_http_status = failedDocument.status;
4291
+ if (!options.freshContext) browserPool.setNavigationError?.(null);
4292
+ } else {
4293
+ navigationError = String(error?.message || "").match(/\b(?:net::)?(ERR_[A-Z0-9_]+)\b/)?.[1] || "NAVIGATION_FAILED";
4294
+ if (navigationError && !options.freshContext) browserPool.setNavigationError?.(navigationError);
4295
+ throw error;
4296
+ }
4297
+ }
3903
4298
  timings.navigationMs = performance.now() - navigationStarted;
3904
4299
  }
3905
4300
  if (options.clearStorage) {
@@ -3909,11 +4304,15 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
3909
4304
  }
3910
4305
  reusedPage = false;
3911
4306
  result.navigation_strategy = "storage_cleared_and_reloaded";
3912
- }
3913
- const snapshotStarted = performance.now();
3914
- try {
3915
- const readiness = await waitForPageReady(page, options, settings, secrets, reusedPage);
3916
- const enriched = await enrichSnapshot(page, readiness.snapshot, settings, secrets, options.snapshotScope, options.snapshotIncludeHidden);
4307
+ }
4308
+ const snapshotStarted = performance.now();
4309
+ try {
4310
+ const readiness = networkOnlyFastPath
4311
+ ? { snapshot: "", readyMs: 0 }
4312
+ : await waitForPageReady(page, options, settings, secrets, reusedPage);
4313
+ const enriched = networkOnlyFastPath
4314
+ ? { snapshot: "", unnamedControls: [] }
4315
+ : await enrichSnapshot(page, readiness.snapshot, settings, secrets, options.snapshotScope, options.snapshotIncludeHidden);
3917
4316
  result.final_snapshot = enriched.snapshot;
3918
4317
  initialSnapshot = continuation?.initialSnapshot || enriched.snapshot;
3919
4318
  if (options.includeUnnamedControls) result.unnamed_controls_initial = enriched.unnamedControls;
@@ -3921,13 +4320,16 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
3921
4320
  navigationError = "AUTHENTICATION_REQUIRED";
3922
4321
  throw new JevBrowserError("the configured login text is visible; sign in to the application, then run the flow again");
3923
4322
  }
3924
- for (const control of enriched.unnamedControls) {
3925
- addDiagnostic(
3926
- result.warnings,
3927
- `interactive ${control.role} has no accessible name; index ${control.index}; nearest label ${JSON.stringify(control.nearest_label)}; set include_unnamed_controls to return sanitized HTML evidence`,
3928
- settings.jev.maxDiagnosticItems,
3929
- );
3930
- }
4323
+ if (enriched.unnamedControls.length) {
4324
+ const examples = enriched.unnamedControls.slice(0, 3)
4325
+ .map(({ role, nearest_label }) => `${role}${nearest_label ? ` near ${JSON.stringify(nearest_label)}` : ""}`)
4326
+ .join("; ");
4327
+ addDiagnostic(
4328
+ result.warnings,
4329
+ `${enriched.unnamedControls.length} interactive control(s) have no accessible name${examples ? `; examples: ${examples}` : ""}; set include_unnamed_controls to return sanitized HTML evidence`,
4330
+ settings.jev.maxDiagnosticItems,
4331
+ );
4332
+ }
3931
4333
  timings.readyMs = readiness.readyMs;
3932
4334
  if (!options.freshContext) browserPool.setNavigationError?.(null);
3933
4335
  if (continuation) {
@@ -4068,11 +4470,14 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
4068
4470
  nativeDialog.result = undefined;
4069
4471
  }
4070
4472
  const actionMarker = networkTracker.mark();
4071
- const execution = {
4072
- networkTracker,
4073
- networkMarker,
4074
- autoAngularIdle: options.autoAngularIdle,
4075
- readyStableMs: options.readyStableMs,
4473
+ const execution = {
4474
+ networkTracker,
4475
+ networkMarker,
4476
+ initialNavigationMarker,
4477
+ flowNetworkMarker: initialNetworkMarker,
4478
+ autoAngularIdle: options.autoAngularIdle,
4479
+ readyStableMs: options.readyStableMs,
4480
+ localOnly: options.localOnly,
4076
4481
  allowMutations: options.allowMutations,
4077
4482
  browserPool,
4078
4483
  nativeDialog,
@@ -4130,16 +4535,18 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
4130
4535
  failure = `accessibility audit reported ${traceStep.accessibility_audit.blocking_violations_count} violation(s) at or above ${traceStep.accessibility_audit.fail_on_impact} impact`;
4131
4536
  }
4132
4537
  }
4133
- snapshot = await snapshotAfterStep(
4134
- activePage,
4135
- settings,
4136
- secrets,
4137
- options.snapshotScope,
4138
- snapshot,
4139
- index + 1,
4140
- result.warnings,
4141
- options.snapshotIncludeHidden,
4142
- );
4538
+ if (!networkOnlyFastPath) {
4539
+ snapshot = await snapshotAfterStep(
4540
+ activePage,
4541
+ settings,
4542
+ secrets,
4543
+ options.snapshotScope,
4544
+ snapshot,
4545
+ index + 1,
4546
+ result.warnings,
4547
+ options.snapshotIncludeHidden,
4548
+ );
4549
+ }
4143
4550
  if (failure) break;
4144
4551
  if (options.stopOnExpected && outcome && !runAllSteps && containsExpectedOutcome(snapshot, outcome)) break;
4145
4552
  } catch (error) {
@@ -4160,7 +4567,7 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
4160
4567
  break;
4161
4568
  }
4162
4569
  }
4163
- if (failure || trace.length === 0) {
4570
+ if ((failure || trace.length === 0) && !networkOnlyFastPath) {
4164
4571
  snapshot = await snapshotAfterStep(
4165
4572
  activePage,
4166
4573
  settings,
@@ -4172,14 +4579,16 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
4172
4579
  options.snapshotIncludeHidden,
4173
4580
  );
4174
4581
  }
4175
- const finalDetails = await enrichSnapshot(
4176
- activePage,
4177
- snapshot,
4178
- settings,
4179
- secrets,
4180
- options.snapshotScope,
4181
- options.snapshotIncludeHidden,
4182
- );
4582
+ const finalDetails = networkOnlyFastPath
4583
+ ? { snapshot, unnamedControls: [] }
4584
+ : await enrichSnapshot(
4585
+ activePage,
4586
+ snapshot,
4587
+ settings,
4588
+ secrets,
4589
+ options.snapshotScope,
4590
+ options.snapshotIncludeHidden,
4591
+ );
4183
4592
  snapshot = finalDetails.snapshot;
4184
4593
  if (options.includeUnnamedControls) result.unnamed_controls_final = finalDetails.unnamedControls;
4185
4594
  timings.browserPlanMs = performance.now() - planStarted;
@@ -4339,7 +4748,7 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
4339
4748
  await Promise.all(createdContexts.map(async (createdContext) => {
4340
4749
  try { await browserPool.releaseContext?.(createdContext); } catch { artifactErrors.push("isolated browser context cleanup failed"); }
4341
4750
  }));
4342
- networkTracker.dispose();
4751
+ await networkTracker.dispose();
4343
4752
  await capture.dispose();
4344
4753
  initialPage.off("dialog", onNativeDialog);
4345
4754
  }
@@ -4348,7 +4757,16 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
4348
4757
  try { result.current_url = evidenceUrl(activePage.url(), secrets, settings.jev.maxDiagnosticChars); } catch { result.current_url = "unavailable"; }
4349
4758
  result.console_errors = groupConsoleErrors([...(continuation?.consoleErrors || []), ...capture.diagnostics.console_errors]);
4350
4759
  result.console_messages = groupConsoleMessages([...(continuation?.consoleMessages || []), ...capture.diagnostics.console_messages]);
4351
- result.network_failures = [...(continuation?.networkFailures || []), ...capture.diagnostics.network_failures];
4760
+ result.network_failures = [...(continuation?.networkFailures || []), ...capture.diagnostics.network_failures];
4761
+ const blockedCookies = networkTracker.blockedCookiesSince(initialNetworkMarker, options.captureNetworkUrlContains)
4762
+ .map(({ url: requestUrl, ...event }) => ({ ...event, url: evidenceUrl(requestUrl, secrets, settings.jev.maxDiagnosticChars) }));
4763
+ if (blockedCookies.length) result.blocked_cookies = blockedCookies;
4764
+ if (options.captureNetwork === "all") {
4765
+ result.network_events = networkTracker.networkEventsSince(
4766
+ initialNetworkMarker,
4767
+ options.captureNetworkUrlContains,
4768
+ ).map(({ url: requestUrl, ...event }) => ({ ...event, url: evidenceUrl(requestUrl, secrets, settings.jev.maxDiagnosticChars) }));
4769
+ }
4352
4770
  result.warnings.push(...capture.diagnostics.warnings);
4353
4771
  result.timings_ms = flowTimings(started, browserSessionMs, timings.navigationMs, timings.initialSnapshotMs, timings.readyMs, timings.decisionMs, timings.browserPlanMs);
4354
4772
  const safeResult = await redactObject(result, secrets);
@@ -4359,7 +4777,7 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
4359
4777
  safeResult.final_snapshot = stripUnnamedControlsSection(safeResult.final_snapshot);
4360
4778
  initialSnapshot = stripUnnamedControlsSection(initialSnapshot);
4361
4779
  }
4362
- if (options.returnSnapshot === "diff" && typeof safeResult.final_snapshot === "string") {
4780
+ if (!networkOnlyFastPath && options.returnSnapshot === "diff" && typeof safeResult.final_snapshot === "string") {
4363
4781
  safeResult.final_snapshot = snapshotDiff(initialSnapshot, safeResult.final_snapshot);
4364
4782
  } else if (options.returnSnapshot === "none") {
4365
4783
  delete safeResult.final_snapshot;