@diegosouzacdv/jev-browser-mcp 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,12 @@
1
1
  import { createHash, randomBytes, randomUUID } from "node:crypto";
2
2
  import { chmod, lstat, mkdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { ACCESSIBILITY_STANDARDS, runAccessibilityAudit } from "./accessibility.mjs";
5
- import { JevBrowserError } from "./config.mjs";
6
- import { buildChoicePayload, COMPLETE_ACTION_ID, parseDecision, requestJev } from "./jev-client.mjs";
4
+ import { ACCESSIBILITY_STANDARDS, runAccessibilityAudit } from "./accessibility.mjs";
5
+ import { JevBrowserError } from "./config.mjs";
6
+ import { buildChoicePayload, COMPLETE_ACTION_ID, parseDecision, requestJev } from "./jev-client.mjs";
7
+ import { assertReadOnlySql, createIntegrationSession, executeIntegrationStep } from "./test-integrations.mjs";
8
+ import { isProfileOriginAllowed, requireEnvironmentProfile } from "./environment-profiles.mjs";
9
+ import { cleanupUploadStagingDirectory, prepareUploadStagingDirectory, withUploadStagingLock } from "./upload-staging.mjs";
7
10
 
8
11
  const PLAN_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,79}$/;
9
12
  const CLICK_ROLES = new Set(["button", "link", "tab", "menuitem", "checkbox", "radio", "switch", "cell", "gridcell", "option"]);
@@ -18,7 +21,7 @@ const CONTAINER_ROLES = new Set([
18
21
  ]);
19
22
  const TARGET_SELECTION_ACTIONS = new Set([
20
23
  "click", "confirm_modal", "check", "uncheck", "type", "clear", "select_option", "hover", "press", "assert_text", "assert_value", "wait_for_value",
21
- "wait_for_enabled", "assert_enabled", "assert_visible", "assert_hidden", "upload_file", "extract",
24
+ "wait_for_enabled", "assert_enabled", "assert_visible", "assert_hidden", "assert_attribute", "upload_file", "extract",
22
25
  ]);
23
26
  const LOCATOR_FIELDS = ["label", "placeholder", "title", "text", "test_id", "selector"];
24
27
  const TARGET_LOCATOR_FIELDS = [...LOCATOR_FIELDS, "target_text"];
@@ -38,6 +41,8 @@ const MIME_TYPES = new Map([
38
41
  [".jpg", "image/jpeg"], [".jpeg", "image/jpeg"], [".png", "image/png"],
39
42
  [".txt", "text/plain"], [".webp", "image/webp"],
40
43
  ]);
44
+ // Jev's Decisions API currently caps a single upload payload at 47 MiB.
45
+ const JEV_UPLOAD_LIMIT_BYTES = 47 * 1024 * 1024;
41
46
  const WEBSOCKET_CLOSE_PROBE = `(() => {
42
47
  const eventsKey = "__jevBrowserWebSocketCloseEvents";
43
48
  const installedKey = "__jevBrowserWebSocketCloseProbe";
@@ -59,12 +64,13 @@ const WEBSOCKET_CLOSE_PROBE = `(() => {
59
64
  const FLOW_OPTION_KEYS = new Set([
60
65
  "allow_mutations", "block_trackers", "capture_console_errors", "capture_network_error_bodies", "capture_network_errors",
61
66
  "auto_angular_idle", "block_fonts", "busy_selectors", "confirmation_token", "dry_run", "fast_path", "local_only",
67
+ "environment_profile",
62
68
  "login_text", "login_url_contains", "max_flow_steps", "ready_network_idle", "ready_stable_ms", "ready_text", "ready_timeout_seconds",
63
69
  "report_path", "permissions", "fake_media", "viewport", "mobile", "device_scale_factor", "locale", "timezone_id",
64
70
  "color_scheme", "geolocation", "screenshot_on_success", "record_video", "trace_on_success",
65
71
  "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
72
  "trace_on_failure", "console_levels", "fresh_context", "clear_storage", "wait_for_http", "storage_state",
67
- "ready",
73
+ "ready", "capture_network", "capture_network_url_contains",
68
74
  ]);
69
75
  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
76
 
@@ -114,8 +120,10 @@ function flowFingerprint({ flow, initialUrl, expectedOutcome, candidatePlans, pa
114
120
  }))).digest("hex");
115
121
  }
116
122
 
