@diegosouzacdv/jev-browser-mcp 0.6.0 → 0.6.1
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 +65 -26
- package/config/ui-testing.json +10 -1
- package/docs/jev-browser-mcp.md +65 -26
- package/mcp_servers/jev-browser-npm/src/config.mjs +91 -55
- package/mcp_servers/jev-browser-npm/src/flow.mjs +536 -92
- package/mcp_servers/jev-browser-npm/src/jev-client.mjs +22 -9
- package/mcp_servers/jev-browser-npm/src/server.mjs +33 -8
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
2
|
import { lstat, mkdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { ACCESSIBILITY_STANDARDS, runAccessibilityAudit } from "./accessibility.mjs";
|
|
@@ -6,7 +6,8 @@ import { JevBrowserError } from "./config.mjs";
|
|
|
6
6
|
import { buildChoicePayload, COMPLETE_ACTION_ID, parseDecision, requestJev } from "./jev-client.mjs";
|
|
7
7
|
|
|
8
8
|
const PLAN_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,79}$/;
|
|
9
|
-
const CLICK_ROLES = new Set(["button", "link", "tab", "menuitem"]);
|
|
9
|
+
const CLICK_ROLES = new Set(["button", "link", "tab", "menuitem", "checkbox", "radio", "cell", "gridcell", "option"]);
|
|
10
|
+
const CHECK_ROLES = new Set(["checkbox", "radio"]);
|
|
10
11
|
const TEXT_ROLES = new Set(["textbox", "searchbox", "combobox"]);
|
|
11
12
|
const HOVER_ROLES = new Set(["button", "link", "tab", "menuitem", "row", "gridcell", "cell", "region", "group"]);
|
|
12
13
|
const DROPZONE_ROLES = new Set(["button", "region", "group"]);
|
|
@@ -16,7 +17,7 @@ const CONTAINER_ROLES = new Set([
|
|
|
16
17
|
"region", "row", "rowgroup", "table", "tablist", "tabpanel", "tree", "treegrid", "treeitem",
|
|
17
18
|
]);
|
|
18
19
|
const TARGET_SELECTION_ACTIONS = new Set([
|
|
19
|
-
"click", "type", "select_option", "hover", "press", "assert_text", "assert_value", "wait_for_value",
|
|
20
|
+
"click", "check", "uncheck", "type", "select_option", "hover", "press", "assert_text", "assert_value", "wait_for_value",
|
|
20
21
|
"wait_for_enabled", "assert_enabled", "assert_visible", "assert_hidden", "upload_file", "extract",
|
|
21
22
|
]);
|
|
22
23
|
const LOCATOR_FIELDS = ["label", "placeholder", "title", "text", "test_id", "selector"];
|
|
@@ -39,11 +40,66 @@ const MIME_TYPES = new Map([
|
|
|
39
40
|
]);
|
|
40
41
|
const FLOW_OPTION_KEYS = new Set([
|
|
41
42
|
"block_trackers", "capture_console_errors", "capture_network_error_bodies", "capture_network_errors",
|
|
42
|
-
"
|
|
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",
|
|
43
45
|
"report_path",
|
|
44
|
-
"continue_from_current_page", "reuse_page", "screenshot_on_failure", "snapshot_include_hidden", "snapshot_scope", "stop_on_expected",
|
|
46
|
+
"continue_from_current_page", "reuse_page", "return_snapshot", "screenshot_on_failure", "snapshot_include_hidden", "snapshot_scope", "step_timeout_seconds", "stop_on_expected",
|
|
45
47
|
"trace_on_failure",
|
|
46
48
|
]);
|
|
49
|
+
const MUTATING_LABEL = /\b(?:save|submit|issue|emit|cancel|delete|remove|void|confirm|approve|pay|send|generate|create|update|finalize|yes|okay|salvar|gravar|emitir|cancelar|excluir|apagar|remover|estornar|confirmar|aprovar|pagar|enviar|gerar|criar|atualizar|finalizar|sim)\b/i;
|
|
50
|
+
|
|
51
|
+
export function createMutationAuthorizationStore() {
|
|
52
|
+
const tokens = new Map();
|
|
53
|
+
const prune = () => {
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
for (const [token, record] of tokens) if (record.expiresAt <= now) tokens.delete(token);
|
|
56
|
+
while (tokens.size > 1024) tokens.delete(tokens.keys().next().value);
|
|
57
|
+
};
|
|
58
|
+
return Object.freeze({
|
|
59
|
+
issue(record, ttlMs) {
|
|
60
|
+
prune();
|
|
61
|
+
const token = randomBytes(32).toString("base64url");
|
|
62
|
+
tokens.set(token, { ...record, expiresAt: Date.now() + ttlMs });
|
|
63
|
+
return token;
|
|
64
|
+
},
|
|
65
|
+
consume(token, fingerprint) {
|
|
66
|
+
prune();
|
|
67
|
+
const record = tokens.get(token);
|
|
68
|
+
if (!record) throw new JevBrowserError("confirmation_token is invalid, expired, or already used; run dry_run again");
|
|
69
|
+
tokens.delete(token);
|
|
70
|
+
if (record.fingerprint !== fingerprint) {
|
|
71
|
+
throw new JevBrowserError("confirmation_token does not match this flow; run dry_run again");
|
|
72
|
+
}
|
|
73
|
+
return record;
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const defaultMutationAuthorizationStore = createMutationAuthorizationStore();
|
|
79
|
+
|
|
80
|
+
function canonicalValue(value) {
|
|
81
|
+
if (Array.isArray(value)) return value.map(canonicalValue);
|
|
82
|
+
if (!isRecord(value)) return value;
|
|
83
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalValue(value[key])]));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function flowFingerprint({ flow, initialUrl, expectedOutcome, candidatePlans, params, options }) {
|
|
87
|
+
const stableOptions = { ...options };
|
|
88
|
+
delete stableOptions.dry_run;
|
|
89
|
+
delete stableOptions.confirmation_token;
|
|
90
|
+
delete stableOptions.dryRun;
|
|
91
|
+
delete stableOptions.confirmationToken;
|
|
92
|
+
return createHash("sha256").update(JSON.stringify(canonicalValue({
|
|
93
|
+
flow, initialUrl, expectedOutcome, candidatePlans, params, options: stableOptions,
|
|
94
|
+
}))).digest("hex");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function looksMutating(rawStep) {
|
|
98
|
+
if (["confirm_dialog", "like_comment", "unlike_comment", "upload_file"].includes(rawStep.action)) return true;
|
|
99
|
+
if (rawStep.action !== "click") return false;
|
|
100
|
+
return [rawStep.name, rawStep.title, rawStep.text, rawStep.target_text, rawStep.label]
|
|
101
|
+
.some((value) => typeof value === "string" && MUTATING_LABEL.test(value));
|
|
102
|
+
}
|
|
47
103
|
|
|
48
104
|
function isRecord(value) {
|
|
49
105
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
@@ -109,13 +165,12 @@ function interpolateStep(value, variables) {
|
|
|
109
165
|
return value;
|
|
110
166
|
}
|
|
111
167
|
|
|
112
|
-
export function validateCandidatePlans(candidatePlans, settings) {
|
|
168
|
+
export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps = settings.browser.maxFlowSteps) {
|
|
113
169
|
if (!isRecord(candidatePlans)) throw new JevBrowserError("candidate_plans must be an object");
|
|
114
170
|
if (Object.keys(candidatePlans).length > settings.jev.maxActionCount) {
|
|
115
171
|
throw new JevBrowserError("candidate_plans exceeds the configured candidate limit");
|
|
116
172
|
}
|
|
117
173
|
const plans = {};
|
|
118
|
-
let totalSteps = 0;
|
|
119
174
|
for (const [rawId, rawPlan] of Object.entries(candidatePlans)) {
|
|
120
175
|
const id = rawId.trim();
|
|
121
176
|
if (!PLAN_ID.test(id) || id === COMPLETE_ACTION_ID) throw new JevBrowserError("each plan needs a valid, non-reserved plan ID");
|
|
@@ -126,8 +181,7 @@ export function validateCandidatePlans(candidatePlans, settings) {
|
|
|
126
181
|
if (!Array.isArray(rawPlan.steps) || rawPlan.steps.length === 0) {
|
|
127
182
|
throw new JevBrowserError("each candidate plan needs at least one browser step");
|
|
128
183
|
}
|
|
129
|
-
|
|
130
|
-
if (totalSteps > settings.browser.maxFlowSteps) throw new JevBrowserError("candidate plans exceed the configured total browser-step limit");
|
|
184
|
+
if (rawPlan.steps.length > maxFlowSteps) throw new JevBrowserError(`candidate plan ${id} exceeds the per-plan browser-step limit of ${maxFlowSteps}`);
|
|
131
185
|
|
|
132
186
|
const steps = rawPlan.steps.map((unprocessedStep) => {
|
|
133
187
|
const rawStep = normalizeStepAliases(unprocessedStep);
|
|
@@ -138,9 +192,12 @@ export function validateCandidatePlans(candidatePlans, settings) {
|
|
|
138
192
|
const locatorFields = TARGET_LOCATOR_FIELDS.filter((field) => field !== "text"
|
|
139
193
|
|| !["type", "wait_for_text", "wait_for_condition", "upload_file"].includes(action));
|
|
140
194
|
const schemas = {
|
|
141
|
-
click: { required: [], optional: [...semanticTarget, "expect_download", "timeout_seconds"] },
|
|
195
|
+
click: { required: [], optional: [...semanticTarget, "expect_download", "expect_popup", "timeout_seconds"] },
|
|
142
196
|
type: { required: ["text"], optional: [...semanticTarget, "blur", "mode", "sensitive", "timeout_seconds"] },
|
|
143
197
|
confirm_dialog: { required: ["expected_text", "button"], optional: ["timeout_seconds"] },
|
|
198
|
+
check: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
|
|
199
|
+
uncheck: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
|
|
200
|
+
navigate_menu: { required: ["path"], optional: ["toggle_selector", "timeout_seconds"] },
|
|
144
201
|
assert_network: { required: ["url_contains", "method", "status"], optional: ["message_contains", "timeout_seconds"] },
|
|
145
202
|
extract: { required: ["as"], optional: [...semanticTarget, "source", "attribute", "sensitive", "timeout_seconds"] },
|
|
146
203
|
wait: { required: ["ms"], optional: [] },
|
|
@@ -149,7 +206,7 @@ export function validateCandidatePlans(candidatePlans, settings) {
|
|
|
149
206
|
wait_for_value: { required: ["value"], optional: [...semanticTarget, "timeout_seconds"] },
|
|
150
207
|
wait_for_enabled: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
|
|
151
208
|
press: { required: ["key"], optional: [...semanticTarget, "timeout_seconds"] },
|
|
152
|
-
select_option: { required: [
|
|
209
|
+
select_option: { required: [], optional: [...semanticTarget, "option", "value", "label_contains", "timeout_seconds"] },
|
|
153
210
|
hover: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
|
|
154
211
|
assert_text: { required: ["expected"], optional: [...semanticTarget, "timeout_seconds"] },
|
|
155
212
|
assert_value: { required: ["expected"], optional: [...semanticTarget, "timeout_seconds"] },
|
|
@@ -171,6 +228,11 @@ export function validateCandidatePlans(candidatePlans, settings) {
|
|
|
171
228
|
}
|
|
172
229
|
if (rawStep[field] !== undefined) step[field] = rawStep[field];
|
|
173
230
|
}
|
|
231
|
+
const inferredMutating = looksMutating(rawStep);
|
|
232
|
+
if (inferredMutating && rawStep.mutating === false) {
|
|
233
|
+
throw new JevBrowserError(`${action} cannot override the default mutating classification for this action`);
|
|
234
|
+
}
|
|
235
|
+
if (inferredMutating) step.mutating = true;
|
|
174
236
|
const selectorFields = locatorFields.filter((field) => rawStep[field] !== undefined);
|
|
175
237
|
const hasNear = rawStep.near !== undefined;
|
|
176
238
|
if (selectorFields.length + Number(hasNear) > 1) {
|
|
@@ -183,7 +245,8 @@ export function validateCandidatePlans(candidatePlans, settings) {
|
|
|
183
245
|
}
|
|
184
246
|
if (rawStep.role !== undefined) {
|
|
185
247
|
const allowedRoles = action === "click" ? CLICK_ROLES
|
|
186
|
-
: action === "
|
|
248
|
+
: action === "check" || action === "uncheck" ? CHECK_ROLES
|
|
249
|
+
: action === "type" || action === "select_option" || action === "assert_value" || action === "wait_for_value" ? TEXT_ROLES
|
|
187
250
|
: action === "hover" ? HOVER_ROLES
|
|
188
251
|
: action === "upload_file" ? (rawStep.target === "button" ? new Set(["button"]) : DROPZONE_ROLES)
|
|
189
252
|
: ASSERT_ROLES;
|
|
@@ -236,22 +299,27 @@ export function validateCandidatePlans(candidatePlans, settings) {
|
|
|
236
299
|
if ((!TARGET_SELECTION_ACTIONS.has(action) && !hiddenWait) || !isRecord(rawStep.within)) {
|
|
237
300
|
throw new JevBrowserError("within must be a semantic container on a target-based browser action");
|
|
238
301
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
302
|
+
const rowKeys = ["row_containing", "row_containing_exact", "row_containing_word"]
|
|
303
|
+
.filter((key) => rawStep.within[key] !== undefined);
|
|
304
|
+
if (rowKeys.length > 1) throw new JevBrowserError("within accepts only one row_containing match mode");
|
|
305
|
+
exactKeys(rawStep.within, [...(rawStep.within.role !== undefined ? ["role"] : []), ...rowKeys], ["name"], "within");
|
|
306
|
+
if (rawStep.within.role !== undefined && (typeof rawStep.within.role !== "string" || !CONTAINER_ROLES.has(rawStep.within.role))) {
|
|
307
|
+
throw new JevBrowserError(`within role must be one of: ${[...CONTAINER_ROLES].join(", ")}`);
|
|
308
|
+
}
|
|
309
|
+
if (rawStep.within.name !== undefined && rawStep.within.role === undefined) {
|
|
310
|
+
throw new JevBrowserError("within.name requires within.role");
|
|
311
|
+
}
|
|
312
|
+
if (!rawStep.within.role && rowKeys.length === 0) {
|
|
313
|
+
throw new JevBrowserError("within needs a semantic container role or row_containing text");
|
|
314
|
+
}
|
|
315
|
+
step.within = {};
|
|
316
|
+
if (rawStep.within.role !== undefined) step.within.role = rawStep.within.role;
|
|
317
|
+
if (rawStep.within.name !== undefined) step.within.name = requiredText(rawStep.within.name, "within accessible name", settings.jev.maxActionDescriptionChars);
|
|
318
|
+
if (rowKeys.length) {
|
|
319
|
+
const key = rowKeys[0];
|
|
320
|
+
const normalizedKey = key === "row_containing_exact" ? "rowContainingExact"
|
|
321
|
+
: key === "row_containing_word" ? "rowContainingWord" : "rowContaining";
|
|
322
|
+
step.within[normalizedKey] = requiredText(rawStep.within[key], `within ${key}`, settings.jev.maxActionDescriptionChars);
|
|
255
323
|
}
|
|
256
324
|
}
|
|
257
325
|
if (rawStep.index !== undefined) {
|
|
@@ -266,10 +334,34 @@ export function validateCandidatePlans(candidatePlans, settings) {
|
|
|
266
334
|
if (typeof rawStep.expect_download !== "boolean") throw new JevBrowserError("click expect_download must be a boolean");
|
|
267
335
|
step.expectDownload = rawStep.expect_download;
|
|
268
336
|
}
|
|
337
|
+
if (action === "click" && rawStep.expect_popup !== undefined) {
|
|
338
|
+
if (typeof rawStep.expect_popup !== "boolean") throw new JevBrowserError("click expect_popup must be a boolean");
|
|
339
|
+
step.expectPopup = rawStep.expect_popup;
|
|
340
|
+
}
|
|
341
|
+
if (action === "click" && step.expectDownload && step.expectPopup) {
|
|
342
|
+
throw new JevBrowserError("click cannot expect both a download and a popup");
|
|
343
|
+
}
|
|
269
344
|
if (action === "select_option") {
|
|
345
|
+
const choices = ["option", "value", "label_contains"].filter((key) => rawStep[key] !== undefined);
|
|
346
|
+
if (choices.length !== 1) throw new JevBrowserError("select_option needs exactly one of option, value, or label_contains");
|
|
270
347
|
if (step.role && step.role !== "combobox") throw new JevBrowserError("select_option role must be combobox");
|
|
271
348
|
step.role = "combobox";
|
|
272
|
-
step.option = requiredText(rawStep
|
|
349
|
+
step.option = requiredText(rawStep[choices[0]], choices[0], settings.jev.maxActionDescriptionChars);
|
|
350
|
+
step.optionMatch = choices[0];
|
|
351
|
+
}
|
|
352
|
+
if (action === "check" || action === "uncheck") {
|
|
353
|
+
if (step.role && !CHECK_ROLES.has(step.role)) throw new JevBrowserError(`${action} role must be checkbox or radio`);
|
|
354
|
+
if (!step.role) step.role = "checkbox";
|
|
355
|
+
if (action === "uncheck" && step.role === "radio") throw new JevBrowserError("uncheck does not support radio controls");
|
|
356
|
+
}
|
|
357
|
+
if (action === "navigate_menu") {
|
|
358
|
+
if (!Array.isArray(rawStep.path) || rawStep.path.length === 0 || rawStep.path.length > 8) {
|
|
359
|
+
throw new JevBrowserError("navigate_menu path must contain between 1 and 8 menu labels");
|
|
360
|
+
}
|
|
361
|
+
step.path = rawStep.path.map((label) => requiredText(label, "menu path label", settings.jev.maxActionDescriptionChars));
|
|
362
|
+
step.toggleSelector = rawStep.toggle_selector === undefined
|
|
363
|
+
? "#open_btn"
|
|
364
|
+
: requiredText(rawStep.toggle_selector, "toggle_selector", settings.jev.maxActionDescriptionChars);
|
|
273
365
|
}
|
|
274
366
|
if (action === "type") {
|
|
275
367
|
if (typeof rawStep.text !== "string" || rawStep.text.length === 0) throw new JevBrowserError("type step needs non-empty text");
|
|
@@ -473,8 +565,8 @@ function resolveFlowOptions(rawOptions, settings) {
|
|
|
473
565
|
}
|
|
474
566
|
const booleanOptions = [
|
|
475
567
|
"block_trackers", "capture_console_errors", "capture_network_error_bodies", "capture_network_errors",
|
|
476
|
-
"
|
|
477
|
-
"stop_on_expected", "trace_on_failure",
|
|
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", "snapshot_include_hidden", "stop_on_expected", "trace_on_failure",
|
|
478
570
|
];
|
|
479
571
|
for (const key of booleanOptions) {
|
|
480
572
|
if (rawOptions[key] !== undefined && typeof rawOptions[key] !== "boolean") {
|
|
@@ -484,6 +576,29 @@ function resolveFlowOptions(rawOptions, settings) {
|
|
|
484
576
|
const scope = rawOptions.snapshot_scope ?? settings.browser.defaults.snapshotScope;
|
|
485
577
|
if (!new Set(["body", "main", "dialog"]).has(scope)) throw new JevBrowserError("options.snapshot_scope must be body, main, or dialog");
|
|
486
578
|
const defaults = settings.browser.defaults;
|
|
579
|
+
const stepTimeoutSeconds = rawOptions.step_timeout_seconds ?? settings.browser.actionTimeoutMs / 1000;
|
|
580
|
+
if (typeof stepTimeoutSeconds !== "number" || !Number.isFinite(stepTimeoutSeconds) || stepTimeoutSeconds <= 0
|
|
581
|
+
|| stepTimeoutSeconds > settings.browser.maxStepTimeoutSeconds) {
|
|
582
|
+
throw new JevBrowserError(`options.step_timeout_seconds must be positive and at most ${settings.browser.maxStepTimeoutSeconds}`);
|
|
583
|
+
}
|
|
584
|
+
const maxFlowSteps = rawOptions.max_flow_steps ?? settings.browser.maxFlowSteps;
|
|
585
|
+
if (!Number.isSafeInteger(maxFlowSteps) || maxFlowSteps <= 0 || maxFlowSteps > settings.browser.maxFlowSteps) {
|
|
586
|
+
throw new JevBrowserError(`options.max_flow_steps must be a positive integer no greater than ${settings.browser.maxFlowSteps}`);
|
|
587
|
+
}
|
|
588
|
+
const returnSnapshot = rawOptions.return_snapshot ?? defaults.returnSnapshot;
|
|
589
|
+
if (!new Set(["none", "diff", "full"]).has(returnSnapshot)) throw new JevBrowserError("options.return_snapshot must be none, diff, or full");
|
|
590
|
+
const normalizeListOption = (key, fallback, maxItems = settings.browser.maxBusySelectors) => {
|
|
591
|
+
const raw = rawOptions[key] ?? fallback;
|
|
592
|
+
if (!Array.isArray(raw) || raw.length > maxItems) throw new JevBrowserError(`options.${key} must be an array with at most ${maxItems} entries`);
|
|
593
|
+
return raw.map((value) => requiredText(value, key, settings.jev.maxActionDescriptionChars));
|
|
594
|
+
};
|
|
595
|
+
const busySelectors = normalizeListOption("busy_selectors", settings.browser.busySelectorsDefault);
|
|
596
|
+
const loginUrlContains = normalizeListOption("login_url_contains", settings.browser.loginUrlContainsDefault);
|
|
597
|
+
const loginText = normalizeListOption("login_text", settings.browser.loginTextDefault);
|
|
598
|
+
let confirmationToken;
|
|
599
|
+
if (rawOptions.confirmation_token !== undefined) {
|
|
600
|
+
confirmationToken = requiredText(rawOptions.confirmation_token, "confirmation_token", 128);
|
|
601
|
+
}
|
|
487
602
|
const readyTimeoutSeconds = rawOptions.ready_timeout_seconds ?? defaults.readyTimeoutSeconds;
|
|
488
603
|
if (typeof readyTimeoutSeconds !== "number" || !Number.isFinite(readyTimeoutSeconds) || readyTimeoutSeconds <= 0
|
|
489
604
|
|| readyTimeoutSeconds > settings.browser.maxReadyTimeoutSeconds) {
|
|
@@ -507,21 +622,31 @@ function resolveFlowOptions(rawOptions, settings) {
|
|
|
507
622
|
reportPath = rawOptions.report_path;
|
|
508
623
|
}
|
|
509
624
|
return Object.freeze({
|
|
625
|
+
autoAngularIdle: rawOptions.auto_angular_idle ?? defaults.autoAngularIdle,
|
|
510
626
|
blockTrackers: rawOptions.block_trackers ?? defaults.blockTrackers,
|
|
627
|
+
blockFonts: rawOptions.block_fonts ?? defaults.blockFonts,
|
|
628
|
+
busySelectors: Object.freeze(busySelectors),
|
|
511
629
|
captureConsoleErrors: rawOptions.capture_console_errors ?? defaults.captureConsoleErrors,
|
|
512
630
|
captureNetworkErrorBodies: rawOptions.capture_network_error_bodies ?? defaults.captureNetworkErrorBodies,
|
|
513
631
|
captureNetworkErrors: rawOptions.capture_network_errors ?? defaults.captureNetworkErrors,
|
|
514
632
|
dryRun: rawOptions.dry_run ?? false,
|
|
515
633
|
fastPath: rawOptions.fast_path ?? defaults.fastPath,
|
|
634
|
+
localOnly: rawOptions.local_only ?? defaults.localOnly,
|
|
635
|
+
loginText: Object.freeze(loginText),
|
|
636
|
+
loginUrlContains: Object.freeze(loginUrlContains),
|
|
637
|
+
maxFlowSteps,
|
|
516
638
|
readyTimeoutMs: Math.ceil(readyTimeoutSeconds * 1000),
|
|
517
639
|
readyNetworkIdle: rawOptions.ready_network_idle ?? defaults.readyNetworkIdle,
|
|
518
640
|
readyStableMs,
|
|
519
641
|
readyText,
|
|
520
642
|
reportPath,
|
|
643
|
+
returnSnapshot,
|
|
521
644
|
reusePage: rawOptions.continue_from_current_page ?? rawOptions.reuse_page ?? defaults.reusePage,
|
|
522
645
|
screenshotOnFailure: rawOptions.screenshot_on_failure ?? defaults.screenshotOnFailure,
|
|
523
646
|
snapshotIncludeHidden: rawOptions.snapshot_include_hidden ?? defaults.snapshotIncludeHidden,
|
|
524
647
|
stopOnExpected: rawOptions.stop_on_expected ?? defaults.stopOnExpected,
|
|
648
|
+
stepTimeoutMs: Math.ceil(stepTimeoutSeconds * 1000),
|
|
649
|
+
confirmationToken,
|
|
525
650
|
traceOnFailure: rawOptions.trace_on_failure ?? defaults.traceOnFailure,
|
|
526
651
|
snapshotScope: scope,
|
|
527
652
|
});
|
|
@@ -893,7 +1018,7 @@ function containsExpectedOutcome(snapshot, phrase) {
|
|
|
893
1018
|
return containsPhrase(expectedOutcomeText(snapshot), phrase);
|
|
894
1019
|
}
|
|
895
1020
|
|
|
896
|
-
function createNetworkTracker(page, settings) {
|
|
1021
|
+
function createNetworkTracker(page, settings, loginUrlContains = []) {
|
|
897
1022
|
const active = new Map();
|
|
898
1023
|
const history = [];
|
|
899
1024
|
let sequence = 0;
|
|
@@ -923,8 +1048,10 @@ function createNetworkTracker(page, settings) {
|
|
|
923
1048
|
since: (marker, urlContains) => history.filter((entry) => entry.sequence > marker && entry.url.includes(urlContains)),
|
|
924
1049
|
responsesSince: (marker, urlContains, method) => history.filter((entry) => entry.sequence > marker
|
|
925
1050
|
&& entry.type === "response" && entry.url.includes(urlContains) && entry.method === method),
|
|
926
|
-
authenticationFailureSince: (marker) => history.find((entry) =>
|
|
927
|
-
|
|
1051
|
+
authenticationFailureSince: (marker) => history.find((entry) => {
|
|
1052
|
+
if (entry.sequence <= marker || entry.type !== "response" || ![401, 403].includes(entry.status)) return false;
|
|
1053
|
+
return loginUrlContains.some((needle) => entry.url.toLocaleLowerCase().includes(needle.toLocaleLowerCase()));
|
|
1054
|
+
}),
|
|
928
1055
|
activeFor: (urlContains) => [...active.values()].filter((entry) => entry.url.includes(urlContains)),
|
|
929
1056
|
dispose: () => {
|
|
930
1057
|
page.off("request", onRequest);
|
|
@@ -957,7 +1084,7 @@ async function waitForFilteredNetworkIdle(tracker, urlContains, marker, timeout,
|
|
|
957
1084
|
throw new JevBrowserError(`requests matching url_contains ${JSON.stringify(urlContains)} did not become idle within ${timeout}ms`);
|
|
958
1085
|
}
|
|
959
1086
|
|
|
960
|
-
async function waitForAngularIdle(page, timeout, settings) {
|
|
1087
|
+
async function waitForAngularIdle(page, timeout, settings, optional = false) {
|
|
961
1088
|
const deadline = performance.now() + timeout;
|
|
962
1089
|
let consecutiveIdle = 0;
|
|
963
1090
|
while (performance.now() < deadline) {
|
|
@@ -981,13 +1108,18 @@ async function waitForAngularIdle(page, timeout, settings) {
|
|
|
981
1108
|
};
|
|
982
1109
|
});
|
|
983
1110
|
} catch {
|
|
1111
|
+
if (optional) return false;
|
|
984
1112
|
throw new JevBrowserError("angular_idle could not inspect the page; the AngularJS application may have navigated or closed");
|
|
985
1113
|
}
|
|
986
|
-
if (!state)
|
|
1114
|
+
if (!state) {
|
|
1115
|
+
if (optional) return false;
|
|
1116
|
+
throw new JevBrowserError("angular_idle requires an AngularJS 1.x page with an accessible injector");
|
|
1117
|
+
}
|
|
987
1118
|
consecutiveIdle = state.pending === 0 && !state.digesting && state.outstanding === 0 ? consecutiveIdle + 1 : 0;
|
|
988
1119
|
if (consecutiveIdle >= 2) return;
|
|
989
1120
|
await new Promise((resolve) => setTimeout(resolve, Math.min(50, remainingMs(deadline))));
|
|
990
1121
|
}
|
|
1122
|
+
if (optional) return false;
|
|
991
1123
|
throw new JevBrowserError(`AngularJS still had pending requests or digest work after ${timeout}ms`);
|
|
992
1124
|
}
|
|
993
1125
|
|
|
@@ -1121,7 +1253,7 @@ async function waitForPageReady(page, options, settings, secrets, continued = fa
|
|
|
1121
1253
|
});
|
|
1122
1254
|
lastSnapshot = snapshot;
|
|
1123
1255
|
const readyTextVisible = !options.readyText || containsPhrase(snapshot, options.readyText);
|
|
1124
|
-
const usefulSnapshot = await hasRenderedContent(page, snapshot);
|
|
1256
|
+
const usefulSnapshot = await hasRenderedContent(page, snapshot, options.busySelectors);
|
|
1125
1257
|
if (continued && snapshot && readyTextVisible && usefulSnapshot) {
|
|
1126
1258
|
return { snapshot, readyMs: performance.now() - started };
|
|
1127
1259
|
}
|
|
@@ -1147,7 +1279,7 @@ async function waitForPageReady(page, options, settings, secrets, continued = fa
|
|
|
1147
1279
|
throw error;
|
|
1148
1280
|
}
|
|
1149
1281
|
|
|
1150
|
-
async function hasRenderedContent(page, snapshot) {
|
|
1282
|
+
async function hasRenderedContent(page, snapshot, busySelectors = []) {
|
|
1151
1283
|
const lines = snapshot.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
1152
1284
|
if (!lines.some((line, index) => index > 0 || /:\s+\S|"[^"]+"/.test(line))) return false;
|
|
1153
1285
|
try {
|
|
@@ -1155,9 +1287,65 @@ async function hasRenderedContent(page, snapshot) {
|
|
|
1155
1287
|
} catch {
|
|
1156
1288
|
// The snapshot itself is the fallback when the DOM is transitioning.
|
|
1157
1289
|
}
|
|
1290
|
+
for (const selector of busySelectors) {
|
|
1291
|
+
try {
|
|
1292
|
+
const busy = page.locator(selector);
|
|
1293
|
+
const count = await busy.count();
|
|
1294
|
+
for (let index = 0; index < count; index += 1) {
|
|
1295
|
+
if (await isActuallyVisible(locatorAt(busy, index))) return false;
|
|
1296
|
+
}
|
|
1297
|
+
} catch {
|
|
1298
|
+
// A malformed or transient selector does not replace the accessible snapshot readiness signal.
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1158
1301
|
return true;
|
|
1159
1302
|
}
|
|
1160
1303
|
|
|
1304
|
+
function sensitiveParamSecrets(variables) {
|
|
1305
|
+
const sensitiveName = /(?:cpf|cnpj|e?mail|telefone|celular|cliente|nome|sacado|pagador|cedente|senha|password|token|api[_-]?key|valor|amount|pre[cç]o)/i;
|
|
1306
|
+
return Object.entries(variables).filter(([name]) => sensitiveName.test(name)).map(([, value]) => value);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
async function waitForText(locator, expected, timeout, description, settings) {
|
|
1310
|
+
const normalized = expected.replace(/\s+/g, " ").trim().toLocaleLowerCase();
|
|
1311
|
+
const deadline = performance.now() + timeout;
|
|
1312
|
+
while (performance.now() < deadline) {
|
|
1313
|
+
try {
|
|
1314
|
+
const actual = (await locator.innerText({ timeout: Math.min(250, remainingMs(deadline)) }))
|
|
1315
|
+
.replace(/\s+/g, " ").trim().toLocaleLowerCase();
|
|
1316
|
+
if (actual.includes(normalized)) return;
|
|
1317
|
+
} catch {
|
|
1318
|
+
// SPA rerenders may briefly detach the target.
|
|
1319
|
+
}
|
|
1320
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(settings.browser.visibilityPollMs, remainingMs(deadline))));
|
|
1321
|
+
}
|
|
1322
|
+
throw new JevBrowserError(`${description} did not contain the expected text within ${timeout}ms`);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
async function pageFingerprint(page, snapshot) {
|
|
1326
|
+
let controls = [];
|
|
1327
|
+
let visibleText = "";
|
|
1328
|
+
try {
|
|
1329
|
+
({ controls, visibleText } = await page.evaluate(() => ({
|
|
1330
|
+
visibleText: String(document.body?.innerText || "").slice(0, 30000),
|
|
1331
|
+
controls: [...document.querySelectorAll("input,textarea,select")].slice(0, 1000).map((element) => ({
|
|
1332
|
+
tag: element.localName,
|
|
1333
|
+
type: element.getAttribute("type") || "",
|
|
1334
|
+
name: element.getAttribute("name") || "",
|
|
1335
|
+
id: element.id || "",
|
|
1336
|
+
value: String(element.value ?? "").slice(0, 4000),
|
|
1337
|
+
checked: "checked" in element ? Boolean(element.checked) : undefined,
|
|
1338
|
+
disabled: Boolean(element.disabled),
|
|
1339
|
+
})),
|
|
1340
|
+
})));
|
|
1341
|
+
} catch {
|
|
1342
|
+
// The page may close between a browser event and an authorization check.
|
|
1343
|
+
}
|
|
1344
|
+
return createHash("sha256").update(JSON.stringify({
|
|
1345
|
+
url: typeof page.url === "function" ? page.url() : "unavailable", snapshot, visibleText, controls,
|
|
1346
|
+
})).digest("hex");
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1161
1349
|
async function snapshotAfterStep(page, settings, secrets, scope, previousSnapshot, stepNumber, warnings, includeHidden) {
|
|
1162
1350
|
const snapshot = await snapshotPage(page, settings, secrets, scope, { allowEmpty: true, includeHidden });
|
|
1163
1351
|
if (snapshot) return snapshot;
|
|
@@ -1315,22 +1503,45 @@ async function exactlyOne(locator, description, timeout, settings) {
|
|
|
1315
1503
|
|
|
1316
1504
|
async function scopedRoot(root, within, timeout, settings) {
|
|
1317
1505
|
if (!within) return root;
|
|
1318
|
-
let
|
|
1319
|
-
|
|
1320
|
-
if (within.
|
|
1321
|
-
locator = root.getByRole("row").filter({ hasText: new RegExp(escapeRegex(within.rowContaining), "iu") });
|
|
1322
|
-
description = `row containing ${JSON.stringify(within.rowContaining)}`;
|
|
1323
|
-
} else if (within.rowContainingExact) {
|
|
1324
|
-
locator = root.getByRole("row").filter({ has: root.getByText(within.rowContainingExact, { exact: true }) });
|
|
1325
|
-
description = `row containing exact text ${JSON.stringify(within.rowContainingExact)}`;
|
|
1326
|
-
} else {
|
|
1506
|
+
let scope = root;
|
|
1507
|
+
const descriptions = [];
|
|
1508
|
+
if (within.role) {
|
|
1327
1509
|
const options = within.name === undefined ? {} : { name: within.name, exact: true };
|
|
1328
|
-
|
|
1329
|
-
description = `visible ${within.role} container${within.name === undefined ? "" : ` ${JSON.stringify(within.name)}`}`;
|
|
1510
|
+
const container = root.getByRole(within.role, options);
|
|
1511
|
+
const description = `visible ${within.role} container${within.name === undefined ? "" : ` ${JSON.stringify(within.name)}`}`;
|
|
1512
|
+
const { visible } = await visibleIndexes(container, timeout, `within ${description}`, settings);
|
|
1513
|
+
if (visible.length !== 1) throw new JevBrowserError(`within ${description} must match exactly one visible container; found ${visible.length}`);
|
|
1514
|
+
scope = locatorAt(container, visible[0]);
|
|
1515
|
+
descriptions.push(description);
|
|
1516
|
+
}
|
|
1517
|
+
const rowText = within.rowContainingExact ?? within.rowContainingWord ?? within.rowContaining;
|
|
1518
|
+
if (rowText !== undefined) {
|
|
1519
|
+
const rows = scope.getByRole("row");
|
|
1520
|
+
const mode = within.rowContainingExact !== undefined ? "exact"
|
|
1521
|
+
: within.rowContainingWord !== undefined ? "whole word" : "substring";
|
|
1522
|
+
if (within.rowContainingWord !== undefined) {
|
|
1523
|
+
const pattern = new RegExp(`(?:^|[^\\p{L}\\p{N}])${escapeRegex(rowText)}(?:$|[^\\p{L}\\p{N}])`, "iu");
|
|
1524
|
+
const count = await rows.count();
|
|
1525
|
+
const matches = [];
|
|
1526
|
+
for (let index = 0; index < count; index += 1) {
|
|
1527
|
+
const row = locatorAt(rows, index);
|
|
1528
|
+
if (await isActuallyVisible(row) && pattern.test(await row.innerText())) matches.push(index);
|
|
1529
|
+
}
|
|
1530
|
+
const description = `row containing ${mode} text ${JSON.stringify(rowText)}`;
|
|
1531
|
+
if (matches.length !== 1) throw new JevBrowserError(`within ${description} must match exactly one visible row; found ${matches.length}`);
|
|
1532
|
+
return locatorAt(rows, matches[0]);
|
|
1533
|
+
}
|
|
1534
|
+
const matcher = within.rowContainingExact !== undefined
|
|
1535
|
+
? { has: scope.getByText(rowText, { exact: true }) }
|
|
1536
|
+
: { hasText: new RegExp(escapeRegex(rowText), "iu") };
|
|
1537
|
+
const rowLocator = rows.filter(matcher);
|
|
1538
|
+
const description = `row containing ${mode} text ${JSON.stringify(rowText)}`;
|
|
1539
|
+
const { visible } = await visibleIndexes(rowLocator, timeout, `within ${description}`, settings);
|
|
1540
|
+
if (visible.length !== 1) throw new JevBrowserError(`within ${description} must match exactly one visible row; found ${visible.length}`);
|
|
1541
|
+
scope = locatorAt(rowLocator, visible[0]);
|
|
1542
|
+
descriptions.push(description);
|
|
1330
1543
|
}
|
|
1331
|
-
|
|
1332
|
-
if (visible.length !== 1) throw new JevBrowserError(`within ${description} must match exactly one visible container; found ${visible.length}`);
|
|
1333
|
-
return locatorAt(locator, visible[0]);
|
|
1544
|
+
return scope;
|
|
1334
1545
|
}
|
|
1335
1546
|
|
|
1336
1547
|
function accessibleNamePattern(name) {
|
|
@@ -1429,11 +1640,11 @@ function describeTarget(step, secrets) {
|
|
|
1429
1640
|
}
|
|
1430
1641
|
if (step.near) parts.push(`near text ${JSON.stringify(redact(step.near.text, secrets))}`);
|
|
1431
1642
|
if (step.within) {
|
|
1432
|
-
parts.push(step.within.
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1643
|
+
if (step.within.role) parts.push(`within ${step.within.role}${step.within.name === undefined ? "" : ` ${JSON.stringify(redact(step.within.name, secrets))}`}`);
|
|
1644
|
+
const rowPrefix = step.within.role ? "" : "within ";
|
|
1645
|
+
if (step.within.rowContainingExact !== undefined) parts.push(`${rowPrefix}row_containing_exact ${JSON.stringify(redact(step.within.rowContainingExact, secrets))}`);
|
|
1646
|
+
else if (step.within.rowContainingWord !== undefined) parts.push(`${rowPrefix}row_containing_word ${JSON.stringify(redact(step.within.rowContainingWord, secrets))}`);
|
|
1647
|
+
else if (step.within.rowContaining !== undefined) parts.push(`${rowPrefix}row_containing ${JSON.stringify(redact(step.within.rowContaining, secrets))}`);
|
|
1437
1648
|
}
|
|
1438
1649
|
if (step.index !== undefined) parts.push(`index ${step.index}`);
|
|
1439
1650
|
return parts.length ? parts.join(" + ") : "focused element";
|
|
@@ -1515,8 +1726,8 @@ async function namedTarget(root, step, snapshot, secrets, timeout, settings) {
|
|
|
1515
1726
|
if (step.fragileName && step.action === "click") {
|
|
1516
1727
|
const title = await selected.evaluate((element) => [element, ...element.querySelectorAll("[title]")]
|
|
1517
1728
|
.map((candidate) => candidate.getAttribute("title") || "").find(Boolean) || "").catch(() => "");
|
|
1518
|
-
if (
|
|
1519
|
-
throw new JevBrowserError(`refused index-only click on a
|
|
1729
|
+
if (MUTATING_LABEL.test(title)) {
|
|
1730
|
+
throw new JevBrowserError(`refused index-only click on a mutating control titled ${JSON.stringify(redact(title, secrets))}; provide a stable locator`);
|
|
1520
1731
|
}
|
|
1521
1732
|
}
|
|
1522
1733
|
return selected;
|
|
@@ -1716,6 +1927,45 @@ async function saveDownload(download, settings, ledger) {
|
|
|
1716
1927
|
}
|
|
1717
1928
|
}
|
|
1718
1929
|
|
|
1930
|
+
async function visibleMenuItem(root, label, timeout, settings) {
|
|
1931
|
+
for (const role of ["menuitem", "link", "button"]) {
|
|
1932
|
+
const locator = root.getByRole(role, { name: label, exact: true });
|
|
1933
|
+
try {
|
|
1934
|
+
const { visible } = await visibleIndexes(locator, Math.min(timeout, 250), `menu ${role} ${JSON.stringify(label)}`, settings);
|
|
1935
|
+
if (visible.length > 0) {
|
|
1936
|
+
// Nested application menus often reuse the same label for a parent and its newly opened leaf.
|
|
1937
|
+
return locatorAt(locator, visible.at(-1));
|
|
1938
|
+
}
|
|
1939
|
+
} catch {
|
|
1940
|
+
// Try the next semantic role before falling back to exact visible text.
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
const textLocator = root.getByText(label, { exact: true });
|
|
1944
|
+
const { visible } = await visibleIndexes(textLocator, Math.min(timeout, 250), `menu text ${JSON.stringify(label)}`, settings);
|
|
1945
|
+
if (visible.length === 0) throw new JevBrowserError(`menu item ${JSON.stringify(label)} must match a visible control; found 0`);
|
|
1946
|
+
return locatorAt(textLocator, visible.at(-1));
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
async function navigateMenu(page, root, step, settings, execution) {
|
|
1950
|
+
let item;
|
|
1951
|
+
try {
|
|
1952
|
+
item = await visibleMenuItem(root, step.path[0], 250, settings);
|
|
1953
|
+
} catch {
|
|
1954
|
+
const toggle = page.locator(step.toggleSelector);
|
|
1955
|
+
const { visible } = await visibleIndexes(toggle, 250, `menu toggle ${step.toggleSelector}`, settings);
|
|
1956
|
+
if (visible.length !== 1) throw new JevBrowserError(`menu toggle ${step.toggleSelector} must match exactly one visible control; found ${visible.length}`);
|
|
1957
|
+
await locatorAt(toggle, visible[0]).click({ timeout: step.timeoutMs ?? settings.browser.actionTimeoutMs });
|
|
1958
|
+
if (execution.autoAngularIdle) await waitForAngularIdle(page, step.timeoutMs ?? settings.browser.actionTimeoutMs, settings, true);
|
|
1959
|
+
item = await visibleMenuItem(root, step.path[0], step.timeoutMs ?? settings.browser.actionTimeoutMs, settings);
|
|
1960
|
+
}
|
|
1961
|
+
for (let index = 0; index < step.path.length; index += 1) {
|
|
1962
|
+
if (index > 0) item = await visibleMenuItem(root, step.path[index], step.timeoutMs ?? settings.browser.actionTimeoutMs, settings);
|
|
1963
|
+
await item.click({ timeout: step.timeoutMs ?? settings.browser.actionTimeoutMs });
|
|
1964
|
+
if (execution.autoAngularIdle) await waitForAngularIdle(page, step.timeoutMs ?? settings.browser.actionTimeoutMs, settings, true);
|
|
1965
|
+
}
|
|
1966
|
+
return { action: step.action, navigated_menu: step.path };
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1719
1969
|
async function executeStep(page, step, settings, snapshot, secrets, downloadLedger, execution = {}) {
|
|
1720
1970
|
const root = await stepRoot(page, step.frame);
|
|
1721
1971
|
const timeout = step.timeoutMs ?? settings.browser.actionTimeoutMs;
|
|
@@ -1732,6 +1982,19 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
1732
1982
|
return { action: step.action, asserted: true, url: evidenceUrl(response.url, secrets, settings.jev.maxDiagnosticChars), method: response.method, response_status: response.status, ...(response.message ? { message: response.message } : {}) };
|
|
1733
1983
|
}
|
|
1734
1984
|
if (step.action === "confirm_dialog") {
|
|
1985
|
+
const native = execution.nativeDialog?.result;
|
|
1986
|
+
if (native) {
|
|
1987
|
+
const expectedText = step.expectedText.replace(/\s+/g, " ").trim();
|
|
1988
|
+
if (!native.matched || native.handlerError || !native.message.toLocaleLowerCase().includes(expectedText.toLocaleLowerCase())) {
|
|
1989
|
+
throw new JevBrowserError(`confirm_dialog refused to act: native dialog text did not match ${JSON.stringify(redact(expectedText, secrets))}`);
|
|
1990
|
+
}
|
|
1991
|
+
return {
|
|
1992
|
+
action: step.action,
|
|
1993
|
+
confirmed_text: redact(expectedText, secrets),
|
|
1994
|
+
native_dialog: true,
|
|
1995
|
+
button: redact(step.button, secrets),
|
|
1996
|
+
};
|
|
1997
|
+
}
|
|
1735
1998
|
const dialog = await exactlyOne(root.getByRole("dialog"), "confirmation dialog", timeout, settings);
|
|
1736
1999
|
const actualText = (await dialog.innerText({ timeout })).replace(/\s+/g, " ").trim();
|
|
1737
2000
|
const expectedText = step.expectedText.replace(/\s+/g, " ").trim();
|
|
@@ -1881,11 +2144,48 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
1881
2144
|
await waitForEnabled(control, timeout, describeTarget(step, secrets), settings);
|
|
1882
2145
|
return { action: step.action, enabled: true, ...targetEvidence(step, secrets, await describeResolvedTarget(control, step, secrets, settings)) };
|
|
1883
2146
|
}
|
|
1884
|
-
|
|
2147
|
+
if (step.action === "navigate_menu") return navigateMenu(page, root, step, settings, execution);
|
|
2148
|
+
const control = execution.preResolvedTarget || await namedTarget(root, step, snapshot, secrets, timeout, settings);
|
|
2149
|
+
if (step.action === "check" || step.action === "uncheck") {
|
|
2150
|
+
const resolvedTarget = await describeResolvedTarget(control, step, secrets, settings);
|
|
2151
|
+
if (step.action === "check") await control.check({ timeout });
|
|
2152
|
+
else await control.uncheck({ timeout });
|
|
2153
|
+
return { action: step.action, checked: step.action === "check", ...targetEvidence(step, secrets, resolvedTarget) };
|
|
2154
|
+
}
|
|
1885
2155
|
if (step.action === "click") {
|
|
1886
2156
|
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
|
+
control.click({ timeout }),
|
|
2163
|
+
]);
|
|
2164
|
+
} catch {
|
|
2165
|
+
throw new JevBrowserError("click did not open the expected popup before the configured timeout");
|
|
2166
|
+
}
|
|
2167
|
+
try {
|
|
2168
|
+
await popup.waitForLoadState("domcontentloaded", { timeout: settings.browser.downloadTimeoutMs }).catch(() => {});
|
|
2169
|
+
const popupUrl = popup.url();
|
|
2170
|
+
const popupTitle = await popup.title().catch(() => "");
|
|
2171
|
+
const isPdf = /\.pdf(?:$|[?#])/i.test(popupUrl)
|
|
2172
|
+
|| await popup.locator('embed[type="application/pdf"], iframe[src*=".pdf" i]').count().then((count) => count > 0).catch(() => false);
|
|
2173
|
+
return {
|
|
2174
|
+
action: step.action,
|
|
2175
|
+
...targetEvidence(step, secrets, resolvedTarget),
|
|
2176
|
+
popup_url: evidenceUrl(popupUrl, secrets, settings.jev.maxDiagnosticChars),
|
|
2177
|
+
...(popupTitle ? { popup_title: redact(popupTitle, secrets).slice(0, settings.jev.maxDiagnosticChars) } : {}),
|
|
2178
|
+
popup_document_type: isPdf ? "pdf" : "page",
|
|
2179
|
+
};
|
|
2180
|
+
} finally {
|
|
2181
|
+
await popup.close().catch(() => {});
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
1887
2184
|
if (!step.expectDownload) {
|
|
1888
2185
|
await control.click({ timeout });
|
|
2186
|
+
if (execution.nativeDialog?.result && !execution.nativeDialog.result.matched) {
|
|
2187
|
+
throw new JevBrowserError(`unexpected native ${execution.nativeDialog.result.type} dialog was dismissed: ${JSON.stringify(sanitizeDiagnosticText(execution.nativeDialog.result.message, secrets, settings.jev.maxDiagnosticChars))}`);
|
|
2188
|
+
}
|
|
1889
2189
|
return { action: step.action, ...targetEvidence(step, secrets, resolvedTarget) };
|
|
1890
2190
|
}
|
|
1891
2191
|
let download;
|
|
@@ -1931,7 +2231,17 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
1931
2231
|
}
|
|
1932
2232
|
if (step.action === "select_option") {
|
|
1933
2233
|
const resolvedTarget = await describeResolvedTarget(control, step, secrets, settings);
|
|
1934
|
-
|
|
2234
|
+
if (step.optionMatch === "value") {
|
|
2235
|
+
await control.selectOption(step.option, { timeout });
|
|
2236
|
+
} else if (step.optionMatch === "label_contains") {
|
|
2237
|
+
const matches = await control.locator("option").evaluateAll((options, text) => options
|
|
2238
|
+
.filter((option) => String(option.label || option.textContent || "").toLocaleLowerCase().includes(text.toLocaleLowerCase()))
|
|
2239
|
+
.map((option) => ({ label: String(option.label || option.textContent || "").trim(), value: String(option.value) })), step.option);
|
|
2240
|
+
if (matches.length !== 1) throw new JevBrowserError(`select_option label_contains must match one option; found ${matches.length}`);
|
|
2241
|
+
await control.selectOption(matches[0].value, { timeout });
|
|
2242
|
+
} else {
|
|
2243
|
+
await control.selectOption({ label: step.option }, { timeout });
|
|
2244
|
+
}
|
|
1935
2245
|
return { action: step.action, ...targetEvidence(step, secrets, resolvedTarget) };
|
|
1936
2246
|
}
|
|
1937
2247
|
if (step.action === "hover") {
|
|
@@ -1948,11 +2258,7 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
1948
2258
|
};
|
|
1949
2259
|
}
|
|
1950
2260
|
if (step.action === "assert_text") {
|
|
1951
|
-
|
|
1952
|
-
const expected = step.expected.replace(/\s+/g, " ").trim();
|
|
1953
|
-
if (!actual.toLocaleLowerCase().includes(expected.toLocaleLowerCase())) {
|
|
1954
|
-
throw new JevBrowserError(`assert_text failed for ${describeTarget(step, secrets)}`);
|
|
1955
|
-
}
|
|
2261
|
+
await waitForText(control, step.expected, timeout, `assert_text for ${describeTarget(step, secrets)}`, settings);
|
|
1956
2262
|
return {
|
|
1957
2263
|
action: step.action,
|
|
1958
2264
|
asserted: true,
|
|
@@ -1960,8 +2266,7 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
1960
2266
|
};
|
|
1961
2267
|
}
|
|
1962
2268
|
if (step.action === "assert_value") {
|
|
1963
|
-
|
|
1964
|
-
if (actual !== step.expected) throw new JevBrowserError(`assert_value failed for ${describeTarget(step, secrets)}`);
|
|
2269
|
+
await waitForValue(control, step.expected, timeout, `assert_value for ${describeTarget(step, secrets)}`, settings);
|
|
1965
2270
|
return {
|
|
1966
2271
|
action: step.action,
|
|
1967
2272
|
asserted: true,
|
|
@@ -1975,6 +2280,24 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
1975
2280
|
throw new JevBrowserError("unsupported browser action");
|
|
1976
2281
|
}
|
|
1977
2282
|
|
|
2283
|
+
async function inferTargetMutation(page, step, snapshot, settings, secrets) {
|
|
2284
|
+
if (step.action !== "click") return { mutating: step.mutating === true };
|
|
2285
|
+
const root = await stepRoot(page, step.frame);
|
|
2286
|
+
const target = await namedTarget(root, step, snapshot, secrets, step.timeoutMs ?? settings.browser.actionTimeoutMs, settings);
|
|
2287
|
+
if (step.mutating === true) return { mutating: true, target };
|
|
2288
|
+
if (typeof target?.evaluate !== "function") return { mutating: false, target };
|
|
2289
|
+
const label = await target.evaluate((element) => [
|
|
2290
|
+
element.getAttribute("aria-label"),
|
|
2291
|
+
element.getAttribute("title"),
|
|
2292
|
+
element.getAttribute("value"),
|
|
2293
|
+
element.innerText,
|
|
2294
|
+
element.textContent,
|
|
2295
|
+
...[...element.querySelectorAll("[title]")].map((child) => child.getAttribute("title")),
|
|
2296
|
+
].filter(Boolean).join(" "));
|
|
2297
|
+
if (MUTATING_LABEL.test(label)) step.mutating = true;
|
|
2298
|
+
return { mutating: step.mutating === true, target };
|
|
2299
|
+
}
|
|
2300
|
+
|
|
1978
2301
|
function flowTimings(started, browserSessionMs, navigationMs, initialSnapshotMs, readyMs, jevDecisionMs, browserPlanMs) {
|
|
1979
2302
|
return {
|
|
1980
2303
|
browser_session_ms: Math.round(browserSessionMs * 10) / 10,
|
|
@@ -2147,11 +2470,14 @@ function trackerUrl(url, settings) {
|
|
|
2147
2470
|
return settings.browser.trackerHostSuffixes.some((suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`));
|
|
2148
2471
|
}
|
|
2149
2472
|
|
|
2150
|
-
async function installTrackerBlocking(context, settings, enabled) {
|
|
2151
|
-
if (!enabled) return undefined;
|
|
2473
|
+
async function installTrackerBlocking(context, settings, enabled, blockFonts = false) {
|
|
2474
|
+
if (!enabled && !blockFonts) return undefined;
|
|
2152
2475
|
const handler = async (route) => {
|
|
2153
2476
|
const request = route.request();
|
|
2154
|
-
|
|
2477
|
+
const resourceType = request.resourceType();
|
|
2478
|
+
if ((enabled && trackerUrl(request.url(), settings))
|
|
2479
|
+
|| (enabled && settings.browser.blockedResourceTypes.includes(resourceType) && (resourceType !== "font" || blockFonts))
|
|
2480
|
+
|| (blockFonts && resourceType === "font")) {
|
|
2155
2481
|
await route.abort("blockedbyclient");
|
|
2156
2482
|
return;
|
|
2157
2483
|
}
|
|
@@ -2261,18 +2587,37 @@ function hasAssertions(steps) {
|
|
|
2261
2587
|
return steps.some(({ action }) => action.startsWith("assert_"));
|
|
2262
2588
|
}
|
|
2263
2589
|
|
|
2264
|
-
|
|
2590
|
+
function snapshotDiff(initialSnapshot, finalSnapshot) {
|
|
2591
|
+
const remaining = new Map();
|
|
2592
|
+
for (const line of initialSnapshot.split(/\r?\n/).filter(Boolean)) remaining.set(line, (remaining.get(line) || 0) + 1);
|
|
2593
|
+
const added = [];
|
|
2594
|
+
for (const line of finalSnapshot.split(/\r?\n/).filter(Boolean)) {
|
|
2595
|
+
const count = remaining.get(line) || 0;
|
|
2596
|
+
if (count > 0) remaining.set(line, count - 1);
|
|
2597
|
+
else added.push(line);
|
|
2598
|
+
}
|
|
2599
|
+
return added.length ? added.join("\n") : "[no new snapshot lines]";
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, candidatePlans, params: rawParams, options: rawOptions, settings, browserPool, fetchImpl = fetch, serverVersion, mutationAuthorizationStore = defaultMutationAuthorizationStore }) {
|
|
2265
2603
|
if (typeof flow !== "string" || typeof expectedOutcome !== "string") throw new JevBrowserError("flow and expected_outcome must be text");
|
|
2266
|
-
|
|
2604
|
+
let variables = validateParams(rawParams, settings);
|
|
2267
2605
|
const requestedFlow = replaceKnownParams(flow, variables).trim();
|
|
2268
2606
|
const outcome = replaceKnownParams(expectedOutcome, variables).trim();
|
|
2269
2607
|
if (!requestedFlow || !outcome) throw new JevBrowserError("flow and expected_outcome must not be empty");
|
|
2270
2608
|
if (requestedFlow.length > settings.jev.maxFlowChars) throw new JevBrowserError("flow exceeds the configured character limit");
|
|
2271
2609
|
if (outcome.length > settings.jev.maxActionDescriptionChars) throw new JevBrowserError("expected_outcome exceeds the configured character limit");
|
|
2272
2610
|
const url = validateUrl(initialUrl);
|
|
2273
|
-
const plans = validateCandidatePlans(replaceKnownParams(candidatePlans, variables), settings);
|
|
2274
2611
|
const options = resolveFlowOptions(rawOptions, settings);
|
|
2275
|
-
const
|
|
2612
|
+
const plans = validateCandidatePlans(replaceKnownParams(candidatePlans, variables), settings, options.maxFlowSteps);
|
|
2613
|
+
if (options.localOnly && Object.keys(plans).length > 0 && !(options.fastPath && Object.keys(plans).length === 1)) {
|
|
2614
|
+
throw new JevBrowserError("local_only does not call Jev; provide exactly one candidate plan and enable fast_path");
|
|
2615
|
+
}
|
|
2616
|
+
const fingerprint = flowFingerprint({ flow, initialUrl: url, expectedOutcome, candidatePlans, params: rawParams, options });
|
|
2617
|
+
const continuation = options.confirmationToken
|
|
2618
|
+
? mutationAuthorizationStore.consume(options.confirmationToken, fingerprint)
|
|
2619
|
+
: undefined;
|
|
2620
|
+
const secrets = [...planSecrets(plans), ...sensitiveParamSecrets(variables), settings.env?.[settings.jev.credentialEnv]].filter(Boolean)
|
|
2276
2621
|
.sort((left, right) => right.length - left.length);
|
|
2277
2622
|
const contentSecrets = planContentSecrets(plans);
|
|
2278
2623
|
const started = performance.now();
|
|
@@ -2294,11 +2639,12 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2294
2639
|
unnamed_controls: [],
|
|
2295
2640
|
unnamed_controls_initial: [],
|
|
2296
2641
|
unnamed_controls_final: [],
|
|
2297
|
-
warnings: [],
|
|
2642
|
+
warnings: continuation?.warnings || [],
|
|
2298
2643
|
final_snapshot: "",
|
|
2299
2644
|
};
|
|
2645
|
+
result.snapshot_mode = options.returnSnapshot;
|
|
2300
2646
|
const downloadLedger = { count: 0, bytes: 0 };
|
|
2301
|
-
const networkTracker = createNetworkTracker(page, settings);
|
|
2647
|
+
const networkTracker = createNetworkTracker(page, settings, options.loginUrlContains);
|
|
2302
2648
|
const initialNetworkMarker = networkTracker.mark();
|
|
2303
2649
|
const timings = { navigationMs: 0, initialSnapshotMs: 0, readyMs: 0, decisionMs: 0, browserPlanMs: 0 };
|
|
2304
2650
|
let unroute;
|
|
@@ -2306,10 +2652,33 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2306
2652
|
let tracePath;
|
|
2307
2653
|
const artifactErrors = [];
|
|
2308
2654
|
let navigationError;
|
|
2655
|
+
let initialSnapshot = "";
|
|
2656
|
+
const nativeDialog = { expectedStep: undefined, result: undefined };
|
|
2657
|
+
const onNativeDialog = async (dialog) => {
|
|
2658
|
+
const expectedStep = nativeDialog.expectedStep;
|
|
2659
|
+
const message = String(dialog.message() || "").replace(/\s+/g, " ").trim();
|
|
2660
|
+
const expectedText = expectedStep?.expectedText?.replace(/\s+/g, " ").trim();
|
|
2661
|
+
const matches = Boolean(expectedText && message.toLocaleLowerCase().includes(expectedText.toLocaleLowerCase()));
|
|
2662
|
+
const affirmative = /^(?:sim|yes|ok|okay|confirm|confirmar|aceitar)$/i.test(expectedStep?.button || "");
|
|
2663
|
+
nativeDialog.result = {
|
|
2664
|
+
message,
|
|
2665
|
+
expectedText,
|
|
2666
|
+
matched: matches,
|
|
2667
|
+
button: expectedStep?.button,
|
|
2668
|
+
type: dialog.type(),
|
|
2669
|
+
};
|
|
2670
|
+
try {
|
|
2671
|
+
if (matches && (dialog.type() === "alert" || affirmative)) await dialog.accept();
|
|
2672
|
+
else await dialog.dismiss();
|
|
2673
|
+
} catch {
|
|
2674
|
+
nativeDialog.result.handlerError = true;
|
|
2675
|
+
}
|
|
2676
|
+
};
|
|
2677
|
+
page.on("dialog", onNativeDialog);
|
|
2309
2678
|
try {
|
|
2310
2679
|
page.setDefaultTimeout(settings.browser.actionTimeoutMs);
|
|
2311
2680
|
context.setDefaultTimeout(settings.browser.actionTimeoutMs);
|
|
2312
|
-
unroute = await installTrackerBlocking(context, settings, options.blockTrackers);
|
|
2681
|
+
unroute = await installTrackerBlocking(context, settings, options.blockTrackers, options.blockFonts);
|
|
2313
2682
|
if (options.traceOnFailure) {
|
|
2314
2683
|
try {
|
|
2315
2684
|
await context.tracing.start({ screenshots: true, snapshots: true });
|
|
@@ -2340,6 +2709,9 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2340
2709
|
} else {
|
|
2341
2710
|
reusedPage = true;
|
|
2342
2711
|
result.navigation_strategy = "continued_current_page";
|
|
2712
|
+
if (new URL(currentUrl).href !== new URL(url).href) {
|
|
2713
|
+
addDiagnostic(result.warnings, "initial_url differs from the current same-origin URL; continued from the current page", settings.jev.maxDiagnosticItems);
|
|
2714
|
+
}
|
|
2343
2715
|
}
|
|
2344
2716
|
}
|
|
2345
2717
|
}
|
|
@@ -2360,9 +2732,14 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2360
2732
|
const readiness = await waitForPageReady(page, options, settings, secrets, reusedPage);
|
|
2361
2733
|
const enriched = await enrichSnapshot(page, readiness.snapshot, settings, secrets, options.snapshotScope, options.snapshotIncludeHidden);
|
|
2362
2734
|
result.final_snapshot = enriched.snapshot;
|
|
2735
|
+
initialSnapshot = continuation?.initialSnapshot || enriched.snapshot;
|
|
2363
2736
|
result.unnamed_controls = enriched.unnamedControls;
|
|
2364
2737
|
result.unnamed_controls_initial = enriched.unnamedControls;
|
|
2365
2738
|
result.unnamed_controls_final = enriched.unnamedControls;
|
|
2739
|
+
if (options.loginText.some((needle) => result.final_snapshot.toLocaleLowerCase().includes(needle.toLocaleLowerCase()))) {
|
|
2740
|
+
navigationError = "AUTHENTICATION_REQUIRED";
|
|
2741
|
+
throw new JevBrowserError("the configured login text is visible; sign in to the application, then run the flow again");
|
|
2742
|
+
}
|
|
2366
2743
|
for (const control of enriched.unnamedControls) {
|
|
2367
2744
|
addDiagnostic(
|
|
2368
2745
|
result.warnings,
|
|
@@ -2372,6 +2749,17 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2372
2749
|
}
|
|
2373
2750
|
timings.readyMs = readiness.readyMs;
|
|
2374
2751
|
browserPool.setNavigationError?.(null);
|
|
2752
|
+
if (continuation) {
|
|
2753
|
+
const currentUrl = typeof page.url === "function" ? page.url() : "unavailable";
|
|
2754
|
+
const currentFingerprint = await pageFingerprint(page, result.final_snapshot);
|
|
2755
|
+
if (continuation.pageUrl !== currentUrl || continuation.pageFingerprint !== currentFingerprint) {
|
|
2756
|
+
throw new JevBrowserError("the page changed after dry_run; run dry_run again before confirming a mutation");
|
|
2757
|
+
}
|
|
2758
|
+
if (!Object.hasOwn(plans, continuation.planId)) {
|
|
2759
|
+
throw new JevBrowserError("confirmation_token refers to a candidate plan that is no longer available");
|
|
2760
|
+
}
|
|
2761
|
+
variables = { ...continuation.variables };
|
|
2762
|
+
}
|
|
2375
2763
|
} catch (error) {
|
|
2376
2764
|
timings.readyMs = error.readyMs || performance.now() - snapshotStarted;
|
|
2377
2765
|
throw error;
|
|
@@ -2386,7 +2774,9 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2386
2774
|
} else {
|
|
2387
2775
|
let planId;
|
|
2388
2776
|
let decision;
|
|
2389
|
-
if (
|
|
2777
|
+
if (continuation) {
|
|
2778
|
+
planId = continuation.planId;
|
|
2779
|
+
} else if (options.fastPath && Object.keys(plans).length === 1) {
|
|
2390
2780
|
[planId] = Object.keys(plans);
|
|
2391
2781
|
} else {
|
|
2392
2782
|
const planIdByDecisionId = new Map();
|
|
@@ -2422,34 +2812,77 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2422
2812
|
|
|
2423
2813
|
if (planId) {
|
|
2424
2814
|
const plan = plans[planId];
|
|
2425
|
-
const trace = [];
|
|
2815
|
+
const trace = continuation ? [...continuation.trace] : [];
|
|
2426
2816
|
let failure = "";
|
|
2427
2817
|
let environmentFailure = false;
|
|
2428
2818
|
let failedStep;
|
|
2429
|
-
let
|
|
2819
|
+
let mutationStop;
|
|
2820
|
+
let issuedToken;
|
|
2430
2821
|
let snapshot = result.final_snapshot;
|
|
2431
2822
|
let networkMarker = networkTracker.mark();
|
|
2823
|
+
result.downloaded_files = continuation?.downloadedFiles || [];
|
|
2824
|
+
result.accessibility_audits = continuation?.accessibilityAudits || [];
|
|
2825
|
+
if (continuation?.downloadLedger) Object.assign(downloadLedger, continuation.downloadLedger);
|
|
2432
2826
|
const planStarted = performance.now();
|
|
2433
2827
|
const runAllSteps = plan.steps.some(({ action, expectDownload }) => action.startsWith("assert_")
|
|
2434
2828
|
|| ["upload_file", "select_option", "hover", "like_comment", "unlike_comment", "audit_accessibility"].includes(action)
|
|
2435
2829
|
|| expectDownload === true);
|
|
2436
|
-
|
|
2830
|
+
let approvedMutationIndex = continuation?.nextStep;
|
|
2831
|
+
for (let index = continuation?.nextStep || 0; index < plan.steps.length; index += 1) {
|
|
2832
|
+
const templateStep = plan.steps[index];
|
|
2437
2833
|
const stepStarted = performance.now();
|
|
2438
2834
|
let step = templateStep;
|
|
2439
2835
|
try {
|
|
2440
2836
|
step = interpolateStep(templateStep, variables);
|
|
2441
|
-
|
|
2442
|
-
|
|
2837
|
+
step.timeoutMs ??= options.stepTimeoutMs;
|
|
2838
|
+
const mutationInspection = await inferTargetMutation(page, step, snapshot, settings, secrets);
|
|
2839
|
+
const dynamicallyMutating = mutationInspection.mutating;
|
|
2840
|
+
const pairedNativeConfirmation = step.action === "confirm_dialog" && nativeDialog.result?.matched === true;
|
|
2841
|
+
if (dynamicallyMutating && !pairedNativeConfirmation && index !== approvedMutationIndex) {
|
|
2842
|
+
mutationStop = { step: String(index + 1), action: step.action };
|
|
2843
|
+
const enriched = await enrichSnapshot(page, snapshot, settings, secrets, options.snapshotScope, options.snapshotIncludeHidden);
|
|
2844
|
+
snapshot = enriched.snapshot;
|
|
2845
|
+
issuedToken = mutationAuthorizationStore.issue({
|
|
2846
|
+
fingerprint,
|
|
2847
|
+
planId,
|
|
2848
|
+
nextStep: index,
|
|
2849
|
+
variables: { ...variables },
|
|
2850
|
+
trace: [...trace],
|
|
2851
|
+
snapshot,
|
|
2852
|
+
initialSnapshot,
|
|
2853
|
+
pageUrl: typeof page.url === "function" ? page.url() : "unavailable",
|
|
2854
|
+
pageFingerprint: await pageFingerprint(page, snapshot),
|
|
2855
|
+
downloadedFiles: [...result.downloaded_files],
|
|
2856
|
+
accessibilityAudits: [...result.accessibility_audits],
|
|
2857
|
+
warnings: [...result.warnings],
|
|
2858
|
+
consoleErrors: [...(continuation?.consoleErrors || []), ...capture.diagnostics.console_errors],
|
|
2859
|
+
networkFailures: [...(continuation?.networkFailures || []), ...capture.diagnostics.network_failures],
|
|
2860
|
+
downloadLedger: { ...downloadLedger },
|
|
2861
|
+
}, settings.browser.mutationConfirmationTtlMs);
|
|
2443
2862
|
break;
|
|
2444
2863
|
}
|
|
2864
|
+
if (index === approvedMutationIndex) approvedMutationIndex = undefined;
|
|
2445
2865
|
if (step.fragileName) addDiagnostic(result.warnings, `step ${index + 1} uses an empty accessible name with index ${step.index}; this target is fragile`, settings.jev.maxDiagnosticItems);
|
|
2446
2866
|
if (step.fragileSelector) addDiagnostic(result.warnings, `step ${index + 1} uses an explicit CSS/XPath selector as a last resort`, settings.jev.maxDiagnosticItems);
|
|
2867
|
+
if (step.action === "click" && plan.steps[index + 1]?.action === "confirm_dialog") {
|
|
2868
|
+
nativeDialog.expectedStep = interpolateStep(plan.steps[index + 1], variables);
|
|
2869
|
+
nativeDialog.result = undefined;
|
|
2870
|
+
} else if (step.action !== "confirm_dialog") {
|
|
2871
|
+
nativeDialog.expectedStep = undefined;
|
|
2872
|
+
nativeDialog.result = undefined;
|
|
2873
|
+
}
|
|
2447
2874
|
const actionMarker = networkTracker.mark();
|
|
2448
2875
|
const traceStep = await executeStep(page, step, settings, snapshot, secrets, downloadLedger, {
|
|
2449
2876
|
networkTracker,
|
|
2450
2877
|
networkMarker,
|
|
2878
|
+
autoAngularIdle: options.autoAngularIdle,
|
|
2879
|
+
nativeDialog,
|
|
2880
|
+
preResolvedTarget: mutationInspection.target,
|
|
2451
2881
|
variables,
|
|
2452
2882
|
});
|
|
2883
|
+
if (options.autoAngularIdle && ["click", "type"].includes(step.action)) {
|
|
2884
|
+
await waitForAngularIdle(page, step.timeoutMs ?? options.stepTimeoutMs, settings, true);
|
|
2885
|
+
}
|
|
2453
2886
|
if (!["wait_for_text", "wait_for_condition", "assert_text", "assert_value", "assert_visible", "assert_hidden", "audit_accessibility"].includes(step.action)) {
|
|
2454
2887
|
networkMarker = actionMarker;
|
|
2455
2888
|
}
|
|
@@ -2539,17 +2972,19 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2539
2972
|
const downloadsPassed = expectedDownloadCount > 0 && !failure
|
|
2540
2973
|
&& result.downloaded_files.length === expectedDownloadCount && trace.length === plan.steps.length;
|
|
2541
2974
|
result.status = failure ? environmentFailure ? "environment_error" : "failed"
|
|
2542
|
-
:
|
|
2975
|
+
: mutationStop ? options.dryRun ? "dry_run" : "confirmation_required" : visible || assertionsPassed || auditsPassed || downloadsPassed ? "passed" : "incomplete";
|
|
2543
2976
|
if (environmentFailure) result.navigation_error = "BROWSER_DISCONNECTED";
|
|
2544
2977
|
result.expected_outcome_visible = visible;
|
|
2545
|
-
result.acceptance_criteria_met = !failure && !
|
|
2978
|
+
result.acceptance_criteria_met = !failure && !mutationStop && (visible || assertionsPassed || auditsPassed || downloadsPassed);
|
|
2546
2979
|
result.selected_plan = planId;
|
|
2547
2980
|
result.steps_executed = trace.length;
|
|
2548
2981
|
result.steps = trace;
|
|
2549
2982
|
result.mutating_steps = trace.filter(({ mutating }) => mutating === true).map(({ step, action, duration_ms, status }) => ({ step, action, duration_ms, status }));
|
|
2550
|
-
if (
|
|
2551
|
-
result.dry_run_stopped_before_step =
|
|
2552
|
-
result.
|
|
2983
|
+
if (mutationStop) {
|
|
2984
|
+
if (options.dryRun) result.dry_run_stopped_before_step = mutationStop;
|
|
2985
|
+
else result.confirmation_required_before_step = mutationStop;
|
|
2986
|
+
result.confirmation_token = issuedToken;
|
|
2987
|
+
result.reason = `${options.dryRun ? "dry_run stopped" : "confirmation required"} before mutating step ${mutationStop.step} (${mutationStop.action}); rerun the same flow with options.confirmation_token to authorize this one step`;
|
|
2553
2988
|
}
|
|
2554
2989
|
result.final_snapshot = snapshot;
|
|
2555
2990
|
if (failure) {
|
|
@@ -2581,6 +3016,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2581
3016
|
if (navigationError) result.navigation_error = navigationError;
|
|
2582
3017
|
result.reason = authenticationFailure
|
|
2583
3018
|
? `The application rejected its authentication request with HTTP ${authenticationFailure.status}; the browser session may have expired. Sign in to the application, then run the flow again.`
|
|
3019
|
+
: navigationError === "AUTHENTICATION_REQUIRED"
|
|
3020
|
+
? "The configured login URL or login text is visible; sign in to the application, then run the flow again."
|
|
2584
3021
|
: disconnected ? "The browser session disconnected; run browser_health to reconnect, then retry the flow."
|
|
2585
3022
|
: error instanceof JevBrowserError
|
|
2586
3023
|
? error.message
|
|
@@ -2639,17 +3076,23 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2639
3076
|
}
|
|
2640
3077
|
networkTracker.dispose();
|
|
2641
3078
|
await capture.dispose();
|
|
3079
|
+
page.off("dialog", onNativeDialog);
|
|
2642
3080
|
}
|
|
2643
3081
|
if (tracePath) result.trace_path = tracePath;
|
|
2644
3082
|
if (artifactErrors.length) result.artifact_errors = artifactErrors;
|
|
2645
|
-
result.console_errors = capture.diagnostics.console_errors;
|
|
2646
|
-
result.network_failures = capture.diagnostics.network_failures;
|
|
3083
|
+
result.console_errors = [...(continuation?.consoleErrors || []), ...capture.diagnostics.console_errors];
|
|
3084
|
+
result.network_failures = [...(continuation?.networkFailures || []), ...capture.diagnostics.network_failures];
|
|
2647
3085
|
result.warnings.push(...capture.diagnostics.warnings);
|
|
2648
3086
|
result.timings_ms = flowTimings(started, browserSessionMs, timings.navigationMs, timings.initialSnapshotMs, timings.readyMs, timings.decisionMs, timings.browserPlanMs);
|
|
2649
3087
|
const safeResult = await redactObject(result, secrets);
|
|
2650
3088
|
if (typeof safeResult.final_snapshot === "string") {
|
|
2651
3089
|
safeResult.final_snapshot = redact(safeResult.final_snapshot, contentSecrets);
|
|
2652
3090
|
}
|
|
3091
|
+
if (options.returnSnapshot === "diff" && typeof safeResult.final_snapshot === "string") {
|
|
3092
|
+
safeResult.final_snapshot = snapshotDiff(initialSnapshot, safeResult.final_snapshot);
|
|
3093
|
+
} else if (options.returnSnapshot === "none") {
|
|
3094
|
+
delete safeResult.final_snapshot;
|
|
3095
|
+
}
|
|
2653
3096
|
if (options.reportPath) {
|
|
2654
3097
|
try {
|
|
2655
3098
|
safeResult.report_path = await writeFlowReport(safeResult, options.reportPath, settings);
|
|
@@ -2660,7 +3103,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
2660
3103
|
return safeResult;
|
|
2661
3104
|
}
|
|
2662
3105
|
|
|
2663
|
-
export async function chooseNextAction({ flow, pageSnapshot, actions, completedSteps = [], settings, fetchImpl = fetch }) {
|
|
3106
|
+
export async function chooseNextAction({ flow, pageSnapshot, actions, completedSteps = [], localOnly = false, settings, fetchImpl = fetch }) {
|
|
3107
|
+
if (localOnly) throw new JevBrowserError("local_only is enabled; choose_next_action cannot call the Jev provider");
|
|
2664
3108
|
const { request, criteria } = buildChoicePayload({ flow, pageSnapshot, actions, completedSteps }, settings.jev);
|
|
2665
3109
|
return parseDecision(await requestJev(request, settings, fetchImpl), criteria);
|
|
2666
3110
|
}
|