@diegosouzacdv/jev-browser-mcp 0.6.1 → 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 +126 -55
- package/config/ui-testing.json +10 -2
- package/docs/jev-browser-mcp.md +126 -55
- package/mcp_servers/jev-browser-npm/src/accessibility.mjs +18 -9
- package/mcp_servers/jev-browser-npm/src/config.mjs +28 -7
- package/mcp_servers/jev-browser-npm/src/flow.mjs +736 -314
- package/mcp_servers/jev-browser-npm/src/server.mjs +82 -28
- package/package.json +1 -1
|
@@ -6,8 +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", "checkbox", "radio", "cell", "gridcell", "option"]);
|
|
10
|
-
const CHECK_ROLES = new Set(["checkbox", "radio"]);
|
|
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"]);
|
|
11
11
|
const TEXT_ROLES = new Set(["textbox", "searchbox", "combobox"]);
|
|
12
12
|
const HOVER_ROLES = new Set(["button", "link", "tab", "menuitem", "row", "gridcell", "cell", "region", "group"]);
|
|
13
13
|
const DROPZONE_ROLES = new Set(["button", "region", "group"]);
|
|
@@ -16,22 +16,22 @@ const CONTAINER_ROLES = new Set([
|
|
|
16
16
|
"group", "list", "listbox", "listitem", "main", "menu", "menubar", "navigation",
|
|
17
17
|
"region", "row", "rowgroup", "table", "tablist", "tabpanel", "tree", "treegrid", "treeitem",
|
|
18
18
|
]);
|
|
19
|
-
const TARGET_SELECTION_ACTIONS = new Set([
|
|
20
|
-
"click", "check", "uncheck", "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",
|
|
21
21
|
"wait_for_enabled", "assert_enabled", "assert_visible", "assert_hidden", "upload_file", "extract",
|
|
22
22
|
]);
|
|
23
23
|
const LOCATOR_FIELDS = ["label", "placeholder", "title", "text", "test_id", "selector"];
|
|
24
24
|
const TARGET_LOCATOR_FIELDS = [...LOCATOR_FIELDS, "target_text"];
|
|
25
25
|
const LOCATOR_STEP_KEYS = { label: "label", placeholder: "placeholder", title: "title", text: "targetText", test_id: "testId", selector: "selector", target_text: "typedTargetText" };
|
|
26
26
|
const ASSERT_ROLES = new Set([
|
|
27
|
-
"alert", "button", "cell", "checkbox", "combobox", "dialog", "gridcell", "heading",
|
|
27
|
+
"alert", "button", "cell", "checkbox", "combobox", "dialog", "gridcell", "heading",
|
|
28
28
|
"img", "link", "listitem", "main", "menuitem", "option", "paragraph", "progressbar",
|
|
29
|
-
"radio", "region", "row", "searchbox", "status", "tab", "textbox",
|
|
29
|
+
"radio", "region", "row", "searchbox", "status", "switch", "tab", "textbox",
|
|
30
30
|
]);
|
|
31
31
|
const UNNAMED_ACCESSIBLE_NAME = /^[\s\uE000-\uF8FF]*$/u;
|
|
32
32
|
const APPROVED_KEYS = new Set([
|
|
33
33
|
"PageDown", "PageUp", "Home", "End", "ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight",
|
|
34
|
-
"Enter", "Escape", "Tab", "Shift+Tab",
|
|
34
|
+
"Enter", "Escape", "Tab", "Shift+Tab", "Space", "Backspace", "Delete",
|
|
35
35
|
]);
|
|
36
36
|
const MIME_TYPES = new Map([
|
|
37
37
|
[".csv", "text/csv"], [".json", "application/json"], [".pdf", "application/pdf"],
|
|
@@ -39,10 +39,11 @@ const MIME_TYPES = new Map([
|
|
|
39
39
|
[".txt", "text/plain"], [".webp", "image/webp"],
|
|
40
40
|
]);
|
|
41
41
|
const FLOW_OPTION_KEYS = new Set([
|
|
42
|
-
"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",
|
|
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",
|
|
46
47
|
"continue_from_current_page", "reuse_page", "return_snapshot", "screenshot_on_failure", "snapshot_include_hidden", "snapshot_scope", "step_timeout_seconds", "stop_on_expected",
|
|
47
48
|
"trace_on_failure",
|
|
48
49
|
]);
|
|
@@ -188,12 +189,14 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
|
|
|
188
189
|
if (!isRecord(rawStep) || typeof rawStep.action !== "string") throw new JevBrowserError("each browser step must be an object with an action");
|
|
189
190
|
const { action } = rawStep;
|
|
190
191
|
const targetSelection = ["frame", "comment", "within", "index", "near", ...TARGET_LOCATOR_FIELDS];
|
|
191
|
-
const semanticTarget = [...targetSelection, "role", "name"];
|
|
192
|
+
const semanticTarget = [...targetSelection, "role", "name", "name_match", "target_page"];
|
|
192
193
|
const locatorFields = TARGET_LOCATOR_FIELDS.filter((field) => field !== "text"
|
|
193
194
|
|| !["type", "wait_for_text", "wait_for_condition", "upload_file"].includes(action));
|
|
194
|
-
const schemas = {
|
|
195
|
-
click: { required: [], optional: [...semanticTarget, "expect_download", "expect_popup", "timeout_seconds"] },
|
|
196
|
-
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"] },
|
|
197
200
|
confirm_dialog: { required: ["expected_text", "button"], optional: ["timeout_seconds"] },
|
|
198
201
|
check: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
|
|
199
202
|
uncheck: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
|
|
@@ -201,8 +204,8 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
|
|
|
201
204
|
assert_network: { required: ["url_contains", "method", "status"], optional: ["message_contains", "timeout_seconds"] },
|
|
202
205
|
extract: { required: ["as"], optional: [...semanticTarget, "source", "attribute", "sensitive", "timeout_seconds"] },
|
|
203
206
|
wait: { required: ["ms"], optional: [] },
|
|
204
|
-
wait_for_text: { required: ["text"], optional: ["frame", "comment", "fail_on", "timeout_seconds"] },
|
|
205
|
-
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"] },
|
|
206
209
|
wait_for_value: { required: ["value"], optional: [...semanticTarget, "timeout_seconds"] },
|
|
207
210
|
wait_for_enabled: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
|
|
208
211
|
press: { required: ["key"], optional: [...semanticTarget, "timeout_seconds"] },
|
|
@@ -214,12 +217,12 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
|
|
|
214
217
|
assert_visible: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
|
|
215
218
|
assert_hidden: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
|
|
216
219
|
upload_file: { required: ["file_paths"], optional: ["target", ...semanticTarget, "timeout_seconds"] },
|
|
217
|
-
audit_accessibility: { required: ["standard"], optional: ["comment", "timeout_seconds"] },
|
|
220
|
+
audit_accessibility: { required: ["standard"], optional: ["comment", "scope", "fail_on_impact", "timeout_seconds", "target_page"] },
|
|
218
221
|
like_comment: { required: ["author", "text", "like_label", "liked_label"], optional: ["frame", "comment", "timeout_seconds"] },
|
|
219
222
|
unlike_comment: { required: ["author", "text", "like_label", "liked_label"], optional: ["frame", "comment", "timeout_seconds"] },
|
|
220
223
|
};
|
|
221
224
|
if (!Object.hasOwn(schemas, action)) throw new JevBrowserError(`unsupported browser action ${JSON.stringify(action)}; supported actions: ${Object.keys(schemas).join(", ")}`);
|
|
222
|
-
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);
|
|
223
226
|
|
|
224
227
|
const step = { action };
|
|
225
228
|
for (const field of ["mutating", "screenshot"]) {
|
|
@@ -255,7 +258,7 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
|
|
|
255
258
|
}
|
|
256
259
|
step.role = rawStep.role;
|
|
257
260
|
}
|
|
258
|
-
if (rawStep.name !== undefined) {
|
|
261
|
+
if (rawStep.name !== undefined) {
|
|
259
262
|
if (typeof rawStep.name !== "string") throw new JevBrowserError("name must be text");
|
|
260
263
|
if (rawStep.name.trim()) step.name = requiredText(rawStep.name, "accessible name", settings.jev.maxActionDescriptionChars);
|
|
261
264
|
else if (rawStep.name === "" && Number.isSafeInteger(rawStep.index) && rawStep.index >= 0) {
|
|
@@ -264,8 +267,27 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
|
|
|
264
267
|
} else {
|
|
265
268
|
throw new JevBrowserError("name may be empty only when a non-negative index is provided");
|
|
266
269
|
}
|
|
267
|
-
if (!step.role) throw new JevBrowserError("name requires role; use a locator field such as label, title, placeholder, test_id, text, or selector");
|
|
268
|
-
}
|
|
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
|
+
}
|
|
269
291
|
if (action === "select_option" && !step.role) step.role = "combobox";
|
|
270
292
|
if (TARGET_SELECTION_ACTIONS.has(action) && action !== "press"
|
|
271
293
|
&& !step.role && step.name === undefined && selectorFields.length === 0 && !hasNear) {
|
|
@@ -354,7 +376,7 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
|
|
|
354
376
|
if (!step.role) step.role = "checkbox";
|
|
355
377
|
if (action === "uncheck" && step.role === "radio") throw new JevBrowserError("uncheck does not support radio controls");
|
|
356
378
|
}
|
|
357
|
-
if (action === "navigate_menu") {
|
|
379
|
+
if (action === "navigate_menu") {
|
|
358
380
|
if (!Array.isArray(rawStep.path) || rawStep.path.length === 0 || rawStep.path.length > 8) {
|
|
359
381
|
throw new JevBrowserError("navigate_menu path must contain between 1 and 8 menu labels");
|
|
360
382
|
}
|
|
@@ -362,10 +384,19 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
|
|
|
362
384
|
step.toggleSelector = rawStep.toggle_selector === undefined
|
|
363
385
|
? "#open_btn"
|
|
364
386
|
: requiredText(rawStep.toggle_selector, "toggle_selector", settings.jev.maxActionDescriptionChars);
|
|
365
|
-
}
|
|
366
|
-
if (action === "
|
|
367
|
-
|
|
368
|
-
|
|
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
|
+
}
|
|
369
400
|
if (rawStep.mode !== undefined && !new Set(["fill", "keys"]).has(rawStep.mode)) {
|
|
370
401
|
throw new JevBrowserError("type mode must be fill or keys");
|
|
371
402
|
}
|
|
@@ -374,11 +405,16 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
|
|
|
374
405
|
if (step.typedTargetText !== undefined && (!step.role || !TEXT_ROLES.has(step.role))) {
|
|
375
406
|
throw new JevBrowserError("type target_text needs role textbox, searchbox, or combobox");
|
|
376
407
|
}
|
|
377
|
-
step.text = rawStep.text;
|
|
378
|
-
step.
|
|
379
|
-
step.
|
|
380
|
-
step.
|
|
381
|
-
|
|
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
|
+
}
|
|
382
418
|
if (action === "wait_for_text") {
|
|
383
419
|
step.text = requiredText(rawStep.text, "wait text", settings.jev.maxActionDescriptionChars);
|
|
384
420
|
if (rawStep.fail_on !== undefined) {
|
|
@@ -435,9 +471,9 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
|
|
|
435
471
|
}
|
|
436
472
|
step.sensitive = rawStep.sensitive ?? true;
|
|
437
473
|
}
|
|
438
|
-
if (action === "wait_for_condition") {
|
|
439
|
-
if (!new Set(["network_idle", "hidden", "text_hidden", "angular_idle"]).has(rawStep.condition)) {
|
|
440
|
-
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");
|
|
441
477
|
}
|
|
442
478
|
step.condition = rawStep.condition;
|
|
443
479
|
if (rawStep.condition === "hidden") {
|
|
@@ -456,26 +492,40 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
|
|
|
456
492
|
}
|
|
457
493
|
step.text = requiredText(rawStep.text, "hidden text", settings.jev.maxActionDescriptionChars);
|
|
458
494
|
step.targetText = step.text;
|
|
459
|
-
} 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") {
|
|
460
502
|
if (rawStep.url_contains !== undefined || step.role || step.name !== undefined || rawStep.text !== undefined
|
|
461
503
|
|| hasLocatorField(step) || step.near || step.within || rawStep.index !== undefined) {
|
|
462
504
|
throw new JevBrowserError("angular_idle condition accepts only condition and timeout_seconds");
|
|
463
505
|
}
|
|
464
|
-
} else if (step.role || step.name !== undefined || rawStep.text !== undefined || hasLocatorField(step)
|
|
465
|
-
|| step.near || step.within || rawStep.index !== undefined) {
|
|
466
|
-
throw new JevBrowserError("network_idle condition accepts only url_contains as an optional filter");
|
|
467
|
-
}
|
|
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
|
+
}
|
|
468
510
|
if (rawStep.condition === "network_idle" && rawStep.url_contains !== undefined) {
|
|
469
511
|
step.urlContains = requiredText(rawStep.url_contains, "url_contains", settings.jev.maxActionDescriptionChars);
|
|
470
|
-
} else if (rawStep.url_contains !== undefined) {
|
|
471
|
-
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");
|
|
472
514
|
}
|
|
473
515
|
}
|
|
474
|
-
if (action === "audit_accessibility") {
|
|
516
|
+
if (action === "audit_accessibility") {
|
|
475
517
|
if (typeof rawStep.standard !== "string" || !Object.hasOwn(ACCESSIBILITY_STANDARDS, rawStep.standard)) {
|
|
476
518
|
throw new JevBrowserError("audit_accessibility standard must be wcag2aa");
|
|
477
519
|
}
|
|
478
|
-
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
|
+
}
|
|
479
529
|
}
|
|
480
530
|
if (action === "press") {
|
|
481
531
|
if (typeof rawStep.key !== "string" || !APPROVED_KEYS.has(rawStep.key)) {
|
|
@@ -554,7 +604,7 @@ function normalizeStepAliases(rawStep) {
|
|
|
554
604
|
return step;
|
|
555
605
|
}
|
|
556
606
|
|
|
557
|
-
function resolveFlowOptions(rawOptions, settings) {
|
|
607
|
+
function resolveFlowOptions(rawOptions, settings) {
|
|
558
608
|
if (rawOptions === undefined) rawOptions = {};
|
|
559
609
|
if (!isRecord(rawOptions) || Object.keys(rawOptions).some((key) => !FLOW_OPTION_KEYS.has(key))) {
|
|
560
610
|
throw new JevBrowserError("options contains unsupported flow settings");
|
|
@@ -563,10 +613,11 @@ function resolveFlowOptions(rawOptions, settings) {
|
|
|
563
613
|
&& rawOptions.continue_from_current_page !== rawOptions.reuse_page) {
|
|
564
614
|
throw new JevBrowserError("options.continue_from_current_page and options.reuse_page cannot disagree");
|
|
565
615
|
}
|
|
566
|
-
const booleanOptions = [
|
|
567
|
-
"block_trackers", "capture_console_errors", "capture_network_error_bodies", "capture_network_errors",
|
|
568
|
-
"auto_angular_idle", "block_fonts", "continue_from_current_page", "dry_run", "fast_path", "local_only",
|
|
569
|
-
"ready_network_idle", "reuse_page", "screenshot_on_failure", "
|
|
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",
|
|
570
621
|
];
|
|
571
622
|
for (const key of booleanOptions) {
|
|
572
623
|
if (rawOptions[key] !== undefined && typeof rawOptions[key] !== "boolean") {
|
|
@@ -592,9 +643,60 @@ function resolveFlowOptions(rawOptions, settings) {
|
|
|
592
643
|
if (!Array.isArray(raw) || raw.length > maxItems) throw new JevBrowserError(`options.${key} must be an array with at most ${maxItems} entries`);
|
|
593
644
|
return raw.map((value) => requiredText(value, key, settings.jev.maxActionDescriptionChars));
|
|
594
645
|
};
|
|
595
|
-
const busySelectors = normalizeListOption("busy_selectors", settings.browser.busySelectorsDefault);
|
|
646
|
+
const busySelectors = normalizeListOption("busy_selectors", settings.browser.busySelectorsDefault);
|
|
596
647
|
const loginUrlContains = normalizeListOption("login_url_contains", settings.browser.loginUrlContainsDefault);
|
|
597
|
-
const loginText = normalizeListOption("login_text", settings.browser.loginTextDefault);
|
|
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");
|
|
598
700
|
let confirmationToken;
|
|
599
701
|
if (rawOptions.confirmation_token !== undefined) {
|
|
600
702
|
confirmationToken = requiredText(rawOptions.confirmation_token, "confirmation_token", 128);
|
|
@@ -604,7 +706,7 @@ function resolveFlowOptions(rawOptions, settings) {
|
|
|
604
706
|
|| readyTimeoutSeconds > settings.browser.maxReadyTimeoutSeconds) {
|
|
605
707
|
throw new JevBrowserError(`options.ready_timeout_seconds must be positive and at most ${settings.browser.maxReadyTimeoutSeconds}`);
|
|
606
708
|
}
|
|
607
|
-
const readyStableMs = rawOptions.ready_stable_ms ?? defaults.readyStableMs;
|
|
709
|
+
const readyStableMs = rawOptions.ready_stable_ms ?? defaults.readyStableMs;
|
|
608
710
|
if (!Number.isSafeInteger(readyStableMs) || readyStableMs <= 0 || readyStableMs > settings.browser.maxReadyStableMs
|
|
609
711
|
|| readyStableMs > readyTimeoutSeconds * 1000) {
|
|
610
712
|
throw new JevBrowserError("options.ready_stable_ms must be a positive integer within the configured readiness limits");
|
|
@@ -621,7 +723,8 @@ function resolveFlowOptions(rawOptions, settings) {
|
|
|
621
723
|
if (!new Set([".md", ".xml"]).has(extension)) throw new JevBrowserError("options.report_path must end in .md or .xml (JUnit)");
|
|
622
724
|
reportPath = rawOptions.report_path;
|
|
623
725
|
}
|
|
624
|
-
return Object.freeze({
|
|
726
|
+
return Object.freeze({
|
|
727
|
+
allowMutations: rawOptions.allow_mutations ?? false,
|
|
625
728
|
autoAngularIdle: rawOptions.auto_angular_idle ?? defaults.autoAngularIdle,
|
|
626
729
|
blockTrackers: rawOptions.block_trackers ?? defaults.blockTrackers,
|
|
627
730
|
blockFonts: rawOptions.block_fonts ?? defaults.blockFonts,
|
|
@@ -633,7 +736,19 @@ function resolveFlowOptions(rawOptions, settings) {
|
|
|
633
736
|
fastPath: rawOptions.fast_path ?? defaults.fastPath,
|
|
634
737
|
localOnly: rawOptions.local_only ?? defaults.localOnly,
|
|
635
738
|
loginText: Object.freeze(loginText),
|
|
636
|
-
loginUrlContains: Object.freeze(loginUrlContains),
|
|
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,
|
|
637
752
|
maxFlowSteps,
|
|
638
753
|
readyTimeoutMs: Math.ceil(readyTimeoutSeconds * 1000),
|
|
639
754
|
readyNetworkIdle: rawOptions.ready_network_idle ?? defaults.readyNetworkIdle,
|
|
@@ -676,12 +791,12 @@ function planSecrets(plans) {
|
|
|
676
791
|
return [...secrets].filter(Boolean).sort((left, right) => right.length - left.length);
|
|
677
792
|
}
|
|
678
793
|
|
|
679
|
-
function redact(value, secrets) {
|
|
794
|
+
function redact(value, secrets) {
|
|
680
795
|
let result = value;
|
|
681
796
|
for (const secret of secrets) {
|
|
682
797
|
const pattern = secret.length < 4
|
|
683
|
-
? new RegExp(`(?<![\\p{L}\\p{N}_])${escapeRegex(secret)}(?![\\p{L}\\p{N}_])`, "
|
|
684
|
-
: new RegExp(escapeRegex(secret), "
|
|
798
|
+
? new RegExp(`(?<![\\p{L}\\p{N}_])${escapeRegex(secret)}(?![\\p{L}\\p{N}_])`, "gu")
|
|
799
|
+
: new RegExp(escapeRegex(secret), "gu");
|
|
685
800
|
result = result.replace(pattern, "[provided form text]");
|
|
686
801
|
}
|
|
687
802
|
return result;
|
|
@@ -748,11 +863,110 @@ function planContentSecrets(plans) {
|
|
|
748
863
|
.filter(Boolean).sort((left, right) => right.length - left.length);
|
|
749
864
|
}
|
|
750
865
|
|
|
751
|
-
function redactSnapshotFieldValues(snapshot, secrets) {
|
|
752
|
-
return snapshot.split(/(\r?\n)/).map((line) => /^\s*-\s*(?:textbox|searchbox|combobox)\b/i.test(line)
|
|
753
|
-
? redact(line, secrets)
|
|
754
|
-
: line).join("");
|
|
755
|
-
}
|
|
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
|
+
}
|
|
756
970
|
|
|
757
971
|
async function restoreHiddenDialogs(hiddenDialogs) {
|
|
758
972
|
for (const { dialog, previous } of hiddenDialogs.reverse()) {
|
|
@@ -1018,70 +1232,86 @@ function containsExpectedOutcome(snapshot, phrase) {
|
|
|
1018
1232
|
return containsPhrase(expectedOutcomeText(snapshot), phrase);
|
|
1019
1233
|
}
|
|
1020
1234
|
|
|
1021
|
-
function createNetworkTracker(page, settings, loginUrlContains = []) {
|
|
1022
|
-
const active = new Map();
|
|
1023
|
-
const history = [];
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
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 };
|
|
1027
1252
|
if (type === "request") active.set(request, entry);
|
|
1028
1253
|
else if (type === "finished" || type === "failed") active.delete(request);
|
|
1029
1254
|
history.push(entry);
|
|
1030
|
-
const historyLimit = settings.browser.
|
|
1255
|
+
const historyLimit = settings.browser.maxNetworkHistoryEvents;
|
|
1031
1256
|
if (history.length > historyLimit) history.splice(0, history.length - historyLimit);
|
|
1032
1257
|
return entry;
|
|
1033
1258
|
};
|
|
1034
1259
|
const onRequest = (request) => record("request", request);
|
|
1035
|
-
const onResponse = (response) => {
|
|
1036
|
-
const entry = record("response", response.request());
|
|
1260
|
+
const onResponse = (response) => {
|
|
1261
|
+
const entry = record("response", response.request());
|
|
1262
|
+
if (!entry) return;
|
|
1037
1263
|
entry.response = response;
|
|
1038
1264
|
entry.status = response.status();
|
|
1039
|
-
};
|
|
1040
|
-
const onFinished = (request) => record("finished", request);
|
|
1041
|
-
const onFailed = (request) => record("failed", request);
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
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))),
|
|
1049
1283
|
responsesSince: (marker, urlContains, method) => history.filter((entry) => entry.sequence > marker
|
|
1050
1284
|
&& entry.type === "response" && entry.url.includes(urlContains) && entry.method === method),
|
|
1051
1285
|
authenticationFailureSince: (marker) => history.find((entry) => {
|
|
1052
1286
|
if (entry.sequence <= marker || entry.type !== "response" || ![401, 403].includes(entry.status)) return false;
|
|
1053
1287
|
return loginUrlContains.some((needle) => entry.url.toLocaleLowerCase().includes(needle.toLocaleLowerCase()));
|
|
1054
1288
|
}),
|
|
1055
|
-
activeFor: (urlContains) => [...active.values()].filter((entry) => entry.url.includes(urlContains)),
|
|
1056
|
-
dispose: () => {
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
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;
|
|
1063
1301
|
},
|
|
1064
1302
|
};
|
|
1065
1303
|
}
|
|
1066
1304
|
|
|
1067
|
-
async function waitForFilteredNetworkIdle(tracker, urlContains, marker, timeout, settings) {
|
|
1068
|
-
const started = performance.now();
|
|
1069
|
-
let
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
}
|
|
1078
|
-
const active = tracker.activeFor(urlContains);
|
|
1079
|
-
if (active.length) sawRequest = true;
|
|
1080
|
-
if (sawRequest && active.length === 0 && performance.now() - lastMatchingActivity >= quietMs) return;
|
|
1081
|
-
await new Promise((resolve) => setTimeout(resolve, Math.min(settings.browser.visibilityPollMs, remainingMs(started + timeout))));
|
|
1082
|
-
}
|
|
1083
|
-
if (!sawRequest) throw new JevBrowserError(`network_idle did not observe a request matching url_contains ${JSON.stringify(urlContains)} within ${timeout}ms`);
|
|
1084
|
-
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`);
|
|
1085
1315
|
}
|
|
1086
1316
|
|
|
1087
1317
|
async function waitForAngularIdle(page, timeout, settings, optional = false) {
|
|
@@ -1123,7 +1353,7 @@ async function waitForAngularIdle(page, timeout, settings, optional = false) {
|
|
|
1123
1353
|
throw new JevBrowserError(`AngularJS still had pending requests or digest work after ${timeout}ms`);
|
|
1124
1354
|
}
|
|
1125
1355
|
|
|
1126
|
-
async function waitForValue(locator, expected, timeout, description, settings) {
|
|
1356
|
+
async function waitForValue(locator, expected, timeout, description, settings) {
|
|
1127
1357
|
const deadline = performance.now() + timeout;
|
|
1128
1358
|
while (performance.now() < deadline) {
|
|
1129
1359
|
try {
|
|
@@ -1222,12 +1452,46 @@ function remainingMs(deadline) {
|
|
|
1222
1452
|
return Math.max(1, Math.ceil(deadline - performance.now()));
|
|
1223
1453
|
}
|
|
1224
1454
|
|
|
1225
|
-
function browserDisconnected(error) {
|
|
1455
|
+
function browserDisconnected(error) {
|
|
1226
1456
|
return /target (?:page, context or browser )?has been closed|browser (?:has been closed|disconnected)|context has been closed|connection closed/i
|
|
1227
1457
|
.test(String(error?.message || error?.name || ""));
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
|
-
|
|
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) {
|
|
1231
1495
|
const started = performance.now();
|
|
1232
1496
|
const deadline = started + options.readyTimeoutMs;
|
|
1233
1497
|
const bestEffortLoadState = async (state) => {
|
|
@@ -1242,7 +1506,7 @@ async function waitForPageReady(page, options, settings, secrets, continued = fa
|
|
|
1242
1506
|
if (options.readyNetworkIdle) await bestEffortLoadState("networkidle");
|
|
1243
1507
|
}
|
|
1244
1508
|
|
|
1245
|
-
let
|
|
1509
|
+
let previousStabilityKey = "";
|
|
1246
1510
|
let stableSince = 0;
|
|
1247
1511
|
let lastSnapshot = "";
|
|
1248
1512
|
while (performance.now() < deadline) {
|
|
@@ -1251,24 +1515,25 @@ async function waitForPageReady(page, options, settings, secrets, continued = fa
|
|
|
1251
1515
|
timeoutMs: remainingMs(deadline),
|
|
1252
1516
|
includeHidden: options.snapshotIncludeHidden,
|
|
1253
1517
|
});
|
|
1254
|
-
lastSnapshot = snapshot;
|
|
1518
|
+
lastSnapshot = snapshot;
|
|
1519
|
+
const stabilityKey = await pageStructureSignature(page, snapshot);
|
|
1255
1520
|
const readyTextVisible = !options.readyText || containsPhrase(snapshot, options.readyText);
|
|
1256
1521
|
const usefulSnapshot = await hasRenderedContent(page, snapshot, options.busySelectors);
|
|
1257
1522
|
if (continued && snapshot && readyTextVisible && usefulSnapshot) {
|
|
1258
1523
|
return { snapshot, readyMs: performance.now() - started };
|
|
1259
1524
|
}
|
|
1260
1525
|
if (snapshot && readyTextVisible && usefulSnapshot) {
|
|
1261
|
-
if (
|
|
1526
|
+
if (stabilityKey === previousStabilityKey) {
|
|
1262
1527
|
if (!stableSince) stableSince = performance.now();
|
|
1263
1528
|
if (performance.now() - stableSince >= options.readyStableMs) {
|
|
1264
1529
|
return { snapshot, readyMs: performance.now() - started };
|
|
1265
1530
|
}
|
|
1266
1531
|
} else {
|
|
1267
|
-
|
|
1268
|
-
stableSince = performance.now();
|
|
1269
|
-
}
|
|
1270
|
-
} else {
|
|
1271
|
-
|
|
1532
|
+
previousStabilityKey = stabilityKey;
|
|
1533
|
+
stableSince = performance.now();
|
|
1534
|
+
}
|
|
1535
|
+
} else {
|
|
1536
|
+
previousStabilityKey = "";
|
|
1272
1537
|
stableSince = 0;
|
|
1273
1538
|
}
|
|
1274
1539
|
await new Promise((resolve) => setTimeout(resolve, Math.min(100, remainingMs(deadline))));
|
|
@@ -1282,8 +1547,12 @@ async function waitForPageReady(page, options, settings, secrets, continued = fa
|
|
|
1282
1547
|
async function hasRenderedContent(page, snapshot, busySelectors = []) {
|
|
1283
1548
|
const lines = snapshot.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
1284
1549
|
if (!lines.some((line, index) => index > 0 || /:\s+\S|"[^"]+"/.test(line))) return false;
|
|
1285
|
-
try {
|
|
1286
|
-
|
|
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
|
+
}
|
|
1287
1556
|
} catch {
|
|
1288
1557
|
// The snapshot itself is the fallback when the DOM is transitioning.
|
|
1289
1558
|
}
|
|
@@ -1322,29 +1591,45 @@ async function waitForText(locator, expected, timeout, description, settings) {
|
|
|
1322
1591
|
throw new JevBrowserError(`${description} did not contain the expected text within ${timeout}ms`);
|
|
1323
1592
|
}
|
|
1324
1593
|
|
|
1325
|
-
async function pageFingerprint(page
|
|
1326
|
-
let
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
}
|
|
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
|
+
}
|
|
1348
1633
|
|
|
1349
1634
|
async function snapshotAfterStep(page, settings, secrets, scope, previousSnapshot, stepNumber, warnings, includeHidden) {
|
|
1350
1635
|
const snapshot = await snapshotPage(page, settings, secrets, scope, { allowEmpty: true, includeHidden });
|
|
@@ -1549,11 +1834,40 @@ function accessibleNamePattern(name) {
|
|
|
1549
1834
|
return new RegExp(`^[\\s\\uE000-\\uF8FF]*${normalized}[\\s\\uE000-\\uF8FF]*$`, "u");
|
|
1550
1835
|
}
|
|
1551
1836
|
|
|
1552
|
-
function roleOptions(step) {
|
|
1553
|
-
if (step.name === "") return { name: UNNAMED_ACCESSIBLE_NAME };
|
|
1554
|
-
if (step.name === undefined) return {};
|
|
1555
|
-
return { name: step.name, exact:
|
|
1556
|
-
}
|
|
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
|
+
}
|
|
1557
1871
|
|
|
1558
1872
|
function xpathRolePredicate(role) {
|
|
1559
1873
|
const nativePredicates = {
|
|
@@ -1614,10 +1928,10 @@ async function targetLocator(root, step, timeout, settings) {
|
|
|
1614
1928
|
locator = targetRoot.getByTestId(step.testId);
|
|
1615
1929
|
} else if (step.near) {
|
|
1616
1930
|
locator = relativeTarget(targetRoot.getByText(step.near.text, { exact: true }).first(), step.role);
|
|
1617
|
-
} else if (step.role) {
|
|
1618
|
-
locator = targetRoot.getByRole(step.role, roleOptions(step));
|
|
1619
|
-
if (step.name && await locator.count() === 0) {
|
|
1620
|
-
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) });
|
|
1621
1935
|
}
|
|
1622
1936
|
} else {
|
|
1623
1937
|
throw new JevBrowserError("step has no resolvable target; use role/name, label, placeholder, title, text, target_text, test_id, selector, or near");
|
|
@@ -1966,9 +2280,22 @@ async function navigateMenu(page, root, step, settings, execution) {
|
|
|
1966
2280
|
return { action: step.action, navigated_menu: step.path };
|
|
1967
2281
|
}
|
|
1968
2282
|
|
|
1969
|
-
async function executeStep(page, step, settings, snapshot, secrets, downloadLedger, execution = {}) {
|
|
1970
|
-
const root = await stepRoot(page, step.frame);
|
|
1971
|
-
const timeout = step.timeoutMs ?? settings.browser.actionTimeoutMs;
|
|
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
|
+
}
|
|
1972
2299
|
if (step.action === "assert_network") {
|
|
1973
2300
|
const response = await waitForNetworkAssertion(
|
|
1974
2301
|
execution.networkTracker,
|
|
@@ -2046,29 +2373,29 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
2046
2373
|
await waitForAngularIdle(page, timeout, settings);
|
|
2047
2374
|
return { action: step.action, condition: step.condition };
|
|
2048
2375
|
}
|
|
2049
|
-
if (step.action === "wait_for_condition") {
|
|
2050
|
-
if (step.condition === "network_idle") {
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
} 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 {
|
|
2063
2389
|
const locator = await targetLocator(root, step, timeout, settings);
|
|
2064
2390
|
await waitForLocatorHidden(locator, timeout, `hidden condition ${describeTarget(step, secrets)}`, settings, step.index);
|
|
2065
2391
|
}
|
|
2066
2392
|
return {
|
|
2067
2393
|
action: step.action,
|
|
2068
|
-
condition: step.condition,
|
|
2069
|
-
...(step.condition === "network_idle"
|
|
2070
|
-
? (step.urlContains ? { url_contains: redact(step.urlContains, secrets) } : {})
|
|
2071
|
-
: {
|
|
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) }),
|
|
2072
2399
|
};
|
|
2073
2400
|
}
|
|
2074
2401
|
if (step.action === "press") {
|
|
@@ -2085,14 +2412,18 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
2085
2412
|
if (step.action === "like_comment" || step.action === "unlike_comment") {
|
|
2086
2413
|
return { action: step.action, result: await reactToComment(root, step, timeout, settings) };
|
|
2087
2414
|
}
|
|
2088
|
-
if (step.action === "audit_accessibility") {
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
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,
|
|
2096
2427
|
};
|
|
2097
2428
|
}
|
|
2098
2429
|
if (step.action === "upload_file") {
|
|
@@ -2154,35 +2485,33 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
2154
2485
|
}
|
|
2155
2486
|
if (step.action === "click") {
|
|
2156
2487
|
const resolvedTarget = await describeResolvedTarget(control, step, secrets, settings);
|
|
2157
|
-
if (step.expectPopup) {
|
|
2158
|
-
let popup;
|
|
2159
|
-
try {
|
|
2160
|
-
[popup] = await Promise.all([
|
|
2161
|
-
page.waitForEvent("popup", { timeout: settings.browser.downloadTimeoutMs }),
|
|
2162
|
-
|
|
2163
|
-
]);
|
|
2164
|
-
} catch {
|
|
2165
|
-
throw new JevBrowserError("click did not open the expected popup before the configured timeout");
|
|
2166
|
-
}
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
}
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
if (!step.expectDownload) {
|
|
2185
|
-
await control.click({ timeout });
|
|
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);
|
|
2186
2515
|
if (execution.nativeDialog?.result && !execution.nativeDialog.result.matched) {
|
|
2187
2516
|
throw new JevBrowserError(`unexpected native ${execution.nativeDialog.result.type} dialog was dismissed: ${JSON.stringify(sanitizeDiagnosticText(execution.nativeDialog.result.message, secrets, settings.jev.maxDiagnosticChars))}`);
|
|
2188
2517
|
}
|
|
@@ -2203,7 +2532,7 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
2203
2532
|
downloaded_file: await saveDownload(download, settings, downloadLedger),
|
|
2204
2533
|
};
|
|
2205
2534
|
}
|
|
2206
|
-
if (step.action === "type") {
|
|
2535
|
+
if (step.action === "type") {
|
|
2207
2536
|
const resolvedTarget = await describeResolvedTarget(control, step, secrets, settings);
|
|
2208
2537
|
if (step.mode === "keys") {
|
|
2209
2538
|
await control.fill("", { timeout });
|
|
@@ -2228,7 +2557,12 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
2228
2557
|
name: step.name,
|
|
2229
2558
|
...targetEvidence(step, secrets, resolvedTarget),
|
|
2230
2559
|
};
|
|
2231
|
-
}
|
|
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
|
+
}
|
|
2232
2566
|
if (step.action === "select_option") {
|
|
2233
2567
|
const resolvedTarget = await describeResolvedTarget(control, step, secrets, settings);
|
|
2234
2568
|
if (step.optionMatch === "value") {
|
|
@@ -2310,9 +2644,20 @@ function flowTimings(started, browserSessionMs, navigationMs, initialSnapshotMs,
|
|
|
2310
2644
|
};
|
|
2311
2645
|
}
|
|
2312
2646
|
|
|
2313
|
-
function addDiagnostic(collection, value, limit) {
|
|
2314
|
-
if (collection.length < limit) collection.push(value);
|
|
2315
|
-
}
|
|
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
|
+
}
|
|
2316
2661
|
|
|
2317
2662
|
function evidenceUrl(value, secrets, limit) {
|
|
2318
2663
|
try {
|
|
@@ -2335,17 +2680,22 @@ function sanitizeDiagnosticText(value, secrets, limit) {
|
|
|
2335
2680
|
return result.slice(0, limit);
|
|
2336
2681
|
}
|
|
2337
2682
|
|
|
2338
|
-
function createDiagnostics(page, options, settings, secrets) {
|
|
2683
|
+
function createDiagnostics(page, options, settings, secrets) {
|
|
2339
2684
|
const diagnostics = { console_errors: [], network_failures: [], warnings: [] };
|
|
2340
2685
|
const pendingBodies = [];
|
|
2341
2686
|
const limit = settings.jev.maxDiagnosticItems;
|
|
2342
|
-
const messageLimit = settings.jev.maxDiagnosticChars;
|
|
2343
|
-
const
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
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));
|
|
2349
2699
|
};
|
|
2350
2700
|
const addResponseMessage = async (response, entry) => {
|
|
2351
2701
|
const warn = (reason) => addDiagnostic(
|
|
@@ -2470,13 +2820,14 @@ function trackerUrl(url, settings) {
|
|
|
2470
2820
|
return settings.browser.trackerHostSuffixes.some((suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`));
|
|
2471
2821
|
}
|
|
2472
2822
|
|
|
2473
|
-
async function installTrackerBlocking(context, settings, enabled, blockFonts = false) {
|
|
2823
|
+
async function installTrackerBlocking(context, settings, enabled, blockFonts = false, allowMedia = false) {
|
|
2474
2824
|
if (!enabled && !blockFonts) return undefined;
|
|
2475
2825
|
const handler = async (route) => {
|
|
2476
2826
|
const request = route.request();
|
|
2477
2827
|
const resourceType = request.resourceType();
|
|
2478
2828
|
if ((enabled && trackerUrl(request.url(), settings))
|
|
2479
|
-
|| (enabled && settings.browser.blockedResourceTypes.includes(resourceType)
|
|
2829
|
+
|| (enabled && settings.browser.blockedResourceTypes.includes(resourceType)
|
|
2830
|
+
&& (resourceType !== "font" || blockFonts) && (resourceType !== "media" || !allowMedia))
|
|
2480
2831
|
|| (blockFonts && resourceType === "font")) {
|
|
2481
2832
|
await route.abort("blockedbyclient");
|
|
2482
2833
|
return;
|
|
@@ -2563,20 +2914,40 @@ function reportContent(result, extension) {
|
|
|
2563
2914
|
].join("\n");
|
|
2564
2915
|
}
|
|
2565
2916
|
|
|
2566
|
-
async function writeFlowReport(result, reportPath, settings) {
|
|
2917
|
+
async function writeFlowReport(result, reportPath, settings) {
|
|
2567
2918
|
const outputPath = await safeReportPath(reportPath, settings);
|
|
2568
2919
|
await writeFile(outputPath, reportContent(result, path.extname(outputPath).toLowerCase()), { encoding: "utf8", flag: "w" });
|
|
2569
2920
|
return outputPath;
|
|
2570
|
-
}
|
|
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
|
+
}
|
|
2571
2942
|
|
|
2572
2943
|
async function redactObject(value, secrets) {
|
|
2573
2944
|
if (typeof value === "string") return redact(value, secrets);
|
|
2574
2945
|
if (Array.isArray(value)) return Promise.all(value.map((item) => redactObject(item, secrets)));
|
|
2575
2946
|
if (isRecord(value)) {
|
|
2576
2947
|
return Object.fromEntries(await Promise.all(Object.entries(value).map(async ([key, item]) => [
|
|
2577
|
-
key,
|
|
2578
|
-
key === "final_snapshot" && typeof item === "string"
|
|
2579
|
-
? redactSnapshotFieldValues(item, secrets)
|
|
2948
|
+
key,
|
|
2949
|
+
key === "final_snapshot" && typeof item === "string"
|
|
2950
|
+
? redactSnapshotFieldValues(redact(item, secrets), secrets)
|
|
2580
2951
|
: await redactObject(item, secrets),
|
|
2581
2952
|
])));
|
|
2582
2953
|
}
|
|
@@ -2599,7 +2970,7 @@ function snapshotDiff(initialSnapshot, finalSnapshot) {
|
|
|
2599
2970
|
return added.length ? added.join("\n") : "[no new snapshot lines]";
|
|
2600
2971
|
}
|
|
2601
2972
|
|
|
2602
|
-
export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, candidatePlans, params: rawParams, options: rawOptions, settings, browserPool, fetchImpl = fetch, serverVersion, mutationAuthorizationStore = defaultMutationAuthorizationStore }) {
|
|
2973
|
+
export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, candidatePlans, params: rawParams, options: rawOptions, settings, browserPool, fetchImpl = fetch, serverVersion, mutationAuthorizationStore = defaultMutationAuthorizationStore }) {
|
|
2603
2974
|
if (typeof flow !== "string" || typeof expectedOutcome !== "string") throw new JevBrowserError("flow and expected_outcome must be text");
|
|
2604
2975
|
let variables = validateParams(rawParams, settings);
|
|
2605
2976
|
const requestedFlow = replaceKnownParams(flow, variables).trim();
|
|
@@ -2608,20 +2979,29 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2608
2979
|
if (requestedFlow.length > settings.jev.maxFlowChars) throw new JevBrowserError("flow exceeds the configured character limit");
|
|
2609
2980
|
if (outcome.length > settings.jev.maxActionDescriptionChars) throw new JevBrowserError("expected_outcome exceeds the configured character limit");
|
|
2610
2981
|
const url = validateUrl(initialUrl);
|
|
2611
|
-
const options = resolveFlowOptions(rawOptions, settings);
|
|
2612
|
-
const plans = validateCandidatePlans(replaceKnownParams(candidatePlans, variables), settings, options.maxFlowSteps);
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
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
|
+
}
|
|
2616
2995
|
const fingerprint = flowFingerprint({ flow, initialUrl: url, expectedOutcome, candidatePlans, params: rawParams, options });
|
|
2617
2996
|
const continuation = options.confirmationToken
|
|
2618
2997
|
? mutationAuthorizationStore.consume(options.confirmationToken, fingerprint)
|
|
2619
2998
|
: undefined;
|
|
2620
2999
|
const secrets = [...planSecrets(plans), ...sensitiveParamSecrets(variables), settings.env?.[settings.jev.credentialEnv]].filter(Boolean)
|
|
2621
3000
|
.sort((left, right) => right.length - left.length);
|
|
2622
|
-
const contentSecrets = planContentSecrets(plans);
|
|
2623
|
-
const started = performance.now();
|
|
2624
|
-
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);
|
|
2625
3005
|
const browserSessionMs = browser.sessionMs;
|
|
2626
3006
|
const page = browser.page;
|
|
2627
3007
|
const context = browser.context;
|
|
@@ -2642,7 +3022,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2642
3022
|
warnings: continuation?.warnings || [],
|
|
2643
3023
|
final_snapshot: "",
|
|
2644
3024
|
};
|
|
2645
|
-
result.snapshot_mode = options.returnSnapshot;
|
|
3025
|
+
result.snapshot_mode = options.returnSnapshot;
|
|
3026
|
+
if (options.allowMutations) result.mutation_authorization = "allowed_on_localhost";
|
|
2646
3027
|
const downloadLedger = { count: 0, bytes: 0 };
|
|
2647
3028
|
const networkTracker = createNetworkTracker(page, settings, options.loginUrlContains);
|
|
2648
3029
|
const initialNetworkMarker = networkTracker.mark();
|
|
@@ -2650,9 +3031,11 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2650
3031
|
let unroute;
|
|
2651
3032
|
let tracingStarted = false;
|
|
2652
3033
|
let tracePath;
|
|
2653
|
-
const artifactErrors = [];
|
|
2654
|
-
let navigationError;
|
|
2655
|
-
let initialSnapshot = "";
|
|
3034
|
+
const artifactErrors = [];
|
|
3035
|
+
let navigationError;
|
|
3036
|
+
let initialSnapshot = "";
|
|
3037
|
+
let popupPage;
|
|
3038
|
+
let activePage = page;
|
|
2656
3039
|
const nativeDialog = { expectedStep: undefined, result: undefined };
|
|
2657
3040
|
const onNativeDialog = async (dialog) => {
|
|
2658
3041
|
const expectedStep = nativeDialog.expectedStep;
|
|
@@ -2678,14 +3061,22 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2678
3061
|
try {
|
|
2679
3062
|
page.setDefaultTimeout(settings.browser.actionTimeoutMs);
|
|
2680
3063
|
context.setDefaultTimeout(settings.browser.actionTimeoutMs);
|
|
2681
|
-
unroute = await installTrackerBlocking(
|
|
2682
|
-
|
|
2683
|
-
|
|
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 {
|
|
2684
3073
|
await context.tracing.start({ screenshots: true, snapshots: true });
|
|
2685
3074
|
tracingStarted = true;
|
|
2686
3075
|
} catch {
|
|
2687
|
-
artifactErrors.push("trace recording could not be started");
|
|
2688
|
-
}
|
|
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);
|
|
2689
3080
|
}
|
|
2690
3081
|
|
|
2691
3082
|
let reusedPage = false;
|
|
@@ -2709,8 +3100,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2709
3100
|
} else {
|
|
2710
3101
|
reusedPage = true;
|
|
2711
3102
|
result.navigation_strategy = "continued_current_page";
|
|
2712
|
-
if (new URL(currentUrl).href !== new URL(url).href) {
|
|
2713
|
-
|
|
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");
|
|
2714
3105
|
}
|
|
2715
3106
|
}
|
|
2716
3107
|
}
|
|
@@ -2742,18 +3133,17 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2742
3133
|
}
|
|
2743
3134
|
for (const control of enriched.unnamedControls) {
|
|
2744
3135
|
addDiagnostic(
|
|
2745
|
-
result.warnings,
|
|
2746
|
-
`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`,
|
|
2747
3138
|
settings.jev.maxDiagnosticItems,
|
|
2748
3139
|
);
|
|
2749
3140
|
}
|
|
2750
3141
|
timings.readyMs = readiness.readyMs;
|
|
2751
3142
|
browserPool.setNavigationError?.(null);
|
|
2752
3143
|
if (continuation) {
|
|
2753
|
-
const
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
throw new JevBrowserError("the page changed after dry_run; run dry_run again before confirming a mutation");
|
|
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");
|
|
2757
3147
|
}
|
|
2758
3148
|
if (!Object.hasOwn(plans, continuation.planId)) {
|
|
2759
3149
|
throw new JevBrowserError("confirmation_token refers to a candidate plan that is no longer available");
|
|
@@ -2788,7 +3178,7 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2788
3178
|
const decisionFlow = redact(`${requestedFlow}\nExpected outcome: ${outcome}`, secrets);
|
|
2789
3179
|
const { request, criteria } = buildChoicePayload({
|
|
2790
3180
|
flow: decisionFlow,
|
|
2791
|
-
pageSnapshot:
|
|
3181
|
+
pageSnapshot: providerSafeSnapshot(result.final_snapshot, [...secrets, ...contentSecrets]),
|
|
2792
3182
|
actions: descriptions,
|
|
2793
3183
|
answerName: "browser_plan",
|
|
2794
3184
|
allowComplete: options.stopOnExpected,
|
|
@@ -2824,23 +3214,29 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2824
3214
|
result.accessibility_audits = continuation?.accessibilityAudits || [];
|
|
2825
3215
|
if (continuation?.downloadLedger) Object.assign(downloadLedger, continuation.downloadLedger);
|
|
2826
3216
|
const planStarted = performance.now();
|
|
2827
|
-
const runAllSteps = plan.steps.some(({ action, expectDownload }) => action.startsWith("assert_")
|
|
2828
|
-
|| ["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)
|
|
2829
3219
|
|| expectDownload === true);
|
|
2830
3220
|
let approvedMutationIndex = continuation?.nextStep;
|
|
2831
3221
|
for (let index = continuation?.nextStep || 0; index < plan.steps.length; index += 1) {
|
|
2832
3222
|
const templateStep = plan.steps[index];
|
|
2833
3223
|
const stepStarted = performance.now();
|
|
2834
3224
|
let step = templateStep;
|
|
2835
|
-
try {
|
|
2836
|
-
step = interpolateStep(templateStep, variables);
|
|
2837
|
-
step.timeoutMs ??= options.stepTimeoutMs;
|
|
2838
|
-
const
|
|
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);
|
|
2839
3234
|
const dynamicallyMutating = mutationInspection.mutating;
|
|
2840
3235
|
const pairedNativeConfirmation = step.action === "confirm_dialog" && nativeDialog.result?.matched === true;
|
|
2841
|
-
if (dynamicallyMutating && !pairedNativeConfirmation && index !== approvedMutationIndex
|
|
3236
|
+
if (dynamicallyMutating && !pairedNativeConfirmation && index !== approvedMutationIndex
|
|
3237
|
+
&& !(options.allowMutations && !options.dryRun)) {
|
|
2842
3238
|
mutationStop = { step: String(index + 1), action: step.action };
|
|
2843
|
-
const enriched = await enrichSnapshot(
|
|
3239
|
+
const enriched = await enrichSnapshot(stepPage, snapshot, settings, secrets, options.snapshotScope, options.snapshotIncludeHidden);
|
|
2844
3240
|
snapshot = enriched.snapshot;
|
|
2845
3241
|
issuedToken = mutationAuthorizationStore.issue({
|
|
2846
3242
|
fingerprint,
|
|
@@ -2851,7 +3247,7 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2851
3247
|
snapshot,
|
|
2852
3248
|
initialSnapshot,
|
|
2853
3249
|
pageUrl: typeof page.url === "function" ? page.url() : "unavailable",
|
|
2854
|
-
pageFingerprint: await pageFingerprint(page
|
|
3250
|
+
pageFingerprint: await pageFingerprint(page),
|
|
2855
3251
|
downloadedFiles: [...result.downloaded_files],
|
|
2856
3252
|
accessibilityAudits: [...result.accessibility_audits],
|
|
2857
3253
|
warnings: [...result.warnings],
|
|
@@ -2871,17 +3267,23 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2871
3267
|
nativeDialog.expectedStep = undefined;
|
|
2872
3268
|
nativeDialog.result = undefined;
|
|
2873
3269
|
}
|
|
2874
|
-
const actionMarker = networkTracker.mark();
|
|
2875
|
-
const
|
|
2876
|
-
networkTracker,
|
|
2877
|
-
networkMarker,
|
|
2878
|
-
autoAngularIdle: options.autoAngularIdle,
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
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);
|
|
2885
3287
|
}
|
|
2886
3288
|
if (!["wait_for_text", "wait_for_condition", "assert_text", "assert_value", "assert_visible", "assert_hidden", "audit_accessibility"].includes(step.action)) {
|
|
2887
3289
|
networkMarker = actionMarker;
|
|
@@ -2889,10 +3291,10 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2889
3291
|
traceStep.step = String(index + 1);
|
|
2890
3292
|
traceStep.status = "passed";
|
|
2891
3293
|
if (step.mutating === true) traceStep.mutating = true;
|
|
2892
|
-
if (step.screenshot === true) {
|
|
2893
|
-
try {
|
|
2894
|
-
traceStep.screenshot_path = await artifactPath(settings, "png");
|
|
2895
|
-
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 });
|
|
2896
3298
|
} catch {
|
|
2897
3299
|
delete traceStep.screenshot_path;
|
|
2898
3300
|
artifactErrors.push(`step ${index + 1} screenshot could not be saved`);
|
|
@@ -2901,14 +3303,14 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2901
3303
|
traceStep.duration_ms = Math.round((performance.now() - stepStarted) * 10) / 10;
|
|
2902
3304
|
trace.push(traceStep);
|
|
2903
3305
|
if (traceStep.downloaded_file) result.downloaded_files.push(traceStep.downloaded_file);
|
|
2904
|
-
if (traceStep.accessibility_audit) {
|
|
2905
|
-
result.accessibility_audits.push(traceStep.accessibility_audit);
|
|
2906
|
-
if (traceStep.accessibility_audit.
|
|
2907
|
-
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`;
|
|
2908
3310
|
}
|
|
2909
3311
|
}
|
|
2910
|
-
snapshot = await snapshotAfterStep(
|
|
2911
|
-
|
|
3312
|
+
snapshot = await snapshotAfterStep(
|
|
3313
|
+
stepPage,
|
|
2912
3314
|
settings,
|
|
2913
3315
|
secrets,
|
|
2914
3316
|
options.snapshotScope,
|
|
@@ -2937,9 +3339,9 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2937
3339
|
break;
|
|
2938
3340
|
}
|
|
2939
3341
|
}
|
|
2940
|
-
if (failure || trace.length === 0) {
|
|
2941
|
-
snapshot = await snapshotAfterStep(
|
|
2942
|
-
|
|
3342
|
+
if (failure || trace.length === 0) {
|
|
3343
|
+
snapshot = await snapshotAfterStep(
|
|
3344
|
+
activePage,
|
|
2943
3345
|
settings,
|
|
2944
3346
|
secrets,
|
|
2945
3347
|
options.snapshotScope,
|
|
@@ -2949,8 +3351,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2949
3351
|
options.snapshotIncludeHidden,
|
|
2950
3352
|
);
|
|
2951
3353
|
}
|
|
2952
|
-
const finalDetails = await enrichSnapshot(
|
|
2953
|
-
|
|
3354
|
+
const finalDetails = await enrichSnapshot(
|
|
3355
|
+
activePage,
|
|
2954
3356
|
snapshot,
|
|
2955
3357
|
settings,
|
|
2956
3358
|
secrets,
|
|
@@ -2965,8 +3367,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2965
3367
|
const assertionCount = plan.steps.filter(({ action }) => action.startsWith("assert_")).length;
|
|
2966
3368
|
const assertionsPassed = assertionCount > 0 && !failure && trace.length === plan.steps.length;
|
|
2967
3369
|
const auditCount = plan.steps.filter(({ action }) => action === "audit_accessibility").length;
|
|
2968
|
-
const auditsPassed = auditCount > 0 && !failure && result.accessibility_audits.length === auditCount
|
|
2969
|
-
&& 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)
|
|
2970
3372
|
&& trace.length === plan.steps.length;
|
|
2971
3373
|
const expectedDownloadCount = plan.steps.filter(({ action, expectDownload }) => action === "click" && expectDownload).length;
|
|
2972
3374
|
const downloadsPassed = expectedDownloadCount > 0 && !failure
|
|
@@ -3047,12 +3449,13 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
3047
3449
|
} catch {
|
|
3048
3450
|
// Keep the last accessible snapshot when the page is closing or unavailable.
|
|
3049
3451
|
}
|
|
3050
|
-
} finally {
|
|
3051
|
-
if (tracingStarted) {
|
|
3052
|
-
try {
|
|
3053
|
-
if (result.status !== "passed" && options.traceOnFailure)
|
|
3054
|
-
|
|
3055
|
-
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 });
|
|
3056
3459
|
} else {
|
|
3057
3460
|
await context.tracing.stop();
|
|
3058
3461
|
}
|
|
@@ -3060,15 +3463,34 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
3060
3463
|
artifactErrors.push("trace recording could not be saved");
|
|
3061
3464
|
}
|
|
3062
3465
|
}
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
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 {
|
|
3068
3472
|
delete result.screenshot_path;
|
|
3069
|
-
artifactErrors.push("failure screenshot could not be saved");
|
|
3070
|
-
}
|
|
3071
|
-
}
|
|
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(() => {});
|
|
3072
3494
|
try {
|
|
3073
3495
|
await unroute?.();
|
|
3074
3496
|
} catch {
|
|
@@ -3080,13 +3502,13 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
3080
3502
|
}
|
|
3081
3503
|
if (tracePath) result.trace_path = tracePath;
|
|
3082
3504
|
if (artifactErrors.length) result.artifact_errors = artifactErrors;
|
|
3083
|
-
result.console_errors = [...(continuation?.consoleErrors || []), ...capture.diagnostics.console_errors];
|
|
3505
|
+
result.console_errors = groupConsoleErrors([...(continuation?.consoleErrors || []), ...capture.diagnostics.console_errors]);
|
|
3084
3506
|
result.network_failures = [...(continuation?.networkFailures || []), ...capture.diagnostics.network_failures];
|
|
3085
3507
|
result.warnings.push(...capture.diagnostics.warnings);
|
|
3086
3508
|
result.timings_ms = flowTimings(started, browserSessionMs, timings.navigationMs, timings.initialSnapshotMs, timings.readyMs, timings.decisionMs, timings.browserPlanMs);
|
|
3087
|
-
const safeResult = await redactObject(result, secrets);
|
|
3088
|
-
if (typeof safeResult.final_snapshot === "string") {
|
|
3089
|
-
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);
|
|
3090
3512
|
}
|
|
3091
3513
|
if (options.returnSnapshot === "diff" && typeof safeResult.final_snapshot === "string") {
|
|
3092
3514
|
safeResult.final_snapshot = snapshotDiff(initialSnapshot, safeResult.final_snapshot);
|