117
- function looksMutating(rawStep) {
118
- if (["confirm_dialog", "confirm_modal", "like_comment", "unlike_comment", "upload_file"].includes(rawStep.action)) return true;
123
+ function looksMutating(rawStep) {
124
+ if (["confirm_dialog", "confirm_modal", "like_comment", "unlike_comment", "upload_file"].includes(rawStep.action)) return true;
125
+ if (["delete_remote_file", "run_hook"].includes(rawStep.action)) return true;
126
+ if (rawStep.action === "http_request" && !["GET", "HEAD"].includes(String(rawStep.method || "GET").toUpperCase())) return true;
119
127
  if (rawStep.action !== "click") return false;
120
128
  return [rawStep.name, rawStep.title, rawStep.text, rawStep.target_text, rawStep.label]
121
129
  .some((value) => typeof value === "string" && MUTATING_LABEL.test(value));
@@ -177,7 +185,7 @@ function replaceKnownParams(value, params, allowUnknown = false) {
177
185
  return value;
178
186
  }
179
187
 
180
- function assertPlanPlaceholders(plans, initialVariables) {
188
+ function assertPlanPlaceholders(plans, initialVariables) {
181
189
  for (const [planId, plan] of Object.entries(plans)) {
182
190
  const available = new Set(Object.keys(initialVariables));
183
191
  for (const field of [plan.description, plan.when?.visible]) {
@@ -186,7 +194,10 @@ function assertPlanPlaceholders(plans, initialVariables) {
186
194
  for (const [index, step] of plan.steps.entries()) {
187
195
  const fields = Object.entries(step).filter(([key]) => !["action", "as", "sensitive"].includes(key)).map(([, value]) => value);
188
196
  for (const field of fields) assertTemplateRefsDeep(field, available, `candidate plan ${planId} step ${index + 1}`);
189
- if (step.action === "extract" || (step.action === "evaluate" && step.as)) available.add(step.as);
197
+ if (step.action === "extract" || step.action === "generate_fixture" || (step.action === "evaluate" && step.as)) available.add(step.as);
198
+ if (["http_request", "assert_db"].includes(step.action)) {
199
+ for (const name of Object.keys(step.extract || {})) available.add(name);
200
+ }
190
201
  }
191
202
  }
192
203
  }
@@ -223,13 +234,25 @@ function actionSchemas(semanticTarget) {
223
234
  clear: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
224
235
  navigate: { required: ["url"], optional: ["timeout_seconds", "timeout_ms", "target_page"] },
225
236
  confirm_dialog: { required: ["expected_text", "button"], optional: ["timeout_seconds"] },
226
- confirm_modal: { required: ["expected_text", "button"], optional: [...semanticTarget, "trigger", "confirm", "timeout_seconds"] },
237
+ confirm_modal: { required: ["expected_text", "button"], optional: [...semanticTarget, "trigger", "trigger_match", "confirm", "timeout_seconds"] },
227
238
  check: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
228
239
  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"] },
240
+ navigate_menu: { required: [], optional: ["path", "screen", "toggle_selector", "timeout_seconds"] },
241
+ assert_network: { required: ["url_contains", "method", "status"], optional: ["message_contains", "timeout_seconds", "timeout_ms"] },
242
+ assert_ws: { required: ["url_contains"], optional: ["message_contains", "json", "close_code", "timeout_seconds", "timeout_ms"] },
243
+ wait_for_request: { required: ["url"], optional: ["method", "status", "timeout_seconds", "timeout_ms"] },
244
+ http_request: { required: ["url"], optional: ["method", "json", "multipart", "include_body", "expected_status", "extract", "timeout_seconds", "timeout_ms"] },
245
+ generate_fixture: { required: ["kind", "as", "file_name"], optional: ["content", "size_bytes", "timeout_seconds", "timeout_ms"] },
246
+ assert_download: { required: ["file_path"], optional: ["mime_type", "timeout_seconds", "timeout_ms"] },
247
+ assert_db: { required: ["sql", "expected"], optional: ["binds", "extract", "timeout_seconds", "timeout_ms"] },
248
+ assert_log: { required: ["source", "contains"], optional: ["not_contains", "since", "timeout_seconds", "timeout_ms"] },
249
+ start_email_capture: { required: [], optional: ["timeout_seconds", "timeout_ms"] },
250
+ assert_email: { required: [], optional: ["to", "subject_contains", "body_contains", "timeout_ms", "timeout_seconds"] },
251
+ assert_remote_file: { required: ["server", "path"], optional: ["exists", "size_bytes", "sha256", "timeout_seconds", "timeout_ms"] },
252
+ delete_remote_file: { required: ["server", "path"], optional: ["timeout_seconds", "timeout_ms"] },
253
+ run_hook: { required: ["hook"], optional: ["args", "expected_exit_code", "timeout_ms", "timeout_seconds"] },
254
+ inspect_cookies: { required: [], optional: ["timeout_seconds", "timeout_ms"] },
255
+ read_angular_state: { required: [], optional: ["timeout_seconds", "timeout_ms"] },
233
256
  extract: { required: ["as"], optional: [...semanticTarget, "source", "attribute", "sensitive", "timeout_seconds", "timeout_ms"] },
234
257
  evaluate: { required: ["expression"], optional: ["as", "timeout_seconds", "timeout_ms"] },
235
258
  screenshot: { required: [], optional: ["full_page", "timeout_seconds", "timeout_ms"] },
@@ -245,8 +268,9 @@ function actionSchemas(semanticTarget) {
245
268
  press: { required: ["key"], optional: [...semanticTarget, "timeout_seconds"] },
246
269
  select_option: { required: [], optional: [...semanticTarget, "option", "value", "label_contains", "timeout_seconds"] },
247
270
  hover: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
248
- assert_text: { required: ["expected"], optional: [...semanticTarget, "timeout_seconds"] },
249
- assert_value: { required: ["expected"], optional: [...semanticTarget, "timeout_seconds"] },
271
+ assert_text: { required: ["expected"], optional: [...semanticTarget, "timeout_seconds"] },
272
+ assert_value: { required: ["expected"], optional: [...semanticTarget, "timeout_seconds"] },
273
+ assert_attribute: { required: ["attribute", "expected"], optional: [...semanticTarget, "match", "timeout_seconds"] },
250
274
  assert_enabled: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
251
275
  assert_visible: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
252
276
  assert_hidden: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
@@ -257,21 +281,92 @@ function actionSchemas(semanticTarget) {
257
281
  };
258
282
  }
259
283
 
260
- export function describeBrowserActions() {
284
+ export function describeBrowserActions(settings) {
261
285
  const semanticTarget = ["frame", "comment", "within", "index", "near", "label", "placeholder", "title", "text", "test_id", "selector", "target_text", "role", "name", "name_match", "target_page"];
262
286
  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
- }])),
287
+ return {
288
+ required_flow_fields: ["flow", "candidate_plans"],
289
+ candidate_plan: { required: ["description", "steps"], optional: ["comment", "when"] },
290
+ actions: Object.fromEntries(Object.entries(schemas).map(([action, schema]) => {
291
+ const contract = {
292
+ required: [...schema.required],
293
+ optional: [...new Set([...schema.optional, "timeout_ms", "mutating", "screenshot"])],
294
+ };
295
+ if (action === "extract") contract.conditional_required = { attribute: "required when source is attribute" };
296
+ if (action === "assert_email") contract.conditional_required = { subject_contains: "at least one of to, subject_contains, or body_contains is required" };
297
+ if (action === "type") contract.conditional_required = { text: "exactly one of text or text_env is required" };
298
+ if (action === "navigate_menu") contract.conditional_required = { path: "exactly one of path or screen is required" };
299
+ if (action === "select_option") contract.conditional_required = { option: "exactly one of option, value or label_contains is required" };
300
+ return [action, contract];
301
+ })),
268
302
  options: [...FLOW_OPTION_KEYS],
303
+ suite: {
304
+ file: ".jev-browser/suites/<name>.json",
305
+ file_fields: { required: ["version", "id", "criteria"], optional: ["description", "environment_profile", "params", "flows", "setup", "teardown"] },
306
+ flow_definition: { required: ["flow", "candidate_plans"], optional: ["initial_url", "expected_outcome", "params", "options"] },
307
+ criterion: { required: ["id", "description", "flow"], optional: ["initial_url", "expected_outcome", "params", "options"] },
308
+ run_test_suite_tool: { required: ["suite_name"], optional: ["params", "environment_profile", "report_path", "background"] },
309
+ execution: "sequential; all setup, criteria and teardown flows are preflighted before browser startup; teardown is always attempted; run_test_suite returns or polls a run_id when background=true",
310
+ },
311
+ profiles: {
312
+ file: ".jev-browser/profiles.json or absolute JEV_BROWSER_PROFILES_FILE inside the project",
313
+ supported: ["dev", "hml", "prd"],
314
+ profile_fields: { required: ["environment"], optional: ["application_origins", "database", "log_sources", "smtp_capture", "remotes", "hooks"] },
315
+ database: ["postgresql", "oracle"],
316
+ database_fields: {
317
+ postgresql: { required: ["engine", "connection_env", "timeout_ms", "max_rows"] },
318
+ oracle: { required: ["engine", "connect_env", "user_env", "password_env", "timeout_ms", "max_rows"] },
319
+ },
320
+ remote_protocols: ["ftp", "ftps", "sftp"],
321
+ remote_fields: { required: ["protocol", "host", "port", "username_env", "password_env", "root"], optional: ["host_fingerprint_env; required for SFTP"] },
322
+ smtp_capture_fields: { required: ["port", "bind_address", "max_messages"] },
323
+ hook_fields: { required: ["script", "arguments", "timeout_ms"], argument_fields: { required: ["type", "required", "max_length"], types: ["string", "integer", "boolean"] } },
324
+ environment_policy: { dev: "test writes allowed with explicit allow_mutations; remote deletion and hooks are DEV-only", hml: "application writes require explicit allow_mutations; database and logs are read-only", prd: "mutations, destructive remote actions and hooks are refused" },
325
+ credentials: "environment variable references only; raw credentials are rejected from reports",
326
+ ...(settings ? { configured: Object.keys(settings.browser.environmentProfiles.profiles) } : {}),
327
+ },
328
+ ...(settings ? { limits: {
329
+ max_flow_steps: settings.browser.maxFlowSteps,
330
+ max_flow_steps_env: settings.browser.maxFlowStepsEnv,
331
+ max_upload_files: settings.browser.maxUploadFiles,
332
+ max_upload_file_bytes: settings.browser.maxUploadFileBytes,
333
+ max_upload_file_bytes_env: settings.browser.maxUploadFileBytesEnv,
334
+ max_upload_total_bytes: settings.browser.maxUploadTotalBytes,
335
+ max_upload_total_bytes_env: settings.browser.maxUploadTotalBytesEnv,
336
+ max_background_flow_runs: settings.browser.maxBackgroundFlowRuns,
337
+ background_flow_result_ttl_ms: settings.browser.backgroundFlowResultTtlMs,
338
+ max_download_files: settings.browser.maxDownloadFiles,
339
+ max_download_file_bytes: settings.browser.maxDownloadFileBytes,
340
+ max_download_total_bytes: settings.browser.maxDownloadTotalBytes,
341
+ max_environment_profile_file_bytes: settings.browser.maxEnvironmentProfileFileBytes,
342
+ max_test_suite_file_bytes: settings.browser.maxTestSuiteFileBytes,
343
+ max_test_suite_criteria: settings.browser.maxTestSuiteCriteria,
344
+ max_test_suite_report_bytes: settings.browser.maxTestSuiteReportBytes,
345
+ max_database_query_chars: settings.browser.maxDatabaseQueryChars,
346
+ max_database_bind_values: settings.browser.maxDatabaseBindValues,
347
+ max_external_extract_variables: settings.browser.maxExternalExtractVariables,
348
+ max_hook_output_bytes: settings.browser.maxHookOutputBytes,
349
+ max_hook_timeout_ms: settings.browser.maxHookTimeoutMs,
350
+ max_hook_argument_chars: settings.browser.maxHookArgumentChars,
351
+ max_smtp_captured_messages: settings.browser.maxSmtpCapturedMessages,
352
+ max_database_timeout_ms: settings.browser.maxDatabaseTimeoutMs,
353
+ max_database_rows: settings.browser.maxDatabaseRows,
354
+ jev_max_upload_file_bytes: JEV_UPLOAD_LIMIT_BYTES,
355
+ } } : {}),
269
356
  option_shapes: {
357
+ capture_network: ["errors", "all", "none"],
358
+ capture_network_url_contains: "optional URL substring filter for capture_network=all",
359
+ environment_profile: "selects a configured DEV, HML, or PRD allowlist from .jev-browser/profiles.json; credentials are environment references only",
360
+ local_only: "required for page inspection and for HTTP requests without an environment profile; localhost loopback only",
270
361
  ready: ["network_idle", { hidden_text: "text that must disappear" }, { selector: "CSS selector", has_value: true }],
271
362
  return_snapshot: ["diff", "full", "none"],
272
363
  include_unnamed_controls: "boolean; false by default",
273
364
  reuse_page_match: ["url", "path"],
274
365
  },
366
+ step_shapes: {
367
+ frame: ["first", { url_contains: "iframe URL substring" }, "exact iframe name or title"],
368
+ navigate_menu: ["path: menu label array", "screen: SAFI Acesso Rápido name or screen code"],
369
+ },
275
370
  expected_outcome: {
276
371
  text: "string description or {text: string} assertion",
277
372
  request: "{request: 'PUT */path', status: 200, message_contains?: string}; request may include a method prefix",
@@ -282,13 +377,74 @@ export function describeBrowserActions() {
282
377
  duration_ms: "wait.ms",
283
378
  value: "type.text or wait_for_text.text",
284
379
  timeout_ms: "per-step timeout in milliseconds",
285
- confirm_modal: { trigger: "button accessible name", confirm: "button", expected_text: "required exact safety phrase" },
380
+ confirm_modal: { trigger: "button accessible name (exact by default)", trigger_match: ["exact", "contains", "regex"], confirm: "button", expected_text: "required safety phrase; checked before the affirmative button is clicked" },
286
381
  },
287
382
  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) {
383
+ };
384
+ }
385
+
386
+ export function collectFlowContractIssues({ flow, candidatePlans }) {
387
+ const issues = [];
388
+ if (typeof flow !== "string" || !flow.trim()) issues.push("flow: required non-empty string");
389
+ if (!isRecord(candidatePlans) || !Object.keys(candidatePlans).length) {
390
+ issues.push("candidate_plans: required non-empty object");
391
+ return issues;
392
+ }
393
+ const schemas = actionSchemas(["frame", "comment", "within", "index", "near", ...LOCATOR_FIELDS, "target_text", "role", "name", "name_match", "target_page"]);
394
+ for (const [planId, plan] of Object.entries(candidatePlans)) {
395
+ if (!isRecord(plan)) {
396
+ issues.push(`candidate_plans.${planId}: must be an object`);
397
+ continue;
398
+ }
399
+ if (typeof plan.description !== "string" || !plan.description.trim()) issues.push(`candidate_plans.${planId}.description: required non-empty string`);
400
+ if (plan.when !== undefined && (!isRecord(plan.when) || typeof plan.when.visible !== "string" || !plan.when.visible.trim())) {
401
+ issues.push(`candidate_plans.${planId}.when.visible: required non-empty string when is provided`);
402
+ }
403
+ if (!Array.isArray(plan.steps) || !plan.steps.length) {
404
+ issues.push(`candidate_plans.${planId}.steps: required non-empty array`);
405
+ continue;
406
+ }
407
+ for (const [index, step] of plan.steps.entries()) {
408
+ const prefix = `candidate_plans.${planId}.steps[${index}]`;
409
+ if (!isRecord(step) || typeof step.action !== "string") {
410
+ issues.push(`${prefix}.action: required supported action`);
411
+ continue;
412
+ }
413
+ let normalizedStep;
414
+ try { normalizedStep = normalizeStepAliases(step); }
415
+ catch (error) {
416
+ issues.push(`${prefix}: ${error instanceof Error ? error.message : "invalid aliases"}`);
417
+ normalizedStep = step;
418
+ }
419
+ const schema = schemas[normalizedStep.action];
420
+ if (!schema) {
421
+ issues.push(`${prefix}.action: unsupported action ${JSON.stringify(normalizedStep.action)}`);
422
+ continue;
423
+ }
424
+ for (const field of schema.required) {
425
+ if (!Object.hasOwn(normalizedStep, field)) issues.push(`${prefix}.${field}: required for ${normalizedStep.action}`);
426
+ }
427
+ if (normalizedStep.action === "extract" && normalizedStep.source === "attribute" && !Object.hasOwn(normalizedStep, "attribute")) {
428
+ issues.push(`${prefix}.attribute: required when extract.source is attribute`);
429
+ }
430
+ if (normalizedStep.action === "type"
431
+ && (Object.hasOwn(normalizedStep, "text") === Object.hasOwn(normalizedStep, "text_env"))) {
432
+ issues.push(`${prefix}: exactly one of text or text_env is required for type`);
433
+ }
434
+ if (normalizedStep.action === "navigate_menu"
435
+ && (Object.hasOwn(normalizedStep, "path") === Object.hasOwn(normalizedStep, "screen"))) {
436
+ issues.push(`${prefix}: exactly one of path or screen is required for navigate_menu`);
437
+ }
438
+ if (normalizedStep.action === "select_option"
439
+ && ["option", "value", "label_contains"].filter((field) => Object.hasOwn(normalizedStep, field)).length !== 1) {
440
+ issues.push(`${prefix}: exactly one of option, value or label_contains is required for select_option`);
441
+ }
442
+ }
443
+ }
444
+ return issues;
445
+ }
446
+
447
+ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps = settings.browser.maxFlowSteps) {
292
448
  if (!isRecord(candidatePlans)) throw new JevBrowserError("candidate_plans must be an object");
293
449
  if (Object.keys(candidatePlans).length > settings.jev.maxActionCount) {
294
450
  throw new JevBrowserError("candidate_plans exceeds the configured candidate limit");
@@ -482,12 +638,18 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
482
638
  if (!step.role) step.role = "checkbox";
483
639
  if (action === "uncheck" && step.role === "radio") throw new JevBrowserError("uncheck does not support radio controls");
484
640
  }
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
641
+ if (action === "navigate_menu") {
642
+ if ((rawStep.path === undefined) === (rawStep.screen === undefined)) {
643
+ throw new JevBrowserError("navigate_menu needs exactly one of path or screen");
644
+ }
645
+ if (rawStep.path !== undefined) {
646
+ if (!Array.isArray(rawStep.path) || rawStep.path.length === 0 || rawStep.path.length > 8) {
647
+ throw new JevBrowserError("navigate_menu path must contain between 1 and 8 menu labels");
648
+ }
649
+ step.path = rawStep.path.map((label) => requiredText(label, "menu path label", settings.jev.maxActionDescriptionChars));
650
+ }
651
+ if (rawStep.screen !== undefined) step.screen = requiredText(rawStep.screen, "quick access screen name or code", settings.jev.maxActionDescriptionChars);
652
+ step.toggleSelector = rawStep.toggle_selector === undefined
491
653
  ? "#open_btn"
492
654
  : requiredText(rawStep.toggle_selector, "toggle_selector", settings.jev.maxActionDescriptionChars);
493
655
  }
@@ -515,7 +677,7 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
515
677
  if (hasTextEnv) step.textEnv = rawStep.text_env;
516
678
  step.mode = rawStep.mode || "fill";
517
679
  step.blur = rawStep.blur ?? false;
518
- step.sensitive = rawStep.sensitive ?? true;
680
+ step.sensitive = rawStep.sensitive ?? true;
519
681
  if (hasTextEnv && rawStep.sensitive === false) throw new JevBrowserError("type text_env is always sensitive");
520
682
  }
521
683
  if (action === "clear" && step.role && !TEXT_ROLES.has(step.role)) {
@@ -577,16 +739,140 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
577
739
  if (!step.messageContains && !step.json && rawStep.close_code === undefined) throw new JevBrowserError("assert_ws needs message_contains, json, or close_code");
578
740
  step.closeCode = rawStep.close_code;
579
741
  }
580
- if (action === "wait_for_request") {
581
- step.urlPattern = requiredText(rawStep.url, "wait_for_request url", settings.jev.maxActionDescriptionChars);
742
+ if (action === "wait_for_request") {
743
+ step.urlPattern = requiredText(rawStep.url, "wait_for_request url", settings.jev.maxActionDescriptionChars);
582
744
  step.method = rawStep.method === undefined ? undefined : requiredText(rawStep.method, "request method", 16).toUpperCase();
583
745
  if (step.method && !/^[A-Z]+$/.test(step.method)) throw new JevBrowserError("wait_for_request method must contain letters only");
584
746
  if (rawStep.status !== undefined && (!Number.isSafeInteger(rawStep.status) || rawStep.status < 100 || rawStep.status > 599)) {
585
747
  throw new JevBrowserError("wait_for_request status must be an HTTP status from 100 to 599");
586
- }
587
- step.status = rawStep.status;
588
- }
589
- if (action === "evaluate") {
748
+ }
749
+ step.status = rawStep.status;
750
+ }
751
+ if (action === "http_request") {
752
+ step.url = requiredText(rawStep.url, "http_request URL", settings.jev.maxActionDescriptionChars);
753
+ step.method = rawStep.method === undefined ? "GET" : requiredText(rawStep.method, "http_request method", 16).toUpperCase();
754
+ if (!new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]).has(step.method)) {
755
+ throw new JevBrowserError("http_request method must be GET, HEAD, POST, PUT, PATCH, or DELETE");
756
+ }
757
+ if (rawStep.json !== undefined && !isRecord(rawStep.json)) throw new JevBrowserError("http_request json must be an object");
758
+ if (rawStep.multipart !== undefined && !Array.isArray(rawStep.multipart)) throw new JevBrowserError("http_request multipart must be an array of typed fields");
759
+ if (rawStep.json !== undefined && rawStep.multipart !== undefined) throw new JevBrowserError("http_request cannot contain both json and multipart");
760
+ step.json = rawStep.json;
761
+ step.multipart = (rawStep.multipart || []).map((field, index) => {
762
+ if (!isRecord(field)) throw new JevBrowserError(`http_request multipart[${index}] must be an object`);
763
+ exactKeys(field, ["name"], ["value", "file_path", "file_name"], `http_request multipart[${index}]`);
764
+ const hasValue = Object.hasOwn(field, "value");
765
+ const hasFile = Object.hasOwn(field, "file_path");
766
+ if (hasValue === hasFile) throw new JevBrowserError(`http_request multipart[${index}] needs exactly one of value or file_path`);
767
+ const item = { name: requiredText(field.name, "multipart field name", 128) };
768
+ if (hasValue) {
769
+ if (!["string", "number", "boolean"].includes(typeof field.value)) throw new JevBrowserError(`http_request multipart[${index}].value must be scalar`);
770
+ item.value = field.value;
771
+ } else {
772
+ item.filePath = requiredText(field.file_path, "multipart file path", settings.browser.maxUploadPathChars);
773
+ if (field.file_name !== undefined) item.fileName = safeDownloadName(requiredText(field.file_name, "multipart file name", 180));
774
+ }
775
+ return item;
776
+ });
777
+ if (rawStep.include_body !== undefined && typeof rawStep.include_body !== "boolean") {
778
+ throw new JevBrowserError("http_request include_body must be a boolean");
779
+ }
780
+ step.includeBody = rawStep.include_body ?? false;
781
+ if (rawStep.expected_status !== undefined && (!Number.isSafeInteger(rawStep.expected_status) || rawStep.expected_status < 100 || rawStep.expected_status > 599)) {
782
+ throw new JevBrowserError("http_request expected_status must be an HTTP status from 100 to 599");
783
+ }
784
+ step.expectedStatus = rawStep.expected_status;
785
+ if (rawStep.extract !== undefined) {
786
+ if (!isRecord(rawStep.extract) || Object.keys(rawStep.extract).length > settings.browser.maxExternalExtractVariables) throw new JevBrowserError("http_request extract must be a bounded object of variable names to JSON paths");
787
+ step.extract = Object.fromEntries(Object.entries(rawStep.extract).map(([name, descriptor]) => {
788
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new JevBrowserError("http_request extract keys must be simple variable names");
789
+ exactKeys(descriptor, ["json_path"], [], `http_request extract.${name}`);
790
+ const jsonPath = requiredText(descriptor.json_path, `http_request extract.${name} JSON path`, 256);
791
+ if (!/^[A-Za-z0-9_$-]+(?:\.(?:[A-Za-z0-9_$-]+|\d+))*$/.test(jsonPath)) throw new JevBrowserError(`http_request extract.${name} JSON path is invalid`);
792
+ return [name, { jsonPath }];
793
+ }));
794
+ } else step.extract = {};
795
+ }
796
+ if (action === "generate_fixture") {
797
+ step.kind = rawStep.kind;
798
+ if (!new Set(["pdf", "text", "bytes"]).has(step.kind)) throw new JevBrowserError("generate_fixture kind must be pdf, text, or bytes");
799
+ step.as = requiredText(rawStep.as, "fixture variable name", 64);
800
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(step.as)) throw new JevBrowserError("generate_fixture as must be a simple variable name");
801
+ step.fileName = requiredText(rawStep.file_name, "fixture file_name", settings.browser.maxUploadPathChars);
802
+ if (rawStep.content !== undefined) step.content = requiredText(rawStep.content, "fixture content", settings.browser.maxTextEntryChars);
803
+ if (step.kind === "bytes") {
804
+ if (!Number.isSafeInteger(rawStep.size_bytes) || rawStep.size_bytes <= 0 || rawStep.size_bytes > Math.min(settings.browser.maxUploadFileBytes, JEV_UPLOAD_LIMIT_BYTES)) {
805
+ throw new JevBrowserError("generate_fixture size_bytes must fit the configured upload limit and Jev's 47 MiB provider limit");
806
+ }
807
+ step.sizeBytes = rawStep.size_bytes;
808
+ } else if (rawStep.size_bytes !== undefined) throw new JevBrowserError("generate_fixture size_bytes is only valid for kind bytes");
809
+ }
810
+ if (action === "assert_download") {
811
+ step.filePath = requiredText(rawStep.file_path, "assert_download file_path", settings.browser.maxUploadPathChars);
812
+ if (rawStep.mime_type !== undefined) step.mimeType = requiredText(rawStep.mime_type, "assert_download mime_type", 128).toLowerCase();
813
+ }
814
+ if (action === "assert_db") {
815
+ step.sql = requiredText(rawStep.sql, "assert_db sql", settings.browser.maxDatabaseQueryChars);
816
+ if (!isRecord(rawStep.expected)) throw new JevBrowserError("assert_db expected must contain row_count, first_row, or rows");
817
+ exactKeys(rawStep.expected, [], ["row_count", "first_row", "rows"], "assert_db expected");
818
+ if (!Object.keys(rawStep.expected).length) throw new JevBrowserError("assert_db expected must contain at least one assertion");
819
+ if (rawStep.expected.row_count !== undefined && (!Number.isSafeInteger(rawStep.expected.row_count) || rawStep.expected.row_count < 0)) {
820
+ throw new JevBrowserError("assert_db expected.row_count must be a non-negative integer");
821
+ }
822
+ if (rawStep.expected.first_row !== undefined && !isRecord(rawStep.expected.first_row)) throw new JevBrowserError("assert_db expected.first_row must be an object");
823
+ if (rawStep.expected.rows !== undefined && !Array.isArray(rawStep.expected.rows)) throw new JevBrowserError("assert_db expected.rows must be an array");
824
+ step.expected = rawStep.expected;
825
+ step.binds = rawStep.binds ?? {};
826
+ if (!isRecord(step.binds) || Object.keys(step.binds).length > settings.browser.maxDatabaseBindValues
827
+ || Object.values(step.binds).some((value) => !["string", "number", "boolean"].includes(typeof value) && value !== null)) {
828
+ throw new JevBrowserError("assert_db binds must be a bounded object of scalar values");
829
+ }
830
+ if (rawStep.extract !== undefined) {
831
+ if (!isRecord(rawStep.extract) || Object.keys(rawStep.extract).length > settings.browser.maxExternalExtractVariables) throw new JevBrowserError("assert_db extract must be a bounded object");
832
+ step.extract = Object.fromEntries(Object.entries(rawStep.extract).map(([name, descriptor]) => {
833
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new JevBrowserError("assert_db extract keys must be simple variable names");
834
+ exactKeys(descriptor, ["column"], ["row"], `assert_db extract.${name}`);
835
+ const row = descriptor.row ?? 0;
836
+ if (!Number.isSafeInteger(row) || row < 0) throw new JevBrowserError(`assert_db extract.${name}.row must be a non-negative integer`);
837
+ return [name, { column: requiredText(descriptor.column, `assert_db extract.${name}.column`, 128), row }];
838
+ }));
839
+ } else step.extract = {};
840
+ }
841
+ if (action === "assert_log") {
842
+ step.source = requiredText(rawStep.source, "assert_log source", 48);
843
+ step.contains = requiredText(rawStep.contains, "assert_log contains", settings.browser.maxTextEntryChars);
844
+ if (rawStep.not_contains !== undefined) step.notContains = requiredText(rawStep.not_contains, "assert_log not_contains", settings.browser.maxTextEntryChars);
845
+ step.since = rawStep.since ?? "flow";
846
+ if (step.since !== "flow" && !/^step:\d+$/.test(step.since)) throw new JevBrowserError("assert_log since must be flow or step:N");
847
+ }
848
+ if (action === "assert_email") {
849
+ for (const field of ["to", "subject_contains", "body_contains"]) {
850
+ if (rawStep[field] !== undefined) step[field === "subject_contains" ? "subjectContains" : field === "body_contains" ? "bodyContains" : field]
851
+ = requiredText(rawStep[field], `assert_email ${field}`, settings.browser.maxTextEntryChars);
852
+ }
853
+ if (!step.to && !step.subjectContains && !step.bodyContains) throw new JevBrowserError("assert_email needs at least one of to, subject_contains, or body_contains");
854
+ if (step.to && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(step.to)) throw new JevBrowserError("assert_email to must be a valid email address");
855
+ }
856
+ if (action === "assert_remote_file" || action === "delete_remote_file") {
857
+ step.server = requiredText(rawStep.server, `${action} server`, 48);
858
+ step.path = requiredText(rawStep.path, `${action} path`, 1024);
859
+ if (action === "assert_remote_file") {
860
+ step.exists = rawStep.exists ?? true;
861
+ if (typeof step.exists !== "boolean") throw new JevBrowserError("assert_remote_file exists must be a boolean");
862
+ if (rawStep.size_bytes !== undefined && (!Number.isSafeInteger(rawStep.size_bytes) || rawStep.size_bytes < 0)) throw new JevBrowserError("assert_remote_file size_bytes must be a non-negative integer");
863
+ step.sizeBytes = rawStep.size_bytes;
864
+ if (rawStep.sha256 !== undefined && !/^[a-f0-9]{64}$/i.test(rawStep.sha256)) throw new JevBrowserError("assert_remote_file sha256 must be a 64-character SHA-256 hex string");
865
+ step.sha256 = rawStep.sha256?.toLowerCase();
866
+ }
867
+ }
868
+ if (action === "run_hook") {
869
+ step.hook = requiredText(rawStep.hook, "run_hook hook", 48);
870
+ step.args = rawStep.args ?? {};
871
+ if (!isRecord(step.args)) throw new JevBrowserError("run_hook args must be an object");
872
+ step.expectedExitCode = rawStep.expected_exit_code ?? 0;
873
+ if (!Number.isSafeInteger(step.expectedExitCode) || step.expectedExitCode < 0 || step.expectedExitCode > 255) throw new JevBrowserError("run_hook expected_exit_code must be 0-255");
874
+ }
875
+ if (action === "evaluate") {
590
876
  step.expression = requiredText(rawStep.expression, "read-only expression", 256);
591
877
  if (!/^(?:window|document|navigator|location)(?:\.[A-Za-z_$][A-Za-z0-9_$]*){1,8}$/.test(step.expression)) {
592
878
  throw new JevBrowserError("evaluate accepts read-only property paths such as navigator.mediaDevices or document.readyState; calls and assignments are not supported");
@@ -632,10 +918,12 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
632
918
  if (["url", "title"].includes(step.source) && hasLocatorField(rawStep)) {
633
919
  throw new JevBrowserError(`extract source ${step.source} does not use a locator`);
634
920
  }
635
- if (rawStep.sensitive !== undefined && typeof rawStep.sensitive !== "boolean") {
636
- throw new JevBrowserError("extract sensitive must be a boolean");
637
- }
638
- step.sensitive = rawStep.sensitive ?? true;
921
+ if (rawStep.sensitive !== undefined && typeof rawStep.sensitive !== "boolean") {
922
+ throw new JevBrowserError("extract sensitive must be a boolean");
923
+ }
924
+ const sensitiveAttribute = step.source === "attribute"
925
+ && /(?:pass(?:word)?|token|secret|authorization|cookie|credential|api[_-]?key|private[_-]?key)/i.test(step.attribute);
926
+ step.sensitive = rawStep.sensitive ?? (step.source !== "attribute" || sensitiveAttribute);
639
927
  }
640
928
  if (action === "wait_for_condition") {
641
929
  if (!new Set(["network_idle", "url", "hidden", "text_hidden", "angular_idle"]).has(rawStep.condition)) {
@@ -699,9 +987,20 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
699
987
  }
700
988
  step.key = rawStep.key;
701
989
  }
702
- if (action === "assert_text" || action === "assert_value") {
703
- step.expected = requiredText(rawStep.expected, "expected value", settings.browser.maxTextEntryChars);
704
- }
990
+ if (action === "assert_text" || action === "assert_value") {
991
+ step.expected = requiredText(rawStep.expected, "expected value", settings.browser.maxTextEntryChars);
992
+ }
993
+ if (action === "assert_attribute") {
994
+ step.attribute = requiredText(rawStep.attribute, "attribute", 128);
995
+ if (!/^[A-Za-z_:][A-Za-z0-9:._-]*$/.test(step.attribute)) {
996
+ throw new JevBrowserError("assert_attribute attribute must be a valid HTML attribute name");
997
+ }
998
+ step.expected = requiredText(rawStep.expected, "expected value", settings.browser.maxTextEntryChars);
999
+ step.match = rawStep.match ?? "exact";
1000
+ if (!new Set(["exact", "contains"]).has(step.match)) {
1001
+ throw new JevBrowserError("assert_attribute match must be exact or contains");
1002
+ }
1003
+ }
705
1004
  if (action === "upload_file") {
706
1005
  const target = rawStep.target || (hasLocatorField(step) ? "input"
707
1006
  : step.role && DROPZONE_ROLES.has(step.role) && step.role !== "button" ? "dropzone"
@@ -744,7 +1043,13 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
744
1043
  throw new JevBrowserError(`${action} labels must distinguish liked and unliked state`);
745
1044
  }
746
1045
  }
747
- if (rawStep.frame !== undefined) step.frame = requiredText(rawStep.frame, "frame name", settings.jev.maxActionDescriptionChars);
1046
+ if (rawStep.frame !== undefined) {
1047
+ if (rawStep.frame === "first") step.frame = "first";
1048
+ else if (isRecord(rawStep.frame)) {
1049
+ exactKeys(rawStep.frame, ["url_contains"], [], "frame");
1050
+ step.frame = { urlContains: requiredText(rawStep.frame.url_contains, "frame URL substring", settings.jev.maxActionDescriptionChars) };
1051
+ } else step.frame = requiredText(rawStep.frame, "frame name", settings.jev.maxActionDescriptionChars);
1052
+ }
748
1053
  return step;
749
1054
  });
