@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.
- package/README.md +113 -12
- package/docs/jev-browser-mcp.md +113 -12
- package/mcp_servers/jev-browser-npm/src/flow.mjs +715 -113
- package/mcp_servers/jev-browser-npm/src/server.mjs +94 -28
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
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
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
.
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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)
|
|
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
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
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
|
|
1257
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
1735
|
+
const resolvedTarget = await describeResolvedTarget(control, step, secrets, settings);
|
|
1736
|
+
if (!step.expectDownload) {
|
|
1331
1737
|
await control.click({ timeout });
|
|
1332
|
-
return { action: step.action,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
1369
|
-
}
|
|
1370
|
-
if (step.action === "hover") {
|
|
1371
|
-
await control
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
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 {
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
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 {
|
|
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
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
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)
|
|
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")
|
|
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
|
-
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
|
|
1571
|
-
const
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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.
|
|
1776
|
-
|
|
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
|
-
: `
|
|
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
|
-
|
|
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);
|