@diegosouzacdv/jev-browser-mcp 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +327 -1771
- package/config/ui-testing.json +15 -3
- package/docs/jev-browser-mcp.md +61 -37
- package/mcp_servers/jev-browser-npm/bin/jev-browser-mcp.cjs +1 -1
- package/mcp_servers/jev-browser-npm/src/config.mjs +49 -8
- package/mcp_servers/jev-browser-npm/src/flow.mjs +548 -228
- package/mcp_servers/jev-browser-npm/src/server.mjs +8 -12
- package/package.json +32 -35
|
@@ -32,10 +32,11 @@ const MIME_TYPES = new Map([
|
|
|
32
32
|
[".jpg", "image/jpeg"], [".jpeg", "image/jpeg"], [".png", "image/png"],
|
|
33
33
|
[".txt", "text/plain"], [".webp", "image/webp"],
|
|
34
34
|
]);
|
|
35
|
-
const FLOW_OPTION_KEYS = new Set([
|
|
36
|
-
"block_trackers", "capture_console_errors", "
|
|
37
|
-
"
|
|
38
|
-
|
|
35
|
+
const FLOW_OPTION_KEYS = new Set([
|
|
36
|
+
"block_trackers", "capture_console_errors", "capture_network_error_bodies", "capture_network_errors",
|
|
37
|
+
"fast_path", "ready_network_idle", "ready_stable_ms", "ready_text", "ready_timeout_seconds",
|
|
38
|
+
"reuse_page", "screenshot_on_failure", "snapshot_scope", "trace_on_failure",
|
|
39
|
+
]);
|
|
39
40
|
|
|
40
41
|
function isRecord(value) {
|
|
41
42
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
@@ -79,28 +80,37 @@ export function validateCandidatePlans(candidatePlans, settings) {
|
|
|
79
80
|
const rawStep = normalizeStepAliases(unprocessedStep);
|
|
80
81
|
if (!isRecord(rawStep) || typeof rawStep.action !== "string") throw new JevBrowserError("each browser step must be an object with an action");
|
|
81
82
|
const { action } = rawStep;
|
|
82
|
-
const targetSelection = ["frame", "comment", "within", "index"];
|
|
83
|
-
const schemas = {
|
|
84
|
-
click: { required: ["role", "name"], optional: [...targetSelection, "expect_download"] },
|
|
85
|
-
type: { required: ["role", "name", "text"], optional: targetSelection },
|
|
86
|
-
wait_for_text: { required: ["text"], optional: ["frame", "comment"] },
|
|
87
|
-
wait_for_condition: { required: ["condition"], optional: ["comment"] },
|
|
88
|
-
press_key: { required: ["key"], optional: ["frame", "comment"] },
|
|
89
|
-
select_option: { required: ["name", "option"], optional: targetSelection },
|
|
90
|
-
hover: { required: ["role", "name"], optional: targetSelection },
|
|
91
|
-
assert_text: { required: ["role", "
|
|
92
|
-
assert_value: { required: ["role", "name", "expected"], optional: targetSelection },
|
|
93
|
-
assert_visible: { required: ["role"], optional: ["name", ...targetSelection] },
|
|
94
|
-
assert_hidden: { required: ["role"], optional: ["name", ...targetSelection] },
|
|
95
|
-
upload_file: { required: ["file_paths"], optional: ["target", "label", "role", "name", ...targetSelection] },
|
|
96
|
-
audit_accessibility: { required: ["standard"], optional: ["comment"] },
|
|
97
|
-
like_comment: { required: ["author", "text", "like_label", "liked_label"], optional: ["frame", "comment"] },
|
|
98
|
-
unlike_comment: { required: ["author", "text", "like_label", "liked_label"], optional: ["frame", "comment"] },
|
|
83
|
+
const targetSelection = ["frame", "comment", "within", "index"];
|
|
84
|
+
const schemas = {
|
|
85
|
+
click: { required: ["role", "name"], optional: [...targetSelection, "expect_download", "timeout_seconds"] },
|
|
86
|
+
type: { required: ["role", "name", "text"], optional: [...targetSelection, "blur", "mode", "sensitive", "timeout_seconds"] },
|
|
87
|
+
wait_for_text: { required: ["text"], optional: ["frame", "comment", "timeout_seconds"] },
|
|
88
|
+
wait_for_condition: { required: ["condition"], optional: ["frame", "comment", "name", "role", "text", "timeout_seconds"] },
|
|
89
|
+
press_key: { required: ["key"], optional: ["frame", "comment", "timeout_seconds"] },
|
|
90
|
+
select_option: { required: ["name", "option"], optional: [...targetSelection, "timeout_seconds"] },
|
|
91
|
+
hover: { required: ["role", "name"], optional: [...targetSelection, "timeout_seconds"] },
|
|
92
|
+
assert_text: { required: ["role", "expected"], optional: ["name", ...targetSelection, "timeout_seconds"] },
|
|
93
|
+
assert_value: { required: ["role", "name", "expected"], optional: [...targetSelection, "timeout_seconds"] },
|
|
94
|
+
assert_visible: { required: ["role"], optional: ["name", ...targetSelection, "timeout_seconds"] },
|
|
95
|
+
assert_hidden: { required: ["role"], optional: ["name", ...targetSelection, "timeout_seconds"] },
|
|
96
|
+
upload_file: { required: ["file_paths"], optional: ["target", "label", "role", "name", ...targetSelection, "timeout_seconds"] },
|
|
97
|
+
audit_accessibility: { required: ["standard"], optional: ["comment", "timeout_seconds"] },
|
|
98
|
+
like_comment: { required: ["author", "text", "like_label", "liked_label"], optional: ["frame", "comment", "timeout_seconds"] },
|
|
99
|
+
unlike_comment: { required: ["author", "text", "like_label", "liked_label"], optional: ["frame", "comment", "timeout_seconds"] },
|
|
99
100
|
};
|
|
100
101
|
if (!Object.hasOwn(schemas, action)) throw new JevBrowserError("unsupported browser action");
|
|
101
102
|
exactKeys(rawStep, ["action", ...schemas[action].required], schemas[action].optional, action);
|
|
102
103
|
|
|
103
|
-
const step = { action };
|
|
104
|
+
const step = { action };
|
|
105
|
+
if (rawStep.timeout_seconds !== undefined) {
|
|
106
|
+
if (typeof rawStep.timeout_seconds !== "number" || !Number.isFinite(rawStep.timeout_seconds) || rawStep.timeout_seconds <= 0) {
|
|
107
|
+
throw new JevBrowserError("timeout_seconds must be a positive number");
|
|
108
|
+
}
|
|
109
|
+
if (rawStep.timeout_seconds > settings.browser.maxStepTimeoutSeconds) {
|
|
110
|
+
throw new JevBrowserError("timeout_seconds exceeds the configured step-timeout limit");
|
|
111
|
+
}
|
|
112
|
+
step.timeoutMs = Math.ceil(rawStep.timeout_seconds * 1000);
|
|
113
|
+
}
|
|
104
114
|
if (rawStep.comment !== undefined) requiredText(rawStep.comment, "comment", settings.jev.maxActionDescriptionChars);
|
|
105
115
|
if (rawStep.within !== undefined) {
|
|
106
116
|
if (!TARGET_SELECTION_ACTIONS.has(action) || !isRecord(rawStep.within)) {
|
|
@@ -134,7 +144,7 @@ export function validateCandidatePlans(candidatePlans, settings) {
|
|
|
134
144
|
}
|
|
135
145
|
step.role = rawStep.role;
|
|
136
146
|
if (rawStep.name !== undefined) step.name = requiredText(rawStep.name, "accessible name", settings.jev.maxActionDescriptionChars);
|
|
137
|
-
if (["click", "type", "hover", "
|
|
147
|
+
if (["click", "type", "hover", "assert_value"].includes(action) && !step.name) {
|
|
138
148
|
throw new JevBrowserError(`${action} step needs an accessible name`);
|
|
139
149
|
}
|
|
140
150
|
}
|
|
@@ -143,17 +153,38 @@ export function validateCandidatePlans(candidatePlans, settings) {
|
|
|
143
153
|
step.name = requiredText(rawStep.name, "accessible name", settings.jev.maxActionDescriptionChars);
|
|
144
154
|
step.option = requiredText(rawStep.option, "option", settings.jev.maxActionDescriptionChars);
|
|
145
155
|
}
|
|
146
|
-
if (action === "type") {
|
|
147
|
-
if (typeof rawStep.text !== "string" || rawStep.text.length === 0) throw new JevBrowserError("type step needs non-empty text");
|
|
148
|
-
if (rawStep.text.length > settings.browser.maxTextEntryChars) throw new JevBrowserError("type text exceeds the configured character limit");
|
|
149
|
-
|
|
156
|
+
if (action === "type") {
|
|
157
|
+
if (typeof rawStep.text !== "string" || rawStep.text.length === 0) throw new JevBrowserError("type step needs non-empty text");
|
|
158
|
+
if (rawStep.text.length > settings.browser.maxTextEntryChars) throw new JevBrowserError("type text exceeds the configured character limit");
|
|
159
|
+
if (rawStep.mode !== undefined && !new Set(["fill", "keys"]).has(rawStep.mode)) {
|
|
160
|
+
throw new JevBrowserError("type mode must be fill or keys");
|
|
161
|
+
}
|
|
162
|
+
if (rawStep.blur !== undefined && typeof rawStep.blur !== "boolean") throw new JevBrowserError("type blur must be a boolean");
|
|
163
|
+
if (rawStep.sensitive !== undefined && typeof rawStep.sensitive !== "boolean") throw new JevBrowserError("type sensitive must be a boolean");
|
|
164
|
+
step.text = rawStep.text;
|
|
165
|
+
step.mode = rawStep.mode || "fill";
|
|
166
|
+
step.blur = rawStep.blur ?? false;
|
|
167
|
+
step.sensitive = rawStep.sensitive ?? true;
|
|
150
168
|
}
|
|
151
169
|
if (action === "wait_for_text") {
|
|
152
170
|
step.text = requiredText(rawStep.text, "wait text", settings.jev.maxActionDescriptionChars);
|
|
153
171
|
}
|
|
154
|
-
if (action === "wait_for_condition") {
|
|
155
|
-
if (
|
|
156
|
-
|
|
172
|
+
if (action === "wait_for_condition") {
|
|
173
|
+
if (!new Set(["network_idle", "hidden", "text_hidden"]).has(rawStep.condition)) {
|
|
174
|
+
throw new JevBrowserError("wait_for_condition supports network_idle, hidden, or text_hidden");
|
|
175
|
+
}
|
|
176
|
+
step.condition = rawStep.condition;
|
|
177
|
+
if (rawStep.condition === "hidden") {
|
|
178
|
+
if (!ASSERT_ROLES.has(rawStep.role)) throw new JevBrowserError("hidden condition needs a supported accessible role");
|
|
179
|
+
step.role = rawStep.role;
|
|
180
|
+
if (rawStep.name !== undefined) step.name = requiredText(rawStep.name, "accessible name", settings.jev.maxActionDescriptionChars);
|
|
181
|
+
if (rawStep.text !== undefined) throw new JevBrowserError("hidden condition does not accept text");
|
|
182
|
+
} else if (rawStep.condition === "text_hidden") {
|
|
183
|
+
step.text = requiredText(rawStep.text, "hidden text", settings.jev.maxActionDescriptionChars);
|
|
184
|
+
if (rawStep.role !== undefined || rawStep.name !== undefined) throw new JevBrowserError("text_hidden condition does not accept role or name");
|
|
185
|
+
} else if (rawStep.role !== undefined || rawStep.name !== undefined || rawStep.text !== undefined) {
|
|
186
|
+
throw new JevBrowserError("network_idle condition does not accept role, name, or text");
|
|
187
|
+
}
|
|
157
188
|
}
|
|
158
189
|
if (action === "audit_accessibility") {
|
|
159
190
|
if (typeof rawStep.standard !== "string" || !Object.hasOwn(ACCESSIBILITY_STANDARDS, rawStep.standard)) {
|
|
@@ -240,23 +271,42 @@ function resolveFlowOptions(rawOptions, settings) {
|
|
|
240
271
|
if (!isRecord(rawOptions) || Object.keys(rawOptions).some((key) => !FLOW_OPTION_KEYS.has(key))) {
|
|
241
272
|
throw new JevBrowserError("options contains unsupported flow settings");
|
|
242
273
|
}
|
|
243
|
-
const booleanOptions = [
|
|
244
|
-
"block_trackers", "capture_console_errors", "
|
|
245
|
-
"screenshot_on_failure", "trace_on_failure",
|
|
274
|
+
const booleanOptions = [
|
|
275
|
+
"block_trackers", "capture_console_errors", "capture_network_error_bodies", "capture_network_errors",
|
|
276
|
+
"fast_path", "ready_network_idle", "reuse_page", "screenshot_on_failure", "trace_on_failure",
|
|
246
277
|
];
|
|
247
278
|
for (const key of booleanOptions) {
|
|
248
279
|
if (rawOptions[key] !== undefined && typeof rawOptions[key] !== "boolean") {
|
|
249
280
|
throw new JevBrowserError(`options.${key} must be a boolean`);
|
|
250
281
|
}
|
|
251
282
|
}
|
|
252
|
-
const scope = rawOptions.snapshot_scope ?? settings.browser.defaults.snapshotScope;
|
|
253
|
-
if (!new Set(["body", "main", "dialog"]).has(scope)) throw new JevBrowserError("options.snapshot_scope must be body, main, or dialog");
|
|
254
|
-
const defaults = settings.browser.defaults;
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
283
|
+
const scope = rawOptions.snapshot_scope ?? settings.browser.defaults.snapshotScope;
|
|
284
|
+
if (!new Set(["body", "main", "dialog"]).has(scope)) throw new JevBrowserError("options.snapshot_scope must be body, main, or dialog");
|
|
285
|
+
const defaults = settings.browser.defaults;
|
|
286
|
+
const readyTimeoutSeconds = rawOptions.ready_timeout_seconds ?? defaults.readyTimeoutSeconds;
|
|
287
|
+
if (typeof readyTimeoutSeconds !== "number" || !Number.isFinite(readyTimeoutSeconds) || readyTimeoutSeconds <= 0
|
|
288
|
+
|| readyTimeoutSeconds > settings.browser.maxReadyTimeoutSeconds) {
|
|
289
|
+
throw new JevBrowserError(`options.ready_timeout_seconds must be positive and at most ${settings.browser.maxReadyTimeoutSeconds}`);
|
|
290
|
+
}
|
|
291
|
+
const readyStableMs = rawOptions.ready_stable_ms ?? defaults.readyStableMs;
|
|
292
|
+
if (!Number.isSafeInteger(readyStableMs) || readyStableMs <= 0 || readyStableMs > settings.browser.maxReadyStableMs
|
|
293
|
+
|| readyStableMs > readyTimeoutSeconds * 1000) {
|
|
294
|
+
throw new JevBrowserError("options.ready_stable_ms must be a positive integer within the configured readiness limits");
|
|
295
|
+
}
|
|
296
|
+
const readyText = rawOptions.ready_text === undefined
|
|
297
|
+
? ""
|
|
298
|
+
: requiredText(rawOptions.ready_text, "ready_text", settings.jev.maxActionDescriptionChars);
|
|
299
|
+
return Object.freeze({
|
|
300
|
+
blockTrackers: rawOptions.block_trackers ?? defaults.blockTrackers,
|
|
301
|
+
captureConsoleErrors: rawOptions.capture_console_errors ?? defaults.captureConsoleErrors,
|
|
302
|
+
captureNetworkErrorBodies: rawOptions.capture_network_error_bodies ?? defaults.captureNetworkErrorBodies,
|
|
303
|
+
captureNetworkErrors: rawOptions.capture_network_errors ?? defaults.captureNetworkErrors,
|
|
304
|
+
fastPath: rawOptions.fast_path ?? defaults.fastPath,
|
|
305
|
+
readyTimeoutMs: Math.ceil(readyTimeoutSeconds * 1000),
|
|
306
|
+
readyNetworkIdle: rawOptions.ready_network_idle ?? defaults.readyNetworkIdle,
|
|
307
|
+
readyStableMs,
|
|
308
|
+
readyText,
|
|
309
|
+
reusePage: rawOptions.reuse_page ?? defaults.reusePage,
|
|
260
310
|
screenshotOnFailure: rawOptions.screenshot_on_failure ?? defaults.screenshotOnFailure,
|
|
261
311
|
traceOnFailure: rawOptions.trace_on_failure ?? defaults.traceOnFailure,
|
|
262
312
|
snapshotScope: scope,
|
|
@@ -267,12 +317,11 @@ function escapeRegex(value) {
|
|
|
267
317
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
268
318
|
}
|
|
269
319
|
|
|
270
|
-
function planSecrets(plans) {
|
|
320
|
+
function planSecrets(plans) {
|
|
271
321
|
const secrets = new Set();
|
|
272
322
|
for (const plan of Object.values(plans)) {
|
|
273
323
|
for (const step of plan.steps) {
|
|
274
|
-
if (step.action === "type") secrets.add(step.text);
|
|
275
|
-
if (step.action === "assert_text" || step.action === "assert_value") secrets.add(step.expected);
|
|
324
|
+
if (step.action === "type" && step.sensitive) secrets.add(step.text);
|
|
276
325
|
if (step.action === "upload_file") {
|
|
277
326
|
for (const filePath of step.filePaths) {
|
|
278
327
|
secrets.add(filePath);
|
|
@@ -354,19 +403,24 @@ async function frameDisplayName(frame) {
|
|
|
354
403
|
return name;
|
|
355
404
|
}
|
|
356
405
|
|
|
357
|
-
async function snapshotPage(page, settings, secrets, snapshotScope = "body") {
|
|
406
|
+
async function snapshotPage(page, settings, secrets, snapshotScope = "body", { allowEmpty = false, timeoutMs } = {}) {
|
|
358
407
|
let snapshot;
|
|
359
408
|
let root;
|
|
360
409
|
try {
|
|
361
410
|
root = snapshotScope === "body" ? page.locator("body") : page.getByRole(snapshotScope);
|
|
362
|
-
const rootCount = snapshotScope === "body" ? 1 : await root.count();
|
|
363
|
-
if (rootCount !== 1)
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
411
|
+
const rootCount = snapshotScope === "body" ? 1 : await root.count();
|
|
412
|
+
if (allowEmpty && rootCount !== 1) return "";
|
|
413
|
+
if (rootCount !== 1) throw new JevBrowserError(`snapshot scope ${snapshotScope} must match exactly one accessible region; found ${rootCount}`);
|
|
414
|
+
snapshot = (await root.ariaSnapshot(timeoutMs ? { timeout: timeoutMs } : undefined)).trim();
|
|
415
|
+
} catch (error) {
|
|
416
|
+
if (error instanceof JevBrowserError) throw error;
|
|
417
|
+
if (allowEmpty) return "";
|
|
418
|
+
throw new JevBrowserError(`Playwright could not read the accessible page snapshot (${error?.name || "BrowserError"})`);
|
|
419
|
+
}
|
|
420
|
+
if (!snapshot) {
|
|
421
|
+
if (allowEmpty) return "";
|
|
422
|
+
throw new JevBrowserError("Playwright returned an empty page snapshot");
|
|
423
|
+
}
|
|
370
424
|
let scopedFrameNames;
|
|
371
425
|
if (snapshotScope !== "body") {
|
|
372
426
|
try {
|
|
@@ -389,16 +443,84 @@ async function snapshotPage(page, settings, secrets, snapshotScope = "body") {
|
|
|
389
443
|
}
|
|
390
444
|
}
|
|
391
445
|
const combined = [snapshot, ...frameSnapshots].join("\n\n");
|
|
392
|
-
return compactSnapshot(redact(combined, secrets), settings.jev.maxSnapshotChars);
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
446
|
+
return compactSnapshot(redact(combined, secrets), settings.jev.maxSnapshotChars);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function remainingMs(deadline) {
|
|
450
|
+
return Math.max(1, Math.ceil(deadline - performance.now()));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function waitForPageReady(page, options, settings, secrets) {
|
|
454
|
+
const started = performance.now();
|
|
455
|
+
const deadline = started + options.readyTimeoutMs;
|
|
456
|
+
const bestEffortLoadState = async (state) => {
|
|
457
|
+
try {
|
|
458
|
+
await page.waitForLoadState(state, { timeout: Math.min(500, remainingMs(deadline)) });
|
|
459
|
+
} catch {
|
|
460
|
+
// SPAs may keep requests open; accessible snapshot stability is the final readiness signal.
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
await bestEffortLoadState("load");
|
|
464
|
+
if (options.readyNetworkIdle) await bestEffortLoadState("networkidle");
|
|
465
|
+
|
|
466
|
+
let previousSnapshot = "";
|
|
467
|
+
let stableSince = 0;
|
|
468
|
+
let lastSnapshot = "";
|
|
469
|
+
while (performance.now() < deadline) {
|
|
470
|
+
const snapshot = await snapshotPage(page, settings, secrets, options.snapshotScope, {
|
|
471
|
+
allowEmpty: true,
|
|
472
|
+
timeoutMs: remainingMs(deadline),
|
|
473
|
+
});
|
|
474
|
+
lastSnapshot = snapshot;
|
|
475
|
+
const readyTextVisible = !options.readyText || containsPhrase(snapshot, options.readyText);
|
|
476
|
+
const usefulSnapshot = await hasRenderedContent(page, snapshot);
|
|
477
|
+
if (snapshot && readyTextVisible && usefulSnapshot) {
|
|
478
|
+
if (snapshot === previousSnapshot) {
|
|
479
|
+
if (!stableSince) stableSince = performance.now();
|
|
480
|
+
if (performance.now() - stableSince >= options.readyStableMs) {
|
|
481
|
+
return { snapshot, readyMs: performance.now() - started };
|
|
482
|
+
}
|
|
483
|
+
} else {
|
|
484
|
+
previousSnapshot = snapshot;
|
|
485
|
+
stableSince = performance.now();
|
|
486
|
+
}
|
|
487
|
+
} else {
|
|
488
|
+
previousSnapshot = "";
|
|
489
|
+
stableSince = 0;
|
|
490
|
+
}
|
|
491
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(100, remainingMs(deadline))));
|
|
492
|
+
}
|
|
493
|
+
const seconds = Number((options.readyTimeoutMs / 1000).toFixed(1));
|
|
494
|
+
const error = new JevBrowserError(`page did not render an accessible snapshot within ${seconds}s${lastSnapshot ? " (ready_text was not visible or snapshot did not stabilize)" : " (still loading?)"}`);
|
|
495
|
+
error.readyMs = performance.now() - started;
|
|
496
|
+
throw error;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function hasRenderedContent(page, snapshot) {
|
|
500
|
+
const lines = snapshot.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
501
|
+
if (!lines.some((line, index) => index > 0 || /:\s+\S|"[^"]+"/.test(line))) return false;
|
|
502
|
+
try {
|
|
503
|
+
if (await page.locator('[aria-busy="true"]').count() > 0) return false;
|
|
504
|
+
} catch {
|
|
505
|
+
// The snapshot itself is the fallback when the DOM is transitioning.
|
|
506
|
+
}
|
|
507
|
+
return true;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function snapshotAfterStep(page, settings, secrets, scope, previousSnapshot, stepNumber, warnings) {
|
|
511
|
+
const snapshot = await snapshotPage(page, settings, secrets, scope, { allowEmpty: true });
|
|
512
|
+
if (snapshot) return snapshot;
|
|
513
|
+
warnings.push(`empty snapshot after step ${stepNumber}; kept the last accessible snapshot while the SPA rendered`);
|
|
514
|
+
const deadline = performance.now() + settings.browser.postStepReadyTimeoutMs;
|
|
515
|
+
while (performance.now() < deadline) {
|
|
516
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(100, remainingMs(deadline))));
|
|
517
|
+
const recovered = await snapshotPage(page, settings, secrets, scope, { allowEmpty: true, timeoutMs: remainingMs(deadline) });
|
|
518
|
+
if (recovered) return recovered;
|
|
519
|
+
}
|
|
520
|
+
return previousSnapshot;
|
|
521
|
+
}
|
|
400
522
|
|
|
401
|
-
function normalizedName(value) {
|
|
523
|
+
function normalizedName(value) {
|
|
402
524
|
return value.toLocaleLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
|
|
403
525
|
}
|
|
404
526
|
|
|
@@ -420,40 +542,73 @@ function editDistance(left, right) {
|
|
|
420
542
|
return previous[b.length];
|
|
421
543
|
}
|
|
422
544
|
|
|
423
|
-
function closestAccessibleNames(snapshot, role, requestedName, secrets) {
|
|
424
|
-
const
|
|
425
|
-
const
|
|
426
|
-
for (const
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
545
|
+
function closestAccessibleNames(snapshot, role, requestedName, secrets) {
|
|
546
|
+
const roles = [...new Set([role, "button", "link", "tab", "menuitem"] )];
|
|
547
|
+
const names = new Map();
|
|
548
|
+
for (const candidateRole of roles) {
|
|
549
|
+
const pattern = new RegExp(`^\\s*-\\s+${escapeRegex(candidateRole)}\\s+"((?:\\\\.|[^"])*)"`, "gmi");
|
|
550
|
+
for (const match of snapshot.matchAll(pattern)) {
|
|
551
|
+
if (names.size >= 200) break;
|
|
552
|
+
if (match[1].length > 160) continue;
|
|
553
|
+
let name;
|
|
554
|
+
try {
|
|
555
|
+
name = JSON.parse(`"${match[1]}"`);
|
|
556
|
+
} catch {
|
|
557
|
+
name = match[1];
|
|
558
|
+
}
|
|
559
|
+
names.set(`${candidateRole}\n${name}`, { role: candidateRole, name });
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
const wanted = normalizedName(requestedName);
|
|
563
|
+
return [...names.values()]
|
|
564
|
+
.map(({ role: foundRole, name }) => {
|
|
565
|
+
const normalized = normalizedName(name);
|
|
566
|
+
const score = normalized && wanted
|
|
567
|
+
? editDistance(normalized, wanted) / Math.max(normalized.length, wanted.length)
|
|
568
|
+
: 1;
|
|
569
|
+
return { role: foundRole, name, score };
|
|
570
|
+
})
|
|
571
|
+
.filter(({ score }) => score <= 0.75)
|
|
572
|
+
.sort((left, right) => left.score - right.score)
|
|
573
|
+
.slice(0, 3)
|
|
574
|
+
.map(({ role: foundRole, name }) => `${foundRole} ${JSON.stringify(redact(name, secrets))}`);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function attachStepCause(error, cause) {
|
|
578
|
+
error.stepCause = cause;
|
|
579
|
+
return error;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async function exactlyOne(locator, description, timeout) {
|
|
583
|
+
const first = typeof locator.first === "function" ? locator.first() : null;
|
|
584
|
+
if (timeout !== undefined && typeof first?.waitFor === "function") {
|
|
585
|
+
try {
|
|
586
|
+
await first.waitFor({ state: "visible", timeout });
|
|
587
|
+
} catch (cause) {
|
|
588
|
+
const error = new JevBrowserError(`${description} did not become visible before the configured timeout`);
|
|
589
|
+
throw attachStepCause(error, cause);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
const count = await locator.count();
|
|
593
|
+
if (count !== 1) throw new JevBrowserError(`${description} must match exactly one visible control; found ${count}`);
|
|
594
|
+
return locator;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async function scopedRoot(root, within, timeout) {
|
|
598
|
+
if (!within) return root;
|
|
599
|
+
try {
|
|
600
|
+
return await exactlyOne(
|
|
601
|
+
root.getByRole(within.role, { name: within.name, exact: true }),
|
|
602
|
+
"accessible within container",
|
|
603
|
+
timeout,
|
|
604
|
+
);
|
|
605
|
+
} catch (error) {
|
|
606
|
+
if (error instanceof JevBrowserError && error.stepCause) {
|
|
607
|
+
throw attachStepCause(new JevBrowserError(`accessible within container ${within.role} ${JSON.stringify(within.name)} was not found`), error.stepCause);
|
|
608
|
+
}
|
|
609
|
+
throw error;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
457
612
|
|
|
458
613
|
function indexedTarget(locator, count, index, description) {
|
|
459
614
|
if (index !== undefined) {
|
|
@@ -464,23 +619,70 @@ function indexedTarget(locator, count, index, description) {
|
|
|
464
619
|
return locator;
|
|
465
620
|
}
|
|
466
621
|
|
|
467
|
-
async function roleLocator(root, role, name, within) {
|
|
468
|
-
const targetRoot = await scopedRoot(root, within);
|
|
469
|
-
const locator = name === undefined
|
|
470
|
-
? targetRoot.getByRole(role)
|
|
471
|
-
: targetRoot.getByRole(role, { name, exact: true });
|
|
472
|
-
return { locator, count: await locator.count() };
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
async function namedTarget(root, role, name, snapshot, secrets, selection = {}) {
|
|
476
|
-
const { locator
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
}
|
|
622
|
+
async function roleLocator(root, role, name, within, timeout) {
|
|
623
|
+
const targetRoot = await scopedRoot(root, within, timeout);
|
|
624
|
+
const locator = name === undefined
|
|
625
|
+
? targetRoot.getByRole(role)
|
|
626
|
+
: targetRoot.getByRole(role, { name, exact: true });
|
|
627
|
+
return { locator, count: await locator.count() };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async function namedTarget(root, role, name, snapshot, secrets, selection = {}) {
|
|
631
|
+
const { locator } = await roleLocator(root, role, name, selection.within, selection.timeoutMs);
|
|
632
|
+
let count = await locator.count();
|
|
633
|
+
const awaited = selection.index === undefined
|
|
634
|
+
? typeof locator.first === "function" ? locator.first() : null
|
|
635
|
+
: typeof locator.nth === "function" ? locator.nth(selection.index) : null;
|
|
636
|
+
if ((selection.index === undefined ? count === 0 : selection.index >= count) && typeof awaited?.waitFor === "function") {
|
|
637
|
+
try {
|
|
638
|
+
await awaited.waitFor({ state: "visible", timeout: selection.timeoutMs });
|
|
639
|
+
count = await locator.count();
|
|
640
|
+
} catch (cause) {
|
|
641
|
+
if (count > 0 && selection.index !== undefined) {
|
|
642
|
+
throw new JevBrowserError(`accessible target ${role} ${JSON.stringify(redact(name, secrets))} index ${selection.index} is out of range; found ${count}`);
|
|
643
|
+
}
|
|
644
|
+
const suggestions = closestAccessibleNames(snapshot, role, name, secrets);
|
|
645
|
+
const suffix = suggestions.length ? `; nearby accessible controls: ${suggestions.join(", ")}` : "";
|
|
646
|
+
const error = new JevBrowserError(`accessible target ${role} ${JSON.stringify(redact(name, secrets))} not found${suffix}`);
|
|
647
|
+
throw attachStepCause(error, cause);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
if (count === 0) {
|
|
651
|
+
const suggestions = closestAccessibleNames(snapshot, role, name, secrets);
|
|
652
|
+
const suffix = suggestions.length ? `; nearby accessible controls: ${suggestions.join(", ")}` : "";
|
|
653
|
+
throw new JevBrowserError(`accessible target ${role} ${JSON.stringify(redact(name, secrets))} not found${suffix}`);
|
|
654
|
+
}
|
|
655
|
+
const description = `accessible target ${role} ${JSON.stringify(redact(name, secrets))}`;
|
|
656
|
+
const target = indexedTarget(locator, count, selection.index, description);
|
|
657
|
+
if (typeof target.waitFor === "function") {
|
|
658
|
+
try {
|
|
659
|
+
await target.waitFor({ state: "visible", timeout: selection.timeoutMs });
|
|
660
|
+
} catch (cause) {
|
|
661
|
+
const detail = selection.index !== undefined && selection.index >= count
|
|
662
|
+
? `${description} index ${selection.index} is out of range; found ${count}`
|
|
663
|
+
: `${description} did not become visible before the configured timeout`;
|
|
664
|
+
throw attachStepCause(new JevBrowserError(detail), cause);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return target;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async function visibleRoleTarget(root, step, timeout, description) {
|
|
671
|
+
const { locator } = await roleLocator(root, step.role, undefined, step.within, timeout);
|
|
672
|
+
const target = step.index === undefined ? locator.first() : locator.nth(step.index);
|
|
673
|
+
if (typeof target?.waitFor === "function") {
|
|
674
|
+
try {
|
|
675
|
+
await target.waitFor({ state: "visible", timeout });
|
|
676
|
+
} catch (cause) {
|
|
677
|
+
throw attachStepCause(new JevBrowserError(`${description} did not become visible before the configured timeout`), cause);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
const count = await locator.count();
|
|
681
|
+
if (count === 0 || (step.index !== undefined && step.index >= count)) {
|
|
682
|
+
throw new JevBrowserError(`${description} has no matching accessible control`);
|
|
683
|
+
}
|
|
684
|
+
return { locator, target: step.index === undefined ? locator.first() : locator.nth(step.index), count };
|
|
685
|
+
}
|
|
484
686
|
|
|
485
687
|
async function stepRoot(page, frameName) {
|
|
486
688
|
if (!frameName) return page;
|
|
@@ -504,8 +706,8 @@ function commentRow(root, author, text) {
|
|
|
504
706
|
return root.getByRole("button", { name: new RegExp(`(?=.*${authorPattern})(?=.*${textPattern})`, "isu") });
|
|
505
707
|
}
|
|
506
708
|
|
|
507
|
-
async function reactToComment(root, step) {
|
|
508
|
-
const row = await exactlyOne(commentRow(root, step.author, step.text), "requested comment row");
|
|
709
|
+
async function reactToComment(root, step, timeout) {
|
|
710
|
+
const row = await exactlyOne(commentRow(root, step.author, step.text), "requested comment row", timeout);
|
|
509
711
|
const isLike = step.action === "like_comment";
|
|
510
712
|
const desired = isLike ? step.liked_label : step.like_label;
|
|
511
713
|
const current = isLike ? step.like_label : step.liked_label;
|
|
@@ -519,8 +721,8 @@ async function reactToComment(root, step) {
|
|
|
519
721
|
if (desiredCount === 1) return isLike ? "already_liked" : "already_unliked";
|
|
520
722
|
if (currentCount !== 1) throw new JevBrowserError("comment reaction control is not uniquely available in the targeted comment");
|
|
521
723
|
const controls = row.getByRole("button").filter({ has: currentImages });
|
|
522
|
-
await (await exactlyOne(controls, "comment reaction button")).click();
|
|
523
|
-
await desiredImages.waitFor({ state: "visible" });
|
|
724
|
+
await (await exactlyOne(controls, "comment reaction button", timeout)).click({ timeout });
|
|
725
|
+
await desiredImages.waitFor({ state: "visible", timeout });
|
|
524
726
|
return isLike ? "liked" : "unliked";
|
|
525
727
|
}
|
|
526
728
|
|
|
@@ -671,71 +873,100 @@ async function saveDownload(download, settings, ledger) {
|
|
|
671
873
|
}
|
|
672
874
|
}
|
|
673
875
|
|
|
674
|
-
async function executeStep(page, step, settings, snapshot, secrets, downloadLedger) {
|
|
675
|
-
const root = await stepRoot(page, step.frame);
|
|
676
|
-
const timeout = settings.browser.actionTimeoutMs;
|
|
677
|
-
if (step.action === "wait_for_text") {
|
|
678
|
-
await root.getByText(step.text, { exact: false }).first().waitFor({ state: "visible", timeout });
|
|
679
|
-
return { action: step.action };
|
|
680
|
-
}
|
|
681
|
-
if (step.action === "wait_for_condition") {
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
876
|
+
async function executeStep(page, step, settings, snapshot, secrets, downloadLedger) {
|
|
877
|
+
const root = await stepRoot(page, step.frame);
|
|
878
|
+
const timeout = step.timeoutMs ?? settings.browser.actionTimeoutMs;
|
|
879
|
+
if (step.action === "wait_for_text") {
|
|
880
|
+
await root.getByText(step.text, { exact: false }).first().waitFor({ state: "visible", timeout });
|
|
881
|
+
return { action: step.action };
|
|
882
|
+
}
|
|
883
|
+
if (step.action === "wait_for_condition") {
|
|
884
|
+
if (step.condition === "network_idle") {
|
|
885
|
+
await root.waitForLoadState("networkidle", { timeout });
|
|
886
|
+
} else if (step.condition === "text_hidden") {
|
|
887
|
+
await root.getByText(step.text, { exact: false }).first().waitFor({ state: "hidden", timeout });
|
|
888
|
+
} else {
|
|
889
|
+
const { locator, count } = await roleLocator(root, step.role, step.name, undefined, timeout);
|
|
890
|
+
if (count > 1 && step.index === undefined) throw new JevBrowserError("hidden condition target is ambiguous; provide an index");
|
|
891
|
+
if (step.index !== undefined && step.index >= count) return { action: step.action, condition: step.condition };
|
|
892
|
+
await (step.index === undefined ? locator.first() : locator.nth(step.index)).waitFor({ state: "hidden", timeout });
|
|
893
|
+
}
|
|
894
|
+
return { action: step.action, condition: step.condition };
|
|
895
|
+
}
|
|
896
|
+
if (step.action === "press_key") {
|
|
897
|
+
await root.locator("body").press(step.key, { timeout });
|
|
898
|
+
return { action: step.action, key: step.key };
|
|
899
|
+
}
|
|
900
|
+
if (step.action === "like_comment" || step.action === "unlike_comment") {
|
|
901
|
+
return { action: step.action, result: await reactToComment(root, step, timeout) };
|
|
902
|
+
}
|
|
903
|
+
if (step.action === "audit_accessibility") {
|
|
904
|
+
return {
|
|
905
|
+
action: step.action,
|
|
906
|
+
accessibility_audit: await withTimeout(
|
|
907
|
+
runAccessibilityAudit(page, step.standard, settings),
|
|
908
|
+
timeout,
|
|
909
|
+
`accessibility audit exceeded the ${timeout}ms step timeout`,
|
|
910
|
+
),
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
if (step.action === "upload_file") {
|
|
914
|
+
const files = await uploadFiles(step, settings);
|
|
915
|
+
if (step.target === "input") {
|
|
916
|
+
const targetRoot = await scopedRoot(root, step.within, timeout);
|
|
917
|
+
const input = targetRoot.getByLabel(step.label, { exact: true });
|
|
918
|
+
const selected = step.index === undefined ? input.first() : input.nth(step.index);
|
|
919
|
+
if (typeof selected?.waitFor === "function") {
|
|
920
|
+
try {
|
|
921
|
+
await selected.waitFor({ state: "attached", timeout });
|
|
922
|
+
} catch (cause) {
|
|
923
|
+
throw attachStepCause(new JevBrowserError("accessible file input did not appear before the configured timeout"), cause);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
const count = await input.count();
|
|
927
|
+
const control = indexedTarget(input, count, step.index, "accessible file input");
|
|
928
|
+
await control.setInputFiles(files, { timeout });
|
|
929
|
+
} else {
|
|
930
|
+
const control = await namedTarget(root, step.role, step.name, snapshot, secrets, { ...step, timeoutMs: timeout });
|
|
931
|
+
if (step.target === "button") {
|
|
710
932
|
const [chooser] = await Promise.all([
|
|
711
933
|
page.waitForEvent("filechooser", { timeout }),
|
|
712
934
|
control.click({ timeout }),
|
|
713
935
|
]);
|
|
714
|
-
await chooser.setFiles(files);
|
|
936
|
+
await withTimeout(chooser.setFiles(files), timeout, `file chooser exceeded the ${timeout}ms step timeout`);
|
|
715
937
|
} else {
|
|
716
938
|
await control.drop({ files }, { timeout });
|
|
717
939
|
}
|
|
718
940
|
}
|
|
719
941
|
return { action: step.action, target: step.target, file_count: files.length };
|
|
720
|
-
}
|
|
721
|
-
if (step.action === "assert_hidden") {
|
|
722
|
-
const { locator, count } = await roleLocator(root, step.role, step.name, step.within);
|
|
723
|
-
if (step.index
|
|
724
|
-
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
942
|
+
}
|
|
943
|
+
if (step.action === "assert_hidden") {
|
|
944
|
+
const { locator, count } = await roleLocator(root, step.role, step.name, step.within, timeout);
|
|
945
|
+
if (step.index !== undefined) {
|
|
946
|
+
if (step.index < count) await locator.nth(step.index).waitFor({ state: "hidden", timeout });
|
|
947
|
+
} else {
|
|
948
|
+
for (let index = 0; index < count; index += 1) await locator.nth(index).waitFor({ state: "hidden", timeout });
|
|
949
|
+
}
|
|
950
|
+
return { action: step.action, asserted: true };
|
|
951
|
+
}
|
|
952
|
+
if (step.action === "assert_visible" && step.name === undefined) {
|
|
953
|
+
await visibleRoleTarget(root, step, timeout, `assert_visible ${step.role}`);
|
|
954
|
+
return { action: step.action, asserted: true };
|
|
955
|
+
}
|
|
956
|
+
if (step.action === "assert_text" && step.name === undefined) {
|
|
957
|
+
const { locator, count, target } = await visibleRoleTarget(root, step, timeout, `assert_text ${step.role}`);
|
|
958
|
+
if (step.index === undefined && count !== 1) {
|
|
959
|
+
throw new JevBrowserError(`assert_text ${step.role} must match exactly one accessible control; found ${count}`);
|
|
960
|
+
}
|
|
961
|
+
const control = step.index === undefined ? locator : target;
|
|
962
|
+
const actual = (await control.innerText({ timeout })).replace(/\s+/g, " ").trim();
|
|
963
|
+
const expected = step.expected.replace(/\s+/g, " ").trim();
|
|
964
|
+
if (!actual.toLocaleLowerCase().includes(expected.toLocaleLowerCase())) {
|
|
965
|
+
throw new JevBrowserError(`assert_text failed for ${step.role}`);
|
|
966
|
+
}
|
|
967
|
+
return { action: step.action, asserted: true };
|
|
968
|
+
}
|
|
969
|
+
const control = await namedTarget(root, step.role, step.name, snapshot, secrets, { ...step, timeoutMs: timeout });
|
|
739
970
|
if (step.action === "click") {
|
|
740
971
|
if (!step.expectDownload) {
|
|
741
972
|
await control.click({ timeout });
|
|
@@ -756,9 +987,16 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
756
987
|
name: step.name,
|
|
757
988
|
downloaded_file: await saveDownload(download, settings, downloadLedger),
|
|
758
989
|
};
|
|
759
|
-
}
|
|
760
|
-
if (step.action === "type") {
|
|
761
|
-
|
|
990
|
+
}
|
|
991
|
+
if (step.action === "type") {
|
|
992
|
+
if (step.mode === "keys") {
|
|
993
|
+
await control.fill("", { timeout });
|
|
994
|
+
await control.pressSequentially(step.text, { delay: settings.browser.keyDelayMs, timeout });
|
|
995
|
+
if (step.blur) await control.press("Tab", { timeout });
|
|
996
|
+
} else {
|
|
997
|
+
await control.fill(step.text, { timeout });
|
|
998
|
+
if (step.blur) await control.press("Tab", { timeout });
|
|
999
|
+
}
|
|
762
1000
|
return { action: step.action, role: step.role, name: step.name };
|
|
763
1001
|
}
|
|
764
1002
|
if (step.action === "select_option") {
|
|
@@ -789,11 +1027,12 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
789
1027
|
throw new JevBrowserError("unsupported browser action");
|
|
790
1028
|
}
|
|
791
1029
|
|
|
792
|
-
function flowTimings(started, browserSessionMs, navigationMs, initialSnapshotMs, jevDecisionMs, browserPlanMs) {
|
|
793
|
-
return {
|
|
794
|
-
browser_session_ms: Math.round(browserSessionMs * 10) / 10,
|
|
795
|
-
navigation_ms: Math.round(navigationMs * 10) / 10,
|
|
796
|
-
initial_snapshot_ms: Math.round(initialSnapshotMs * 10) / 10,
|
|
1030
|
+
function flowTimings(started, browserSessionMs, navigationMs, initialSnapshotMs, readyMs, jevDecisionMs, browserPlanMs) {
|
|
1031
|
+
return {
|
|
1032
|
+
browser_session_ms: Math.round(browserSessionMs * 10) / 10,
|
|
1033
|
+
navigation_ms: Math.round(navigationMs * 10) / 10,
|
|
1034
|
+
initial_snapshot_ms: Math.round(initialSnapshotMs * 10) / 10,
|
|
1035
|
+
ready_ms: Math.round(readyMs * 10) / 10,
|
|
797
1036
|
jev_decision_ms: Math.round(jevDecisionMs * 10) / 10,
|
|
798
1037
|
browser_plan_ms: Math.round(browserPlanMs * 10) / 10,
|
|
799
1038
|
total_ms: Math.round((performance.now() - started) * 10) / 10,
|
|
@@ -825,10 +1064,11 @@ function sanitizeDiagnosticText(value, secrets, limit) {
|
|
|
825
1064
|
return result.slice(0, limit);
|
|
826
1065
|
}
|
|
827
1066
|
|
|
828
|
-
function createDiagnostics(page, options, settings, secrets) {
|
|
829
|
-
const diagnostics = { console_errors: [], network_failures: [] };
|
|
830
|
-
const
|
|
831
|
-
const
|
|
1067
|
+
function createDiagnostics(page, options, settings, secrets) {
|
|
1068
|
+
const diagnostics = { console_errors: [], network_failures: [] };
|
|
1069
|
+
const pendingBodies = [];
|
|
1070
|
+
const limit = settings.jev.maxDiagnosticItems;
|
|
1071
|
+
const messageLimit = settings.jev.maxDiagnosticChars;
|
|
832
1072
|
const onConsole = (message) => {
|
|
833
1073
|
if (message.type() !== "error") return;
|
|
834
1074
|
addDiagnostic(diagnostics.console_errors, sanitizeDiagnosticText(message.text(), secrets, messageLimit), limit);
|
|
@@ -836,14 +1076,41 @@ function createDiagnostics(page, options, settings, secrets) {
|
|
|
836
1076
|
const onPageError = (error) => {
|
|
837
1077
|
addDiagnostic(diagnostics.console_errors, sanitizeDiagnosticText(error.message || error.name, secrets, messageLimit), limit);
|
|
838
1078
|
};
|
|
839
|
-
const
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
1079
|
+
const addResponseMessage = async (response, entry) => {
|
|
1080
|
+
try {
|
|
1081
|
+
const responseUrl = new URL(response.url());
|
|
1082
|
+
const pageUrl = new URL(page.url());
|
|
1083
|
+
if (responseUrl.origin !== pageUrl.origin) return;
|
|
1084
|
+
const headers = await response.headers();
|
|
1085
|
+
const contentType = String(headers["content-type"] || "").toLowerCase();
|
|
1086
|
+
const contentLength = Number(headers["content-length"]);
|
|
1087
|
+
if (!contentType.includes("application/json") || !Number.isSafeInteger(contentLength)
|
|
1088
|
+
|| contentLength < 0 || contentLength > settings.browser.maxNetworkErrorBodyBytes) return;
|
|
1089
|
+
const body = await withTimeout(
|
|
1090
|
+
response.body(),
|
|
1091
|
+
settings.browser.actionTimeoutMs,
|
|
1092
|
+
"error response body read timed out",
|
|
1093
|
+
);
|
|
1094
|
+
if (body.length > settings.browser.maxNetworkErrorBodyBytes) return;
|
|
1095
|
+
const payload = JSON.parse(body.toString("utf8"));
|
|
1096
|
+
if (!isRecord(payload) || typeof payload.message !== "string") return;
|
|
1097
|
+
entry.message = sanitizeDiagnosticText(payload.message, secrets, settings.browser.maxNetworkErrorMessageChars);
|
|
1098
|
+
} catch {
|
|
1099
|
+
// A diagnostic body is optional; malformed, oversized, or late bodies do not fail the flow.
|
|
1100
|
+
}
|
|
1101
|
+
};
|
|
1102
|
+
const onResponse = (response) => {
|
|
1103
|
+
if (response.status() < 400) return;
|
|
1104
|
+
const request = response.request();
|
|
1105
|
+
const failure = {
|
|
1106
|
+
url: evidenceUrl(response.url(), secrets, messageLimit),
|
|
1107
|
+
method: request.method(),
|
|
1108
|
+
status: response.status(),
|
|
1109
|
+
};
|
|
1110
|
+
addDiagnostic(diagnostics.network_failures, failure, limit);
|
|
1111
|
+
if (options.captureNetworkErrorBodies && diagnostics.network_failures.includes(failure)) {
|
|
1112
|
+
pendingBodies.push(addResponseMessage(response, failure));
|
|
1113
|
+
}
|
|
847
1114
|
};
|
|
848
1115
|
const onRequestFailed = (request) => {
|
|
849
1116
|
addDiagnostic(diagnostics.network_failures, {
|
|
@@ -856,20 +1123,41 @@ function createDiagnostics(page, options, settings, secrets) {
|
|
|
856
1123
|
page.on("console", onConsole);
|
|
857
1124
|
page.on("pageerror", onPageError);
|
|
858
1125
|
}
|
|
859
|
-
if (options.captureNetworkErrors) {
|
|
860
|
-
page.on("response", onResponse);
|
|
861
|
-
|
|
1126
|
+
if (options.captureNetworkErrors || options.captureNetworkErrorBodies) {
|
|
1127
|
+
page.on("response", onResponse);
|
|
1128
|
+
}
|
|
1129
|
+
if (options.captureNetworkErrors) {
|
|
1130
|
+
page.on("requestfailed", onRequestFailed);
|
|
862
1131
|
}
|
|
863
1132
|
return {
|
|
864
1133
|
diagnostics,
|
|
865
|
-
dispose() {
|
|
1134
|
+
async dispose() {
|
|
866
1135
|
page.off("console", onConsole);
|
|
867
1136
|
page.off("pageerror", onPageError);
|
|
868
|
-
page.off("response", onResponse);
|
|
869
|
-
page.off("requestfailed", onRequestFailed);
|
|
1137
|
+
page.off("response", onResponse);
|
|
1138
|
+
page.off("requestfailed", onRequestFailed);
|
|
1139
|
+
await Promise.allSettled(pendingBodies);
|
|
870
1140
|
},
|
|
871
|
-
};
|
|
872
|
-
}
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function stepFailureText(error, secrets, limit) {
|
|
1145
|
+
const value = error?.message || error?.name || "BrowserError";
|
|
1146
|
+
const timeout = String(value).match(/Timeout\s+\d+ms exceeded\./i);
|
|
1147
|
+
return sanitizeDiagnosticText(timeout ? timeout[0] : String(value).split(/\r?\n/, 1)[0], secrets, limit);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function failedStepDetails(step, index, error, settings, secrets) {
|
|
1151
|
+
const details = {
|
|
1152
|
+
index,
|
|
1153
|
+
action: step.action,
|
|
1154
|
+
};
|
|
1155
|
+
if (step.role !== undefined) details.role = step.role;
|
|
1156
|
+
if (step.name !== undefined) details.name = redact(step.name, secrets);
|
|
1157
|
+
details.timeout_ms = step.timeoutMs ?? settings.browser.actionTimeoutMs;
|
|
1158
|
+
details.error = stepFailureText(error?.stepCause || error, secrets, settings.jev.maxDiagnosticChars);
|
|
1159
|
+
return details;
|
|
1160
|
+
}
|
|
873
1161
|
|
|
874
1162
|
function trackerUrl(url, settings) {
|
|
875
1163
|
let hostname;
|
|
@@ -927,7 +1215,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
927
1215
|
const url = validateUrl(initialUrl);
|
|
928
1216
|
const plans = validateCandidatePlans(candidatePlans, settings);
|
|
929
1217
|
const options = resolveFlowOptions(rawOptions, settings);
|
|
930
|
-
const secrets = planSecrets(plans)
|
|
1218
|
+
const secrets = [...planSecrets(plans), settings.env?.[settings.jev.credentialEnv]].filter(Boolean)
|
|
1219
|
+
.sort((left, right) => right.length - left.length);
|
|
931
1220
|
const started = performance.now();
|
|
932
1221
|
const browser = await browserPool.getPage();
|
|
933
1222
|
const browserSessionMs = browser.sessionMs;
|
|
@@ -942,11 +1231,12 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
942
1231
|
jev_decisions: 0,
|
|
943
1232
|
steps: [],
|
|
944
1233
|
downloaded_files: [],
|
|
945
|
-
accessibility_audits: [],
|
|
946
|
-
|
|
947
|
-
|
|
1234
|
+
accessibility_audits: [],
|
|
1235
|
+
warnings: [],
|
|
1236
|
+
final_snapshot: "",
|
|
1237
|
+
};
|
|
948
1238
|
const downloadLedger = { count: 0, bytes: 0 };
|
|
949
|
-
const timings = { navigationMs: 0, initialSnapshotMs: 0, decisionMs: 0, browserPlanMs: 0 };
|
|
1239
|
+
const timings = { navigationMs: 0, initialSnapshotMs: 0, readyMs: 0, decisionMs: 0, browserPlanMs: 0 };
|
|
950
1240
|
let unroute;
|
|
951
1241
|
let tracingStarted = false;
|
|
952
1242
|
let tracePath;
|
|
@@ -964,12 +1254,37 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
964
1254
|
}
|
|
965
1255
|
}
|
|
966
1256
|
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1257
|
+
let reusedPage = false;
|
|
1258
|
+
if (options.reusePage) {
|
|
1259
|
+
const currentUrl = page.url();
|
|
1260
|
+
if (currentUrl && currentUrl !== "about:blank") {
|
|
1261
|
+
let currentOrigin;
|
|
1262
|
+
try {
|
|
1263
|
+
currentOrigin = new URL(currentUrl).origin;
|
|
1264
|
+
} catch {
|
|
1265
|
+
currentOrigin = "";
|
|
1266
|
+
}
|
|
1267
|
+
if (currentOrigin !== new URL(url).origin) {
|
|
1268
|
+
throw new JevBrowserError("reuse_page requires the current page to be on the same origin as initial_url");
|
|
1269
|
+
}
|
|
1270
|
+
reusedPage = true;
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
if (!reusedPage) {
|
|
1274
|
+
const navigationStarted = performance.now();
|
|
1275
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: settings.browser.actionTimeoutMs * 4 });
|
|
1276
|
+
timings.navigationMs = performance.now() - navigationStarted;
|
|
1277
|
+
}
|
|
1278
|
+
const snapshotStarted = performance.now();
|
|
1279
|
+
try {
|
|
1280
|
+
const readiness = await waitForPageReady(page, options, settings, secrets);
|
|
1281
|
+
result.final_snapshot = readiness.snapshot;
|
|
1282
|
+
timings.readyMs = readiness.readyMs;
|
|
1283
|
+
} catch (error) {
|
|
1284
|
+
timings.readyMs = error.readyMs || performance.now() - snapshotStarted;
|
|
1285
|
+
throw error;
|
|
1286
|
+
}
|
|
1287
|
+
timings.initialSnapshotMs = performance.now() - snapshotStarted;
|
|
973
1288
|
|
|
974
1289
|
const initialVisible = containsPhrase(result.final_snapshot, outcome);
|
|
975
1290
|
const allSteps = Object.values(plans).flatMap(({ steps }) => steps);
|
|
@@ -1018,16 +1333,17 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
1018
1333
|
|
|
1019
1334
|
if (planId) {
|
|
1020
1335
|
const plan = plans[planId];
|
|
1021
|
-
const trace = [];
|
|
1022
|
-
let failure = "";
|
|
1023
|
-
let
|
|
1336
|
+
const trace = [];
|
|
1337
|
+
let failure = "";
|
|
1338
|
+
let failedStep;
|
|
1339
|
+
let snapshot = result.final_snapshot;
|
|
1024
1340
|
const planStarted = performance.now();
|
|
1025
1341
|
const runAllSteps = plan.steps.some(({ action, expectDownload }) => action.startsWith("assert_")
|
|
1026
1342
|
|| ["upload_file", "select_option", "hover", "like_comment", "unlike_comment", "audit_accessibility"].includes(action)
|
|
1027
1343
|
|| expectDownload === true);
|
|
1028
1344
|
for (const [index, step] of plan.steps.entries()) {
|
|
1029
1345
|
try {
|
|
1030
|
-
const traceStep = await executeStep(page, step, settings, snapshot, secrets, downloadLedger);
|
|
1346
|
+
const traceStep = await executeStep(page, step, settings, snapshot, secrets, downloadLedger);
|
|
1031
1347
|
traceStep.step = String(index + 1);
|
|
1032
1348
|
trace.push(traceStep);
|
|
1033
1349
|
if (traceStep.downloaded_file) result.downloaded_files.push(traceStep.downloaded_file);
|
|
@@ -1037,14 +1353,15 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
1037
1353
|
failure = `accessibility audit reported ${traceStep.accessibility_audit.violations_count} violation(s)`;
|
|
1038
1354
|
}
|
|
1039
1355
|
}
|
|
1040
|
-
snapshot = await
|
|
1356
|
+
snapshot = await snapshotAfterStep(page, settings, secrets, options.snapshotScope, snapshot, index + 1, result.warnings);
|
|
1041
1357
|
if (failure) break;
|
|
1042
1358
|
if (!runAllSteps && containsPhrase(snapshot, outcome)) break;
|
|
1043
|
-
} catch (error) {
|
|
1044
|
-
failure = error instanceof JevBrowserError
|
|
1045
|
-
? error.message
|
|
1046
|
-
: `Playwright action failed (${error?.name || "BrowserError"})`;
|
|
1047
|
-
|
|
1359
|
+
} catch (error) {
|
|
1360
|
+
failure = error instanceof JevBrowserError
|
|
1361
|
+
? error.message
|
|
1362
|
+
: `Playwright action failed (${error?.name || "BrowserError"})`;
|
|
1363
|
+
failedStep = failedStepDetails(step, index, error, settings, secrets);
|
|
1364
|
+
break;
|
|
1048
1365
|
}
|
|
1049
1366
|
}
|
|
1050
1367
|
timings.browserPlanMs = performance.now() - planStarted;
|
|
@@ -1063,9 +1380,12 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
1063
1380
|
result.acceptance_criteria_met = !failure && (visible || assertionsPassed || auditsPassed || downloadsPassed);
|
|
1064
1381
|
result.selected_plan = planId;
|
|
1065
1382
|
result.steps_executed = trace.length;
|
|
1066
|
-
result.steps = trace;
|
|
1067
|
-
result.final_snapshot = snapshot;
|
|
1068
|
-
if (failure)
|
|
1383
|
+
result.steps = trace;
|
|
1384
|
+
result.final_snapshot = snapshot;
|
|
1385
|
+
if (failure) {
|
|
1386
|
+
result.reason = failure;
|
|
1387
|
+
result.failed_step = failedStep;
|
|
1388
|
+
}
|
|
1069
1389
|
if (assertionCount > 0) result.assertions_passed = assertionsPassed;
|
|
1070
1390
|
if (auditCount > 0) result.accessibility_audit_passed = auditsPassed;
|
|
1071
1391
|
if (decision) {
|
|
@@ -1080,7 +1400,7 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
1080
1400
|
? error.message
|
|
1081
1401
|
: `Playwright flow failed (${error?.name || "BrowserError"})`;
|
|
1082
1402
|
try {
|
|
1083
|
-
result.final_snapshot = await snapshotPage(page, settings, secrets, options.snapshotScope);
|
|
1403
|
+
result.final_snapshot = await snapshotPage(page, settings, secrets, options.snapshotScope, { allowEmpty: true });
|
|
1084
1404
|
result.expected_outcome_visible = containsPhrase(result.final_snapshot, outcome);
|
|
1085
1405
|
} catch {
|
|
1086
1406
|
// Keep the last accessible snapshot when the page is closing or unavailable.
|
|
@@ -1112,13 +1432,13 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
1112
1432
|
} catch {
|
|
1113
1433
|
artifactErrors.push("tracker blocking cleanup failed");
|
|
1114
1434
|
}
|
|
1115
|
-
capture.dispose();
|
|
1435
|
+
await capture.dispose();
|
|
1116
1436
|
}
|
|
1117
1437
|
if (tracePath) result.trace_path = tracePath;
|
|
1118
1438
|
if (artifactErrors.length) result.artifact_errors = artifactErrors;
|
|
1119
1439
|
result.console_errors = capture.diagnostics.console_errors;
|
|
1120
1440
|
result.network_failures = capture.diagnostics.network_failures;
|
|
1121
|
-
result.timings_ms = flowTimings(started, browserSessionMs, timings.navigationMs, timings.initialSnapshotMs, timings.decisionMs, timings.browserPlanMs);
|
|
1441
|
+
result.timings_ms = flowTimings(started, browserSessionMs, timings.navigationMs, timings.initialSnapshotMs, timings.readyMs, timings.decisionMs, timings.browserPlanMs);
|
|
1122
1442
|
return redactObject(result, secrets);
|
|
1123
1443
|
}
|
|
1124
1444
|
|