@diegosouzacdv/jev-browser-mcp 0.7.0 → 0.7.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 +90 -27
- package/docs/jev-browser-mcp.md +90 -27
- package/mcp_servers/jev-browser-npm/src/flow.mjs +575 -157
- package/mcp_servers/jev-browser-npm/src/server.mjs +146 -92
- package/package.json +1 -1
|
@@ -64,7 +64,7 @@ const FLOW_OPTION_KEYS = new Set([
|
|
|
64
64
|
"color_scheme", "geolocation", "screenshot_on_success", "record_video", "trace_on_success",
|
|
65
65
|
"continue_from_current_page", "reuse_page", "reuse_page_match", "return_snapshot", "screenshot_on_failure", "snapshot_include_hidden", "include_unnamed_controls", "snapshot_scope", "step_timeout_seconds", "stop_on_expected",
|
|
66
66
|
"trace_on_failure", "console_levels", "fresh_context", "clear_storage", "wait_for_http", "storage_state",
|
|
67
|
-
"ready",
|
|
67
|
+
"ready", "capture_network", "capture_network_url_contains",
|
|
68
68
|
]);
|
|
69
69
|
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;
|
|
70
70
|
|
|
@@ -226,10 +226,13 @@ function actionSchemas(semanticTarget) {
|
|
|
226
226
|
confirm_modal: { required: ["expected_text", "button"], optional: [...semanticTarget, "trigger", "confirm", "timeout_seconds"] },
|
|
227
227
|
check: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
|
|
228
228
|
uncheck: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
|
|
229
|
-
navigate_menu: { required: [
|
|
230
|
-
assert_network: { required: ["url_contains", "method", "status"], optional: ["message_contains", "timeout_seconds", "timeout_ms"] },
|
|
231
|
-
assert_ws: { required: ["url_contains"], optional: ["message_contains", "json", "close_code", "timeout_seconds", "timeout_ms"] },
|
|
232
|
-
wait_for_request: { required: ["url"], optional: ["method", "status", "timeout_seconds", "timeout_ms"] },
|
|
229
|
+
navigate_menu: { required: [], optional: ["path", "screen", "toggle_selector", "timeout_seconds"] },
|
|
230
|
+
assert_network: { required: ["url_contains", "method", "status"], optional: ["message_contains", "timeout_seconds", "timeout_ms"] },
|
|
231
|
+
assert_ws: { required: ["url_contains"], optional: ["message_contains", "json", "close_code", "timeout_seconds", "timeout_ms"] },
|
|
232
|
+
wait_for_request: { required: ["url"], optional: ["method", "status", "timeout_seconds", "timeout_ms"] },
|
|
233
|
+
http_request: { required: ["url"], optional: ["method", "include_body", "timeout_seconds", "timeout_ms"] },
|
|
234
|
+
inspect_cookies: { required: [], optional: ["timeout_seconds", "timeout_ms"] },
|
|
235
|
+
read_angular_state: { required: [], optional: ["timeout_seconds", "timeout_ms"] },
|
|
233
236
|
extract: { required: ["as"], optional: [...semanticTarget, "source", "attribute", "sensitive", "timeout_seconds", "timeout_ms"] },
|
|
234
237
|
evaluate: { required: ["expression"], optional: ["as", "timeout_seconds", "timeout_ms"] },
|
|
235
238
|
screenshot: { required: [], optional: ["full_page", "timeout_seconds", "timeout_ms"] },
|
|
@@ -257,21 +260,37 @@ function actionSchemas(semanticTarget) {
|
|
|
257
260
|
};
|
|
258
261
|
}
|
|
259
262
|
|
|
260
|
-
export function describeBrowserActions() {
|
|
263
|
+
export function describeBrowserActions() {
|
|
261
264
|
const semanticTarget = ["frame", "comment", "within", "index", "near", "label", "placeholder", "title", "text", "test_id", "selector", "target_text", "role", "name", "name_match", "target_page"];
|
|
262
265
|
const schemas = actionSchemas(semanticTarget);
|
|
263
|
-
return {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
266
|
+
return {
|
|
267
|
+
required_flow_fields: ["flow", "candidate_plans"],
|
|
268
|
+
candidate_plan: { required: ["description", "steps"], optional: ["comment", "when"] },
|
|
269
|
+
actions: Object.fromEntries(Object.entries(schemas).map(([action, schema]) => {
|
|
270
|
+
const contract = {
|
|
271
|
+
required: [...schema.required],
|
|
272
|
+
optional: [...new Set([...schema.optional, "timeout_ms", "mutating", "screenshot"])],
|
|
273
|
+
};
|
|
274
|
+
if (action === "extract") contract.conditional_required = { attribute: "required when source is attribute" };
|
|
275
|
+
if (action === "type") contract.conditional_required = { text: "exactly one of text or text_env is required" };
|
|
276
|
+
if (action === "navigate_menu") contract.conditional_required = { path: "exactly one of path or screen is required" };
|
|
277
|
+
if (action === "select_option") contract.conditional_required = { option: "exactly one of option, value or label_contains is required" };
|
|
278
|
+
return [action, contract];
|
|
279
|
+
})),
|
|
268
280
|
options: [...FLOW_OPTION_KEYS],
|
|
269
281
|
option_shapes: {
|
|
282
|
+
capture_network: ["errors", "all", "none"],
|
|
283
|
+
capture_network_url_contains: "optional URL substring filter for capture_network=all",
|
|
284
|
+
local_only: "required for http_request, read_angular_state and inspect_cookies; only localhost loopback is allowed",
|
|
270
285
|
ready: ["network_idle", { hidden_text: "text that must disappear" }, { selector: "CSS selector", has_value: true }],
|
|
271
286
|
return_snapshot: ["diff", "full", "none"],
|
|
272
287
|
include_unnamed_controls: "boolean; false by default",
|
|
273
288
|
reuse_page_match: ["url", "path"],
|
|
274
289
|
},
|
|
290
|
+
step_shapes: {
|
|
291
|
+
frame: ["first", { url_contains: "iframe URL substring" }, "exact iframe name or title"],
|
|
292
|
+
navigate_menu: ["path: menu label array", "screen: SAFI Acesso Rápido name or screen code"],
|
|
293
|
+
},
|
|
275
294
|
expected_outcome: {
|
|
276
295
|
text: "string description or {text: string} assertion",
|
|
277
296
|
request: "{request: 'PUT */path', status: 200, message_contains?: string}; request may include a method prefix",
|
|
@@ -285,10 +304,71 @@ export function describeBrowserActions() {
|
|
|
285
304
|
confirm_modal: { trigger: "button accessible name", confirm: "button", expected_text: "required exact safety phrase" },
|
|
286
305
|
},
|
|
287
306
|
placeholders: "Use {name}; declare params.name or extract it before use. Unknown and {{double-brace}} placeholders are rejected.",
|
|
288
|
-
};
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
export function
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function collectFlowContractIssues({ flow, candidatePlans }) {
|
|
311
|
+
const issues = [];
|
|
312
|
+
if (typeof flow !== "string" || !flow.trim()) issues.push("flow: required non-empty string");
|
|
313
|
+
if (!isRecord(candidatePlans) || !Object.keys(candidatePlans).length) {
|
|
314
|
+
issues.push("candidate_plans: required non-empty object");
|
|
315
|
+
return issues;
|
|
316
|
+
}
|
|
317
|
+
const schemas = actionSchemas(["frame", "comment", "within", "index", "near", ...LOCATOR_FIELDS, "target_text", "role", "name", "name_match", "target_page"]);
|
|
318
|
+
for (const [planId, plan] of Object.entries(candidatePlans)) {
|
|
319
|
+
if (!isRecord(plan)) {
|
|
320
|
+
issues.push(`candidate_plans.${planId}: must be an object`);
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (typeof plan.description !== "string" || !plan.description.trim()) issues.push(`candidate_plans.${planId}.description: required non-empty string`);
|
|
324
|
+
if (plan.when !== undefined && (!isRecord(plan.when) || typeof plan.when.visible !== "string" || !plan.when.visible.trim())) {
|
|
325
|
+
issues.push(`candidate_plans.${planId}.when.visible: required non-empty string when is provided`);
|
|
326
|
+
}
|
|
327
|
+
if (!Array.isArray(plan.steps) || !plan.steps.length) {
|
|
328
|
+
issues.push(`candidate_plans.${planId}.steps: required non-empty array`);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
for (const [index, step] of plan.steps.entries()) {
|
|
332
|
+
const prefix = `candidate_plans.${planId}.steps[${index}]`;
|
|
333
|
+
if (!isRecord(step) || typeof step.action !== "string") {
|
|
334
|
+
issues.push(`${prefix}.action: required supported action`);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
let normalizedStep;
|
|
338
|
+
try { normalizedStep = normalizeStepAliases(step); }
|
|
339
|
+
catch (error) {
|
|
340
|
+
issues.push(`${prefix}: ${error instanceof Error ? error.message : "invalid aliases"}`);
|
|
341
|
+
normalizedStep = step;
|
|
342
|
+
}
|
|
343
|
+
const schema = schemas[normalizedStep.action];
|
|
344
|
+
if (!schema) {
|
|
345
|
+
issues.push(`${prefix}.action: unsupported action ${JSON.stringify(normalizedStep.action)}`);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
for (const field of schema.required) {
|
|
349
|
+
if (!Object.hasOwn(normalizedStep, field)) issues.push(`${prefix}.${field}: required for ${normalizedStep.action}`);
|
|
350
|
+
}
|
|
351
|
+
if (normalizedStep.action === "extract" && normalizedStep.source === "attribute" && !Object.hasOwn(normalizedStep, "attribute")) {
|
|
352
|
+
issues.push(`${prefix}.attribute: required when extract.source is attribute`);
|
|
353
|
+
}
|
|
354
|
+
if (normalizedStep.action === "type"
|
|
355
|
+
&& (Object.hasOwn(normalizedStep, "text") === Object.hasOwn(normalizedStep, "text_env"))) {
|
|
356
|
+
issues.push(`${prefix}: exactly one of text or text_env is required for type`);
|
|
357
|
+
}
|
|
358
|
+
if (normalizedStep.action === "navigate_menu"
|
|
359
|
+
&& (Object.hasOwn(normalizedStep, "path") === Object.hasOwn(normalizedStep, "screen"))) {
|
|
360
|
+
issues.push(`${prefix}: exactly one of path or screen is required for navigate_menu`);
|
|
361
|
+
}
|
|
362
|
+
if (normalizedStep.action === "select_option"
|
|
363
|
+
&& ["option", "value", "label_contains"].filter((field) => Object.hasOwn(normalizedStep, field)).length !== 1) {
|
|
364
|
+
issues.push(`${prefix}: exactly one of option, value or label_contains is required for select_option`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return issues;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps = settings.browser.maxFlowSteps) {
|
|
292
372
|
if (!isRecord(candidatePlans)) throw new JevBrowserError("candidate_plans must be an object");
|
|
293
373
|
if (Object.keys(candidatePlans).length > settings.jev.maxActionCount) {
|
|
294
374
|
throw new JevBrowserError("candidate_plans exceeds the configured candidate limit");
|
|
@@ -482,12 +562,18 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
|
|
|
482
562
|
if (!step.role) step.role = "checkbox";
|
|
483
563
|
if (action === "uncheck" && step.role === "radio") throw new JevBrowserError("uncheck does not support radio controls");
|
|
484
564
|
}
|
|
485
|
-
if (action === "navigate_menu") {
|
|
486
|
-
if (
|
|
487
|
-
throw new JevBrowserError("navigate_menu
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
|
|
565
|
+
if (action === "navigate_menu") {
|
|
566
|
+
if ((rawStep.path === undefined) === (rawStep.screen === undefined)) {
|
|
567
|
+
throw new JevBrowserError("navigate_menu needs exactly one of path or screen");
|
|
568
|
+
}
|
|
569
|
+
if (rawStep.path !== undefined) {
|
|
570
|
+
if (!Array.isArray(rawStep.path) || rawStep.path.length === 0 || rawStep.path.length > 8) {
|
|
571
|
+
throw new JevBrowserError("navigate_menu path must contain between 1 and 8 menu labels");
|
|
572
|
+
}
|
|
573
|
+
step.path = rawStep.path.map((label) => requiredText(label, "menu path label", settings.jev.maxActionDescriptionChars));
|
|
574
|
+
}
|
|
575
|
+
if (rawStep.screen !== undefined) step.screen = requiredText(rawStep.screen, "quick access screen name or code", settings.jev.maxActionDescriptionChars);
|
|
576
|
+
step.toggleSelector = rawStep.toggle_selector === undefined
|
|
491
577
|
? "#open_btn"
|
|
492
578
|
: requiredText(rawStep.toggle_selector, "toggle_selector", settings.jev.maxActionDescriptionChars);
|
|
493
579
|
}
|
|
@@ -577,15 +663,24 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
|
|
|
577
663
|
if (!step.messageContains && !step.json && rawStep.close_code === undefined) throw new JevBrowserError("assert_ws needs message_contains, json, or close_code");
|
|
578
664
|
step.closeCode = rawStep.close_code;
|
|
579
665
|
}
|
|
580
|
-
if (action === "wait_for_request") {
|
|
581
|
-
step.urlPattern = requiredText(rawStep.url, "wait_for_request url", settings.jev.maxActionDescriptionChars);
|
|
666
|
+
if (action === "wait_for_request") {
|
|
667
|
+
step.urlPattern = requiredText(rawStep.url, "wait_for_request url", settings.jev.maxActionDescriptionChars);
|
|
582
668
|
step.method = rawStep.method === undefined ? undefined : requiredText(rawStep.method, "request method", 16).toUpperCase();
|
|
583
669
|
if (step.method && !/^[A-Z]+$/.test(step.method)) throw new JevBrowserError("wait_for_request method must contain letters only");
|
|
584
670
|
if (rawStep.status !== undefined && (!Number.isSafeInteger(rawStep.status) || rawStep.status < 100 || rawStep.status > 599)) {
|
|
585
671
|
throw new JevBrowserError("wait_for_request status must be an HTTP status from 100 to 599");
|
|
586
|
-
}
|
|
587
|
-
step.status = rawStep.status;
|
|
588
|
-
}
|
|
672
|
+
}
|
|
673
|
+
step.status = rawStep.status;
|
|
674
|
+
}
|
|
675
|
+
if (action === "http_request") {
|
|
676
|
+
step.url = requiredText(rawStep.url, "http_request URL", settings.jev.maxActionDescriptionChars);
|
|
677
|
+
step.method = rawStep.method === undefined ? "GET" : requiredText(rawStep.method, "http_request method", 16).toUpperCase();
|
|
678
|
+
if (step.method !== "GET") throw new JevBrowserError("http_request supports GET only");
|
|
679
|
+
if (rawStep.include_body !== undefined && typeof rawStep.include_body !== "boolean") {
|
|
680
|
+
throw new JevBrowserError("http_request include_body must be a boolean");
|
|
681
|
+
}
|
|
682
|
+
step.includeBody = rawStep.include_body ?? false;
|
|
683
|
+
}
|
|
589
684
|
if (action === "evaluate") {
|
|
590
685
|
step.expression = requiredText(rawStep.expression, "read-only expression", 256);
|
|
591
686
|
if (!/^(?:window|document|navigator|location)(?:\.[A-Za-z_$][A-Za-z0-9_$]*){1,8}$/.test(step.expression)) {
|
|
@@ -744,7 +839,13 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
|
|
|
744
839
|
throw new JevBrowserError(`${action} labels must distinguish liked and unliked state`);
|
|
745
840
|
}
|
|
746
841
|
}
|
|
747
|
-
if (rawStep.frame !== undefined)
|
|
842
|
+
if (rawStep.frame !== undefined) {
|
|
843
|
+
if (rawStep.frame === "first") step.frame = "first";
|
|
844
|
+
else if (isRecord(rawStep.frame)) {
|
|
845
|
+
exactKeys(rawStep.frame, ["url_contains"], [], "frame");
|
|
846
|
+
step.frame = { urlContains: requiredText(rawStep.frame.url_contains, "frame URL substring", settings.jev.maxActionDescriptionChars) };
|
|
847
|
+
} else step.frame = requiredText(rawStep.frame, "frame name", settings.jev.maxActionDescriptionChars);
|
|
848
|
+
}
|
|
748
849
|
return step;
|
|
749
850
|
});
|
|
750
851
|
plans[id] = { description, steps, ...(rawPlan.when ? { when: { visible: rawPlan.when.visible } } : {}) };
|
|
@@ -958,10 +1059,18 @@ function resolveFlowOptions(rawOptions, settings) {
|
|
|
958
1059
|
if (storageState !== undefined && !/^[A-Za-z0-9_-]+$/.test(storageState)) {
|
|
959
1060
|
throw new JevBrowserError("options.storage_state must be a simple account name using letters, numbers, _ or -");
|
|
960
1061
|
}
|
|
961
|
-
if (storageState && (rawOptions.fresh_context || rawOptions.clear_storage)) {
|
|
962
|
-
throw new JevBrowserError("options.storage_state cannot be combined with fresh_context or clear_storage");
|
|
963
|
-
}
|
|
964
|
-
|
|
1062
|
+
if (storageState && (rawOptions.fresh_context || rawOptions.clear_storage)) {
|
|
1063
|
+
throw new JevBrowserError("options.storage_state cannot be combined with fresh_context or clear_storage");
|
|
1064
|
+
}
|
|
1065
|
+
const configuredNetworkErrors = rawOptions.capture_network_errors ?? defaults.captureNetworkErrors;
|
|
1066
|
+
const configuredNetworkErrorBodies = rawOptions.capture_network_error_bodies ?? defaults.captureNetworkErrorBodies;
|
|
1067
|
+
const captureNetwork = rawOptions.capture_network ?? (configuredNetworkErrors || configuredNetworkErrorBodies ? "errors" : "none");
|
|
1068
|
+
if (!new Set(["none", "errors", "all"]).has(captureNetwork)) {
|
|
1069
|
+
throw new JevBrowserError("options.capture_network must be none, errors, or all");
|
|
1070
|
+
}
|
|
1071
|
+
const captureNetworkUrlContains = rawOptions.capture_network_url_contains === undefined
|
|
1072
|
+
? undefined : requiredText(rawOptions.capture_network_url_contains, "capture_network_url_contains", settings.jev.maxActionDescriptionChars);
|
|
1073
|
+
let reportPath;
|
|
965
1074
|
if (rawOptions.report_path !== undefined) {
|
|
966
1075
|
if (typeof rawOptions.report_path !== "string" || !path.isAbsolute(rawOptions.report_path)) {
|
|
967
1076
|
throw new JevBrowserError("options.report_path must be an absolute path inside the configured artifact directory");
|
|
@@ -977,8 +1086,10 @@ function resolveFlowOptions(rawOptions, settings) {
|
|
|
977
1086
|
blockFonts: rawOptions.block_fonts ?? defaults.blockFonts,
|
|
978
1087
|
busySelectors: Object.freeze(busySelectors),
|
|
979
1088
|
captureConsoleErrors: rawOptions.capture_console_errors ?? defaults.captureConsoleErrors,
|
|
980
|
-
captureNetworkErrorBodies:
|
|
981
|
-
captureNetworkErrors:
|
|
1089
|
+
captureNetworkErrorBodies: captureNetwork !== "none" && configuredNetworkErrorBodies,
|
|
1090
|
+
captureNetworkErrors: captureNetwork === "errors" || captureNetwork === "all",
|
|
1091
|
+
captureNetwork,
|
|
1092
|
+
captureNetworkUrlContains,
|
|
982
1093
|
dryRun: rawOptions.dry_run ?? false,
|
|
983
1094
|
fastPath: rawOptions.fast_path ?? defaults.fastPath,
|
|
984
1095
|
localOnly: rawOptions.local_only ?? defaults.localOnly,
|
|
@@ -1145,9 +1256,36 @@ function providerSafeSnapshot(snapshot, secrets) {
|
|
|
1145
1256
|
});
|
|
1146
1257
|
}
|
|
1147
1258
|
|
|
1148
|
-
function localHostOnly(url) {
|
|
1149
|
-
return new Set(["localhost", "127.0.0.1", "::1", "[::1]"]).has(new URL(url).hostname.toLowerCase());
|
|
1150
|
-
}
|
|
1259
|
+
function localHostOnly(url) {
|
|
1260
|
+
return new Set(["localhost", "127.0.0.1", "::1", "[::1]"]).has(new URL(url).hostname.toLowerCase());
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
const SAFE_RESPONSE_HEADER_NAMES = new Set([
|
|
1264
|
+
"cache-control", "content-length", "content-type", "date", "etag", "last-modified", "retry-after", "vary",
|
|
1265
|
+
]);
|
|
1266
|
+
|
|
1267
|
+
function safeResponseHeaders(headers) {
|
|
1268
|
+
return Object.fromEntries(Object.entries(headers || {})
|
|
1269
|
+
.filter(([name, value]) => SAFE_RESPONSE_HEADER_NAMES.has(name.toLowerCase()) && typeof value === "string")
|
|
1270
|
+
.map(([name, value]) => [name.toLowerCase(), value.slice(0, 256)]));
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function assertLocalDebugPage(page, localOnly, action) {
|
|
1274
|
+
const currentUrl = page.url();
|
|
1275
|
+
if (!localOnly || !currentUrl || currentUrl === "about:blank" || !localHostOnly(currentUrl)) {
|
|
1276
|
+
throw new JevBrowserError(`${action} requires options.local_only: true and a localhost page`);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function redactLocalPayload(value) {
|
|
1281
|
+
if (Array.isArray(value)) return value.map(redactLocalPayload);
|
|
1282
|
+
if (!isRecord(value)) return value;
|
|
1283
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
1284
|
+
key,
|
|
1285
|
+
/(?:auth|cookie|credential|secret|password|token|session|user|usuario|nome|email|cpf|cnpj|matricula|identity)/i.test(key)
|
|
1286
|
+
? "[redacted]" : redactLocalPayload(item),
|
|
1287
|
+
]));
|
|
1288
|
+
}
|
|
1151
1289
|
|
|
1152
1290
|
async function resolveFakeMediaPath(fakeMedia, settings) {
|
|
1153
1291
|
if (!fakeMedia) return undefined;
|
|
@@ -1665,12 +1803,17 @@ async function evaluateExpectedOutcome(expected, snapshot, networkTracker, marke
|
|
|
1665
1803
|
return checks;
|
|
1666
1804
|
}
|
|
1667
1805
|
|
|
1668
|
-
function createNetworkTracker(page, settings, loginUrlContains = []) {
|
|
1669
|
-
const active = new Map();
|
|
1670
|
-
const history = [];
|
|
1806
|
+
function createNetworkTracker(page, settings, loginUrlContains = [], watchPatterns = []) {
|
|
1807
|
+
const active = new Map();
|
|
1808
|
+
const history = [];
|
|
1809
|
+
const watchedHistory = [];
|
|
1671
1810
|
const websocketHistory = [];
|
|
1672
1811
|
const websocketUrls = new Map();
|
|
1673
1812
|
const websocketObserverPromises = new Set();
|
|
1813
|
+
const blockedCookieEvents = [];
|
|
1814
|
+
const cdpRequestUrls = new Map();
|
|
1815
|
+
const cdpSessions = new Set();
|
|
1816
|
+
const pendingHeaderReads = new Set();
|
|
1674
1817
|
const ignored = new Set();
|
|
1675
1818
|
let sequence = 0;
|
|
1676
1819
|
const shouldIgnore = (request) => {
|
|
@@ -1684,21 +1827,41 @@ function createNetworkTracker(page, settings, loginUrlContains = []) {
|
|
|
1684
1827
|
return undefined;
|
|
1685
1828
|
}
|
|
1686
1829
|
if (ignored.has(request)) return undefined;
|
|
1687
|
-
const
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
if (
|
|
1830
|
+
const startedAt = active.get(request)?.at;
|
|
1831
|
+
const entry = {
|
|
1832
|
+
sequence: ++sequence, type, url: request.url(), method: request.method(),
|
|
1833
|
+
resourceType: request.resourceType(), at: performance.now(), request,
|
|
1834
|
+
};
|
|
1835
|
+
if (startedAt !== undefined && type !== "request") entry.durationMs = Math.max(0, entry.at - startedAt);
|
|
1836
|
+
if (type === "request") active.set(request, entry);
|
|
1837
|
+
else if (type === "finished" || type === "failed") {
|
|
1838
|
+
const original = active.get(request);
|
|
1839
|
+
if (original && entry.durationMs !== undefined) original.durationMs = entry.durationMs;
|
|
1840
|
+
active.delete(request);
|
|
1841
|
+
}
|
|
1842
|
+
history.push(entry);
|
|
1843
|
+
const historyLimit = settings.browser.maxNetworkHistoryEvents;
|
|
1844
|
+
if (history.length > historyLimit) history.splice(0, history.length - historyLimit);
|
|
1845
|
+
if (watchPatterns.some((pattern) => pattern.includes("*")
|
|
1846
|
+
? urlPatternMatcher(pattern).test(entry.url) : entry.url.includes(pattern))) {
|
|
1847
|
+
watchedHistory.push(entry);
|
|
1848
|
+
if (watchedHistory.length > historyLimit) watchedHistory.splice(0, watchedHistory.length - historyLimit);
|
|
1849
|
+
}
|
|
1693
1850
|
return entry;
|
|
1694
1851
|
};
|
|
1695
1852
|
const onRequest = (request) => record("request", request);
|
|
1696
|
-
const onResponse = (response) => {
|
|
1697
|
-
const entry = record("response", response.request());
|
|
1698
|
-
if (!entry) return;
|
|
1699
|
-
entry.response = response;
|
|
1700
|
-
entry.status = response.status();
|
|
1701
|
-
|
|
1853
|
+
const onResponse = (response) => {
|
|
1854
|
+
const entry = record("response", response.request());
|
|
1855
|
+
if (!entry) return;
|
|
1856
|
+
entry.response = response;
|
|
1857
|
+
entry.status = response.status();
|
|
1858
|
+
const requestEntry = active.get(response.request());
|
|
1859
|
+
if (requestEntry && entry.durationMs !== undefined) requestEntry.durationMs = entry.durationMs;
|
|
1860
|
+
const headerRead = Promise.resolve().then(() => response.allHeaders())
|
|
1861
|
+
.then((headers) => { entry.responseHeaders = safeResponseHeaders(headers); }).catch(() => {});
|
|
1862
|
+
pendingHeaderReads.add(headerRead);
|
|
1863
|
+
void headerRead.finally(() => pendingHeaderReads.delete(headerRead));
|
|
1864
|
+
};
|
|
1702
1865
|
const onFinished = (request) => record("finished", request);
|
|
1703
1866
|
const onFailed = (request) => record("failed", request);
|
|
1704
1867
|
const trackedPages = new Set();
|
|
@@ -1717,9 +1880,41 @@ function createNetworkTracker(page, settings, loginUrlContains = []) {
|
|
|
1717
1880
|
};
|
|
1718
1881
|
const attachCdpWebSocketObserver = async (trackedPage) => {
|
|
1719
1882
|
try {
|
|
1720
|
-
const session = await trackedPage.context().newCDPSession(trackedPage);
|
|
1721
|
-
|
|
1722
|
-
session.
|
|
1883
|
+
const session = await trackedPage.context().newCDPSession(trackedPage);
|
|
1884
|
+
cdpSessions.add(session);
|
|
1885
|
+
await session.send("Network.enable");
|
|
1886
|
+
session.on("Network.requestWillBeSent", ({ requestId, request }) => {
|
|
1887
|
+
if (request?.url) {
|
|
1888
|
+
cdpRequestUrls.set(requestId, request.url);
|
|
1889
|
+
while (cdpRequestUrls.size > settings.browser.maxNetworkHistoryEvents) {
|
|
1890
|
+
cdpRequestUrls.delete(cdpRequestUrls.keys().next().value);
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
});
|
|
1894
|
+
session.on("Network.responseReceivedExtraInfo", ({ requestId, statusCode, headers, blockedCookies }) => {
|
|
1895
|
+
const url = cdpRequestUrls.get(requestId);
|
|
1896
|
+
if (!url) return;
|
|
1897
|
+
const blocked = Array.isArray(blockedCookies) ? blockedCookies.map(({ cookie, blockedReasons }) => ({
|
|
1898
|
+
name: String(cookie?.name || "").slice(0, 128),
|
|
1899
|
+
domain: String(cookie?.domain || "").slice(0, 255),
|
|
1900
|
+
path: String(cookie?.path || "").slice(0, 256),
|
|
1901
|
+
secure: Boolean(cookie?.secure),
|
|
1902
|
+
http_only: Boolean(cookie?.httpOnly),
|
|
1903
|
+
same_site: cookie?.sameSite,
|
|
1904
|
+
blocked_reasons: Array.isArray(blockedReasons) ? blockedReasons.map((reason) => String(reason).slice(0, 128)) : [],
|
|
1905
|
+
})) : [];
|
|
1906
|
+
if (blocked.length) {
|
|
1907
|
+
blockedCookieEvents.push({ sequence: ++sequence, url, status: statusCode, cookies: blocked });
|
|
1908
|
+
if (blockedCookieEvents.length > settings.browser.maxNetworkHistoryEvents) {
|
|
1909
|
+
blockedCookieEvents.splice(0, blockedCookieEvents.length - settings.browser.maxNetworkHistoryEvents);
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
const safeHeaders = safeResponseHeaders(headers);
|
|
1913
|
+
const matching = [...history, ...watchedHistory].reverse().find((event) => event.type === "response" && event.url === url);
|
|
1914
|
+
if (matching && Object.keys(safeHeaders).length) matching.responseHeaders = safeHeaders;
|
|
1915
|
+
cdpRequestUrls.delete(requestId);
|
|
1916
|
+
});
|
|
1917
|
+
session.on("Network.webSocketCreated", ({ requestId, url }) => websocketUrls.set(requestId, url));
|
|
1723
1918
|
session.on("Network.webSocketFrameReceived", ({ requestId, response }) => {
|
|
1724
1919
|
if (response?.opcode !== 8 || typeof response.payloadData !== "string") return;
|
|
1725
1920
|
try {
|
|
@@ -1732,7 +1927,10 @@ function createNetworkTracker(page, settings, loginUrlContains = []) {
|
|
|
1732
1927
|
} catch { /* A non-binary close payload has no status code. */ }
|
|
1733
1928
|
});
|
|
1734
1929
|
session.on("Network.webSocketClosed", ({ requestId }) => websocketUrls.delete(requestId));
|
|
1735
|
-
trackedPage.once("close", () => {
|
|
1930
|
+
trackedPage.once("close", () => {
|
|
1931
|
+
cdpSessions.delete(session);
|
|
1932
|
+
void session.detach().catch(() => {});
|
|
1933
|
+
});
|
|
1736
1934
|
} catch {
|
|
1737
1935
|
// WebSocket close-code assertions are available only on Chromium CDP sessions.
|
|
1738
1936
|
}
|
|
@@ -1760,15 +1958,18 @@ function createNetworkTracker(page, settings, loginUrlContains = []) {
|
|
|
1760
1958
|
websocketObserverPromises.add(ready);
|
|
1761
1959
|
return ready;
|
|
1762
1960
|
};
|
|
1763
|
-
|
|
1961
|
+
const historySince = (marker) => [...new Map([...history, ...watchedHistory]
|
|
1962
|
+
.filter((entry) => entry.sequence > marker)
|
|
1963
|
+
.map((entry) => [entry.sequence, entry])).values()].sort((left, right) => left.sequence - right.sequence);
|
|
1964
|
+
attachPage(page);
|
|
1764
1965
|
return {
|
|
1765
1966
|
mark: () => sequence,
|
|
1766
1967
|
attachPage,
|
|
1767
1968
|
waitForWebSocketObservers: async () => Promise.allSettled([...websocketObserverPromises]),
|
|
1768
1969
|
since: (marker, urlContains) => history.filter((entry) => entry.sequence > marker
|
|
1769
1970
|
&& (urlContains === undefined || entry.url.includes(urlContains))),
|
|
1770
|
-
responsesSince: (marker, urlContains, method) =>
|
|
1771
|
-
|
|
1971
|
+
responsesSince: (marker, urlContains, method) => historySince(marker).filter((entry) =>
|
|
1972
|
+
entry.type === "response" && entry.url.includes(urlContains) && entry.method === method),
|
|
1772
1973
|
websocketSince: (marker, urlContains) => websocketHistory.filter((entry) => entry.sequence > marker && entry.url.includes(urlContains)),
|
|
1773
1974
|
collectWebSocketCloseCodes: async () => {
|
|
1774
1975
|
for (const trackedPage of trackedPages) {
|
|
@@ -1791,13 +1992,35 @@ function createNetworkTracker(page, settings, loginUrlContains = []) {
|
|
|
1791
1992
|
}
|
|
1792
1993
|
}
|
|
1793
1994
|
},
|
|
1794
|
-
historySince
|
|
1995
|
+
historySince,
|
|
1996
|
+
networkEventsSince: (marker, urlContains, limit = settings.jev.maxDiagnosticItems) => {
|
|
1997
|
+
const events = historySince(marker);
|
|
1998
|
+
const entries = events.filter((entry) => entry.type === "request"
|
|
1999
|
+
&& (urlContains === undefined || entry.url.includes(urlContains)));
|
|
2000
|
+
return entries.slice(-limit).map((requestEntry) => {
|
|
2001
|
+
const response = events.find((event) => event.type === "response" && event.request === requestEntry.request);
|
|
2002
|
+
const terminal = events.find((event) => (event.type === "finished" || event.type === "failed")
|
|
2003
|
+
&& event.request === requestEntry.request);
|
|
2004
|
+
return {
|
|
2005
|
+
url: requestEntry.url,
|
|
2006
|
+
method: requestEntry.method,
|
|
2007
|
+
resource_type: requestEntry.resourceType,
|
|
2008
|
+
...(response ? { status: response.status } : {}),
|
|
2009
|
+
...(requestEntry.durationMs !== undefined ? { duration_ms: Math.round(requestEntry.durationMs * 10) / 10 }
|
|
2010
|
+
: terminal?.durationMs !== undefined ? { duration_ms: Math.round(terminal.durationMs * 10) / 10 } : {}),
|
|
2011
|
+
...(response?.responseHeaders ? { response_headers: response.responseHeaders } : {}),
|
|
2012
|
+
};
|
|
2013
|
+
});
|
|
2014
|
+
},
|
|
2015
|
+
blockedCookiesSince: (marker, urlContains, limit = settings.jev.maxDiagnosticItems) => blockedCookieEvents
|
|
2016
|
+
.filter((event) => event.sequence > marker && (urlContains === undefined || event.url.includes(urlContains)))
|
|
2017
|
+
.slice(-limit),
|
|
1795
2018
|
authenticationFailureSince: (marker) => history.find((entry) => {
|
|
1796
2019
|
if (entry.sequence <= marker || entry.type !== "response" || ![401, 403].includes(entry.status)) return false;
|
|
1797
2020
|
return loginUrlContains.some((needle) => entry.url.toLocaleLowerCase().includes(needle.toLocaleLowerCase()));
|
|
1798
2021
|
}),
|
|
1799
2022
|
activeFor: (urlContains) => [...active.values()].filter((entry) => entry.url.includes(urlContains)),
|
|
1800
|
-
dispose: () => {
|
|
2023
|
+
dispose: async () => {
|
|
1801
2024
|
for (const trackedPage of trackedPages) {
|
|
1802
2025
|
trackedPage.off("request", onRequest);
|
|
1803
2026
|
trackedPage.off("response", onResponse);
|
|
@@ -1805,11 +2028,14 @@ function createNetworkTracker(page, settings, loginUrlContains = []) {
|
|
|
1805
2028
|
trackedPage.off("requestfailed", onFailed);
|
|
1806
2029
|
trackedPage.off("websocket", onWebSocket);
|
|
1807
2030
|
}
|
|
1808
|
-
trackedPages.clear();
|
|
1809
|
-
active.clear();
|
|
1810
|
-
ignored.clear();
|
|
1811
|
-
|
|
1812
|
-
|
|
2031
|
+
trackedPages.clear();
|
|
2032
|
+
active.clear();
|
|
2033
|
+
ignored.clear();
|
|
2034
|
+
await Promise.allSettled([...pendingHeaderReads]);
|
|
2035
|
+
pendingHeaderReads.clear();
|
|
2036
|
+
cdpRequestUrls.clear();
|
|
2037
|
+
await Promise.allSettled([...cdpSessions].map((session) => session.detach()));
|
|
2038
|
+
cdpSessions.clear();
|
|
1813
2039
|
},
|
|
1814
2040
|
};
|
|
1815
2041
|
}
|
|
@@ -1922,10 +2148,13 @@ async function networkResponseMessage(response, page, settings, secrets) {
|
|
|
1922
2148
|
}
|
|
1923
2149
|
}
|
|
1924
2150
|
|
|
1925
|
-
async function waitForNetworkAssertion(tracker, step, marker, timeout, page, settings, secrets) {
|
|
1926
|
-
const started = performance.now();
|
|
1927
|
-
while (performance.now() - started < timeout) {
|
|
1928
|
-
const [
|
|
2151
|
+
async function waitForNetworkAssertion(tracker, step, marker, initialNavigationMarker, timeout, page, settings, secrets) {
|
|
2152
|
+
const started = performance.now();
|
|
2153
|
+
while (performance.now() - started < timeout) {
|
|
2154
|
+
const [stepResponse] = tracker.responsesSince(marker, step.urlContains, step.method);
|
|
2155
|
+
const entry = stepResponse || (initialNavigationMarker === undefined
|
|
2156
|
+
? undefined
|
|
2157
|
+
: tracker.responsesSince(initialNavigationMarker, step.urlContains, step.method).at(-1));
|
|
1929
2158
|
if (entry) {
|
|
1930
2159
|
if (entry.status !== step.status) {
|
|
1931
2160
|
throw new JevBrowserError(`assert_network expected ${step.method} ${JSON.stringify(redact(step.urlContains, secrets))} status ${step.status}, received ${entry.status}`);
|
|
@@ -2651,19 +2880,29 @@ async function namedTarget(root, step, snapshot, secrets, timeout, settings) {
|
|
|
2651
2880
|
return locatorAt(locator, match.visible[0]);
|
|
2652
2881
|
}
|
|
2653
2882
|
|
|
2654
|
-
async function stepRoot(page, frameName) {
|
|
2655
|
-
if (!frameName) return page;
|
|
2656
|
-
const frames =
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
}
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2883
|
+
async function stepRoot(page, frameName) {
|
|
2884
|
+
if (!frameName) return page;
|
|
2885
|
+
const frames = page.frames().filter((candidate) => candidate !== page.mainFrame());
|
|
2886
|
+
if (frameName === "first") {
|
|
2887
|
+
if (frames.length) return frames[0];
|
|
2888
|
+
throw new JevBrowserError("frame first found no iframe on the page");
|
|
2889
|
+
}
|
|
2890
|
+
if (isRecord(frameName) && frameName.urlContains) {
|
|
2891
|
+
const matches = frames.filter((frame) => frame.url().includes(frameName.urlContains));
|
|
2892
|
+
if (matches.length === 1) return matches[0];
|
|
2893
|
+
throw new JevBrowserError(`frame URL substring ${JSON.stringify(frameName.urlContains)} must match exactly one iframe; found ${matches.length}`);
|
|
2894
|
+
}
|
|
2895
|
+
const namedFrames = [];
|
|
2896
|
+
for (const frame of frames) {
|
|
2897
|
+
const name = await frameDisplayName(frame);
|
|
2898
|
+
if (name) namedFrames.push({ frame, name });
|
|
2899
|
+
}
|
|
2900
|
+
const matches = namedFrames.filter(({ name }) => name === frameName);
|
|
2901
|
+
if (matches.length !== 1) {
|
|
2902
|
+
const available = namedFrames.map(({ name }) => name).slice(0, 5);
|
|
2903
|
+
const suffix = available.length ? `; available frames: ${available.map((name) => JSON.stringify(name)).join(", ")}` : "";
|
|
2904
|
+
throw new JevBrowserError(`frame ${JSON.stringify(frameName)} must match exactly one named iframe; found ${matches.length}${suffix}; use "first" or {url_contains: "..."} for unnamed frames`);
|
|
2905
|
+
}
|
|
2667
2906
|
return matches[0].frame;
|
|
2668
2907
|
}
|
|
2669
2908
|
|
|
@@ -2859,8 +3098,26 @@ async function visibleMenuItem(root, label, timeout, settings) {
|
|
|
2859
3098
|
return locatorAt(textLocator, visible.at(-1));
|
|
2860
3099
|
}
|
|
2861
3100
|
|
|
2862
|
-
async function navigateMenu(page, root, step, settings, execution) {
|
|
2863
|
-
|
|
3101
|
+
async function navigateMenu(page, root, step, settings, execution) {
|
|
3102
|
+
if (step.screen) {
|
|
3103
|
+
const search = page.locator("#pesquisa");
|
|
3104
|
+
const { visible } = await visibleIndexes(search, step.timeoutMs ?? settings.browser.actionTimeoutMs, "SAFI Acesso Rápido search", settings);
|
|
3105
|
+
if (visible.length !== 1) throw new JevBrowserError(`SAFI Acesso Rápido search must match exactly one visible input; found ${visible.length}`);
|
|
3106
|
+
await locatorAt(search, visible[0]).fill(step.screen, { timeout: step.timeoutMs ?? settings.browser.actionTimeoutMs });
|
|
3107
|
+
const timeout = step.timeoutMs ?? settings.browser.actionTimeoutMs;
|
|
3108
|
+
const allRows = page.locator("#ConsultaTelas tbody tr");
|
|
3109
|
+
const exactRows = allRows.filter({ has: page.getByText(step.screen, { exact: true }) });
|
|
3110
|
+
const exactMatches = await visibleIndexes(exactRows, timeout, "exact SAFI Acesso Rápido result", settings, { allowNone: true });
|
|
3111
|
+
const rows = exactMatches.visible.length
|
|
3112
|
+
? exactRows
|
|
3113
|
+
: allRows.filter({ has: page.getByText(step.screen, { exact: false }) });
|
|
3114
|
+
const row = await exactlyOne(rows, `SAFI Acesso Rápido result ${JSON.stringify(step.screen)}`, timeout, settings);
|
|
3115
|
+
const link = await exactlyOne(row.getByRole("link", { name: new RegExp("Acessar", "i") }), "SAFI Acesso Rápido result link", step.timeoutMs ?? settings.browser.actionTimeoutMs, settings);
|
|
3116
|
+
await link.click({ timeout: step.timeoutMs ?? settings.browser.actionTimeoutMs });
|
|
3117
|
+
await page.locator("#pesquisa").waitFor({ state: "hidden", timeout: step.timeoutMs ?? settings.browser.actionTimeoutMs });
|
|
3118
|
+
return { action: step.action, navigated_screen: step.screen, navigation_source: "safi_quick_access" };
|
|
3119
|
+
}
|
|
3120
|
+
let item;
|
|
2864
3121
|
try {
|
|
2865
3122
|
item = await visibleMenuItem(root, step.path[0], 250, settings);
|
|
2866
3123
|
} catch {
|
|
@@ -2879,13 +3136,13 @@ async function navigateMenu(page, root, step, settings, execution) {
|
|
|
2879
3136
|
return { action: step.action, navigated_menu: step.path };
|
|
2880
3137
|
}
|
|
2881
3138
|
|
|
2882
|
-
async function executeStep(page, step, settings, snapshot, secrets, downloadLedger, execution = {}) {
|
|
2883
|
-
const root = await stepRoot(page, step.frame);
|
|
2884
|
-
const timeout = step.timeoutMs ?? settings.browser.actionTimeoutMs;
|
|
3139
|
+
async function executeStep(page, step, settings, snapshot, secrets, downloadLedger, execution = {}) {
|
|
3140
|
+
const root = await stepRoot(page, step.frame);
|
|
3141
|
+
const timeout = step.timeoutMs ?? settings.browser.actionTimeoutMs;
|
|
2885
3142
|
if (step.action === "click" && step.ifVisible && !await conditionIsVisible(root, step.ifVisible)) {
|
|
2886
3143
|
return { action: step.action, skipped: true, skip_reason: "if_visible condition was not visible" };
|
|
2887
3144
|
}
|
|
2888
|
-
if (step.action === "navigate") {
|
|
3145
|
+
if (step.action === "navigate") {
|
|
2889
3146
|
let destination;
|
|
2890
3147
|
try { destination = new URL(step.url, page.url()).toString(); } catch {
|
|
2891
3148
|
throw new JevBrowserError("navigate URL must be absolute or relative to the current page");
|
|
@@ -2896,9 +3153,106 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
2896
3153
|
}
|
|
2897
3154
|
await page.goto(destination, { waitUntil: "domcontentloaded", timeout });
|
|
2898
3155
|
execution.browserPool?.setNavigationError?.(null);
|
|
2899
|
-
return { action: step.action, url: evidenceUrl(destination, secrets, settings.jev.maxDiagnosticChars) };
|
|
2900
|
-
}
|
|
2901
|
-
if (step.action === "
|
|
3156
|
+
return { action: step.action, url: evidenceUrl(destination, secrets, settings.jev.maxDiagnosticChars) };
|
|
3157
|
+
}
|
|
3158
|
+
if (step.action === "http_request") {
|
|
3159
|
+
assertLocalDebugPage(page, execution.localOnly, "http_request");
|
|
3160
|
+
const pageUrl = new URL(page.url());
|
|
3161
|
+
const requestUrl = new URL(step.url, pageUrl);
|
|
3162
|
+
if (requestUrl.origin !== pageUrl.origin || !localHostOnly(requestUrl.toString())) {
|
|
3163
|
+
throw new JevBrowserError("http_request must target the current localhost origin");
|
|
3164
|
+
}
|
|
3165
|
+
const reply = await page.evaluate(async ({ url, includeBody, maxBytes }) => {
|
|
3166
|
+
const response = await fetch(url, {
|
|
3167
|
+
method: "GET", credentials: "same-origin", redirect: "error", cache: "no-store",
|
|
3168
|
+
});
|
|
3169
|
+
const headers = {};
|
|
3170
|
+
for (const name of ["cache-control", "content-length", "content-type", "date", "etag", "last-modified", "retry-after", "vary"]) {
|
|
3171
|
+
const value = response.headers.get(name);
|
|
3172
|
+
if (value !== null) headers[name] = value.slice(0, 256);
|
|
3173
|
+
}
|
|
3174
|
+
let body = "";
|
|
3175
|
+
let truncated = false;
|
|
3176
|
+
if (includeBody && response.body) {
|
|
3177
|
+
const reader = response.body.getReader();
|
|
3178
|
+
const chunks = [];
|
|
3179
|
+
let size = 0;
|
|
3180
|
+
while (size <= maxBytes) {
|
|
3181
|
+
const { done, value } = await reader.read();
|
|
3182
|
+
if (done) break;
|
|
3183
|
+
const chunk = value.slice(0, Math.max(0, maxBytes + 1 - size));
|
|
3184
|
+
chunks.push(chunk);
|
|
3185
|
+
size += chunk.length;
|
|
3186
|
+
if (size > maxBytes || chunk.length < value.length) {
|
|
3187
|
+
truncated = true;
|
|
3188
|
+
await reader.cancel();
|
|
3189
|
+
break;
|
|
3190
|
+
}
|
|
3191
|
+
}
|
|
3192
|
+
const bytes = new Uint8Array(size);
|
|
3193
|
+
let offset = 0;
|
|
3194
|
+
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }
|
|
3195
|
+
body = new TextDecoder().decode(bytes.slice(0, maxBytes));
|
|
3196
|
+
}
|
|
3197
|
+
return { status: response.status, ok: response.ok, headers, body, body_truncated: truncated };
|
|
3198
|
+
}, { url: requestUrl.toString(), includeBody: step.includeBody, maxBytes: settings.browser.maxNetworkErrorBodyBytes });
|
|
3199
|
+
let responseBody;
|
|
3200
|
+
if (step.includeBody && reply.body) {
|
|
3201
|
+
try { responseBody = redactLocalPayload(JSON.parse(reply.body)); }
|
|
3202
|
+
catch { responseBody = sanitizeDiagnosticText(reply.body, secrets, settings.browser.maxNetworkErrorMessageChars); }
|
|
3203
|
+
}
|
|
3204
|
+
return {
|
|
3205
|
+
action: step.action,
|
|
3206
|
+
method: "GET",
|
|
3207
|
+
url: evidenceUrl(requestUrl.toString(), secrets, settings.jev.maxDiagnosticChars),
|
|
3208
|
+
status: reply.status,
|
|
3209
|
+
ok: reply.ok,
|
|
3210
|
+
response_headers: reply.headers,
|
|
3211
|
+
...(step.includeBody ? { response_body: responseBody ?? "", body_truncated: reply.body_truncated } : {}),
|
|
3212
|
+
};
|
|
3213
|
+
}
|
|
3214
|
+
if (step.action === "inspect_cookies") {
|
|
3215
|
+
assertLocalDebugPage(page, execution.localOnly, "inspect_cookies");
|
|
3216
|
+
const cookies = await execution.context.cookies([page.url()]);
|
|
3217
|
+
return {
|
|
3218
|
+
action: step.action,
|
|
3219
|
+
cookies: cookies.map(({ name, domain, path: cookiePath, expires, httpOnly, secure, sameSite }) => ({
|
|
3220
|
+
name: String(name).slice(0, 128), domain: String(domain).slice(0, 255), path: String(cookiePath).slice(0, 256),
|
|
3221
|
+
expires, http_only: Boolean(httpOnly), secure: Boolean(secure), same_site: sameSite,
|
|
3222
|
+
value: "[redacted]",
|
|
3223
|
+
})),
|
|
3224
|
+
};
|
|
3225
|
+
}
|
|
3226
|
+
if (step.action === "read_angular_state") {
|
|
3227
|
+
assertLocalDebugPage(page, execution.localOnly, "read_angular_state");
|
|
3228
|
+
const state = await page.evaluate(() => {
|
|
3229
|
+
const angular = window.angular;
|
|
3230
|
+
if (!angular) return { angular_present: false };
|
|
3231
|
+
const root = document.querySelector("[ng-app], [data-ng-app]") || document.body;
|
|
3232
|
+
let injector;
|
|
3233
|
+
try { injector = angular.element(root).injector(); } catch { injector = undefined; }
|
|
3234
|
+
if (!injector) return { angular_present: true, angular_version: angular.version?.full || null, injector_ready: false };
|
|
3235
|
+
let currentState = null;
|
|
3236
|
+
let parameterNames = [];
|
|
3237
|
+
try {
|
|
3238
|
+
const router = injector.get("$state");
|
|
3239
|
+
currentState = router.current?.name || null;
|
|
3240
|
+
parameterNames = Object.keys(router.params || {}).slice(0, 32);
|
|
3241
|
+
} catch { /* ui-router may not be registered on this page. */ }
|
|
3242
|
+
let authenticated = null;
|
|
3243
|
+
try { authenticated = Boolean(injector.get("$rootScope").usuario); } catch { /* no app-specific auth scope */ }
|
|
3244
|
+
return {
|
|
3245
|
+
angular_present: true,
|
|
3246
|
+
angular_version: angular.version?.full || null,
|
|
3247
|
+
injector_ready: true,
|
|
3248
|
+
state: currentState,
|
|
3249
|
+
parameter_names: parameterNames,
|
|
3250
|
+
authenticated,
|
|
3251
|
+
};
|
|
3252
|
+
});
|
|
3253
|
+
return { action: step.action, ...state };
|
|
3254
|
+
}
|
|
3255
|
+
if (step.action === "evaluate") {
|
|
2902
3256
|
const value = await page.evaluate((expression) => {
|
|
2903
3257
|
const parts = expression.split(".");
|
|
2904
3258
|
let current = globalThis;
|
|
@@ -2956,9 +3310,14 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
2956
3310
|
if (!tabs[step.index]) throw new JevBrowserError(`switch_tab index ${step.index} does not exist; open tabs: ${tabs.length}`);
|
|
2957
3311
|
return { action: step.action, tab_index: step.index, activePage: tabs[step.index], activeContext: selectedContext };
|
|
2958
3312
|
}
|
|
2959
|
-
if (step.action === "wait_for_request") {
|
|
2960
|
-
const request = await waitForRequestAssertion(execution.networkTracker, step, execution.networkMarker, timeout, settings);
|
|
2961
|
-
return {
|
|
3313
|
+
if (step.action === "wait_for_request") {
|
|
3314
|
+
const request = await waitForRequestAssertion(execution.networkTracker, step, execution.flowNetworkMarker ?? execution.networkMarker, timeout, settings);
|
|
3315
|
+
return {
|
|
3316
|
+
action: step.action, asserted: true, method: request.method, status: request.status,
|
|
3317
|
+
...(request.durationMs === undefined ? {} : { duration_ms: Math.round(request.durationMs * 10) / 10 }),
|
|
3318
|
+
...(request.responseHeaders ? { response_headers: request.responseHeaders } : {}),
|
|
3319
|
+
url: evidenceUrl(request.url, secrets, settings.jev.maxDiagnosticChars),
|
|
3320
|
+
};
|
|
2962
3321
|
}
|
|
2963
3322
|
if (step.action === "assert_ws") {
|
|
2964
3323
|
const frame = await waitForWebSocketAssertion(execution.networkTracker, step, execution.networkMarker, timeout, settings);
|
|
@@ -2969,14 +3328,21 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
2969
3328
|
if (step.action === "assert_network") {
|
|
2970
3329
|
const response = await waitForNetworkAssertion(
|
|
2971
3330
|
execution.networkTracker,
|
|
2972
|
-
step,
|
|
2973
|
-
execution.networkMarker,
|
|
2974
|
-
|
|
2975
|
-
|
|
3331
|
+
step,
|
|
3332
|
+
execution.networkMarker,
|
|
3333
|
+
execution.initialNavigationMarker,
|
|
3334
|
+
timeout,
|
|
3335
|
+
page,
|
|
2976
3336
|
settings,
|
|
2977
3337
|
secrets,
|
|
2978
3338
|
);
|
|
2979
|
-
return {
|
|
3339
|
+
return {
|
|
3340
|
+
action: step.action, asserted: true, url: evidenceUrl(response.url, secrets, settings.jev.maxDiagnosticChars),
|
|
3341
|
+
method: response.method, response_status: response.status,
|
|
3342
|
+
...(response.durationMs === undefined ? {} : { duration_ms: Math.round(response.durationMs * 10) / 10 }),
|
|
3343
|
+
...(response.responseHeaders ? { response_headers: response.responseHeaders } : {}),
|
|
3344
|
+
...(response.message ? { message: response.message } : {}),
|
|
3345
|
+
};
|
|
2980
3346
|
}
|
|
2981
3347
|
if (step.action === "confirm_modal") {
|
|
2982
3348
|
const trigger = await namedTarget(root, step, snapshot, secrets, timeout, settings);
|
|
@@ -3725,9 +4091,12 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
3725
4091
|
if (!requestedFlow) throw new JevBrowserError("flow must not be empty");
|
|
3726
4092
|
if (requestedFlow.length > settings.jev.maxFlowChars) throw new JevBrowserError("flow exceeds the configured character limit");
|
|
3727
4093
|
if (outcome.length > settings.jev.maxActionDescriptionChars) throw new JevBrowserError("expected_outcome exceeds the configured character limit");
|
|
3728
|
-
const url = initialUrl === undefined ? undefined : validateUrl(initialUrl);
|
|
4094
|
+
const url = initialUrl === undefined ? undefined : validateUrl(initialUrl);
|
|
3729
4095
|
const options = resolveFlowOptions(rawOptions, settings);
|
|
3730
4096
|
const plans = validateCandidatePlans(replaceKnownParams(candidatePlans, variables, true), settings, options.maxFlowSteps);
|
|
4097
|
+
const networkOnlyFastPath = options.fastPath && Object.keys(plans).length === 1
|
|
4098
|
+
&& !Object.values(plans)[0].when
|
|
4099
|
+
&& Object.values(plans)[0].steps.every(({ action }) => ["assert_network", "wait_for_request"].includes(action));
|
|
3731
4100
|
assertPlanPlaceholders(plans, variables);
|
|
3732
4101
|
if (!url && !options.reusePage) throw new JevBrowserError("initial_url is required when continue_from_current_page is false");
|
|
3733
4102
|
if (options.waitForHttp && !localHostOnly(options.waitForHttp.url)
|
|
@@ -3778,10 +4147,17 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
3778
4147
|
browserSessionMs = browser.sessionMs;
|
|
3779
4148
|
page = browser.page;
|
|
3780
4149
|
context = browser.context;
|
|
4150
|
+
if (browser.recovered && options.reusePage && !url) browserPool.setNavigationError?.("BROWSER_PAGE_NOT_RESTORED");
|
|
3781
4151
|
}
|
|
3782
4152
|
const initialPage = page;
|
|
3783
4153
|
const initialContext = context;
|
|
3784
|
-
const
|
|
4154
|
+
const networkWatchPatterns = Object.values(plans).flatMap(({ steps }) => steps.flatMap((step) => {
|
|
4155
|
+
if (step.action === "wait_for_request") return [step.urlPattern];
|
|
4156
|
+
if (step.action === "assert_network") return [step.urlContains];
|
|
4157
|
+
return [];
|
|
4158
|
+
})).concat(expected.assertions.filter(({ kind }) => kind === "request").map(({ request }) => request));
|
|
4159
|
+
const networkTracker = createNetworkTracker(page, settings, options.loginUrlContains, networkWatchPatterns);
|
|
4160
|
+
const capture = createDiagnostics(page, options, settings, secrets);
|
|
3785
4161
|
const result = {
|
|
3786
4162
|
...(serverVersion ? { server_version: serverVersion } : {}),
|
|
3787
4163
|
status: "failed",
|
|
@@ -3798,9 +4174,9 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
3798
4174
|
result.snapshot_mode = options.returnSnapshot;
|
|
3799
4175
|
if (options.allowMutations) result.mutation_authorization = "allowed_on_localhost";
|
|
3800
4176
|
const downloadLedger = { count: 0, bytes: 0 };
|
|
3801
|
-
const networkTracker = createNetworkTracker(page, settings, options.loginUrlContains);
|
|
3802
4177
|
await networkTracker.waitForWebSocketObservers();
|
|
3803
|
-
const initialNetworkMarker = networkTracker.mark();
|
|
4178
|
+
const initialNetworkMarker = networkTracker.mark();
|
|
4179
|
+
let initialNavigationMarker;
|
|
3804
4180
|
const timings = { navigationMs: 0, initialSnapshotMs: 0, readyMs: 0, decisionMs: 0, browserPlanMs: 0 };
|
|
3805
4181
|
const routeCleanups = [];
|
|
3806
4182
|
const contextStack = [context];
|
|
@@ -3855,17 +4231,24 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
3855
4231
|
addDiagnostic(result.warnings, "trace, video, and screenshots were suppressed because this flow contains sensitive text", settings.jev.maxDiagnosticItems);
|
|
3856
4232
|
}
|
|
3857
4233
|
|
|
3858
|
-
let reusedPage = false;
|
|
4234
|
+
let reusedPage = false;
|
|
3859
4235
|
result.navigation_strategy = options.freshContext ? "fresh_isolated_context" : "navigated_to_initial_url";
|
|
3860
|
-
const plansBeginWithNavigation = Object.values(plans).every(({ steps }) => steps[0]?.action === "navigate");
|
|
4236
|
+
const plansBeginWithNavigation = Object.values(plans).every(({ steps }) => steps[0]?.action === "navigate");
|
|
3861
4237
|
if (options.reusePage && !options.freshContext) {
|
|
3862
|
-
navigationError = browserPool.getNavigationError?.();
|
|
3863
|
-
if (navigationError) {
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
4238
|
+
navigationError = browserPool.getNavigationError?.();
|
|
4239
|
+
if (navigationError) {
|
|
4240
|
+
if (navigationError === "BROWSER_PAGE_NOT_RESTORED" && url) {
|
|
4241
|
+
result.navigation_strategy = "recovered_with_initial_url";
|
|
4242
|
+
} else {
|
|
4243
|
+
result.navigation_error = navigationError;
|
|
4244
|
+
const detail = navigationError === "BROWSER_PAGE_NOT_RESTORED"
|
|
4245
|
+
? "the previous page was not restored after reconnecting; provide initial_url to navigate again"
|
|
4246
|
+
: `the previous navigation failed with ${navigationError}; run once with reuse_page: false to navigate again`;
|
|
4247
|
+
throw new JevBrowserError(`reuse_page cannot continue because ${detail}`);
|
|
4248
|
+
}
|
|
4249
|
+
}
|
|
4250
|
+
const currentUrl = typeof page.url === "function" ? page.url() : "about:blank";
|
|
4251
|
+
if (!navigationError && currentUrl && currentUrl !== "about:blank") {
|
|
3869
4252
|
let currentRoute;
|
|
3870
4253
|
try {
|
|
3871
4254
|
currentRoute = normalizedRouteUrl(currentUrl);
|
|
@@ -3888,18 +4271,30 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
3888
4271
|
}
|
|
3889
4272
|
}
|
|
3890
4273
|
}
|
|
3891
|
-
if (!reusedPage) {
|
|
4274
|
+
if (!reusedPage) {
|
|
3892
4275
|
const destinationUrl = url;
|
|
3893
4276
|
if (!destinationUrl) throw new JevBrowserError("initial_url is required because there is no open page to continue");
|
|
3894
4277
|
const navigationStarted = performance.now();
|
|
4278
|
+
initialNavigationMarker = networkTracker.mark();
|
|
3895
4279
|
try {
|
|
3896
|
-
await page.goto(destinationUrl, { waitUntil: "domcontentloaded", timeout: settings.browser.actionTimeoutMs * 4 });
|
|
4280
|
+
const response = await page.goto(destinationUrl, { waitUntil: "domcontentloaded", timeout: settings.browser.actionTimeoutMs * 4 });
|
|
4281
|
+
if (response?.status() >= 400) result.navigation_http_status = response.status();
|
|
3897
4282
|
if (!options.freshContext) browserPool.setNavigationError?.(null);
|
|
3898
4283
|
} catch (error) {
|
|
3899
|
-
|
|
3900
|
-
|
|
3901
|
-
|
|
3902
|
-
|
|
4284
|
+
const failedDocument = networkOnlyFastPath
|
|
4285
|
+
? networkTracker.historySince(initialNavigationMarker)
|
|
4286
|
+
.filter((entry) => entry.type === "response" && entry.resourceType === "document" && entry.status >= 400)
|
|
4287
|
+
.at(-1)
|
|
4288
|
+
: undefined;
|
|
4289
|
+
if (failedDocument) {
|
|
4290
|
+
result.navigation_http_status = failedDocument.status;
|
|
4291
|
+
if (!options.freshContext) browserPool.setNavigationError?.(null);
|
|
4292
|
+
} else {
|
|
4293
|
+
navigationError = String(error?.message || "").match(/\b(?:net::)?(ERR_[A-Z0-9_]+)\b/)?.[1] || "NAVIGATION_FAILED";
|
|
4294
|
+
if (navigationError && !options.freshContext) browserPool.setNavigationError?.(navigationError);
|
|
4295
|
+
throw error;
|
|
4296
|
+
}
|
|
4297
|
+
}
|
|
3903
4298
|
timings.navigationMs = performance.now() - navigationStarted;
|
|
3904
4299
|
}
|
|
3905
4300
|
if (options.clearStorage) {
|
|
@@ -3909,11 +4304,15 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
3909
4304
|
}
|
|
3910
4305
|
reusedPage = false;
|
|
3911
4306
|
result.navigation_strategy = "storage_cleared_and_reloaded";
|
|
3912
|
-
}
|
|
3913
|
-
const snapshotStarted = performance.now();
|
|
3914
|
-
try {
|
|
3915
|
-
const readiness =
|
|
3916
|
-
|
|
4307
|
+
}
|
|
4308
|
+
const snapshotStarted = performance.now();
|
|
4309
|
+
try {
|
|
4310
|
+
const readiness = networkOnlyFastPath
|
|
4311
|
+
? { snapshot: "", readyMs: 0 }
|
|
4312
|
+
: await waitForPageReady(page, options, settings, secrets, reusedPage);
|
|
4313
|
+
const enriched = networkOnlyFastPath
|
|
4314
|
+
? { snapshot: "", unnamedControls: [] }
|
|
4315
|
+
: await enrichSnapshot(page, readiness.snapshot, settings, secrets, options.snapshotScope, options.snapshotIncludeHidden);
|
|
3917
4316
|
result.final_snapshot = enriched.snapshot;
|
|
3918
4317
|
initialSnapshot = continuation?.initialSnapshot || enriched.snapshot;
|
|
3919
4318
|
if (options.includeUnnamedControls) result.unnamed_controls_initial = enriched.unnamedControls;
|
|
@@ -3921,13 +4320,16 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
3921
4320
|
navigationError = "AUTHENTICATION_REQUIRED";
|
|
3922
4321
|
throw new JevBrowserError("the configured login text is visible; sign in to the application, then run the flow again");
|
|
3923
4322
|
}
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
4323
|
+
if (enriched.unnamedControls.length) {
|
|
4324
|
+
const examples = enriched.unnamedControls.slice(0, 3)
|
|
4325
|
+
.map(({ role, nearest_label }) => `${role}${nearest_label ? ` near ${JSON.stringify(nearest_label)}` : ""}`)
|
|
4326
|
+
.join("; ");
|
|
4327
|
+
addDiagnostic(
|
|
4328
|
+
result.warnings,
|
|
4329
|
+
`${enriched.unnamedControls.length} interactive control(s) have no accessible name${examples ? `; examples: ${examples}` : ""}; set include_unnamed_controls to return sanitized HTML evidence`,
|
|
4330
|
+
settings.jev.maxDiagnosticItems,
|
|
4331
|
+
);
|
|
4332
|
+
}
|
|
3931
4333
|
timings.readyMs = readiness.readyMs;
|
|
3932
4334
|
if (!options.freshContext) browserPool.setNavigationError?.(null);
|
|
3933
4335
|
if (continuation) {
|
|
@@ -4068,11 +4470,14 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
4068
4470
|
nativeDialog.result = undefined;
|
|
4069
4471
|
}
|
|
4070
4472
|
const actionMarker = networkTracker.mark();
|
|
4071
|
-
const execution = {
|
|
4072
|
-
networkTracker,
|
|
4073
|
-
networkMarker,
|
|
4074
|
-
|
|
4075
|
-
|
|
4473
|
+
const execution = {
|
|
4474
|
+
networkTracker,
|
|
4475
|
+
networkMarker,
|
|
4476
|
+
initialNavigationMarker,
|
|
4477
|
+
flowNetworkMarker: initialNetworkMarker,
|
|
4478
|
+
autoAngularIdle: options.autoAngularIdle,
|
|
4479
|
+
readyStableMs: options.readyStableMs,
|
|
4480
|
+
localOnly: options.localOnly,
|
|
4076
4481
|
allowMutations: options.allowMutations,
|
|
4077
4482
|
browserPool,
|
|
4078
4483
|
nativeDialog,
|
|
@@ -4130,16 +4535,18 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
4130
4535
|
failure = `accessibility audit reported ${traceStep.accessibility_audit.blocking_violations_count} violation(s) at or above ${traceStep.accessibility_audit.fail_on_impact} impact`;
|
|
4131
4536
|
}
|
|
4132
4537
|
}
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4538
|
+
if (!networkOnlyFastPath) {
|
|
4539
|
+
snapshot = await snapshotAfterStep(
|
|
4540
|
+
activePage,
|
|
4541
|
+
settings,
|
|
4542
|
+
secrets,
|
|
4543
|
+
options.snapshotScope,
|
|
4544
|
+
snapshot,
|
|
4545
|
+
index + 1,
|
|
4546
|
+
result.warnings,
|
|
4547
|
+
options.snapshotIncludeHidden,
|
|
4548
|
+
);
|
|
4549
|
+
}
|
|
4143
4550
|
if (failure) break;
|
|
4144
4551
|
if (options.stopOnExpected && outcome && !runAllSteps && containsExpectedOutcome(snapshot, outcome)) break;
|
|
4145
4552
|
} catch (error) {
|
|
@@ -4160,7 +4567,7 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
4160
4567
|
break;
|
|
4161
4568
|
}
|
|
4162
4569
|
}
|
|
4163
|
-
if (failure || trace.length === 0) {
|
|
4570
|
+
if ((failure || trace.length === 0) && !networkOnlyFastPath) {
|
|
4164
4571
|
snapshot = await snapshotAfterStep(
|
|
4165
4572
|
activePage,
|
|
4166
4573
|
settings,
|
|
@@ -4172,14 +4579,16 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
4172
4579
|
options.snapshotIncludeHidden,
|
|
4173
4580
|
);
|
|
4174
4581
|
}
|
|
4175
|
-
const finalDetails =
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
4582
|
+
const finalDetails = networkOnlyFastPath
|
|
4583
|
+
? { snapshot, unnamedControls: [] }
|
|
4584
|
+
: await enrichSnapshot(
|
|
4585
|
+
activePage,
|
|
4586
|
+
snapshot,
|
|
4587
|
+
settings,
|
|
4588
|
+
secrets,
|
|
4589
|
+
options.snapshotScope,
|
|
4590
|
+
options.snapshotIncludeHidden,
|
|
4591
|
+
);
|
|
4183
4592
|
snapshot = finalDetails.snapshot;
|
|
4184
4593
|
if (options.includeUnnamedControls) result.unnamed_controls_final = finalDetails.unnamedControls;
|
|
4185
4594
|
timings.browserPlanMs = performance.now() - planStarted;
|
|
@@ -4339,7 +4748,7 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
4339
4748
|
await Promise.all(createdContexts.map(async (createdContext) => {
|
|
4340
4749
|
try { await browserPool.releaseContext?.(createdContext); } catch { artifactErrors.push("isolated browser context cleanup failed"); }
|
|
4341
4750
|
}));
|
|
4342
|
-
networkTracker.dispose();
|
|
4751
|
+
await networkTracker.dispose();
|
|
4343
4752
|
await capture.dispose();
|
|
4344
4753
|
initialPage.off("dialog", onNativeDialog);
|
|
4345
4754
|
}
|
|
@@ -4348,7 +4757,16 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
4348
4757
|
try { result.current_url = evidenceUrl(activePage.url(), secrets, settings.jev.maxDiagnosticChars); } catch { result.current_url = "unavailable"; }
|
|
4349
4758
|
result.console_errors = groupConsoleErrors([...(continuation?.consoleErrors || []), ...capture.diagnostics.console_errors]);
|
|
4350
4759
|
result.console_messages = groupConsoleMessages([...(continuation?.consoleMessages || []), ...capture.diagnostics.console_messages]);
|
|
4351
|
-
result.network_failures = [...(continuation?.networkFailures || []), ...capture.diagnostics.network_failures];
|
|
4760
|
+
result.network_failures = [...(continuation?.networkFailures || []), ...capture.diagnostics.network_failures];
|
|
4761
|
+
const blockedCookies = networkTracker.blockedCookiesSince(initialNetworkMarker, options.captureNetworkUrlContains)
|
|
4762
|
+
.map(({ url: requestUrl, ...event }) => ({ ...event, url: evidenceUrl(requestUrl, secrets, settings.jev.maxDiagnosticChars) }));
|
|
4763
|
+
if (blockedCookies.length) result.blocked_cookies = blockedCookies;
|
|
4764
|
+
if (options.captureNetwork === "all") {
|
|
4765
|
+
result.network_events = networkTracker.networkEventsSince(
|
|
4766
|
+
initialNetworkMarker,
|
|
4767
|
+
options.captureNetworkUrlContains,
|
|
4768
|
+
).map(({ url: requestUrl, ...event }) => ({ ...event, url: evidenceUrl(requestUrl, secrets, settings.jev.maxDiagnosticChars) }));
|
|
4769
|
+
}
|
|
4352
4770
|
result.warnings.push(...capture.diagnostics.warnings);
|
|
4353
4771
|
result.timings_ms = flowTimings(started, browserSessionMs, timings.navigationMs, timings.initialSnapshotMs, timings.readyMs, timings.decisionMs, timings.browserPlanMs);
|
|
4354
4772
|
const safeResult = await redactObject(result, secrets);
|
|
@@ -4359,7 +4777,7 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
4359
4777
|
safeResult.final_snapshot = stripUnnamedControlsSection(safeResult.final_snapshot);
|
|
4360
4778
|
initialSnapshot = stripUnnamedControlsSection(initialSnapshot);
|
|
4361
4779
|
}
|
|
4362
|
-
if (options.returnSnapshot === "diff" && typeof safeResult.final_snapshot === "string") {
|
|
4780
|
+
if (!networkOnlyFastPath && options.returnSnapshot === "diff" && typeof safeResult.final_snapshot === "string") {
|
|
4363
4781
|
safeResult.final_snapshot = snapshotDiff(initialSnapshot, safeResult.final_snapshot);
|
|
4364
4782
|
} else if (options.returnSnapshot === "none") {
|
|
4365
4783
|
delete safeResult.final_snapshot;
|