@diegosouzacdv/jev-browser-mcp 0.7.1 → 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,6 +64,7 @@ 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",
@@ -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,14 +234,23 @@ 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
240
  navigate_menu: { required: [], optional: ["path", "screen", "toggle_selector", "timeout_seconds"] },
230
241
  assert_network: { required: ["url_contains", "method", "status"], optional: ["message_contains", "timeout_seconds", "timeout_ms"] },
231
242
  assert_ws: { required: ["url_contains"], optional: ["message_contains", "json", "close_code", "timeout_seconds", "timeout_ms"] },
232
243
  wait_for_request: { required: ["url"], optional: ["method", "status", "timeout_seconds", "timeout_ms"] },
233
- http_request: { required: ["url"], optional: ["method", "include_body", "timeout_seconds", "timeout_ms"] },
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"] },
234
254
  inspect_cookies: { required: [], optional: ["timeout_seconds", "timeout_ms"] },
235
255
  read_angular_state: { required: [], optional: ["timeout_seconds", "timeout_ms"] },
236
256
  extract: { required: ["as"], optional: [...semanticTarget, "source", "attribute", "sensitive", "timeout_seconds", "timeout_ms"] },
@@ -248,8 +268,9 @@ function actionSchemas(semanticTarget) {
248
268
  press: { required: ["key"], optional: [...semanticTarget, "timeout_seconds"] },
249
269
  select_option: { required: [], optional: [...semanticTarget, "option", "value", "label_contains", "timeout_seconds"] },
250
270
  hover: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
251
- assert_text: { required: ["expected"], optional: [...semanticTarget, "timeout_seconds"] },
252
- 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"] },
253
274
  assert_enabled: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
254
275
  assert_visible: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
255
276
  assert_hidden: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
@@ -260,7 +281,7 @@ function actionSchemas(semanticTarget) {
260
281
  };
261
282
  }
262
283
 
263
- export function describeBrowserActions() {
284
+ export function describeBrowserActions(settings) {
264
285
  const semanticTarget = ["frame", "comment", "within", "index", "near", "label", "placeholder", "title", "text", "test_id", "selector", "target_text", "role", "name", "name_match", "target_page"];
265
286
  const schemas = actionSchemas(semanticTarget);
266
287
  return {
@@ -272,16 +293,71 @@ export function describeBrowserActions() {
272
293
  optional: [...new Set([...schema.optional, "timeout_ms", "mutating", "screenshot"])],
273
294
  };
274
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" };
275
297
  if (action === "type") contract.conditional_required = { text: "exactly one of text or text_env is required" };
276
298
  if (action === "navigate_menu") contract.conditional_required = { path: "exactly one of path or screen is required" };
277
299
  if (action === "select_option") contract.conditional_required = { option: "exactly one of option, value or label_contains is required" };
278
300
  return [action, contract];
279
301
  })),
280
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
+ } } : {}),
281
356
  option_shapes: {
282
357
  capture_network: ["errors", "all", "none"],
283
358
  capture_network_url_contains: "optional URL substring filter for capture_network=all",
284
- local_only: "required for http_request, read_angular_state and inspect_cookies; only localhost loopback is allowed",
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",
285
361
  ready: ["network_idle", { hidden_text: "text that must disappear" }, { selector: "CSS selector", has_value: true }],
286
362
  return_snapshot: ["diff", "full", "none"],
287
363
  include_unnamed_controls: "boolean; false by default",
@@ -301,7 +377,7 @@ export function describeBrowserActions() {
301
377
  duration_ms: "wait.ms",
302
378
  value: "type.text or wait_for_text.text",
303
379
  timeout_ms: "per-step timeout in milliseconds",
304
- 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" },
305
381
  },
306
382
  placeholders: "Use {name}; declare params.name or extract it before use. Unknown and {{double-brace}} placeholders are rejected.",
307
383
  };
@@ -601,7 +677,7 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
601
677
  if (hasTextEnv) step.textEnv = rawStep.text_env;
602
678
  step.mode = rawStep.mode || "fill";
603
679
  step.blur = rawStep.blur ?? false;
604
- step.sensitive = rawStep.sensitive ?? true;
680
+ step.sensitive = rawStep.sensitive ?? true;
605
681
  if (hasTextEnv && rawStep.sensitive === false) throw new JevBrowserError("type text_env is always sensitive");
606
682
  }
