@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.
- package/README.md +100 -15
- package/docs/jev-browser-mcp.md +100 -15
- package/mcp_servers/jev-browser-npm/src/flow.mjs +411 -46
- 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,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
|
-
|
|
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)) {
|
|
@@ -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:
|
|
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)
|
|
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.
|
|
1141
|
-
? `within
|
|
1142
|
-
:
|
|
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,
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
|
|
1803
|
-
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();
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
: `
|
|
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
|
-
|
|
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);
|