@diegosouzacdv/jev-browser-mcp 0.4.2 → 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,6 +140,9 @@ 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"] },
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"] },
105
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"] },
@@ -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)) {
@@ -246,6 +297,39 @@ export function validateCandidatePlans(candidatePlans, settings) {
246
297
  }
247
298
  }
248
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
+ }
249
333
  if (action === "wait_for_condition") {
250
334
  if (!new Set(["network_idle", "hidden", "text_hidden"]).has(rawStep.condition)) {
251
335
  throw new JevBrowserError("wait_for_condition condition must be network_idle, hidden, or text_hidden");
@@ -360,14 +444,14 @@ function normalizeStepAliases(rawStep) {
360
444
  return step;
361
445
  }
362
446
 
363
- function resolveFlowOptions(rawOptions, settings) {
447
+ function resolveFlowOptions(rawOptions, settings) {
364
448
  if (rawOptions === undefined) rawOptions = {};
365
449
  if (!isRecord(rawOptions) || Object.keys(rawOptions).some((key) => !FLOW_OPTION_KEYS.has(key))) {
366
450
  throw new JevBrowserError("options contains unsupported flow settings");
367
451
  }
368
452
  const booleanOptions = [
369
453
  "block_trackers", "capture_console_errors", "capture_network_error_bodies", "capture_network_errors",
370
- "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",
371
455
  "stop_on_expected", "trace_on_failure",
372
456
  ];
373
457
  for (const key of booleanOptions) {
@@ -391,16 +475,27 @@ function resolveFlowOptions(rawOptions, settings) {
391
475
  const readyText = rawOptions.ready_text === undefined
392
476
  ? ""
393
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
+ }
394
487
  return Object.freeze({
395
488
  blockTrackers: rawOptions.block_trackers ?? defaults.blockTrackers,
396
489
  captureConsoleErrors: rawOptions.capture_console_errors ?? defaults.captureConsoleErrors,
397
490
  captureNetworkErrorBodies: rawOptions.capture_network_error_bodies ?? defaults.captureNetworkErrorBodies,
398
491
  captureNetworkErrors: rawOptions.capture_network_errors ?? defaults.captureNetworkErrors,
492
+ dryRun: rawOptions.dry_run ?? false,
399
493
  fastPath: rawOptions.fast_path ?? defaults.fastPath,
400
494
  readyTimeoutMs: Math.ceil(readyTimeoutSeconds * 1000),
401
495
  readyNetworkIdle: rawOptions.ready_network_idle ?? defaults.readyNetworkIdle,
402
496
  readyStableMs,
403
497
  readyText,
498
+ reportPath,
404
499
  reusePage: rawOptions.reuse_page ?? defaults.reusePage,
405
500
  screenshotOnFailure: rawOptions.screenshot_on_failure ?? defaults.screenshotOnFailure,
406
501
  snapshotIncludeHidden: rawOptions.snapshot_include_hidden ?? defaults.snapshotIncludeHidden,
@@ -584,7 +679,7 @@ async function collectUnnamedControls(root, roles, settings, secrets) {
584
679
  const unnamedControls = [];
585
680
  for (const role of roles) {
586
681
  if (unnamedControls.length >= settings.jev.maxDiagnosticItems) break;
587
- const locator = root.getByRole(role, { name: "", exact: true });
682
+ const locator = root.getByRole(role, { name: UNNAMED_ACCESSIBLE_NAME });
588
683
  const controls = await locator.evaluateAll((elements, maxChars) => {
589
684
  const textOf = (element) => (element?.innerText || element?.textContent || "").replace(/\s+/g, " ").trim();
590
685
  const nearestLabel = (element) => {
@@ -760,25 +855,35 @@ function createNetworkTracker(page, settings) {
760
855
  const history = [];
761
856
  let sequence = 0;
762
857
  const record = (type, request) => {
763
- 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 };
764
859
  if (type === "request") active.set(request, entry);
765
- else active.delete(request);
860
+ else if (type === "finished" || type === "failed") active.delete(request);
766
861
  history.push(entry);
767
862
  const historyLimit = settings.browser.maxFlowSteps * 8;
768
863
  if (history.length > historyLimit) history.splice(0, history.length - historyLimit);
864
+ return entry;
769
865
  };
770
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
+ };
771
872
  const onFinished = (request) => record("finished", request);
772
873
  const onFailed = (request) => record("failed", request);
773
874
  page.on("request", onRequest);
875
+ page.on("response", onResponse);
774
876
  page.on("requestfinished", onFinished);
775
877
  page.on("requestfailed", onFailed);
776
878
  return {
777
879
  mark: () => sequence,
778
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),
779
883
  activeFor: (urlContains) => [...active.values()].filter((entry) => entry.url.includes(urlContains)),
780
884
  dispose: () => {
781
885
  page.off("request", onRequest);
886
+ page.off("response", onResponse);
782
887
  page.off("requestfinished", onFinished);
783
888
  page.off("requestfailed", onFailed);
784
889
  active.clear();
@@ -807,6 +912,60 @@ async function waitForFilteredNetworkIdle(tracker, urlContains, marker, timeout,
807
912
  throw new JevBrowserError(`requests matching url_contains ${JSON.stringify(urlContains)} did not become idle within ${timeout}ms`);
808
913
  }
809
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
+
810
969
  async function waitForLocatorHidden(locator, timeout, description, settings, index) {
811
970
  const deadline = performance.now() + timeout;
812
971
  while (true) {
@@ -1000,6 +1159,7 @@ async function waitForTextOrFailure(root, step, timeout, settings, secrets) {
1000
1159
  const failure = root.getByRole(step.failOn.role, roleOptions(step.failOn));
1001
1160
  const deadline = performance.now() + timeout;
1002
1161
  while (true) {
1162
+ if (await firstVisibleMatch(expected)) return;
1003
1163
  const failedMatch = await firstVisibleMatch(failure);
1004
1164
  if (failedMatch) {
1005
1165
  const visibleText = await failedMatch.innerText().catch(() => "");
@@ -1009,7 +1169,6 @@ async function waitForTextOrFailure(root, step, timeout, settings, secrets) {
1009
1169
  `wait_for_text ${JSON.stringify(redact(step.text, secrets))} stopped because ${failureTarget} appeared: ${JSON.stringify(message)}`,
1010
1170
  );
1011
1171
  }
1012
- if (await firstVisibleMatch(expected)) return;
1013
1172
  const left = deadline - performance.now();
1014
1173
  if (left <= 0) {
1015
1174
  throw new JevBrowserError(`text ${JSON.stringify(redact(step.text, secrets))} has no visible match within ${timeout}ms; ${step.failOn.role} did not appear`);
@@ -1046,6 +1205,9 @@ async function scopedRoot(root, within, timeout, settings) {
1046
1205
  if (within.rowContaining) {
1047
1206
  locator = root.getByRole("row").filter({ hasText: new RegExp(escapeRegex(within.rowContaining), "iu") });
1048
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)}`;
1049
1211
  } else {
1050
1212
  const options = within.name === undefined ? {} : { name: within.name, exact: true };
1051
1213
  locator = root.getByRole(within.role, options);
@@ -1057,6 +1219,7 @@ async function scopedRoot(root, within, timeout, settings) {
1057
1219
  }
1058
1220
 
1059
1221
  function roleOptions(step) {
1222
+ if (step.name === "") return { name: UNNAMED_ACCESSIBLE_NAME };
1060
1223
  return step.name === undefined ? {} : { name: step.name, exact: true };
1061
1224
  }
1062
1225
 
@@ -1094,7 +1257,12 @@ async function targetLocator(root, step, timeout, settings) {
1094
1257
  locator = targetRoot.locator(step.selector);
1095
1258
  } else if (field === "label") {
1096
1259
  locator = targetRoot.getByLabel(step.label, { exact: true });
1097
- 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
+ }
1098
1266
  } else if (field === "placeholder") {
1099
1267
  locator = targetRoot.getByPlaceholder(step.placeholder, { exact: true });
1100
1268
  } else if (field === "title") {
@@ -1137,9 +1305,11 @@ function describeTarget(step, secrets) {
1137
1305
  }
1138
1306
  if (step.near) parts.push(`near text ${JSON.stringify(redact(step.near.text, secrets))}`);
1139
1307
  if (step.within) {
1140
- parts.push(step.within.rowContaining !== undefined
1141
- ? `within row_containing ${JSON.stringify(redact(step.within.rowContaining, secrets))}`
1142
- : `within ${step.within.role}${step.within.name === undefined ? "" : ` ${JSON.stringify(redact(step.within.name, secrets))}`}`);
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))}`}`);
1143
1313
  }
1144
1314
  if (step.index !== undefined) parts.push(`index ${step.index}`);
1145
1315
  return parts.length ? parts.join(" + ") : "focused element";
@@ -1149,7 +1319,7 @@ async function describeResolvedTarget(locator, step, secrets, settings) {
1149
1319
  if (typeof locator?.evaluate !== "function") return undefined;
1150
1320
  try {
1151
1321
  const includeValue = step.action === "type" && step.sensitive === false;
1152
- const resolved = await locator.evaluate((element, includeFieldValue) => {
1322
+ const resolved = await locator.evaluate((element, options) => {
1153
1323
  const textOf = (node) => (node?.innerText || node?.textContent || "").replace(/\s+/g, " ").trim();
1154
1324
  const referenced = (element.getAttribute("aria-labelledby") || "").trim().split(/\s+/).filter(Boolean)
1155
1325
  .map((id) => textOf(element.ownerDocument.getElementById(id))).filter(Boolean).join(" ");
@@ -1170,13 +1340,20 @@ async function describeResolvedTarget(locator, step, secrets, settings) {
1170
1340
  role,
1171
1341
  accessible_name: element.getAttribute("aria-label") || referenced || labels || controlPlaceholder
1172
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") || "",
1173
1345
  };
1174
- if (includeFieldValue && "value" in element) result.value = String(element.value ?? "");
1346
+ if (options.includeFieldValue && "value" in element) result.value = String(element.value ?? "");
1175
1347
  return result;
1176
- }, includeValue);
1348
+ }, { includeFieldValue: includeValue, matchedTitle: step.title || "" });
1177
1349
  if (step.role) resolved.role = step.role;
1350
+ if (step.matchStrategy) resolved.match_strategy = step.matchStrategy;
1178
1351
  resolved.id = redact(resolved.id, secrets);
1179
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;
1180
1357
  if (Object.hasOwn(resolved, "value")) {
1181
1358
  resolved.value = redact(resolved.value.slice(0, settings.browser.maxTextEntryChars), secrets);
1182
1359
  }
@@ -1409,6 +1586,56 @@ async function saveDownload(download, settings, ledger) {
1409
1586
  async function executeStep(page, step, settings, snapshot, secrets, downloadLedger, execution = {}) {
1410
1587
  const root = await stepRoot(page, step.frame);
1411
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
+ }
1412
1639
  if (step.action === "wait_for_text") {
1413
1640
  const locator = root.getByText(step.text, { exact: false });
1414
1641
  if (step.failOn) await waitForTextOrFailure(root, step, timeout, settings, secrets);
@@ -1779,10 +2006,87 @@ async function installTrackerBlocking(context, settings, enabled) {
1779
2006
  return async () => context.unroute("**/*", handler);
1780
2007
  }
1781
2008
 
1782
- async function artifactPath(settings, extension) {
2009
+ async function artifactPath(settings, extension) {
1783
2010
  await mkdir(settings.browser.artifactDirectory, { recursive: true });
1784
2011
  return path.join(settings.browser.artifactDirectory, `jev-browser-${Date.now()}-${randomUUID()}.${extension}`);
1785
- }
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
+ }
1786
2090
 
1787
2091
  async function redactObject(value, secrets) {
1788
2092
  if (typeof value === "string") return redact(value, secrets);
@@ -1797,15 +2101,16 @@ function hasAssertions(steps) {
1797
2101
  return steps.some(({ action }) => action.startsWith("assert_"));
1798
2102
  }
1799
2103
 
1800
- export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, candidatePlans, options: rawOptions, settings, browserPool, fetchImpl = fetch }) {
1801
- if (typeof flow !== "string" || typeof expectedOutcome !== "string") throw new JevBrowserError("flow and expected_outcome must be text");
1802
- const requestedFlow = flow.trim();
1803
- 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();
1804
2109
  if (!requestedFlow || !outcome) throw new JevBrowserError("flow and expected_outcome must not be empty");
1805
2110
  if (requestedFlow.length > settings.jev.maxFlowChars) throw new JevBrowserError("flow exceeds the configured character limit");
1806
2111
  if (outcome.length > settings.jev.maxActionDescriptionChars) throw new JevBrowserError("expected_outcome exceeds the configured character limit");
1807
2112
  const url = validateUrl(initialUrl);
1808
- const plans = validateCandidatePlans(candidatePlans, settings);
2113
+ const plans = validateCandidatePlans(replaceKnownParams(candidatePlans, variables), settings);
1809
2114
  const options = resolveFlowOptions(rawOptions, settings);
1810
2115
  const secrets = [...planSecrets(plans), settings.env?.[settings.jev.credentialEnv]].filter(Boolean)
1811
2116
  .sort((left, right) => right.length - left.length);
@@ -1815,7 +2120,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
1815
2120
  const page = browser.page;
1816
2121
  const context = browser.context;
1817
2122
  const capture = createDiagnostics(page, options, settings, secrets);
1818
- const result = {
2123
+ const result = {
2124
+ ...(serverVersion ? { server_version: serverVersion } : {}),
1819
2125
  status: "failed",
1820
2126
  expected_outcome_visible: false,
1821
2127
  acceptance_criteria_met: false,
@@ -1836,7 +2142,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
1836
2142
  let unroute;
1837
2143
  let tracingStarted = false;
1838
2144
  let tracePath;
1839
- const artifactErrors = [];
2145
+ const artifactErrors = [];
2146
+ let navigationError;
1840
2147
  try {
1841
2148
  page.setDefaultTimeout(settings.browser.actionTimeoutMs);
1842
2149
  context.setDefaultTimeout(settings.browser.actionTimeoutMs);
@@ -1852,6 +2159,11 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
1852
2159
 
1853
2160
  let reusedPage = false;
1854
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
+ }
1855
2167
  const currentUrl = page.url();
1856
2168
  if (currentUrl && currentUrl !== "about:blank") {
1857
2169
  let currentOrigin;
@@ -1868,7 +2180,14 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
1868
2180
  }
1869
2181
  if (!reusedPage) {
1870
2182
  const navigationStarted = performance.now();
1871
- 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
+ }
1872
2191
  timings.navigationMs = performance.now() - navigationStarted;
1873
2192
  }
1874
2193
  const snapshotStarted = performance.now();
@@ -1887,6 +2206,7 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
1887
2206
  );
1888
2207
  }
1889
2208
  timings.readyMs = readiness.readyMs;
2209
+ browserPool.setNavigationError?.(null);
1890
2210
  } catch (error) {
1891
2211
  timings.readyMs = error.readyMs || performance.now() - snapshotStarted;
1892
2212
  throw error;
@@ -1940,26 +2260,47 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
1940
2260
  const trace = [];
1941
2261
  let failure = "";
1942
2262
  let failedStep;
2263
+ let dryRunStop;
1943
2264
  let snapshot = result.final_snapshot;
1944
2265
  let networkMarker = networkTracker.mark();
1945
2266
  const planStarted = performance.now();
1946
2267
  const runAllSteps = plan.steps.some(({ action, expectDownload }) => action.startsWith("assert_")
1947
2268
  || ["upload_file", "select_option", "hover", "like_comment", "unlike_comment", "audit_accessibility"].includes(action)
1948
2269
  || expectDownload === true);
1949
- 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;
1950
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
+ }
1951
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);
1952
2280
  if (step.fragileSelector) addDiagnostic(result.warnings, `step ${index + 1} uses an explicit CSS/XPath selector as a last resort`, settings.jev.maxDiagnosticItems);
1953
2281
  const actionMarker = networkTracker.mark();
1954
2282
  const traceStep = await executeStep(page, step, settings, snapshot, secrets, downloadLedger, {
1955
2283
  networkTracker,
1956
2284
  networkMarker,
2285
+ variables,
1957
2286
  });
1958
2287
  if (!["wait_for_text", "wait_for_condition", "assert_text", "assert_value", "assert_visible", "assert_hidden", "audit_accessibility"].includes(step.action)) {
1959
2288
  networkMarker = actionMarker;
1960
2289
  }
1961
- traceStep.step = String(index + 1);
1962
- 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);
1963
2304
  if (traceStep.downloaded_file) result.downloaded_files.push(traceStep.downloaded_file);
1964
2305
  if (traceStep.accessibility_audit) {
1965
2306
  result.accessibility_audits.push(traceStep.accessibility_audit);
@@ -1983,6 +2324,15 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
1983
2324
  failure = error instanceof JevBrowserError
1984
2325
  ? error.message
1985
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);
1986
2336
  failedStep = failedStepDetails(step, index, error, settings, secrets);
1987
2337
  break;
1988
2338
  }
@@ -2021,12 +2371,17 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2021
2371
  const expectedDownloadCount = plan.steps.filter(({ action, expectDownload }) => action === "click" && expectDownload).length;
2022
2372
  const downloadsPassed = expectedDownloadCount > 0 && !failure
2023
2373
  && result.downloaded_files.length === expectedDownloadCount && trace.length === plan.steps.length;
2024
- result.status = failure ? "failed" : visible || assertionsPassed || auditsPassed || downloadsPassed ? "passed" : "incomplete";
2025
- result.expected_outcome_visible = visible;
2026
- 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);
2027
2377
  result.selected_plan = planId;
2028
2378
  result.steps_executed = trace.length;
2029
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
+ }
2030
2385
  result.final_snapshot = snapshot;
2031
2386
  if (failure) {
2032
2387
  result.reason = failure;
@@ -2040,11 +2395,13 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2040
2395
  }
2041
2396
  }
2042
2397
  } catch (error) {
2043
- result.status = "failed";
2044
- 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;
2045
2401
  result.reason = error instanceof JevBrowserError
2046
2402
  ? error.message
2047
- : `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"})`;
2048
2405
  try {
2049
2406
  const snapshot = await snapshotPage(page, settings, secrets, options.snapshotScope, {
2050
2407
  allowEmpty: true,
@@ -2105,8 +2462,16 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
2105
2462
  result.network_failures = capture.diagnostics.network_failures;
2106
2463
  result.warnings.push(...capture.diagnostics.warnings);
2107
2464
  result.timings_ms = flowTimings(started, browserSessionMs, timings.navigationMs, timings.initialSnapshotMs, timings.readyMs, timings.decisionMs, timings.browserPlanMs);
2108
- return redactObject(result, secrets);
2109
- }
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
+ }
2110
2475
 
2111
2476
  export async function chooseNextAction({ flow, pageSnapshot, actions, completedSteps = [], settings, fetchImpl = fetch }) {
2112
2477
  const { request, criteria } = buildChoicePayload({ flow, pageSnapshot, actions, completedSteps }, settings.jev);