607
683
  if (action === "clear" && step.role && !TEXT_ROLES.has(step.role)) {
@@ -675,13 +751,128 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
675
751
  if (action === "http_request") {
676
752
  step.url = requiredText(rawStep.url, "http_request URL", settings.jev.maxActionDescriptionChars);
677
753
  step.method = rawStep.method === undefined ? "GET" : requiredText(rawStep.method, "http_request method", 16).toUpperCase();
678
- if (step.method !== "GET") throw new JevBrowserError("http_request supports GET only");
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
+ });
679
777
  if (rawStep.include_body !== undefined && typeof rawStep.include_body !== "boolean") {
680
778
  throw new JevBrowserError("http_request include_body must be a boolean");
681
779
  }
682
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 = {};
683
795
  }
684
- if (action === "evaluate") {
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") {
685
876
  step.expression = requiredText(rawStep.expression, "read-only expression", 256);
686
877
  if (!/^(?:window|document|navigator|location)(?:\.[A-Za-z_$][A-Za-z0-9_$]*){1,8}$/.test(step.expression)) {
687
878
  throw new JevBrowserError("evaluate accepts read-only property paths such as navigator.mediaDevices or document.readyState; calls and assignments are not supported");
@@ -727,10 +918,12 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
727
918
  if (["url", "title"].includes(step.source) && hasLocatorField(rawStep)) {
728
919
  throw new JevBrowserError(`extract source ${step.source} does not use a locator`);
729
920
  }
730
- if (rawStep.sensitive !== undefined && typeof rawStep.sensitive !== "boolean") {
731
- throw new JevBrowserError("extract sensitive must be a boolean");
732
- }
733
- 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);
734
927
  }
735
928
  if (action === "wait_for_condition") {
736
929
  if (!new Set(["network_idle", "url", "hidden", "text_hidden", "angular_idle"]).has(rawStep.condition)) {
@@ -794,9 +987,20 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
794
987
  }
795
988
  step.key = rawStep.key;
796
989
  }
797
- if (action === "assert_text" || action === "assert_value") {
798
- step.expected = requiredText(rawStep.expected, "expected value", settings.browser.maxTextEntryChars);
799
- }
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
+ }
800
1004
  if (action === "upload_file") {
801
1005
  const target = rawStep.target || (hasLocatorField(step) ? "input"
802
1006
  : step.role && DROPZONE_ROLES.has(step.role) && step.role !== "button" ? "dropzone"
@@ -880,6 +1084,11 @@ export function normalizeStepAliases(rawStep) {
880
1084
  step[canonical] = step[alias];
881
1085
  delete step[alias];
882
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
+ }
883
1092
  if (step.name !== undefined && step.role === undefined) step.role = "button";
884
1093
  }
885
1094
  return step;
@@ -933,7 +1142,16 @@ function resolveFlowOptions(rawOptions, settings) {
933
1142
  }
934
1143
  const scope = rawOptions.snapshot_scope ?? settings.browser.defaults.snapshotScope;
935
1144
  if (!new Set(["body", "main", "dialog"]).has(scope)) throw new JevBrowserError("options.snapshot_scope must be body, main, or dialog");
936
- 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
+ }
937
1155
  const stepTimeoutSeconds = rawOptions.step_timeout_seconds ?? settings.browser.actionTimeoutMs / 1000;