750
1055
  plans[id] = { description, steps, ...(rawPlan.when ? { when: { visible: rawPlan.when.visible } } : {}) };
@@ -779,6 +1084,11 @@ export function normalizeStepAliases(rawStep) {
779
1084
  step[canonical] = step[alias];
780
1085
  delete step[alias];
781
1086
  }
1087
+ if (Object.hasOwn(step, "trigger_match")) {
1088
+ if (Object.hasOwn(step, "name_match")) throw new JevBrowserError("confirm_modal cannot contain both trigger_match and name_match");
1089
+ step.name_match = step.trigger_match;
1090
+ delete step.trigger_match;
1091
+ }
782
1092
  if (step.name !== undefined && step.role === undefined) step.role = "button";
783
1093
  }
784
1094
  return step;
@@ -832,7 +1142,16 @@ function resolveFlowOptions(rawOptions, settings) {
832
1142
  }
833
1143
  const scope = rawOptions.snapshot_scope ?? settings.browser.defaults.snapshotScope;
834
1144
  if (!new Set(["body", "main", "dialog"]).has(scope)) throw new JevBrowserError("options.snapshot_scope must be body, main, or dialog");
835
- const defaults = settings.browser.defaults;
1145
+ const defaults = settings.browser.defaults;
1146
+ const environmentProfile = rawOptions.environment_profile === undefined
1147
+ ? undefined
1148
+ : requiredText(rawOptions.environment_profile, "environment_profile", 48);
1149
+ const selectedProfile = environmentProfile
1150
+ ? requireEnvironmentProfile(settings, environmentProfile, "options.environment_profile")
1151
+ : undefined;
1152
+ if ((rawOptions.allow_mutations ?? false) && selectedProfile?.environment === "prd") {
1153
+ throw new JevBrowserError("allow_mutations is forbidden for PRD profiles");
1154
+ }
836
1155
  const stepTimeoutSeconds = rawOptions.step_timeout_seconds ?? settings.browser.actionTimeoutMs / 1000;
