@diegosouzacdv/jev-browser-mcp 0.4.1 → 0.5.0

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,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { mkdir, readFile, realpath, rm, stat } from "node:fs/promises";
2
+ import { lstat, mkdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { ACCESSIBILITY_STANDARDS, runAccessibilityAudit } from "./accessibility.mjs";
5
5
  import { JevBrowserError } from "./config.mjs";
@@ -17,16 +17,17 @@ const CONTAINER_ROLES = new Set([
17
17
  ]);
18
18
  const TARGET_SELECTION_ACTIONS = new Set([
19
19
  "click", "type", "select_option", "hover", "press", "assert_text", "assert_value",
20
- "assert_visible", "assert_hidden", "upload_file",
20
+ "assert_visible", "assert_hidden", "upload_file", "extract",
21
21
  ]);
22
22
  const LOCATOR_FIELDS = ["label", "placeholder", "title", "text", "test_id", "selector"];
23
23
  const TARGET_LOCATOR_FIELDS = [...LOCATOR_FIELDS, "target_text"];
24
24
  const LOCATOR_STEP_KEYS = { label: "label", placeholder: "placeholder", title: "title", text: "targetText", test_id: "testId", selector: "selector", target_text: "typedTargetText" };
25
- const ASSERT_ROLES = new Set([
25
+ const ASSERT_ROLES = new Set([
26
26
  "alert", "button", "cell", "checkbox", "combobox", "dialog", "gridcell", "heading",
27
27
  "img", "link", "listitem", "main", "menuitem", "option", "paragraph", "progressbar",
28
- "radio", "region", "row", "searchbox", "status", "tab", "textbox",
29
- ]);
28
+ "radio", "region", "row", "searchbox", "status", "tab", "textbox",
29
+ ]);
30
+ const UNNAMED_ACCESSIBLE_NAME = /^[\s\uE000-\uF8FF]*$/u;
30
31
  const APPROVED_KEYS = new Set([
31
32
  "PageDown", "PageUp", "Home", "End", "ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight",
32
33
  "Enter", "Escape", "Tab", "Shift+Tab",
@@ -38,7 +39,8 @@ const MIME_TYPES = new Map([
38
39
  ]);
39
40
  const FLOW_OPTION_KEYS = new Set([
40
41
  "block_trackers", "capture_console_errors", "capture_network_error_bodies", "capture_network_errors",
41
- "fast_path", "ready_network_idle", "ready_stable_ms", "ready_text", "ready_timeout_seconds",
42
+ "dry_run", "fast_path", "ready_network_idle", "ready_stable_ms", "ready_text", "ready_timeout_seconds",
43
+ "report_path",
42
44
  "reuse_page", "screenshot_on_failure", "snapshot_include_hidden", "snapshot_scope", "stop_on_expected",
43
45
  "trace_on_failure",
44
46
  ]);
@@ -70,8 +72,44 @@ function requiredText(value, field, limit) {
70
72
  function hasLocatorField(step) {
71
73
  return TARGET_LOCATOR_FIELDS.some((field) => step[LOCATOR_STEP_KEYS[field]] !== undefined);
72
74
  }
73
-
74
- export function validateCandidatePlans(candidatePlans, settings) {
75
+
76
+ function validateParams(rawParams, settings) {
77
+ if (rawParams === undefined) return {};
78
+ if (!isRecord(rawParams) || Object.keys(rawParams).length > settings.browser.maxFlowSteps * 4) {
79
+ throw new JevBrowserError("params must be a bounded object of scalar values");
80
+ }
81
+ return Object.fromEntries(Object.entries(rawParams).map(([name, value]) => {
82
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || !["string", "number", "boolean"].includes(typeof value)) {
83
+ throw new JevBrowserError("params keys must be simple variable names and values must be text, numbers, or booleans");
84
+ }
85
+ const text = String(value);
86
+ if (text.length > settings.browser.maxTextEntryChars) throw new JevBrowserError(`params.${name} exceeds the configured character limit`);
87
+ return [name, text];
88
+ }));
89
+ }
90
+
91
+ function replaceKnownParams(value, params) {
92
+ if (typeof value === "string") {
93
+ return value.replace(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (placeholder, name) => Object.hasOwn(params, name) ? params[name] : placeholder);
94
+ }
95
+ if (Array.isArray(value)) return value.map((item) => replaceKnownParams(item, params));
96
+ if (isRecord(value)) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, replaceKnownParams(item, params)]));
97
+ return value;
98
+ }
99
+
100
+ function interpolateStep(value, variables) {
101
+ if (typeof value === "string") {
102
+ return value.replace(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_placeholder, name) => {
103
+ if (!Object.hasOwn(variables, name)) throw new JevBrowserError(`step references {${name}} before it has been supplied or extracted`);
104
+ return variables[name];
105
+ });
106
+ }
107
+ if (Array.isArray(value)) return value.map((item) => interpolateStep(item, variables));
108
+ if (isRecord(value)) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, interpolateStep(item, variables)]));
109
+ return value;
110
+ }
111
+
112
+ export function validateCandidatePlans(candidatePlans, settings) {
75
113
  if (!isRecord(candidatePlans)) throw new JevBrowserError("candidate_plans must be an object");
76
114
  if (Object.keys(candidatePlans).length > settings.jev.maxActionCount) {
77
115
  throw new JevBrowserError("candidate_plans exceeds the configured candidate limit");
@@ -102,7 +140,10 @@ export function validateCandidatePlans(candidatePlans, settings) {
102
140
  const schemas = {
103
141
  click: { required: [], optional: [...semanticTarget, "expect_download", "timeout_seconds"] },
104
142
  type: { required: ["text"], optional: [...semanticTarget, "blur", "mode", "sensitive", "timeout_seconds"] },
105
- wait_for_text: { required: ["text"], optional: ["frame", "comment", "timeout_seconds"] },
143
+ confirm_dialog: { required: ["expected_text", "button"], optional: ["timeout_seconds"] },
144
+ assert_network: { required: ["url_contains", "method", "status"], optional: ["message_contains", "timeout_seconds"] },
145
+ extract: { required: ["as"], optional: [...semanticTarget, "source", "attribute", "sensitive", "timeout_seconds"] },
146
+ wait_for_text: { required: ["text"], optional: ["frame", "comment", "fail_on", "timeout_seconds"] },
106
147
  wait_for_condition: { required: ["condition"], optional: [...semanticTarget, "url_contains", "timeout_seconds"] },
107
148
  press: { required: ["key"], optional: [...semanticTarget, "timeout_seconds"] },
108
149
  select_option: { required: ["option"], optional: [...semanticTarget, "timeout_seconds"] },
@@ -117,9 +158,15 @@ export function validateCandidatePlans(candidatePlans, settings) {
117
158
  unlike_comment: { required: ["author", "text", "like_label", "liked_label"], optional: ["frame", "comment", "timeout_seconds"] },
118
159
  };
119
160
  if (!Object.hasOwn(schemas, action)) throw new JevBrowserError("unsupported browser action");
120
- exactKeys(rawStep, ["action", ...schemas[action].required], schemas[action].optional, action);
161
+ exactKeys(rawStep, ["action", ...schemas[action].required], [...schemas[action].optional, "mutating", "screenshot"], action);
121
162
 
122
163
  const step = { action };
164
+ for (const field of ["mutating", "screenshot"]) {
165
+ if (rawStep[field] !== undefined && typeof rawStep[field] !== "boolean") {
166
+ throw new JevBrowserError(`${action} ${field} must be a boolean`);
167
+ }
168
+ if (rawStep[field] !== undefined) step[field] = rawStep[field];
169
+ }
123
170
  const selectorFields = locatorFields.filter((field) => rawStep[field] !== undefined);
124
171
  const hasNear = rawStep.near !== undefined;
125
172
  if (selectorFields.length + Number(hasNear) > 1) {
@@ -185,9 +232,13 @@ export function validateCandidatePlans(candidatePlans, settings) {
185
232
  if ((!TARGET_SELECTION_ACTIONS.has(action) && !hiddenWait) || !isRecord(rawStep.within)) {
186
233
  throw new JevBrowserError("within must be a semantic container on a target-based browser action");
187
234
  }
188
- if (rawStep.within.row_containing !== undefined) {
189
- exactKeys(rawStep.within, ["row_containing"], [], "within");
190
- step.within = { rowContaining: requiredText(rawStep.within.row_containing, "within row text", settings.jev.maxActionDescriptionChars) };
235
+ if (rawStep.within.row_containing !== undefined || rawStep.within.row_containing_exact !== undefined) {
236
+ const exact = rawStep.within.row_containing_exact !== undefined;
237
+ const key = exact ? "row_containing_exact" : "row_containing";
238
+ exactKeys(rawStep.within, [key], [], "within");
239
+ step.within = exact
240
+ ? { rowContainingExact: requiredText(rawStep.within.row_containing_exact, "within exact row text", settings.jev.maxActionDescriptionChars) }
241
+ : { rowContaining: requiredText(rawStep.within.row_containing, "within row text", settings.jev.maxActionDescriptionChars) };
191
242
  } else {
192
243
  exactKeys(rawStep.within, ["role"], ["name"], "within");
193
244
  if (typeof rawStep.within.role !== "string" || !CONTAINER_ROLES.has(rawStep.within.role)) {
@@ -232,9 +283,53 @@ export function validateCandidatePlans(candidatePlans, settings) {
232
283
  step.blur = rawStep.blur ?? false;
233
284
  step.sensitive = rawStep.sensitive ?? true;
234
285
  }
235
- if (action === "wait_for_text") {
236
- step.text = requiredText(rawStep.text, "wait text", settings.jev.maxActionDescriptionChars);
237
- }
286
+ if (action === "wait_for_text") {
287
+ step.text = requiredText(rawStep.text, "wait text", settings.jev.maxActionDescriptionChars);
288
+ if (rawStep.fail_on !== undefined) {
289
+ if (!isRecord(rawStep.fail_on)) throw new JevBrowserError("wait_for_text fail_on must be an object with a role");
290
+ exactKeys(rawStep.fail_on, ["role"], ["name"], "wait_for_text fail_on");
291
+ if (typeof rawStep.fail_on.role !== "string" || !ASSERT_ROLES.has(rawStep.fail_on.role)) {
292
+ throw new JevBrowserError(`wait_for_text fail_on role must be one of: ${[...ASSERT_ROLES].join(", ")}`);
293
+ }
294
+ step.failOn = { role: rawStep.fail_on.role };
295
+ if (rawStep.fail_on.name !== undefined) {
296
+ step.failOn.name = requiredText(rawStep.fail_on.name, "fail_on accessible name", settings.jev.maxActionDescriptionChars);
297
+ }
298
+ }
299
+ }
300
+ if (action === "confirm_dialog") {
301
+ step.expectedText = requiredText(rawStep.expected_text, "expected dialog text", settings.jev.maxActionDescriptionChars);
302
+ step.button = requiredText(rawStep.button, "confirmation button", settings.jev.maxActionDescriptionChars);
303
+ }
304
+ if (action === "assert_network") {
305
+ step.urlContains = requiredText(rawStep.url_contains, "url_contains", settings.jev.maxActionDescriptionChars);
306
+ step.method = requiredText(rawStep.method, "network method", 16).toUpperCase();
307
+ if (!/^[A-Z]+$/.test(step.method)) throw new JevBrowserError("assert_network method must contain letters only");
308
+ if (!Number.isSafeInteger(rawStep.status) || rawStep.status < 100 || rawStep.status > 599) {
309
+ throw new JevBrowserError("assert_network status must be an HTTP status from 100 to 599");
310
+ }
311
+ step.status = rawStep.status;
312
+ if (rawStep.message_contains !== undefined) {
313
+ step.messageContains = requiredText(rawStep.message_contains, "network message", settings.browser.maxNetworkErrorMessageChars);
314
+ }
315
+ }
316
+ if (action === "extract") {
317
+ step.as = requiredText(rawStep.as, "extract variable name", 64);
318
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(step.as)) throw new JevBrowserError("extract as must be a simple variable name");
319
+ step.source = rawStep.source ?? "text";
320
+ if (!new Set(["text", "value", "attribute"]).has(step.source)) {
321
+ throw new JevBrowserError("extract source must be text, value, or attribute");
322
+ }
323
+ if (step.source === "attribute") {
324
+ step.attribute = requiredText(rawStep.attribute, "extract attribute", 128);
325
+ } else if (rawStep.attribute !== undefined) {
326
+ throw new JevBrowserError("extract attribute is only supported when source is attribute");
327
+ }
328
+ if (rawStep.sensitive !== undefined && typeof rawStep.sensitive !== "boolean") {
329
+ throw new JevBrowserError("extract sensitive must be a boolean");
330
+ }
331
+ step.sensitive = rawStep.sensitive ?? true;
332
+ }
238
333
  if (action === "wait_for_condition") {
239
334
  if (!new Set(["network_idle", "hidden", "text_hidden"]).has(rawStep.condition)) {
240
335
  throw new JevBrowserError("wait_for_condition condition must be network_idle, hidden, or text_hidden");
@@ -349,14 +444,14 @@ function normalizeStepAliases(rawStep) {
349
444
  return step;
350
445
  }
351
446
 
352
- function resolveFlowOptions(rawOptions, settings) {
447
+ function resolveFlowOptions(rawOptions, settings) {
353
448
  if (rawOptions === undefined) rawOptions = {};
354
449
  if (!isRecord(rawOptions) || Object.keys(rawOptions).some((key) => !FLOW_OPTION_KEYS.has(key))) {
355
450
  throw new JevBrowserError("options contains unsupported flow settings");
356
451
  }
357
452
  const booleanOptions = [
358
453
  "block_trackers", "capture_console_errors", "capture_network_error_bodies", "capture_network_errors",
359
- "fast_path", "ready_network_idle", "reuse_page", "screenshot_on_failure", "snapshot_include_hidden",
454
+ "dry_run", "fast_path", "ready_network_idle", "reuse_page", "screenshot_on_failure", "snapshot_include_hidden",
360
455
  "stop_on_expected", "trace_on_failure",
361
456
  ];
362
457
  for (const key of booleanOptions) {
@@ -380,16 +475,27 @@ function resolveFlowOptions(rawOptions, settings) {
380
475
  const readyText = rawOptions.ready_text === undefined
381
476
  ? ""
382
477
  : requiredText(rawOptions.ready_text, "ready_text", settings.jev.maxActionDescriptionChars);
478
+ let reportPath;
479
+ if (rawOptions.report_path !== undefined) {
480
+ if (typeof rawOptions.report_path !== "string" || !path.isAbsolute(rawOptions.report_path)) {
481
+ throw new JevBrowserError("options.report_path must be an absolute path inside the configured artifact directory");
482
+ }
483
+ const extension = path.extname(rawOptions.report_path).toLowerCase();
484
+ if (!new Set([".md", ".xml"]).has(extension)) throw new JevBrowserError("options.report_path must end in .md or .xml (JUnit)");
485
+ reportPath = rawOptions.report_path;
486
+ }
383
487
  return Object.freeze({
384
488
  blockTrackers: rawOptions.block_trackers ?? defaults.blockTrackers,
385
489
  captureConsoleErrors: rawOptions.capture_console_errors ?? defaults.captureConsoleErrors,
386
490
  captureNetworkErrorBodies: rawOptions.capture_network_error_bodies ?? defaults.captureNetworkErrorBodies,
387
491
  captureNetworkErrors: rawOptions.capture_network_errors ?? defaults.captureNetworkErrors,
492
+ dryRun: rawOptions.dry_run ?? false,
388
493
  fastPath: rawOptions.fast_path ?? defaults.fastPath,
389
494
  readyTimeoutMs: Math.ceil(readyTimeoutSeconds * 1000),
390
495
  readyNetworkIdle: rawOptions.ready_network_idle ?? defaults.readyNetworkIdle,
391
496
  readyStableMs,
392
497
  readyText,
498
+ reportPath,
393
499
  reusePage: rawOptions.reuse_page ?? defaults.reusePage,
394
500
  screenshotOnFailure: rawOptions.screenshot_on_failure ?? defaults.screenshotOnFailure,
395
501
  snapshotIncludeHidden: rawOptions.snapshot_include_hidden ?? defaults.snapshotIncludeHidden,
@@ -569,6 +675,59 @@ async function snapshotPage(page, settings, secrets, snapshotScope = "body", { a
569
675
  return compactSnapshot(redact(combined, secrets), settings.jev.maxSnapshotChars);
570
676
  }
571
677
 
678
+ async function collectUnnamedControls(root, roles, settings, secrets) {
679
+ const unnamedControls = [];
680
+ for (const role of roles) {
681
+ if (unnamedControls.length >= settings.jev.maxDiagnosticItems) break;
682
+ const locator = root.getByRole(role, { name: UNNAMED_ACCESSIBLE_NAME });
683
+ const controls = await locator.evaluateAll((elements, maxChars) => {
684
+ const textOf = (element) => (element?.innerText || element?.textContent || "").replace(/\s+/g, " ").trim();
685
+ const nearestLabel = (element) => {
686
+ const labels = [...element.ownerDocument.querySelectorAll("label")]
687
+ .filter((label) => label.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING).reverse();
688
+ for (const label of labels) {
689
+ const controlsBetween = [...element.ownerDocument.querySelectorAll("input,textarea,select,[role=textbox],[role=combobox]")]
690
+ .some((control) => (label.compareDocumentPosition(control) & Node.DOCUMENT_POSITION_FOLLOWING)
691
+ && (control.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING));
692
+ if (!controlsBetween) return textOf(label);
693
+ }
694
+ return "";
695
+ };
696
+ const allowedAttributes = new Set(["id", "name", "type", "title", "aria-label", "aria-labelledby", "placeholder", "role", "data-testid", "href", "alt", "disabled"]);
697
+ const safeHtml = (element) => {
698
+ const clone = element.cloneNode(true);
699
+ for (const node of [clone, ...clone.querySelectorAll("*")]) {
700
+ for (const attribute of [...node.attributes]) if (!allowedAttributes.has(attribute.name.toLowerCase())) node.removeAttribute(attribute.name);
701
+ if (node.matches("input,textarea")) {
702
+ node.removeAttribute("value");
703
+ if (node.matches("textarea")) node.textContent = "";
704
+ }
705
+ }
706
+ clone.querySelectorAll("script,style").forEach((node) => node.remove());
707
+ return clone.outerHTML.replace(/\s+/g, " ").slice(0, maxChars);
708
+ };
709
+ return elements.map((element, index) => ({
710
+ index,
711
+ disabled: Boolean(element.disabled || element.matches(":disabled") || element.getAttribute("aria-disabled") === "true"),
712
+ nearestLabel: nearestLabel(element),
713
+ html: safeHtml(element),
714
+ }));
715
+ }, settings.jev.maxDiagnosticChars);
716
+ for (const control of controls) {
717
+ if (control.disabled) continue;
718
+ unnamedControls.push({
719
+ role,
720
+ index: control.index,
721
+ name: "",
722
+ nearest_label: redact(control.nearestLabel, secrets),
723
+ html: redact(control.html, secrets),
724
+ });
725
+ if (unnamedControls.length >= settings.jev.maxDiagnosticItems) break;
726
+ }
727
+ }
728
+ return unnamedControls;
729
+ }
730
+
572
731
  async function enrichSnapshot(page, snapshot, settings, secrets, snapshotScope, includeHidden) {
573
732
  let root;
574
733
  try {
@@ -603,6 +762,7 @@ async function enrichSnapshot(page, snapshot, settings, secrets, snapshotScope,
603
762
  };
604
763
  const accessibleName = (element) => element.getAttribute("aria-label") || referencedName(element)
605
764
  || [...(element.labels || [])].map(textOf).filter(Boolean).join(" ")
765
+ || (element.matches("input,textarea") ? element.getAttribute("placeholder") : "")
606
766
  || element.getAttribute("alt") || (element.matches("a,button,[role=button],[role=link],[role=menuitem]") ? textOf(element) : "")
607
767
  || element.getAttribute("title") || "";
608
768
  const sensitiveField = (element) => {
@@ -642,16 +802,21 @@ async function enrichSnapshot(page, snapshot, settings, secrets, snapshotScope,
642
802
  clone.querySelectorAll("script,style").forEach((node) => node.remove());
643
803
  return clone.outerHTML.replace(/\s+/g, " ").slice(0, limits.maxChars);
644
804
  };
645
- const interactive = [...scope.querySelectorAll("a,button,input:not([type=hidden]),select,textarea,[role=button],[role=link],[role=menuitem]")]
646
- .filter((element) => limits.includeHidden || isVisible(element));
647
- const unnamed = interactive.filter((element) => !accessibleName(element).trim()).slice(0, limits.maxItems)
648
- .map((element, index) => ({
649
- role: element.getAttribute("role") || element.localName,
650
- index,
651
- nearestLabel: nearestLabel(element),
652
- html: safeHtml(element),
653
- }));
654
- return { formLines, unnamed };
805
+ const roleForInteractive = (element) => {
806
+ const explicitRole = element.getAttribute("role");
807
+ if (explicitRole) return explicitRole;
808
+ if (element.matches("a[href]")) return "link";
809
+ if (element.matches("button,input[type=button],input[type=submit],input[type=reset]")) return "button";
810
+ if (element.matches("input[type=checkbox]")) return "checkbox";
811
+ if (element.matches("input[type=radio]")) return "radio";
812
+ if (element.matches("input[type=search]")) return "searchbox";
813
+ if (element.matches("input:not([type=hidden]),textarea")) return "textbox";
814
+ if (element.matches("select")) return element.multiple ? "listbox" : "combobox";
815
+ return "";
816
+ };
817
+ const interactive = [...scope.querySelectorAll("a,button,input:not([type=hidden]),select,textarea,[role=button],[role=link],[role=menuitem],[role=textbox],[role=searchbox],[role=combobox],[role=checkbox],[role=radio]")];
818
+ const interactiveRoles = [...new Set(interactive.map(roleForInteractive).filter(Boolean))];
819
+ return { formLines, interactiveRoles };
655
820
  }, {
656
821
  includeHidden,
657
822
  maxItems: settings.jev.maxDiagnosticItems,
@@ -659,16 +824,13 @@ async function enrichSnapshot(page, snapshot, settings, secrets, snapshotScope,
659
824
  });
660
825
  const sections = [];
661
826
  if (details.formLines.length) sections.push(`[form controls: ids, names, values (password/token fields redacted), disabled state and same-name index]\n${details.formLines.join("\n")}`);
662
- if (details.unnamed.length) {
663
- sections.push(`[interactive controls without accessible names]\n${details.unnamed.map(({ role, index, nearestLabel, html }) =>
664
- `- ${role} index=${index}; nearest_label=${JSON.stringify(nearestLabel)}; html=${html}`).join("\n")}`);
827
+ const unnamedControls = typeof root.getByRole === "function"
828
+ ? await collectUnnamedControls(root, details.interactiveRoles, settings, secrets)
829
+ : [];
830
+ if (unnamedControls.length) {
831
+ sections.push(`[interactive controls without accessible names; indices match Playwright role/name locators, disabled controls omitted]\n${unnamedControls.map(({ role, index, nearest_label, html }) =>
832
+ `- ${role} name=${JSON.stringify("")} index=${index}; nearest_label=${JSON.stringify(nearest_label)}; html=${html}`).join("\n")}`);
665
833
  }
666
- const unnamedControls = details.unnamed.map(({ role, index, nearestLabel, html }) => ({
667
- role,
668
- index,
669
- nearest_label: redact(nearestLabel, secrets),
670
- html: redact(html, secrets),
671
- }));
672
834
  return {
673
835
  snapshot: compactSnapshot(redact([snapshot, ...sections].join("\n\n"), secrets), settings.jev.maxSnapshotChars),
674
836
  unnamedControls,
@@ -693,25 +855,35 @@ function createNetworkTracker(page, settings) {
693
855
  const history = [];
694
856
  let sequence = 0;
695
857
  const record = (type, request) => {
696
- const entry = { sequence: ++sequence, type, url: request.url(), at: performance.now(), request };
858
+ const entry = { sequence: ++sequence, type, url: request.url(), method: request.method(), at: performance.now(), request };
697
859
  if (type === "request") active.set(request, entry);
698
- else active.delete(request);
860
+ else if (type === "finished" || type === "failed") active.delete(request);
699
861
  history.push(entry);
700
862
  const historyLimit = settings.browser.maxFlowSteps * 8;
701
863
  if (history.length > historyLimit) history.splice(0, history.length - historyLimit);
864
+ return entry;
702
865
  };
703
866
  const onRequest = (request) => record("request", request);
867
+ const onResponse = (response) => {
868
+ const entry = record("response", response.request());
869
+ entry.response = response;
870
+ entry.status = response.status();
871
+ };
704
872
  const onFinished = (request) => record("finished", request);
705
873
  const onFailed = (request) => record("failed", request);
706
874
  page.on("request", onRequest);
875
+ page.on("response", onResponse);
707
876
  page.on("requestfinished", onFinished);
708
877
  page.on("requestfailed", onFailed);
709
878
  return {
710
879
  mark: () => sequence,
711
880
  since: (marker, urlContains) => history.filter((entry) => entry.sequence > marker && entry.url.includes(urlContains)),
881
+ responsesSince: (marker, urlContains, method) => history.filter((entry) => entry.sequence > marker
882
+ && entry.type === "response" && entry.url.includes(urlContains) && entry.method === method),
712
883
  activeFor: (urlContains) => [...active.values()].filter((entry) => entry.url.includes(urlContains)),
713
884
  dispose: () => {
714
885
  page.off("request", onRequest);
886
+ page.off("response", onResponse);
715
887
  page.off("requestfinished", onFinished);
716
888
  page.off("requestfailed", onFailed);
717
889
  active.clear();
@@ -740,6 +912,60 @@ async function waitForFilteredNetworkIdle(tracker, urlContains, marker, timeout,
740
912
  throw new JevBrowserError(`requests matching url_contains ${JSON.stringify(urlContains)} did not become idle within ${timeout}ms`);
741
913
  }
742
914
 
915
+ async function networkResponseMessage(response, page, settings, secrets) {
916
+ try {
917
+ if (new URL(response.url()).origin !== new URL(page.url()).origin) {
918
+ throw new JevBrowserError("assert_network message checks are limited to same-origin responses");
919
+ }
920
+ await withTimeout(response.finished(), settings.browser.actionTimeoutMs, "network response did not finish before the assertion timeout");
921
+ const headers = await response.headers();
922
+ const declaredLength = Number(headers["content-length"]);
923
+ if (Number.isFinite(declaredLength) && declaredLength > settings.browser.maxNetworkErrorBodyBytes) {
924
+ throw new JevBrowserError("assert_network response body exceeds the configured capture limit");
925
+ }
926
+ const body = await withTimeout(response.body(), settings.browser.actionTimeoutMs, "network response body read timed out");
927
+ if (body.length > settings.browser.maxNetworkErrorBodyBytes) {
928
+ throw new JevBrowserError("assert_network response body exceeds the configured capture limit");
929
+ }
930
+ const text = body.toString("utf8");
931
+ try {
932
+ const payload = JSON.parse(text);
933
+ if (isRecord(payload) && typeof payload.message === "string") {
934
+ return sanitizeDiagnosticText(payload.message, secrets, settings.browser.maxNetworkErrorMessageChars);
935
+ }
936
+ } catch {
937
+ // Plain text responses remain useful to a bounded message_contains assertion.
938
+ }
939
+ return sanitizeDiagnosticText(text, secrets, settings.browser.maxNetworkErrorMessageChars);
940
+ } catch (error) {
941
+ if (error instanceof JevBrowserError) throw error;
942
+ throw new JevBrowserError("assert_network could not read the response message safely");
943
+ }
944
+ }
945
+
946
+ async function waitForNetworkAssertion(tracker, step, marker, timeout, page, settings, secrets) {
947
+ const started = performance.now();
948
+ while (performance.now() - started < timeout) {
949
+ const [entry] = tracker.responsesSince(marker, step.urlContains, step.method);
950
+ if (entry) {
951
+ if (entry.status !== step.status) {
952
+ throw new JevBrowserError(`assert_network expected ${step.method} ${JSON.stringify(redact(step.urlContains, secrets))} status ${step.status}, received ${entry.status}`);
953
+ }
954
+ const message = step.messageContains === undefined
955
+ ? undefined
956
+ : await networkResponseMessage(entry.response, page, settings, secrets);
957
+ if (message !== undefined && !message.toLocaleLowerCase().includes(step.messageContains.toLocaleLowerCase())) {
958
+ throw new JevBrowserError(`assert_network response message did not contain ${JSON.stringify(redact(step.messageContains, secrets))}`);
959
+ }
960
+ return { url: entry.url, method: entry.method, status: entry.status, ...(message !== undefined ? { message } : {}) };
961
+ }
962
+ const left = timeout - (performance.now() - started);
963
+ if (left <= 0) break;
964
+ await new Promise((resolve) => setTimeout(resolve, Math.min(settings.browser.visibilityPollMs, left)));
965
+ }
966
+ throw new JevBrowserError(`assert_network observed no ${step.method} response matching ${JSON.stringify(redact(step.urlContains, secrets))} within ${timeout}ms`);
967
+ }
968
+
743
969
  async function waitForLocatorHidden(locator, timeout, description, settings, index) {
744
970
  const deadline = performance.now() + timeout;
745
971
  while (true) {
@@ -919,6 +1145,38 @@ function locatorAt(locator, index) {
919
1145
  throw new JevBrowserError(`locator does not support occurrence index ${index}`);
920
1146
  }
921
1147
 
1148
+ async function firstVisibleMatch(locator) {
1149
+ const count = await locator.count();
1150
+ for (let index = 0; index < count; index += 1) {
1151
+ const candidate = locatorAt(locator, index);
1152
+ if (await isActuallyVisible(candidate)) return candidate;
1153
+ }
1154
+ return undefined;
1155
+ }
1156
+
1157
+ async function waitForTextOrFailure(root, step, timeout, settings, secrets) {
1158
+ const expected = root.getByText(step.text, { exact: false });
1159
+ const failure = root.getByRole(step.failOn.role, roleOptions(step.failOn));
1160
+ const deadline = performance.now() + timeout;
1161
+ while (true) {
1162
+ if (await firstVisibleMatch(expected)) return;
1163
+ const failedMatch = await firstVisibleMatch(failure);
1164
+ if (failedMatch) {
1165
+ const visibleText = await failedMatch.innerText().catch(() => "");
1166
+ const message = sanitizeDiagnosticText(visibleText || step.failOn.name || step.failOn.role, secrets, settings.jev.maxDiagnosticChars);
1167
+ const failureTarget = `role ${step.failOn.role}${step.failOn.name ? ` ${JSON.stringify(redact(step.failOn.name, secrets))}` : ""}`;
1168
+ throw new JevBrowserError(
1169
+ `wait_for_text ${JSON.stringify(redact(step.text, secrets))} stopped because ${failureTarget} appeared: ${JSON.stringify(message)}`,
1170
+ );
1171
+ }
1172
+ const left = deadline - performance.now();
1173
+ if (left <= 0) {
1174
+ throw new JevBrowserError(`text ${JSON.stringify(redact(step.text, secrets))} has no visible match within ${timeout}ms; ${step.failOn.role} did not appear`);
1175
+ }
1176
+ await new Promise((resolve) => setTimeout(resolve, Math.min(settings.browser.visibilityPollMs, left)));
1177
+ }
1178
+ }
1179
+
922
1180
  async function visibleIndexes(locator, timeout, description, settings, { allowNone = false } = {}) {
923
1181
  const deadline = performance.now() + timeout;
924
1182
  while (true) {
@@ -947,6 +1205,9 @@ async function scopedRoot(root, within, timeout, settings) {
947
1205
  if (within.rowContaining) {
948
1206
  locator = root.getByRole("row").filter({ hasText: new RegExp(escapeRegex(within.rowContaining), "iu") });
949
1207
  description = `row containing ${JSON.stringify(within.rowContaining)}`;
1208
+ } else if (within.rowContainingExact) {
1209
+ locator = root.getByRole("row").filter({ has: root.getByText(within.rowContainingExact, { exact: true }) });
1210
+ description = `row containing exact text ${JSON.stringify(within.rowContainingExact)}`;
950
1211
  } else {
951
1212
  const options = within.name === undefined ? {} : { name: within.name, exact: true };
952
1213
  locator = root.getByRole(within.role, options);
@@ -958,6 +1219,7 @@ async function scopedRoot(root, within, timeout, settings) {
958
1219
  }
959
1220
 
960
1221
  function roleOptions(step) {
1222
+ if (step.name === "") return { name: UNNAMED_ACCESSIBLE_NAME };
961
1223
  return step.name === undefined ? {} : { name: step.name, exact: true };
962
1224
  }
963
1225
 
@@ -995,7 +1257,12 @@ async function targetLocator(root, step, timeout, settings) {
995
1257
  locator = targetRoot.locator(step.selector);
996
1258
  } else if (field === "label") {
997
1259
  locator = targetRoot.getByLabel(step.label, { exact: true });
998
- if (await locator.count() === 0) locator = relativeTarget(targetRoot.getByText(step.label, { exact: true }).first(), step.role);
1260
+ if (await locator.count() === 0) {
1261
+ step.matchStrategy = "label-proximity";
1262
+ locator = relativeTarget(targetRoot.getByText(step.label, { exact: true }).first(), step.role);
1263
+ } else {
1264
+ step.matchStrategy = "label";
1265
+ }
999
1266
  } else if (field === "placeholder") {
1000
1267
  locator = targetRoot.getByPlaceholder(step.placeholder, { exact: true });
1001
1268
  } else if (field === "title") {
@@ -1027,11 +1294,79 @@ async function targetLocator(root, step, timeout, settings) {
1027
1294
  }
1028
1295
 
1029
1296
  function describeTarget(step, secrets) {
1030
- if (step.role) return `role ${step.role}${step.name === undefined ? "" : ` ${JSON.stringify(redact(step.name, secrets))}`}`;
1031
- const field = TARGET_LOCATOR_FIELDS.find((candidate) => step[LOCATOR_STEP_KEYS[candidate]] !== undefined);
1032
- if (field) return `${field} ${JSON.stringify(redact(step[LOCATOR_STEP_KEYS[field]], secrets))}`;
1033
- if (step.near) return `target near text ${JSON.stringify(redact(step.near.text, secrets))}`;
1034
- return "focused element";
1297
+ const parts = [];
1298
+ if (step.role) {
1299
+ const name = step.name === undefined ? "" : ` ${JSON.stringify(redact(step.name, secrets))}`;
1300
+ parts.push(`role ${step.role}${name}`);
1301
+ }
1302
+ for (const field of TARGET_LOCATOR_FIELDS) {
1303
+ const value = step[LOCATOR_STEP_KEYS[field]];
1304
+ if (value !== undefined) parts.push(`${field} ${JSON.stringify(redact(value, secrets))}`);
1305
+ }
1306
+ if (step.near) parts.push(`near text ${JSON.stringify(redact(step.near.text, secrets))}`);
1307
+ if (step.within) {
1308
+ parts.push(step.within.rowContainingExact !== undefined
1309
+ ? `within row_containing_exact ${JSON.stringify(redact(step.within.rowContainingExact, secrets))}`
1310
+ : step.within.rowContaining !== undefined
1311
+ ? `within row_containing ${JSON.stringify(redact(step.within.rowContaining, secrets))}`
1312
+ : `within ${step.within.role}${step.within.name === undefined ? "" : ` ${JSON.stringify(redact(step.within.name, secrets))}`}`);
1313
+ }
1314
+ if (step.index !== undefined) parts.push(`index ${step.index}`);
1315
+ return parts.length ? parts.join(" + ") : "focused element";
1316
+ }
1317
+
1318
+ async function describeResolvedTarget(locator, step, secrets, settings) {
1319
+ if (typeof locator?.evaluate !== "function") return undefined;
1320
+ try {
1321
+ const includeValue = step.action === "type" && step.sensitive === false;
1322
+ const resolved = await locator.evaluate((element, options) => {
1323
+ const textOf = (node) => (node?.innerText || node?.textContent || "").replace(/\s+/g, " ").trim();
1324
+ const referenced = (element.getAttribute("aria-labelledby") || "").trim().split(/\s+/).filter(Boolean)
1325
+ .map((id) => textOf(element.ownerDocument.getElementById(id))).filter(Boolean).join(" ");
1326
+ const labels = [...(element.labels || [])].map(textOf).filter(Boolean).join(" ");
1327
+ const controlPlaceholder = element.matches("input,textarea,select") ? element.getAttribute("placeholder") || "" : "";
1328
+ const textAlternative = element.matches("a,button,[role=button],[role=link],[role=menuitem]") ? textOf(element) : "";
1329
+ const role = element.getAttribute("role")
1330
+ || (element.matches("a[href]") ? "link"
1331
+ : element.matches("button,input[type=button],input[type=submit],input[type=reset]") ? "button"
1332
+ : element.matches("input[type=checkbox]") ? "checkbox"
1333
+ : element.matches("input[type=radio]") ? "radio"
1334
+ : element.matches("input[type=search]") ? "searchbox"
1335
+ : element.matches("select") ? "combobox"
1336
+ : element.matches("input,textarea") ? "textbox" : "");
1337
+ const result = {
1338
+ tag: element.localName,
1339
+ id: element.id || "",
1340
+ role,
1341
+ accessible_name: element.getAttribute("aria-label") || referenced || labels || controlPlaceholder
1342
+ || element.getAttribute("alt") || textAlternative || element.getAttribute("title") || "",
1343
+ title: options.matchedTitle || element.getAttribute("title") || "",
1344
+ href: element.matches("a[href]") ? element.href : element.getAttribute("href") || "",
1345
+ };
1346
+ if (options.includeFieldValue && "value" in element) result.value = String(element.value ?? "");
1347
+ return result;
1348
+ }, { includeFieldValue: includeValue, matchedTitle: step.title || "" });
1349
+ if (step.role) resolved.role = step.role;
1350
+ if (step.matchStrategy) resolved.match_strategy = step.matchStrategy;
1351
+ resolved.id = redact(resolved.id, secrets);
1352
+ resolved.accessible_name = redact(String(resolved.accessible_name).slice(0, settings.jev.maxDiagnosticChars), secrets);
1353
+ if (resolved.title) resolved.title = redact(String(resolved.title).slice(0, settings.jev.maxDiagnosticChars), secrets);
1354
+ else delete resolved.title;
1355
+ if (resolved.href) resolved.href = evidenceUrl(resolved.href, secrets, settings.jev.maxDiagnosticChars);
1356
+ else delete resolved.href;
1357
+ if (Object.hasOwn(resolved, "value")) {
1358
+ resolved.value = redact(resolved.value.slice(0, settings.browser.maxTextEntryChars), secrets);
1359
+ }
1360
+ return resolved;
1361
+ } catch {
1362
+ return undefined;
1363
+ }
1364
+ }
1365
+
1366
+ function targetEvidence(step, secrets, resolvedTarget) {
1367
+ const evidence = { target: describeTarget(step, secrets) };
1368
+ if (resolvedTarget) evidence.resolved_target = resolvedTarget;
1369
+ return evidence;
1035
1370
  }
1036
1371
 
1037
1372
  async function namedTarget(root, step, snapshot, secrets, timeout, settings) {
@@ -1251,10 +1586,61 @@ async function saveDownload(download, settings, ledger) {
1251
1586
  async function executeStep(page, step, settings, snapshot, secrets, downloadLedger, execution = {}) {
1252
1587
  const root = await stepRoot(page, step.frame);
1253
1588
  const timeout = step.timeoutMs ?? settings.browser.actionTimeoutMs;
1589
+ if (step.action === "assert_network") {
1590
+ const response = await waitForNetworkAssertion(
1591
+ execution.networkTracker,
1592
+ step,
1593
+ execution.networkMarker,
1594
+ timeout,
1595
+ page,
1596
+ settings,
1597
+ secrets,
1598
+ );
1599
+ 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 } : {}) };
1600
+ }
1601
+ if (step.action === "confirm_dialog") {
1602
+ const dialog = await exactlyOne(root.getByRole("dialog"), "confirmation dialog", timeout, settings);
1603
+ const actualText = (await dialog.innerText({ timeout })).replace(/\s+/g, " ").trim();
1604
+ const expectedText = step.expectedText.replace(/\s+/g, " ").trim();
1605
+ if (!actualText.includes(expectedText)) {
1606
+ throw new JevBrowserError(`confirm_dialog refused to click ${JSON.stringify(redact(step.button, secrets))}: expected dialog text was not present`);
1607
+ }
1608
+ const button = await exactlyOne(dialog.getByRole("button", { name: step.button, exact: true }), `dialog button ${JSON.stringify(redact(step.button, secrets))}`, timeout, settings);
1609
+ const resolvedTarget = await describeResolvedTarget(button, { ...step, action: "click", role: "button", name: step.button }, secrets, settings);
1610
+ await button.click({ timeout });
1611
+ return {
1612
+ action: step.action,
1613
+ confirmed_text: redact(expectedText, secrets),
1614
+ ...targetEvidence({ action: step.action, role: "dialog", name: step.expectedText }, secrets, resolvedTarget),
1615
+ };
1616
+ }
1617
+ if (step.action === "extract") {
1618
+ const control = await namedTarget(root, step, snapshot, secrets, timeout, settings);
1619
+ const resolvedTarget = await describeResolvedTarget(control, step, secrets, settings);
1620
+ let value;
1621
+ if (step.source === "value") value = await control.inputValue({ timeout });
1622
+ else if (step.source === "attribute") value = await control.getAttribute(step.attribute, { timeout }) || "";
1623
+ else value = (await control.innerText({ timeout })).replace(/\s+/g, " ").trim();
1624
+ if (value.length > settings.browser.maxTextEntryChars) {
1625
+ throw new JevBrowserError("extracted value exceeds the configured character limit");
1626
+ }
1627
+ execution.variables[step.as] = value;
1628
+ if (step.sensitive && value && !secrets.includes(value)) {
1629
+ secrets.push(value);
1630
+ secrets.sort((left, right) => right.length - left.length);
1631
+ }
1632
+ return {
1633
+ action: step.action,
1634
+ variable: step.as,
1635
+ ...(step.sensitive ? { value_redacted: Boolean(value) } : { value: redact(value, secrets) }),
1636
+ ...targetEvidence(step, secrets, resolvedTarget),
1637
+ };
1638
+ }
1254
1639
  if (step.action === "wait_for_text") {
1255
1640
  const locator = root.getByText(step.text, { exact: false });
1256
- await visibleIndexes(locator, timeout, `text ${JSON.stringify(redact(step.text, secrets))}`, settings);
1257
- return { action: step.action };
1641
+ if (step.failOn) await waitForTextOrFailure(root, step, timeout, settings, secrets);
1642
+ else await visibleIndexes(locator, timeout, `text ${JSON.stringify(redact(step.text, secrets))}`, settings);
1643
+ return { action: step.action, target: `text ${JSON.stringify(redact(step.text, secrets))}` };
1258
1644
  }
1259
1645
  if (step.action === "wait_for_condition") {
1260
1646
  if (step.condition === "network_idle") {
@@ -1273,16 +1659,24 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
1273
1659
  const locator = await targetLocator(root, step, timeout, settings);
1274
1660
  await waitForLocatorHidden(locator, timeout, `hidden condition ${describeTarget(step, secrets)}`, settings, step.index);
1275
1661
  }
1276
- return { action: step.action, condition: step.condition };
1662
+ return {
1663
+ action: step.action,
1664
+ condition: step.condition,
1665
+ ...(step.condition === "network_idle"
1666
+ ? (step.urlContains ? { url_contains: redact(step.urlContains, secrets) } : {})
1667
+ : { target: describeTarget(step, secrets) }),
1668
+ };
1277
1669
  }
1278
1670
  if (step.action === "press") {
1671
+ let evidence = {};
1279
1672
  if (step.role || step.name !== undefined || hasLocatorField(step) || step.near) {
1280
1673
  const control = await namedTarget(root, step, snapshot, secrets, timeout, settings);
1674
+ evidence = targetEvidence(step, secrets, await describeResolvedTarget(control, step, secrets, settings));
1281
1675
  await control.press(step.key, { timeout });
1282
1676
  } else {
1283
1677
  await page.keyboard.press(step.key, { timeout });
1284
1678
  }
1285
- return { action: step.action, key: step.key };
1679
+ return { action: step.action, key: step.key, ...evidence };
1286
1680
  }
1287
1681
  if (step.action === "like_comment" || step.action === "unlike_comment") {
1288
1682
  return { action: step.action, result: await reactToComment(root, step, timeout, settings) };
@@ -1300,6 +1694,7 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
1300
1694
  if (step.action === "upload_file") {
1301
1695
  const files = await uploadFiles(step, settings);
1302
1696
  const control = await namedTarget(root, step, snapshot, secrets, timeout, settings);
1697
+ const resolvedTarget = await describeResolvedTarget(control, step, secrets, settings);
1303
1698
  if (step.target === "input") {
1304
1699
  await control.setInputFiles(files, { timeout });
1305
1700
  } else {
@@ -1313,23 +1708,34 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
1313
1708
  await control.drop({ files }, { timeout });
1314
1709
  }
1315
1710
  }
1316
- return { action: step.action, target: step.target, file_count: files.length };
1711
+ return {
1712
+ action: step.action,
1713
+ target: step.target,
1714
+ target_locator: describeTarget(step, secrets),
1715
+ ...(resolvedTarget ? { resolved_target: resolvedTarget } : {}),
1716
+ file_count: files.length,
1717
+ };
1317
1718
  }
1318
1719
  if (step.action === "assert_hidden") {
1319
1720
  const locator = await targetLocator(root, step, timeout, settings);
1320
1721
  await waitForLocatorHidden(locator, timeout, `assert_hidden ${describeTarget(step, secrets)}`, settings, step.index);
1321
- return { action: step.action, asserted: true };
1722
+ return { action: step.action, asserted: true, target: describeTarget(step, secrets) };
1322
1723
  }
1323
1724
  if (step.action === "assert_visible") {
1324
1725
  const control = await namedTarget(root, step, snapshot, secrets, timeout, settings);
1325
1726
  if (!(await isActuallyVisible(control))) throw new JevBrowserError(`assert_visible failed for ${describeTarget(step, secrets)}`);
1326
- return { action: step.action, asserted: true };
1727
+ return {
1728
+ action: step.action,
1729
+ asserted: true,
1730
+ ...targetEvidence(step, secrets, await describeResolvedTarget(control, step, secrets, settings)),
1731
+ };
1327
1732
  }
1328
1733
  const control = await namedTarget(root, step, snapshot, secrets, timeout, settings);
1329
1734
  if (step.action === "click") {
1330
- if (!step.expectDownload) {
1735
+ const resolvedTarget = await describeResolvedTarget(control, step, secrets, settings);
1736
+ if (!step.expectDownload) {
1331
1737
  await control.click({ timeout });
1332
- return { action: step.action, target: describeTarget(step, secrets) };
1738
+ return { action: step.action, ...targetEvidence(step, secrets, resolvedTarget) };
1333
1739
  }
1334
1740
  let download;
1335
1741
  try {
@@ -1340,13 +1746,14 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
1340
1746
  } catch {
1341
1747
  throw new JevBrowserError("click did not produce the expected download before the configured timeout");
1342
1748
  }
1343
- return {
1344
- action: step.action,
1345
- target: describeTarget(step, secrets),
1346
- downloaded_file: await saveDownload(download, settings, downloadLedger),
1347
- };
1749
+ return {
1750
+ action: step.action,
1751
+ ...targetEvidence(step, secrets, resolvedTarget),
1752
+ downloaded_file: await saveDownload(download, settings, downloadLedger),
1753
+ };
1348
1754
  }
1349
1755
  if (step.action === "type") {
1756
+ const resolvedTarget = await describeResolvedTarget(control, step, secrets, settings);
1350
1757
  if (step.mode === "keys") {
1351
1758
  await control.fill("", { timeout });
1352
1759
  const normalizedText = step.text.replace(/\r\n?/g, "\n");
@@ -1361,32 +1768,54 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
1361
1768
  await control.fill(step.text, { timeout });
1362
1769
  if (step.blur) await control.press("Tab", { timeout });
1363
1770
  }
1364
- return { action: step.action, role: step.role, name: step.name };
1365
- }
1771
+ if (resolvedTarget && step.sensitive === false) {
1772
+ resolvedTarget.value = redact((await control.inputValue({ timeout })).slice(0, settings.browser.maxTextEntryChars), secrets);
1773
+ }
1774
+ return {
1775
+ action: step.action,
1776
+ role: step.role,
1777
+ name: step.name,
1778
+ ...targetEvidence(step, secrets, resolvedTarget),
1779
+ };
1780
+ }
1366
1781
  if (step.action === "select_option") {
1782
+ const resolvedTarget = await describeResolvedTarget(control, step, secrets, settings);
1367
1783
  await control.selectOption({ label: step.option }, { timeout });
1368
- return { action: step.action, target: describeTarget(step, secrets) };
1369
- }
1370
- if (step.action === "hover") {
1371
- await control.hover({ timeout });
1372
- return { action: step.action, role: step.role, name: step.name };
1373
- }
1374
- if (step.action === "assert_visible") {
1375
- if (!(await control.isVisible())) throw new JevBrowserError(`assert_visible failed for ${step.role} ${JSON.stringify(redact(step.name, secrets))}`);
1376
- return { action: step.action, asserted: true };
1377
- }
1784
+ return { action: step.action, ...targetEvidence(step, secrets, resolvedTarget) };
1785
+ }
1786
+ if (step.action === "hover") {
1787
+ const resolvedTarget = await describeResolvedTarget(control, step, secrets, settings);
1788
+ await control.hover({ timeout });
1789
+ return { action: step.action, role: step.role, name: step.name, ...targetEvidence(step, secrets, resolvedTarget) };
1790
+ }
1791
+ if (step.action === "assert_visible") {
1792
+ if (!(await control.isVisible())) throw new JevBrowserError(`assert_visible failed for ${step.role} ${JSON.stringify(redact(step.name, secrets))}`);
1793
+ return {
1794
+ action: step.action,
1795
+ asserted: true,
1796
+ ...targetEvidence(step, secrets, await describeResolvedTarget(control, step, secrets, settings)),
1797
+ };
1798
+ }
1378
1799
  if (step.action === "assert_text") {
1379
- const actual = (await control.innerText({ timeout })).replace(/\s+/g, " ").trim();
1380
- const expected = step.expected.replace(/\s+/g, " ").trim();
1381
- if (!actual.toLocaleLowerCase().includes(expected.toLocaleLowerCase())) {
1800
+ const actual = (await control.innerText({ timeout })).replace(/\s+/g, " ").trim();
1801
+ const expected = step.expected.replace(/\s+/g, " ").trim();
1802
+ if (!actual.toLocaleLowerCase().includes(expected.toLocaleLowerCase())) {
1382
1803
  throw new JevBrowserError(`assert_text failed for ${describeTarget(step, secrets)}`);
1383
- }
1384
- return { action: step.action, asserted: true };
1385
- }
1386
- if (step.action === "assert_value") {
1387
- const actual = await control.inputValue({ timeout });
1804
+ }
1805
+ return {
1806
+ action: step.action,
1807
+ asserted: true,
1808
+ ...targetEvidence(step, secrets, await describeResolvedTarget(control, step, secrets, settings)),
1809
+ };
1810
+ }
1811
+ if (step.action === "assert_value") {
1812
+ const actual = await control.inputValue({ timeout });
1388
1813
  if (actual !== step.expected) throw new JevBrowserError(`assert_value failed for ${describeTarget(step, secrets)}`);
1389
- return { action: step.action, asserted: true };
1814
+ return {
1815
+ action: step.action,
1816
+ asserted: true,
1817
+ ...targetEvidence(step, secrets, await describeResolvedTarget(control, step, secrets, settings)),
1818
+ };
1390
1819
  }
1391
1820
  throw new JevBrowserError("unsupported browser action");
1392
1821
  }
@@ -1429,7 +1858,7 @@ function sanitizeDiagnosticText(value, secrets, limit) {
1429
1858
  }
1430
1859
 
1431
1860
  function createDiagnostics(page, options, settings, secrets) {
1432
- const diagnostics = { console_errors: [], network_failures: [] };
1861
+ const diagnostics = { console_errors: [], network_failures: [], warnings: [] };
1433
1862
  const pendingBodies = [];
1434
1863
  const limit = settings.jev.maxDiagnosticItems;
1435
1864
  const messageLimit = settings.jev.maxDiagnosticChars;
@@ -1441,26 +1870,50 @@ function createDiagnostics(page, options, settings, secrets) {
1441
1870
  addDiagnostic(diagnostics.console_errors, sanitizeDiagnosticText(error.message || error.name, secrets, messageLimit), limit);
1442
1871
  };
1443
1872
  const addResponseMessage = async (response, entry) => {
1873
+ const warn = (reason) => addDiagnostic(
1874
+ diagnostics.warnings,
1875
+ `network error response body omitted for ${entry.url}: ${reason}`,
1876
+ limit,
1877
+ );
1444
1878
  try {
1445
1879
  const responseUrl = new URL(response.url());
1446
1880
  const pageUrl = new URL(page.url());
1447
1881
  if (responseUrl.origin !== pageUrl.origin) return;
1448
1882
  const headers = await response.headers();
1449
1883
  const contentType = String(headers["content-type"] || "").toLowerCase();
1450
- const contentLength = Number(headers["content-length"]);
1451
- if (!contentType.includes("application/json") || !Number.isSafeInteger(contentLength)
1452
- || contentLength < 0 || contentLength > settings.browser.maxNetworkErrorBodyBytes) return;
1884
+ if (!contentType.includes("application/json")) {
1885
+ warn("content-type is not application/json");
1886
+ return;
1887
+ }
1888
+ const contentLengthHeader = headers["content-length"];
1889
+ if (contentLengthHeader !== undefined) {
1890
+ const contentLength = Number(contentLengthHeader);
1891
+ if (!Number.isSafeInteger(contentLength) || contentLength < 0) {
1892
+ warn("Content-Length is invalid");
1893
+ return;
1894
+ }
1895
+ if (contentLength > settings.browser.maxNetworkErrorBodyBytes) {
1896
+ warn("declared Content-Length exceeds the configured limit");
1897
+ return;
1898
+ }
1899
+ }
1453
1900
  const body = await withTimeout(
1454
1901
  response.body(),
1455
1902
  settings.browser.actionTimeoutMs,
1456
1903
  "error response body read timed out",
1457
1904
  );
1458
- if (body.length > settings.browser.maxNetworkErrorBodyBytes) return;
1905
+ if (body.length > settings.browser.maxNetworkErrorBodyBytes) {
1906
+ warn("received body exceeds the configured limit");
1907
+ return;
1908
+ }
1459
1909
  const payload = JSON.parse(body.toString("utf8"));
1460
- if (!isRecord(payload) || typeof payload.message !== "string") return;
1910
+ if (!isRecord(payload) || typeof payload.message !== "string") {
1911
+ warn("JSON body does not contain a text message");
1912
+ return;
1913
+ }
1461
1914
  entry.message = sanitizeDiagnosticText(payload.message, secrets, settings.browser.maxNetworkErrorMessageChars);
1462
1915
  } catch {
1463
- // A diagnostic body is optional; malformed, oversized, or late bodies do not fail the flow.
1916
+ warn("body could not be read or parsed safely");
1464
1917
  }
1465
1918
  };
1466
1919
  const onResponse = (response) => {
@@ -1518,6 +1971,12 @@ function failedStepDetails(step, index, error, settings, secrets) {
1518
1971
  };
1519
1972
  if (step.role !== undefined) details.role = step.role;
1520
1973
  if (step.name !== undefined) details.name = redact(step.name, secrets);
1974
+ if (step.role || hasLocatorField(step) || step.near || step.within) details.target = describeTarget(step, secrets);
1975
+ if (step.action === "wait_for_text") details.text = redact(step.text, secrets);
1976
+ if (step.failOn) {
1977
+ details.fail_on = { role: step.failOn.role };
1978
+ if (step.failOn.name !== undefined) details.fail_on.name = redact(step.failOn.name, secrets);
1979
+ }
1521
1980
  details.timeout_ms = step.timeoutMs ?? settings.browser.actionTimeoutMs;
1522
1981
  details.error = stepFailureText(error?.stepCause || error, secrets, settings.jev.maxDiagnosticChars);
1523
1982
  return details;
@@ -1547,10 +2006,87 @@ async function installTrackerBlocking(context, settings, enabled) {
1547
2006
  return async () => context.unroute("**/*", handler);
1548
2007
  }
1549
2008
 
1550
- async function artifactPath(settings, extension) {
2009
+ async function artifactPath(settings, extension) {
1551
2010
  await mkdir(settings.browser.artifactDirectory, { recursive: true });
1552
2011
  return path.join(settings.browser.artifactDirectory, `jev-browser-${Date.now()}-${randomUUID()}.${extension}`);
1553
- }
2012
+ }
2013
+
2014
+ async function safeReportPath(requestedPath, settings) {
2015
+ await mkdir(settings.browser.artifactDirectory, { recursive: true });
2016
+ const root = await realpath(settings.browser.artifactDirectory);
2017
+ const target = path.resolve(requestedPath);
2018
+ const relative = path.relative(root, target);
2019
+ if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
2020
+ throw new JevBrowserError("report_path must name a file inside the configured artifact directory");
2021
+ }
2022
+ await mkdir(path.dirname(target), { recursive: true });
2023
+ const actualParent = await realpath(path.dirname(target));
2024
+ const parentRelative = path.relative(root, actualParent);
2025
+ if (parentRelative === ".." || parentRelative.startsWith(`..${path.sep}`) || path.isAbsolute(parentRelative)) {
2026
+ throw new JevBrowserError("report_path cannot resolve outside the configured artifact directory");
2027
+ }
2028
+ const outputPath = path.join(actualParent, path.basename(target));
2029
+ try {
2030
+ if ((await lstat(outputPath)).isSymbolicLink()) throw new JevBrowserError("report_path cannot replace a symbolic link");
2031
+ } catch (error) {
2032
+ if (error instanceof JevBrowserError) throw error;
2033
+ if (error?.code !== "ENOENT") throw error;
2034
+ }
2035
+ return outputPath;
2036
+ }
2037
+
2038
+ function xmlEscape(value) {
2039
+ return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" })[char]);
2040
+ }
2041
+
2042
+ function reportContent(result, extension) {
2043
+ if (extension === ".xml") {
2044
+ const steps = result.steps || [];
2045
+ const failures = steps.filter(({ status }) => status === "failed");
2046
+ const cases = steps.map((step) => {
2047
+ const name = `${step.step || "?"} ${step.action || "step"}`;
2048
+ const evidence = [
2049
+ step.target ? `target: ${step.target}` : "",
2050
+ step.resolved_target ? `resolved_target: ${JSON.stringify(step.resolved_target)}` : "",
2051
+ step.method ? `network: ${step.method} ${step.url || ""} -> ${step.response_status ?? ""}${step.message ? ` (${step.message})` : ""}` : "",
2052
+ step.screenshot_path ? `screenshot: ${step.screenshot_path}` : "",
2053
+ step.mutating === true ? "mutating: true" : "",
2054
+ ].filter(Boolean).join("\n");
2055
+ const output = evidence ? `<system-out>${xmlEscape(evidence)}</system-out>` : "";
2056
+ const failure = step.status === "failed" ? `<failure message="${xmlEscape(step.error || "step failed")}">${xmlEscape(step.error || "step failed")}</failure>` : "";
2057
+ return `<testcase name="${xmlEscape(name)}" time="${Math.max(0, Number(step.duration_ms || 0) / 1000).toFixed(3)}">${failure}${output}</testcase>`;
2058
+ }).join("");
2059
+ const suiteEvidence = [
2060
+ `server_version: ${result.server_version || "unknown"}`,
2061
+ `status: ${result.status || "unknown"}`,
2062
+ result.navigation_error ? `navigation_error: ${result.navigation_error}` : "",
2063
+ ...(result.network_failures || []).map((entry) => `network_failure: ${entry.method || ""} ${entry.url || ""} -> ${entry.status ?? entry.failure ?? "error"}${entry.message ? ` (${entry.message})` : ""}`),
2064
+ ].filter(Boolean).join("\n");
2065
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<testsuite name="jev-browser" tests="${steps.length}" failures="${failures.length}" time="${(Number(result.timings_ms?.total_ms || 0) / 1000).toFixed(3)}"><system-out>${xmlEscape(suiteEvidence)}</system-out>${cases}</testsuite>\n`;
2066
+ }
2067
+ const rows = (result.steps || []).map((step) => {
2068
+ const details = [step.target, step.resolved_target ? JSON.stringify(step.resolved_target) : "", step.screenshot_path ? `screenshot: ${step.screenshot_path}` : ""]
2069
+ .filter(Boolean).join("; ");
2070
+ return `| ${step.step || ""} | ${step.action || ""} | ${step.status || "passed"} | ${step.duration_ms ?? ""} | ${String(details).replace(/\|/g, "\\|")} |`;
2071
+ });
2072
+ return [
2073
+ "# Jev Browser flow report", "", `- Status: ${result.status}`, `- Server: ${result.server_version || "unknown"}`,
2074
+ `- Selected plan: ${result.selected_plan || "none"}`, `- Total: ${result.timings_ms?.total_ms ?? "unknown"} ms`,
2075
+ `- Navigation error: ${result.navigation_error || "none"}`, "", "## Steps", "",
2076
+ "| # | Action | Status | Duration (ms) | Evidence |", "| -: | --- | --- | ---: | --- |", ...rows,
2077
+ "", "## Network", "", ...(result.steps || []).filter(({ action }) => action === "assert_network")
2078
+ .map(({ step, method, response_status: status, url, message }) => `- Step ${step}: ${method} ${url} → ${status}${message ? ` — ${message}` : ""}`),
2079
+ ...(result.steps || []).some(({ action }) => action === "assert_network") ? [] : ["No network assertions."],
2080
+ "", "## Errors", "", ...(result.network_failures || []).map((failure) => `- ${failure.method || ""} ${failure.url || ""} — ${failure.status ?? failure.failure ?? "error"}${failure.message ? `: ${failure.message}` : ""}`),
2081
+ ...(result.network_failures || []).length ? [] : ["No network failures."], "",
2082
+ ].join("\n");
2083
+ }
2084
+
2085
+ async function writeFlowReport(result, reportPath, settings) {
2086
+ const outputPath = await safeReportPath(reportPath, settings);
2087
+ await writeFile(outputPath, reportContent(result, path.extname(outputPath).toLowerCase()), { encoding: "utf8", flag: "w" });
2088
+ return outputPath;
2089
+ }
1554
2090
 
1555
2091
  async function redactObject(value, secrets) {
1556
2092
  if (typeof value === "string") return redact(value, secrets);
@@ -1565,15 +2101,16 @@ function hasAssertions(steps) {
1565
2101
  return steps.some(({ action }) => action.startsWith("assert_"));
1566
2102
  }
1567
2103
 
1568
- export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, candidatePlans, options: rawOptions, settings, browserPool, fetchImpl = fetch }) {
1569
- if (typeof flow !== "string" || typeof expectedOutcome !== "string") throw new JevBrowserError("flow and expected_outcome must be text");
1570
- const requestedFlow = flow.trim();
1571
- const outcome = expectedOutcome.trim();
2104
+ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, candidatePlans, params: rawParams, options: rawOptions, settings, browserPool, fetchImpl = fetch, serverVersion }) {
2105
+ if (typeof flow !== "string" || typeof expectedOutcome !== "string") throw new JevBrowserError("flow and expected_outcome must be text");
2106
+ const variables = validateParams(rawParams, settings);
2107
+ const requestedFlow = replaceKnownParams(flow, variables).trim();
2108
+ const outcome = replaceKnownParams(expectedOutcome, variables).trim();
1572
2109
  if (!requestedFlow || !outcome) throw new JevBrowserError("flow and expected_outcome must not be empty");
1573
2110
  if (requestedFlow.length > settings.jev.maxFlowChars) throw new JevBrowserError("flow exceeds the configured character limit");
1574
2111
  if (outcome.length > settings.jev.maxActionDescriptionChars) throw new JevBrowserError("expected_outcome exceeds the configured character limit");
1575
2112
  const url = validateUrl(initialUrl);
1576
- const plans = validateCandidatePlans(candidatePlans, settings);
2113
+ const plans = validateCandidatePlans(replaceKnownParams(candidatePlans, variables), settings);
1577
2114
  const options = resolveFlowOptions(rawOptions, settings);
1578
2115
  const secrets = [...planSecrets(plans), settings.env?.[settings.jev.credentialEnv]].filter(Boolean)
1579
2116
  .sort((left, right) => right.length - left.length);
@@ -1583,7 +2120,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
1583
2120
  const page = browser.page;
1584
2121
  const context = browser.context;
1585
2122
  const capture = createDiagnostics(page, options, settings, secrets);
1586
- const result = {
2123
+ const result = {
2124
+ ...(serverVersion ? { server_version: serverVersion } : {}),
1587
2125
  status: "failed",
1588
2126
  expected_outcome_visible: false,
1589
2127
  acceptance_criteria_met: false,
@@ -1593,6 +2131,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
1593
2131
  downloaded_files: [],
1594
2132
  accessibility_audits: [],
1595
2133
  unnamed_controls: [],
2134
+ unnamed_controls_initial: [],
2135
+ unnamed_controls_final: [],
1596
2136
  warnings: [],
1597
2137
  final_snapshot: "",
1598
2138
  };
@@ -1602,7 +2142,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
1602
2142
  let unroute;
1603
2143
  let tracingStarted = false;
1604
2144
  let tracePath;
1605
- const artifactErrors = [];
2145
+ const artifactErrors = [];
2146
+ let navigationError;
1606
2147
  try {
1607
2148
  page.setDefaultTimeout(settings.browser.actionTimeoutMs);
1608
2149
  context.setDefaultTimeout(settings.browser.actionTimeoutMs);
@@ -1618,6 +2159,11 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
1618
2159
 
1619
2160
  let reusedPage = false;
1620
2161
  if (options.reusePage) {
2162
+ navigationError = browserPool.getNavigationError?.();
2163
+ if (navigationError) {
2164
+ result.navigation_error = navigationError;
2165
+ throw new JevBrowserError(`reuse_page cannot continue because the previous navigation failed with ${navigationError}; run once with reuse_page: false to navigate again`);
2166
+ }
1621
2167
  const currentUrl = page.url();
1622
2168
  if (currentUrl && currentUrl !== "about:blank") {
1623
2169
  let currentOrigin;
@@ -1634,7 +2180,14 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
1634
2180
  }
1635
2181
  if (!reusedPage) {
1636
2182
  const navigationStarted = performance.now();
1637
- await page.goto(url, { waitUntil: "domcontentloaded", timeout: settings.browser.actionTimeoutMs * 4 });
2183
+ try {
2184
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: settings.browser.actionTimeoutMs * 4 });
2185
+ browserPool.setNavigationError?.(null);
2186
+ } catch (error) {
2187
+ navigationError = String(error?.message || "").match(/\b(?:net::)?(ERR_[A-Z0-9_]+)\b/)?.[1] || "NAVIGATION_FAILED";
2188
+ if (navigationError) browserPool.setNavigationError?.(navigationError);
2189
+ throw error;
2190
+ }
1638
2191
  timings.navigationMs = performance.now() - navigationStarted;
1639
2192
  }
1640
2193
  const snapshotStarted = performance.now();
@@ -1643,6 +2196,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
1643
2196
  const enriched = await enrichSnapshot(page, readiness.snapshot, settings, secrets, options.snapshotScope, options.snapshotIncludeHidden);
1644
2197
  result.final_snapshot = enriched.snapshot;
1645
2198
  result.unnamed_controls = enriched.unnamedControls;
2199
+ result.unnamed_controls_initial = enriched.unnamedControls;
2200
+ result.unnamed_controls_final = enriched.unnamedControls;
1646
2201
  for (const control of enriched.unnamedControls) {
1647
2202
  addDiagnostic(
1648
2203
  result.warnings,
@@ -1651,6 +2206,7 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
1651
2206
  );
1652
2207
  }
1653
2208
  timings.readyMs = readiness.readyMs;
2209
+ browserPool.setNavigationError?.(null);
1654
2210
  } catch (error) {
1655
2211
  timings.readyMs = error.readyMs || performance.now() - snapshotStarted;
1656
2212
  throw error;
@@ -1704,26 +2260,47 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
1704
2260
  const trace = [];
1705
2261
  let failure = "";
1706
2262
  let failedStep;
2263
+ let dryRunStop;
1707
2264
  let snapshot = result.final_snapshot;
1708
2265
  let networkMarker = networkTracker.mark();
1709
2266
  const planStarted = performance.now();
1710
2267
  const runAllSteps = plan.steps.some(({ action, expectDownload }) => action.startsWith("assert_")
1711
2268
  || ["upload_file", "select_option", "hover", "like_comment", "unlike_comment", "audit_accessibility"].includes(action)
1712
2269
  || expectDownload === true);
1713
- for (const [index, step] of plan.steps.entries()) {
2270
+ for (const [index, templateStep] of plan.steps.entries()) {
2271
+ const stepStarted = performance.now();
2272
+ let step = templateStep;
1714
2273
  try {
2274
+ step = interpolateStep(templateStep, variables);
2275
+ if (options.dryRun && step.mutating === true) {
2276
+ dryRunStop = { step: String(index + 1), action: step.action };
2277
+ break;
2278
+ }
1715
2279
  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);
1716
2280
  if (step.fragileSelector) addDiagnostic(result.warnings, `step ${index + 1} uses an explicit CSS/XPath selector as a last resort`, settings.jev.maxDiagnosticItems);
1717
2281
  const actionMarker = networkTracker.mark();
1718
2282
  const traceStep = await executeStep(page, step, settings, snapshot, secrets, downloadLedger, {
1719
2283
  networkTracker,
1720
2284
  networkMarker,
2285
+ variables,
1721
2286
  });
1722
2287
  if (!["wait_for_text", "wait_for_condition", "assert_text", "assert_value", "assert_visible", "assert_hidden", "audit_accessibility"].includes(step.action)) {
1723
2288
  networkMarker = actionMarker;
1724
2289
  }
1725
- traceStep.step = String(index + 1);
1726
- trace.push(traceStep);
2290
+ traceStep.step = String(index + 1);
2291
+ traceStep.status = "passed";
2292
+ if (step.mutating === true) traceStep.mutating = true;
2293
+ if (step.screenshot === true) {
2294
+ try {
2295
+ traceStep.screenshot_path = await artifactPath(settings, "png");
2296
+ await page.screenshot({ path: traceStep.screenshot_path, fullPage: false });
2297
+ } catch {
2298
+ delete traceStep.screenshot_path;
2299
+ artifactErrors.push(`step ${index + 1} screenshot could not be saved`);
2300
+ }
2301
+ }
2302
+ traceStep.duration_ms = Math.round((performance.now() - stepStarted) * 10) / 10;
2303
+ trace.push(traceStep);
1727
2304
  if (traceStep.downloaded_file) result.downloaded_files.push(traceStep.downloaded_file);
1728
2305
  if (traceStep.accessibility_audit) {
1729
2306
  result.accessibility_audits.push(traceStep.accessibility_audit);
@@ -1747,6 +2324,15 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
1747
2324
  failure = error instanceof JevBrowserError
1748
2325
  ? error.message
1749
2326
  : `Playwright action failed (${error?.name || "BrowserError"})`;
2327
+ const failedTraceStep = {
2328
+ step: String(index + 1),
2329
+ action: step?.action || templateStep.action,
2330
+ status: "failed",
2331
+ duration_ms: Math.round((performance.now() - stepStarted) * 10) / 10,
2332
+ error: sanitizeDiagnosticText(failure, secrets, settings.jev.maxDiagnosticChars),
2333
+ ...(step?.mutating === true ? { mutating: true } : {}),
2334
+ };
2335
+ trace.push(failedTraceStep);
1750
2336
  failedStep = failedStepDetails(step, index, error, settings, secrets);
1751
2337
  break;
1752
2338
  }
@@ -1772,9 +2358,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
1772
2358
  options.snapshotIncludeHidden,
1773
2359
  );
1774
2360
  snapshot = finalDetails.snapshot;
1775
- result.unnamed_controls = [...result.unnamed_controls, ...finalDetails.unnamedControls]
1776
- .filter((control, index, all) => all.findIndex((candidate) => candidate.role === control.role
1777
- && candidate.html === control.html) === index);
2361
+ result.unnamed_controls_final = finalDetails.unnamedControls;
2362
+ result.unnamed_controls = finalDetails.unnamedControls;
1778
2363
  timings.browserPlanMs = performance.now() - planStarted;
1779
2364
  const visible = containsExpectedOutcome(snapshot, outcome);
1780
2365
  const assertionCount = plan.steps.filter(({ action }) => action.startsWith("assert_")).length;
@@ -1786,12 +2371,17 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
1786
2371
  const expectedDownloadCount = plan.steps.filter(({ action, expectDownload }) => action === "click" && expectDownload).length;
1787
2372
  const downloadsPassed = expectedDownloadCount > 0 && !failure
1788
2373
  && result.downloaded_files.length === expectedDownloadCount && trace.length === plan.steps.length;
1789
- result.status = failure ? "failed" : visible || assertionsPassed || auditsPassed || downloadsPassed ? "passed" : "incomplete";
1790
- result.expected_outcome_visible = visible;
1791
- result.acceptance_criteria_met = !failure && (visible || assertionsPassed || auditsPassed || downloadsPassed);
2374
+ result.status = failure ? "failed" : dryRunStop ? "dry_run" : visible || assertionsPassed || auditsPassed || downloadsPassed ? "passed" : "incomplete";
2375
+ result.expected_outcome_visible = visible;
2376
+ result.acceptance_criteria_met = !failure && !dryRunStop && (visible || assertionsPassed || auditsPassed || downloadsPassed);
1792
2377
  result.selected_plan = planId;
1793
2378
  result.steps_executed = trace.length;
1794
2379
  result.steps = trace;
2380
+ result.mutating_steps = trace.filter(({ mutating }) => mutating === true).map(({ step, action, duration_ms, status }) => ({ step, action, duration_ms, status }));
2381
+ if (dryRunStop) {
2382
+ result.dry_run_stopped_before_step = dryRunStop;
2383
+ result.reason = `dry_run stopped before mutating step ${dryRunStop.step} (${dryRunStop.action})`;
2384
+ }
1795
2385
  result.final_snapshot = snapshot;
1796
2386
  if (failure) {
1797
2387
  result.reason = failure;
@@ -1805,11 +2395,13 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
1805
2395
  }
1806
2396
  }
1807
2397
  } catch (error) {
1808
- result.status = "failed";
1809
- result.acceptance_criteria_met = false;
2398
+ result.status = navigationError ? "environment_error" : "failed";
2399
+ result.acceptance_criteria_met = false;
2400
+ if (navigationError) result.navigation_error = navigationError;
1810
2401
  result.reason = error instanceof JevBrowserError
1811
2402
  ? error.message
1812
- : `Playwright flow failed (${error?.name || "BrowserError"})`;
2403
+ : navigationError ? `Navigation failed with ${navigationError}; verify that the application server is running and reachable`
2404
+ : `Playwright flow failed (${error?.name || "BrowserError"})`;
1813
2405
  try {
1814
2406
  const snapshot = await snapshotPage(page, settings, secrets, options.snapshotScope, {
1815
2407
  allowEmpty: true,
@@ -1825,6 +2417,7 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
1825
2417
  options.snapshotIncludeHidden,
1826
2418
  );
1827
2419
  result.final_snapshot = enriched.snapshot;
2420
+ result.unnamed_controls_final = enriched.unnamedControls;
1828
2421
  result.unnamed_controls = enriched.unnamedControls;
1829
2422
  } else {
1830
2423
  result.final_snapshot = snapshot;
@@ -1865,11 +2458,20 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
1865
2458
  }
1866
2459
  if (tracePath) result.trace_path = tracePath;
1867
2460
  if (artifactErrors.length) result.artifact_errors = artifactErrors;
1868
- result.console_errors = capture.diagnostics.console_errors;
1869
- result.network_failures = capture.diagnostics.network_failures;
2461
+ result.console_errors = capture.diagnostics.console_errors;
2462
+ result.network_failures = capture.diagnostics.network_failures;
2463
+ result.warnings.push(...capture.diagnostics.warnings);
1870
2464
  result.timings_ms = flowTimings(started, browserSessionMs, timings.navigationMs, timings.initialSnapshotMs, timings.readyMs, timings.decisionMs, timings.browserPlanMs);
1871
- return redactObject(result, secrets);
1872
- }
2465
+ const safeResult = await redactObject(result, secrets);
2466
+ if (options.reportPath) {
2467
+ try {
2468
+ safeResult.report_path = await writeFlowReport(safeResult, options.reportPath, settings);
2469
+ } catch (error) {
2470
+ safeResult.artifact_errors = [...(safeResult.artifact_errors || []), error instanceof JevBrowserError ? error.message : "flow report could not be saved"];
2471
+ }
2472
+ }
2473
+ return safeResult;
2474
+ }
1873
2475
 
1874
2476
  export async function chooseNextAction({ flow, pageSnapshot, actions, completedSteps = [], settings, fetchImpl = fetch }) {
1875
2477
  const { request, criteria } = buildChoicePayload({ flow, pageSnapshot, actions, completedSteps }, settings.jev);