938
1156
  if (typeof stepTimeoutSeconds !== "number" || !Number.isFinite(stepTimeoutSeconds) || stepTimeoutSeconds <= 0
939
1157
  || stepTimeoutSeconds > settings.browser.maxStepTimeoutSeconds) {
@@ -1092,7 +1310,8 @@ function resolveFlowOptions(rawOptions, settings) {
1092
1310
  captureNetworkUrlContains,
1093
1311
  dryRun: rawOptions.dry_run ?? false,
1094
1312
  fastPath: rawOptions.fast_path ?? defaults.fastPath,
1095
- localOnly: rawOptions.local_only ?? defaults.localOnly,
1313
+ localOnly: rawOptions.local_only ?? defaults.localOnly,
1314
+ environmentProfile,
1096
1315
  loginText: Object.freeze(loginText),
1097
1316
  loginUrlContains: Object.freeze(loginUrlContains),
1098
1317
  permissions: Object.freeze([...permissions]),
@@ -1137,11 +1356,29 @@ function escapeRegex(value) {
1137
1356
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1138
1357
  }
1139
1358
 
1140
- function planSecrets(plans) {
1141
- const secrets = new Set();
1142
- for (const plan of Object.values(plans)) {
1143
- for (const step of plan.steps) {
1144
- 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);
1145
1382
  if (step.action === "upload_file") {
1146
1383
  for (const filePath of step.filePaths) {
1147
1384
  secrets.add(filePath);
@@ -1287,24 +1524,24 @@ function redactLocalPayload(value) {
1287
1524
  ]));
1288
1525
  }
1289
1526
 
1290
- async function resolveFakeMediaPath(fakeMedia, settings) {
1291
- if (!fakeMedia) return undefined;
1292
- if (!settings.browser.uploadRoot) {
1293
- throw new JevBrowserError("fake_media is disabled; configure JEV_BROWSER_UPLOAD_ROOT and keep the WAV file inside it");
1294
- }
1295
- let root;
1296
- let audio;
1297
- try {
1298
- root = await realpath(settings.browser.uploadRoot);
1299
- const requested = path.isAbsolute(fakeMedia) ? fakeMedia : path.resolve(root, fakeMedia);
1300
- audio = await realpath(requested);
1301
- } catch {
1302
- throw new JevBrowserError("fake_media could not read the configured WAV file");
1303
- }
1304
- const relative = path.relative(root, audio);
1305
- if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
1306
- throw new JevBrowserError("fake_media path must stay inside JEV_BROWSER_UPLOAD_ROOT");
1307
- }
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");
1308
1545
  if (path.extname(audio).toLowerCase() !== ".wav") throw new JevBrowserError("fake_media accepts WAV files only");
1309
1546
  const file = await stat(audio);
1310
1547
  if (!file.isFile() || file.size > settings.browser.maxUploadFileBytes) {
@@ -1807,6 +2044,9 @@ function createNetworkTracker(page, settings, loginUrlContains = [], watchPatter
1807
2044
  const active = new Map();
1808
2045
  const history = [];
1809
2046
  const watchedHistory = [];
2047
+ const watchedResponses = new Map();
2048
+ const responseWaiters = new Set();
2049
+ const responseObservers = new Set();
1810
2050
  const websocketHistory = [];
1811
2051
  const websocketUrls = new Map();
1812
2052
  const websocketObserverPromises = new Set();
@@ -1855,12 +2095,29 @@ function createNetworkTracker(page, settings, loginUrlContains = [], watchPatter
1855
2095
  if (!entry) return;
1856
2096
  entry.response = response;
1857
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
+ }
1858
2107
  const requestEntry = active.get(response.request());
1859
2108
  if (requestEntry && entry.durationMs !== undefined) requestEntry.durationMs = entry.durationMs;
1860
2109
  const headerRead = Promise.resolve().then(() => response.allHeaders())
1861
2110
  .then((headers) => { entry.responseHeaders = safeResponseHeaders(headers); }).catch(() => {});
1862
2111
  pendingHeaderReads.add(headerRead);
1863
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);
1864
2121
  };
1865
2122
  const onFinished = (request) => record("finished", request);
1866
2123
  const onFailed = (request) => record("failed", request);
@@ -1958,7 +2215,7 @@ function createNetworkTracker(page, settings, loginUrlContains = [], watchPatter
1958
2215
  websocketObserverPromises.add(ready);
1959
2216
  return ready;
1960
2217
  };
1961
- const historySince = (marker) => [...new Map([...history, ...watchedHistory]
2218
+ const historySince = (marker) => [...new Map([...history, ...watchedHistory, ...watchedResponses.values()]
1962
2219
  .filter((entry) => entry.sequence > marker)
1963
2220
  .map((entry) => [entry.sequence, entry])).values()].sort((left, right) => left.sequence - right.sequence);
1964
2221
  attachPage(page);
@@ -1966,6 +2223,61 @@ function createNetworkTracker(page, settings, loginUrlContains = [], watchPatter
1966
2223
  mark: () => sequence,
1967
2224
  attachPage,
1968
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
+ },
1969
2281
  since: (marker, urlContains) => history.filter((entry) => entry.sequence > marker
1970
2282
  && (urlContains === undefined || entry.url.includes(urlContains))),
1971
2283
  responsesSince: (marker, urlContains, method) => historySince(marker).filter((entry) =>
@@ -2031,6 +2343,8 @@ function createNetworkTracker(page, settings, loginUrlContains = [], watchPatter
2031
2343
  trackedPages.clear();
2032
2344
  active.clear();
2033
2345
  ignored.clear();
2346
+ for (const observer of [...responseObservers]) observer.dispose();
2347
+ responseObservers.clear();
2034
2348
  await Promise.allSettled([...pendingHeaderReads]);
2035
2349
  pendingHeaderReads.clear();
2036
2350
  cdpRequestUrls.clear();
@@ -2178,17 +2492,15 @@ function urlPatternMatcher(pattern) {
2178
2492
  return new RegExp(`^${pattern.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`, "i");
2179
2493
  }
2180
2494
 
2181
- async function waitForRequestAssertion(tracker, step, marker, timeout, settings) {
2182
- const matcher = urlPatternMatcher(step.urlPattern);
2183
- const started = performance.now();
2184
- while (performance.now() - started < timeout) {
2185
- const entry = tracker.historySince(marker).find((event) => event.type === "response"
2186
- && matcher.test(event.url)
2187
- && (!step.method || event.method === step.method)
2188
- && (step.status === undefined || event.status === step.status));
2189
- if (entry) return entry;
2190
- await new Promise((resolve) => setTimeout(resolve, Math.min(settings.browser.visibilityPollMs, remainingMs(started + timeout))));
2191
- }
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;
2192
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`);
2193
2505
  }
2194
2506
 
@@ -2714,12 +3026,13 @@ function xpathRolePredicate(role) {
2714
3026
  return native ? `@role='${role}' or ${native}` : `@role='${role}'`;
2715
3027
  }
2716
3028
 
2717
- function relativeTarget(anchor, role) {
2718
- const selector = TEXT_ROLES.has(role)
2719
- ? "xpath=following::*[self::input or self::textarea or self::select or @role='textbox' or @role='searchbox' or @role='combobox'][1]"
2720
- : role === "link" ? "xpath=following::*[self::a or @role='link'][1]"
2721
- : role === "button" ? "xpath=following::*[self::button or @role='button'][1]"
2722
- : "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}`;
2723
3036
  return anchor.locator(selector);
2724
3037
  }
2725
3038
 
@@ -2754,8 +3067,10 @@ async function targetLocator(root, step, timeout, settings) {
2754
3067
  locator = step.action === "type" && step.role ? relativeTarget(anchor, step.role) : anchor;
2755
3068
  } else if (field === "test_id") {
2756
3069
  locator = targetRoot.getByTestId(step.testId);
2757
- } else if (step.near) {
2758
- 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)));
2759
3074
  } else if (step.role) {
2760
3075
  locator = targetRoot.getByRole(step.role, roleOptions(step));
2761
3076
  if (step.name && (!step.nameMatch || step.nameMatch === "exact") && await locator.count() === 0) {
@@ -2936,17 +3251,18 @@ function fileMimeType(fileName) {
2936
3251
  return MIME_TYPES.get(path.extname(fileName).toLowerCase()) || "application/octet-stream";
2937
3252
  }
2938
3253
 
2939
- async function uploadFiles(step, settings) {
2940
- const uploadRoot = settings.browser.uploadRoot;
2941
- if (!uploadRoot) throw new JevBrowserError("file uploads are disabled; configure JEV_BROWSER_UPLOAD_ROOT first");
2942
- let root;
2943
- try {
2944
- root = await realpath(uploadRoot);
2945
- } catch {
2946
- throw new JevBrowserError("configured JEV_BROWSER_UPLOAD_ROOT does not exist or is not accessible");
2947
- }
2948
- const rootInfo = await stat(root);
2949
- 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");
2950
3266
  let totalBytes = 0;
2951
3267
  const files = [];
2952
3268
  for (const requestedPath of step.filePaths) {
@@ -2957,9 +3273,12 @@ async function uploadFiles(step, settings) {
2957
3273
  } catch {
2958
3274
  throw new JevBrowserError("upload_file could not read one of the configured files");
2959
3275
  }
2960
- const relativePath = path.relative(root, resolved);
2961
- if (!relativePath || relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) {
2962
- 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");
2963
3282
  }
2964
3283
  const fileInfo = await stat(resolved);
2965
3284
  if (!fileInfo.isFile()) throw new JevBrowserError("upload_file accepts regular files only");
@@ -3009,7 +3328,7 @@ async function removeBrowserDownload(download) {
3009
3328
  }
3010
3329
  }
3011
3330
 
3012
- async function saveDownload(download, settings, ledger) {
3331
+ async function saveDownload(download, settings, ledger, networkTracker) {
3013
3332
  if (ledger.count >= settings.browser.maxDownloadFiles) {
3014
3333
  await removeBrowserDownload(download);
3015
3334
  throw new JevBrowserError("download exceeds the configured file-count limit");
@@ -3070,7 +3389,14 @@ async function saveDownload(download, settings, ledger) {
3070
3389
  ledger.count += 1;
3071
3390
  ledger.bytes += savedInfo.size;
3072
3391
  await removeBrowserDownload(download);
3073
- 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;
3074
3400
  } catch (error) {
3075
3401
  await rm(destination, { force: true }).catch(() => {});
3076
3402
  await removeBrowserDownload(download);
@@ -3139,6 +3465,16 @@ async function navigateMenu(page, root, step, settings, execution) {
3139
3465
  async function executeStep(page, step, settings, snapshot, secrets, downloadLedger, execution = {}) {
3140
3466
  const root = await stepRoot(page, step.frame);
3141
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
+ }
3142
3478
  if (step.action === "click" && step.ifVisible && !await conditionIsVisible(root, step.ifVisible)) {
3143
3479
  return { action: step.action, skipped: true, skip_reason: "if_visible condition was not visible" };
3144
3480
  }
@@ -3148,69 +3484,13 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
3148
3484
  throw new JevBrowserError("navigate URL must be absolute or relative to the current page");
3149
3485
  }
3150
3486
  destination = validateUrl(destination);
3151
- if (execution.allowMutations && !localHostOnly(destination)) {
3152
- 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");
3153
3489
  }
3154
3490
  await page.goto(destination, { waitUntil: "domcontentloaded", timeout });
3155
3491
  execution.browserPool?.setNavigationError?.(null);
3156
3492
  return { action: step.action, url: evidenceUrl(destination, secrets, settings.jev.maxDiagnosticChars) };
3157
3493
  }
3158
- if (step.action === "http_request") {
3159
- assertLocalDebugPage(page, execution.localOnly, "http_request");
3160
- const pageUrl = new URL(page.url());
3161
- const requestUrl = new URL(step.url, pageUrl);
3162
- if (requestUrl.origin !== pageUrl.origin || !localHostOnly(requestUrl.toString())) {
3163
- throw new JevBrowserError("http_request must target the current localhost origin");
3164
- }
3165
- const reply = await page.evaluate(async ({ url, includeBody, maxBytes }) => {
3166
- const response = await fetch(url, {
3167
- method: "GET", credentials: "same-origin", redirect: "error", cache: "no-store",
3168
- });
3169
- const headers = {};
3170
- for (const name of ["cache-control", "content-length", "content-type", "date", "etag", "last-modified", "retry-after", "vary"]) {
3171
- const value = response.headers.get(name);
3172
- if (value !== null) headers[name] = value.slice(0, 256);
3173
- }
3174
- let body = "";
3175
- let truncated = false;
3176
- if (includeBody && response.body) {
3177
- const reader = response.body.getReader();
3178
- const chunks = [];
3179
- let size = 0;
3180
- while (size <= maxBytes) {
3181
- const { done, value } = await reader.read();
3182
- if (done) break;
3183
- const chunk = value.slice(0, Math.max(0, maxBytes + 1 - size));
3184
- chunks.push(chunk);
3185
- size += chunk.length;
3186
- if (size > maxBytes || chunk.length < value.length) {
3187
- truncated = true;
3188
- await reader.cancel();
3189
- break;
3190
- }
3191
- }
3192
- const bytes = new Uint8Array(size);
3193
- let offset = 0;
3194
- for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }
3195
- body = new TextDecoder().decode(bytes.slice(0, maxBytes));
3196
- }
3197
- return { status: response.status, ok: response.ok, headers, body, body_truncated: truncated };
3198
- }, { url: requestUrl.toString(), includeBody: step.includeBody, maxBytes: settings.browser.maxNetworkErrorBodyBytes });
3199
- let responseBody;
3200
- if (step.includeBody && reply.body) {
3201
- try { responseBody = redactLocalPayload(JSON.parse(reply.body)); }
3202
- catch { responseBody = sanitizeDiagnosticText(reply.body, secrets, settings.browser.maxNetworkErrorMessageChars); }
3203
- }
3204
- return {
3205
- action: step.action,
3206
- method: "GET",
3207
- url: evidenceUrl(requestUrl.toString(), secrets, settings.jev.maxDiagnosticChars),
3208
- status: reply.status,
3209
- ok: reply.ok,
3210
- response_headers: reply.headers,
3211
- ...(step.includeBody ? { response_body: responseBody ?? "", body_truncated: reply.body_truncated } : {}),
3212
- };
3213
- }
3214
3494
  if (step.action === "inspect_cookies") {
3215
3495
  assertLocalDebugPage(page, execution.localOnly, "inspect_cookies");
3216
3496
  const cookies = await execution.context.cookies([page.url()]);
@@ -3285,7 +3565,9 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
3285
3565
  await execution.networkTracker.attachPage(next);
3286
3566
  if (step.url) {
3287
3567
  const destination = validateUrl(new URL(step.url, page.url()).toString());
3288
- 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
+ }
3289
3571
  await next.goto(destination, { waitUntil: "domcontentloaded", timeout });
3290
3572
  }
3291
3573
  execution.capture.attachPage(next);
@@ -3311,9 +3593,15 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
3311
3593
  return { action: step.action, tab_index: step.index, activePage: tabs[step.index], activeContext: selectedContext };
3312
3594
  }
3313
3595
  if (step.action === "wait_for_request") {
3314
- const request = await waitForRequestAssertion(execution.networkTracker, step, execution.flowNetworkMarker ?? execution.networkMarker, timeout, settings);
3596
+ const request = await waitForRequestAssertion(
3597
+ execution.networkTracker,
3598
+ step,
3599
+ execution.flowNetworkMarker ?? execution.networkMarker,
3600
+ timeout,
3601
+ execution.requestObserver,
3602
+ );
3315
3603
  return {
3316
- action: step.action, asserted: true, method: request.method, status: request.status,
3604
+ action: step.action, asserted: true, method: request.method, response_status: request.status,
3317
3605
  ...(request.durationMs === undefined ? {} : { duration_ms: Math.round(request.durationMs * 10) / 10 }),
3318
3606
  ...(request.responseHeaders ? { response_headers: request.responseHeaders } : {}),
3319
3607
  url: evidenceUrl(request.url, secrets, settings.jev.maxDiagnosticChars),
@@ -3404,7 +3692,17 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
3404
3692
  if (step.source === "url") value = page.url();
3405
3693
  else if (step.source === "title") value = await page.title();
3406
3694
  else if (step.source === "value") value = await control.inputValue({ timeout });
3407
- 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
+ }
3408
3706
  else value = (await control.innerText({ timeout })).replace(/\s+/g, " ").trim();
3409
3707
  if (value.length > settings.browser.maxTextEntryChars) {
3410
3708
  throw new JevBrowserError("extracted value exceeds the configured character limit");
@@ -3663,14 +3961,30 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
3663
3961
  ...targetEvidence(step, secrets, await describeResolvedTarget(control, step, secrets, settings)),
3664
3962
  };
3665
3963
  }
3666
- if (step.action === "assert_value") {
3667
- await waitForValue(control, step.expected, timeout, `assert_value for ${describeTarget(step, secrets)}`, settings);
3668
- return {
3669
- action: step.action,
3670
- asserted: true,
3671
- ...targetEvidence(step, secrets, await describeResolvedTarget(control, step, secrets, settings)),
3672
- };
3673
- }
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
+ }
3674
3988
  if (step.action === "assert_enabled") {
3675
3989
  if (!(await control.isEnabled())) throw new JevBrowserError(`assert_enabled failed for ${describeTarget(step, secrets)}`);
3676
3990
  return { action: step.action, asserted: true, enabled: true, ...targetEvidence(step, secrets, await describeResolvedTarget(control, step, secrets, settings)) };
@@ -4025,7 +4339,7 @@ async function writeFlowReport(result, reportPath, settings) {
4025
4339
  return outputPath;
4026
4340
  }
4027
4341
 
4028
- async function collectPerformanceMetrics(page) {
4342
+ async function collectPerformanceMetrics(page) {
4029
4343
  try {
4030
4344
  return await page.evaluate(() => {
4031
4345
  const navigation = performance.getEntriesByType("navigation")[0];
@@ -4043,9 +4357,52 @@ async function collectPerformanceMetrics(page) {
4043
4357
  } catch {
4044
4358
  return undefined;
4045
4359
  }
4046
- }
4047
-
4048
- 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) {
4049
4406
  if (typeof value === "string") return redact(value, secrets);
4050
4407
  if (Array.isArray(value)) return Promise.all(value.map((item) => redactObject(item, secrets)));
4051
4408
  if (isRecord(value)) {
@@ -4075,46 +4432,53 @@ function snapshotDiff(initialSnapshot, finalSnapshot) {
4075
4432
  return added.length ? added.join("\n") : "[no new snapshot lines]";
4076
4433
  }
4077
4434
 
4078
- function stripUnnamedControlsSection(snapshot) {
4079
- return snapshot.replace(/\n\n\[interactive controls without accessible names;[^\]]+\][\s\S]*$/i, "").trim();
4080
- }
4081
-
4082
- 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 }) {
4083
4436
  if (typeof flow !== "string" || (expectedOutcome !== undefined && typeof expectedOutcome !== "string" && !isRecord(expectedOutcome))) {
4084
4437
  throw new JevBrowserError("flow must be text and expected_outcome must be text or a structured assertion object");
4085
4438
  }
4086
- let variables = validateParams(rawParams, settings);
4439
+ const variables = validateParams(rawParams, settings);
4087
4440
  const requestedFlow = replaceKnownParams(flow, variables).trim();
4088
4441
  const expected = normalizeExpectedOutcome(expectedOutcome, variables, settings);
4089
- const outcome = expected.description;
4090
- const outcomeText = expected.text;
4091
4442
  if (!requestedFlow) throw new JevBrowserError("flow must not be empty");
4092
4443
  if (requestedFlow.length > settings.jev.maxFlowChars) throw new JevBrowserError("flow exceeds the configured character limit");
4093
- if (outcome.length > settings.jev.maxActionDescriptionChars) throw new JevBrowserError("expected_outcome exceeds the configured character limit");
4444
+ if (expected.description.length > settings.jev.maxActionDescriptionChars) throw new JevBrowserError("expected_outcome exceeds the configured character limit");
4094
4445
  const url = initialUrl === undefined ? undefined : validateUrl(initialUrl);
4095
4446
  const options = resolveFlowOptions(rawOptions, settings);
4096
4447
  const plans = validateCandidatePlans(replaceKnownParams(candidatePlans, variables, true), settings, options.maxFlowSteps);
4097
- const networkOnlyFastPath = options.fastPath && Object.keys(plans).length === 1
4098
- && !Object.values(plans)[0].when
4099
- && Object.values(plans)[0].steps.every(({ action }) => ["assert_network", "wait_for_request"].includes(action));
4448
+ const selectedProfile = validateIntegratedActions(plans, options, settings);
4100
4449
  assertPlanPlaceholders(plans, variables);
4101
4450
  if (!url && !options.reusePage) throw new JevBrowserError("initial_url is required when continue_from_current_page is false");
4102
4451
  if (options.waitForHttp && !localHostOnly(options.waitForHttp.url)
4103
4452
  && (!url || new URL(options.waitForHttp.url).hostname.toLowerCase() !== new URL(url).hostname.toLowerCase())) {
4104
4453
  throw new JevBrowserError("wait_for_http must target localhost or the same host as initial_url");
4105
4454
  }
4106
- if (!url && options.allowMutations) throw new JevBrowserError("allow_mutations needs an explicit localhost initial_url");
4107
- resolveEnvironmentText(plans, settings);
4108
- if (options.localOnly && Object.keys(plans).length > 0 && !(options.fastPath && Object.keys(plans).length === 1)) {
4109
- throw new JevBrowserError("local_only does not call Jev; provide exactly one candidate plan and enable fast_path");
4110
- }
4111
- if (options.allowMutations && url && !localHostOnly(url)) {
4112
- throw new JevBrowserError("allow_mutations is limited to localhost, 127.0.0.1, and ::1 URLs");
4113
- }
4114
- const secretBearingFlow = Object.values(plans).some((plan) => plan.steps.some((step) => step.action === "type" && step.sensitive));
4115
- if (options.recordVideo && secretBearingFlow) {
4116
- throw new JevBrowserError("record_video is disabled for flows that type sensitive text");
4117
- }
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));
4118
4482
  const fingerprint = flowFingerprint({ flow, initialUrl: url, expectedOutcome, candidatePlans, params: rawParams, options });
4119
4483
  const continuation = options.confirmationToken
4120
4484
  ? mutationAuthorizationStore.consume(options.confirmationToken, fingerprint)
@@ -4159,7 +4523,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
4159
4523
  const networkTracker = createNetworkTracker(page, settings, options.loginUrlContains, networkWatchPatterns);
4160
4524
  const capture = createDiagnostics(page, options, settings, secrets);
4161
4525
  const result = {
4162
- ...(serverVersion ? { server_version: serverVersion } : {}),
4526
+ ...(serverVersion ? { server_version: serverVersion } : {}),
4527
+ ...(selectedProfile ? { environment_profile: { id: selectedProfile.id, environment: selectedProfile.environment } } : {}),
4163
4528
  status: "failed",
4164
4529
  expected_outcome_visible: false,
4165
4530
  acceptance_criteria_met: false,
@@ -4172,10 +4537,30 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
4172
4537
  final_snapshot: "",
4173
4538
  };
4174
4539
  result.snapshot_mode = options.returnSnapshot;
4175
- if (options.allowMutations) result.mutation_authorization = "allowed_on_localhost";
4176
- const downloadLedger = { count: 0, bytes: 0 };
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: [] };
4177
4544
  await networkTracker.waitForWebSocketObservers();
4178
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();
4179
4564
  let initialNavigationMarker;
4180
4565
  const timings = { navigationMs: 0, initialSnapshotMs: 0, readyMs: 0, decisionMs: 0, browserPlanMs: 0 };
4181
4566
  const routeCleanups = [];
@@ -4248,6 +4633,9 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
4248
4633
  }
4249
4634
  }
4250
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
+ }
4251
4639
  if (!navigationError && currentUrl && currentUrl !== "about:blank") {
4252
4640
  let currentRoute;
4253
4641
  try {
@@ -4409,8 +4797,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
4409
4797
  result.accessibility_audits = continuation?.accessibilityAudits || [];
4410
4798
  if (continuation?.downloadLedger) Object.assign(downloadLedger, continuation.downloadLedger);
4411
4799
  const planStarted = performance.now();
4412
- const runAllSteps = plan.steps.some(({ action, expectDownload }) => action.startsWith("assert_")
4413
- || ["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)
4414
4802
  || expectDownload === true);
4415
4803
  let approvedMutationIndex = continuation?.nextStep;
4416
4804
  for (let index = continuation?.nextStep || 0; index < plan.steps.length; index += 1) {
@@ -4462,7 +4850,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
4462
4850
  if (index === approvedMutationIndex) approvedMutationIndex = undefined;
4463
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);
4464
4852
  if (step.fragileSelector) addDiagnostic(result.warnings, `step ${index + 1} uses an explicit CSS/XPath selector as a last resort`, settings.jev.maxDiagnosticItems);
4465
- 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") {
4466
4855
  nativeDialog.expectedStep = interpolateStep(plan.steps[index + 1], variables);
4467
4856
  nativeDialog.result = undefined;
4468
4857
  } else if (step.action !== "confirm_dialog") {
@@ -4478,12 +4867,17 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
4478
4867
  autoAngularIdle: options.autoAngularIdle,
4479
4868
  readyStableMs: options.readyStableMs,
4480
4869
  localOnly: options.localOnly,
4481
- allowMutations: options.allowMutations,
4870
+ allowMutations: options.allowMutations || index === approvedMutationIndex,
4871
+ environmentProfile: selectedProfile,
4872
+ integrationSession,
4873
+ downloadLedger,
4482
4874
  browserPool,
4483
4875
  nativeDialog,
4484
- preResolvedTarget: mutationInspection.target,
4485
- popupPage,
4486
- variables,
4876
+ preResolvedTarget: mutationInspection.target,
4877
+ requestObserver: requestObservers.get(`${planId}:${index}`),
4878
+ popupPage,
4879
+ variables,
4880
+ secrets,
4487
4881
  context,
4488
4882
  contextStack,
4489
4883
  capture,
@@ -4501,6 +4895,7 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
4501
4895
  },
4502
4896
  };
4503
4897
  const traceStep = await executeStep(stepPage, step, settings, snapshot, secrets, downloadLedger, execution);
4898
+ armAvailableRequestObservers();
4504
4899
  popupPage = execution.popupPage;
4505
4900
  if (traceStep.activeContext) context = traceStep.activeContext;
4506
4901
  const nextActivePage = traceStep.activePage;
@@ -4548,7 +4943,7 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
4548
4943
  );
4549
4944
  }
4550
4945
  if (failure) break;
4551
- if (options.stopOnExpected && outcome && !runAllSteps && containsExpectedOutcome(snapshot, outcome)) break;
4946
+ if (options.stopOnExpected && outcome && !runAllSteps && containsExpectedOutcome(snapshot, outcome)) break;
4552
4947
  } catch (error) {
4553
4948
  environmentFailure = browserDisconnected(error);
4554
4949
  failure = error instanceof JevBrowserError
@@ -4645,8 +5040,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
4645
5040
  }
4646
5041
  }
4647
5042
  }
4648
- } catch (error) {
4649
- const authenticationFailure = networkTracker.authenticationFailureSince(initialNetworkMarker);
5043
+ } catch (error) {
5044
+ const authenticationFailure = networkTracker.authenticationFailureSince(initialNetworkMarker);
4650
5045
  const disconnected = (!options.freshContext && browserPool.getNavigationError?.() === "BROWSER_DISCONNECTED") || browserDisconnected(error);
4651
5046
  if (authenticationFailure) {
4652
5047
  navigationError = "AUTHENTICATION_REQUIRED";
@@ -4789,10 +5184,64 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
4789
5184
  safeResult.artifact_errors = [...(safeResult.artifact_errors || []), error instanceof JevBrowserError ? error.message : "flow report could not be saved"];
4790
5185
  }
4791
5186
  }
4792
- return safeResult;
4793
- }
4794
-
4795
- 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 }) {
4796
5245
  if (localOnly) throw new JevBrowserError("local_only is enabled; choose_next_action cannot call the Jev provider");
4797
5246
  const { request, criteria } = buildChoicePayload({ flow, pageSnapshot, actions, completedSteps }, settings.jev);
4798
5247
  return parseDecision(await requestJev(request, settings, fetchImpl), criteria);