@diegosouzacdv/jev-browser-mcp 0.6.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +176 -66
- package/config/ui-testing.json +20 -3
- package/docs/jev-browser-mcp.md +176 -66
- package/mcp_servers/jev-browser-npm/src/accessibility.mjs +18 -9
- package/mcp_servers/jev-browser-npm/src/config.mjs +109 -52
- package/mcp_servers/jev-browser-npm/src/flow.mjs +1181 -315
- package/mcp_servers/jev-browser-npm/src/jev-client.mjs +22 -9
- package/mcp_servers/jev-browser-npm/src/server.mjs +109 -30
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
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";
|
|
@@ -6,7 +6,8 @@ import { JevBrowserError } from "./config.mjs";
|
|
|
6
6
|
import { buildChoicePayload, COMPLETE_ACTION_ID, parseDecision, requestJev } from "./jev-client.mjs";
|
|
7
7
|
|
|
8
8
|
const PLAN_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,79}$/;
|
|
9
|
-
const CLICK_ROLES = new Set(["button", "link", "tab", "menuitem"]);
|
|
9
|
+
const CLICK_ROLES = new Set(["button", "link", "tab", "menuitem", "checkbox", "radio", "switch", "cell", "gridcell", "option"]);
|
|
10
|
+
const CHECK_ROLES = new Set(["checkbox", "radio", "switch"]);
|
|
10
11
|
const TEXT_ROLES = new Set(["textbox", "searchbox", "combobox"]);
|
|
11
12
|
const HOVER_ROLES = new Set(["button", "link", "tab", "menuitem", "row", "gridcell", "cell", "region", "group"]);
|
|
12
13
|
const DROPZONE_ROLES = new Set(["button", "region", "group"]);
|
|
@@ -15,22 +16,22 @@ const CONTAINER_ROLES = new Set([
|
|
|
15
16
|
"group", "list", "listbox", "listitem", "main", "menu", "menubar", "navigation",
|
|
16
17
|
"region", "row", "rowgroup", "table", "tablist", "tabpanel", "tree", "treegrid", "treeitem",
|
|
17
18
|
]);
|
|
18
|
-
const TARGET_SELECTION_ACTIONS = new Set([
|
|
19
|
-
"click", "type", "select_option", "hover", "press", "assert_text", "assert_value", "wait_for_value",
|
|
19
|
+
const TARGET_SELECTION_ACTIONS = new Set([
|
|
20
|
+
"click", "check", "uncheck", "type", "clear", "select_option", "hover", "press", "assert_text", "assert_value", "wait_for_value",
|
|
20
21
|
"wait_for_enabled", "assert_enabled", "assert_visible", "assert_hidden", "upload_file", "extract",
|
|
21
22
|
]);
|
|
22
23
|
const LOCATOR_FIELDS = ["label", "placeholder", "title", "text", "test_id", "selector"];
|
|
23
24
|
const TARGET_LOCATOR_FIELDS = [...LOCATOR_FIELDS, "target_text"];
|
|
24
25
|
const LOCATOR_STEP_KEYS = { label: "label", placeholder: "placeholder", title: "title", text: "targetText", test_id: "testId", selector: "selector", target_text: "typedTargetText" };
|
|
25
26
|
const ASSERT_ROLES = new Set([
|
|
26
|
-
"alert", "button", "cell", "checkbox", "combobox", "dialog", "gridcell", "heading",
|
|
27
|
+
"alert", "button", "cell", "checkbox", "combobox", "dialog", "gridcell", "heading",
|
|
27
28
|
"img", "link", "listitem", "main", "menuitem", "option", "paragraph", "progressbar",
|
|
28
|
-
"radio", "region", "row", "searchbox", "status", "tab", "textbox",
|
|
29
|
+
"radio", "region", "row", "searchbox", "status", "switch", "tab", "textbox",
|
|
29
30
|
]);
|
|
30
31
|
const UNNAMED_ACCESSIBLE_NAME = /^[\s\uE000-\uF8FF]*$/u;
|
|
31
32
|
const APPROVED_KEYS = new Set([
|
|
32
33
|
"PageDown", "PageUp", "Home", "End", "ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight",
|
|
33
|
-
"Enter", "Escape", "Tab", "Shift+Tab",
|
|
34
|
+
"Enter", "Escape", "Tab", "Shift+Tab", "Space", "Backspace", "Delete",
|
|
34
35
|
]);
|
|
35
36
|
const MIME_TYPES = new Map([
|
|
36
37
|
[".csv", "text/csv"], [".json", "application/json"], [".pdf", "application/pdf"],
|
|
@@ -38,12 +39,68 @@ const MIME_TYPES = new Map([
|
|
|
38
39
|
[".txt", "text/plain"], [".webp", "image/webp"],
|
|
39
40
|
]);
|
|
40
41
|
const FLOW_OPTION_KEYS = new Set([
|
|
41
|
-
"block_trackers", "capture_console_errors", "capture_network_error_bodies", "capture_network_errors",
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
42
|
+
"allow_mutations", "block_trackers", "capture_console_errors", "capture_network_error_bodies", "capture_network_errors",
|
|
43
|
+
"auto_angular_idle", "block_fonts", "busy_selectors", "confirmation_token", "dry_run", "fast_path", "local_only",
|
|
44
|
+
"login_text", "login_url_contains", "max_flow_steps", "ready_network_idle", "ready_stable_ms", "ready_text", "ready_timeout_seconds",
|
|
45
|
+
"report_path", "permissions", "fake_media", "viewport", "mobile", "device_scale_factor", "locale", "timezone_id",
|
|
46
|
+
"color_scheme", "geolocation", "screenshot_on_success", "record_video", "trace_on_success",
|
|
47
|
+
"continue_from_current_page", "reuse_page", "return_snapshot", "screenshot_on_failure", "snapshot_include_hidden", "snapshot_scope", "step_timeout_seconds", "stop_on_expected",
|
|
45
48
|
"trace_on_failure",
|
|
46
49
|
]);
|
|
50
|
+
const MUTATING_LABEL = /\b(?:save|submit|issue|emit|cancel|delete|remove|void|confirm|approve|pay|send|generate|create|update|finalize|yes|okay|salvar|gravar|emitir|cancelar|excluir|apagar|remover|estornar|confirmar|aprovar|pagar|enviar|gerar|criar|atualizar|finalizar|sim)\b/i;
|
|
51
|
+
|
|
52
|
+
export function createMutationAuthorizationStore() {
|
|
53
|
+
const tokens = new Map();
|
|
54
|
+
const prune = () => {
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
for (const [token, record] of tokens) if (record.expiresAt <= now) tokens.delete(token);
|
|
57
|
+
while (tokens.size > 1024) tokens.delete(tokens.keys().next().value);
|
|
58
|
+
};
|
|
59
|
+
return Object.freeze({
|
|
60
|
+
issue(record, ttlMs) {
|
|
61
|
+
prune();
|
|
62
|
+
const token = randomBytes(32).toString("base64url");
|
|
63
|
+
tokens.set(token, { ...record, expiresAt: Date.now() + ttlMs });
|
|
64
|
+
return token;
|
|
65
|
+
},
|
|
66
|
+
consume(token, fingerprint) {
|
|
67
|
+
prune();
|
|
68
|
+
const record = tokens.get(token);
|
|
69
|
+
if (!record) throw new JevBrowserError("confirmation_token is invalid, expired, or already used; run dry_run again");
|
|
70
|
+
tokens.delete(token);
|
|
71
|
+
if (record.fingerprint !== fingerprint) {
|
|
72
|
+
throw new JevBrowserError("confirmation_token does not match this flow; run dry_run again");
|
|
73
|
+
}
|
|
74
|
+
return record;
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const defaultMutationAuthorizationStore = createMutationAuthorizationStore();
|
|
80
|
+
|
|
81
|
+
function canonicalValue(value) {
|
|
82
|
+
if (Array.isArray(value)) return value.map(canonicalValue);
|
|
83
|
+
if (!isRecord(value)) return value;
|
|
84
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalValue(value[key])]));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function flowFingerprint({ flow, initialUrl, expectedOutcome, candidatePlans, params, options }) {
|
|
88
|
+
const stableOptions = { ...options };
|
|
89
|
+
delete stableOptions.dry_run;
|
|
90
|
+
delete stableOptions.confirmation_token;
|
|
91
|
+
delete stableOptions.dryRun;
|
|
92
|
+
delete stableOptions.confirmationToken;
|
|
93
|
+
return createHash("sha256").update(JSON.stringify(canonicalValue({
|
|
94
|
+
flow, initialUrl, expectedOutcome, candidatePlans, params, options: stableOptions,
|
|
95
|
+
}))).digest("hex");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function looksMutating(rawStep) {
|
|
99
|
+
if (["confirm_dialog", "like_comment", "unlike_comment", "upload_file"].includes(rawStep.action)) return true;
|
|
100
|
+
if (rawStep.action !== "click") return false;
|
|
101
|
+
return [rawStep.name, rawStep.title, rawStep.text, rawStep.target_text, rawStep.label]
|
|
102
|
+
.some((value) => typeof value === "string" && MUTATING_LABEL.test(value));
|
|
103
|
+
}
|
|
47
104
|
|
|
48
105
|
function isRecord(value) {
|
|
49
106
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
@@ -109,13 +166,12 @@ function interpolateStep(value, variables) {
|
|
|
109
166
|
return value;
|
|
110
167
|
}
|
|
111
168
|
|
|
112
|
-
export function validateCandidatePlans(candidatePlans, settings) {
|
|
169
|
+
export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps = settings.browser.maxFlowSteps) {
|
|
113
170
|
if (!isRecord(candidatePlans)) throw new JevBrowserError("candidate_plans must be an object");
|
|
114
171
|
if (Object.keys(candidatePlans).length > settings.jev.maxActionCount) {
|
|
115
172
|
throw new JevBrowserError("candidate_plans exceeds the configured candidate limit");
|
|
116
173
|
}
|
|
117
174
|
const plans = {};
|
|
118
|
-
let totalSteps = 0;
|
|
119
175
|
for (const [rawId, rawPlan] of Object.entries(candidatePlans)) {
|
|
120
176
|
const id = rawId.trim();
|
|
121
177
|
if (!PLAN_ID.test(id) || id === COMPLETE_ACTION_ID) throw new JevBrowserError("each plan needs a valid, non-reserved plan ID");
|
|
@@ -126,30 +182,34 @@ export function validateCandidatePlans(candidatePlans, settings) {
|
|
|
126
182
|
if (!Array.isArray(rawPlan.steps) || rawPlan.steps.length === 0) {
|
|
127
183
|
throw new JevBrowserError("each candidate plan needs at least one browser step");
|
|
128
184
|
}
|
|
129
|
-
|
|
130
|
-
if (totalSteps > settings.browser.maxFlowSteps) throw new JevBrowserError("candidate plans exceed the configured total browser-step limit");
|
|
185
|
+
if (rawPlan.steps.length > maxFlowSteps) throw new JevBrowserError(`candidate plan ${id} exceeds the per-plan browser-step limit of ${maxFlowSteps}`);
|
|
131
186
|
|
|
132
187
|
const steps = rawPlan.steps.map((unprocessedStep) => {
|
|
133
188
|
const rawStep = normalizeStepAliases(unprocessedStep);
|
|
134
189
|
if (!isRecord(rawStep) || typeof rawStep.action !== "string") throw new JevBrowserError("each browser step must be an object with an action");
|
|
135
190
|
const { action } = rawStep;
|
|
136
191
|
const targetSelection = ["frame", "comment", "within", "index", "near", ...TARGET_LOCATOR_FIELDS];
|
|
137
|
-
const semanticTarget = [...targetSelection, "role", "name"];
|
|
192
|
+
const semanticTarget = [...targetSelection, "role", "name", "name_match", "target_page"];
|
|
138
193
|
const locatorFields = TARGET_LOCATOR_FIELDS.filter((field) => field !== "text"
|
|
139
194
|
|| !["type", "wait_for_text", "wait_for_condition", "upload_file"].includes(action));
|
|
140
|
-
const schemas = {
|
|
141
|
-
click: { required: [], optional: [...semanticTarget, "expect_download", "timeout_seconds"] },
|
|
142
|
-
type: { required: [
|
|
195
|
+
const schemas = {
|
|
196
|
+
click: { required: [], optional: [...semanticTarget, "expect_download", "expect_popup", "timeout_seconds"] },
|
|
197
|
+
type: { required: [], optional: [...semanticTarget, "text", "text_env", "blur", "mode", "sensitive", "timeout_seconds"] },
|
|
198
|
+
clear: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
|
|
199
|
+
navigate: { required: ["url"], optional: ["timeout_seconds", "target_page"] },
|
|
143
200
|
confirm_dialog: { required: ["expected_text", "button"], optional: ["timeout_seconds"] },
|
|
201
|
+
check: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
|
|
202
|
+
uncheck: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
|
|
203
|
+
navigate_menu: { required: ["path"], optional: ["toggle_selector", "timeout_seconds"] },
|
|
144
204
|
assert_network: { required: ["url_contains", "method", "status"], optional: ["message_contains", "timeout_seconds"] },
|
|
145
205
|
extract: { required: ["as"], optional: [...semanticTarget, "source", "attribute", "sensitive", "timeout_seconds"] },
|
|
146
206
|
wait: { required: ["ms"], optional: [] },
|
|
147
|
-
wait_for_text: { required: ["text"], optional: ["frame", "comment", "fail_on", "timeout_seconds"] },
|
|
148
|
-
wait_for_condition: { required: ["condition"], optional: [...semanticTarget, "url_contains", "timeout_seconds"] },
|
|
207
|
+
wait_for_text: { required: ["text"], optional: ["frame", "comment", "fail_on", "target_page", "timeout_seconds"] },
|
|
208
|
+
wait_for_condition: { required: ["condition"], optional: [...semanticTarget, "url_contains", "timeout_seconds"] },
|
|
149
209
|
wait_for_value: { required: ["value"], optional: [...semanticTarget, "timeout_seconds"] },
|
|
150
210
|
wait_for_enabled: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
|
|
151
211
|
press: { required: ["key"], optional: [...semanticTarget, "timeout_seconds"] },
|
|
152
|
-
select_option: { required: [
|
|
212
|
+
select_option: { required: [], optional: [...semanticTarget, "option", "value", "label_contains", "timeout_seconds"] },
|
|
153
213
|
hover: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
|
|
154
214
|
assert_text: { required: ["expected"], optional: [...semanticTarget, "timeout_seconds"] },
|
|
155
215
|
assert_value: { required: ["expected"], optional: [...semanticTarget, "timeout_seconds"] },
|
|
@@ -157,12 +217,12 @@ export function validateCandidatePlans(candidatePlans, settings) {
|
|
|
157
217
|
assert_visible: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
|
|
158
218
|
assert_hidden: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
|
|
159
219
|
upload_file: { required: ["file_paths"], optional: ["target", ...semanticTarget, "timeout_seconds"] },
|
|
160
|
-
audit_accessibility: { required: ["standard"], optional: ["comment", "timeout_seconds"] },
|
|
220
|
+
audit_accessibility: { required: ["standard"], optional: ["comment", "scope", "fail_on_impact", "timeout_seconds", "target_page"] },
|
|
161
221
|
like_comment: { required: ["author", "text", "like_label", "liked_label"], optional: ["frame", "comment", "timeout_seconds"] },
|
|
162
222
|
unlike_comment: { required: ["author", "text", "like_label", "liked_label"], optional: ["frame", "comment", "timeout_seconds"] },
|
|
163
223
|
};
|
|
164
224
|
if (!Object.hasOwn(schemas, action)) throw new JevBrowserError(`unsupported browser action ${JSON.stringify(action)}; supported actions: ${Object.keys(schemas).join(", ")}`);
|
|
165
|
-
exactKeys(rawStep, ["action", ...schemas[action].required], [...schemas[action].optional, "mutating", "screenshot"], action);
|
|
225
|
+
exactKeys(rawStep, ["action", ...schemas[action].required], [...schemas[action].optional, "mutating", "screenshot"], action);
|
|
166
226
|
|
|
167
227
|
const step = { action };
|
|
168
228
|
for (const field of ["mutating", "screenshot"]) {
|
|
@@ -171,6 +231,11 @@ export function validateCandidatePlans(candidatePlans, settings) {
|
|
|
171
231
|
}
|
|
172
232
|
if (rawStep[field] !== undefined) step[field] = rawStep[field];
|
|
173
233
|
}
|
|
234
|
+
const inferredMutating = looksMutating(rawStep);
|
|
235
|
+
if (inferredMutating && rawStep.mutating === false) {
|
|
236
|
+
throw new JevBrowserError(`${action} cannot override the default mutating classification for this action`);
|
|
237
|
+
}
|
|
238
|
+
if (inferredMutating) step.mutating = true;
|
|
174
239
|
const selectorFields = locatorFields.filter((field) => rawStep[field] !== undefined);
|
|
175
240
|
const hasNear = rawStep.near !== undefined;
|
|
176
241
|
if (selectorFields.length + Number(hasNear) > 1) {
|
|
@@ -183,7 +248,8 @@ export function validateCandidatePlans(candidatePlans, settings) {
|
|
|
183
248
|
}
|
|
184
249
|
if (rawStep.role !== undefined) {
|
|
185
250
|
const allowedRoles = action === "click" ? CLICK_ROLES
|
|
186
|
-
: action === "
|
|
251
|
+
: action === "check" || action === "uncheck" ? CHECK_ROLES
|
|
252
|
+
: action === "type" || action === "select_option" || action === "assert_value" || action === "wait_for_value" ? TEXT_ROLES
|
|
187
253
|
: action === "hover" ? HOVER_ROLES
|
|
188
254
|
: action === "upload_file" ? (rawStep.target === "button" ? new Set(["button"]) : DROPZONE_ROLES)
|
|
189
255
|
: ASSERT_ROLES;
|
|
@@ -192,7 +258,7 @@ export function validateCandidatePlans(candidatePlans, settings) {
|
|
|
192
258
|
}
|
|
193
259
|
step.role = rawStep.role;
|
|
194
260
|
}
|
|
195
|
-
if (rawStep.name !== undefined) {
|
|
261
|
+
if (rawStep.name !== undefined) {
|
|
196
262
|
if (typeof rawStep.name !== "string") throw new JevBrowserError("name must be text");
|
|
197
263
|
if (rawStep.name.trim()) step.name = requiredText(rawStep.name, "accessible name", settings.jev.maxActionDescriptionChars);
|
|
198
264
|
else if (rawStep.name === "" && Number.isSafeInteger(rawStep.index) && rawStep.index >= 0) {
|
|
@@ -201,8 +267,27 @@ export function validateCandidatePlans(candidatePlans, settings) {
|
|
|
201
267
|
} else {
|
|
202
268
|
throw new JevBrowserError("name may be empty only when a non-negative index is provided");
|
|
203
269
|
}
|
|
204
|
-
if (!step.role) throw new JevBrowserError("name requires role; use a locator field such as label, title, placeholder, test_id, text, or selector");
|
|
205
|
-
}
|
|
270
|
+
if (!step.role) throw new JevBrowserError("name requires role; use a locator field such as label, title, placeholder, test_id, text, or selector");
|
|
271
|
+
}
|
|
272
|
+
if (rawStep.name_match !== undefined) {
|
|
273
|
+
if (!step.name) throw new JevBrowserError("name_match requires a non-empty name");
|
|
274
|
+
if (!new Set(["exact", "contains", "regex"]).has(rawStep.name_match)) {
|
|
275
|
+
throw new JevBrowserError("name_match must be exact, contains, or regex");
|
|
276
|
+
}
|
|
277
|
+
if (rawStep.name_match === "regex") {
|
|
278
|
+
if (step.name.length > 128 || /\([^)]*[+*][^)]*\)[+*{]|\\[1-9]|\(\?[=!<]/.test(step.name)) {
|
|
279
|
+
throw new JevBrowserError("name_match regex is too long or uses an unsafe construct");
|
|
280
|
+
}
|
|
281
|
+
try { new RegExp(step.name, "u"); } catch { throw new JevBrowserError("name_match regex is invalid"); }
|
|
282
|
+
}
|
|
283
|
+
step.nameMatch = rawStep.name_match;
|
|
284
|
+
}
|
|
285
|
+
if (rawStep.target_page !== undefined) {
|
|
286
|
+
if (!new Set(["current", "popup"]).has(rawStep.target_page)) {
|
|
287
|
+
throw new JevBrowserError("target_page must be current or popup");
|
|
288
|
+
}
|
|
289
|
+
step.targetPage = rawStep.target_page;
|
|
290
|
+
}
|
|
206
291
|
if (action === "select_option" && !step.role) step.role = "combobox";
|
|
207
292
|
if (TARGET_SELECTION_ACTIONS.has(action) && action !== "press"
|
|
208
293
|
&& !step.role && step.name === undefined && selectorFields.length === 0 && !hasNear) {
|
|
@@ -236,22 +321,27 @@ export function validateCandidatePlans(candidatePlans, settings) {
|
|
|
236
321
|
if ((!TARGET_SELECTION_ACTIONS.has(action) && !hiddenWait) || !isRecord(rawStep.within)) {
|
|
237
322
|
throw new JevBrowserError("within must be a semantic container on a target-based browser action");
|
|
238
323
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
324
|
+
const rowKeys = ["row_containing", "row_containing_exact", "row_containing_word"]
|
|
325
|
+
.filter((key) => rawStep.within[key] !== undefined);
|
|
326
|
+
if (rowKeys.length > 1) throw new JevBrowserError("within accepts only one row_containing match mode");
|
|
327
|
+
exactKeys(rawStep.within, [...(rawStep.within.role !== undefined ? ["role"] : []), ...rowKeys], ["name"], "within");
|
|
328
|
+
if (rawStep.within.role !== undefined && (typeof rawStep.within.role !== "string" || !CONTAINER_ROLES.has(rawStep.within.role))) {
|
|
329
|
+
throw new JevBrowserError(`within role must be one of: ${[...CONTAINER_ROLES].join(", ")}`);
|
|
330
|
+
}
|
|
331
|
+
if (rawStep.within.name !== undefined && rawStep.within.role === undefined) {
|
|
332
|
+
throw new JevBrowserError("within.name requires within.role");
|
|
333
|
+
}
|
|
334
|
+
if (!rawStep.within.role && rowKeys.length === 0) {
|
|
335
|
+
throw new JevBrowserError("within needs a semantic container role or row_containing text");
|
|
336
|
+
}
|
|
337
|
+
step.within = {};
|
|
338
|
+
if (rawStep.within.role !== undefined) step.within.role = rawStep.within.role;
|
|
339
|
+
if (rawStep.within.name !== undefined) step.within.name = requiredText(rawStep.within.name, "within accessible name", settings.jev.maxActionDescriptionChars);
|
|
340
|
+
if (rowKeys.length) {
|
|
341
|
+
const key = rowKeys[0];
|
|
342
|
+
const normalizedKey = key === "row_containing_exact" ? "rowContainingExact"
|
|
343
|
+
: key === "row_containing_word" ? "rowContainingWord" : "rowContaining";
|
|
344
|
+
step.within[normalizedKey] = requiredText(rawStep.within[key], `within ${key}`, settings.jev.maxActionDescriptionChars);
|
|
255
345
|
}
|
|
256
346
|
}
|
|
257
347
|
if (rawStep.index !== undefined) {
|
|
@@ -266,14 +356,47 @@ export function validateCandidatePlans(candidatePlans, settings) {
|
|
|
266
356
|
if (typeof rawStep.expect_download !== "boolean") throw new JevBrowserError("click expect_download must be a boolean");
|
|
267
357
|
step.expectDownload = rawStep.expect_download;
|
|
268
358
|
}
|
|
359
|
+
if (action === "click" && rawStep.expect_popup !== undefined) {
|
|
360
|
+
if (typeof rawStep.expect_popup !== "boolean") throw new JevBrowserError("click expect_popup must be a boolean");
|
|
361
|
+
step.expectPopup = rawStep.expect_popup;
|
|
362
|
+
}
|
|
363
|
+
if (action === "click" && step.expectDownload && step.expectPopup) {
|
|
364
|
+
throw new JevBrowserError("click cannot expect both a download and a popup");
|
|
365
|
+
}
|
|
269
366
|
if (action === "select_option") {
|
|
367
|
+
const choices = ["option", "value", "label_contains"].filter((key) => rawStep[key] !== undefined);
|
|
368
|
+
if (choices.length !== 1) throw new JevBrowserError("select_option needs exactly one of option, value, or label_contains");
|
|
270
369
|
if (step.role && step.role !== "combobox") throw new JevBrowserError("select_option role must be combobox");
|
|
271
370
|
step.role = "combobox";
|
|
272
|
-
step.option = requiredText(rawStep
|
|
371
|
+
step.option = requiredText(rawStep[choices[0]], choices[0], settings.jev.maxActionDescriptionChars);
|
|
372
|
+
step.optionMatch = choices[0];
|
|
273
373
|
}
|
|
274
|
-
if (action === "
|
|
275
|
-
if (
|
|
276
|
-
if (
|
|
374
|
+
if (action === "check" || action === "uncheck") {
|
|
375
|
+
if (step.role && !CHECK_ROLES.has(step.role)) throw new JevBrowserError(`${action} role must be checkbox or radio`);
|
|
376
|
+
if (!step.role) step.role = "checkbox";
|
|
377
|
+
if (action === "uncheck" && step.role === "radio") throw new JevBrowserError("uncheck does not support radio controls");
|
|
378
|
+
}
|
|
379
|
+
if (action === "navigate_menu") {
|
|
380
|
+
if (!Array.isArray(rawStep.path) || rawStep.path.length === 0 || rawStep.path.length > 8) {
|
|
381
|
+
throw new JevBrowserError("navigate_menu path must contain between 1 and 8 menu labels");
|
|
382
|
+
}
|
|
383
|
+
step.path = rawStep.path.map((label) => requiredText(label, "menu path label", settings.jev.maxActionDescriptionChars));
|
|
384
|
+
step.toggleSelector = rawStep.toggle_selector === undefined
|
|
385
|
+
? "#open_btn"
|
|
386
|
+
: requiredText(rawStep.toggle_selector, "toggle_selector", settings.jev.maxActionDescriptionChars);
|
|
387
|
+
}
|
|
388
|
+
if (action === "navigate") {
|
|
389
|
+
step.url = requiredText(rawStep.url, "navigation URL", settings.jev.maxActionDescriptionChars);
|
|
390
|
+
}
|
|
391
|
+
if (action === "type") {
|
|
392
|
+
const hasText = typeof rawStep.text === "string";
|
|
393
|
+
const hasTextEnv = rawStep.text_env !== undefined;
|
|
394
|
+
if (hasText === hasTextEnv) throw new JevBrowserError("type step needs exactly one of text or text_env");
|
|
395
|
+
if (hasText && rawStep.text.length === 0) throw new JevBrowserError("type text must not be empty; use action clear to empty a field");
|
|
396
|
+
if (hasText && rawStep.text.length > settings.browser.maxTextEntryChars) throw new JevBrowserError("type text exceeds the configured character limit");
|
|
397
|
+
if (hasTextEnv && (typeof rawStep.text_env !== "string" || !/^[A-Z][A-Z0-9_]*$/.test(rawStep.text_env))) {
|
|
398
|
+
throw new JevBrowserError("type text_env must name an environment variable such as APP_PASSWORD");
|
|
399
|
+
}
|
|
277
400
|
if (rawStep.mode !== undefined && !new Set(["fill", "keys"]).has(rawStep.mode)) {
|
|
278
401
|
throw new JevBrowserError("type mode must be fill or keys");
|
|
279
402
|
}
|
|
@@ -282,11 +405,16 @@ export function validateCandidatePlans(candidatePlans, settings) {
|
|
|
282
405
|
if (step.typedTargetText !== undefined && (!step.role || !TEXT_ROLES.has(step.role))) {
|
|
283
406
|
throw new JevBrowserError("type target_text needs role textbox, searchbox, or combobox");
|
|
284
407
|
}
|
|
285
|
-
step.text = rawStep.text;
|
|
286
|
-
step.
|
|
287
|
-
step.
|
|
288
|
-
step.
|
|
289
|
-
|
|
408
|
+
if (hasText) step.text = rawStep.text;
|
|
409
|
+
if (hasTextEnv) step.textEnv = rawStep.text_env;
|
|
410
|
+
step.mode = rawStep.mode || "fill";
|
|
411
|
+
step.blur = rawStep.blur ?? false;
|
|
412
|
+
step.sensitive = rawStep.sensitive ?? true;
|
|
413
|
+
if (hasTextEnv && rawStep.sensitive === false) throw new JevBrowserError("type text_env is always sensitive");
|
|
414
|
+
}
|
|
415
|
+
if (action === "clear" && step.role && !TEXT_ROLES.has(step.role)) {
|
|
416
|
+
throw new JevBrowserError("clear requires role textbox, searchbox, or combobox");
|
|
417
|
+
}
|
|
290
418
|
if (action === "wait_for_text") {
|
|
291
419
|
step.text = requiredText(rawStep.text, "wait text", settings.jev.maxActionDescriptionChars);
|
|
292
420
|
if (rawStep.fail_on !== undefined) {
|
|
@@ -343,9 +471,9 @@ export function validateCandidatePlans(candidatePlans, settings) {
|
|
|
343
471
|
}
|
|
344
472
|
step.sensitive = rawStep.sensitive ?? true;
|
|
345
473
|
}
|
|
346
|
-
if (action === "wait_for_condition") {
|
|
347
|
-
if (!new Set(["network_idle", "hidden", "text_hidden", "angular_idle"]).has(rawStep.condition)) {
|
|
348
|
-
throw new JevBrowserError("wait_for_condition condition must be network_idle, hidden, text_hidden, or angular_idle");
|
|
474
|
+
if (action === "wait_for_condition") {
|
|
475
|
+
if (!new Set(["network_idle", "url", "hidden", "text_hidden", "angular_idle"]).has(rawStep.condition)) {
|
|
476
|
+
throw new JevBrowserError("wait_for_condition condition must be network_idle, url, hidden, text_hidden, or angular_idle");
|
|
349
477
|
}
|
|
350
478
|
step.condition = rawStep.condition;
|
|
351
479
|
if (rawStep.condition === "hidden") {
|
|
@@ -364,26 +492,40 @@ export function validateCandidatePlans(candidatePlans, settings) {
|
|
|
364
492
|
}
|
|
365
493
|
step.text = requiredText(rawStep.text, "hidden text", settings.jev.maxActionDescriptionChars);
|
|
366
494
|
step.targetText = step.text;
|
|
367
|
-
} else if (rawStep.condition === "
|
|
495
|
+
} else if (rawStep.condition === "url") {
|
|
496
|
+
if (step.role || step.name !== undefined || rawStep.text !== undefined || hasLocatorField(step)
|
|
497
|
+
|| step.near || step.within || rawStep.index !== undefined) {
|
|
498
|
+
throw new JevBrowserError("url condition accepts only url_contains and timeout_seconds");
|
|
499
|
+
}
|
|
500
|
+
step.urlContains = requiredText(rawStep.url_contains, "url_contains", settings.jev.maxActionDescriptionChars);
|
|
501
|
+
} else if (rawStep.condition === "angular_idle") {
|
|
368
502
|
if (rawStep.url_contains !== undefined || step.role || step.name !== undefined || rawStep.text !== undefined
|
|
369
503
|
|| hasLocatorField(step) || step.near || step.within || rawStep.index !== undefined) {
|
|
370
504
|
throw new JevBrowserError("angular_idle condition accepts only condition and timeout_seconds");
|
|
371
505
|
}
|
|
372
|
-
} else if (step.role || step.name !== undefined || rawStep.text !== undefined || hasLocatorField(step)
|
|
373
|
-
|| step.near || step.within || rawStep.index !== undefined) {
|
|
374
|
-
throw new JevBrowserError("network_idle condition accepts only url_contains as an optional filter");
|
|
375
|
-
}
|
|
506
|
+
} else if (rawStep.condition === "network_idle" && (step.role || step.name !== undefined || rawStep.text !== undefined || hasLocatorField(step)
|
|
507
|
+
|| step.near || step.within || rawStep.index !== undefined)) {
|
|
508
|
+
throw new JevBrowserError("network_idle condition accepts only url_contains as an optional filter");
|
|
509
|
+
}
|
|
376
510
|
if (rawStep.condition === "network_idle" && rawStep.url_contains !== undefined) {
|
|
377
511
|
step.urlContains = requiredText(rawStep.url_contains, "url_contains", settings.jev.maxActionDescriptionChars);
|
|
378
|
-
} else if (rawStep.url_contains !== undefined) {
|
|
379
|
-
throw new JevBrowserError("url_contains is only supported by the network_idle
|
|
512
|
+
} else if (rawStep.condition !== "url" && rawStep.url_contains !== undefined) {
|
|
513
|
+
throw new JevBrowserError("url_contains is only supported by the network_idle and url conditions");
|
|
380
514
|
}
|
|
381
515
|
}
|
|
382
|
-
if (action === "audit_accessibility") {
|
|
516
|
+
if (action === "audit_accessibility") {
|
|
383
517
|
if (typeof rawStep.standard !== "string" || !Object.hasOwn(ACCESSIBILITY_STANDARDS, rawStep.standard)) {
|
|
384
518
|
throw new JevBrowserError("audit_accessibility standard must be wcag2aa");
|
|
385
519
|
}
|
|
386
|
-
step.standard = rawStep.standard;
|
|
520
|
+
step.standard = rawStep.standard;
|
|
521
|
+
if (rawStep.scope !== undefined) {
|
|
522
|
+
step.scope = requiredText(rawStep.scope, "accessibility scope", settings.jev.maxActionDescriptionChars);
|
|
523
|
+
if (/^xpath(?:=|:)/i.test(step.scope)) throw new JevBrowserError("audit_accessibility scope must be a CSS selector");
|
|
524
|
+
}
|
|
525
|
+
step.failOnImpact = rawStep.fail_on_impact ?? settings.browser.accessibilityFailOnImpactDefault;
|
|
526
|
+
if (!new Set(["minor", "moderate", "serious", "critical"]).has(step.failOnImpact)) {
|
|
527
|
+
throw new JevBrowserError("audit_accessibility fail_on_impact must be minor, moderate, serious, or critical");
|
|
528
|
+
}
|
|
387
529
|
}
|
|
388
530
|
if (action === "press") {
|
|
389
531
|
if (typeof rawStep.key !== "string" || !APPROVED_KEYS.has(rawStep.key)) {
|
|
@@ -462,7 +604,7 @@ function normalizeStepAliases(rawStep) {
|
|
|
462
604
|
return step;
|
|
463
605
|
}
|
|
464
606
|
|
|
465
|
-
function resolveFlowOptions(rawOptions, settings) {
|
|
607
|
+
function resolveFlowOptions(rawOptions, settings) {
|
|
466
608
|
if (rawOptions === undefined) rawOptions = {};
|
|
467
609
|
if (!isRecord(rawOptions) || Object.keys(rawOptions).some((key) => !FLOW_OPTION_KEYS.has(key))) {
|
|
468
610
|
throw new JevBrowserError("options contains unsupported flow settings");
|
|
@@ -471,10 +613,11 @@ function resolveFlowOptions(rawOptions, settings) {
|
|
|
471
613
|
&& rawOptions.continue_from_current_page !== rawOptions.reuse_page) {
|
|
472
614
|
throw new JevBrowserError("options.continue_from_current_page and options.reuse_page cannot disagree");
|
|
473
615
|
}
|
|
474
|
-
const booleanOptions = [
|
|
475
|
-
"block_trackers", "capture_console_errors", "capture_network_error_bodies", "capture_network_errors",
|
|
476
|
-
"
|
|
477
|
-
"
|
|
616
|
+
const booleanOptions = [
|
|
617
|
+
"allow_mutations", "block_trackers", "capture_console_errors", "capture_network_error_bodies", "capture_network_errors",
|
|
618
|
+
"auto_angular_idle", "block_fonts", "continue_from_current_page", "dry_run", "fast_path", "local_only",
|
|
619
|
+
"ready_network_idle", "reuse_page", "screenshot_on_failure", "screenshot_on_success", "record_video", "mobile",
|
|
620
|
+
"snapshot_include_hidden", "stop_on_expected", "trace_on_failure", "trace_on_success",
|
|
478
621
|
];
|
|
479
622
|
for (const key of booleanOptions) {
|
|
480
623
|
if (rawOptions[key] !== undefined && typeof rawOptions[key] !== "boolean") {
|
|
@@ -484,12 +627,86 @@ function resolveFlowOptions(rawOptions, settings) {
|
|
|
484
627
|
const scope = rawOptions.snapshot_scope ?? settings.browser.defaults.snapshotScope;
|
|
485
628
|
if (!new Set(["body", "main", "dialog"]).has(scope)) throw new JevBrowserError("options.snapshot_scope must be body, main, or dialog");
|
|
486
629
|
const defaults = settings.browser.defaults;
|
|
630
|
+
const stepTimeoutSeconds = rawOptions.step_timeout_seconds ?? settings.browser.actionTimeoutMs / 1000;
|
|
631
|
+
if (typeof stepTimeoutSeconds !== "number" || !Number.isFinite(stepTimeoutSeconds) || stepTimeoutSeconds <= 0
|
|
632
|
+
|| stepTimeoutSeconds > settings.browser.maxStepTimeoutSeconds) {
|
|
633
|
+
throw new JevBrowserError(`options.step_timeout_seconds must be positive and at most ${settings.browser.maxStepTimeoutSeconds}`);
|
|
634
|
+
}
|
|
635
|
+
const maxFlowSteps = rawOptions.max_flow_steps ?? settings.browser.maxFlowSteps;
|
|
636
|
+
if (!Number.isSafeInteger(maxFlowSteps) || maxFlowSteps <= 0 || maxFlowSteps > settings.browser.maxFlowSteps) {
|
|
637
|
+
throw new JevBrowserError(`options.max_flow_steps must be a positive integer no greater than ${settings.browser.maxFlowSteps}`);
|
|
638
|
+
}
|
|
639
|
+
const returnSnapshot = rawOptions.return_snapshot ?? defaults.returnSnapshot;
|
|
640
|
+
if (!new Set(["none", "diff", "full"]).has(returnSnapshot)) throw new JevBrowserError("options.return_snapshot must be none, diff, or full");
|
|
641
|
+
const normalizeListOption = (key, fallback, maxItems = settings.browser.maxBusySelectors) => {
|
|
642
|
+
const raw = rawOptions[key] ?? fallback;
|
|
643
|
+
if (!Array.isArray(raw) || raw.length > maxItems) throw new JevBrowserError(`options.${key} must be an array with at most ${maxItems} entries`);
|
|
644
|
+
return raw.map((value) => requiredText(value, key, settings.jev.maxActionDescriptionChars));
|
|
645
|
+
};
|
|
646
|
+
const busySelectors = normalizeListOption("busy_selectors", settings.browser.busySelectorsDefault);
|
|
647
|
+
const loginUrlContains = normalizeListOption("login_url_contains", settings.browser.loginUrlContainsDefault);
|
|
648
|
+
const loginText = normalizeListOption("login_text", settings.browser.loginTextDefault);
|
|
649
|
+
const rawPermissions = rawOptions.permissions;
|
|
650
|
+
if (rawPermissions !== undefined && !Array.isArray(rawPermissions)) {
|
|
651
|
+
throw new JevBrowserError("options.permissions must contain unique supported values: microphone, geolocation");
|
|
652
|
+
}
|
|
653
|
+
const permissions = rawPermissions === undefined ? [] : [...rawPermissions];
|
|
654
|
+
if (permissions.length > 2
|
|
655
|
+
|| permissions.some((permission) => !new Set(["microphone", "geolocation"]).has(permission))
|
|
656
|
+
|| new Set(permissions).size !== permissions.length) {
|
|
657
|
+
throw new JevBrowserError("options.permissions must contain unique supported values: microphone, geolocation");
|
|
658
|
+
}
|
|
659
|
+
const viewport = rawOptions.viewport;
|
|
660
|
+
if (viewport !== undefined) {
|
|
661
|
+
if (!isRecord(viewport)) throw new JevBrowserError("options.viewport must contain width and height");
|
|
662
|
+
exactKeys(viewport, ["width", "height"], [], "options.viewport");
|
|
663
|
+
if (!Number.isSafeInteger(viewport.width) || viewport.width < 320 || viewport.width > settings.browser.maxViewportWidth
|
|
664
|
+
|| !Number.isSafeInteger(viewport.height) || viewport.height < 240 || viewport.height > settings.browser.maxViewportHeight) {
|
|
665
|
+
throw new JevBrowserError(`options.viewport dimensions must be within 320x240 and ${settings.browser.maxViewportWidth}x${settings.browser.maxViewportHeight}`);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
if (rawOptions.mobile === true && !viewport) throw new JevBrowserError("options.viewport is required when options.mobile is true");
|
|
669
|
+
const deviceScaleFactor = rawOptions.device_scale_factor;
|
|
670
|
+
if (deviceScaleFactor !== undefined && (typeof deviceScaleFactor !== "number" || !Number.isFinite(deviceScaleFactor)
|
|
671
|
+
|| deviceScaleFactor < 0.5 || deviceScaleFactor > 3)) {
|
|
672
|
+
throw new JevBrowserError("options.device_scale_factor must be between 0.5 and 3");
|
|
673
|
+
}
|
|
674
|
+
const locale = rawOptions.locale === undefined ? undefined : requiredText(rawOptions.locale, "locale", 64);
|
|
675
|
+
if (locale !== undefined) {
|
|
676
|
+
try { new Intl.Locale(locale); } catch { throw new JevBrowserError("options.locale must be a valid BCP 47 locale"); }
|
|
677
|
+
}
|
|
678
|
+
const timezoneId = rawOptions.timezone_id === undefined ? undefined : requiredText(rawOptions.timezone_id, "timezone_id", 64);
|
|
679
|
+
if (timezoneId !== undefined) {
|
|
680
|
+
try { new Intl.DateTimeFormat("en", { timeZone: timezoneId }); } catch { throw new JevBrowserError("options.timezone_id must be a valid IANA time zone"); }
|
|
681
|
+
}
|
|
682
|
+
const colorScheme = rawOptions.color_scheme;
|
|
683
|
+
if (colorScheme !== undefined && !new Set(["light", "dark", "no-preference"]).has(colorScheme)) {
|
|
684
|
+
throw new JevBrowserError("options.color_scheme must be light, dark, or no-preference");
|
|
685
|
+
}
|
|
686
|
+
const geolocation = rawOptions.geolocation;
|
|
687
|
+
if (geolocation !== undefined) {
|
|
688
|
+
if (!isRecord(geolocation)) throw new JevBrowserError("options.geolocation must contain latitude and longitude");
|
|
689
|
+
exactKeys(geolocation, ["latitude", "longitude"], ["accuracy"], "options.geolocation");
|
|
690
|
+
if (![geolocation.latitude, geolocation.longitude].every((value) => typeof value === "number" && Number.isFinite(value))
|
|
691
|
+
|| geolocation.latitude < -90 || geolocation.latitude > 90 || geolocation.longitude < -180 || geolocation.longitude > 180
|
|
692
|
+
|| (geolocation.accuracy !== undefined && (typeof geolocation.accuracy !== "number" || !Number.isFinite(geolocation.accuracy)
|
|
693
|
+
|| geolocation.accuracy < 0 || geolocation.accuracy > 10000))) {
|
|
694
|
+
throw new JevBrowserError("options.geolocation coordinates or accuracy are outside valid ranges");
|
|
695
|
+
}
|
|
696
|
+
if (!permissions.includes("geolocation")) permissions.push("geolocation");
|
|
697
|
+
}
|
|
698
|
+
const fakeMedia = rawOptions.fake_media === undefined ? undefined : requiredText(rawOptions.fake_media, "fake_media", settings.browser.maxUploadPathChars);
|
|
699
|
+
if (fakeMedia && !permissions.includes("microphone")) permissions.push("microphone");
|
|
700
|
+
let confirmationToken;
|
|
701
|
+
if (rawOptions.confirmation_token !== undefined) {
|
|
702
|
+
confirmationToken = requiredText(rawOptions.confirmation_token, "confirmation_token", 128);
|
|
703
|
+
}
|
|
487
704
|
const readyTimeoutSeconds = rawOptions.ready_timeout_seconds ?? defaults.readyTimeoutSeconds;
|
|
488
705
|
if (typeof readyTimeoutSeconds !== "number" || !Number.isFinite(readyTimeoutSeconds) || readyTimeoutSeconds <= 0
|
|
489
706
|
|| readyTimeoutSeconds > settings.browser.maxReadyTimeoutSeconds) {
|
|
490
707
|
throw new JevBrowserError(`options.ready_timeout_seconds must be positive and at most ${settings.browser.maxReadyTimeoutSeconds}`);
|
|
491
708
|
}
|
|
492
|
-
const readyStableMs = rawOptions.ready_stable_ms ?? defaults.readyStableMs;
|
|
709
|
+
const readyStableMs = rawOptions.ready_stable_ms ?? defaults.readyStableMs;
|
|
493
710
|
if (!Number.isSafeInteger(readyStableMs) || readyStableMs <= 0 || readyStableMs > settings.browser.maxReadyStableMs
|
|
494
711
|
|| readyStableMs > readyTimeoutSeconds * 1000) {
|
|
495
712
|
throw new JevBrowserError("options.ready_stable_ms must be a positive integer within the configured readiness limits");
|
|
@@ -506,22 +723,45 @@ function resolveFlowOptions(rawOptions, settings) {
|
|
|
506
723
|
if (!new Set([".md", ".xml"]).has(extension)) throw new JevBrowserError("options.report_path must end in .md or .xml (JUnit)");
|
|
507
724
|
reportPath = rawOptions.report_path;
|
|
508
725
|
}
|
|
509
|
-
return Object.freeze({
|
|
726
|
+
return Object.freeze({
|
|
727
|
+
allowMutations: rawOptions.allow_mutations ?? false,
|
|
728
|
+
autoAngularIdle: rawOptions.auto_angular_idle ?? defaults.autoAngularIdle,
|
|
510
729
|
blockTrackers: rawOptions.block_trackers ?? defaults.blockTrackers,
|
|
730
|
+
blockFonts: rawOptions.block_fonts ?? defaults.blockFonts,
|
|
731
|
+
busySelectors: Object.freeze(busySelectors),
|
|
511
732
|
captureConsoleErrors: rawOptions.capture_console_errors ?? defaults.captureConsoleErrors,
|
|
512
733
|
captureNetworkErrorBodies: rawOptions.capture_network_error_bodies ?? defaults.captureNetworkErrorBodies,
|
|
513
734
|
captureNetworkErrors: rawOptions.capture_network_errors ?? defaults.captureNetworkErrors,
|
|
514
735
|
dryRun: rawOptions.dry_run ?? false,
|
|
515
736
|
fastPath: rawOptions.fast_path ?? defaults.fastPath,
|
|
737
|
+
localOnly: rawOptions.local_only ?? defaults.localOnly,
|
|
738
|
+
loginText: Object.freeze(loginText),
|
|
739
|
+
loginUrlContains: Object.freeze(loginUrlContains),
|
|
740
|
+
permissions: Object.freeze([...permissions]),
|
|
741
|
+
fakeMedia,
|
|
742
|
+
viewport: viewport ? Object.freeze({ width: viewport.width, height: viewport.height }) : undefined,
|
|
743
|
+
mobile: rawOptions.mobile ?? false,
|
|
744
|
+
deviceScaleFactor,
|
|
745
|
+
locale,
|
|
746
|
+
timezoneId,
|
|
747
|
+
colorScheme,
|
|
748
|
+
geolocation: geolocation ? Object.freeze({ ...geolocation }) : undefined,
|
|
749
|
+
screenshotOnSuccess: rawOptions.screenshot_on_success ?? false,
|
|
750
|
+
recordVideo: rawOptions.record_video ?? false,
|
|
751
|
+
traceOnSuccess: rawOptions.trace_on_success ?? settings.browser.defaults.traceOnSuccess,
|
|
752
|
+
maxFlowSteps,
|
|
516
753
|
readyTimeoutMs: Math.ceil(readyTimeoutSeconds * 1000),
|
|
517
754
|
readyNetworkIdle: rawOptions.ready_network_idle ?? defaults.readyNetworkIdle,
|
|
518
755
|
readyStableMs,
|
|
519
756
|
readyText,
|
|
520
757
|
reportPath,
|
|
758
|
+
returnSnapshot,
|
|
521
759
|
reusePage: rawOptions.continue_from_current_page ?? rawOptions.reuse_page ?? defaults.reusePage,
|
|
522
760
|
screenshotOnFailure: rawOptions.screenshot_on_failure ?? defaults.screenshotOnFailure,
|
|
523
761
|
snapshotIncludeHidden: rawOptions.snapshot_include_hidden ?? defaults.snapshotIncludeHidden,
|
|
524
762
|
stopOnExpected: rawOptions.stop_on_expected ?? defaults.stopOnExpected,
|
|
763
|
+
stepTimeoutMs: Math.ceil(stepTimeoutSeconds * 1000),
|
|
764
|
+
confirmationToken,
|
|
525
765
|
traceOnFailure: rawOptions.trace_on_failure ?? defaults.traceOnFailure,
|
|
526
766
|
snapshotScope: scope,
|
|
527
767
|
});
|
|
@@ -551,12 +791,12 @@ function planSecrets(plans) {
|
|
|
551
791
|
return [...secrets].filter(Boolean).sort((left, right) => right.length - left.length);
|
|
552
792
|
}
|
|
553
793
|
|
|
554
|
-
function redact(value, secrets) {
|
|
794
|
+
function redact(value, secrets) {
|
|
555
795
|
let result = value;
|
|
556
796
|
for (const secret of secrets) {
|
|
557
797
|
const pattern = secret.length < 4
|
|
558
|
-
? new RegExp(`(?<![\\p{L}\\p{N}_])${escapeRegex(secret)}(?![\\p{L}\\p{N}_])`, "
|
|
559
|
-
: new RegExp(escapeRegex(secret), "
|
|
798
|
+
? new RegExp(`(?<![\\p{L}\\p{N}_])${escapeRegex(secret)}(?![\\p{L}\\p{N}_])`, "gu")
|
|
799
|
+
: new RegExp(escapeRegex(secret), "gu");
|
|
560
800
|
result = result.replace(pattern, "[provided form text]");
|
|
561
801
|
}
|
|
562
802
|
return result;
|
|
@@ -623,11 +863,110 @@ function planContentSecrets(plans) {
|
|
|
623
863
|
.filter(Boolean).sort((left, right) => right.length - left.length);
|
|
624
864
|
}
|
|
625
865
|
|
|
626
|
-
function redactSnapshotFieldValues(snapshot, secrets) {
|
|
627
|
-
return snapshot.split(/(\r?\n)/).map((line) => /^\s*-\s*(?:textbox|searchbox|combobox)\b/i.test(line)
|
|
628
|
-
? redact(line, secrets)
|
|
629
|
-
: line).join("");
|
|
630
|
-
}
|
|
866
|
+
function redactSnapshotFieldValues(snapshot, secrets) {
|
|
867
|
+
return snapshot.split(/(\r?\n)/).map((line) => /^\s*-\s*(?:textbox|searchbox|combobox)\b/i.test(line)
|
|
868
|
+
? redact(line, secrets)
|
|
869
|
+
: line).join("");
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function providerSafeSnapshot(snapshot, secrets) {
|
|
873
|
+
return redactSnapshotFieldValues(redact(snapshot, secrets), secrets)
|
|
874
|
+
.split(/(\r?\n)/).map((line) => {
|
|
875
|
+
if (/^\s*-\s*(?:textbox|searchbox|combobox)\b/i.test(line)) {
|
|
876
|
+
return line.replace(/(:\s*).+$/, "$1[form value omitted]");
|
|
877
|
+
}
|
|
878
|
+
if (/^\s*-\s*role\b/i.test(line) || /^\s*-\s*\[form controls:/i.test(line)) {
|
|
879
|
+
return line.replace(/\bvalue=(?:"[^"]*"|'[^']*'|\S+)/gi, 'value="[form value omitted]"');
|
|
880
|
+
}
|
|
881
|
+
return line;
|
|
882
|
+
}).join("")
|
|
883
|
+
.replace(/https?:\/\/[^\s"'<>]+/gi, (value) => {
|
|
884
|
+
try {
|
|
885
|
+
const url = new URL(value);
|
|
886
|
+
return `${url.origin}${url.pathname}`;
|
|
887
|
+
} catch {
|
|
888
|
+
return value.split(/[?#]/, 1)[0];
|
|
889
|
+
}
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function localHostOnly(url) {
|
|
894
|
+
return new Set(["localhost", "127.0.0.1", "::1", "[::1]"]).has(new URL(url).hostname.toLowerCase());
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
async function resolveFakeMediaPath(fakeMedia, settings) {
|
|
898
|
+
if (!fakeMedia) return undefined;
|
|
899
|
+
if (!settings.browser.uploadRoot) {
|
|
900
|
+
throw new JevBrowserError("fake_media is disabled; configure JEV_BROWSER_UPLOAD_ROOT and keep the WAV file inside it");
|
|
901
|
+
}
|
|
902
|
+
let root;
|
|
903
|
+
let audio;
|
|
904
|
+
try {
|
|
905
|
+
root = await realpath(settings.browser.uploadRoot);
|
|
906
|
+
const requested = path.isAbsolute(fakeMedia) ? fakeMedia : path.resolve(root, fakeMedia);
|
|
907
|
+
audio = await realpath(requested);
|
|
908
|
+
} catch {
|
|
909
|
+
throw new JevBrowserError("fake_media could not read the configured WAV file");
|
|
910
|
+
}
|
|
911
|
+
const relative = path.relative(root, audio);
|
|
912
|
+
if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
913
|
+
throw new JevBrowserError("fake_media path must stay inside JEV_BROWSER_UPLOAD_ROOT");
|
|
914
|
+
}
|
|
915
|
+
if (path.extname(audio).toLowerCase() !== ".wav") throw new JevBrowserError("fake_media accepts WAV files only");
|
|
916
|
+
const file = await stat(audio);
|
|
917
|
+
if (!file.isFile() || file.size > settings.browser.maxUploadFileBytes) {
|
|
918
|
+
throw new JevBrowserError("fake_media must be a regular WAV file within the configured file-size limit");
|
|
919
|
+
}
|
|
920
|
+
const header = await readFile(audio, { encoding: null, flag: "r" });
|
|
921
|
+
if (header.length < 12 || header.toString("ascii", 0, 4) !== "RIFF" || header.toString("ascii", 8, 12) !== "WAVE") {
|
|
922
|
+
throw new JevBrowserError("fake_media file does not have a valid RIFF/WAVE header");
|
|
923
|
+
}
|
|
924
|
+
return audio;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
async function browserSessionConfiguration(options, settings) {
|
|
928
|
+
const audioPath = await resolveFakeMediaPath(options.fakeMedia, settings);
|
|
929
|
+
const contextOptions = {};
|
|
930
|
+
if (options.viewport) contextOptions.viewport = options.viewport;
|
|
931
|
+
if (options.mobile) contextOptions.isMobile = true;
|
|
932
|
+
if (options.mobile) contextOptions.hasTouch = true;
|
|
933
|
+
if (options.deviceScaleFactor !== undefined) contextOptions.deviceScaleFactor = options.deviceScaleFactor;
|
|
934
|
+
if (options.locale) contextOptions.locale = options.locale;
|
|
935
|
+
if (options.timezoneId) contextOptions.timezoneId = options.timezoneId;
|
|
936
|
+
if (options.colorScheme) contextOptions.colorScheme = options.colorScheme;
|
|
937
|
+
if (options.geolocation) contextOptions.geolocation = options.geolocation;
|
|
938
|
+
if (options.permissions.length) contextOptions.permissions = options.permissions;
|
|
939
|
+
if (options.recordVideo) {
|
|
940
|
+
contextOptions.recordVideo = {
|
|
941
|
+
dir: settings.browser.artifactDirectory,
|
|
942
|
+
size: options.viewport || settings.browser.recordVideoSize,
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
const launchArgs = audioPath ? [
|
|
946
|
+
"--use-fake-device-for-media-stream",
|
|
947
|
+
"--use-fake-ui-for-media-stream",
|
|
948
|
+
`--use-file-for-fake-audio-capture=${audioPath}`,
|
|
949
|
+
] : [];
|
|
950
|
+
return { contextOptions, launchArgs };
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function resolveEnvironmentText(plans, settings) {
|
|
954
|
+
for (const plan of Object.values(plans)) {
|
|
955
|
+
for (const step of plan.steps) {
|
|
956
|
+
if (step.action !== "type" || !step.textEnv) continue;
|
|
957
|
+
const value = settings.env?.[step.textEnv];
|
|
958
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
959
|
+
throw new JevBrowserError(`type text_env references missing or empty environment variable ${step.textEnv}`);
|
|
960
|
+
}
|
|
961
|
+
if (value.length > settings.browser.maxTextEntryChars) {
|
|
962
|
+
throw new JevBrowserError(`environment value ${step.textEnv} exceeds the configured character limit`);
|
|
963
|
+
}
|
|
964
|
+
step.text = value;
|
|
965
|
+
step.sensitive = true;
|
|
966
|
+
delete step.textEnv;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
631
970
|
|
|
632
971
|
async function restoreHiddenDialogs(hiddenDialogs) {
|
|
633
972
|
for (const { dialog, previous } of hiddenDialogs.reverse()) {
|
|
@@ -893,71 +1232,89 @@ function containsExpectedOutcome(snapshot, phrase) {
|
|
|
893
1232
|
return containsPhrase(expectedOutcomeText(snapshot), phrase);
|
|
894
1233
|
}
|
|
895
1234
|
|
|
896
|
-
function createNetworkTracker(page, settings) {
|
|
897
|
-
const active = new Map();
|
|
898
|
-
const history = [];
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
1235
|
+
function createNetworkTracker(page, settings, loginUrlContains = []) {
|
|
1236
|
+
const active = new Map();
|
|
1237
|
+
const history = [];
|
|
1238
|
+
const ignored = new Set();
|
|
1239
|
+
let sequence = 0;
|
|
1240
|
+
const shouldIgnore = (request) => {
|
|
1241
|
+
const type = request.resourceType?.();
|
|
1242
|
+
return type === "websocket" || type === "eventsource" || /^wss?:/i.test(request.url());
|
|
1243
|
+
};
|
|
1244
|
+
const record = (type, request) => {
|
|
1245
|
+
if (shouldIgnore(request)) {
|
|
1246
|
+
if (type === "request") ignored.add(request);
|
|
1247
|
+
if (type === "finished" || type === "failed") ignored.delete(request);
|
|
1248
|
+
return undefined;
|
|
1249
|
+
}
|
|
1250
|
+
if (ignored.has(request)) return undefined;
|
|
1251
|
+
const entry = { sequence: ++sequence, type, url: request.url(), method: request.method(), at: performance.now(), request };
|
|
902
1252
|
if (type === "request") active.set(request, entry);
|
|
903
1253
|
else if (type === "finished" || type === "failed") active.delete(request);
|
|
904
1254
|
history.push(entry);
|
|
905
|
-
const historyLimit = settings.browser.
|
|
1255
|
+
const historyLimit = settings.browser.maxNetworkHistoryEvents;
|
|
906
1256
|
if (history.length > historyLimit) history.splice(0, history.length - historyLimit);
|
|
907
1257
|
return entry;
|
|
908
1258
|
};
|
|
909
1259
|
const onRequest = (request) => record("request", request);
|
|
910
|
-
const onResponse = (response) => {
|
|
911
|
-
const entry = record("response", response.request());
|
|
1260
|
+
const onResponse = (response) => {
|
|
1261
|
+
const entry = record("response", response.request());
|
|
1262
|
+
if (!entry) return;
|
|
912
1263
|
entry.response = response;
|
|
913
1264
|
entry.status = response.status();
|
|
914
|
-
};
|
|
915
|
-
const onFinished = (request) => record("finished", request);
|
|
916
|
-
const onFailed = (request) => record("failed", request);
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
1265
|
+
};
|
|
1266
|
+
const onFinished = (request) => record("finished", request);
|
|
1267
|
+
const onFailed = (request) => record("failed", request);
|
|
1268
|
+
const trackedPages = new Set();
|
|
1269
|
+
const attachPage = (trackedPage) => {
|
|
1270
|
+
if (trackedPages.has(trackedPage)) return;
|
|
1271
|
+
trackedPages.add(trackedPage);
|
|
1272
|
+
trackedPage.on("request", onRequest);
|
|
1273
|
+
trackedPage.on("response", onResponse);
|
|
1274
|
+
trackedPage.on("requestfinished", onFinished);
|
|
1275
|
+
trackedPage.on("requestfailed", onFailed);
|
|
1276
|
+
};
|
|
1277
|
+
attachPage(page);
|
|
1278
|
+
return {
|
|
1279
|
+
mark: () => sequence,
|
|
1280
|
+
attachPage,
|
|
1281
|
+
since: (marker, urlContains) => history.filter((entry) => entry.sequence > marker
|
|
1282
|
+
&& (urlContains === undefined || entry.url.includes(urlContains))),
|
|
924
1283
|
responsesSince: (marker, urlContains, method) => history.filter((entry) => entry.sequence > marker
|
|
925
1284
|
&& entry.type === "response" && entry.url.includes(urlContains) && entry.method === method),
|
|
926
|
-
authenticationFailureSince: (marker) => history.find((entry) =>
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
1285
|
+
authenticationFailureSince: (marker) => history.find((entry) => {
|
|
1286
|
+
if (entry.sequence <= marker || entry.type !== "response" || ![401, 403].includes(entry.status)) return false;
|
|
1287
|
+
return loginUrlContains.some((needle) => entry.url.toLocaleLowerCase().includes(needle.toLocaleLowerCase()));
|
|
1288
|
+
}),
|
|
1289
|
+
activeFor: (urlContains) => [...active.values()].filter((entry) => entry.url.includes(urlContains)),
|
|
1290
|
+
dispose: () => {
|
|
1291
|
+
for (const trackedPage of trackedPages) {
|
|
1292
|
+
trackedPage.off("request", onRequest);
|
|
1293
|
+
trackedPage.off("response", onResponse);
|
|
1294
|
+
trackedPage.off("requestfinished", onFinished);
|
|
1295
|
+
trackedPage.off("requestfailed", onFailed);
|
|
1296
|
+
}
|
|
1297
|
+
trackedPages.clear();
|
|
1298
|
+
active.clear();
|
|
1299
|
+
ignored.clear();
|
|
1300
|
+
history.length = 0;
|
|
936
1301
|
},
|
|
937
1302
|
};
|
|
938
1303
|
}
|
|
939
1304
|
|
|
940
|
-
async function waitForFilteredNetworkIdle(tracker, urlContains, marker, timeout, settings) {
|
|
941
|
-
const started = performance.now();
|
|
942
|
-
let
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
}
|
|
951
|
-
const active = tracker.activeFor(urlContains);
|
|
952
|
-
if (active.length) sawRequest = true;
|
|
953
|
-
if (sawRequest && active.length === 0 && performance.now() - lastMatchingActivity >= quietMs) return;
|
|
954
|
-
await new Promise((resolve) => setTimeout(resolve, Math.min(settings.browser.visibilityPollMs, remainingMs(started + timeout))));
|
|
955
|
-
}
|
|
956
|
-
if (!sawRequest) throw new JevBrowserError(`network_idle did not observe a request matching url_contains ${JSON.stringify(urlContains)} within ${timeout}ms`);
|
|
957
|
-
throw new JevBrowserError(`requests matching url_contains ${JSON.stringify(urlContains)} did not become idle within ${timeout}ms`);
|
|
1305
|
+
async function waitForFilteredNetworkIdle(tracker, urlContains, marker, timeout, quietMs, settings) {
|
|
1306
|
+
const started = performance.now();
|
|
1307
|
+
let lastMatchingActivity = started;
|
|
1308
|
+
while (performance.now() - started < timeout) {
|
|
1309
|
+
const events = tracker.since(marker, urlContains);
|
|
1310
|
+
if (events.length) lastMatchingActivity = Math.max(lastMatchingActivity, events.at(-1).at);
|
|
1311
|
+
if (performance.now() - lastMatchingActivity >= quietMs) return;
|
|
1312
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(settings.browser.visibilityPollMs, remainingMs(started + timeout))));
|
|
1313
|
+
}
|
|
1314
|
+
throw new JevBrowserError(`network activity${urlContains ? ` matching url_contains ${JSON.stringify(urlContains)}` : ""} did not become idle within ${timeout}ms`);
|
|
958
1315
|
}
|
|
959
1316
|
|
|
960
|
-
async function waitForAngularIdle(page, timeout, settings) {
|
|
1317
|
+
async function waitForAngularIdle(page, timeout, settings, optional = false) {
|
|
961
1318
|
const deadline = performance.now() + timeout;
|
|
962
1319
|
let consecutiveIdle = 0;
|
|
963
1320
|
while (performance.now() < deadline) {
|
|
@@ -981,17 +1338,22 @@ async function waitForAngularIdle(page, timeout, settings) {
|
|
|
981
1338
|
};
|
|
982
1339
|
});
|
|
983
1340
|
} catch {
|
|
1341
|
+
if (optional) return false;
|
|
984
1342
|
throw new JevBrowserError("angular_idle could not inspect the page; the AngularJS application may have navigated or closed");
|
|
985
1343
|
}
|
|
986
|
-
if (!state)
|
|
1344
|
+
if (!state) {
|
|
1345
|
+
if (optional) return false;
|
|
1346
|
+
throw new JevBrowserError("angular_idle requires an AngularJS 1.x page with an accessible injector");
|
|
1347
|
+
}
|
|
987
1348
|
consecutiveIdle = state.pending === 0 && !state.digesting && state.outstanding === 0 ? consecutiveIdle + 1 : 0;
|
|
988
1349
|
if (consecutiveIdle >= 2) return;
|
|
989
1350
|
await new Promise((resolve) => setTimeout(resolve, Math.min(50, remainingMs(deadline))));
|
|
990
1351
|
}
|
|
1352
|
+
if (optional) return false;
|
|
991
1353
|
throw new JevBrowserError(`AngularJS still had pending requests or digest work after ${timeout}ms`);
|
|
992
1354
|
}
|
|
993
1355
|
|
|
994
|
-
async function waitForValue(locator, expected, timeout, description, settings) {
|
|
1356
|
+
async function waitForValue(locator, expected, timeout, description, settings) {
|
|
995
1357
|
const deadline = performance.now() + timeout;
|
|
996
1358
|
while (performance.now() < deadline) {
|
|
997
1359
|
try {
|
|
@@ -1090,12 +1452,46 @@ function remainingMs(deadline) {
|
|
|
1090
1452
|
return Math.max(1, Math.ceil(deadline - performance.now()));
|
|
1091
1453
|
}
|
|
1092
1454
|
|
|
1093
|
-
function browserDisconnected(error) {
|
|
1455
|
+
function browserDisconnected(error) {
|
|
1094
1456
|
return /target (?:page, context or browser )?has been closed|browser (?:has been closed|disconnected)|context has been closed|connection closed/i
|
|
1095
1457
|
.test(String(error?.message || error?.name || ""));
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
function snapshotStabilityKey(snapshot) {
|
|
1461
|
+
return snapshot.split(/\r?\n/)
|
|
1462
|
+
.filter((line) => !/^\s*-\s*text:/i.test(line))
|
|
1463
|
+
.map((line) => line.replace(/"(?:\\.|[^"\\])*"/g, '""').replace(/\bvalue=(?:"[^"]*"|'[^']*'|\S+)/gi, 'value=""'))
|
|
1464
|
+
.join("\n");
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
async function pageStructureSignature(page, snapshot) {
|
|
1468
|
+
try {
|
|
1469
|
+
const structure = await page.evaluate(() => {
|
|
1470
|
+
const elements = [];
|
|
1471
|
+
const visit = (root) => {
|
|
1472
|
+
for (const element of root.querySelectorAll("*")) {
|
|
1473
|
+
elements.push([
|
|
1474
|
+
element.localName,
|
|
1475
|
+
element.getAttribute("role") || "",
|
|
1476
|
+
element.id || "",
|
|
1477
|
+
element.getAttribute("type") || "",
|
|
1478
|
+
element.getAttribute("aria-busy") || "",
|
|
1479
|
+
Boolean(element.disabled),
|
|
1480
|
+
]);
|
|
1481
|
+
if (element.shadowRoot) visit(element.shadowRoot);
|
|
1482
|
+
if (elements.length >= 5000) return;
|
|
1483
|
+
}
|
|
1484
|
+
};
|
|
1485
|
+
visit(document);
|
|
1486
|
+
return elements;
|
|
1487
|
+
});
|
|
1488
|
+
return JSON.stringify(structure);
|
|
1489
|
+
} catch {
|
|
1490
|
+
return snapshotStabilityKey(snapshot);
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
async function waitForPageReady(page, options, settings, secrets, continued = false) {
|
|
1099
1495
|
const started = performance.now();
|
|
1100
1496
|
const deadline = started + options.readyTimeoutMs;
|
|
1101
1497
|
const bestEffortLoadState = async (state) => {
|
|
@@ -1110,7 +1506,7 @@ async function waitForPageReady(page, options, settings, secrets, continued = fa
|
|
|
1110
1506
|
if (options.readyNetworkIdle) await bestEffortLoadState("networkidle");
|
|
1111
1507
|
}
|
|
1112
1508
|
|
|
1113
|
-
let
|
|
1509
|
+
let previousStabilityKey = "";
|
|
1114
1510
|
let stableSince = 0;
|
|
1115
1511
|
let lastSnapshot = "";
|
|
1116
1512
|
while (performance.now() < deadline) {
|
|
@@ -1119,24 +1515,25 @@ async function waitForPageReady(page, options, settings, secrets, continued = fa
|
|
|
1119
1515
|
timeoutMs: remainingMs(deadline),
|
|
1120
1516
|
includeHidden: options.snapshotIncludeHidden,
|
|
1121
1517
|
});
|
|
1122
|
-
lastSnapshot = snapshot;
|
|
1518
|
+
lastSnapshot = snapshot;
|
|
1519
|
+
const stabilityKey = await pageStructureSignature(page, snapshot);
|
|
1123
1520
|
const readyTextVisible = !options.readyText || containsPhrase(snapshot, options.readyText);
|
|
1124
|
-
const usefulSnapshot = await hasRenderedContent(page, snapshot);
|
|
1521
|
+
const usefulSnapshot = await hasRenderedContent(page, snapshot, options.busySelectors);
|
|
1125
1522
|
if (continued && snapshot && readyTextVisible && usefulSnapshot) {
|
|
1126
1523
|
return { snapshot, readyMs: performance.now() - started };
|
|
1127
1524
|
}
|
|
1128
1525
|
if (snapshot && readyTextVisible && usefulSnapshot) {
|
|
1129
|
-
if (
|
|
1526
|
+
if (stabilityKey === previousStabilityKey) {
|
|
1130
1527
|
if (!stableSince) stableSince = performance.now();
|
|
1131
1528
|
if (performance.now() - stableSince >= options.readyStableMs) {
|
|
1132
1529
|
return { snapshot, readyMs: performance.now() - started };
|
|
1133
1530
|
}
|
|
1134
1531
|
} else {
|
|
1135
|
-
|
|
1136
|
-
stableSince = performance.now();
|
|
1137
|
-
}
|
|
1138
|
-
} else {
|
|
1139
|
-
|
|
1532
|
+
previousStabilityKey = stabilityKey;
|
|
1533
|
+
stableSince = performance.now();
|
|
1534
|
+
}
|
|
1535
|
+
} else {
|
|
1536
|
+
previousStabilityKey = "";
|
|
1140
1537
|
stableSince = 0;
|
|
1141
1538
|
}
|
|
1142
1539
|
await new Promise((resolve) => setTimeout(resolve, Math.min(100, remainingMs(deadline))));
|
|
@@ -1147,17 +1544,93 @@ async function waitForPageReady(page, options, settings, secrets, continued = fa
|
|
|
1147
1544
|
throw error;
|
|
1148
1545
|
}
|
|
1149
1546
|
|
|
1150
|
-
async function hasRenderedContent(page, snapshot) {
|
|
1547
|
+
async function hasRenderedContent(page, snapshot, busySelectors = []) {
|
|
1151
1548
|
const lines = snapshot.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
1152
1549
|
if (!lines.some((line, index) => index > 0 || /:\s+\S|"[^"]+"/.test(line))) return false;
|
|
1153
|
-
try {
|
|
1154
|
-
|
|
1550
|
+
try {
|
|
1551
|
+
const busy = page.locator('[aria-busy="true"]');
|
|
1552
|
+
const count = await busy.count();
|
|
1553
|
+
for (let index = 0; index < count; index += 1) {
|
|
1554
|
+
if (await isActuallyVisible(locatorAt(busy, index))) return false;
|
|
1555
|
+
}
|
|
1155
1556
|
} catch {
|
|
1156
1557
|
// The snapshot itself is the fallback when the DOM is transitioning.
|
|
1157
1558
|
}
|
|
1559
|
+
for (const selector of busySelectors) {
|
|
1560
|
+
try {
|
|
1561
|
+
const busy = page.locator(selector);
|
|
1562
|
+
const count = await busy.count();
|
|
1563
|
+
for (let index = 0; index < count; index += 1) {
|
|
1564
|
+
if (await isActuallyVisible(locatorAt(busy, index))) return false;
|
|
1565
|
+
}
|
|
1566
|
+
} catch {
|
|
1567
|
+
// A malformed or transient selector does not replace the accessible snapshot readiness signal.
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1158
1570
|
return true;
|
|
1159
1571
|
}
|
|
1160
1572
|
|
|
1573
|
+
function sensitiveParamSecrets(variables) {
|
|
1574
|
+
const sensitiveName = /(?:cpf|cnpj|e?mail|telefone|celular|cliente|nome|sacado|pagador|cedente|senha|password|token|api[_-]?key|valor|amount|pre[cç]o)/i;
|
|
1575
|
+
return Object.entries(variables).filter(([name]) => sensitiveName.test(name)).map(([, value]) => value);
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
async function waitForText(locator, expected, timeout, description, settings) {
|
|
1579
|
+
const normalized = expected.replace(/\s+/g, " ").trim().toLocaleLowerCase();
|
|
1580
|
+
const deadline = performance.now() + timeout;
|
|
1581
|
+
while (performance.now() < deadline) {
|
|
1582
|
+
try {
|
|
1583
|
+
const actual = (await locator.innerText({ timeout: Math.min(250, remainingMs(deadline)) }))
|
|
1584
|
+
.replace(/\s+/g, " ").trim().toLocaleLowerCase();
|
|
1585
|
+
if (actual.includes(normalized)) return;
|
|
1586
|
+
} catch {
|
|
1587
|
+
// SPA rerenders may briefly detach the target.
|
|
1588
|
+
}
|
|
1589
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(settings.browser.visibilityPollMs, remainingMs(deadline))));
|
|
1590
|
+
}
|
|
1591
|
+
throw new JevBrowserError(`${description} did not contain the expected text within ${timeout}ms`);
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
async function pageFingerprint(page) {
|
|
1595
|
+
let structure = [];
|
|
1596
|
+
try {
|
|
1597
|
+
structure = await page.evaluate(() => {
|
|
1598
|
+
const controls = [];
|
|
1599
|
+
const visit = (root) => {
|
|
1600
|
+
for (const element of root.querySelectorAll("a[href],button,input,textarea,select,[role]")) {
|
|
1601
|
+
const style = getComputedStyle(element);
|
|
1602
|
+
if (style.display === "none" || style.visibility === "hidden" || element.getClientRects().length === 0) continue;
|
|
1603
|
+
controls.push({
|
|
1604
|
+
tag: element.localName,
|
|
1605
|
+
role: element.getAttribute("role") || "",
|
|
1606
|
+
type: element.getAttribute("type") || "",
|
|
1607
|
+
name: element.getAttribute("name") || "",
|
|
1608
|
+
id: element.id || "",
|
|
1609
|
+
href: element instanceof HTMLAnchorElement ? `${element.origin}${element.pathname}` : "",
|
|
1610
|
+
disabled: Boolean(element.disabled || element.getAttribute("aria-disabled") === "true"),
|
|
1611
|
+
checked: "checked" in element ? Boolean(element.checked) : undefined,
|
|
1612
|
+
expanded: element.getAttribute("aria-expanded") || "",
|
|
1613
|
+
});
|
|
1614
|
+
if (element.shadowRoot) visit(element.shadowRoot);
|
|
1615
|
+
}
|
|
1616
|
+
};
|
|
1617
|
+
visit(document);
|
|
1618
|
+
return controls.slice(0, 2000);
|
|
1619
|
+
});
|
|
1620
|
+
} catch {
|
|
1621
|
+
// The page may close between a browser event and an authorization check.
|
|
1622
|
+
}
|
|
1623
|
+
let route = "unavailable";
|
|
1624
|
+
try {
|
|
1625
|
+
const url = new URL(page.url());
|
|
1626
|
+
route = `${url.origin}${url.pathname}`;
|
|
1627
|
+
} catch { /* Keep the unavailable marker. */ }
|
|
1628
|
+
return createHash("sha256").update(JSON.stringify({
|
|
1629
|
+
route,
|
|
1630
|
+
structure,
|
|
1631
|
+
})).digest("hex");
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1161
1634
|
async function snapshotAfterStep(page, settings, secrets, scope, previousSnapshot, stepNumber, warnings, includeHidden) {
|
|
1162
1635
|
const snapshot = await snapshotPage(page, settings, secrets, scope, { allowEmpty: true, includeHidden });
|
|
1163
1636
|
if (snapshot) return snapshot;
|
|
@@ -1315,22 +1788,45 @@ async function exactlyOne(locator, description, timeout, settings) {
|
|
|
1315
1788
|
|
|
1316
1789
|
async function scopedRoot(root, within, timeout, settings) {
|
|
1317
1790
|
if (!within) return root;
|
|
1318
|
-
let
|
|
1319
|
-
|
|
1320
|
-
if (within.
|
|
1321
|
-
locator = root.getByRole("row").filter({ hasText: new RegExp(escapeRegex(within.rowContaining), "iu") });
|
|
1322
|
-
description = `row containing ${JSON.stringify(within.rowContaining)}`;
|
|
1323
|
-
} else if (within.rowContainingExact) {
|
|
1324
|
-
locator = root.getByRole("row").filter({ has: root.getByText(within.rowContainingExact, { exact: true }) });
|
|
1325
|
-
description = `row containing exact text ${JSON.stringify(within.rowContainingExact)}`;
|
|
1326
|
-
} else {
|
|
1791
|
+
let scope = root;
|
|
1792
|
+
const descriptions = [];
|
|
1793
|
+
if (within.role) {
|
|
1327
1794
|
const options = within.name === undefined ? {} : { name: within.name, exact: true };
|
|
1328
|
-
|
|
1329
|
-
description = `visible ${within.role} container${within.name === undefined ? "" : ` ${JSON.stringify(within.name)}`}`;
|
|
1795
|
+
const container = root.getByRole(within.role, options);
|
|
1796
|
+
const description = `visible ${within.role} container${within.name === undefined ? "" : ` ${JSON.stringify(within.name)}`}`;
|
|
1797
|
+
const { visible } = await visibleIndexes(container, timeout, `within ${description}`, settings);
|
|
1798
|
+
if (visible.length !== 1) throw new JevBrowserError(`within ${description} must match exactly one visible container; found ${visible.length}`);
|
|
1799
|
+
scope = locatorAt(container, visible[0]);
|
|
1800
|
+
descriptions.push(description);
|
|
1801
|
+
}
|
|
1802
|
+
const rowText = within.rowContainingExact ?? within.rowContainingWord ?? within.rowContaining;
|
|
1803
|
+
if (rowText !== undefined) {
|
|
1804
|
+
const rows = scope.getByRole("row");
|
|
1805
|
+
const mode = within.rowContainingExact !== undefined ? "exact"
|
|
1806
|
+
: within.rowContainingWord !== undefined ? "whole word" : "substring";
|
|
1807
|
+
if (within.rowContainingWord !== undefined) {
|
|
1808
|
+
const pattern = new RegExp(`(?:^|[^\\p{L}\\p{N}])${escapeRegex(rowText)}(?:$|[^\\p{L}\\p{N}])`, "iu");
|
|
1809
|
+
const count = await rows.count();
|
|
1810
|
+
const matches = [];
|
|
1811
|
+
for (let index = 0; index < count; index += 1) {
|
|
1812
|
+
const row = locatorAt(rows, index);
|
|
1813
|
+
if (await isActuallyVisible(row) && pattern.test(await row.innerText())) matches.push(index);
|
|
1814
|
+
}
|
|
1815
|
+
const description = `row containing ${mode} text ${JSON.stringify(rowText)}`;
|
|
1816
|
+
if (matches.length !== 1) throw new JevBrowserError(`within ${description} must match exactly one visible row; found ${matches.length}`);
|
|
1817
|
+
return locatorAt(rows, matches[0]);
|
|
1818
|
+
}
|
|
1819
|
+
const matcher = within.rowContainingExact !== undefined
|
|
1820
|
+
? { has: scope.getByText(rowText, { exact: true }) }
|
|
1821
|
+
: { hasText: new RegExp(escapeRegex(rowText), "iu") };
|
|
1822
|
+
const rowLocator = rows.filter(matcher);
|
|
1823
|
+
const description = `row containing ${mode} text ${JSON.stringify(rowText)}`;
|
|
1824
|
+
const { visible } = await visibleIndexes(rowLocator, timeout, `within ${description}`, settings);
|
|
1825
|
+
if (visible.length !== 1) throw new JevBrowserError(`within ${description} must match exactly one visible row; found ${visible.length}`);
|
|
1826
|
+
scope = locatorAt(rowLocator, visible[0]);
|
|
1827
|
+
descriptions.push(description);
|
|
1330
1828
|
}
|
|
1331
|
-
|
|
1332
|
-
if (visible.length !== 1) throw new JevBrowserError(`within ${description} must match exactly one visible container; found ${visible.length}`);
|
|
1333
|
-
return locatorAt(locator, visible[0]);
|
|
1829
|
+
return scope;
|
|
1334
1830
|
}
|
|
1335
1831
|
|
|
1336
1832
|
function accessibleNamePattern(name) {
|
|
@@ -1338,11 +1834,40 @@ function accessibleNamePattern(name) {
|
|
|
1338
1834
|
return new RegExp(`^[\\s\\uE000-\\uF8FF]*${normalized}[\\s\\uE000-\\uF8FF]*$`, "u");
|
|
1339
1835
|
}
|
|
1340
1836
|
|
|
1341
|
-
function roleOptions(step) {
|
|
1342
|
-
if (step.name === "") return { name: UNNAMED_ACCESSIBLE_NAME };
|
|
1343
|
-
if (step.name === undefined) return {};
|
|
1344
|
-
return { name: step.name, exact:
|
|
1345
|
-
}
|
|
1837
|
+
function roleOptions(step) {
|
|
1838
|
+
if (step.name === "") return { name: UNNAMED_ACCESSIBLE_NAME };
|
|
1839
|
+
if (step.name === undefined) return {};
|
|
1840
|
+
if (step.nameMatch === "contains") return { name: step.name, exact: false };
|
|
1841
|
+
if (step.nameMatch === "regex") return { name: new RegExp(step.name, "u") };
|
|
1842
|
+
return { name: step.name, exact: true };
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
async function waitForUrlContains(page, fragment, timeout, settings) {
|
|
1846
|
+
const deadline = performance.now() + timeout;
|
|
1847
|
+
while (performance.now() < deadline) {
|
|
1848
|
+
if (page.url().includes(fragment)) return;
|
|
1849
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(settings.browser.visibilityPollMs, remainingMs(deadline))));
|
|
1850
|
+
}
|
|
1851
|
+
throw new JevBrowserError(`page URL did not contain ${JSON.stringify(fragment)} within ${timeout}ms`);
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
async function clickAndWaitForLoad(page, control, timeout) {
|
|
1855
|
+
const isPageClosed = () => typeof page.isClosed === "function" && page.isClosed();
|
|
1856
|
+
const waitForDocument = async () => {
|
|
1857
|
+
if (!isPageClosed() && typeof page.waitForLoadState === "function") {
|
|
1858
|
+
await page.waitForLoadState("domcontentloaded", { timeout });
|
|
1859
|
+
}
|
|
1860
|
+
};
|
|
1861
|
+
try {
|
|
1862
|
+
await control.click({ timeout });
|
|
1863
|
+
} catch (error) {
|
|
1864
|
+
if (!/execution context was destroyed|frame was detached/i.test(String(error?.message || ""))) throw error;
|
|
1865
|
+
if (isPageClosed()) throw error;
|
|
1866
|
+
await waitForDocument();
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1869
|
+
await waitForDocument();
|
|
1870
|
+
}
|
|
1346
1871
|
|
|
1347
1872
|
function xpathRolePredicate(role) {
|
|
1348
1873
|
const nativePredicates = {
|
|
@@ -1403,10 +1928,10 @@ async function targetLocator(root, step, timeout, settings) {
|
|
|
1403
1928
|
locator = targetRoot.getByTestId(step.testId);
|
|
1404
1929
|
} else if (step.near) {
|
|
1405
1930
|
locator = relativeTarget(targetRoot.getByText(step.near.text, { exact: true }).first(), step.role);
|
|
1406
|
-
} else if (step.role) {
|
|
1407
|
-
locator = targetRoot.getByRole(step.role, roleOptions(step));
|
|
1408
|
-
if (step.name && await locator.count() === 0) {
|
|
1409
|
-
locator = targetRoot.getByRole(step.role, { name: accessibleNamePattern(step.name) });
|
|
1931
|
+
} else if (step.role) {
|
|
1932
|
+
locator = targetRoot.getByRole(step.role, roleOptions(step));
|
|
1933
|
+
if (step.name && (!step.nameMatch || step.nameMatch === "exact") && await locator.count() === 0) {
|
|
1934
|
+
locator = targetRoot.getByRole(step.role, { name: accessibleNamePattern(step.name) });
|
|
1410
1935
|
}
|
|
1411
1936
|
} else {
|
|
1412
1937
|
throw new JevBrowserError("step has no resolvable target; use role/name, label, placeholder, title, text, target_text, test_id, selector, or near");
|
|
@@ -1429,11 +1954,11 @@ function describeTarget(step, secrets) {
|
|
|
1429
1954
|
}
|
|
1430
1955
|
if (step.near) parts.push(`near text ${JSON.stringify(redact(step.near.text, secrets))}`);
|
|
1431
1956
|
if (step.within) {
|
|
1432
|
-
parts.push(step.within.
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1957
|
+
if (step.within.role) parts.push(`within ${step.within.role}${step.within.name === undefined ? "" : ` ${JSON.stringify(redact(step.within.name, secrets))}`}`);
|
|
1958
|
+
const rowPrefix = step.within.role ? "" : "within ";
|
|
1959
|
+
if (step.within.rowContainingExact !== undefined) parts.push(`${rowPrefix}row_containing_exact ${JSON.stringify(redact(step.within.rowContainingExact, secrets))}`);
|
|
1960
|
+
else if (step.within.rowContainingWord !== undefined) parts.push(`${rowPrefix}row_containing_word ${JSON.stringify(redact(step.within.rowContainingWord, secrets))}`);
|
|
1961
|
+
else if (step.within.rowContaining !== undefined) parts.push(`${rowPrefix}row_containing ${JSON.stringify(redact(step.within.rowContaining, secrets))}`);
|
|
1437
1962
|
}
|
|
1438
1963
|
if (step.index !== undefined) parts.push(`index ${step.index}`);
|
|
1439
1964
|
return parts.length ? parts.join(" + ") : "focused element";
|
|
@@ -1515,8 +2040,8 @@ async function namedTarget(root, step, snapshot, secrets, timeout, settings) {
|
|
|
1515
2040
|
if (step.fragileName && step.action === "click") {
|
|
1516
2041
|
const title = await selected.evaluate((element) => [element, ...element.querySelectorAll("[title]")]
|
|
1517
2042
|
.map((candidate) => candidate.getAttribute("title") || "").find(Boolean) || "").catch(() => "");
|
|
1518
|
-
if (
|
|
1519
|
-
throw new JevBrowserError(`refused index-only click on a
|
|
2043
|
+
if (MUTATING_LABEL.test(title)) {
|
|
2044
|
+
throw new JevBrowserError(`refused index-only click on a mutating control titled ${JSON.stringify(redact(title, secrets))}; provide a stable locator`);
|
|
1520
2045
|
}
|
|
1521
2046
|
}
|
|
1522
2047
|
return selected;
|
|
@@ -1716,9 +2241,61 @@ async function saveDownload(download, settings, ledger) {
|
|
|
1716
2241
|
}
|
|
1717
2242
|
}
|
|
1718
2243
|
|
|
1719
|
-
async function
|
|
1720
|
-
const
|
|
1721
|
-
|
|
2244
|
+
async function visibleMenuItem(root, label, timeout, settings) {
|
|
2245
|
+
for (const role of ["menuitem", "link", "button"]) {
|
|
2246
|
+
const locator = root.getByRole(role, { name: label, exact: true });
|
|
2247
|
+
try {
|
|
2248
|
+
const { visible } = await visibleIndexes(locator, Math.min(timeout, 250), `menu ${role} ${JSON.stringify(label)}`, settings);
|
|
2249
|
+
if (visible.length > 0) {
|
|
2250
|
+
// Nested application menus often reuse the same label for a parent and its newly opened leaf.
|
|
2251
|
+
return locatorAt(locator, visible.at(-1));
|
|
2252
|
+
}
|
|
2253
|
+
} catch {
|
|
2254
|
+
// Try the next semantic role before falling back to exact visible text.
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
const textLocator = root.getByText(label, { exact: true });
|
|
2258
|
+
const { visible } = await visibleIndexes(textLocator, Math.min(timeout, 250), `menu text ${JSON.stringify(label)}`, settings);
|
|
2259
|
+
if (visible.length === 0) throw new JevBrowserError(`menu item ${JSON.stringify(label)} must match a visible control; found 0`);
|
|
2260
|
+
return locatorAt(textLocator, visible.at(-1));
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
async function navigateMenu(page, root, step, settings, execution) {
|
|
2264
|
+
let item;
|
|
2265
|
+
try {
|
|
2266
|
+
item = await visibleMenuItem(root, step.path[0], 250, settings);
|
|
2267
|
+
} catch {
|
|
2268
|
+
const toggle = page.locator(step.toggleSelector);
|
|
2269
|
+
const { visible } = await visibleIndexes(toggle, 250, `menu toggle ${step.toggleSelector}`, settings);
|
|
2270
|
+
if (visible.length !== 1) throw new JevBrowserError(`menu toggle ${step.toggleSelector} must match exactly one visible control; found ${visible.length}`);
|
|
2271
|
+
await locatorAt(toggle, visible[0]).click({ timeout: step.timeoutMs ?? settings.browser.actionTimeoutMs });
|
|
2272
|
+
if (execution.autoAngularIdle) await waitForAngularIdle(page, step.timeoutMs ?? settings.browser.actionTimeoutMs, settings, true);
|
|
2273
|
+
item = await visibleMenuItem(root, step.path[0], step.timeoutMs ?? settings.browser.actionTimeoutMs, settings);
|
|
2274
|
+
}
|
|
2275
|
+
for (let index = 0; index < step.path.length; index += 1) {
|
|
2276
|
+
if (index > 0) item = await visibleMenuItem(root, step.path[index], step.timeoutMs ?? settings.browser.actionTimeoutMs, settings);
|
|
2277
|
+
await item.click({ timeout: step.timeoutMs ?? settings.browser.actionTimeoutMs });
|
|
2278
|
+
if (execution.autoAngularIdle) await waitForAngularIdle(page, step.timeoutMs ?? settings.browser.actionTimeoutMs, settings, true);
|
|
2279
|
+
}
|
|
2280
|
+
return { action: step.action, navigated_menu: step.path };
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
async function executeStep(page, step, settings, snapshot, secrets, downloadLedger, execution = {}) {
|
|
2284
|
+
const root = await stepRoot(page, step.frame);
|
|
2285
|
+
const timeout = step.timeoutMs ?? settings.browser.actionTimeoutMs;
|
|
2286
|
+
if (step.action === "navigate") {
|
|
2287
|
+
let destination;
|
|
2288
|
+
try { destination = new URL(step.url, page.url()).toString(); } catch {
|
|
2289
|
+
throw new JevBrowserError("navigate URL must be absolute or relative to the current page");
|
|
2290
|
+
}
|
|
2291
|
+
destination = validateUrl(destination);
|
|
2292
|
+
if (execution.allowMutations && !localHostOnly(destination)) {
|
|
2293
|
+
throw new JevBrowserError("allow_mutations only permits navigation to localhost, 127.0.0.1, or ::1");
|
|
2294
|
+
}
|
|
2295
|
+
await page.goto(destination, { waitUntil: "domcontentloaded", timeout });
|
|
2296
|
+
execution.browserPool?.setNavigationError?.(null);
|
|
2297
|
+
return { action: step.action, url: evidenceUrl(destination, secrets, settings.jev.maxDiagnosticChars) };
|
|
2298
|
+
}
|
|
1722
2299
|
if (step.action === "assert_network") {
|
|
1723
2300
|
const response = await waitForNetworkAssertion(
|
|
1724
2301
|
execution.networkTracker,
|
|
@@ -1732,6 +2309,19 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
1732
2309
|
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 } : {}) };
|
|
1733
2310
|
}
|
|
1734
2311
|
if (step.action === "confirm_dialog") {
|
|
2312
|
+
const native = execution.nativeDialog?.result;
|
|
2313
|
+
if (native) {
|
|
2314
|
+
const expectedText = step.expectedText.replace(/\s+/g, " ").trim();
|
|
2315
|
+
if (!native.matched || native.handlerError || !native.message.toLocaleLowerCase().includes(expectedText.toLocaleLowerCase())) {
|
|
2316
|
+
throw new JevBrowserError(`confirm_dialog refused to act: native dialog text did not match ${JSON.stringify(redact(expectedText, secrets))}`);
|
|
2317
|
+
}
|
|
2318
|
+
return {
|
|
2319
|
+
action: step.action,
|
|
2320
|
+
confirmed_text: redact(expectedText, secrets),
|
|
2321
|
+
native_dialog: true,
|
|
2322
|
+
button: redact(step.button, secrets),
|
|
2323
|
+
};
|
|
2324
|
+
}
|
|
1735
2325
|
const dialog = await exactlyOne(root.getByRole("dialog"), "confirmation dialog", timeout, settings);
|
|
1736
2326
|
const actualText = (await dialog.innerText({ timeout })).replace(/\s+/g, " ").trim();
|
|
1737
2327
|
const expectedText = step.expectedText.replace(/\s+/g, " ").trim();
|
|
@@ -1783,29 +2373,29 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
1783
2373
|
await waitForAngularIdle(page, timeout, settings);
|
|
1784
2374
|
return { action: step.action, condition: step.condition };
|
|
1785
2375
|
}
|
|
1786
|
-
if (step.action === "wait_for_condition") {
|
|
1787
|
-
if (step.condition === "network_idle") {
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
} else {
|
|
2376
|
+
if (step.action === "wait_for_condition") {
|
|
2377
|
+
if (step.condition === "network_idle") {
|
|
2378
|
+
await waitForFilteredNetworkIdle(
|
|
2379
|
+
execution.networkTracker,
|
|
2380
|
+
step.urlContains,
|
|
2381
|
+
execution.networkMarker,
|
|
2382
|
+
timeout,
|
|
2383
|
+
execution.readyStableMs,
|
|
2384
|
+
settings,
|
|
2385
|
+
);
|
|
2386
|
+
} else if (step.condition === "url") {
|
|
2387
|
+
await waitForUrlContains(page, step.urlContains, timeout, settings);
|
|
2388
|
+
} else {
|
|
1800
2389
|
const locator = await targetLocator(root, step, timeout, settings);
|
|
1801
2390
|
await waitForLocatorHidden(locator, timeout, `hidden condition ${describeTarget(step, secrets)}`, settings, step.index);
|
|
1802
2391
|
}
|
|
1803
2392
|
return {
|
|
1804
2393
|
action: step.action,
|
|
1805
|
-
condition: step.condition,
|
|
1806
|
-
...(step.condition === "network_idle"
|
|
1807
|
-
? (step.urlContains ? { url_contains: redact(step.urlContains, secrets) } : {})
|
|
1808
|
-
: {
|
|
2394
|
+
condition: step.condition,
|
|
2395
|
+
...(step.condition === "network_idle"
|
|
2396
|
+
? (step.urlContains ? { url_contains: redact(step.urlContains, secrets) } : {})
|
|
2397
|
+
: step.condition === "url" ? { url_contains: redact(step.urlContains, secrets) }
|
|
2398
|
+
: { target: describeTarget(step, secrets) }),
|
|
1809
2399
|
};
|
|
1810
2400
|
}
|
|
1811
2401
|
if (step.action === "press") {
|
|
@@ -1822,14 +2412,18 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
1822
2412
|
if (step.action === "like_comment" || step.action === "unlike_comment") {
|
|
1823
2413
|
return { action: step.action, result: await reactToComment(root, step, timeout, settings) };
|
|
1824
2414
|
}
|
|
1825
|
-
if (step.action === "audit_accessibility") {
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
2415
|
+
if (step.action === "audit_accessibility") {
|
|
2416
|
+
const accessibilityAudit = await withTimeout(
|
|
2417
|
+
runAccessibilityAudit(page, step.standard, settings, {
|
|
2418
|
+
scope: step.scope,
|
|
2419
|
+
failOnImpact: step.failOnImpact,
|
|
2420
|
+
}),
|
|
2421
|
+
timeout,
|
|
2422
|
+
`accessibility audit exceeded the ${timeout}ms step timeout`,
|
|
2423
|
+
);
|
|
2424
|
+
return {
|
|
2425
|
+
action: step.action,
|
|
2426
|
+
accessibility_audit: accessibilityAudit,
|
|
1833
2427
|
};
|
|
1834
2428
|
}
|
|
1835
2429
|
if (step.action === "upload_file") {
|
|
@@ -1881,11 +2475,46 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
1881
2475
|
await waitForEnabled(control, timeout, describeTarget(step, secrets), settings);
|
|
1882
2476
|
return { action: step.action, enabled: true, ...targetEvidence(step, secrets, await describeResolvedTarget(control, step, secrets, settings)) };
|
|
1883
2477
|
}
|
|
1884
|
-
|
|
2478
|
+
if (step.action === "navigate_menu") return navigateMenu(page, root, step, settings, execution);
|
|
2479
|
+
const control = execution.preResolvedTarget || await namedTarget(root, step, snapshot, secrets, timeout, settings);
|
|
2480
|
+
if (step.action === "check" || step.action === "uncheck") {
|
|
2481
|
+
const resolvedTarget = await describeResolvedTarget(control, step, secrets, settings);
|
|
2482
|
+
if (step.action === "check") await control.check({ timeout });
|
|
2483
|
+
else await control.uncheck({ timeout });
|
|
2484
|
+
return { action: step.action, checked: step.action === "check", ...targetEvidence(step, secrets, resolvedTarget) };
|
|
2485
|
+
}
|
|
1885
2486
|
if (step.action === "click") {
|
|
1886
2487
|
const resolvedTarget = await describeResolvedTarget(control, step, secrets, settings);
|
|
1887
|
-
if (
|
|
1888
|
-
|
|
2488
|
+
if (step.expectPopup) {
|
|
2489
|
+
let popup;
|
|
2490
|
+
try {
|
|
2491
|
+
[popup] = await Promise.all([
|
|
2492
|
+
page.waitForEvent("popup", { timeout: settings.browser.downloadTimeoutMs }),
|
|
2493
|
+
clickAndWaitForLoad(page, control, timeout),
|
|
2494
|
+
]);
|
|
2495
|
+
} catch {
|
|
2496
|
+
throw new JevBrowserError("click did not open the expected popup before the configured timeout");
|
|
2497
|
+
}
|
|
2498
|
+
execution.popupPage = popup;
|
|
2499
|
+
execution.networkTracker?.attachPage(popup);
|
|
2500
|
+
await popup.waitForLoadState("domcontentloaded", { timeout: settings.browser.downloadTimeoutMs }).catch(() => {});
|
|
2501
|
+
const popupUrl = popup.url();
|
|
2502
|
+
const popupTitle = await popup.title().catch(() => "");
|
|
2503
|
+
const isPdf = /\.pdf(?:$|[?#])/i.test(popupUrl)
|
|
2504
|
+
|| await popup.locator('embed[type="application/pdf"], iframe[src*=".pdf" i]').count().then((count) => count > 0).catch(() => false);
|
|
2505
|
+
return {
|
|
2506
|
+
action: step.action,
|
|
2507
|
+
...targetEvidence(step, secrets, resolvedTarget),
|
|
2508
|
+
popup_url: evidenceUrl(popupUrl, secrets, settings.jev.maxDiagnosticChars),
|
|
2509
|
+
...(popupTitle ? { popup_title: redact(popupTitle, secrets).slice(0, settings.jev.maxDiagnosticChars) } : {}),
|
|
2510
|
+
popup_document_type: isPdf ? "pdf" : "page",
|
|
2511
|
+
};
|
|
2512
|
+
}
|
|
2513
|
+
if (!step.expectDownload) {
|
|
2514
|
+
await clickAndWaitForLoad(page, control, timeout);
|
|
2515
|
+
if (execution.nativeDialog?.result && !execution.nativeDialog.result.matched) {
|
|
2516
|
+
throw new JevBrowserError(`unexpected native ${execution.nativeDialog.result.type} dialog was dismissed: ${JSON.stringify(sanitizeDiagnosticText(execution.nativeDialog.result.message, secrets, settings.jev.maxDiagnosticChars))}`);
|
|
2517
|
+
}
|
|
1889
2518
|
return { action: step.action, ...targetEvidence(step, secrets, resolvedTarget) };
|
|
1890
2519
|
}
|
|
1891
2520
|
let download;
|
|
@@ -1903,7 +2532,7 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
1903
2532
|
downloaded_file: await saveDownload(download, settings, downloadLedger),
|
|
1904
2533
|
};
|
|
1905
2534
|
}
|
|
1906
|
-
if (step.action === "type") {
|
|
2535
|
+
if (step.action === "type") {
|
|
1907
2536
|
const resolvedTarget = await describeResolvedTarget(control, step, secrets, settings);
|
|
1908
2537
|
if (step.mode === "keys") {
|
|
1909
2538
|
await control.fill("", { timeout });
|
|
@@ -1928,10 +2557,25 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
1928
2557
|
name: step.name,
|
|
1929
2558
|
...targetEvidence(step, secrets, resolvedTarget),
|
|
1930
2559
|
};
|
|
1931
|
-
}
|
|
2560
|
+
}
|
|
2561
|
+
if (step.action === "clear") {
|
|
2562
|
+
const resolvedTarget = await describeResolvedTarget(control, step, secrets, settings);
|
|
2563
|
+
await control.fill("", { timeout });
|
|
2564
|
+
return { action: step.action, cleared: true, ...targetEvidence(step, secrets, resolvedTarget) };
|
|
2565
|
+
}
|
|
1932
2566
|
if (step.action === "select_option") {
|
|
1933
2567
|
const resolvedTarget = await describeResolvedTarget(control, step, secrets, settings);
|
|
1934
|
-
|
|
2568
|
+
if (step.optionMatch === "value") {
|
|
2569
|
+
await control.selectOption(step.option, { timeout });
|
|
2570
|
+
} else if (step.optionMatch === "label_contains") {
|
|
2571
|
+
const matches = await control.locator("option").evaluateAll((options, text) => options
|
|
2572
|
+
.filter((option) => String(option.label || option.textContent || "").toLocaleLowerCase().includes(text.toLocaleLowerCase()))
|
|
2573
|
+
.map((option) => ({ label: String(option.label || option.textContent || "").trim(), value: String(option.value) })), step.option);
|
|
2574
|
+
if (matches.length !== 1) throw new JevBrowserError(`select_option label_contains must match one option; found ${matches.length}`);
|
|
2575
|
+
await control.selectOption(matches[0].value, { timeout });
|
|
2576
|
+
} else {
|
|
2577
|
+
await control.selectOption({ label: step.option }, { timeout });
|
|
2578
|
+
}
|
|
1935
2579
|
return { action: step.action, ...targetEvidence(step, secrets, resolvedTarget) };
|
|
1936
2580
|
}
|
|
1937
2581
|
if (step.action === "hover") {
|
|
@@ -1948,11 +2592,7 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
1948
2592
|
};
|
|
1949
2593
|
}
|
|
1950
2594
|
if (step.action === "assert_text") {
|
|
1951
|
-
|
|
1952
|
-
const expected = step.expected.replace(/\s+/g, " ").trim();
|
|
1953
|
-
if (!actual.toLocaleLowerCase().includes(expected.toLocaleLowerCase())) {
|
|
1954
|
-
throw new JevBrowserError(`assert_text failed for ${describeTarget(step, secrets)}`);
|
|
1955
|
-
}
|
|
2595
|
+
await waitForText(control, step.expected, timeout, `assert_text for ${describeTarget(step, secrets)}`, settings);
|
|
1956
2596
|
return {
|
|
1957
2597
|
action: step.action,
|
|
1958
2598
|
asserted: true,
|
|
@@ -1960,8 +2600,7 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
1960
2600
|
};
|
|
1961
2601
|
}
|
|
1962
2602
|
if (step.action === "assert_value") {
|
|
1963
|
-
|
|
1964
|
-
if (actual !== step.expected) throw new JevBrowserError(`assert_value failed for ${describeTarget(step, secrets)}`);
|
|
2603
|
+
await waitForValue(control, step.expected, timeout, `assert_value for ${describeTarget(step, secrets)}`, settings);
|
|
1965
2604
|
return {
|
|
1966
2605
|
action: step.action,
|
|
1967
2606
|
asserted: true,
|
|
@@ -1975,6 +2614,24 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
1975
2614
|
throw new JevBrowserError("unsupported browser action");
|
|
1976
2615
|
}
|
|
1977
2616
|
|
|
2617
|
+
async function inferTargetMutation(page, step, snapshot, settings, secrets) {
|
|
2618
|
+
if (step.action !== "click") return { mutating: step.mutating === true };
|
|
2619
|
+
const root = await stepRoot(page, step.frame);
|
|
2620
|
+
const target = await namedTarget(root, step, snapshot, secrets, step.timeoutMs ?? settings.browser.actionTimeoutMs, settings);
|
|
2621
|
+
if (step.mutating === true) return { mutating: true, target };
|
|
2622
|
+
if (typeof target?.evaluate !== "function") return { mutating: false, target };
|
|
2623
|
+
const label = await target.evaluate((element) => [
|
|
2624
|
+
element.getAttribute("aria-label"),
|
|
2625
|
+
element.getAttribute("title"),
|
|
2626
|
+
element.getAttribute("value"),
|
|
2627
|
+
element.innerText,
|
|
2628
|
+
element.textContent,
|
|
2629
|
+
...[...element.querySelectorAll("[title]")].map((child) => child.getAttribute("title")),
|
|
2630
|
+
].filter(Boolean).join(" "));
|
|
2631
|
+
if (MUTATING_LABEL.test(label)) step.mutating = true;
|
|
2632
|
+
return { mutating: step.mutating === true, target };
|
|
2633
|
+
}
|
|
2634
|
+
|
|
1978
2635
|
function flowTimings(started, browserSessionMs, navigationMs, initialSnapshotMs, readyMs, jevDecisionMs, browserPlanMs) {
|
|
1979
2636
|
return {
|
|
1980
2637
|
browser_session_ms: Math.round(browserSessionMs * 10) / 10,
|
|
@@ -1987,9 +2644,20 @@ function flowTimings(started, browserSessionMs, navigationMs, initialSnapshotMs,
|
|
|
1987
2644
|
};
|
|
1988
2645
|
}
|
|
1989
2646
|
|
|
1990
|
-
function addDiagnostic(collection, value, limit) {
|
|
1991
|
-
if (collection.length < limit) collection.push(value);
|
|
1992
|
-
}
|
|
2647
|
+
function addDiagnostic(collection, value, limit) {
|
|
2648
|
+
if (collection.length < limit) collection.push(value);
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
function groupConsoleErrors(errors) {
|
|
2652
|
+
const grouped = new Map();
|
|
2653
|
+
for (const entry of errors) {
|
|
2654
|
+
const message = typeof entry === "string" ? entry : String(entry?.message || "");
|
|
2655
|
+
if (!message) continue;
|
|
2656
|
+
const count = typeof entry === "object" ? Math.max(1, Number(entry.count) || 1) : 1;
|
|
2657
|
+
grouped.set(message, (grouped.get(message) || 0) + count);
|
|
2658
|
+
}
|
|
2659
|
+
return [...grouped].map(([message, count]) => ({ message, count }));
|
|
2660
|
+
}
|
|
1993
2661
|
|
|
1994
2662
|
function evidenceUrl(value, secrets, limit) {
|
|
1995
2663
|
try {
|
|
@@ -2012,17 +2680,22 @@ function sanitizeDiagnosticText(value, secrets, limit) {
|
|
|
2012
2680
|
return result.slice(0, limit);
|
|
2013
2681
|
}
|
|
2014
2682
|
|
|
2015
|
-
function createDiagnostics(page, options, settings, secrets) {
|
|
2683
|
+
function createDiagnostics(page, options, settings, secrets) {
|
|
2016
2684
|
const diagnostics = { console_errors: [], network_failures: [], warnings: [] };
|
|
2017
2685
|
const pendingBodies = [];
|
|
2018
2686
|
const limit = settings.jev.maxDiagnosticItems;
|
|
2019
|
-
const messageLimit = settings.jev.maxDiagnosticChars;
|
|
2020
|
-
const
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2687
|
+
const messageLimit = settings.jev.maxDiagnosticChars;
|
|
2688
|
+
const addConsoleError = (value) => {
|
|
2689
|
+
const existing = diagnostics.console_errors.find((entry) => entry.message === value);
|
|
2690
|
+
if (existing) existing.count += 1;
|
|
2691
|
+
else if (diagnostics.console_errors.length < limit) diagnostics.console_errors.push({ message: value, count: 1 });
|
|
2692
|
+
};
|
|
2693
|
+
const onConsole = (message) => {
|
|
2694
|
+
if (message.type() !== "error") return;
|
|
2695
|
+
addConsoleError(sanitizeDiagnosticText(message.text(), secrets, messageLimit));
|
|
2696
|
+
};
|
|
2697
|
+
const onPageError = (error) => {
|
|
2698
|
+
addConsoleError(sanitizeDiagnosticText(error.message || error.name, secrets, messageLimit));
|
|
2026
2699
|
};
|
|
2027
2700
|
const addResponseMessage = async (response, entry) => {
|
|
2028
2701
|
const warn = (reason) => addDiagnostic(
|
|
@@ -2147,11 +2820,15 @@ function trackerUrl(url, settings) {
|
|
|
2147
2820
|
return settings.browser.trackerHostSuffixes.some((suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`));
|
|
2148
2821
|
}
|
|
2149
2822
|
|
|
2150
|
-
async function installTrackerBlocking(context, settings, enabled) {
|
|
2151
|
-
if (!enabled) return undefined;
|
|
2823
|
+
async function installTrackerBlocking(context, settings, enabled, blockFonts = false, allowMedia = false) {
|
|
2824
|
+
if (!enabled && !blockFonts) return undefined;
|
|
2152
2825
|
const handler = async (route) => {
|
|
2153
2826
|
const request = route.request();
|
|
2154
|
-
|
|
2827
|
+
const resourceType = request.resourceType();
|
|
2828
|
+
if ((enabled && trackerUrl(request.url(), settings))
|
|
2829
|
+
|| (enabled && settings.browser.blockedResourceTypes.includes(resourceType)
|
|
2830
|
+
&& (resourceType !== "font" || blockFonts) && (resourceType !== "media" || !allowMedia))
|
|
2831
|
+
|| (blockFonts && resourceType === "font")) {
|
|
2155
2832
|
await route.abort("blockedbyclient");
|
|
2156
2833
|
return;
|
|
2157
2834
|
}
|
|
@@ -2237,20 +2914,40 @@ function reportContent(result, extension) {
|
|
|
2237
2914
|
].join("\n");
|
|
2238
2915
|
}
|
|
2239
2916
|
|
|
2240
|
-
async function writeFlowReport(result, reportPath, settings) {
|
|
2917
|
+
async function writeFlowReport(result, reportPath, settings) {
|
|
2241
2918
|
const outputPath = await safeReportPath(reportPath, settings);
|
|
2242
2919
|
await writeFile(outputPath, reportContent(result, path.extname(outputPath).toLowerCase()), { encoding: "utf8", flag: "w" });
|
|
2243
2920
|
return outputPath;
|
|
2244
|
-
}
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
async function collectPerformanceMetrics(page) {
|
|
2924
|
+
try {
|
|
2925
|
+
return await page.evaluate(() => {
|
|
2926
|
+
const navigation = performance.getEntriesByType("navigation")[0];
|
|
2927
|
+
const paints = performance.getEntriesByType("paint");
|
|
2928
|
+
const firstContentfulPaint = paints.find(({ name }) => name === "first-contentful-paint")?.startTime;
|
|
2929
|
+
return {
|
|
2930
|
+
dom_interactive_ms: navigation ? Math.round(navigation.domInteractive * 10) / 10 : null,
|
|
2931
|
+
dom_content_loaded_ms: navigation ? Math.round(navigation.domContentLoadedEventEnd * 10) / 10 : null,
|
|
2932
|
+
load_event_ms: navigation ? Math.round(navigation.loadEventEnd * 10) / 10 : null,
|
|
2933
|
+
first_contentful_paint_ms: Number.isFinite(firstContentfulPaint) ? Math.round(firstContentfulPaint * 10) / 10 : null,
|
|
2934
|
+
resource_count: performance.getEntriesByType("resource").length,
|
|
2935
|
+
transfer_size_bytes: performance.getEntriesByType("resource").reduce((sum, entry) => sum + Number(entry.transferSize || 0), 0),
|
|
2936
|
+
};
|
|
2937
|
+
});
|
|
2938
|
+
} catch {
|
|
2939
|
+
return undefined;
|
|
2940
|
+
}
|
|
2941
|
+
}
|
|
2245
2942
|
|
|
2246
2943
|
async function redactObject(value, secrets) {
|
|
2247
2944
|
if (typeof value === "string") return redact(value, secrets);
|
|
2248
2945
|
if (Array.isArray(value)) return Promise.all(value.map((item) => redactObject(item, secrets)));
|
|
2249
2946
|
if (isRecord(value)) {
|
|
2250
2947
|
return Object.fromEntries(await Promise.all(Object.entries(value).map(async ([key, item]) => [
|
|
2251
|
-
key,
|
|
2252
|
-
key === "final_snapshot" && typeof item === "string"
|
|
2253
|
-
? redactSnapshotFieldValues(item, secrets)
|
|
2948
|
+
key,
|
|
2949
|
+
key === "final_snapshot" && typeof item === "string"
|
|
2950
|
+
? redactSnapshotFieldValues(redact(item, secrets), secrets)
|
|
2254
2951
|
: await redactObject(item, secrets),
|
|
2255
2952
|
])));
|
|
2256
2953
|
}
|
|
@@ -2261,22 +2958,50 @@ function hasAssertions(steps) {
|
|
|
2261
2958
|
return steps.some(({ action }) => action.startsWith("assert_"));
|
|
2262
2959
|
}
|
|
2263
2960
|
|
|
2264
|
-
|
|
2961
|
+
function snapshotDiff(initialSnapshot, finalSnapshot) {
|
|
2962
|
+
const remaining = new Map();
|
|
2963
|
+
for (const line of initialSnapshot.split(/\r?\n/).filter(Boolean)) remaining.set(line, (remaining.get(line) || 0) + 1);
|
|
2964
|
+
const added = [];
|
|
2965
|
+
for (const line of finalSnapshot.split(/\r?\n/).filter(Boolean)) {
|
|
2966
|
+
const count = remaining.get(line) || 0;
|
|
2967
|
+
if (count > 0) remaining.set(line, count - 1);
|
|
2968
|
+
else added.push(line);
|
|
2969
|
+
}
|
|
2970
|
+
return added.length ? added.join("\n") : "[no new snapshot lines]";
|
|
2971
|
+
}
|
|
2972
|
+
|
|
2973
|
+
export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, candidatePlans, params: rawParams, options: rawOptions, settings, browserPool, fetchImpl = fetch, serverVersion, mutationAuthorizationStore = defaultMutationAuthorizationStore }) {
|
|
2265
2974
|
if (typeof flow !== "string" || typeof expectedOutcome !== "string") throw new JevBrowserError("flow and expected_outcome must be text");
|
|
2266
|
-
|
|
2975
|
+
let variables = validateParams(rawParams, settings);
|
|
2267
2976
|
const requestedFlow = replaceKnownParams(flow, variables).trim();
|
|
2268
2977
|
const outcome = replaceKnownParams(expectedOutcome, variables).trim();
|
|
2269
2978
|
if (!requestedFlow || !outcome) throw new JevBrowserError("flow and expected_outcome must not be empty");
|
|
2270
2979
|
if (requestedFlow.length > settings.jev.maxFlowChars) throw new JevBrowserError("flow exceeds the configured character limit");
|
|
2271
2980
|
if (outcome.length > settings.jev.maxActionDescriptionChars) throw new JevBrowserError("expected_outcome exceeds the configured character limit");
|
|
2272
2981
|
const url = validateUrl(initialUrl);
|
|
2273
|
-
const
|
|
2274
|
-
const
|
|
2275
|
-
|
|
2982
|
+
const options = resolveFlowOptions(rawOptions, settings);
|
|
2983
|
+
const plans = validateCandidatePlans(replaceKnownParams(candidatePlans, variables), settings, options.maxFlowSteps);
|
|
2984
|
+
resolveEnvironmentText(plans, settings);
|
|
2985
|
+
if (options.localOnly && Object.keys(plans).length > 0 && !(options.fastPath && Object.keys(plans).length === 1)) {
|
|
2986
|
+
throw new JevBrowserError("local_only does not call Jev; provide exactly one candidate plan and enable fast_path");
|
|
2987
|
+
}
|
|
2988
|
+
if (options.allowMutations && !localHostOnly(url)) {
|
|
2989
|
+
throw new JevBrowserError("allow_mutations is limited to localhost, 127.0.0.1, and ::1 URLs");
|
|
2990
|
+
}
|
|
2991
|
+
const secretBearingFlow = Object.values(plans).some((plan) => plan.steps.some((step) => step.action === "type" && step.sensitive));
|
|
2992
|
+
if (options.recordVideo && secretBearingFlow) {
|
|
2993
|
+
throw new JevBrowserError("record_video is disabled for flows that type sensitive text");
|
|
2994
|
+
}
|
|
2995
|
+
const fingerprint = flowFingerprint({ flow, initialUrl: url, expectedOutcome, candidatePlans, params: rawParams, options });
|
|
2996
|
+
const continuation = options.confirmationToken
|
|
2997
|
+
? mutationAuthorizationStore.consume(options.confirmationToken, fingerprint)
|
|
2998
|
+
: undefined;
|
|
2999
|
+
const secrets = [...planSecrets(plans), ...sensitiveParamSecrets(variables), settings.env?.[settings.jev.credentialEnv]].filter(Boolean)
|
|
2276
3000
|
.sort((left, right) => right.length - left.length);
|
|
2277
|
-
const contentSecrets = planContentSecrets(plans);
|
|
2278
|
-
const started = performance.now();
|
|
2279
|
-
const
|
|
3001
|
+
const contentSecrets = planContentSecrets(plans);
|
|
3002
|
+
const started = performance.now();
|
|
3003
|
+
const sessionConfiguration = await browserSessionConfiguration(options, settings);
|
|
3004
|
+
const browser = await browserPool.getPage(sessionConfiguration);
|
|
2280
3005
|
const browserSessionMs = browser.sessionMs;
|
|
2281
3006
|
const page = browser.page;
|
|
2282
3007
|
const context = browser.context;
|
|
@@ -2294,29 +3019,64 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2294
3019
|
unnamed_controls: [],
|
|
2295
3020
|
unnamed_controls_initial: [],
|
|
2296
3021
|
unnamed_controls_final: [],
|
|
2297
|
-
warnings: [],
|
|
3022
|
+
warnings: continuation?.warnings || [],
|
|
2298
3023
|
final_snapshot: "",
|
|
2299
3024
|
};
|
|
3025
|
+
result.snapshot_mode = options.returnSnapshot;
|
|
3026
|
+
if (options.allowMutations) result.mutation_authorization = "allowed_on_localhost";
|
|
2300
3027
|
const downloadLedger = { count: 0, bytes: 0 };
|
|
2301
|
-
const networkTracker = createNetworkTracker(page, settings);
|
|
3028
|
+
const networkTracker = createNetworkTracker(page, settings, options.loginUrlContains);
|
|
2302
3029
|
const initialNetworkMarker = networkTracker.mark();
|
|
2303
3030
|
const timings = { navigationMs: 0, initialSnapshotMs: 0, readyMs: 0, decisionMs: 0, browserPlanMs: 0 };
|
|
2304
3031
|
let unroute;
|
|
2305
3032
|
let tracingStarted = false;
|
|
2306
3033
|
let tracePath;
|
|
2307
|
-
const artifactErrors = [];
|
|
2308
|
-
let navigationError;
|
|
3034
|
+
const artifactErrors = [];
|
|
3035
|
+
let navigationError;
|
|
3036
|
+
let initialSnapshot = "";
|
|
3037
|
+
let popupPage;
|
|
3038
|
+
let activePage = page;
|
|
3039
|
+
const nativeDialog = { expectedStep: undefined, result: undefined };
|
|
3040
|
+
const onNativeDialog = async (dialog) => {
|
|
3041
|
+
const expectedStep = nativeDialog.expectedStep;
|
|
3042
|
+
const message = String(dialog.message() || "").replace(/\s+/g, " ").trim();
|
|
3043
|
+
const expectedText = expectedStep?.expectedText?.replace(/\s+/g, " ").trim();
|
|
3044
|
+
const matches = Boolean(expectedText && message.toLocaleLowerCase().includes(expectedText.toLocaleLowerCase()));
|
|
3045
|
+
const affirmative = /^(?:sim|yes|ok|okay|confirm|confirmar|aceitar)$/i.test(expectedStep?.button || "");
|
|
3046
|
+
nativeDialog.result = {
|
|
3047
|
+
message,
|
|
3048
|
+
expectedText,
|
|
3049
|
+
matched: matches,
|
|
3050
|
+
button: expectedStep?.button,
|
|
3051
|
+
type: dialog.type(),
|
|
3052
|
+
};
|
|
3053
|
+
try {
|
|
3054
|
+
if (matches && (dialog.type() === "alert" || affirmative)) await dialog.accept();
|
|
3055
|
+
else await dialog.dismiss();
|
|
3056
|
+
} catch {
|
|
3057
|
+
nativeDialog.result.handlerError = true;
|
|
3058
|
+
}
|
|
3059
|
+
};
|
|
3060
|
+
page.on("dialog", onNativeDialog);
|
|
2309
3061
|
try {
|
|
2310
3062
|
page.setDefaultTimeout(settings.browser.actionTimeoutMs);
|
|
2311
3063
|
context.setDefaultTimeout(settings.browser.actionTimeoutMs);
|
|
2312
|
-
unroute = await installTrackerBlocking(
|
|
2313
|
-
|
|
2314
|
-
|
|
3064
|
+
unroute = await installTrackerBlocking(
|
|
3065
|
+
context,
|
|
3066
|
+
settings,
|
|
3067
|
+
options.blockTrackers,
|
|
3068
|
+
options.blockFonts,
|
|
3069
|
+
options.permissions.includes("microphone") || Boolean(options.fakeMedia),
|
|
3070
|
+
);
|
|
3071
|
+
if ((options.traceOnFailure || options.traceOnSuccess) && !secretBearingFlow) {
|
|
3072
|
+
try {
|
|
2315
3073
|
await context.tracing.start({ screenshots: true, snapshots: true });
|
|
2316
3074
|
tracingStarted = true;
|
|
2317
3075
|
} catch {
|
|
2318
|
-
artifactErrors.push("trace recording could not be started");
|
|
2319
|
-
}
|
|
3076
|
+
artifactErrors.push("trace recording could not be started");
|
|
3077
|
+
}
|
|
3078
|
+
} else if ((options.traceOnFailure || options.traceOnSuccess || options.recordVideo) && secretBearingFlow) {
|
|
3079
|
+
addDiagnostic(result.warnings, "trace, video, and screenshots were suppressed because this flow contains sensitive text", settings.jev.maxDiagnosticItems);
|
|
2320
3080
|
}
|
|
2321
3081
|
|
|
2322
3082
|
let reusedPage = false;
|
|
@@ -2340,6 +3100,9 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2340
3100
|
} else {
|
|
2341
3101
|
reusedPage = true;
|
|
2342
3102
|
result.navigation_strategy = "continued_current_page";
|
|
3103
|
+
if (new URL(currentUrl).href !== new URL(url).href) {
|
|
3104
|
+
throw new JevBrowserError("reuse_page found a different same-origin URL; add a navigate step or set continue_from_current_page: false");
|
|
3105
|
+
}
|
|
2343
3106
|
}
|
|
2344
3107
|
}
|
|
2345
3108
|
}
|
|
@@ -2360,18 +3123,33 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2360
3123
|
const readiness = await waitForPageReady(page, options, settings, secrets, reusedPage);
|
|
2361
3124
|
const enriched = await enrichSnapshot(page, readiness.snapshot, settings, secrets, options.snapshotScope, options.snapshotIncludeHidden);
|
|
2362
3125
|
result.final_snapshot = enriched.snapshot;
|
|
3126
|
+
initialSnapshot = continuation?.initialSnapshot || enriched.snapshot;
|
|
2363
3127
|
result.unnamed_controls = enriched.unnamedControls;
|
|
2364
3128
|
result.unnamed_controls_initial = enriched.unnamedControls;
|
|
2365
3129
|
result.unnamed_controls_final = enriched.unnamedControls;
|
|
3130
|
+
if (options.loginText.some((needle) => result.final_snapshot.toLocaleLowerCase().includes(needle.toLocaleLowerCase()))) {
|
|
3131
|
+
navigationError = "AUTHENTICATION_REQUIRED";
|
|
3132
|
+
throw new JevBrowserError("the configured login text is visible; sign in to the application, then run the flow again");
|
|
3133
|
+
}
|
|
2366
3134
|
for (const control of enriched.unnamedControls) {
|
|
2367
3135
|
addDiagnostic(
|
|
2368
|
-
result.warnings,
|
|
2369
|
-
`interactive ${control.role} has no accessible name; index ${control.index}; nearest label ${JSON.stringify(control.nearest_label)};
|
|
3136
|
+
result.warnings,
|
|
3137
|
+
`interactive ${control.role} has no accessible name; index ${control.index}; nearest label ${JSON.stringify(control.nearest_label)}; HTML is available in unnamed_controls_initial/final`,
|
|
2370
3138
|
settings.jev.maxDiagnosticItems,
|
|
2371
3139
|
);
|
|
2372
3140
|
}
|
|
2373
3141
|
timings.readyMs = readiness.readyMs;
|
|
2374
3142
|
browserPool.setNavigationError?.(null);
|
|
3143
|
+
if (continuation) {
|
|
3144
|
+
const currentFingerprint = await pageFingerprint(page);
|
|
3145
|
+
if (continuation.pageFingerprint !== currentFingerprint) {
|
|
3146
|
+
throw new JevBrowserError("the page structure or route changed after dry_run; run dry_run again before confirming a mutation");
|
|
3147
|
+
}
|
|
3148
|
+
if (!Object.hasOwn(plans, continuation.planId)) {
|
|
3149
|
+
throw new JevBrowserError("confirmation_token refers to a candidate plan that is no longer available");
|
|
3150
|
+
}
|
|
3151
|
+
variables = { ...continuation.variables };
|
|
3152
|
+
}
|
|
2375
3153
|
} catch (error) {
|
|
2376
3154
|
timings.readyMs = error.readyMs || performance.now() - snapshotStarted;
|
|
2377
3155
|
throw error;
|
|
@@ -2386,7 +3164,9 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2386
3164
|
} else {
|
|
2387
3165
|
let planId;
|
|
2388
3166
|
let decision;
|
|
2389
|
-
if (
|
|
3167
|
+
if (continuation) {
|
|
3168
|
+
planId = continuation.planId;
|
|
3169
|
+
} else if (options.fastPath && Object.keys(plans).length === 1) {
|
|
2390
3170
|
[planId] = Object.keys(plans);
|
|
2391
3171
|
} else {
|
|
2392
3172
|
const planIdByDecisionId = new Map();
|
|
@@ -2398,7 +3178,7 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2398
3178
|
const decisionFlow = redact(`${requestedFlow}\nExpected outcome: ${outcome}`, secrets);
|
|
2399
3179
|
const { request, criteria } = buildChoicePayload({
|
|
2400
3180
|
flow: decisionFlow,
|
|
2401
|
-
pageSnapshot:
|
|
3181
|
+
pageSnapshot: providerSafeSnapshot(result.final_snapshot, [...secrets, ...contentSecrets]),
|
|
2402
3182
|
actions: descriptions,
|
|
2403
3183
|
answerName: "browser_plan",
|
|
2404
3184
|
allowComplete: options.stopOnExpected,
|
|
@@ -2422,44 +3202,99 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2422
3202
|
|
|
2423
3203
|
if (planId) {
|
|
2424
3204
|
const plan = plans[planId];
|
|
2425
|
-
const trace = [];
|
|
3205
|
+
const trace = continuation ? [...continuation.trace] : [];
|
|
2426
3206
|
let failure = "";
|
|
2427
3207
|
let environmentFailure = false;
|
|
2428
3208
|
let failedStep;
|
|
2429
|
-
let
|
|
3209
|
+
let mutationStop;
|
|
3210
|
+
let issuedToken;
|
|
2430
3211
|
let snapshot = result.final_snapshot;
|
|
2431
3212
|
let networkMarker = networkTracker.mark();
|
|
3213
|
+
result.downloaded_files = continuation?.downloadedFiles || [];
|
|
3214
|
+
result.accessibility_audits = continuation?.accessibilityAudits || [];
|
|
3215
|
+
if (continuation?.downloadLedger) Object.assign(downloadLedger, continuation.downloadLedger);
|
|
2432
3216
|
const planStarted = performance.now();
|
|
2433
|
-
const runAllSteps = plan.steps.some(({ action, expectDownload }) => action.startsWith("assert_")
|
|
2434
|
-
|| ["upload_file", "select_option", "hover", "like_comment", "unlike_comment", "audit_accessibility"].includes(action)
|
|
3217
|
+
const runAllSteps = plan.steps.some(({ action, expectDownload }) => action.startsWith("assert_")
|
|
3218
|
+
|| ["navigate", "clear", "upload_file", "select_option", "hover", "like_comment", "unlike_comment", "audit_accessibility"].includes(action)
|
|
2435
3219
|
|| expectDownload === true);
|
|
2436
|
-
|
|
3220
|
+
let approvedMutationIndex = continuation?.nextStep;
|
|
3221
|
+
for (let index = continuation?.nextStep || 0; index < plan.steps.length; index += 1) {
|
|
3222
|
+
const templateStep = plan.steps[index];
|
|
2437
3223
|
const stepStarted = performance.now();
|
|
2438
3224
|
let step = templateStep;
|
|
2439
|
-
try {
|
|
2440
|
-
step = interpolateStep(templateStep, variables);
|
|
2441
|
-
|
|
2442
|
-
|
|
3225
|
+
try {
|
|
3226
|
+
step = interpolateStep(templateStep, variables);
|
|
3227
|
+
step.timeoutMs ??= options.stepTimeoutMs;
|
|
3228
|
+
const stepPage = step.targetPage === "popup" ? popupPage : page;
|
|
3229
|
+
if (step.targetPage === "popup" && (!popupPage || popupPage.isClosed())) {
|
|
3230
|
+
throw new JevBrowserError("target_page popup requires a preceding click with expect_popup: true");
|
|
3231
|
+
}
|
|
3232
|
+
activePage = stepPage;
|
|
3233
|
+
const mutationInspection = await inferTargetMutation(stepPage, step, snapshot, settings, secrets);
|
|
3234
|
+
const dynamicallyMutating = mutationInspection.mutating;
|
|
3235
|
+
const pairedNativeConfirmation = step.action === "confirm_dialog" && nativeDialog.result?.matched === true;
|
|
3236
|
+
if (dynamicallyMutating && !pairedNativeConfirmation && index !== approvedMutationIndex
|
|
3237
|
+
&& !(options.allowMutations && !options.dryRun)) {
|
|
3238
|
+
mutationStop = { step: String(index + 1), action: step.action };
|
|
3239
|
+
const enriched = await enrichSnapshot(stepPage, snapshot, settings, secrets, options.snapshotScope, options.snapshotIncludeHidden);
|
|
3240
|
+
snapshot = enriched.snapshot;
|
|
3241
|
+
issuedToken = mutationAuthorizationStore.issue({
|
|
3242
|
+
fingerprint,
|
|
3243
|
+
planId,
|
|
3244
|
+
nextStep: index,
|
|
3245
|
+
variables: { ...variables },
|
|
3246
|
+
trace: [...trace],
|
|
3247
|
+
snapshot,
|
|
3248
|
+
initialSnapshot,
|
|
3249
|
+
pageUrl: typeof page.url === "function" ? page.url() : "unavailable",
|
|
3250
|
+
pageFingerprint: await pageFingerprint(page),
|
|
3251
|
+
downloadedFiles: [...result.downloaded_files],
|
|
3252
|
+
accessibilityAudits: [...result.accessibility_audits],
|
|
3253
|
+
warnings: [...result.warnings],
|
|
3254
|
+
consoleErrors: [...(continuation?.consoleErrors || []), ...capture.diagnostics.console_errors],
|
|
3255
|
+
networkFailures: [...(continuation?.networkFailures || []), ...capture.diagnostics.network_failures],
|
|
3256
|
+
downloadLedger: { ...downloadLedger },
|
|
3257
|
+
}, settings.browser.mutationConfirmationTtlMs);
|
|
2443
3258
|
break;
|
|
2444
3259
|
}
|
|
3260
|
+
if (index === approvedMutationIndex) approvedMutationIndex = undefined;
|
|
2445
3261
|
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);
|
|
2446
3262
|
if (step.fragileSelector) addDiagnostic(result.warnings, `step ${index + 1} uses an explicit CSS/XPath selector as a last resort`, settings.jev.maxDiagnosticItems);
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
3263
|
+
if (step.action === "click" && plan.steps[index + 1]?.action === "confirm_dialog") {
|
|
3264
|
+
nativeDialog.expectedStep = interpolateStep(plan.steps[index + 1], variables);
|
|
3265
|
+
nativeDialog.result = undefined;
|
|
3266
|
+
} else if (step.action !== "confirm_dialog") {
|
|
3267
|
+
nativeDialog.expectedStep = undefined;
|
|
3268
|
+
nativeDialog.result = undefined;
|
|
3269
|
+
}
|
|
3270
|
+
const actionMarker = networkTracker.mark();
|
|
3271
|
+
const execution = {
|
|
3272
|
+
networkTracker,
|
|
3273
|
+
networkMarker,
|
|
3274
|
+
autoAngularIdle: options.autoAngularIdle,
|
|
3275
|
+
readyStableMs: options.readyStableMs,
|
|
3276
|
+
allowMutations: options.allowMutations,
|
|
3277
|
+
browserPool,
|
|
3278
|
+
nativeDialog,
|
|
3279
|
+
preResolvedTarget: mutationInspection.target,
|
|
3280
|
+
popupPage,
|
|
3281
|
+
variables,
|
|
3282
|
+
};
|
|
3283
|
+
const traceStep = await executeStep(stepPage, step, settings, snapshot, secrets, downloadLedger, execution);
|
|
3284
|
+
popupPage = execution.popupPage;
|
|
3285
|
+
if (options.autoAngularIdle && ["click", "type"].includes(step.action)) {
|
|
3286
|
+
await waitForAngularIdle(stepPage, step.timeoutMs ?? options.stepTimeoutMs, settings, true);
|
|
3287
|
+
}
|
|
2453
3288
|
if (!["wait_for_text", "wait_for_condition", "assert_text", "assert_value", "assert_visible", "assert_hidden", "audit_accessibility"].includes(step.action)) {
|
|
2454
3289
|
networkMarker = actionMarker;
|
|
2455
3290
|
}
|
|
2456
3291
|
traceStep.step = String(index + 1);
|
|
2457
3292
|
traceStep.status = "passed";
|
|
2458
3293
|
if (step.mutating === true) traceStep.mutating = true;
|
|
2459
|
-
if (step.screenshot === true) {
|
|
2460
|
-
try {
|
|
2461
|
-
traceStep.screenshot_path = await artifactPath(settings, "png");
|
|
2462
|
-
await
|
|
3294
|
+
if (step.screenshot === true && !secretBearingFlow) {
|
|
3295
|
+
try {
|
|
3296
|
+
traceStep.screenshot_path = await artifactPath(settings, "png");
|
|
3297
|
+
await stepPage.screenshot({ path: traceStep.screenshot_path, fullPage: false });
|
|
2463
3298
|
} catch {
|
|
2464
3299
|
delete traceStep.screenshot_path;
|
|
2465
3300
|
artifactErrors.push(`step ${index + 1} screenshot could not be saved`);
|
|
@@ -2468,14 +3303,14 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2468
3303
|
traceStep.duration_ms = Math.round((performance.now() - stepStarted) * 10) / 10;
|
|
2469
3304
|
trace.push(traceStep);
|
|
2470
3305
|
if (traceStep.downloaded_file) result.downloaded_files.push(traceStep.downloaded_file);
|
|
2471
|
-
if (traceStep.accessibility_audit) {
|
|
2472
|
-
result.accessibility_audits.push(traceStep.accessibility_audit);
|
|
2473
|
-
if (traceStep.accessibility_audit.
|
|
2474
|
-
failure = `accessibility audit reported ${traceStep.accessibility_audit.
|
|
3306
|
+
if (traceStep.accessibility_audit) {
|
|
3307
|
+
result.accessibility_audits.push(traceStep.accessibility_audit);
|
|
3308
|
+
if (traceStep.accessibility_audit.blocking_violations_count > 0) {
|
|
3309
|
+
failure = `accessibility audit reported ${traceStep.accessibility_audit.blocking_violations_count} violation(s) at or above ${traceStep.accessibility_audit.fail_on_impact} impact`;
|
|
2475
3310
|
}
|
|
2476
3311
|
}
|
|
2477
|
-
snapshot = await snapshotAfterStep(
|
|
2478
|
-
|
|
3312
|
+
snapshot = await snapshotAfterStep(
|
|
3313
|
+
stepPage,
|
|
2479
3314
|
settings,
|
|
2480
3315
|
secrets,
|
|
2481
3316
|
options.snapshotScope,
|
|
@@ -2504,9 +3339,9 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2504
3339
|
break;
|
|
2505
3340
|
}
|
|
2506
3341
|
}
|
|
2507
|
-
if (failure || trace.length === 0) {
|
|
2508
|
-
snapshot = await snapshotAfterStep(
|
|
2509
|
-
|
|
3342
|
+
if (failure || trace.length === 0) {
|
|
3343
|
+
snapshot = await snapshotAfterStep(
|
|
3344
|
+
activePage,
|
|
2510
3345
|
settings,
|
|
2511
3346
|
secrets,
|
|
2512
3347
|
options.snapshotScope,
|
|
@@ -2516,8 +3351,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2516
3351
|
options.snapshotIncludeHidden,
|
|
2517
3352
|
);
|
|
2518
3353
|
}
|
|
2519
|
-
const finalDetails = await enrichSnapshot(
|
|
2520
|
-
|
|
3354
|
+
const finalDetails = await enrichSnapshot(
|
|
3355
|
+
activePage,
|
|
2521
3356
|
snapshot,
|
|
2522
3357
|
settings,
|
|
2523
3358
|
secrets,
|
|
@@ -2532,24 +3367,26 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2532
3367
|
const assertionCount = plan.steps.filter(({ action }) => action.startsWith("assert_")).length;
|
|
2533
3368
|
const assertionsPassed = assertionCount > 0 && !failure && trace.length === plan.steps.length;
|
|
2534
3369
|
const auditCount = plan.steps.filter(({ action }) => action === "audit_accessibility").length;
|
|
2535
|
-
const auditsPassed = auditCount > 0 && !failure && result.accessibility_audits.length === auditCount
|
|
2536
|
-
&& result.accessibility_audits.every(({
|
|
3370
|
+
const auditsPassed = auditCount > 0 && !failure && result.accessibility_audits.length === auditCount
|
|
3371
|
+
&& result.accessibility_audits.every(({ blocking_violations_count: count }) => count === 0)
|
|
2537
3372
|
&& trace.length === plan.steps.length;
|
|
2538
3373
|
const expectedDownloadCount = plan.steps.filter(({ action, expectDownload }) => action === "click" && expectDownload).length;
|
|
2539
3374
|
const downloadsPassed = expectedDownloadCount > 0 && !failure
|
|
2540
3375
|
&& result.downloaded_files.length === expectedDownloadCount && trace.length === plan.steps.length;
|
|
2541
3376
|
result.status = failure ? environmentFailure ? "environment_error" : "failed"
|
|
2542
|
-
:
|
|
3377
|
+
: mutationStop ? options.dryRun ? "dry_run" : "confirmation_required" : visible || assertionsPassed || auditsPassed || downloadsPassed ? "passed" : "incomplete";
|
|
2543
3378
|
if (environmentFailure) result.navigation_error = "BROWSER_DISCONNECTED";
|
|
2544
3379
|
result.expected_outcome_visible = visible;
|
|
2545
|
-
result.acceptance_criteria_met = !failure && !
|
|
3380
|
+
result.acceptance_criteria_met = !failure && !mutationStop && (visible || assertionsPassed || auditsPassed || downloadsPassed);
|
|
2546
3381
|
result.selected_plan = planId;
|
|
2547
3382
|
result.steps_executed = trace.length;
|
|
2548
3383
|
result.steps = trace;
|
|
2549
3384
|
result.mutating_steps = trace.filter(({ mutating }) => mutating === true).map(({ step, action, duration_ms, status }) => ({ step, action, duration_ms, status }));
|
|
2550
|
-
if (
|
|
2551
|
-
result.dry_run_stopped_before_step =
|
|
2552
|
-
result.
|
|
3385
|
+
if (mutationStop) {
|
|
3386
|
+
if (options.dryRun) result.dry_run_stopped_before_step = mutationStop;
|
|
3387
|
+
else result.confirmation_required_before_step = mutationStop;
|
|
3388
|
+
result.confirmation_token = issuedToken;
|
|
3389
|
+
result.reason = `${options.dryRun ? "dry_run stopped" : "confirmation required"} before mutating step ${mutationStop.step} (${mutationStop.action}); rerun the same flow with options.confirmation_token to authorize this one step`;
|
|
2553
3390
|
}
|
|
2554
3391
|
result.final_snapshot = snapshot;
|
|
2555
3392
|
if (failure) {
|
|
@@ -2581,6 +3418,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2581
3418
|
if (navigationError) result.navigation_error = navigationError;
|
|
2582
3419
|
result.reason = authenticationFailure
|
|
2583
3420
|
? `The application rejected its authentication request with HTTP ${authenticationFailure.status}; the browser session may have expired. Sign in to the application, then run the flow again.`
|
|
3421
|
+
: navigationError === "AUTHENTICATION_REQUIRED"
|
|
3422
|
+
? "The configured login URL or login text is visible; sign in to the application, then run the flow again."
|
|
2584
3423
|
: disconnected ? "The browser session disconnected; run browser_health to reconnect, then retry the flow."
|
|
2585
3424
|
: error instanceof JevBrowserError
|
|
2586
3425
|
? error.message
|
|
@@ -2610,12 +3449,13 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2610
3449
|
} catch {
|
|
2611
3450
|
// Keep the last accessible snapshot when the page is closing or unavailable.
|
|
2612
3451
|
}
|
|
2613
|
-
} finally {
|
|
2614
|
-
if (tracingStarted) {
|
|
2615
|
-
try {
|
|
2616
|
-
if (result.status !== "passed" && options.traceOnFailure)
|
|
2617
|
-
|
|
2618
|
-
await
|
|
3452
|
+
} finally {
|
|
3453
|
+
if (tracingStarted) {
|
|
3454
|
+
try {
|
|
3455
|
+
if ((result.status !== "passed" && options.traceOnFailure)
|
|
3456
|
+
|| (result.status === "passed" && options.traceOnSuccess)) {
|
|
3457
|
+
tracePath = await artifactPath(settings, "zip");
|
|
3458
|
+
await context.tracing.stop({ path: tracePath });
|
|
2619
3459
|
} else {
|
|
2620
3460
|
await context.tracing.stop();
|
|
2621
3461
|
}
|
|
@@ -2623,15 +3463,34 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2623
3463
|
artifactErrors.push("trace recording could not be saved");
|
|
2624
3464
|
}
|
|
2625
3465
|
}
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
3466
|
+
const shouldScreenshot = result.status === "passed" ? options.screenshotOnSuccess : options.screenshotOnFailure;
|
|
3467
|
+
if (shouldScreenshot && !secretBearingFlow) {
|
|
3468
|
+
try {
|
|
3469
|
+
result.screenshot_path = await artifactPath(settings, "png");
|
|
3470
|
+
await activePage.screenshot({ path: result.screenshot_path, fullPage: false });
|
|
3471
|
+
} catch {
|
|
2631
3472
|
delete result.screenshot_path;
|
|
2632
|
-
artifactErrors.push("failure screenshot could not be saved");
|
|
2633
|
-
}
|
|
2634
|
-
}
|
|
3473
|
+
artifactErrors.push("failure screenshot could not be saved");
|
|
3474
|
+
}
|
|
3475
|
+
}
|
|
3476
|
+
const metrics = await collectPerformanceMetrics(activePage);
|
|
3477
|
+
if (metrics) result.performance_metrics = metrics;
|
|
3478
|
+
if (options.recordVideo && !secretBearingFlow) {
|
|
3479
|
+
try {
|
|
3480
|
+
const video = activePage.video?.();
|
|
3481
|
+
if (!video) throw new Error("video capture was unavailable");
|
|
3482
|
+
result.video_path = await artifactPath(settings, "webm");
|
|
3483
|
+
await activePage.close();
|
|
3484
|
+
await video.saveAs(result.video_path);
|
|
3485
|
+
const nextContextOptions = { ...sessionConfiguration.contextOptions };
|
|
3486
|
+
delete nextContextOptions.recordVideo;
|
|
3487
|
+
await browserPool.getPage?.({ contextOptions: nextContextOptions, launchArgs: sessionConfiguration.launchArgs });
|
|
3488
|
+
} catch {
|
|
3489
|
+
delete result.video_path;
|
|
3490
|
+
artifactErrors.push("video recording could not be saved");
|
|
3491
|
+
}
|
|
3492
|
+
}
|
|
3493
|
+
if (popupPage && !popupPage.isClosed()) await popupPage.close().catch(() => {});
|
|
2635
3494
|
try {
|
|
2636
3495
|
await unroute?.();
|
|
2637
3496
|
} catch {
|
|
@@ -2639,16 +3498,22 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2639
3498
|
}
|
|
2640
3499
|
networkTracker.dispose();
|
|
2641
3500
|
await capture.dispose();
|
|
3501
|
+
page.off("dialog", onNativeDialog);
|
|
2642
3502
|
}
|
|
2643
3503
|
if (tracePath) result.trace_path = tracePath;
|
|
2644
3504
|
if (artifactErrors.length) result.artifact_errors = artifactErrors;
|
|
2645
|
-
result.console_errors = capture.diagnostics.console_errors;
|
|
2646
|
-
result.network_failures = capture.diagnostics.network_failures;
|
|
3505
|
+
result.console_errors = groupConsoleErrors([...(continuation?.consoleErrors || []), ...capture.diagnostics.console_errors]);
|
|
3506
|
+
result.network_failures = [...(continuation?.networkFailures || []), ...capture.diagnostics.network_failures];
|
|
2647
3507
|
result.warnings.push(...capture.diagnostics.warnings);
|
|
2648
3508
|
result.timings_ms = flowTimings(started, browserSessionMs, timings.navigationMs, timings.initialSnapshotMs, timings.readyMs, timings.decisionMs, timings.browserPlanMs);
|
|
2649
|
-
const safeResult = await redactObject(result, secrets);
|
|
2650
|
-
if (typeof safeResult.final_snapshot === "string") {
|
|
2651
|
-
safeResult.final_snapshot = redact(safeResult.final_snapshot, contentSecrets);
|
|
3509
|
+
const safeResult = await redactObject(result, secrets);
|
|
3510
|
+
if (typeof safeResult.final_snapshot === "string") {
|
|
3511
|
+
safeResult.final_snapshot = redact(safeResult.final_snapshot, contentSecrets);
|
|
3512
|
+
}
|
|
3513
|
+
if (options.returnSnapshot === "diff" && typeof safeResult.final_snapshot === "string") {
|
|
3514
|
+
safeResult.final_snapshot = snapshotDiff(initialSnapshot, safeResult.final_snapshot);
|
|
3515
|
+
} else if (options.returnSnapshot === "none") {
|
|
3516
|
+
delete safeResult.final_snapshot;
|
|
2652
3517
|
}
|
|
2653
3518
|
if (options.reportPath) {
|
|
2654
3519
|
try {
|
|
@@ -2660,7 +3525,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2660
3525
|
return safeResult;
|
|
2661
3526
|
}
|
|
2662
3527
|
|
|
2663
|
-
export async function chooseNextAction({ flow, pageSnapshot, actions, completedSteps = [], settings, fetchImpl = fetch }) {
|
|
3528
|
+
export async function chooseNextAction({ flow, pageSnapshot, actions, completedSteps = [], localOnly = false, settings, fetchImpl = fetch }) {
|
|
3529
|
+
if (localOnly) throw new JevBrowserError("local_only is enabled; choose_next_action cannot call the Jev provider");
|
|
2664
3530
|
const { request, criteria } = buildChoicePayload({ flow, pageSnapshot, actions, completedSteps }, settings.jev);
|
|
2665
3531
|
return parseDecision(await requestJev(request, settings, fetchImpl), criteria);
|
|
2666
3532
|
}
|