837
1156
  if (typeof stepTimeoutSeconds !== "number" || !Number.isFinite(stepTimeoutSeconds) || stepTimeoutSeconds <= 0
838
1157
  || stepTimeoutSeconds > settings.browser.maxStepTimeoutSeconds) {
@@ -958,10 +1277,18 @@ function resolveFlowOptions(rawOptions, settings) {
958
1277
  if (storageState !== undefined && !/^[A-Za-z0-9_-]+$/.test(storageState)) {
959
1278
  throw new JevBrowserError("options.storage_state must be a simple account name using letters, numbers, _ or -");
960
1279
  }
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;
1280
+ if (storageState && (rawOptions.fresh_context || rawOptions.clear_storage)) {
1281
+ throw new JevBrowserError("options.storage_state cannot be combined with fresh_context or clear_storage");
1282
+ }
1283
+ const configuredNetworkErrors = rawOptions.capture_network_errors ?? defaults.captureNetworkErrors;
1284
+ const configuredNetworkErrorBodies = rawOptions.capture_network_error_bodies ?? defaults.captureNetworkErrorBodies;
1285
+ const captureNetwork = rawOptions.capture_network ?? (configuredNetworkErrors || configuredNetworkErrorBodies ? "errors" : "none");
1286
+ if (!new Set(["none", "errors", "all"]).has(captureNetwork)) {
1287
+ throw new JevBrowserError("options.capture_network must be none, errors, or all");
1288
+ }
1289
+ const captureNetworkUrlContains = rawOptions.capture_network_url_contains === undefined
1290
+ ? undefined : requiredText(rawOptions.capture_network_url_contains, "capture_network_url_contains", settings.jev.maxActionDescriptionChars);
1291
+ let reportPath;
965
1292
  if (rawOptions.report_path !== undefined) {
966
1293
  if (typeof rawOptions.report_path !== "string" || !path.isAbsolute(rawOptions.report_path)) {
967
1294
  throw new JevBrowserError("options.report_path must be an absolute path inside the configured artifact directory");
@@ -977,11 +1304,14 @@ function resolveFlowOptions(rawOptions, settings) {
977
1304
  blockFonts: rawOptions.block_fonts ?? defaults.blockFonts,
978
1305
  busySelectors: Object.freeze(busySelectors),
979
1306
  captureConsoleErrors: rawOptions.capture_console_errors ?? defaults.captureConsoleErrors,
980
- captureNetworkErrorBodies: rawOptions.capture_network_error_bodies ?? defaults.captureNetworkErrorBodies,
981
- captureNetworkErrors: rawOptions.capture_network_errors ?? defaults.captureNetworkErrors,
1307
+ captureNetworkErrorBodies: captureNetwork !== "none" && configuredNetworkErrorBodies,
1308
+ captureNetworkErrors: captureNetwork === "errors" || captureNetwork === "all",
1309
+ captureNetwork,
1310
+ captureNetworkUrlContains,
982
1311
  dryRun: rawOptions.dry_run ?? false,
983
1312
  fastPath: rawOptions.fast_path ?? defaults.fastPath,
984
- localOnly: rawOptions.local_only ?? defaults.localOnly,
1313
+ localOnly: rawOptions.local_only ?? defaults.localOnly,
1314
+ environmentProfile,
985
1315
  loginText: Object.freeze(loginText),
986
1316
  loginUrlContains: Object.freeze(loginUrlContains),
987
1317
  permissions: Object.freeze([...permissions]),
@@ -1026,11 +1356,29 @@ function escapeRegex(value) {
1026
1356
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1027
1357
  }
1028
1358
 
1029
- function planSecrets(plans) {
1030
- const secrets = new Set();
1031
- for (const plan of Object.values(plans)) {
1032
- for (const step of plan.steps) {
1033
- if (step.action === "type" && step.sensitive) secrets.add(step.text);
1359
+ function planSecrets(plans) {
1360
+ const secrets = new Set();
1361
+ const collectSensitiveValues = (value, sensitiveParent = false) => {
1362
+ if (typeof value === "string") {
1363
+ if (sensitiveParent && value.length >= 4) secrets.add(value);
1364
+ return;
1365
+ }
1366
+ if (Array.isArray(value)) {
1367
+ for (const item of value) collectSensitiveValues(item, sensitiveParent);
1368
+ return;
1369
+ }
1370
+ if (isRecord(value)) {
1371
+ for (const [key, item] of Object.entries(value)) collectSensitiveValues(item, sensitiveParent || /pass(?:word)?|secret|token|authorization|cookie|credential|api[_-]?key|private[_-]?key/i.test(key));
1372
+ }
1373
+ };
1374
+ for (const plan of Object.values(plans)) {
1375
+ for (const step of plan.steps) {
1376
+ if (step.action === "type" && step.sensitive) secrets.add(step.text);
1377
+ if (step.action === "http_request") {
1378
+ collectSensitiveValues(step.json);
1379
+ collectSensitiveValues(step.multipart);
1380
+ }
1381
+ if (step.action === "assert_db") collectSensitiveValues(step.binds);
1034
1382
  if (step.action === "upload_file") {
1035
1383
  for (const filePath of step.filePaths) {
1036
1384
  secrets.add(filePath);
@@ -1145,28 +1493,55 @@ function providerSafeSnapshot(snapshot, secrets) {
1145
1493
  });
1146
1494
  }
1147
1495
 
1148
- function localHostOnly(url) {
1149
- return new Set(["localhost", "127.0.0.1", "::1", "[::1]"]).has(new URL(url).hostname.toLowerCase());
1150
- }
1496
+ function localHostOnly(url) {
1497
+ return new Set(["localhost", "127.0.0.1", "::1", "[::1]"]).has(new URL(url).hostname.toLowerCase());
1498
+ }
1499
+
1500
+ const SAFE_RESPONSE_HEADER_NAMES = new Set([
1501
+ "cache-control", "content-length", "content-type", "date", "etag", "last-modified", "retry-after", "vary",
1502
+ ]);
1503
+
1504
+ function safeResponseHeaders(headers) {
1505
+ return Object.fromEntries(Object.entries(headers || {})
1506
+ .filter(([name, value]) => SAFE_RESPONSE_HEADER_NAMES.has(name.toLowerCase()) && typeof value === "string")
1507
+ .map(([name, value]) => [name.toLowerCase(), value.slice(0, 256)]));
1508
+ }
1509
+
1510
+ function assertLocalDebugPage(page, localOnly, action) {
1511
+ const currentUrl = page.url();
1512
+ if (!localOnly || !currentUrl || currentUrl === "about:blank" || !localHostOnly(currentUrl)) {
1513
+ throw new JevBrowserError(`${action} requires options.local_only: true and a localhost page`);
1514
+ }
1515
+ }
1516
+
1517
+ function redactLocalPayload(value) {
1518
+ if (Array.isArray(value)) return value.map(redactLocalPayload);
1519
+ if (!isRecord(value)) return value;
1520
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
1521
+ key,
1522
+ /(?:auth|cookie|credential|secret|password|token|session|user|usuario|nome|email|cpf|cnpj|matricula|identity)/i.test(key)
1523
+ ? "[redacted]" : redactLocalPayload(item),
1524
+ ]));
1525
+ }
1151
1526
 
1152
- async function resolveFakeMediaPath(fakeMedia, settings) {
1153
- if (!fakeMedia) return undefined;
1154
- if (!settings.browser.uploadRoot) {
1155
- throw new JevBrowserError("fake_media is disabled; configure JEV_BROWSER_UPLOAD_ROOT and keep the WAV file inside it");
1156
- }
1157
- let root;
1158
- let audio;
1159
- try {
1160
- root = await realpath(settings.browser.uploadRoot);
1161
- const requested = path.isAbsolute(fakeMedia) ? fakeMedia : path.resolve(root, fakeMedia);
1162
- audio = await realpath(requested);
1163
- } catch {
1164
- throw new JevBrowserError("fake_media could not read the configured WAV file");
1165
- }
1166
- const relative = path.relative(root, audio);
1167
- if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
1168
- throw new JevBrowserError("fake_media path must stay inside JEV_BROWSER_UPLOAD_ROOT");
1169
- }
1527
+ async function resolveFakeMediaPath(fakeMedia, settings) {
1528
+ if (!fakeMedia) return undefined;
1529
+ const staging = await prepareUploadStagingDirectory(settings);
1530
+ const roots = [settings.browser.uploadRoot, staging].filter(Boolean);
1531
+ let audio;
1532
+ for (const rootPath of roots) {
1533
+ const candidate = path.isAbsolute(fakeMedia) ? fakeMedia : path.resolve(rootPath, fakeMedia);
1534
+ const resolved = await realpath(candidate).catch(() => undefined);
1535
+ if (!resolved) continue;
1536
+ const root = await realpath(rootPath).catch(() => undefined);
1537
+ if (!root) continue;
1538
+ const relative = path.relative(root, resolved);
1539
+ if (relative && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) {
1540
+ audio = resolved;
1541
+ break;
1542
+ }
1543
+ }
1544
+ if (!audio) throw new JevBrowserError("fake_media must resolve inside managed project staging or JEV_BROWSER_UPLOAD_ROOT");
1170
1545
  if (path.extname(audio).toLowerCase() !== ".wav") throw new JevBrowserError("fake_media accepts WAV files only");
1171
1546
  const file = await stat(audio);
1172
1547
  if (!file.isFile() || file.size > settings.browser.maxUploadFileBytes) {
@@ -1665,12 +2040,20 @@ async function evaluateExpectedOutcome(expected, snapshot, networkTracker, marke
1665
2040
  return checks;
1666
2041
  }
1667
2042
 
1668
- function createNetworkTracker(page, settings, loginUrlContains = []) {
1669
- const active = new Map();
1670
- const history = [];
2043
+ function createNetworkTracker(page, settings, loginUrlContains = [], watchPatterns = []) {
2044
+ const active = new Map();
2045
+ const history = [];
2046
+ const watchedHistory = [];
2047
+ const watchedResponses = new Map();
2048
+ const responseWaiters = new Set();
2049
+ const responseObservers = new Set();
1671
2050
  const websocketHistory = [];
1672
2051
  const websocketUrls = new Map();
1673
2052
  const websocketObserverPromises = new Set();
2053
+ const blockedCookieEvents = [];
2054
+ const cdpRequestUrls = new Map();
2055
+ const cdpSessions = new Set();
2056
+ const pendingHeaderReads = new Set();
1674
2057
  const ignored = new Set();
1675
2058
  let sequence = 0;
1676
2059
  const shouldIgnore = (request) => {
@@ -1684,21 +2067,58 @@ function createNetworkTracker(page, settings, loginUrlContains = []) {
1684
2067
  return undefined;
1685
2068
  }
1686
2069
  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);
2070
+ const startedAt = active.get(request)?.at;
2071
+ const entry = {
2072
+ sequence: ++sequence, type, url: request.url(), method: request.method(),
2073
+ resourceType: request.resourceType(), at: performance.now(), request,
2074
+ };
2075
+ if (startedAt !== undefined && type !== "request") entry.durationMs = Math.max(0, entry.at - startedAt);
2076
+ if (type === "request") active.set(request, entry);
2077
+ else if (type === "finished" || type === "failed") {
2078
+ const original = active.get(request);
2079
+ if (original && entry.durationMs !== undefined) original.durationMs = entry.durationMs;
2080
+ active.delete(request);
2081
+ }
2082
+ history.push(entry);
2083
+ const historyLimit = settings.browser.maxNetworkHistoryEvents;
2084
+ if (history.length > historyLimit) history.splice(0, history.length - historyLimit);
2085
+ if (watchPatterns.some((pattern) => pattern.includes("*")
2086
+ ? urlPatternMatcher(pattern).test(entry.url) : entry.url.includes(pattern))) {
2087
+ watchedHistory.push(entry);
2088
+ if (watchedHistory.length > historyLimit) watchedHistory.splice(0, watchedHistory.length - historyLimit);
2089
+ }
1693
2090
  return entry;
1694
2091
  };
1695
2092
  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
- };
2093
+ const onResponse = (response) => {
2094
+ const entry = record("response", response.request());
2095
+ if (!entry) return;
2096
+ entry.response = response;
2097
+ entry.status = response.status();
2098
+ if (watchPatterns.some((pattern) => pattern.includes("*")
2099
+ ? urlPatternMatcher(pattern).test(entry.url) : entry.url.includes(pattern))) {
2100
+ const watchKey = `${entry.url}\n${entry.method}\n${entry.status}`;
2101
+ watchedResponses.delete(watchKey);
2102
+ watchedResponses.set(watchKey, entry);
2103
+ while (watchedResponses.size > settings.browser.maxNetworkHistoryEvents) {
2104
+ watchedResponses.delete(watchedResponses.keys().next().value);
2105
+ }
2106
+ }
2107
+ const requestEntry = active.get(response.request());
2108
+ if (requestEntry && entry.durationMs !== undefined) requestEntry.durationMs = entry.durationMs;
2109
+ const headerRead = Promise.resolve().then(() => response.allHeaders())
2110
+ .then((headers) => { entry.responseHeaders = safeResponseHeaders(headers); }).catch(() => {});
2111
+ pendingHeaderReads.add(headerRead);
2112
+ void headerRead.finally(() => pendingHeaderReads.delete(headerRead));
2113
+ for (const waiter of [...responseWaiters]) {
2114
+ if (entry.sequence > waiter.marker && waiter.predicate(entry)) {
2115
+ responseWaiters.delete(waiter);
2116
+ clearTimeout(waiter.timer);
2117
+ waiter.resolve(entry);
2118
+ }
2119
+ }
2120
+ for (const observer of [...responseObservers]) observer.capture(entry);
2121
+ };
1702
2122
  const onFinished = (request) => record("finished", request);
1703
2123
  const onFailed = (request) => record("failed", request);
1704
2124
  const trackedPages = new Set();
@@ -1717,9 +2137,41 @@ function createNetworkTracker(page, settings, loginUrlContains = []) {
1717
2137
  };
1718
2138
  const attachCdpWebSocketObserver = async (trackedPage) => {
1719
2139
  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));
2140
+ const session = await trackedPage.context().newCDPSession(trackedPage);
2141
+ cdpSessions.add(session);
2142
+ await session.send("Network.enable");
2143
+ session.on("Network.requestWillBeSent", ({ requestId, request }) => {
2144
+ if (request?.url) {
2145
+ cdpRequestUrls.set(requestId, request.url);
2146
+ while (cdpRequestUrls.size > settings.browser.maxNetworkHistoryEvents) {
2147
+ cdpRequestUrls.delete(cdpRequestUrls.keys().next().value);
2148
+ }
2149
+ }
2150
+ });
2151
+ session.on("Network.responseReceivedExtraInfo", ({ requestId, statusCode, headers, blockedCookies }) => {
2152
+ const url = cdpRequestUrls.get(requestId);
2153
+ if (!url) return;
2154
+ const blocked = Array.isArray(blockedCookies) ? blockedCookies.map(({ cookie, blockedReasons }) => ({
2155
+ name: String(cookie?.name || "").slice(0, 128),
2156
+ domain: String(cookie?.domain || "").slice(0, 255),
2157
+ path: String(cookie?.path || "").slice(0, 256),
2158
+ secure: Boolean(cookie?.secure),
2159
+ http_only: Boolean(cookie?.httpOnly),
2160
+ same_site: cookie?.sameSite,
2161
+ blocked_reasons: Array.isArray(blockedReasons) ? blockedReasons.map((reason) => String(reason).slice(0, 128)) : [],
2162
+ })) : [];
2163
+ if (blocked.length) {
2164
+ blockedCookieEvents.push({ sequence: ++sequence, url, status: statusCode, cookies: blocked });
2165
+ if (blockedCookieEvents.length > settings.browser.maxNetworkHistoryEvents) {
2166
+ blockedCookieEvents.splice(0, blockedCookieEvents.length - settings.browser.maxNetworkHistoryEvents);
2167
+ }
2168
+ }
2169
+ const safeHeaders = safeResponseHeaders(headers);
2170
+ const matching = [...history, ...watchedHistory].reverse().find((event) => event.type === "response" && event.url === url);
2171
+ if (matching && Object.keys(safeHeaders).length) matching.responseHeaders = safeHeaders;
2172
+ cdpRequestUrls.delete(requestId);
2173
+ });
2174
+ session.on("Network.webSocketCreated", ({ requestId, url }) => websocketUrls.set(requestId, url));
1723
2175
  session.on("Network.webSocketFrameReceived", ({ requestId, response }) => {
1724
2176
  if (response?.opcode !== 8 || typeof response.payloadData !== "string") return;
1725
2177
  try {
@@ -1732,7 +2184,10 @@ function createNetworkTracker(page, settings, loginUrlContains = []) {
1732
2184
  } catch { /* A non-binary close payload has no status code. */ }
1733
2185
  });
1734
2186
  session.on("Network.webSocketClosed", ({ requestId }) => websocketUrls.delete(requestId));
1735
- trackedPage.once("close", () => { void session.detach().catch(() => {}); });
2187
+ trackedPage.once("close", () => {
2188
+ cdpSessions.delete(session);
2189
+ void session.detach().catch(() => {});
2190
+ });
1736
2191
  } catch {
1737
2192
  // WebSocket close-code assertions are available only on Chromium CDP sessions.
1738
2193
  }
@@ -1760,15 +2215,73 @@ function createNetworkTracker(page, settings, loginUrlContains = []) {
1760
2215
  websocketObserverPromises.add(ready);
1761
2216
  return ready;
1762
2217
  };
1763
- attachPage(page);
2218
+ const historySince = (marker) => [...new Map([...history, ...watchedHistory, ...watchedResponses.values()]
2219
+ .filter((entry) => entry.sequence > marker)
2220
+ .map((entry) => [entry.sequence, entry])).values()].sort((left, right) => left.sequence - right.sequence);
2221
+ attachPage(page);
1764
2222
  return {
1765
2223
  mark: () => sequence,
1766
2224
  attachPage,
1767
2225
  waitForWebSocketObservers: async () => Promise.allSettled([...websocketObserverPromises]),
2226
+ observeResponseSince: (marker, predicate) => {
2227
+ const observer = {
2228
+ entry: historySince(marker).find((candidate) => candidate.type === "response" && predicate(candidate)),
2229
+ resolve: undefined,
2230
+ timer: undefined,
2231
+ capture(candidate) {
2232
+ if (this.entry || candidate.sequence <= marker || !predicate(candidate)) return;
2233
+ this.entry = candidate;
2234
+ responseObservers.delete(this);
2235
+ clearTimeout(this.timer);
2236
+ this.timer = undefined;
2237
+ const resolve = this.resolve;
2238
+ this.resolve = undefined;
2239
+ resolve?.(candidate);
2240
+ },
2241
+ wait(timeout) {
2242
+ if (this.entry) return Promise.resolve(this.entry);
2243
+ return new Promise((resolve) => {
2244
+ this.resolve = resolve;
2245
+ this.timer = setTimeout(() => {
2246
+ responseObservers.delete(this);
2247
+ this.timer = undefined;
2248
+ this.resolve = undefined;
2249
+ resolve(undefined);
2250
+ }, timeout);
2251
+ });
2252
+ },
2253
+ dispose() {
2254
+ responseObservers.delete(this);
2255
+ clearTimeout(this.timer);
2256
+ this.timer = undefined;
2257
+ const resolve = this.resolve;
2258
+ this.resolve = undefined;
2259
+ resolve?.(this.entry);
2260
+ },
2261
+ };
2262
+ if (!observer.entry) responseObservers.add(observer);
2263
+ return observer;
2264
+ },
2265
+ waitForResponseSince: (marker, predicate, timeout) => {
2266
+ const existing = historySince(marker).find((entry) => entry.type === "response" && predicate(entry));
2267
+ if (existing) return Promise.resolve(existing);
2268
+ return new Promise((resolve) => {
2269
+ const waiter = {
2270
+ marker,
2271
+ predicate,
2272
+ timer: setTimeout(() => {
2273
+ responseWaiters.delete(waiter);
2274
+ resolve(undefined);
2275
+ }, timeout),
2276
+ resolve,
2277
+ };
2278
+ responseWaiters.add(waiter);
2279
+ });
2280
+ },
1768
2281
  since: (marker, urlContains) => history.filter((entry) => entry.sequence > marker
1769
2282
  && (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),
2283
+ responsesSince: (marker, urlContains, method) => historySince(marker).filter((entry) =>
2284
+ entry.type === "response" && entry.url.includes(urlContains) && entry.method === method),
1772
2285
  websocketSince: (marker, urlContains) => websocketHistory.filter((entry) => entry.sequence > marker && entry.url.includes(urlContains)),
1773
2286
  collectWebSocketCloseCodes: async () => {
1774
2287
  for (const trackedPage of trackedPages) {
@@ -1791,13 +2304,35 @@ function createNetworkTracker(page, settings, loginUrlContains = []) {
1791
2304
  }
1792
2305
  }
1793
2306
  },
1794
- historySince: (marker) => history.filter((entry) => entry.sequence > marker),
2307
+ historySince,
2308
+ networkEventsSince: (marker, urlContains, limit = settings.jev.maxDiagnosticItems) => {
2309
+ const events = historySince(marker);
2310
+ const entries = events.filter((entry) => entry.type === "request"
2311
+ && (urlContains === undefined || entry.url.includes(urlContains)));
2312
+ return entries.slice(-limit).map((requestEntry) => {
2313
+ const response = events.find((event) => event.type === "response" && event.request === requestEntry.request);
2314
+ const terminal = events.find((event) => (event.type === "finished" || event.type === "failed")
2315
+ && event.request === requestEntry.request);
2316
+ return {
2317
+ url: requestEntry.url,
2318
+ method: requestEntry.method,
2319
+ resource_type: requestEntry.resourceType,
2320
+ ...(response ? { status: response.status } : {}),
2321
+ ...(requestEntry.durationMs !== undefined ? { duration_ms: Math.round(requestEntry.durationMs * 10) / 10 }
2322
+ : terminal?.durationMs !== undefined ? { duration_ms: Math.round(terminal.durationMs * 10) / 10 } : {}),
2323
+ ...(response?.responseHeaders ? { response_headers: response.responseHeaders } : {}),
2324
+ };
2325
+ });
2326
+ },
2327
+ blockedCookiesSince: (marker, urlContains, limit = settings.jev.maxDiagnosticItems) => blockedCookieEvents
2328
+ .filter((event) => event.sequence > marker && (urlContains === undefined || event.url.includes(urlContains)))
2329
+ .slice(-limit),
1795
2330
  authenticationFailureSince: (marker) => history.find((entry) => {
1796
2331
  if (entry.sequence <= marker || entry.type !== "response" || ![401, 403].includes(entry.status)) return false;
1797
2332
  return loginUrlContains.some((needle) => entry.url.toLocaleLowerCase().includes(needle.toLocaleLowerCase()));
1798
2333
  }),
1799
2334
  activeFor: (urlContains) => [...active.values()].filter((entry) => entry.url.includes(urlContains)),
1800
- dispose: () => {
2335
+ dispose: async () => {
1801
2336
  for (const trackedPage of trackedPages) {
1802
2337
  trackedPage.off("request", onRequest);
1803
2338
  trackedPage.off("response", onResponse);
@@ -1805,11 +2340,16 @@ function createNetworkTracker(page, settings, loginUrlContains = []) {
1805
2340
  trackedPage.off("requestfailed", onFailed);
1806
2341
  trackedPage.off("websocket", onWebSocket);
1807
2342
  }
1808
- trackedPages.clear();
1809
- active.clear();
1810
- ignored.clear();
1811
- history.length = 0;
1812
- websocketHistory.length = 0;
2343
+ trackedPages.clear();
2344
+ active.clear();
2345
+ ignored.clear();
2346
+ for (const observer of [...responseObservers]) observer.dispose();
2347
+ responseObservers.clear();
2348
+ await Promise.allSettled([...pendingHeaderReads]);
2349
+ pendingHeaderReads.clear();
2350
+ cdpRequestUrls.clear();
2351
+ await Promise.allSettled([...cdpSessions].map((session) => session.detach()));
2352
+ cdpSessions.clear();
1813
2353
  },
1814
2354
  };
1815
2355
  }
@@ -1922,10 +2462,13 @@ async function networkResponseMessage(response, page, settings, secrets) {
1922
2462
  }
1923
2463
  }
1924
2464
 
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);
2465
+ async function waitForNetworkAssertion(tracker, step, marker, initialNavigationMarker, timeout, page, settings, secrets) {
2466
+ const started = performance.now();
2467
+ while (performance.now() - started < timeout) {
2468
+ const [stepResponse] = tracker.responsesSince(marker, step.urlContains, step.method);
2469
+ const entry = stepResponse || (initialNavigationMarker === undefined
2470
+ ? undefined
2471
+ : tracker.responsesSince(initialNavigationMarker, step.urlContains, step.method).at(-1));
1929
2472
  if (entry) {
1930
2473
  if (entry.status !== step.status) {
1931
2474
  throw new JevBrowserError(`assert_network expected ${step.method} ${JSON.stringify(redact(step.urlContains, secrets))} status ${step.status}, received ${entry.status}`);
@@ -1949,17 +2492,15 @@ function urlPatternMatcher(pattern) {
1949
2492
  return new RegExp(`^${pattern.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`, "i");
1950
2493
  }
1951
2494
 
1952
- async function waitForRequestAssertion(tracker, step, marker, timeout, settings) {
1953
- const matcher = urlPatternMatcher(step.urlPattern);
1954
- const started = performance.now();
1955
- while (performance.now() - started < timeout) {
1956
- const entry = tracker.historySince(marker).find((event) => event.type === "response"
1957
- && matcher.test(event.url)
1958
- && (!step.method || event.method === step.method)
1959
- && (step.status === undefined || event.status === step.status));
1960
- if (entry) return entry;
1961
- await new Promise((resolve) => setTimeout(resolve, Math.min(settings.browser.visibilityPollMs, remainingMs(started + timeout))));
1962
- }
2495
+ async function waitForRequestAssertion(tracker, step, marker, timeout, observer) {
2496
+ const matcher = urlPatternMatcher(step.urlPattern);
2497
+ const matches = (event) => matcher.test(event.url)
2498
+ && (!step.method || event.method === step.method)
2499
+ && (step.status === undefined || event.status === step.status);
2500
+ const entry = observer
2501
+ ? await observer.wait(timeout)
2502
+ : await tracker.waitForResponseSince(marker, matches, timeout);
2503
+ if (entry) return entry;
1963
2504
  throw new JevBrowserError(`wait_for_request did not observe ${step.method || "any method"} ${JSON.stringify(step.urlPattern)}${step.status ? ` status ${step.status}` : ""} within ${timeout}ms`);
1964
2505
  }
1965
2506
 
@@ -2485,12 +3026,13 @@ function xpathRolePredicate(role) {
2485
3026
  return native ? `@role='${role}' or ${native}` : `@role='${role}'`;
2486
3027
  }
2487
3028
 
2488
- function relativeTarget(anchor, role) {
2489
- const selector = TEXT_ROLES.has(role)
2490
- ? "xpath=following::*[self::input or self::textarea or self::select or @role='textbox' or @role='searchbox' or @role='combobox'][1]"
2491
- : role === "link" ? "xpath=following::*[self::a or @role='link'][1]"
2492
- : role === "button" ? "xpath=following::*[self::button or @role='button'][1]"
2493
- : "xpath=following::*[self::input or self::textarea or self::select or self::button or self::a or @role='textbox' or @role='combobox' or @role='button' or @role='link'][1]";
3029
+ function relativeTarget(anchor, role, includeFollowingControls = false) {
3030
+ const firstOnly = includeFollowingControls ? "" : "[1]";
3031
+ const selector = TEXT_ROLES.has(role)
3032
+ ? `xpath=following::*[self::input or self::textarea or self::select or @role='textbox' or @role='searchbox' or @role='combobox']${firstOnly}`
3033
+ : role === "link" ? `xpath=following::*[self::a or @role='link']${firstOnly}`
3034
+ : role === "button" ? `xpath=following::*[self::button or @role='button']${firstOnly}`
3035
+ : `xpath=following::*[self::input or self::textarea or self::select or self::button or self::a or @role='textbox' or @role='combobox' or @role='button' or @role='link']${firstOnly}`;
2494
3036
  return anchor.locator(selector);
2495
3037
  }
2496
3038
 
@@ -2525,8 +3067,10 @@ async function targetLocator(root, step, timeout, settings) {
2525
3067
  locator = step.action === "type" && step.role ? relativeTarget(anchor, step.role) : anchor;
2526
3068
  } else if (field === "test_id") {
2527
3069
  locator = targetRoot.getByTestId(step.testId);
2528
- } else if (step.near) {
2529
- locator = relativeTarget(targetRoot.getByText(step.near.text, { exact: true }).first(), step.role);
3070
+ } else if (step.near) {
3071
+ const anchor = targetRoot.getByText(step.near.text, { exact: true }).first();
3072
+ locator = relativeTarget(anchor, step.role, step.name !== undefined);
3073
+ if (step.name !== undefined) locator = locator.and(targetRoot.getByRole(step.role, roleOptions(step)));
2530
3074
  } else if (step.role) {
2531
3075
  locator = targetRoot.getByRole(step.role, roleOptions(step));
2532
3076
  if (step.name && (!step.nameMatch || step.nameMatch === "exact") && await locator.count() === 0) {
@@ -2651,19 +3195,29 @@ async function namedTarget(root, step, snapshot, secrets, timeout, settings) {
2651
3195
  return locatorAt(locator, match.visible[0]);
2652
3196
  }
2653
3197
 
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
- }
3198
+ async function stepRoot(page, frameName) {
3199
+ if (!frameName) return page;
3200
+ const frames = page.frames().filter((candidate) => candidate !== page.mainFrame());
3201
+ if (frameName === "first") {
3202
+ if (frames.length) return frames[0];
3203
+ throw new JevBrowserError("frame first found no iframe on the page");
3204
+ }
3205
+ if (isRecord(frameName) && frameName.urlContains) {
3206
+ const matches = frames.filter((frame) => frame.url().includes(frameName.urlContains));
3207
+ if (matches.length === 1) return matches[0];
3208
+ throw new JevBrowserError(`frame URL substring ${JSON.stringify(frameName.urlContains)} must match exactly one iframe; found ${matches.length}`);
3209
+ }
3210
+ const namedFrames = [];
3211
+ for (const frame of frames) {
3212
+ const name = await frameDisplayName(frame);
3213
+ if (name) namedFrames.push({ frame, name });
3214
+ }
3215
+ const matches = namedFrames.filter(({ name }) => name === frameName);
3216
+ if (matches.length !== 1) {
3217
+ const available = namedFrames.map(({ name }) => name).slice(0, 5);
3218
+ const suffix = available.length ? `; available frames: ${available.map((name) => JSON.stringify(name)).join(", ")}` : "";
3219
+ 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`);
3220
+ }
2667
3221
  return matches[0].frame;
2668
3222
  }
2669
3223
 
@@ -2697,17 +3251,18 @@ function fileMimeType(fileName) {
2697
3251
  return MIME_TYPES.get(path.extname(fileName).toLowerCase()) || "application/octet-stream";
2698
3252
  }
2699
3253
 
2700
- async function uploadFiles(step, settings) {
2701
- const uploadRoot = settings.browser.uploadRoot;
2702
- if (!uploadRoot) throw new JevBrowserError("file uploads are disabled; configure JEV_BROWSER_UPLOAD_ROOT first");
2703
- let root;
2704
- try {
2705
- root = await realpath(uploadRoot);
2706
- } catch {
2707
- throw new JevBrowserError("configured JEV_BROWSER_UPLOAD_ROOT does not exist or is not accessible");
2708
- }
2709
- const rootInfo = await stat(root);
2710
- if (!rootInfo.isDirectory()) throw new JevBrowserError("configured JEV_BROWSER_UPLOAD_ROOT must be a directory");
3254
+ async function uploadFiles(step, settings) {
3255
+ const roots = [settings.browser.uploadRoot, await prepareUploadStagingDirectory(settings)].filter(Boolean);
3256
+ const allowedRoots = [];
3257
+ for (const candidate of roots) {
3258
+ try {
3259
+ const root = await realpath(candidate);
3260
+ if ((await stat(root)).isDirectory()) allowedRoots.push(root);
3261
+ } catch {
3262
+ if (candidate === settings.browser.uploadRoot) throw new JevBrowserError("configured JEV_BROWSER_UPLOAD_ROOT does not exist or is not accessible");
3263
+ }
3264
+ }
3265
+ if (!allowedRoots.length) throw new JevBrowserError("no permitted upload directory is available");
2711
3266
  let totalBytes = 0;
2712
3267
  const files = [];
2713
3268
  for (const requestedPath of step.filePaths) {
@@ -2718,9 +3273,12 @@ async function uploadFiles(step, settings) {
2718
3273
  } catch {
2719
3274
  throw new JevBrowserError("upload_file could not read one of the configured files");
2720
3275
  }
2721
- const relativePath = path.relative(root, resolved);
2722
- if (!relativePath || relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) {
2723
- throw new JevBrowserError("upload_file path must stay inside JEV_BROWSER_UPLOAD_ROOT");
3276
+ const allowed = allowedRoots.some((root) => {
3277
+ const relativePath = path.relative(root, resolved);
3278
+ return Boolean(relativePath) && relativePath !== ".." && !relativePath.startsWith(`..${path.sep}`) && !path.isAbsolute(relativePath);
3279
+ });
3280
+ if (!allowed) {
3281
+ throw new JevBrowserError("upload_file path must stay inside JEV_BROWSER_UPLOAD_ROOT");
2724
3282
  }
2725
3283
  const fileInfo = await stat(resolved);
2726
3284
  if (!fileInfo.isFile()) throw new JevBrowserError("upload_file accepts regular files only");
@@ -2770,7 +3328,7 @@ async function removeBrowserDownload(download) {
2770
3328
  }
2771
3329
  }
2772
3330
 
2773
- async function saveDownload(download, settings, ledger) {
3331
+ async function saveDownload(download, settings, ledger, networkTracker) {
2774
3332
  if (ledger.count >= settings.browser.maxDownloadFiles) {
2775
3333
  await removeBrowserDownload(download);
2776
3334
  throw new JevBrowserError("download exceeds the configured file-count limit");
@@ -2831,7 +3389,14 @@ async function saveDownload(download, settings, ledger) {
2831
3389
  ledger.count += 1;
2832
3390
  ledger.bytes += savedInfo.size;
2833
3391
  await removeBrowserDownload(download);
2834
- return { name, path: destination, size_bytes: savedInfo.size };
3392
+ let responseMimeType;
3393
+ try {
3394
+ const entry = networkTracker?.historySince?.(0)?.filter(({ url: responseUrl, status }) => responseUrl === download.url() && status !== undefined).at(-1);
3395
+ responseMimeType = entry?.responseHeaders?.["content-type"]?.split(";")[0]?.trim().toLowerCase();
3396
+ } catch { /* The download remains verifiable by content hash and extension when response headers are unavailable. */ }
3397
+ const saved = { name, path: destination, size_bytes: savedInfo.size, mime_type: responseMimeType || fileMimeType(name) };
3398
+ ledger.files?.push({ name, path: destination, sizeBytes: savedInfo.size, mimeType: responseMimeType });
3399
+ return saved;
2835
3400
  } catch (error) {
2836
3401
  await rm(destination, { force: true }).catch(() => {});
2837
3402
  await removeBrowserDownload(download);
@@ -2859,8 +3424,26 @@ async function visibleMenuItem(root, label, timeout, settings) {
2859
3424
  return locatorAt(textLocator, visible.at(-1));
2860
3425
  }
2861
3426
 
2862
- async function navigateMenu(page, root, step, settings, execution) {
2863
- let item;
3427
+ async function navigateMenu(page, root, step, settings, execution) {
3428
+ if (step.screen) {
3429
+ const search = page.locator("#pesquisa");
3430
+ const { visible } = await visibleIndexes(search, step.timeoutMs ?? settings.browser.actionTimeoutMs, "SAFI Acesso Rápido search", settings);
3431
+ if (visible.length !== 1) throw new JevBrowserError(`SAFI Acesso Rápido search must match exactly one visible input; found ${visible.length}`);
3432
+ await locatorAt(search, visible[0]).fill(step.screen, { timeout: step.timeoutMs ?? settings.browser.actionTimeoutMs });
3433
+ const timeout = step.timeoutMs ?? settings.browser.actionTimeoutMs;
3434
+ const allRows = page.locator("#ConsultaTelas tbody tr");
3435
+ const exactRows = allRows.filter({ has: page.getByText(step.screen, { exact: true }) });
3436
+ const exactMatches = await visibleIndexes(exactRows, timeout, "exact SAFI Acesso Rápido result", settings, { allowNone: true });
3437
+ const rows = exactMatches.visible.length
3438
+ ? exactRows
3439
+ : allRows.filter({ has: page.getByText(step.screen, { exact: false }) });
3440
+ const row = await exactlyOne(rows, `SAFI Acesso Rápido result ${JSON.stringify(step.screen)}`, timeout, settings);
3441
+ const link = await exactlyOne(row.getByRole("link", { name: new RegExp("Acessar", "i") }), "SAFI Acesso Rápido result link", step.timeoutMs ?? settings.browser.actionTimeoutMs, settings);
3442
+ await link.click({ timeout: step.timeoutMs ?? settings.browser.actionTimeoutMs });
3443
+ await page.locator("#pesquisa").waitFor({ state: "hidden", timeout: step.timeoutMs ?? settings.browser.actionTimeoutMs });
3444
+ return { action: step.action, navigated_screen: step.screen, navigation_source: "safi_quick_access" };
3445
+ }
3446
+ let item;
2864
3447
  try {
2865
3448
  item = await visibleMenuItem(root, step.path[0], 250, settings);
2866
3449
  } catch {
@@ -2879,26 +3462,77 @@ async function navigateMenu(page, root, step, settings, execution) {
2879
3462
  return { action: step.action, navigated_menu: step.path };
2880
3463
  }
2881
3464
 
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;
3465
+ async function executeStep(page, step, settings, snapshot, secrets, downloadLedger, execution = {}) {
3466
+ const root = await stepRoot(page, step.frame);
3467
+ const timeout = step.timeoutMs ?? settings.browser.actionTimeoutMs;
3468
+ if (["http_request", "generate_fixture", "assert_download", "assert_db", "assert_log", "start_email_capture", "assert_email", "assert_remote_file", "delete_remote_file", "run_hook"].includes(step.action)) {
3469
+ return executeIntegrationStep(step, {
3470
+ settings,
3471
+ profile: execution.environmentProfile,
3472
+ session: execution.integrationSession,
3473
+ variables: execution.variables,
3474
+ page,
3475
+ execution: { ...execution, downloadLedger },
3476
+ });
3477
+ }
2885
3478
  if (step.action === "click" && step.ifVisible && !await conditionIsVisible(root, step.ifVisible)) {
2886
3479
  return { action: step.action, skipped: true, skip_reason: "if_visible condition was not visible" };
2887
3480
  }
2888
- if (step.action === "navigate") {
3481
+ if (step.action === "navigate") {
2889
3482
  let destination;
2890
3483
  try { destination = new URL(step.url, page.url()).toString(); } catch {
2891
3484
  throw new JevBrowserError("navigate URL must be absolute or relative to the current page");
2892
3485
  }
2893
3486
  destination = validateUrl(destination);
2894
- if (execution.allowMutations && !localHostOnly(destination)) {
2895
- throw new JevBrowserError("allow_mutations only permits navigation to localhost, 127.0.0.1, or ::1");
3487
+ if (execution.allowMutations && !localHostOnly(destination) && !isProfileOriginAllowed(execution.environmentProfile, destination)) {
3488
+ throw new JevBrowserError("allow_mutations navigation must stay on localhost or an application origin allow-listed by the selected DEV/HML profile");
2896
3489
  }
2897
3490
  await page.goto(destination, { waitUntil: "domcontentloaded", timeout });
2898
3491
  execution.browserPool?.setNavigationError?.(null);
2899
- return { action: step.action, url: evidenceUrl(destination, secrets, settings.jev.maxDiagnosticChars) };
2900
- }
2901
- if (step.action === "evaluate") {
3492
+ return { action: step.action, url: evidenceUrl(destination, secrets, settings.jev.maxDiagnosticChars) };
3493
+ }
3494
+ if (step.action === "inspect_cookies") {
3495
+ assertLocalDebugPage(page, execution.localOnly, "inspect_cookies");
3496
+ const cookies = await execution.context.cookies([page.url()]);
3497
+ return {
3498
+ action: step.action,
3499
+ cookies: cookies.map(({ name, domain, path: cookiePath, expires, httpOnly, secure, sameSite }) => ({
3500
+ name: String(name).slice(0, 128), domain: String(domain).slice(0, 255), path: String(cookiePath).slice(0, 256),
3501
+ expires, http_only: Boolean(httpOnly), secure: Boolean(secure), same_site: sameSite,
3502
+ value: "[redacted]",
3503
+ })),
3504
+ };
3505
+ }
3506
+ if (step.action === "read_angular_state") {
3507
+ assertLocalDebugPage(page, execution.localOnly, "read_angular_state");
3508
+ const state = await page.evaluate(() => {
3509
+ const angular = window.angular;
3510
+ if (!angular) return { angular_present: false };
3511
+ const root = document.querySelector("[ng-app], [data-ng-app]") || document.body;
3512
+ let injector;
3513
+ try { injector = angular.element(root).injector(); } catch { injector = undefined; }
3514
+ if (!injector) return { angular_present: true, angular_version: angular.version?.full || null, injector_ready: false };
3515
+ let currentState = null;
3516
+ let parameterNames = [];
3517
+ try {
3518
+ const router = injector.get("$state");
3519
+ currentState = router.current?.name || null;
3520
+ parameterNames = Object.keys(router.params || {}).slice(0, 32);
3521
+ } catch { /* ui-router may not be registered on this page. */ }
3522
+ let authenticated = null;
3523
+ try { authenticated = Boolean(injector.get("$rootScope").usuario); } catch { /* no app-specific auth scope */ }
3524
+ return {
3525
+ angular_present: true,
3526
+ angular_version: angular.version?.full || null,
3527
+ injector_ready: true,
3528
+ state: currentState,
3529
+ parameter_names: parameterNames,
3530
+ authenticated,
3531
+ };
3532
+ });
3533
+ return { action: step.action, ...state };
3534
+ }
3535
+ if (step.action === "evaluate") {
2902
3536
  const value = await page.evaluate((expression) => {
2903
3537
  const parts = expression.split(".");
2904
3538
  let current = globalThis;
@@ -2931,7 +3565,9 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
2931
3565
  await execution.networkTracker.attachPage(next);
2932
3566
  if (step.url) {
2933
3567
  const destination = validateUrl(new URL(step.url, page.url()).toString());
2934
- if (execution.allowMutations && !localHostOnly(destination)) throw new JevBrowserError("allow_mutations only permits navigation to localhost, 127.0.0.1, or ::1");
3568
+ if (execution.allowMutations && !localHostOnly(destination) && !isProfileOriginAllowed(execution.environmentProfile, destination)) {
3569
+ throw new JevBrowserError("allow_mutations navigation must stay on localhost or an application origin allow-listed by the selected DEV/HML profile");
3570
+ }
2935
3571
  await next.goto(destination, { waitUntil: "domcontentloaded", timeout });
2936
3572
  }
2937
3573
  execution.capture.attachPage(next);
@@ -2956,9 +3592,20 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
2956
3592
  if (!tabs[step.index]) throw new JevBrowserError(`switch_tab index ${step.index} does not exist; open tabs: ${tabs.length}`);
2957
3593
  return { action: step.action, tab_index: step.index, activePage: tabs[step.index], activeContext: selectedContext };
2958
3594
  }
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) };
3595
+ if (step.action === "wait_for_request") {
3596
+ const request = await waitForRequestAssertion(
3597
+ execution.networkTracker,
3598
+ step,
3599
+ execution.flowNetworkMarker ?? execution.networkMarker,
3600
+ timeout,
3601
+ execution.requestObserver,
3602
+ );
3603
+ return {
3604
+ action: step.action, asserted: true, method: request.method, response_status: request.status,
3605
+ ...(request.durationMs === undefined ? {} : { duration_ms: Math.round(request.durationMs * 10) / 10 }),
3606
+ ...(request.responseHeaders ? { response_headers: request.responseHeaders } : {}),
3607
+ url: evidenceUrl(request.url, secrets, settings.jev.maxDiagnosticChars),
3608
+ };
2962
3609
  }
2963
3610
  if (step.action === "assert_ws") {
2964
3611
  const frame = await waitForWebSocketAssertion(execution.networkTracker, step, execution.networkMarker, timeout, settings);
@@ -2969,14 +3616,21 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
2969
3616
  if (step.action === "assert_network") {
2970
3617
  const response = await waitForNetworkAssertion(
2971
3618
  execution.networkTracker,
2972
- step,
2973
- execution.networkMarker,
2974
- timeout,
2975
- page,
3619
+ step,
3620
+ execution.networkMarker,
3621
+ execution.initialNavigationMarker,
3622
+ timeout,
3623
+ page,
2976
3624
  settings,
2977
3625
  secrets,
2978
3626
  );
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 } : {}) };
3627
+ return {
3628
+ action: step.action, asserted: true, url: evidenceUrl(response.url, secrets, settings.jev.maxDiagnosticChars),
3629
+ method: response.method, response_status: response.status,
3630
+ ...(response.durationMs === undefined ? {} : { duration_ms: Math.round(response.durationMs * 10) / 10 }),
3631
+ ...(response.responseHeaders ? { response_headers: response.responseHeaders } : {}),
3632
+ ...(response.message ? { message: response.message } : {}),
3633
+ };
2980
3634
  }
2981
3635
  if (step.action === "confirm_modal") {
2982
3636
  const trigger = await namedTarget(root, step, snapshot, secrets, timeout, settings);
@@ -3038,7 +3692,17 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
3038
3692
  if (step.source === "url") value = page.url();
3039
3693
  else if (step.source === "title") value = await page.title();
3040
3694
  else if (step.source === "value") value = await control.inputValue({ timeout });
3041
- else if (step.source === "attribute") value = await control.getAttribute(step.attribute, { timeout }) || "";
3695
+ else if (step.source === "attribute") {
3696
+ value = await control.getAttribute(step.attribute, { timeout }) || "";
3697
+ if (new Set(["href", "src", "action", "formaction", "poster"]).has(step.attribute.toLowerCase()) && value) {
3698
+ try {
3699
+ const parsed = new URL(value, page.url());
3700
+ parsed.search = "";
3701
+ parsed.hash = "";
3702
+ value = parsed.toString();
3703
+ } catch { value = "[redacted invalid URL attribute]"; }
3704
+ }
3705
+ }
3042
3706
  else value = (await control.innerText({ timeout })).replace(/\s+/g, " ").trim();
3043
3707
  if (value.length > settings.browser.maxTextEntryChars) {
3044
3708
  throw new JevBrowserError("extracted value exceeds the configured character limit");
@@ -3297,14 +3961,30 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
3297
3961
  ...targetEvidence(step, secrets, await describeResolvedTarget(control, step, secrets, settings)),
3298
3962
  };
3299
3963
  }
3300
- if (step.action === "assert_value") {
3301
- await waitForValue(control, step.expected, timeout, `assert_value for ${describeTarget(step, secrets)}`, settings);
3302
- return {
3303
- action: step.action,
3304
- asserted: true,
3305
- ...targetEvidence(step, secrets, await describeResolvedTarget(control, step, secrets, settings)),
3306
- };
3307
- }
3964
+ if (step.action === "assert_value") {
3965
+ await waitForValue(control, step.expected, timeout, `assert_value for ${describeTarget(step, secrets)}`, settings);
3966
+ return {
3967
+ action: step.action,
3968
+ asserted: true,
3969
+ ...targetEvidence(step, secrets, await describeResolvedTarget(control, step, secrets, settings)),
3970
+ };
3971
+ }
3972
+ if (step.action === "assert_attribute") {
3973
+ const actual = await control.getAttribute(step.attribute, { timeout });
3974
+ const matches = actual !== null && (step.match === "contains"
3975
+ ? actual.includes(step.expected)
3976
+ : actual === step.expected);
3977
+ if (!matches) {
3978
+ throw new JevBrowserError(`assert_attribute failed for ${describeTarget(step, secrets)}: attribute ${step.attribute} did not match the expected value`);
3979
+ }
3980
+ return {
3981
+ action: step.action,
3982
+ asserted: true,
3983
+ attribute: step.attribute,
3984
+ match: step.match,
3985
+ ...targetEvidence(step, secrets, await describeResolvedTarget(control, step, secrets, settings)),
3986
+ };
3987
+ }
3308
3988
  if (step.action === "assert_enabled") {
3309
3989
  if (!(await control.isEnabled())) throw new JevBrowserError(`assert_enabled failed for ${describeTarget(step, secrets)}`);
3310
3990
  return { action: step.action, asserted: true, enabled: true, ...targetEvidence(step, secrets, await describeResolvedTarget(control, step, secrets, settings)) };
@@ -3659,7 +4339,7 @@ async function writeFlowReport(result, reportPath, settings) {
3659
4339
  return outputPath;
3660
4340
  }
3661
4341
 
3662
- async function collectPerformanceMetrics(page) {
4342
+ async function collectPerformanceMetrics(page) {
3663
4343
  try {
3664
4344
  return await page.evaluate(() => {
3665
4345
  const navigation = performance.getEntriesByType("navigation")[0];
@@ -3677,9 +4357,52 @@ async function collectPerformanceMetrics(page) {
3677
4357
  } catch {
3678
4358
  return undefined;
3679
4359
  }
3680
- }
3681
-
3682
- async function redactObject(value, secrets) {
4360
+ }
4361
+
4362
+ function validateIntegratedActions(plans, options, settings) {
4363
+ const profile = options.environmentProfile
4364
+ ? requireEnvironmentProfile(settings, options.environmentProfile, "integrated test actions")
4365
+ : undefined;
4366
+ for (const [planId, plan] of Object.entries(plans)) {
4367
+ for (const [index, step] of plan.steps.entries()) {
4368
+ const where = `candidate plan ${planId} step ${index + 1}`;
4369
+ if (profile?.environment === "prd" && step.mutating === true) throw new JevBrowserError(`${where} mutating actions are forbidden for PRD profiles`);
4370
+ if (["assert_db", "assert_log", "start_email_capture", "assert_email", "assert_remote_file", "delete_remote_file", "run_hook"].includes(step.action) && !profile) {
4371
+ throw new JevBrowserError(`${where} action ${step.action} requires options.environment_profile`);
4372
+ }
4373
+ if (step.action === "http_request") {
4374
+ if (!profile && !options.localOnly) throw new JevBrowserError(`${where} http_request requires local_only or a configured environment_profile`);
4375
+ if (!profile && step.method !== "GET" && step.method !== "HEAD") throw new JevBrowserError(`${where} mutating http_request requires a configured DEV or HML profile`);
4376
+ if (profile?.environment === "prd" && !["GET", "HEAD"].includes(step.method)) throw new JevBrowserError(`${where} mutating http_request is forbidden for PRD`);
4377
+ if (!["GET", "HEAD"].includes(step.method) && !options.allowMutations) throw new JevBrowserError(`${where} mutating http_request requires explicit options.allow_mutations authorization`);
4378
+ }
4379
+ if (step.action === "assert_db" && !profile?.database) throw new JevBrowserError(`${where} needs a database connection in environment profile ${profile?.id || ""}`);
4380
+ if (step.action === "assert_db") assertReadOnlySql(step.sql, settings.browser.maxDatabaseQueryChars);
4381
+ if (step.action === "assert_log" && !Object.hasOwn(profile?.logSources || {}, step.source)) throw new JevBrowserError(`${where} log source ${step.source} is not allow-listed`);
4382
+ if (["start_email_capture", "assert_email"].includes(step.action) && !profile?.smtpCapture) throw new JevBrowserError(`${where} needs smtp_capture in environment profile ${profile?.id || ""}`);
4383
+ if (step.action === "assert_remote_file" || step.action === "delete_remote_file") {
4384
+ if (!Object.hasOwn(profile?.remotes || {}, step.server)) throw new JevBrowserError(`${where} remote server ${step.server} is not allow-listed`);
4385
+ if (step.action === "delete_remote_file" && profile.environment !== "dev") throw new JevBrowserError(`${where} delete_remote_file is restricted to DEV profiles`);
4386
+ if (step.action === "delete_remote_file" && !options.allowMutations) throw new JevBrowserError(`${where} delete_remote_file requires explicit options.allow_mutations authorization`);
4387
+ }
4388
+ if (step.action === "run_hook") {
4389
+ if (!Object.hasOwn(profile?.hooks || {}, step.hook)) throw new JevBrowserError(`${where} hook ${step.hook} is not allow-listed`);
4390
+ if (profile.environment !== "dev") throw new JevBrowserError(`${where} run_hook is restricted to DEV profiles`);
4391
+ if (!options.allowMutations) throw new JevBrowserError(`${where} run_hook requires explicit options.allow_mutations authorization`);
4392
+ }
4393
+ if (step.action === "generate_fixture") {
4394
+ const extension = path.extname(step.fileName).toLowerCase();
4395
+ if (step.kind === "pdf" && extension !== ".pdf") throw new JevBrowserError(`${where} PDF fixtures must use a .pdf filename`);
4396
+ if (step.kind === "text" && extension !== ".txt") throw new JevBrowserError(`${where} text fixtures must use a .txt filename`);
4397
+ if (/[\\/\0]/.test(step.fileName) || step.fileName === "." || step.fileName === "..") throw new JevBrowserError(`${where} fixture file_name cannot contain a path`);
4398
+ }
4399
+ }
4400
+ }
4401
+ if (options.allowMutations && profile?.environment === "prd") throw new JevBrowserError("allow_mutations is forbidden for PRD profiles");
4402
+ return profile;
4403
+ }
4404
+
4405
+ async function redactObject(value, secrets) {
3683
4406
  if (typeof value === "string") return redact(value, secrets);
3684
4407
  if (Array.isArray(value)) return Promise.all(value.map((item) => redactObject(item, secrets)));
3685
4408
  if (isRecord(value)) {
@@ -3709,43 +4432,53 @@ function snapshotDiff(initialSnapshot, finalSnapshot) {
3709
4432
  return added.length ? added.join("\n") : "[no new snapshot lines]";
3710
4433
  }
3711
4434
 
3712
- function stripUnnamedControlsSection(snapshot) {
3713
- return snapshot.replace(/\n\n\[interactive controls without accessible names;[^\]]+\][\s\S]*$/i, "").trim();
3714
- }
3715
-
3716
- export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, candidatePlans, params: rawParams, options: rawOptions, settings, browserPool, fetchImpl = fetch, serverVersion, mutationAuthorizationStore = defaultMutationAuthorizationStore }) {
4435
+ export function validateBrowserFlowRequest({ flow, initialUrl, expectedOutcome, candidatePlans, params: rawParams, options: rawOptions, settings }) {
3717
4436
  if (typeof flow !== "string" || (expectedOutcome !== undefined && typeof expectedOutcome !== "string" && !isRecord(expectedOutcome))) {
3718
4437
  throw new JevBrowserError("flow must be text and expected_outcome must be text or a structured assertion object");
3719
4438
  }
3720
- let variables = validateParams(rawParams, settings);
4439
+ const variables = validateParams(rawParams, settings);
3721
4440
  const requestedFlow = replaceKnownParams(flow, variables).trim();
3722
4441
  const expected = normalizeExpectedOutcome(expectedOutcome, variables, settings);
3723
- const outcome = expected.description;
3724
- const outcomeText = expected.text;
3725
4442
  if (!requestedFlow) throw new JevBrowserError("flow must not be empty");
3726
4443
  if (requestedFlow.length > settings.jev.maxFlowChars) throw new JevBrowserError("flow exceeds the configured character limit");
3727
- if (outcome.length > settings.jev.maxActionDescriptionChars) throw new JevBrowserError("expected_outcome exceeds the configured character limit");
3728
- const url = initialUrl === undefined ? undefined : validateUrl(initialUrl);
4444
+ if (expected.description.length > settings.jev.maxActionDescriptionChars) throw new JevBrowserError("expected_outcome exceeds the configured character limit");
4445
+ const url = initialUrl === undefined ? undefined : validateUrl(initialUrl);
3729
4446
  const options = resolveFlowOptions(rawOptions, settings);
3730
4447
  const plans = validateCandidatePlans(replaceKnownParams(candidatePlans, variables, true), settings, options.maxFlowSteps);
4448
+ const selectedProfile = validateIntegratedActions(plans, options, settings);
3731
4449
  assertPlanPlaceholders(plans, variables);
3732
4450
  if (!url && !options.reusePage) throw new JevBrowserError("initial_url is required when continue_from_current_page is false");
3733
4451
  if (options.waitForHttp && !localHostOnly(options.waitForHttp.url)
3734
4452
  && (!url || new URL(options.waitForHttp.url).hostname.toLowerCase() !== new URL(url).hostname.toLowerCase())) {
3735
4453
  throw new JevBrowserError("wait_for_http must target localhost or the same host as initial_url");
3736
4454
  }
3737
- if (!url && options.allowMutations) throw new JevBrowserError("allow_mutations needs an explicit localhost initial_url");
3738
- resolveEnvironmentText(plans, settings);
3739
- if (options.localOnly && Object.keys(plans).length > 0 && !(options.fastPath && Object.keys(plans).length === 1)) {
3740
- throw new JevBrowserError("local_only does not call Jev; provide exactly one candidate plan and enable fast_path");
3741
- }
3742
- if (options.allowMutations && url && !localHostOnly(url)) {
3743
- throw new JevBrowserError("allow_mutations is limited to localhost, 127.0.0.1, and ::1 URLs");
3744
- }
3745
- const secretBearingFlow = Object.values(plans).some((plan) => plan.steps.some((step) => step.action === "type" && step.sensitive));
3746
- if (options.recordVideo && secretBearingFlow) {
3747
- throw new JevBrowserError("record_video is disabled for flows that type sensitive text");
3748
- }
4455
+ if (!url && options.allowMutations && !selectedProfile) throw new JevBrowserError("allow_mutations needs an explicit localhost initial_url or a configured environment_profile");
4456
+ resolveEnvironmentText(plans, settings);
4457
+ if (options.localOnly && Object.keys(plans).length > 0 && !(options.fastPath && Object.keys(plans).length === 1)) {
4458
+ throw new JevBrowserError("local_only does not call Jev; provide exactly one candidate plan and enable fast_path");
4459
+ }
4460
+ if (options.allowMutations && url && !localHostOnly(url) && !isProfileOriginAllowed(selectedProfile, url)) {
4461
+ throw new JevBrowserError("allow_mutations URL must be localhost or an application origin allow-listed by the selected DEV/HML profile");
4462
+ }
4463
+ if (options.freshContext && !url) throw new JevBrowserError("fresh_context requires initial_url so the isolated context never inherits a previous page or account");
4464
+ const secretBearingFlow = Object.values(plans).some((plan) => plan.steps.some((step) => step.action === "type" && step.sensitive));
4465
+ if (options.recordVideo && secretBearingFlow) throw new JevBrowserError("record_video is disabled for flows that type sensitive text");
4466
+ return { variables, requestedFlow, expected, url, options, plans, selectedProfile, secretBearingFlow };
4467
+ }
4468
+
4469
+ function stripUnnamedControlsSection(snapshot) {
4470
+ return snapshot.replace(/\n\n\[interactive controls without accessible names;[^\]]+\][\s\S]*$/i, "").trim();
4471
+ }
4472
+
4473
+ async function executeBrowserFlowCore({ flow, initialUrl, expectedOutcome, candidatePlans, params: rawParams, options: rawOptions, settings, browserPool, fetchImpl = fetch, serverVersion, integrationSession, mutationAuthorizationStore = defaultMutationAuthorizationStore }) {
4474
+ const preflight = validateBrowserFlowRequest({ flow, initialUrl, expectedOutcome, candidatePlans, params: rawParams, options: rawOptions, settings });
4475
+ const { requestedFlow, expected, url, options, plans, selectedProfile, secretBearingFlow } = preflight;
4476
+ let variables = preflight.variables;
4477
+ const outcome = expected.description;
4478
+ const outcomeText = expected.text;
4479
+ const networkOnlyFastPath = options.fastPath && Object.keys(plans).length === 1
4480
+ && !Object.values(plans)[0].when
4481
+ && Object.values(plans)[0].steps.every(({ action }) => ["assert_network", "wait_for_request"].includes(action));
3749
4482
  const fingerprint = flowFingerprint({ flow, initialUrl: url, expectedOutcome, candidatePlans, params: rawParams, options });
3750
4483
  const continuation = options.confirmationToken
3751
4484
  ? mutationAuthorizationStore.consume(options.confirmationToken, fingerprint)
@@ -3778,12 +4511,20 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
3778
4511
  browserSessionMs = browser.sessionMs;
3779
4512
  page = browser.page;
3780
4513
  context = browser.context;
4514
+ if (browser.recovered && options.reusePage && !url) browserPool.setNavigationError?.("BROWSER_PAGE_NOT_RESTORED");
3781
4515
  }
3782
4516
  const initialPage = page;
3783
4517
  const initialContext = context;
3784
- const capture = createDiagnostics(page, options, settings, secrets);
4518
+ const networkWatchPatterns = Object.values(plans).flatMap(({ steps }) => steps.flatMap((step) => {
4519
+ if (step.action === "wait_for_request") return [step.urlPattern];
4520
+ if (step.action === "assert_network") return [step.urlContains];
4521
+ return [];
4522
+ })).concat(expected.assertions.filter(({ kind }) => kind === "request").map(({ request }) => request));
4523
+ const networkTracker = createNetworkTracker(page, settings, options.loginUrlContains, networkWatchPatterns);
4524
+ const capture = createDiagnostics(page, options, settings, secrets);
3785
4525
  const result = {
3786
- ...(serverVersion ? { server_version: serverVersion } : {}),
4526
+ ...(serverVersion ? { server_version: serverVersion } : {}),
4527
+ ...(selectedProfile ? { environment_profile: { id: selectedProfile.id, environment: selectedProfile.environment } } : {}),
3787
4528
  status: "failed",
3788
4529
  expected_outcome_visible: false,
3789
4530
  acceptance_criteria_met: false,
@@ -3796,11 +4537,31 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
3796
4537
  final_snapshot: "",
3797
4538
  };
3798
4539
  result.snapshot_mode = options.returnSnapshot;
3799
- if (options.allowMutations) result.mutation_authorization = "allowed_on_localhost";
3800
- const downloadLedger = { count: 0, bytes: 0 };
3801
- const networkTracker = createNetworkTracker(page, settings, options.loginUrlContains);
4540
+ if (options.allowMutations) result.mutation_authorization = selectedProfile
4541
+ ? `allowed_on_${selectedProfile.environment}_profile`
4542
+ : "allowed_on_localhost";
4543
+ const downloadLedger = { count: 0, bytes: 0, files: [] };
3802
4544
  await networkTracker.waitForWebSocketObservers();
3803
- const initialNetworkMarker = networkTracker.mark();
4545
+ const initialNetworkMarker = networkTracker.mark();
4546
+ const requestObservers = new Map();
4547
+ const armAvailableRequestObservers = () => {
4548
+ for (const [planId, candidatePlan] of Object.entries(plans)) {
4549
+ candidatePlan.steps.forEach((templateStep, stepIndex) => {
4550
+ const observerKey = `${planId}:${stepIndex}`;
4551
+ if (templateStep.action !== "wait_for_request" || requestObservers.has(observerKey)) return;
4552
+ const unresolved = [...templateStep.urlPattern.matchAll(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g)]
4553
+ .some(([, name]) => !Object.hasOwn(variables, name));
4554
+ if (unresolved) return;
4555
+ const resolvedPattern = replaceKnownParams(templateStep.urlPattern, variables);
4556
+ const matcher = urlPatternMatcher(resolvedPattern);
4557
+ requestObservers.set(observerKey, networkTracker.observeResponseSince(initialNetworkMarker, (event) => matcher.test(event.url)
4558
+ && (!templateStep.method || event.method === templateStep.method)
4559
+ && (templateStep.status === undefined || event.status === templateStep.status)));
4560
+ });
4561
+ }
4562
+ };
4563
+ armAvailableRequestObservers();
4564
+ let initialNavigationMarker;
3804
4565
  const timings = { navigationMs: 0, initialSnapshotMs: 0, readyMs: 0, decisionMs: 0, browserPlanMs: 0 };
3805
4566
  const routeCleanups = [];
3806
4567
  const contextStack = [context];
@@ -3855,17 +4616,27 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
3855
4616
  addDiagnostic(result.warnings, "trace, video, and screenshots were suppressed because this flow contains sensitive text", settings.jev.maxDiagnosticItems);
3856
4617
  }
3857
4618
 
3858
- let reusedPage = false;
4619
+ let reusedPage = false;
3859
4620
  result.navigation_strategy = options.freshContext ? "fresh_isolated_context" : "navigated_to_initial_url";
3860
- const plansBeginWithNavigation = Object.values(plans).every(({ steps }) => steps[0]?.action === "navigate");
4621
+ const plansBeginWithNavigation = Object.values(plans).every(({ steps }) => steps[0]?.action === "navigate");
3861
4622
  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") {
4623
+ navigationError = browserPool.getNavigationError?.();
4624
+ if (navigationError) {
4625
+ if (navigationError === "BROWSER_PAGE_NOT_RESTORED" && url) {
4626
+ result.navigation_strategy = "recovered_with_initial_url";
4627
+ } else {
4628
+ result.navigation_error = navigationError;
4629
+ const detail = navigationError === "BROWSER_PAGE_NOT_RESTORED"
4630
+ ? "the previous page was not restored after reconnecting; provide initial_url to navigate again"
4631
+ : `the previous navigation failed with ${navigationError}; run once with reuse_page: false to navigate again`;
4632
+ throw new JevBrowserError(`reuse_page cannot continue because ${detail}`);
4633
+ }
4634
+ }
4635
+ const currentUrl = typeof page.url === "function" ? page.url() : "about:blank";
4636
+ if (options.allowMutations && !localHostOnly(currentUrl) && !isProfileOriginAllowed(selectedProfile, currentUrl)) {
4637
+ throw new JevBrowserError("allow_mutations cannot continue on a page outside localhost or the selected profile's application origin allowlist");
4638
+ }
4639
+ if (!navigationError && currentUrl && currentUrl !== "about:blank") {
3869
4640
  let currentRoute;
3870
4641
  try {
3871
4642
  currentRoute = normalizedRouteUrl(currentUrl);
@@ -3888,18 +4659,30 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
3888
4659
  }
3889
4660
  }
3890
4661
  }
3891
- if (!reusedPage) {
4662
+ if (!reusedPage) {
3892
4663
  const destinationUrl = url;
3893
4664
  if (!destinationUrl) throw new JevBrowserError("initial_url is required because there is no open page to continue");
3894
4665
  const navigationStarted = performance.now();
4666
+ initialNavigationMarker = networkTracker.mark();
3895
4667
  try {
3896
- await page.goto(destinationUrl, { waitUntil: "domcontentloaded", timeout: settings.browser.actionTimeoutMs * 4 });
4668
+ const response = await page.goto(destinationUrl, { waitUntil: "domcontentloaded", timeout: settings.browser.actionTimeoutMs * 4 });
4669
+ if (response?.status() >= 400) result.navigation_http_status = response.status();
3897
4670
  if (!options.freshContext) browserPool.setNavigationError?.(null);
3898
4671
  } 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
- }
4672
+ const failedDocument = networkOnlyFastPath
4673
+ ? networkTracker.historySince(initialNavigationMarker)
4674
+ .filter((entry) => entry.type === "response" && entry.resourceType === "document" && entry.status >= 400)
4675
+ .at(-1)
4676
+ : undefined;
4677
+ if (failedDocument) {
4678
+ result.navigation_http_status = failedDocument.status;
4679
+ if (!options.freshContext) browserPool.setNavigationError?.(null);
4680
+ } else {
4681
+ navigationError = String(error?.message || "").match(/\b(?:net::)?(ERR_[A-Z0-9_]+)\b/)?.[1] || "NAVIGATION_FAILED";
4682
+ if (navigationError && !options.freshContext) browserPool.setNavigationError?.(navigationError);
4683
+ throw error;
4684
+ }
4685
+ }
3903
4686
  timings.navigationMs = performance.now() - navigationStarted;
3904
4687
  }
3905
4688
  if (options.clearStorage) {
@@ -3909,11 +4692,15 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
3909
4692
  }
3910
4693
  reusedPage = false;
3911
4694
  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);
4695
+ }
4696
+ const snapshotStarted = performance.now();
4697
+ try {
4698
+ const readiness = networkOnlyFastPath
4699
+ ? { snapshot: "", readyMs: 0 }
4700
+ : await waitForPageReady(page, options, settings, secrets, reusedPage);
4701
+ const enriched = networkOnlyFastPath
4702
+ ? { snapshot: "", unnamedControls: [] }
4703
+ : await enrichSnapshot(page, readiness.snapshot, settings, secrets, options.snapshotScope, options.snapshotIncludeHidden);
3917
4704
  result.final_snapshot = enriched.snapshot;
3918
4705
  initialSnapshot = continuation?.initialSnapshot || enriched.snapshot;
3919
4706
  if (options.includeUnnamedControls) result.unnamed_controls_initial = enriched.unnamedControls;
@@ -3921,13 +4708,16 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
3921
4708
  navigationError = "AUTHENTICATION_REQUIRED";
3922
4709
  throw new JevBrowserError("the configured login text is visible; sign in to the application, then run the flow again");
3923
4710
  }
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
- }
4711
+ if (enriched.unnamedControls.length) {
4712
+ const examples = enriched.unnamedControls.slice(0, 3)
4713
+ .map(({ role, nearest_label }) => `${role}${nearest_label ? ` near ${JSON.stringify(nearest_label)}` : ""}`)
4714
+ .join("; ");
4715
+ addDiagnostic(
4716
+ result.warnings,
4717
+ `${enriched.unnamedControls.length} interactive control(s) have no accessible name${examples ? `; examples: ${examples}` : ""}; set include_unnamed_controls to return sanitized HTML evidence`,
4718
+ settings.jev.maxDiagnosticItems,
4719
+ );
4720
+ }
3931
4721
  timings.readyMs = readiness.readyMs;
3932
4722
  if (!options.freshContext) browserPool.setNavigationError?.(null);
3933
4723
  if (continuation) {
@@ -4007,8 +4797,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
4007
4797
  result.accessibility_audits = continuation?.accessibilityAudits || [];
4008
4798
  if (continuation?.downloadLedger) Object.assign(downloadLedger, continuation.downloadLedger);
4009
4799
  const planStarted = performance.now();
4010
- const runAllSteps = plan.steps.some(({ action, expectDownload }) => action.startsWith("assert_")
4011
- || ["navigate", "clear", "clear_storage", "new_tab", "new_context", "switch_tab", "screenshot", "wait_for_request", "upload_file", "select_option", "hover", "like_comment", "unlike_comment", "audit_accessibility"].includes(action)
4800
+ const runAllSteps = plan.steps.some(({ action, expectDownload }) => action.startsWith("assert_")
4801
+ || ["navigate", "clear", "clear_storage", "new_tab", "new_context", "switch_tab", "screenshot", "wait_for_request", "upload_file", "generate_fixture", "http_request", "assert_db", "assert_log", "start_email_capture", "assert_email", "assert_remote_file", "delete_remote_file", "run_hook", "select_option", "hover", "like_comment", "unlike_comment", "audit_accessibility"].includes(action)
4012
4802
  || expectDownload === true);
4013
4803
  let approvedMutationIndex = continuation?.nextStep;
4014
4804
  for (let index = continuation?.nextStep || 0; index < plan.steps.length; index += 1) {
@@ -4060,7 +4850,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
4060
4850
  if (index === approvedMutationIndex) approvedMutationIndex = undefined;
4061
4851
  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);
4062
4852
  if (step.fragileSelector) addDiagnostic(result.warnings, `step ${index + 1} uses an explicit CSS/XPath selector as a last resort`, settings.jev.maxDiagnosticItems);
4063
- if (step.action === "click" && plan.steps[index + 1]?.action === "confirm_dialog") {
4853
+ await integrationSession?.markStep?.(index + 1);
4854
+ if (step.action === "click" && plan.steps[index + 1]?.action === "confirm_dialog") {
4064
4855
  nativeDialog.expectedStep = interpolateStep(plan.steps[index + 1], variables);
4065
4856
  nativeDialog.result = undefined;
4066
4857
  } else if (step.action !== "confirm_dialog") {
@@ -4068,17 +4859,25 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
4068
4859
  nativeDialog.result = undefined;
4069
4860
  }
4070
4861
  const actionMarker = networkTracker.mark();
4071
- const execution = {
4072
- networkTracker,
4073
- networkMarker,
4074
- autoAngularIdle: options.autoAngularIdle,
4075
- readyStableMs: options.readyStableMs,
4076
- allowMutations: options.allowMutations,
4862
+ const execution = {
4863
+ networkTracker,
4864
+ networkMarker,
4865
+ initialNavigationMarker,
4866
+ flowNetworkMarker: initialNetworkMarker,
4867
+ autoAngularIdle: options.autoAngularIdle,
4868
+ readyStableMs: options.readyStableMs,
4869
+ localOnly: options.localOnly,
4870
+ allowMutations: options.allowMutations || index === approvedMutationIndex,
4871
+ environmentProfile: selectedProfile,
4872
+ integrationSession,
4873
+ downloadLedger,
4077
4874
  browserPool,
4078
4875
  nativeDialog,
4079
- preResolvedTarget: mutationInspection.target,
4080
- popupPage,
4081
- variables,
4876
+ preResolvedTarget: mutationInspection.target,
4877
+ requestObserver: requestObservers.get(`${planId}:${index}`),
4878
+ popupPage,
4879
+ variables,
4880
+ secrets,
4082
4881
  context,
4083
4882
  contextStack,
4084
4883
  capture,
@@ -4096,6 +4895,7 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
4096
4895
  },
4097
4896
  };
4098
4897
  const traceStep = await executeStep(stepPage, step, settings, snapshot, secrets, downloadLedger, execution);
4898
+ armAvailableRequestObservers();
4099
4899
  popupPage = execution.popupPage;
4100
4900
  if (traceStep.activeContext) context = traceStep.activeContext;
4101
4901
  const nextActivePage = traceStep.activePage;
@@ -4130,18 +4930,20 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
4130
4930
  failure = `accessibility audit reported ${traceStep.accessibility_audit.blocking_violations_count} violation(s) at or above ${traceStep.accessibility_audit.fail_on_impact} impact`;
4131
4931
  }
4132
4932
  }
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
- );
4933
+ if (!networkOnlyFastPath) {
4934
+ snapshot = await snapshotAfterStep(
4935
+ activePage,
4936
+ settings,
4937
+ secrets,
4938
+ options.snapshotScope,
4939
+ snapshot,
4940
+ index + 1,
4941
+ result.warnings,
4942
+ options.snapshotIncludeHidden,
4943
+ );
4944
+ }
4143
4945
  if (failure) break;
4144
- if (options.stopOnExpected && outcome && !runAllSteps && containsExpectedOutcome(snapshot, outcome)) break;
4946
+ if (options.stopOnExpected && outcome && !runAllSteps && containsExpectedOutcome(snapshot, outcome)) break;
4145
4947
  } catch (error) {
4146
4948
  environmentFailure = browserDisconnected(error);
4147
4949
  failure = error instanceof JevBrowserError
@@ -4160,7 +4962,7 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
4160
4962
  break;
4161
4963
  }
4162
4964
  }
4163
- if (failure || trace.length === 0) {
4965
+ if ((failure || trace.length === 0) && !networkOnlyFastPath) {
4164
4966
  snapshot = await snapshotAfterStep(
4165
4967
  activePage,
4166
4968
  settings,
@@ -4172,14 +4974,16 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
4172
4974
  options.snapshotIncludeHidden,
4173
4975
  );
4174
4976
  }
4175
- const finalDetails = await enrichSnapshot(
4176
- activePage,
4177
- snapshot,
4178
- settings,
4179
- secrets,
4180
- options.snapshotScope,
4181
- options.snapshotIncludeHidden,
4182
- );
4977
+ const finalDetails = networkOnlyFastPath
4978
+ ? { snapshot, unnamedControls: [] }
4979
+ : await enrichSnapshot(
4980
+ activePage,
4981
+ snapshot,
4982
+ settings,
4983
+ secrets,
4984
+ options.snapshotScope,
4985
+ options.snapshotIncludeHidden,
4986
+ );
4183
4987
  snapshot = finalDetails.snapshot;
4184
4988
  if (options.includeUnnamedControls) result.unnamed_controls_final = finalDetails.unnamedControls;
4185
4989
  timings.browserPlanMs = performance.now() - planStarted;
@@ -4236,8 +5040,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
4236
5040
  }
4237
5041
  }
4238
5042
  }
4239
- } catch (error) {
4240
- const authenticationFailure = networkTracker.authenticationFailureSince(initialNetworkMarker);
5043
+ } catch (error) {
5044
+ const authenticationFailure = networkTracker.authenticationFailureSince(initialNetworkMarker);
4241
5045
  const disconnected = (!options.freshContext && browserPool.getNavigationError?.() === "BROWSER_DISCONNECTED") || browserDisconnected(error);
4242
5046
  if (authenticationFailure) {
4243
5047
  navigationError = "AUTHENTICATION_REQUIRED";
@@ -4339,7 +5143,7 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
4339
5143
  await Promise.all(createdContexts.map(async (createdContext) => {
4340
5144
  try { await browserPool.releaseContext?.(createdContext); } catch { artifactErrors.push("isolated browser context cleanup failed"); }
4341
5145
  }));
4342
- networkTracker.dispose();
5146
+ await networkTracker.dispose();
4343
5147
  await capture.dispose();
4344
5148
  initialPage.off("dialog", onNativeDialog);
4345
5149
  }
@@ -4348,7 +5152,16 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
4348
5152
  try { result.current_url = evidenceUrl(activePage.url(), secrets, settings.jev.maxDiagnosticChars); } catch { result.current_url = "unavailable"; }
4349
5153
  result.console_errors = groupConsoleErrors([...(continuation?.consoleErrors || []), ...capture.diagnostics.console_errors]);
4350
5154
  result.console_messages = groupConsoleMessages([...(continuation?.consoleMessages || []), ...capture.diagnostics.console_messages]);
4351
- result.network_failures = [...(continuation?.networkFailures || []), ...capture.diagnostics.network_failures];
5155
+ result.network_failures = [...(continuation?.networkFailures || []), ...capture.diagnostics.network_failures];
5156
+ const blockedCookies = networkTracker.blockedCookiesSince(initialNetworkMarker, options.captureNetworkUrlContains)
5157
+ .map(({ url: requestUrl, ...event }) => ({ ...event, url: evidenceUrl(requestUrl, secrets, settings.jev.maxDiagnosticChars) }));
5158
+ if (blockedCookies.length) result.blocked_cookies = blockedCookies;
5159
+ if (options.captureNetwork === "all") {
5160
+ result.network_events = networkTracker.networkEventsSince(
5161
+ initialNetworkMarker,
5162
+ options.captureNetworkUrlContains,
5163
+ ).map(({ url: requestUrl, ...event }) => ({ ...event, url: evidenceUrl(requestUrl, secrets, settings.jev.maxDiagnosticChars) }));
5164
+ }
4352
5165
  result.warnings.push(...capture.diagnostics.warnings);
4353
5166
  result.timings_ms = flowTimings(started, browserSessionMs, timings.navigationMs, timings.initialSnapshotMs, timings.readyMs, timings.decisionMs, timings.browserPlanMs);
4354
5167
  const safeResult = await redactObject(result, secrets);
@@ -4359,7 +5172,7 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
4359
5172
  safeResult.final_snapshot = stripUnnamedControlsSection(safeResult.final_snapshot);
4360
5173
  initialSnapshot = stripUnnamedControlsSection(initialSnapshot);
4361
5174
  }
4362
- if (options.returnSnapshot === "diff" && typeof safeResult.final_snapshot === "string") {
5175
+ if (!networkOnlyFastPath && options.returnSnapshot === "diff" && typeof safeResult.final_snapshot === "string") {
4363
5176
  safeResult.final_snapshot = snapshotDiff(initialSnapshot, safeResult.final_snapshot);
4364
5177
  } else if (options.returnSnapshot === "none") {
4365
5178
  delete safeResult.final_snapshot;
@@ -4371,10 +5184,64 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
4371
5184
  safeResult.artifact_errors = [...(safeResult.artifact_errors || []), error instanceof JevBrowserError ? error.message : "flow report could not be saved"];
4372
5185
  }
4373
5186
  }
4374
- return safeResult;
4375
- }
4376
-
4377
- export async function chooseNextAction({ flow, pageSnapshot, actions, completedSteps = [], localOnly = false, settings, fetchImpl = fetch }) {
5187
+ return safeResult;
5188
+ }
5189
+
5190
+ function candidatePlansIncludeUpload(candidatePlans) {
5191
+ const plans = isRecord(candidatePlans) && Array.isArray(candidatePlans.steps)
5192
+ ? [candidatePlans]
5193
+ : isRecord(candidatePlans) ? Object.values(candidatePlans) : [];
5194
+ return plans.some((plan) => isRecord(plan)
5195
+ && Array.isArray(plan.steps)
5196
+ && plan.steps.some((step) => isRecord(step) && (
5197
+ step.action === "upload_file" || step.action === "generate_fixture"
5198
+ || step.action === "http_request" && Array.isArray(step.multipart) && step.multipart.some((field) => isRecord(field) && field.file_path)
5199
+ )));
5200
+ }
5201
+
5202
+ export async function executeBrowserFlow(args) {
5203
+ const run = async () => {
5204
+ const ownsIntegrationSession = !args.integrationSession;
5205
+ let integrationSession = args.integrationSession;
5206
+ let result;
5207
+ let flowError;
5208
+ try {
5209
+ if (!integrationSession) integrationSession = createIntegrationSession(args.settings, args.options?.environment_profile);
5210
+ if (ownsIntegrationSession) await integrationSession.markFlowStart();
5211
+ result = await executeBrowserFlowCore({ ...args, integrationSession });
5212
+ } catch (error) {
5213
+ flowError = error;
5214
+ } finally {
5215
+ if (ownsIntegrationSession) await integrationSession?.close?.();
5216
+ }
5217
+ const retainFilesForConfirmation = result?.status === "confirmation_required" || result?.status === "dry_run";
5218
+ let cleanupError;
5219
+ if (candidatePlansIncludeUpload(args.candidatePlans) && !args.retainUploadStaging && !retainFilesForConfirmation && args.settings.browser.managedUploadStaging) {
5220
+ try {
5221
+ await cleanupUploadStagingDirectory(args.settings);
5222
+ } catch {
5223
+ cleanupError = true;
5224
+ }
5225
+ }
5226
+ if (flowError) {
5227
+ if (cleanupError) throw new JevBrowserError("the browser flow failed and managed upload staging cleanup also failed");
5228
+ throw flowError;
5229
+ }
5230
+ if (cleanupError) {
5231
+ result.status = "failed";
5232
+ result.acceptance_criteria_met = false;
5233
+ result.reason = "The browser flow finished, but managed upload staging cleanup failed.";
5234
+ result.artifact_errors = [...(result.artifact_errors || []), "managed upload staging cleanup failed"];
5235
+ }
5236
+ return result;
5237
+ };
5238
+ if (args.settings.browser.managedUploadStaging && candidatePlansIncludeUpload(args.candidatePlans)) {
5239
+ return withUploadStagingLock(args.settings, run);
5240
+ }
5241
+ return run();
5242
+ }
5243
+
5244
+ export async function chooseNextAction({ flow, pageSnapshot, actions, completedSteps = [], localOnly = false, settings, fetchImpl = fetch }) {
4378
5245
  if (localOnly) throw new JevBrowserError("local_only is enabled; choose_next_action cannot call the Jev provider");
4379
5246
  const { request, criteria } = buildChoicePayload({ flow, pageSnapshot, actions, completedSteps }, settings.jev);
4380
5247
  return parseDecision(await requestJev(request, settings, fetchImpl), criteria);