@diegosouzacdv/jev-browser-mcp 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +359 -71
- package/config/ui-testing.json +31 -10
- package/docs/jev-browser-mcp.md +359 -71
- package/mcp_servers/jev-browser-npm/bin/jev-browser-mcp.cjs +4 -1
- package/mcp_servers/jev-browser-npm/src/browser-flow-runs.mjs +57 -0
- package/mcp_servers/jev-browser-npm/src/config.mjs +136 -37
- package/mcp_servers/jev-browser-npm/src/environment-profiles.mjs +241 -0
- package/mcp_servers/jev-browser-npm/src/flow.mjs +1159 -292
- package/mcp_servers/jev-browser-npm/src/server.mjs +281 -126
- package/mcp_servers/jev-browser-npm/src/test-integrations.mjs +655 -0
- package/mcp_servers/jev-browser-npm/src/test-suites.mjs +438 -0
- package/mcp_servers/jev-browser-npm/src/upload-staging.mjs +80 -0
- package/package.json +7 -1
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
2
|
import { chmod, lstat, mkdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { ACCESSIBILITY_STANDARDS, runAccessibilityAudit } from "./accessibility.mjs";
|
|
5
|
-
import { JevBrowserError } from "./config.mjs";
|
|
6
|
-
import { buildChoicePayload, COMPLETE_ACTION_ID, parseDecision, requestJev } from "./jev-client.mjs";
|
|
4
|
+
import { ACCESSIBILITY_STANDARDS, runAccessibilityAudit } from "./accessibility.mjs";
|
|
5
|
+
import { JevBrowserError } from "./config.mjs";
|
|
6
|
+
import { buildChoicePayload, COMPLETE_ACTION_ID, parseDecision, requestJev } from "./jev-client.mjs";
|
|
7
|
+
import { assertReadOnlySql, createIntegrationSession, executeIntegrationStep } from "./test-integrations.mjs";
|
|
8
|
+
import { isProfileOriginAllowed, requireEnvironmentProfile } from "./environment-profiles.mjs";
|
|
9
|
+
import { cleanupUploadStagingDirectory, prepareUploadStagingDirectory, withUploadStagingLock } from "./upload-staging.mjs";
|
|
7
10
|
|
|
8
11
|
const PLAN_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,79}$/;
|
|
9
12
|
const CLICK_ROLES = new Set(["button", "link", "tab", "menuitem", "checkbox", "radio", "switch", "cell", "gridcell", "option"]);
|
|
@@ -18,7 +21,7 @@ const CONTAINER_ROLES = new Set([
|
|
|
18
21
|
]);
|
|
19
22
|
const TARGET_SELECTION_ACTIONS = new Set([
|
|
20
23
|
"click", "confirm_modal", "check", "uncheck", "type", "clear", "select_option", "hover", "press", "assert_text", "assert_value", "wait_for_value",
|
|
21
|
-
"wait_for_enabled", "assert_enabled", "assert_visible", "assert_hidden", "upload_file", "extract",
|
|
24
|
+
"wait_for_enabled", "assert_enabled", "assert_visible", "assert_hidden", "assert_attribute", "upload_file", "extract",
|
|
22
25
|
]);
|
|
23
26
|
const LOCATOR_FIELDS = ["label", "placeholder", "title", "text", "test_id", "selector"];
|
|
24
27
|
const TARGET_LOCATOR_FIELDS = [...LOCATOR_FIELDS, "target_text"];
|
|
@@ -38,6 +41,8 @@ const MIME_TYPES = new Map([
|
|
|
38
41
|
[".jpg", "image/jpeg"], [".jpeg", "image/jpeg"], [".png", "image/png"],
|
|
39
42
|
[".txt", "text/plain"], [".webp", "image/webp"],
|
|
40
43
|
]);
|
|
44
|
+
// Jev's Decisions API currently caps a single upload payload at 47 MiB.
|
|
45
|
+
const JEV_UPLOAD_LIMIT_BYTES = 47 * 1024 * 1024;
|
|
41
46
|
const WEBSOCKET_CLOSE_PROBE = `(() => {
|
|
42
47
|
const eventsKey = "__jevBrowserWebSocketCloseEvents";
|
|
43
48
|
const installedKey = "__jevBrowserWebSocketCloseProbe";
|
|
@@ -59,12 +64,13 @@ const WEBSOCKET_CLOSE_PROBE = `(() => {
|
|
|
59
64
|
const FLOW_OPTION_KEYS = new Set([
|
|
60
65
|
"allow_mutations", "block_trackers", "capture_console_errors", "capture_network_error_bodies", "capture_network_errors",
|
|
61
66
|
"auto_angular_idle", "block_fonts", "busy_selectors", "confirmation_token", "dry_run", "fast_path", "local_only",
|
|
67
|
+
"environment_profile",
|
|
62
68
|
"login_text", "login_url_contains", "max_flow_steps", "ready_network_idle", "ready_stable_ms", "ready_text", "ready_timeout_seconds",
|
|
63
69
|
"report_path", "permissions", "fake_media", "viewport", "mobile", "device_scale_factor", "locale", "timezone_id",
|
|
64
70
|
"color_scheme", "geolocation", "screenshot_on_success", "record_video", "trace_on_success",
|
|
65
71
|
"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
72
|
"trace_on_failure", "console_levels", "fresh_context", "clear_storage", "wait_for_http", "storage_state",
|
|
67
|
-
"ready",
|
|
73
|
+
"ready", "capture_network", "capture_network_url_contains",
|
|
68
74
|
]);
|
|
69
75
|
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
76
|
|
|
@@ -114,8 +120,10 @@ function flowFingerprint({ flow, initialUrl, expectedOutcome, candidatePlans, pa
|
|
|
114
120
|
}))).digest("hex");
|
|
115
121
|
}
|
|
116
122
|
|
|
117
|
-
function looksMutating(rawStep) {
|
|
118
|
-
if (["confirm_dialog", "confirm_modal", "like_comment", "unlike_comment", "upload_file"].includes(rawStep.action)) return true;
|
|
123
|
+
function looksMutating(rawStep) {
|
|
124
|
+
if (["confirm_dialog", "confirm_modal", "like_comment", "unlike_comment", "upload_file"].includes(rawStep.action)) return true;
|
|
125
|
+
if (["delete_remote_file", "run_hook"].includes(rawStep.action)) return true;
|
|
126
|
+
if (rawStep.action === "http_request" && !["GET", "HEAD"].includes(String(rawStep.method || "GET").toUpperCase())) return true;
|
|
119
127
|
if (rawStep.action !== "click") return false;
|
|
120
128
|
return [rawStep.name, rawStep.title, rawStep.text, rawStep.target_text, rawStep.label]
|
|
121
129
|
.some((value) => typeof value === "string" && MUTATING_LABEL.test(value));
|
|
@@ -177,7 +185,7 @@ function replaceKnownParams(value, params, allowUnknown = false) {
|
|
|
177
185
|
return value;
|
|
178
186
|
}
|
|
179
187
|
|
|
180
|
-
function assertPlanPlaceholders(plans, initialVariables) {
|
|
188
|
+
function assertPlanPlaceholders(plans, initialVariables) {
|
|
181
189
|
for (const [planId, plan] of Object.entries(plans)) {
|
|
182
190
|
const available = new Set(Object.keys(initialVariables));
|
|
183
191
|
for (const field of [plan.description, plan.when?.visible]) {
|
|
@@ -186,7 +194,10 @@ function assertPlanPlaceholders(plans, initialVariables) {
|
|
|
186
194
|
for (const [index, step] of plan.steps.entries()) {
|
|
187
195
|
const fields = Object.entries(step).filter(([key]) => !["action", "as", "sensitive"].includes(key)).map(([, value]) => value);
|
|
188
196
|
for (const field of fields) assertTemplateRefsDeep(field, available, `candidate plan ${planId} step ${index + 1}`);
|
|
189
|
-
if (step.action === "extract" || (step.action === "evaluate" && step.as)) available.add(step.as);
|
|
197
|
+
if (step.action === "extract" || step.action === "generate_fixture" || (step.action === "evaluate" && step.as)) available.add(step.as);
|
|
198
|
+
if (["http_request", "assert_db"].includes(step.action)) {
|
|
199
|
+
for (const name of Object.keys(step.extract || {})) available.add(name);
|
|
200
|
+
}
|
|
190
201
|
}
|
|
191
202
|
}
|
|
192
203
|
}
|
|
@@ -223,13 +234,25 @@ function actionSchemas(semanticTarget) {
|
|
|
223
234
|
clear: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
|
|
224
235
|
navigate: { required: ["url"], optional: ["timeout_seconds", "timeout_ms", "target_page"] },
|
|
225
236
|
confirm_dialog: { required: ["expected_text", "button"], optional: ["timeout_seconds"] },
|
|
226
|
-
confirm_modal: { required: ["expected_text", "button"], optional: [...semanticTarget, "trigger", "confirm", "timeout_seconds"] },
|
|
237
|
+
confirm_modal: { required: ["expected_text", "button"], optional: [...semanticTarget, "trigger", "trigger_match", "confirm", "timeout_seconds"] },
|
|
227
238
|
check: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
|
|
228
239
|
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"] },
|
|
240
|
+
navigate_menu: { required: [], optional: ["path", "screen", "toggle_selector", "timeout_seconds"] },
|
|
241
|
+
assert_network: { required: ["url_contains", "method", "status"], optional: ["message_contains", "timeout_seconds", "timeout_ms"] },
|
|
242
|
+
assert_ws: { required: ["url_contains"], optional: ["message_contains", "json", "close_code", "timeout_seconds", "timeout_ms"] },
|
|
243
|
+
wait_for_request: { required: ["url"], optional: ["method", "status", "timeout_seconds", "timeout_ms"] },
|
|
244
|
+
http_request: { required: ["url"], optional: ["method", "json", "multipart", "include_body", "expected_status", "extract", "timeout_seconds", "timeout_ms"] },
|
|
245
|
+
generate_fixture: { required: ["kind", "as", "file_name"], optional: ["content", "size_bytes", "timeout_seconds", "timeout_ms"] },
|
|
246
|
+
assert_download: { required: ["file_path"], optional: ["mime_type", "timeout_seconds", "timeout_ms"] },
|
|
247
|
+
assert_db: { required: ["sql", "expected"], optional: ["binds", "extract", "timeout_seconds", "timeout_ms"] },
|
|
248
|
+
assert_log: { required: ["source", "contains"], optional: ["not_contains", "since", "timeout_seconds", "timeout_ms"] },
|
|
249
|
+
start_email_capture: { required: [], optional: ["timeout_seconds", "timeout_ms"] },
|
|
250
|
+
assert_email: { required: [], optional: ["to", "subject_contains", "body_contains", "timeout_ms", "timeout_seconds"] },
|
|
251
|
+
assert_remote_file: { required: ["server", "path"], optional: ["exists", "size_bytes", "sha256", "timeout_seconds", "timeout_ms"] },
|
|
252
|
+
delete_remote_file: { required: ["server", "path"], optional: ["timeout_seconds", "timeout_ms"] },
|
|
253
|
+
run_hook: { required: ["hook"], optional: ["args", "expected_exit_code", "timeout_ms", "timeout_seconds"] },
|
|
254
|
+
inspect_cookies: { required: [], optional: ["timeout_seconds", "timeout_ms"] },
|
|
255
|
+
read_angular_state: { required: [], optional: ["timeout_seconds", "timeout_ms"] },
|
|
233
256
|
extract: { required: ["as"], optional: [...semanticTarget, "source", "attribute", "sensitive", "timeout_seconds", "timeout_ms"] },
|
|
234
257
|
evaluate: { required: ["expression"], optional: ["as", "timeout_seconds", "timeout_ms"] },
|
|
235
258
|
screenshot: { required: [], optional: ["full_page", "timeout_seconds", "timeout_ms"] },
|
|
@@ -245,8 +268,9 @@ function actionSchemas(semanticTarget) {
|
|
|
245
268
|
press: { required: ["key"], optional: [...semanticTarget, "timeout_seconds"] },
|
|
246
269
|
select_option: { required: [], optional: [...semanticTarget, "option", "value", "label_contains", "timeout_seconds"] },
|
|
247
270
|
hover: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
|
|
248
|
-
assert_text: { required: ["expected"], optional: [...semanticTarget, "timeout_seconds"] },
|
|
249
|
-
assert_value: { required: ["expected"], optional: [...semanticTarget, "timeout_seconds"] },
|
|
271
|
+
assert_text: { required: ["expected"], optional: [...semanticTarget, "timeout_seconds"] },
|
|
272
|
+
assert_value: { required: ["expected"], optional: [...semanticTarget, "timeout_seconds"] },
|
|
273
|
+
assert_attribute: { required: ["attribute", "expected"], optional: [...semanticTarget, "match", "timeout_seconds"] },
|
|
250
274
|
assert_enabled: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
|
|
251
275
|
assert_visible: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
|
|
252
276
|
assert_hidden: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
|
|
@@ -257,21 +281,92 @@ function actionSchemas(semanticTarget) {
|
|
|
257
281
|
};
|
|
258
282
|
}
|
|
259
283
|
|
|
260
|
-
export function describeBrowserActions() {
|
|
284
|
+
export function describeBrowserActions(settings) {
|
|
261
285
|
const semanticTarget = ["frame", "comment", "within", "index", "near", "label", "placeholder", "title", "text", "test_id", "selector", "target_text", "role", "name", "name_match", "target_page"];
|
|
262
286
|
const schemas = actionSchemas(semanticTarget);
|
|
263
|
-
return {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
287
|
+
return {
|
|
288
|
+
required_flow_fields: ["flow", "candidate_plans"],
|
|
289
|
+
candidate_plan: { required: ["description", "steps"], optional: ["comment", "when"] },
|
|
290
|
+
actions: Object.fromEntries(Object.entries(schemas).map(([action, schema]) => {
|
|
291
|
+
const contract = {
|
|
292
|
+
required: [...schema.required],
|
|
293
|
+
optional: [...new Set([...schema.optional, "timeout_ms", "mutating", "screenshot"])],
|
|
294
|
+
};
|
|
295
|
+
if (action === "extract") contract.conditional_required = { attribute: "required when source is attribute" };
|
|
296
|
+
if (action === "assert_email") contract.conditional_required = { subject_contains: "at least one of to, subject_contains, or body_contains is required" };
|
|
297
|
+
if (action === "type") contract.conditional_required = { text: "exactly one of text or text_env is required" };
|
|
298
|
+
if (action === "navigate_menu") contract.conditional_required = { path: "exactly one of path or screen is required" };
|
|
299
|
+
if (action === "select_option") contract.conditional_required = { option: "exactly one of option, value or label_contains is required" };
|
|
300
|
+
return [action, contract];
|
|
301
|
+
})),
|
|
268
302
|
options: [...FLOW_OPTION_KEYS],
|
|
303
|
+
suite: {
|
|
304
|
+
file: ".jev-browser/suites/<name>.json",
|
|
305
|
+
file_fields: { required: ["version", "id", "criteria"], optional: ["description", "environment_profile", "params", "flows", "setup", "teardown"] },
|
|
306
|
+
flow_definition: { required: ["flow", "candidate_plans"], optional: ["initial_url", "expected_outcome", "params", "options"] },
|
|
307
|
+
criterion: { required: ["id", "description", "flow"], optional: ["initial_url", "expected_outcome", "params", "options"] },
|
|
308
|
+
run_test_suite_tool: { required: ["suite_name"], optional: ["params", "environment_profile", "report_path", "background"] },
|
|
309
|
+
execution: "sequential; all setup, criteria and teardown flows are preflighted before browser startup; teardown is always attempted; run_test_suite returns or polls a run_id when background=true",
|
|
310
|
+
},
|
|
311
|
+
profiles: {
|
|
312
|
+
file: ".jev-browser/profiles.json or absolute JEV_BROWSER_PROFILES_FILE inside the project",
|
|
313
|
+
supported: ["dev", "hml", "prd"],
|
|
314
|
+
profile_fields: { required: ["environment"], optional: ["application_origins", "database", "log_sources", "smtp_capture", "remotes", "hooks"] },
|
|
315
|
+
database: ["postgresql", "oracle"],
|
|
316
|
+
database_fields: {
|
|
317
|
+
postgresql: { required: ["engine", "connection_env", "timeout_ms", "max_rows"] },
|
|
318
|
+
oracle: { required: ["engine", "connect_env", "user_env", "password_env", "timeout_ms", "max_rows"] },
|
|
319
|
+
},
|
|
320
|
+
remote_protocols: ["ftp", "ftps", "sftp"],
|
|
321
|
+
remote_fields: { required: ["protocol", "host", "port", "username_env", "password_env", "root"], optional: ["host_fingerprint_env; required for SFTP"] },
|
|
322
|
+
smtp_capture_fields: { required: ["port", "bind_address", "max_messages"] },
|
|
323
|
+
hook_fields: { required: ["script", "arguments", "timeout_ms"], argument_fields: { required: ["type", "required", "max_length"], types: ["string", "integer", "boolean"] } },
|
|
324
|
+
environment_policy: { dev: "test writes allowed with explicit allow_mutations; remote deletion and hooks are DEV-only", hml: "application writes require explicit allow_mutations; database and logs are read-only", prd: "mutations, destructive remote actions and hooks are refused" },
|
|
325
|
+
credentials: "environment variable references only; raw credentials are rejected from reports",
|
|
326
|
+
...(settings ? { configured: Object.keys(settings.browser.environmentProfiles.profiles) } : {}),
|
|
327
|
+
},
|
|
328
|
+
...(settings ? { limits: {
|
|
329
|
+
max_flow_steps: settings.browser.maxFlowSteps,
|
|
330
|
+
max_flow_steps_env: settings.browser.maxFlowStepsEnv,
|
|
331
|
+
max_upload_files: settings.browser.maxUploadFiles,
|
|
332
|
+
max_upload_file_bytes: settings.browser.maxUploadFileBytes,
|
|
333
|
+
max_upload_file_bytes_env: settings.browser.maxUploadFileBytesEnv,
|
|
334
|
+
max_upload_total_bytes: settings.browser.maxUploadTotalBytes,
|
|
335
|
+
max_upload_total_bytes_env: settings.browser.maxUploadTotalBytesEnv,
|
|
336
|
+
max_background_flow_runs: settings.browser.maxBackgroundFlowRuns,
|
|
337
|
+
background_flow_result_ttl_ms: settings.browser.backgroundFlowResultTtlMs,
|
|
338
|
+
max_download_files: settings.browser.maxDownloadFiles,
|
|
339
|
+
max_download_file_bytes: settings.browser.maxDownloadFileBytes,
|
|
340
|
+
max_download_total_bytes: settings.browser.maxDownloadTotalBytes,
|
|
341
|
+
max_environment_profile_file_bytes: settings.browser.maxEnvironmentProfileFileBytes,
|
|
342
|
+
max_test_suite_file_bytes: settings.browser.maxTestSuiteFileBytes,
|
|
343
|
+
max_test_suite_criteria: settings.browser.maxTestSuiteCriteria,
|
|
344
|
+
max_test_suite_report_bytes: settings.browser.maxTestSuiteReportBytes,
|
|
345
|
+
max_database_query_chars: settings.browser.maxDatabaseQueryChars,
|
|
346
|
+
max_database_bind_values: settings.browser.maxDatabaseBindValues,
|
|
347
|
+
max_external_extract_variables: settings.browser.maxExternalExtractVariables,
|
|
348
|
+
max_hook_output_bytes: settings.browser.maxHookOutputBytes,
|
|
349
|
+
max_hook_timeout_ms: settings.browser.maxHookTimeoutMs,
|
|
350
|
+
max_hook_argument_chars: settings.browser.maxHookArgumentChars,
|
|
351
|
+
max_smtp_captured_messages: settings.browser.maxSmtpCapturedMessages,
|
|
352
|
+
max_database_timeout_ms: settings.browser.maxDatabaseTimeoutMs,
|
|
353
|
+
max_database_rows: settings.browser.maxDatabaseRows,
|
|
354
|
+
jev_max_upload_file_bytes: JEV_UPLOAD_LIMIT_BYTES,
|
|
355
|
+
} } : {}),
|
|
269
356
|
option_shapes: {
|
|
357
|
+
capture_network: ["errors", "all", "none"],
|
|
358
|
+
capture_network_url_contains: "optional URL substring filter for capture_network=all",
|
|
359
|
+
environment_profile: "selects a configured DEV, HML, or PRD allowlist from .jev-browser/profiles.json; credentials are environment references only",
|
|
360
|
+
local_only: "required for page inspection and for HTTP requests without an environment profile; localhost loopback only",
|
|
270
361
|
ready: ["network_idle", { hidden_text: "text that must disappear" }, { selector: "CSS selector", has_value: true }],
|
|
271
362
|
return_snapshot: ["diff", "full", "none"],
|
|
272
363
|
include_unnamed_controls: "boolean; false by default",
|
|
273
364
|
reuse_page_match: ["url", "path"],
|
|
274
365
|
},
|
|
366
|
+
step_shapes: {
|
|
367
|
+
frame: ["first", { url_contains: "iframe URL substring" }, "exact iframe name or title"],
|
|
368
|
+
navigate_menu: ["path: menu label array", "screen: SAFI Acesso Rápido name or screen code"],
|
|
369
|
+
},
|
|
275
370
|
expected_outcome: {
|
|
276
371
|
text: "string description or {text: string} assertion",
|
|
277
372
|
request: "{request: 'PUT */path', status: 200, message_contains?: string}; request may include a method prefix",
|
|
@@ -282,13 +377,74 @@ export function describeBrowserActions() {
|
|
|
282
377
|
duration_ms: "wait.ms",
|
|
283
378
|
value: "type.text or wait_for_text.text",
|
|
284
379
|
timeout_ms: "per-step timeout in milliseconds",
|
|
285
|
-
confirm_modal: { trigger: "button accessible name", confirm: "button", expected_text: "required
|
|
380
|
+
confirm_modal: { trigger: "button accessible name (exact by default)", trigger_match: ["exact", "contains", "regex"], confirm: "button", expected_text: "required safety phrase; checked before the affirmative button is clicked" },
|
|
286
381
|
},
|
|
287
382
|
placeholders: "Use {name}; declare params.name or extract it before use. Unknown and {{double-brace}} placeholders are rejected.",
|
|
288
|
-
};
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
export function
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export function collectFlowContractIssues({ flow, candidatePlans }) {
|
|
387
|
+
const issues = [];
|
|
388
|
+
if (typeof flow !== "string" || !flow.trim()) issues.push("flow: required non-empty string");
|
|
389
|
+
if (!isRecord(candidatePlans) || !Object.keys(candidatePlans).length) {
|
|
390
|
+
issues.push("candidate_plans: required non-empty object");
|
|
391
|
+
return issues;
|
|
392
|
+
}
|
|
393
|
+
const schemas = actionSchemas(["frame", "comment", "within", "index", "near", ...LOCATOR_FIELDS, "target_text", "role", "name", "name_match", "target_page"]);
|
|
394
|
+
for (const [planId, plan] of Object.entries(candidatePlans)) {
|
|
395
|
+
if (!isRecord(plan)) {
|
|
396
|
+
issues.push(`candidate_plans.${planId}: must be an object`);
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
if (typeof plan.description !== "string" || !plan.description.trim()) issues.push(`candidate_plans.${planId}.description: required non-empty string`);
|
|
400
|
+
if (plan.when !== undefined && (!isRecord(plan.when) || typeof plan.when.visible !== "string" || !plan.when.visible.trim())) {
|
|
401
|
+
issues.push(`candidate_plans.${planId}.when.visible: required non-empty string when is provided`);
|
|
402
|
+
}
|
|
403
|
+
if (!Array.isArray(plan.steps) || !plan.steps.length) {
|
|
404
|
+
issues.push(`candidate_plans.${planId}.steps: required non-empty array`);
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
for (const [index, step] of plan.steps.entries()) {
|
|
408
|
+
const prefix = `candidate_plans.${planId}.steps[${index}]`;
|
|
409
|
+
if (!isRecord(step) || typeof step.action !== "string") {
|
|
410
|
+
issues.push(`${prefix}.action: required supported action`);
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
let normalizedStep;
|
|
414
|
+
try { normalizedStep = normalizeStepAliases(step); }
|
|
415
|
+
catch (error) {
|
|
416
|
+
issues.push(`${prefix}: ${error instanceof Error ? error.message : "invalid aliases"}`);
|
|
417
|
+
normalizedStep = step;
|
|
418
|
+
}
|
|
419
|
+
const schema = schemas[normalizedStep.action];
|
|
420
|
+
if (!schema) {
|
|
421
|
+
issues.push(`${prefix}.action: unsupported action ${JSON.stringify(normalizedStep.action)}`);
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
for (const field of schema.required) {
|
|
425
|
+
if (!Object.hasOwn(normalizedStep, field)) issues.push(`${prefix}.${field}: required for ${normalizedStep.action}`);
|
|
426
|
+
}
|
|
427
|
+
if (normalizedStep.action === "extract" && normalizedStep.source === "attribute" && !Object.hasOwn(normalizedStep, "attribute")) {
|
|
428
|
+
issues.push(`${prefix}.attribute: required when extract.source is attribute`);
|
|
429
|
+
}
|
|
430
|
+
if (normalizedStep.action === "type"
|
|
431
|
+
&& (Object.hasOwn(normalizedStep, "text") === Object.hasOwn(normalizedStep, "text_env"))) {
|
|
432
|
+
issues.push(`${prefix}: exactly one of text or text_env is required for type`);
|
|
433
|
+
}
|
|
434
|
+
if (normalizedStep.action === "navigate_menu"
|
|
435
|
+
&& (Object.hasOwn(normalizedStep, "path") === Object.hasOwn(normalizedStep, "screen"))) {
|
|
436
|
+
issues.push(`${prefix}: exactly one of path or screen is required for navigate_menu`);
|
|
437
|
+
}
|
|
438
|
+
if (normalizedStep.action === "select_option"
|
|
439
|
+
&& ["option", "value", "label_contains"].filter((field) => Object.hasOwn(normalizedStep, field)).length !== 1) {
|
|
440
|
+
issues.push(`${prefix}: exactly one of option, value or label_contains is required for select_option`);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return issues;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps = settings.browser.maxFlowSteps) {
|
|
292
448
|
if (!isRecord(candidatePlans)) throw new JevBrowserError("candidate_plans must be an object");
|
|
293
449
|
if (Object.keys(candidatePlans).length > settings.jev.maxActionCount) {
|
|
294
450
|
throw new JevBrowserError("candidate_plans exceeds the configured candidate limit");
|
|
@@ -482,12 +638,18 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
|
|
|
482
638
|
if (!step.role) step.role = "checkbox";
|
|
483
639
|
if (action === "uncheck" && step.role === "radio") throw new JevBrowserError("uncheck does not support radio controls");
|
|
484
640
|
}
|
|
485
|
-
if (action === "navigate_menu") {
|
|
486
|
-
if (
|
|
487
|
-
throw new JevBrowserError("navigate_menu
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
|
|
641
|
+
if (action === "navigate_menu") {
|
|
642
|
+
if ((rawStep.path === undefined) === (rawStep.screen === undefined)) {
|
|
643
|
+
throw new JevBrowserError("navigate_menu needs exactly one of path or screen");
|
|
644
|
+
}
|
|
645
|
+
if (rawStep.path !== undefined) {
|
|
646
|
+
if (!Array.isArray(rawStep.path) || rawStep.path.length === 0 || rawStep.path.length > 8) {
|
|
647
|
+
throw new JevBrowserError("navigate_menu path must contain between 1 and 8 menu labels");
|
|
648
|
+
}
|
|
649
|
+
step.path = rawStep.path.map((label) => requiredText(label, "menu path label", settings.jev.maxActionDescriptionChars));
|
|
650
|
+
}
|
|
651
|
+
if (rawStep.screen !== undefined) step.screen = requiredText(rawStep.screen, "quick access screen name or code", settings.jev.maxActionDescriptionChars);
|
|
652
|
+
step.toggleSelector = rawStep.toggle_selector === undefined
|
|
491
653
|
? "#open_btn"
|
|
492
654
|
: requiredText(rawStep.toggle_selector, "toggle_selector", settings.jev.maxActionDescriptionChars);
|
|
493
655
|
}
|
|
@@ -515,7 +677,7 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
|
|
|
515
677
|
if (hasTextEnv) step.textEnv = rawStep.text_env;
|
|
516
678
|
step.mode = rawStep.mode || "fill";
|
|
517
679
|
step.blur = rawStep.blur ?? false;
|
|
518
|
-
step.sensitive = rawStep.sensitive ?? true;
|
|
680
|
+
step.sensitive = rawStep.sensitive ?? true;
|
|
519
681
|
if (hasTextEnv && rawStep.sensitive === false) throw new JevBrowserError("type text_env is always sensitive");
|
|
520
682
|
}
|
|
521
683
|
if (action === "clear" && step.role && !TEXT_ROLES.has(step.role)) {
|
|
@@ -577,16 +739,140 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
|
|
|
577
739
|
if (!step.messageContains && !step.json && rawStep.close_code === undefined) throw new JevBrowserError("assert_ws needs message_contains, json, or close_code");
|
|
578
740
|
step.closeCode = rawStep.close_code;
|
|
579
741
|
}
|
|
580
|
-
if (action === "wait_for_request") {
|
|
581
|
-
step.urlPattern = requiredText(rawStep.url, "wait_for_request url", settings.jev.maxActionDescriptionChars);
|
|
742
|
+
if (action === "wait_for_request") {
|
|
743
|
+
step.urlPattern = requiredText(rawStep.url, "wait_for_request url", settings.jev.maxActionDescriptionChars);
|
|
582
744
|
step.method = rawStep.method === undefined ? undefined : requiredText(rawStep.method, "request method", 16).toUpperCase();
|
|
583
745
|
if (step.method && !/^[A-Z]+$/.test(step.method)) throw new JevBrowserError("wait_for_request method must contain letters only");
|
|
584
746
|
if (rawStep.status !== undefined && (!Number.isSafeInteger(rawStep.status) || rawStep.status < 100 || rawStep.status > 599)) {
|
|
585
747
|
throw new JevBrowserError("wait_for_request status must be an HTTP status from 100 to 599");
|
|
586
|
-
}
|
|
587
|
-
step.status = rawStep.status;
|
|
588
|
-
}
|
|
589
|
-
if (action === "
|
|
748
|
+
}
|
|
749
|
+
step.status = rawStep.status;
|
|
750
|
+
}
|
|
751
|
+
if (action === "http_request") {
|
|
752
|
+
step.url = requiredText(rawStep.url, "http_request URL", settings.jev.maxActionDescriptionChars);
|
|
753
|
+
step.method = rawStep.method === undefined ? "GET" : requiredText(rawStep.method, "http_request method", 16).toUpperCase();
|
|
754
|
+
if (!new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]).has(step.method)) {
|
|
755
|
+
throw new JevBrowserError("http_request method must be GET, HEAD, POST, PUT, PATCH, or DELETE");
|
|
756
|
+
}
|
|
757
|
+
if (rawStep.json !== undefined && !isRecord(rawStep.json)) throw new JevBrowserError("http_request json must be an object");
|
|
758
|
+
if (rawStep.multipart !== undefined && !Array.isArray(rawStep.multipart)) throw new JevBrowserError("http_request multipart must be an array of typed fields");
|
|
759
|
+
if (rawStep.json !== undefined && rawStep.multipart !== undefined) throw new JevBrowserError("http_request cannot contain both json and multipart");
|
|
760
|
+
step.json = rawStep.json;
|
|
761
|
+
step.multipart = (rawStep.multipart || []).map((field, index) => {
|
|
762
|
+
if (!isRecord(field)) throw new JevBrowserError(`http_request multipart[${index}] must be an object`);
|
|
763
|
+
exactKeys(field, ["name"], ["value", "file_path", "file_name"], `http_request multipart[${index}]`);
|
|
764
|
+
const hasValue = Object.hasOwn(field, "value");
|
|
765
|
+
const hasFile = Object.hasOwn(field, "file_path");
|
|
766
|
+
if (hasValue === hasFile) throw new JevBrowserError(`http_request multipart[${index}] needs exactly one of value or file_path`);
|
|
767
|
+
const item = { name: requiredText(field.name, "multipart field name", 128) };
|
|
768
|
+
if (hasValue) {
|
|
769
|
+
if (!["string", "number", "boolean"].includes(typeof field.value)) throw new JevBrowserError(`http_request multipart[${index}].value must be scalar`);
|
|
770
|
+
item.value = field.value;
|
|
771
|
+
} else {
|
|
772
|
+
item.filePath = requiredText(field.file_path, "multipart file path", settings.browser.maxUploadPathChars);
|
|
773
|
+
if (field.file_name !== undefined) item.fileName = safeDownloadName(requiredText(field.file_name, "multipart file name", 180));
|
|
774
|
+
}
|
|
775
|
+
return item;
|
|
776
|
+
});
|
|
777
|
+
if (rawStep.include_body !== undefined && typeof rawStep.include_body !== "boolean") {
|
|
778
|
+
throw new JevBrowserError("http_request include_body must be a boolean");
|
|
779
|
+
}
|
|
780
|
+
step.includeBody = rawStep.include_body ?? false;
|
|
781
|
+
if (rawStep.expected_status !== undefined && (!Number.isSafeInteger(rawStep.expected_status) || rawStep.expected_status < 100 || rawStep.expected_status > 599)) {
|
|
782
|
+
throw new JevBrowserError("http_request expected_status must be an HTTP status from 100 to 599");
|
|
783
|
+
}
|
|
784
|
+
step.expectedStatus = rawStep.expected_status;
|
|
785
|
+
if (rawStep.extract !== undefined) {
|
|
786
|
+
if (!isRecord(rawStep.extract) || Object.keys(rawStep.extract).length > settings.browser.maxExternalExtractVariables) throw new JevBrowserError("http_request extract must be a bounded object of variable names to JSON paths");
|
|
787
|
+
step.extract = Object.fromEntries(Object.entries(rawStep.extract).map(([name, descriptor]) => {
|
|
788
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new JevBrowserError("http_request extract keys must be simple variable names");
|
|
789
|
+
exactKeys(descriptor, ["json_path"], [], `http_request extract.${name}`);
|
|
790
|
+
const jsonPath = requiredText(descriptor.json_path, `http_request extract.${name} JSON path`, 256);
|
|
791
|
+
if (!/^[A-Za-z0-9_$-]+(?:\.(?:[A-Za-z0-9_$-]+|\d+))*$/.test(jsonPath)) throw new JevBrowserError(`http_request extract.${name} JSON path is invalid`);
|
|
792
|
+
return [name, { jsonPath }];
|
|
793
|
+
}));
|
|
794
|
+
} else step.extract = {};
|
|
795
|
+
}
|
|
796
|
+
if (action === "generate_fixture") {
|
|
797
|
+
step.kind = rawStep.kind;
|
|
798
|
+
if (!new Set(["pdf", "text", "bytes"]).has(step.kind)) throw new JevBrowserError("generate_fixture kind must be pdf, text, or bytes");
|
|
799
|
+
step.as = requiredText(rawStep.as, "fixture variable name", 64);
|
|
800
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(step.as)) throw new JevBrowserError("generate_fixture as must be a simple variable name");
|
|
801
|
+
step.fileName = requiredText(rawStep.file_name, "fixture file_name", settings.browser.maxUploadPathChars);
|
|
802
|
+
if (rawStep.content !== undefined) step.content = requiredText(rawStep.content, "fixture content", settings.browser.maxTextEntryChars);
|
|
803
|
+
if (step.kind === "bytes") {
|
|
804
|
+
if (!Number.isSafeInteger(rawStep.size_bytes) || rawStep.size_bytes <= 0 || rawStep.size_bytes > Math.min(settings.browser.maxUploadFileBytes, JEV_UPLOAD_LIMIT_BYTES)) {
|
|
805
|
+
throw new JevBrowserError("generate_fixture size_bytes must fit the configured upload limit and Jev's 47 MiB provider limit");
|
|
806
|
+
}
|
|
807
|
+
step.sizeBytes = rawStep.size_bytes;
|
|
808
|
+
} else if (rawStep.size_bytes !== undefined) throw new JevBrowserError("generate_fixture size_bytes is only valid for kind bytes");
|
|
809
|
+
}
|
|
810
|
+
if (action === "assert_download") {
|
|
811
|
+
step.filePath = requiredText(rawStep.file_path, "assert_download file_path", settings.browser.maxUploadPathChars);
|
|
812
|
+
if (rawStep.mime_type !== undefined) step.mimeType = requiredText(rawStep.mime_type, "assert_download mime_type", 128).toLowerCase();
|
|
813
|
+
}
|
|
814
|
+
if (action === "assert_db") {
|
|
815
|
+
step.sql = requiredText(rawStep.sql, "assert_db sql", settings.browser.maxDatabaseQueryChars);
|
|
816
|
+
if (!isRecord(rawStep.expected)) throw new JevBrowserError("assert_db expected must contain row_count, first_row, or rows");
|
|
817
|
+
exactKeys(rawStep.expected, [], ["row_count", "first_row", "rows"], "assert_db expected");
|
|
818
|
+
if (!Object.keys(rawStep.expected).length) throw new JevBrowserError("assert_db expected must contain at least one assertion");
|
|
819
|
+
if (rawStep.expected.row_count !== undefined && (!Number.isSafeInteger(rawStep.expected.row_count) || rawStep.expected.row_count < 0)) {
|
|
820
|
+
throw new JevBrowserError("assert_db expected.row_count must be a non-negative integer");
|
|
821
|
+
}
|
|
822
|
+
if (rawStep.expected.first_row !== undefined && !isRecord(rawStep.expected.first_row)) throw new JevBrowserError("assert_db expected.first_row must be an object");
|
|
823
|
+
if (rawStep.expected.rows !== undefined && !Array.isArray(rawStep.expected.rows)) throw new JevBrowserError("assert_db expected.rows must be an array");
|
|
824
|
+
step.expected = rawStep.expected;
|
|
825
|
+
step.binds = rawStep.binds ?? {};
|
|
826
|
+
if (!isRecord(step.binds) || Object.keys(step.binds).length > settings.browser.maxDatabaseBindValues
|
|
827
|
+
|| Object.values(step.binds).some((value) => !["string", "number", "boolean"].includes(typeof value) && value !== null)) {
|
|
828
|
+
throw new JevBrowserError("assert_db binds must be a bounded object of scalar values");
|
|
829
|
+
}
|
|
830
|
+
if (rawStep.extract !== undefined) {
|
|
831
|
+
if (!isRecord(rawStep.extract) || Object.keys(rawStep.extract).length > settings.browser.maxExternalExtractVariables) throw new JevBrowserError("assert_db extract must be a bounded object");
|
|
832
|
+
step.extract = Object.fromEntries(Object.entries(rawStep.extract).map(([name, descriptor]) => {
|
|
833
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new JevBrowserError("assert_db extract keys must be simple variable names");
|
|
834
|
+
exactKeys(descriptor, ["column"], ["row"], `assert_db extract.${name}`);
|
|
835
|
+
const row = descriptor.row ?? 0;
|
|
836
|
+
if (!Number.isSafeInteger(row) || row < 0) throw new JevBrowserError(`assert_db extract.${name}.row must be a non-negative integer`);
|
|
837
|
+
return [name, { column: requiredText(descriptor.column, `assert_db extract.${name}.column`, 128), row }];
|
|
838
|
+
}));
|
|
839
|
+
} else step.extract = {};
|
|
840
|
+
}
|
|
841
|
+
if (action === "assert_log") {
|
|
842
|
+
step.source = requiredText(rawStep.source, "assert_log source", 48);
|
|
843
|
+
step.contains = requiredText(rawStep.contains, "assert_log contains", settings.browser.maxTextEntryChars);
|
|
844
|
+
if (rawStep.not_contains !== undefined) step.notContains = requiredText(rawStep.not_contains, "assert_log not_contains", settings.browser.maxTextEntryChars);
|
|
845
|
+
step.since = rawStep.since ?? "flow";
|
|
846
|
+
if (step.since !== "flow" && !/^step:\d+$/.test(step.since)) throw new JevBrowserError("assert_log since must be flow or step:N");
|
|
847
|
+
}
|
|
848
|
+
if (action === "assert_email") {
|
|
849
|
+
for (const field of ["to", "subject_contains", "body_contains"]) {
|
|
850
|
+
if (rawStep[field] !== undefined) step[field === "subject_contains" ? "subjectContains" : field === "body_contains" ? "bodyContains" : field]
|
|
851
|
+
= requiredText(rawStep[field], `assert_email ${field}`, settings.browser.maxTextEntryChars);
|
|
852
|
+
}
|
|
853
|
+
if (!step.to && !step.subjectContains && !step.bodyContains) throw new JevBrowserError("assert_email needs at least one of to, subject_contains, or body_contains");
|
|
854
|
+
if (step.to && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(step.to)) throw new JevBrowserError("assert_email to must be a valid email address");
|
|
855
|
+
}
|
|
856
|
+
if (action === "assert_remote_file" || action === "delete_remote_file") {
|
|
857
|
+
step.server = requiredText(rawStep.server, `${action} server`, 48);
|
|
858
|
+
step.path = requiredText(rawStep.path, `${action} path`, 1024);
|
|
859
|
+
if (action === "assert_remote_file") {
|
|
860
|
+
step.exists = rawStep.exists ?? true;
|
|
861
|
+
if (typeof step.exists !== "boolean") throw new JevBrowserError("assert_remote_file exists must be a boolean");
|
|
862
|
+
if (rawStep.size_bytes !== undefined && (!Number.isSafeInteger(rawStep.size_bytes) || rawStep.size_bytes < 0)) throw new JevBrowserError("assert_remote_file size_bytes must be a non-negative integer");
|
|
863
|
+
step.sizeBytes = rawStep.size_bytes;
|
|
864
|
+
if (rawStep.sha256 !== undefined && !/^[a-f0-9]{64}$/i.test(rawStep.sha256)) throw new JevBrowserError("assert_remote_file sha256 must be a 64-character SHA-256 hex string");
|
|
865
|
+
step.sha256 = rawStep.sha256?.toLowerCase();
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
if (action === "run_hook") {
|
|
869
|
+
step.hook = requiredText(rawStep.hook, "run_hook hook", 48);
|
|
870
|
+
step.args = rawStep.args ?? {};
|
|
871
|
+
if (!isRecord(step.args)) throw new JevBrowserError("run_hook args must be an object");
|
|
872
|
+
step.expectedExitCode = rawStep.expected_exit_code ?? 0;
|
|
873
|
+
if (!Number.isSafeInteger(step.expectedExitCode) || step.expectedExitCode < 0 || step.expectedExitCode > 255) throw new JevBrowserError("run_hook expected_exit_code must be 0-255");
|
|
874
|
+
}
|
|
875
|
+
if (action === "evaluate") {
|
|
590
876
|
step.expression = requiredText(rawStep.expression, "read-only expression", 256);
|
|
591
877
|
if (!/^(?:window|document|navigator|location)(?:\.[A-Za-z_$][A-Za-z0-9_$]*){1,8}$/.test(step.expression)) {
|
|
592
878
|
throw new JevBrowserError("evaluate accepts read-only property paths such as navigator.mediaDevices or document.readyState; calls and assignments are not supported");
|
|
@@ -632,10 +918,12 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
|
|
|
632
918
|
if (["url", "title"].includes(step.source) && hasLocatorField(rawStep)) {
|
|
633
919
|
throw new JevBrowserError(`extract source ${step.source} does not use a locator`);
|
|
634
920
|
}
|
|
635
|
-
if (rawStep.sensitive !== undefined && typeof rawStep.sensitive !== "boolean") {
|
|
636
|
-
throw new JevBrowserError("extract sensitive must be a boolean");
|
|
637
|
-
}
|
|
638
|
-
|
|
921
|
+
if (rawStep.sensitive !== undefined && typeof rawStep.sensitive !== "boolean") {
|
|
922
|
+
throw new JevBrowserError("extract sensitive must be a boolean");
|
|
923
|
+
}
|
|
924
|
+
const sensitiveAttribute = step.source === "attribute"
|
|
925
|
+
&& /(?:pass(?:word)?|token|secret|authorization|cookie|credential|api[_-]?key|private[_-]?key)/i.test(step.attribute);
|
|
926
|
+
step.sensitive = rawStep.sensitive ?? (step.source !== "attribute" || sensitiveAttribute);
|
|
639
927
|
}
|
|
640
928
|
if (action === "wait_for_condition") {
|
|
641
929
|
if (!new Set(["network_idle", "url", "hidden", "text_hidden", "angular_idle"]).has(rawStep.condition)) {
|
|
@@ -699,9 +987,20 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
|
|
|
699
987
|
}
|
|
700
988
|
step.key = rawStep.key;
|
|
701
989
|
}
|
|
702
|
-
if (action === "assert_text" || action === "assert_value") {
|
|
703
|
-
step.expected = requiredText(rawStep.expected, "expected value", settings.browser.maxTextEntryChars);
|
|
704
|
-
}
|
|
990
|
+
if (action === "assert_text" || action === "assert_value") {
|
|
991
|
+
step.expected = requiredText(rawStep.expected, "expected value", settings.browser.maxTextEntryChars);
|
|
992
|
+
}
|
|
993
|
+
if (action === "assert_attribute") {
|
|
994
|
+
step.attribute = requiredText(rawStep.attribute, "attribute", 128);
|
|
995
|
+
if (!/^[A-Za-z_:][A-Za-z0-9:._-]*$/.test(step.attribute)) {
|
|
996
|
+
throw new JevBrowserError("assert_attribute attribute must be a valid HTML attribute name");
|
|
997
|
+
}
|
|
998
|
+
step.expected = requiredText(rawStep.expected, "expected value", settings.browser.maxTextEntryChars);
|
|
999
|
+
step.match = rawStep.match ?? "exact";
|
|
1000
|
+
if (!new Set(["exact", "contains"]).has(step.match)) {
|
|
1001
|
+
throw new JevBrowserError("assert_attribute match must be exact or contains");
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
705
1004
|
if (action === "upload_file") {
|
|
706
1005
|
const target = rawStep.target || (hasLocatorField(step) ? "input"
|
|
707
1006
|
: step.role && DROPZONE_ROLES.has(step.role) && step.role !== "button" ? "dropzone"
|
|
@@ -744,7 +1043,13 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
|
|
|
744
1043
|
throw new JevBrowserError(`${action} labels must distinguish liked and unliked state`);
|
|
745
1044
|
}
|
|
746
1045
|
}
|
|
747
|
-
if (rawStep.frame !== undefined)
|
|
1046
|
+
if (rawStep.frame !== undefined) {
|
|
1047
|
+
if (rawStep.frame === "first") step.frame = "first";
|
|
1048
|
+
else if (isRecord(rawStep.frame)) {
|
|
1049
|
+
exactKeys(rawStep.frame, ["url_contains"], [], "frame");
|
|
1050
|
+
step.frame = { urlContains: requiredText(rawStep.frame.url_contains, "frame URL substring", settings.jev.maxActionDescriptionChars) };
|
|
1051
|
+
} else step.frame = requiredText(rawStep.frame, "frame name", settings.jev.maxActionDescriptionChars);
|
|
1052
|
+
}
|
|
748
1053
|
return step;
|
|
749
1054
|
});
|
|
750
1055
|
plans[id] = { description, steps, ...(rawPlan.when ? { when: { visible: rawPlan.when.visible } } : {}) };
|
|
@@ -779,6 +1084,11 @@ export function normalizeStepAliases(rawStep) {
|
|
|
779
1084
|
step[canonical] = step[alias];
|
|
780
1085
|
delete step[alias];
|
|
781
1086
|
}
|
|
1087
|
+
if (Object.hasOwn(step, "trigger_match")) {
|
|
1088
|
+
if (Object.hasOwn(step, "name_match")) throw new JevBrowserError("confirm_modal cannot contain both trigger_match and name_match");
|
|
1089
|
+
step.name_match = step.trigger_match;
|
|
1090
|
+
delete step.trigger_match;
|
|
1091
|
+
}
|
|
782
1092
|
if (step.name !== undefined && step.role === undefined) step.role = "button";
|
|
783
1093
|
}
|
|
784
1094
|
return step;
|
|
@@ -832,7 +1142,16 @@ function resolveFlowOptions(rawOptions, settings) {
|
|
|
832
1142
|
}
|
|
833
1143
|
const scope = rawOptions.snapshot_scope ?? settings.browser.defaults.snapshotScope;
|
|
834
1144
|
if (!new Set(["body", "main", "dialog"]).has(scope)) throw new JevBrowserError("options.snapshot_scope must be body, main, or dialog");
|
|
835
|
-
const defaults = settings.browser.defaults;
|
|
1145
|
+
const defaults = settings.browser.defaults;
|
|
1146
|
+
const environmentProfile = rawOptions.environment_profile === undefined
|
|
1147
|
+
? undefined
|
|
1148
|
+
: requiredText(rawOptions.environment_profile, "environment_profile", 48);
|
|
1149
|
+
const selectedProfile = environmentProfile
|
|
1150
|
+
? requireEnvironmentProfile(settings, environmentProfile, "options.environment_profile")
|
|
1151
|
+
: undefined;
|
|
1152
|
+
if ((rawOptions.allow_mutations ?? false) && selectedProfile?.environment === "prd") {
|
|
1153
|
+
throw new JevBrowserError("allow_mutations is forbidden for PRD profiles");
|
|
1154
|
+
}
|
|
836
1155
|
const stepTimeoutSeconds = rawOptions.step_timeout_seconds ?? settings.browser.actionTimeoutMs / 1000;
|
|
837
1156
|
if (typeof stepTimeoutSeconds !== "number" || !Number.isFinite(stepTimeoutSeconds) || stepTimeoutSeconds <= 0
|
|
838
1157
|
|| stepTimeoutSeconds > settings.browser.maxStepTimeoutSeconds) {
|
|
@@ -958,10 +1277,18 @@ function resolveFlowOptions(rawOptions, settings) {
|
|
|
958
1277
|
if (storageState !== undefined && !/^[A-Za-z0-9_-]+$/.test(storageState)) {
|
|
959
1278
|
throw new JevBrowserError("options.storage_state must be a simple account name using letters, numbers, _ or -");
|
|
960
1279
|
}
|
|
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
|
-
|
|
1280
|
+
if (storageState && (rawOptions.fresh_context || rawOptions.clear_storage)) {
|
|
1281
|
+
throw new JevBrowserError("options.storage_state cannot be combined with fresh_context or clear_storage");
|
|
1282
|
+
}
|
|
1283
|
+
const configuredNetworkErrors = rawOptions.capture_network_errors ?? defaults.captureNetworkErrors;
|
|
1284
|
+
const configuredNetworkErrorBodies = rawOptions.capture_network_error_bodies ?? defaults.captureNetworkErrorBodies;
|
|
1285
|
+
const captureNetwork = rawOptions.capture_network ?? (configuredNetworkErrors || configuredNetworkErrorBodies ? "errors" : "none");
|
|
1286
|
+
if (!new Set(["none", "errors", "all"]).has(captureNetwork)) {
|
|
1287
|
+
throw new JevBrowserError("options.capture_network must be none, errors, or all");
|
|
1288
|
+
}
|
|
1289
|
+
const captureNetworkUrlContains = rawOptions.capture_network_url_contains === undefined
|
|
1290
|
+
? undefined : requiredText(rawOptions.capture_network_url_contains, "capture_network_url_contains", settings.jev.maxActionDescriptionChars);
|
|
1291
|
+
let reportPath;
|
|
965
1292
|
if (rawOptions.report_path !== undefined) {
|
|
966
1293
|
if (typeof rawOptions.report_path !== "string" || !path.isAbsolute(rawOptions.report_path)) {
|
|
967
1294
|
throw new JevBrowserError("options.report_path must be an absolute path inside the configured artifact directory");
|
|
@@ -977,11 +1304,14 @@ function resolveFlowOptions(rawOptions, settings) {
|
|
|
977
1304
|
blockFonts: rawOptions.block_fonts ?? defaults.blockFonts,
|
|
978
1305
|
busySelectors: Object.freeze(busySelectors),
|
|
979
1306
|
captureConsoleErrors: rawOptions.capture_console_errors ?? defaults.captureConsoleErrors,
|
|
980
|
-
captureNetworkErrorBodies:
|
|
981
|
-
captureNetworkErrors:
|
|
1307
|
+
captureNetworkErrorBodies: captureNetwork !== "none" && configuredNetworkErrorBodies,
|
|
1308
|
+
captureNetworkErrors: captureNetwork === "errors" || captureNetwork === "all",
|
|
1309
|
+
captureNetwork,
|
|
1310
|
+
captureNetworkUrlContains,
|
|
982
1311
|
dryRun: rawOptions.dry_run ?? false,
|
|
983
1312
|
fastPath: rawOptions.fast_path ?? defaults.fastPath,
|
|
984
|
-
localOnly: rawOptions.local_only ?? defaults.localOnly,
|
|
1313
|
+
localOnly: rawOptions.local_only ?? defaults.localOnly,
|
|
1314
|
+
environmentProfile,
|
|
985
1315
|
loginText: Object.freeze(loginText),
|
|
986
1316
|
loginUrlContains: Object.freeze(loginUrlContains),
|
|
987
1317
|
permissions: Object.freeze([...permissions]),
|
|
@@ -1026,11 +1356,29 @@ function escapeRegex(value) {
|
|
|
1026
1356
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1027
1357
|
}
|
|
1028
1358
|
|
|
1029
|
-
function planSecrets(plans) {
|
|
1030
|
-
const secrets = new Set();
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
if (
|
|
1359
|
+
function planSecrets(plans) {
|
|
1360
|
+
const secrets = new Set();
|
|
1361
|
+
const collectSensitiveValues = (value, sensitiveParent = false) => {
|
|
1362
|
+
if (typeof value === "string") {
|
|
1363
|
+
if (sensitiveParent && value.length >= 4) secrets.add(value);
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
if (Array.isArray(value)) {
|
|
1367
|
+
for (const item of value) collectSensitiveValues(item, sensitiveParent);
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
if (isRecord(value)) {
|
|
1371
|
+
for (const [key, item] of Object.entries(value)) collectSensitiveValues(item, sensitiveParent || /pass(?:word)?|secret|token|authorization|cookie|credential|api[_-]?key|private[_-]?key/i.test(key));
|
|
1372
|
+
}
|
|
1373
|
+
};
|
|
1374
|
+
for (const plan of Object.values(plans)) {
|
|
1375
|
+
for (const step of plan.steps) {
|
|
1376
|
+
if (step.action === "type" && step.sensitive) secrets.add(step.text);
|
|
1377
|
+
if (step.action === "http_request") {
|
|
1378
|
+
collectSensitiveValues(step.json);
|
|
1379
|
+
collectSensitiveValues(step.multipart);
|
|
1380
|
+
}
|
|
1381
|
+
if (step.action === "assert_db") collectSensitiveValues(step.binds);
|
|
1034
1382
|
if (step.action === "upload_file") {
|
|
1035
1383
|
for (const filePath of step.filePaths) {
|
|
1036
1384
|
secrets.add(filePath);
|
|
@@ -1145,28 +1493,55 @@ function providerSafeSnapshot(snapshot, secrets) {
|
|
|
1145
1493
|
});
|
|
1146
1494
|
}
|
|
1147
1495
|
|
|
1148
|
-
function localHostOnly(url) {
|
|
1149
|
-
return new Set(["localhost", "127.0.0.1", "::1", "[::1]"]).has(new URL(url).hostname.toLowerCase());
|
|
1150
|
-
}
|
|
1496
|
+
function localHostOnly(url) {
|
|
1497
|
+
return new Set(["localhost", "127.0.0.1", "::1", "[::1]"]).has(new URL(url).hostname.toLowerCase());
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
const SAFE_RESPONSE_HEADER_NAMES = new Set([
|
|
1501
|
+
"cache-control", "content-length", "content-type", "date", "etag", "last-modified", "retry-after", "vary",
|
|
1502
|
+
]);
|
|
1503
|
+
|
|
1504
|
+
function safeResponseHeaders(headers) {
|
|
1505
|
+
return Object.fromEntries(Object.entries(headers || {})
|
|
1506
|
+
.filter(([name, value]) => SAFE_RESPONSE_HEADER_NAMES.has(name.toLowerCase()) && typeof value === "string")
|
|
1507
|
+
.map(([name, value]) => [name.toLowerCase(), value.slice(0, 256)]));
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
function assertLocalDebugPage(page, localOnly, action) {
|
|
1511
|
+
const currentUrl = page.url();
|
|
1512
|
+
if (!localOnly || !currentUrl || currentUrl === "about:blank" || !localHostOnly(currentUrl)) {
|
|
1513
|
+
throw new JevBrowserError(`${action} requires options.local_only: true and a localhost page`);
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
function redactLocalPayload(value) {
|
|
1518
|
+
if (Array.isArray(value)) return value.map(redactLocalPayload);
|
|
1519
|
+
if (!isRecord(value)) return value;
|
|
1520
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
1521
|
+
key,
|
|
1522
|
+
/(?:auth|cookie|credential|secret|password|token|session|user|usuario|nome|email|cpf|cnpj|matricula|identity)/i.test(key)
|
|
1523
|
+
? "[redacted]" : redactLocalPayload(item),
|
|
1524
|
+
]));
|
|
1525
|
+
}
|
|
1151
1526
|
|
|
1152
|
-
async function resolveFakeMediaPath(fakeMedia, settings) {
|
|
1153
|
-
if (!fakeMedia) return undefined;
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
const
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1527
|
+
async function resolveFakeMediaPath(fakeMedia, settings) {
|
|
1528
|
+
if (!fakeMedia) return undefined;
|
|
1529
|
+
const staging = await prepareUploadStagingDirectory(settings);
|
|
1530
|
+
const roots = [settings.browser.uploadRoot, staging].filter(Boolean);
|
|
1531
|
+
let audio;
|
|
1532
|
+
for (const rootPath of roots) {
|
|
1533
|
+
const candidate = path.isAbsolute(fakeMedia) ? fakeMedia : path.resolve(rootPath, fakeMedia);
|
|
1534
|
+
const resolved = await realpath(candidate).catch(() => undefined);
|
|
1535
|
+
if (!resolved) continue;
|
|
1536
|
+
const root = await realpath(rootPath).catch(() => undefined);
|
|
1537
|
+
if (!root) continue;
|
|
1538
|
+
const relative = path.relative(root, resolved);
|
|
1539
|
+
if (relative && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) {
|
|
1540
|
+
audio = resolved;
|
|
1541
|
+
break;
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
if (!audio) throw new JevBrowserError("fake_media must resolve inside managed project staging or JEV_BROWSER_UPLOAD_ROOT");
|
|
1170
1545
|
if (path.extname(audio).toLowerCase() !== ".wav") throw new JevBrowserError("fake_media accepts WAV files only");
|
|
1171
1546
|
const file = await stat(audio);
|
|
1172
1547
|
if (!file.isFile() || file.size > settings.browser.maxUploadFileBytes) {
|
|
@@ -1665,12 +2040,20 @@ async function evaluateExpectedOutcome(expected, snapshot, networkTracker, marke
|
|
|
1665
2040
|
return checks;
|
|
1666
2041
|
}
|
|
1667
2042
|
|
|
1668
|
-
function createNetworkTracker(page, settings, loginUrlContains = []) {
|
|
1669
|
-
const active = new Map();
|
|
1670
|
-
const history = [];
|
|
2043
|
+
function createNetworkTracker(page, settings, loginUrlContains = [], watchPatterns = []) {
|
|
2044
|
+
const active = new Map();
|
|
2045
|
+
const history = [];
|
|
2046
|
+
const watchedHistory = [];
|
|
2047
|
+
const watchedResponses = new Map();
|
|
2048
|
+
const responseWaiters = new Set();
|
|
2049
|
+
const responseObservers = new Set();
|
|
1671
2050
|
const websocketHistory = [];
|
|
1672
2051
|
const websocketUrls = new Map();
|
|
1673
2052
|
const websocketObserverPromises = new Set();
|
|
2053
|
+
const blockedCookieEvents = [];
|
|
2054
|
+
const cdpRequestUrls = new Map();
|
|
2055
|
+
const cdpSessions = new Set();
|
|
2056
|
+
const pendingHeaderReads = new Set();
|
|
1674
2057
|
const ignored = new Set();
|
|
1675
2058
|
let sequence = 0;
|
|
1676
2059
|
const shouldIgnore = (request) => {
|
|
@@ -1684,21 +2067,58 @@ function createNetworkTracker(page, settings, loginUrlContains = []) {
|
|
|
1684
2067
|
return undefined;
|
|
1685
2068
|
}
|
|
1686
2069
|
if (ignored.has(request)) return undefined;
|
|
1687
|
-
const
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
if (
|
|
2070
|
+
const startedAt = active.get(request)?.at;
|
|
2071
|
+
const entry = {
|
|
2072
|
+
sequence: ++sequence, type, url: request.url(), method: request.method(),
|
|
2073
|
+
resourceType: request.resourceType(), at: performance.now(), request,
|
|
2074
|
+
};
|
|
2075
|
+
if (startedAt !== undefined && type !== "request") entry.durationMs = Math.max(0, entry.at - startedAt);
|
|
2076
|
+
if (type === "request") active.set(request, entry);
|
|
2077
|
+
else if (type === "finished" || type === "failed") {
|
|
2078
|
+
const original = active.get(request);
|
|
2079
|
+
if (original && entry.durationMs !== undefined) original.durationMs = entry.durationMs;
|
|
2080
|
+
active.delete(request);
|
|
2081
|
+
}
|
|
2082
|
+
history.push(entry);
|
|
2083
|
+
const historyLimit = settings.browser.maxNetworkHistoryEvents;
|
|
2084
|
+
if (history.length > historyLimit) history.splice(0, history.length - historyLimit);
|
|
2085
|
+
if (watchPatterns.some((pattern) => pattern.includes("*")
|
|
2086
|
+
? urlPatternMatcher(pattern).test(entry.url) : entry.url.includes(pattern))) {
|
|
2087
|
+
watchedHistory.push(entry);
|
|
2088
|
+
if (watchedHistory.length > historyLimit) watchedHistory.splice(0, watchedHistory.length - historyLimit);
|
|
2089
|
+
}
|
|
1693
2090
|
return entry;
|
|
1694
2091
|
};
|
|
1695
2092
|
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
|
-
|
|
2093
|
+
const onResponse = (response) => {
|
|
2094
|
+
const entry = record("response", response.request());
|
|
2095
|
+
if (!entry) return;
|
|
2096
|
+
entry.response = response;
|
|
2097
|
+
entry.status = response.status();
|
|
2098
|
+
if (watchPatterns.some((pattern) => pattern.includes("*")
|
|
2099
|
+
? urlPatternMatcher(pattern).test(entry.url) : entry.url.includes(pattern))) {
|
|
2100
|
+
const watchKey = `${entry.url}\n${entry.method}\n${entry.status}`;
|
|
2101
|
+
watchedResponses.delete(watchKey);
|
|
2102
|
+
watchedResponses.set(watchKey, entry);
|
|
2103
|
+
while (watchedResponses.size > settings.browser.maxNetworkHistoryEvents) {
|
|
2104
|
+
watchedResponses.delete(watchedResponses.keys().next().value);
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
const requestEntry = active.get(response.request());
|
|
2108
|
+
if (requestEntry && entry.durationMs !== undefined) requestEntry.durationMs = entry.durationMs;
|
|
2109
|
+
const headerRead = Promise.resolve().then(() => response.allHeaders())
|
|
2110
|
+
.then((headers) => { entry.responseHeaders = safeResponseHeaders(headers); }).catch(() => {});
|
|
2111
|
+
pendingHeaderReads.add(headerRead);
|
|
2112
|
+
void headerRead.finally(() => pendingHeaderReads.delete(headerRead));
|
|
2113
|
+
for (const waiter of [...responseWaiters]) {
|
|
2114
|
+
if (entry.sequence > waiter.marker && waiter.predicate(entry)) {
|
|
2115
|
+
responseWaiters.delete(waiter);
|
|
2116
|
+
clearTimeout(waiter.timer);
|
|
2117
|
+
waiter.resolve(entry);
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
for (const observer of [...responseObservers]) observer.capture(entry);
|
|
2121
|
+
};
|
|
1702
2122
|
const onFinished = (request) => record("finished", request);
|
|
1703
2123
|
const onFailed = (request) => record("failed", request);
|
|
1704
2124
|
const trackedPages = new Set();
|
|
@@ -1717,9 +2137,41 @@ function createNetworkTracker(page, settings, loginUrlContains = []) {
|
|
|
1717
2137
|
};
|
|
1718
2138
|
const attachCdpWebSocketObserver = async (trackedPage) => {
|
|
1719
2139
|
try {
|
|
1720
|
-
const session = await trackedPage.context().newCDPSession(trackedPage);
|
|
1721
|
-
|
|
1722
|
-
session.
|
|
2140
|
+
const session = await trackedPage.context().newCDPSession(trackedPage);
|
|
2141
|
+
cdpSessions.add(session);
|
|
2142
|
+
await session.send("Network.enable");
|
|
2143
|
+
session.on("Network.requestWillBeSent", ({ requestId, request }) => {
|
|
2144
|
+
if (request?.url) {
|
|
2145
|
+
cdpRequestUrls.set(requestId, request.url);
|
|
2146
|
+
while (cdpRequestUrls.size > settings.browser.maxNetworkHistoryEvents) {
|
|
2147
|
+
cdpRequestUrls.delete(cdpRequestUrls.keys().next().value);
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
});
|
|
2151
|
+
session.on("Network.responseReceivedExtraInfo", ({ requestId, statusCode, headers, blockedCookies }) => {
|
|
2152
|
+
const url = cdpRequestUrls.get(requestId);
|
|
2153
|
+
if (!url) return;
|
|
2154
|
+
const blocked = Array.isArray(blockedCookies) ? blockedCookies.map(({ cookie, blockedReasons }) => ({
|
|
2155
|
+
name: String(cookie?.name || "").slice(0, 128),
|
|
2156
|
+
domain: String(cookie?.domain || "").slice(0, 255),
|
|
2157
|
+
path: String(cookie?.path || "").slice(0, 256),
|
|
2158
|
+
secure: Boolean(cookie?.secure),
|
|
2159
|
+
http_only: Boolean(cookie?.httpOnly),
|
|
2160
|
+
same_site: cookie?.sameSite,
|
|
2161
|
+
blocked_reasons: Array.isArray(blockedReasons) ? blockedReasons.map((reason) => String(reason).slice(0, 128)) : [],
|
|
2162
|
+
})) : [];
|
|
2163
|
+
if (blocked.length) {
|
|
2164
|
+
blockedCookieEvents.push({ sequence: ++sequence, url, status: statusCode, cookies: blocked });
|
|
2165
|
+
if (blockedCookieEvents.length > settings.browser.maxNetworkHistoryEvents) {
|
|
2166
|
+
blockedCookieEvents.splice(0, blockedCookieEvents.length - settings.browser.maxNetworkHistoryEvents);
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
const safeHeaders = safeResponseHeaders(headers);
|
|
2170
|
+
const matching = [...history, ...watchedHistory].reverse().find((event) => event.type === "response" && event.url === url);
|
|
2171
|
+
if (matching && Object.keys(safeHeaders).length) matching.responseHeaders = safeHeaders;
|
|
2172
|
+
cdpRequestUrls.delete(requestId);
|
|
2173
|
+
});
|
|
2174
|
+
session.on("Network.webSocketCreated", ({ requestId, url }) => websocketUrls.set(requestId, url));
|
|
1723
2175
|
session.on("Network.webSocketFrameReceived", ({ requestId, response }) => {
|
|
1724
2176
|
if (response?.opcode !== 8 || typeof response.payloadData !== "string") return;
|
|
1725
2177
|
try {
|
|
@@ -1732,7 +2184,10 @@ function createNetworkTracker(page, settings, loginUrlContains = []) {
|
|
|
1732
2184
|
} catch { /* A non-binary close payload has no status code. */ }
|
|
1733
2185
|
});
|
|
1734
2186
|
session.on("Network.webSocketClosed", ({ requestId }) => websocketUrls.delete(requestId));
|
|
1735
|
-
trackedPage.once("close", () => {
|
|
2187
|
+
trackedPage.once("close", () => {
|
|
2188
|
+
cdpSessions.delete(session);
|
|
2189
|
+
void session.detach().catch(() => {});
|
|
2190
|
+
});
|
|
1736
2191
|
} catch {
|
|
1737
2192
|
// WebSocket close-code assertions are available only on Chromium CDP sessions.
|
|
1738
2193
|
}
|
|
@@ -1760,15 +2215,73 @@ function createNetworkTracker(page, settings, loginUrlContains = []) {
|
|
|
1760
2215
|
websocketObserverPromises.add(ready);
|
|
1761
2216
|
return ready;
|
|
1762
2217
|
};
|
|
1763
|
-
|
|
2218
|
+
const historySince = (marker) => [...new Map([...history, ...watchedHistory, ...watchedResponses.values()]
|
|
2219
|
+
.filter((entry) => entry.sequence > marker)
|
|
2220
|
+
.map((entry) => [entry.sequence, entry])).values()].sort((left, right) => left.sequence - right.sequence);
|
|
2221
|
+
attachPage(page);
|
|
1764
2222
|
return {
|
|
1765
2223
|
mark: () => sequence,
|
|
1766
2224
|
attachPage,
|
|
1767
2225
|
waitForWebSocketObservers: async () => Promise.allSettled([...websocketObserverPromises]),
|
|
2226
|
+
observeResponseSince: (marker, predicate) => {
|
|
2227
|
+
const observer = {
|
|
2228
|
+
entry: historySince(marker).find((candidate) => candidate.type === "response" && predicate(candidate)),
|
|
2229
|
+
resolve: undefined,
|
|
2230
|
+
timer: undefined,
|
|
2231
|
+
capture(candidate) {
|
|
2232
|
+
if (this.entry || candidate.sequence <= marker || !predicate(candidate)) return;
|
|
2233
|
+
this.entry = candidate;
|
|
2234
|
+
responseObservers.delete(this);
|
|
2235
|
+
clearTimeout(this.timer);
|
|
2236
|
+
this.timer = undefined;
|
|
2237
|
+
const resolve = this.resolve;
|
|
2238
|
+
this.resolve = undefined;
|
|
2239
|
+
resolve?.(candidate);
|
|
2240
|
+
},
|
|
2241
|
+
wait(timeout) {
|
|
2242
|
+
if (this.entry) return Promise.resolve(this.entry);
|
|
2243
|
+
return new Promise((resolve) => {
|
|
2244
|
+
this.resolve = resolve;
|
|
2245
|
+
this.timer = setTimeout(() => {
|
|
2246
|
+
responseObservers.delete(this);
|
|
2247
|
+
this.timer = undefined;
|
|
2248
|
+
this.resolve = undefined;
|
|
2249
|
+
resolve(undefined);
|
|
2250
|
+
}, timeout);
|
|
2251
|
+
});
|
|
2252
|
+
},
|
|
2253
|
+
dispose() {
|
|
2254
|
+
responseObservers.delete(this);
|
|
2255
|
+
clearTimeout(this.timer);
|
|
2256
|
+
this.timer = undefined;
|
|
2257
|
+
const resolve = this.resolve;
|
|
2258
|
+
this.resolve = undefined;
|
|
2259
|
+
resolve?.(this.entry);
|
|
2260
|
+
},
|
|
2261
|
+
};
|
|
2262
|
+
if (!observer.entry) responseObservers.add(observer);
|
|
2263
|
+
return observer;
|
|
2264
|
+
},
|
|
2265
|
+
waitForResponseSince: (marker, predicate, timeout) => {
|
|
2266
|
+
const existing = historySince(marker).find((entry) => entry.type === "response" && predicate(entry));
|
|
2267
|
+
if (existing) return Promise.resolve(existing);
|
|
2268
|
+
return new Promise((resolve) => {
|
|
2269
|
+
const waiter = {
|
|
2270
|
+
marker,
|
|
2271
|
+
predicate,
|
|
2272
|
+
timer: setTimeout(() => {
|
|
2273
|
+
responseWaiters.delete(waiter);
|
|
2274
|
+
resolve(undefined);
|
|
2275
|
+
}, timeout),
|
|
2276
|
+
resolve,
|
|
2277
|
+
};
|
|
2278
|
+
responseWaiters.add(waiter);
|
|
2279
|
+
});
|
|
2280
|
+
},
|
|
1768
2281
|
since: (marker, urlContains) => history.filter((entry) => entry.sequence > marker
|
|
1769
2282
|
&& (urlContains === undefined || entry.url.includes(urlContains))),
|
|
1770
|
-
responsesSince: (marker, urlContains, method) =>
|
|
1771
|
-
|
|
2283
|
+
responsesSince: (marker, urlContains, method) => historySince(marker).filter((entry) =>
|
|
2284
|
+
entry.type === "response" && entry.url.includes(urlContains) && entry.method === method),
|
|
1772
2285
|
websocketSince: (marker, urlContains) => websocketHistory.filter((entry) => entry.sequence > marker && entry.url.includes(urlContains)),
|
|
1773
2286
|
collectWebSocketCloseCodes: async () => {
|
|
1774
2287
|
for (const trackedPage of trackedPages) {
|
|
@@ -1791,13 +2304,35 @@ function createNetworkTracker(page, settings, loginUrlContains = []) {
|
|
|
1791
2304
|
}
|
|
1792
2305
|
}
|
|
1793
2306
|
},
|
|
1794
|
-
historySince
|
|
2307
|
+
historySince,
|
|
2308
|
+
networkEventsSince: (marker, urlContains, limit = settings.jev.maxDiagnosticItems) => {
|
|
2309
|
+
const events = historySince(marker);
|
|
2310
|
+
const entries = events.filter((entry) => entry.type === "request"
|
|
2311
|
+
&& (urlContains === undefined || entry.url.includes(urlContains)));
|
|
2312
|
+
return entries.slice(-limit).map((requestEntry) => {
|
|
2313
|
+
const response = events.find((event) => event.type === "response" && event.request === requestEntry.request);
|
|
2314
|
+
const terminal = events.find((event) => (event.type === "finished" || event.type === "failed")
|
|
2315
|
+
&& event.request === requestEntry.request);
|
|
2316
|
+
return {
|
|
2317
|
+
url: requestEntry.url,
|
|
2318
|
+
method: requestEntry.method,
|
|
2319
|
+
resource_type: requestEntry.resourceType,
|
|
2320
|
+
...(response ? { status: response.status } : {}),
|
|
2321
|
+
...(requestEntry.durationMs !== undefined ? { duration_ms: Math.round(requestEntry.durationMs * 10) / 10 }
|
|
2322
|
+
: terminal?.durationMs !== undefined ? { duration_ms: Math.round(terminal.durationMs * 10) / 10 } : {}),
|
|
2323
|
+
...(response?.responseHeaders ? { response_headers: response.responseHeaders } : {}),
|
|
2324
|
+
};
|
|
2325
|
+
});
|
|
2326
|
+
},
|
|
2327
|
+
blockedCookiesSince: (marker, urlContains, limit = settings.jev.maxDiagnosticItems) => blockedCookieEvents
|
|
2328
|
+
.filter((event) => event.sequence > marker && (urlContains === undefined || event.url.includes(urlContains)))
|
|
2329
|
+
.slice(-limit),
|
|
1795
2330
|
authenticationFailureSince: (marker) => history.find((entry) => {
|
|
1796
2331
|
if (entry.sequence <= marker || entry.type !== "response" || ![401, 403].includes(entry.status)) return false;
|
|
1797
2332
|
return loginUrlContains.some((needle) => entry.url.toLocaleLowerCase().includes(needle.toLocaleLowerCase()));
|
|
1798
2333
|
}),
|
|
1799
2334
|
activeFor: (urlContains) => [...active.values()].filter((entry) => entry.url.includes(urlContains)),
|
|
1800
|
-
dispose: () => {
|
|
2335
|
+
dispose: async () => {
|
|
1801
2336
|
for (const trackedPage of trackedPages) {
|
|
1802
2337
|
trackedPage.off("request", onRequest);
|
|
1803
2338
|
trackedPage.off("response", onResponse);
|
|
@@ -1805,11 +2340,16 @@ function createNetworkTracker(page, settings, loginUrlContains = []) {
|
|
|
1805
2340
|
trackedPage.off("requestfailed", onFailed);
|
|
1806
2341
|
trackedPage.off("websocket", onWebSocket);
|
|
1807
2342
|
}
|
|
1808
|
-
trackedPages.clear();
|
|
1809
|
-
active.clear();
|
|
1810
|
-
ignored.clear();
|
|
1811
|
-
|
|
1812
|
-
|
|
2343
|
+
trackedPages.clear();
|
|
2344
|
+
active.clear();
|
|
2345
|
+
ignored.clear();
|
|
2346
|
+
for (const observer of [...responseObservers]) observer.dispose();
|
|
2347
|
+
responseObservers.clear();
|
|
2348
|
+
await Promise.allSettled([...pendingHeaderReads]);
|
|
2349
|
+
pendingHeaderReads.clear();
|
|
2350
|
+
cdpRequestUrls.clear();
|
|
2351
|
+
await Promise.allSettled([...cdpSessions].map((session) => session.detach()));
|
|
2352
|
+
cdpSessions.clear();
|
|
1813
2353
|
},
|
|
1814
2354
|
};
|
|
1815
2355
|
}
|
|
@@ -1922,10 +2462,13 @@ async function networkResponseMessage(response, page, settings, secrets) {
|
|
|
1922
2462
|
}
|
|
1923
2463
|
}
|
|
1924
2464
|
|
|
1925
|
-
async function waitForNetworkAssertion(tracker, step, marker, timeout, page, settings, secrets) {
|
|
1926
|
-
const started = performance.now();
|
|
1927
|
-
while (performance.now() - started < timeout) {
|
|
1928
|
-
const [
|
|
2465
|
+
async function waitForNetworkAssertion(tracker, step, marker, initialNavigationMarker, timeout, page, settings, secrets) {
|
|
2466
|
+
const started = performance.now();
|
|
2467
|
+
while (performance.now() - started < timeout) {
|
|
2468
|
+
const [stepResponse] = tracker.responsesSince(marker, step.urlContains, step.method);
|
|
2469
|
+
const entry = stepResponse || (initialNavigationMarker === undefined
|
|
2470
|
+
? undefined
|
|
2471
|
+
: tracker.responsesSince(initialNavigationMarker, step.urlContains, step.method).at(-1));
|
|
1929
2472
|
if (entry) {
|
|
1930
2473
|
if (entry.status !== step.status) {
|
|
1931
2474
|
throw new JevBrowserError(`assert_network expected ${step.method} ${JSON.stringify(redact(step.urlContains, secrets))} status ${step.status}, received ${entry.status}`);
|
|
@@ -1949,17 +2492,15 @@ function urlPatternMatcher(pattern) {
|
|
|
1949
2492
|
return new RegExp(`^${pattern.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`, "i");
|
|
1950
2493
|
}
|
|
1951
2494
|
|
|
1952
|
-
async function waitForRequestAssertion(tracker, step, marker, timeout,
|
|
1953
|
-
const matcher = urlPatternMatcher(step.urlPattern);
|
|
1954
|
-
const
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
await new Promise((resolve) => setTimeout(resolve, Math.min(settings.browser.visibilityPollMs, remainingMs(started + timeout))));
|
|
1962
|
-
}
|
|
2495
|
+
async function waitForRequestAssertion(tracker, step, marker, timeout, observer) {
|
|
2496
|
+
const matcher = urlPatternMatcher(step.urlPattern);
|
|
2497
|
+
const matches = (event) => matcher.test(event.url)
|
|
2498
|
+
&& (!step.method || event.method === step.method)
|
|
2499
|
+
&& (step.status === undefined || event.status === step.status);
|
|
2500
|
+
const entry = observer
|
|
2501
|
+
? await observer.wait(timeout)
|
|
2502
|
+
: await tracker.waitForResponseSince(marker, matches, timeout);
|
|
2503
|
+
if (entry) return entry;
|
|
1963
2504
|
throw new JevBrowserError(`wait_for_request did not observe ${step.method || "any method"} ${JSON.stringify(step.urlPattern)}${step.status ? ` status ${step.status}` : ""} within ${timeout}ms`);
|
|
1964
2505
|
}
|
|
1965
2506
|
|
|
@@ -2485,12 +3026,13 @@ function xpathRolePredicate(role) {
|
|
|
2485
3026
|
return native ? `@role='${role}' or ${native}` : `@role='${role}'`;
|
|
2486
3027
|
}
|
|
2487
3028
|
|
|
2488
|
-
function relativeTarget(anchor, role) {
|
|
2489
|
-
const
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
3029
|
+
function relativeTarget(anchor, role, includeFollowingControls = false) {
|
|
3030
|
+
const firstOnly = includeFollowingControls ? "" : "[1]";
|
|
3031
|
+
const selector = TEXT_ROLES.has(role)
|
|
3032
|
+
? `xpath=following::*[self::input or self::textarea or self::select or @role='textbox' or @role='searchbox' or @role='combobox']${firstOnly}`
|
|
3033
|
+
: role === "link" ? `xpath=following::*[self::a or @role='link']${firstOnly}`
|
|
3034
|
+
: role === "button" ? `xpath=following::*[self::button or @role='button']${firstOnly}`
|
|
3035
|
+
: `xpath=following::*[self::input or self::textarea or self::select or self::button or self::a or @role='textbox' or @role='combobox' or @role='button' or @role='link']${firstOnly}`;
|
|
2494
3036
|
return anchor.locator(selector);
|
|
2495
3037
|
}
|
|
2496
3038
|
|
|
@@ -2525,8 +3067,10 @@ async function targetLocator(root, step, timeout, settings) {
|
|
|
2525
3067
|
locator = step.action === "type" && step.role ? relativeTarget(anchor, step.role) : anchor;
|
|
2526
3068
|
} else if (field === "test_id") {
|
|
2527
3069
|
locator = targetRoot.getByTestId(step.testId);
|
|
2528
|
-
} else if (step.near) {
|
|
2529
|
-
|
|
3070
|
+
} else if (step.near) {
|
|
3071
|
+
const anchor = targetRoot.getByText(step.near.text, { exact: true }).first();
|
|
3072
|
+
locator = relativeTarget(anchor, step.role, step.name !== undefined);
|
|
3073
|
+
if (step.name !== undefined) locator = locator.and(targetRoot.getByRole(step.role, roleOptions(step)));
|
|
2530
3074
|
} else if (step.role) {
|
|
2531
3075
|
locator = targetRoot.getByRole(step.role, roleOptions(step));
|
|
2532
3076
|
if (step.name && (!step.nameMatch || step.nameMatch === "exact") && await locator.count() === 0) {
|
|
@@ -2651,19 +3195,29 @@ async function namedTarget(root, step, snapshot, secrets, timeout, settings) {
|
|
|
2651
3195
|
return locatorAt(locator, match.visible[0]);
|
|
2652
3196
|
}
|
|
2653
3197
|
|
|
2654
|
-
async function stepRoot(page, frameName) {
|
|
2655
|
-
if (!frameName) return page;
|
|
2656
|
-
const frames =
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
}
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
3198
|
+
async function stepRoot(page, frameName) {
|
|
3199
|
+
if (!frameName) return page;
|
|
3200
|
+
const frames = page.frames().filter((candidate) => candidate !== page.mainFrame());
|
|
3201
|
+
if (frameName === "first") {
|
|
3202
|
+
if (frames.length) return frames[0];
|
|
3203
|
+
throw new JevBrowserError("frame first found no iframe on the page");
|
|
3204
|
+
}
|
|
3205
|
+
if (isRecord(frameName) && frameName.urlContains) {
|
|
3206
|
+
const matches = frames.filter((frame) => frame.url().includes(frameName.urlContains));
|
|
3207
|
+
if (matches.length === 1) return matches[0];
|
|
3208
|
+
throw new JevBrowserError(`frame URL substring ${JSON.stringify(frameName.urlContains)} must match exactly one iframe; found ${matches.length}`);
|
|
3209
|
+
}
|
|
3210
|
+
const namedFrames = [];
|
|
3211
|
+
for (const frame of frames) {
|
|
3212
|
+
const name = await frameDisplayName(frame);
|
|
3213
|
+
if (name) namedFrames.push({ frame, name });
|
|
3214
|
+
}
|
|
3215
|
+
const matches = namedFrames.filter(({ name }) => name === frameName);
|
|
3216
|
+
if (matches.length !== 1) {
|
|
3217
|
+
const available = namedFrames.map(({ name }) => name).slice(0, 5);
|
|
3218
|
+
const suffix = available.length ? `; available frames: ${available.map((name) => JSON.stringify(name)).join(", ")}` : "";
|
|
3219
|
+
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`);
|
|
3220
|
+
}
|
|
2667
3221
|
return matches[0].frame;
|
|
2668
3222
|
}
|
|
2669
3223
|
|
|
@@ -2697,17 +3251,18 @@ function fileMimeType(fileName) {
|
|
|
2697
3251
|
return MIME_TYPES.get(path.extname(fileName).toLowerCase()) || "application/octet-stream";
|
|
2698
3252
|
}
|
|
2699
3253
|
|
|
2700
|
-
async function uploadFiles(step, settings) {
|
|
2701
|
-
const
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
3254
|
+
async function uploadFiles(step, settings) {
|
|
3255
|
+
const roots = [settings.browser.uploadRoot, await prepareUploadStagingDirectory(settings)].filter(Boolean);
|
|
3256
|
+
const allowedRoots = [];
|
|
3257
|
+
for (const candidate of roots) {
|
|
3258
|
+
try {
|
|
3259
|
+
const root = await realpath(candidate);
|
|
3260
|
+
if ((await stat(root)).isDirectory()) allowedRoots.push(root);
|
|
3261
|
+
} catch {
|
|
3262
|
+
if (candidate === settings.browser.uploadRoot) throw new JevBrowserError("configured JEV_BROWSER_UPLOAD_ROOT does not exist or is not accessible");
|
|
3263
|
+
}
|
|
3264
|
+
}
|
|
3265
|
+
if (!allowedRoots.length) throw new JevBrowserError("no permitted upload directory is available");
|
|
2711
3266
|
let totalBytes = 0;
|
|
2712
3267
|
const files = [];
|
|
2713
3268
|
for (const requestedPath of step.filePaths) {
|
|
@@ -2718,9 +3273,12 @@ async function uploadFiles(step, settings) {
|
|
|
2718
3273
|
} catch {
|
|
2719
3274
|
throw new JevBrowserError("upload_file could not read one of the configured files");
|
|
2720
3275
|
}
|
|
2721
|
-
const
|
|
2722
|
-
|
|
2723
|
-
|
|
3276
|
+
const allowed = allowedRoots.some((root) => {
|
|
3277
|
+
const relativePath = path.relative(root, resolved);
|
|
3278
|
+
return Boolean(relativePath) && relativePath !== ".." && !relativePath.startsWith(`..${path.sep}`) && !path.isAbsolute(relativePath);
|
|
3279
|
+
});
|
|
3280
|
+
if (!allowed) {
|
|
3281
|
+
throw new JevBrowserError("upload_file path must stay inside JEV_BROWSER_UPLOAD_ROOT");
|
|
2724
3282
|
}
|
|
2725
3283
|
const fileInfo = await stat(resolved);
|
|
2726
3284
|
if (!fileInfo.isFile()) throw new JevBrowserError("upload_file accepts regular files only");
|
|
@@ -2770,7 +3328,7 @@ async function removeBrowserDownload(download) {
|
|
|
2770
3328
|
}
|
|
2771
3329
|
}
|
|
2772
3330
|
|
|
2773
|
-
async function saveDownload(download, settings, ledger) {
|
|
3331
|
+
async function saveDownload(download, settings, ledger, networkTracker) {
|
|
2774
3332
|
if (ledger.count >= settings.browser.maxDownloadFiles) {
|
|
2775
3333
|
await removeBrowserDownload(download);
|
|
2776
3334
|
throw new JevBrowserError("download exceeds the configured file-count limit");
|
|
@@ -2831,7 +3389,14 @@ async function saveDownload(download, settings, ledger) {
|
|
|
2831
3389
|
ledger.count += 1;
|
|
2832
3390
|
ledger.bytes += savedInfo.size;
|
|
2833
3391
|
await removeBrowserDownload(download);
|
|
2834
|
-
|
|
3392
|
+
let responseMimeType;
|
|
3393
|
+
try {
|
|
3394
|
+
const entry = networkTracker?.historySince?.(0)?.filter(({ url: responseUrl, status }) => responseUrl === download.url() && status !== undefined).at(-1);
|
|
3395
|
+
responseMimeType = entry?.responseHeaders?.["content-type"]?.split(";")[0]?.trim().toLowerCase();
|
|
3396
|
+
} catch { /* The download remains verifiable by content hash and extension when response headers are unavailable. */ }
|
|
3397
|
+
const saved = { name, path: destination, size_bytes: savedInfo.size, mime_type: responseMimeType || fileMimeType(name) };
|
|
3398
|
+
ledger.files?.push({ name, path: destination, sizeBytes: savedInfo.size, mimeType: responseMimeType });
|
|
3399
|
+
return saved;
|
|
2835
3400
|
} catch (error) {
|
|
2836
3401
|
await rm(destination, { force: true }).catch(() => {});
|
|
2837
3402
|
await removeBrowserDownload(download);
|
|
@@ -2859,8 +3424,26 @@ async function visibleMenuItem(root, label, timeout, settings) {
|
|
|
2859
3424
|
return locatorAt(textLocator, visible.at(-1));
|
|
2860
3425
|
}
|
|
2861
3426
|
|
|
2862
|
-
async function navigateMenu(page, root, step, settings, execution) {
|
|
2863
|
-
|
|
3427
|
+
async function navigateMenu(page, root, step, settings, execution) {
|
|
3428
|
+
if (step.screen) {
|
|
3429
|
+
const search = page.locator("#pesquisa");
|
|
3430
|
+
const { visible } = await visibleIndexes(search, step.timeoutMs ?? settings.browser.actionTimeoutMs, "SAFI Acesso Rápido search", settings);
|
|
3431
|
+
if (visible.length !== 1) throw new JevBrowserError(`SAFI Acesso Rápido search must match exactly one visible input; found ${visible.length}`);
|
|
3432
|
+
await locatorAt(search, visible[0]).fill(step.screen, { timeout: step.timeoutMs ?? settings.browser.actionTimeoutMs });
|
|
3433
|
+
const timeout = step.timeoutMs ?? settings.browser.actionTimeoutMs;
|
|
3434
|
+
const allRows = page.locator("#ConsultaTelas tbody tr");
|
|
3435
|
+
const exactRows = allRows.filter({ has: page.getByText(step.screen, { exact: true }) });
|
|
3436
|
+
const exactMatches = await visibleIndexes(exactRows, timeout, "exact SAFI Acesso Rápido result", settings, { allowNone: true });
|
|
3437
|
+
const rows = exactMatches.visible.length
|
|
3438
|
+
? exactRows
|
|
3439
|
+
: allRows.filter({ has: page.getByText(step.screen, { exact: false }) });
|
|
3440
|
+
const row = await exactlyOne(rows, `SAFI Acesso Rápido result ${JSON.stringify(step.screen)}`, timeout, settings);
|
|
3441
|
+
const link = await exactlyOne(row.getByRole("link", { name: new RegExp("Acessar", "i") }), "SAFI Acesso Rápido result link", step.timeoutMs ?? settings.browser.actionTimeoutMs, settings);
|
|
3442
|
+
await link.click({ timeout: step.timeoutMs ?? settings.browser.actionTimeoutMs });
|
|
3443
|
+
await page.locator("#pesquisa").waitFor({ state: "hidden", timeout: step.timeoutMs ?? settings.browser.actionTimeoutMs });
|
|
3444
|
+
return { action: step.action, navigated_screen: step.screen, navigation_source: "safi_quick_access" };
|
|
3445
|
+
}
|
|
3446
|
+
let item;
|
|
2864
3447
|
try {
|
|
2865
3448
|
item = await visibleMenuItem(root, step.path[0], 250, settings);
|
|
2866
3449
|
} catch {
|
|
@@ -2879,26 +3462,77 @@ async function navigateMenu(page, root, step, settings, execution) {
|
|
|
2879
3462
|
return { action: step.action, navigated_menu: step.path };
|
|
2880
3463
|
}
|
|
2881
3464
|
|
|
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;
|
|
3465
|
+
async function executeStep(page, step, settings, snapshot, secrets, downloadLedger, execution = {}) {
|
|
3466
|
+
const root = await stepRoot(page, step.frame);
|
|
3467
|
+
const timeout = step.timeoutMs ?? settings.browser.actionTimeoutMs;
|
|
3468
|
+
if (["http_request", "generate_fixture", "assert_download", "assert_db", "assert_log", "start_email_capture", "assert_email", "assert_remote_file", "delete_remote_file", "run_hook"].includes(step.action)) {
|
|
3469
|
+
return executeIntegrationStep(step, {
|
|
3470
|
+
settings,
|
|
3471
|
+
profile: execution.environmentProfile,
|
|
3472
|
+
session: execution.integrationSession,
|
|
3473
|
+
variables: execution.variables,
|
|
3474
|
+
page,
|
|
3475
|
+
execution: { ...execution, downloadLedger },
|
|
3476
|
+
});
|
|
3477
|
+
}
|
|
2885
3478
|
if (step.action === "click" && step.ifVisible && !await conditionIsVisible(root, step.ifVisible)) {
|
|
2886
3479
|
return { action: step.action, skipped: true, skip_reason: "if_visible condition was not visible" };
|
|
2887
3480
|
}
|
|
2888
|
-
if (step.action === "navigate") {
|
|
3481
|
+
if (step.action === "navigate") {
|
|
2889
3482
|
let destination;
|
|
2890
3483
|
try { destination = new URL(step.url, page.url()).toString(); } catch {
|
|
2891
3484
|
throw new JevBrowserError("navigate URL must be absolute or relative to the current page");
|
|
2892
3485
|
}
|
|
2893
3486
|
destination = validateUrl(destination);
|
|
2894
|
-
if (execution.allowMutations && !localHostOnly(destination)) {
|
|
2895
|
-
throw new JevBrowserError("allow_mutations
|
|
3487
|
+
if (execution.allowMutations && !localHostOnly(destination) && !isProfileOriginAllowed(execution.environmentProfile, destination)) {
|
|
3488
|
+
throw new JevBrowserError("allow_mutations navigation must stay on localhost or an application origin allow-listed by the selected DEV/HML profile");
|
|
2896
3489
|
}
|
|
2897
3490
|
await page.goto(destination, { waitUntil: "domcontentloaded", timeout });
|
|
2898
3491
|
execution.browserPool?.setNavigationError?.(null);
|
|
2899
|
-
return { action: step.action, url: evidenceUrl(destination, secrets, settings.jev.maxDiagnosticChars) };
|
|
2900
|
-
}
|
|
2901
|
-
if (step.action === "
|
|
3492
|
+
return { action: step.action, url: evidenceUrl(destination, secrets, settings.jev.maxDiagnosticChars) };
|
|
3493
|
+
}
|
|
3494
|
+
if (step.action === "inspect_cookies") {
|
|
3495
|
+
assertLocalDebugPage(page, execution.localOnly, "inspect_cookies");
|
|
3496
|
+
const cookies = await execution.context.cookies([page.url()]);
|
|
3497
|
+
return {
|
|
3498
|
+
action: step.action,
|
|
3499
|
+
cookies: cookies.map(({ name, domain, path: cookiePath, expires, httpOnly, secure, sameSite }) => ({
|
|
3500
|
+
name: String(name).slice(0, 128), domain: String(domain).slice(0, 255), path: String(cookiePath).slice(0, 256),
|
|
3501
|
+
expires, http_only: Boolean(httpOnly), secure: Boolean(secure), same_site: sameSite,
|
|
3502
|
+
value: "[redacted]",
|
|
3503
|
+
})),
|
|
3504
|
+
};
|
|
3505
|
+
}
|
|
3506
|
+
if (step.action === "read_angular_state") {
|
|
3507
|
+
assertLocalDebugPage(page, execution.localOnly, "read_angular_state");
|
|
3508
|
+
const state = await page.evaluate(() => {
|
|
3509
|
+
const angular = window.angular;
|
|
3510
|
+
if (!angular) return { angular_present: false };
|
|
3511
|
+
const root = document.querySelector("[ng-app], [data-ng-app]") || document.body;
|
|
3512
|
+
let injector;
|
|
3513
|
+
try { injector = angular.element(root).injector(); } catch { injector = undefined; }
|
|
3514
|
+
if (!injector) return { angular_present: true, angular_version: angular.version?.full || null, injector_ready: false };
|
|
3515
|
+
let currentState = null;
|
|
3516
|
+
let parameterNames = [];
|
|
3517
|
+
try {
|
|
3518
|
+
const router = injector.get("$state");
|
|
3519
|
+
currentState = router.current?.name || null;
|
|
3520
|
+
parameterNames = Object.keys(router.params || {}).slice(0, 32);
|
|
3521
|
+
} catch { /* ui-router may not be registered on this page. */ }
|
|
3522
|
+
let authenticated = null;
|
|
3523
|
+
try { authenticated = Boolean(injector.get("$rootScope").usuario); } catch { /* no app-specific auth scope */ }
|
|
3524
|
+
return {
|
|
3525
|
+
angular_present: true,
|
|
3526
|
+
angular_version: angular.version?.full || null,
|
|
3527
|
+
injector_ready: true,
|
|
3528
|
+
state: currentState,
|
|
3529
|
+
parameter_names: parameterNames,
|
|
3530
|
+
authenticated,
|
|
3531
|
+
};
|
|
3532
|
+
});
|
|
3533
|
+
return { action: step.action, ...state };
|
|
3534
|
+
}
|
|
3535
|
+
if (step.action === "evaluate") {
|
|
2902
3536
|
const value = await page.evaluate((expression) => {
|
|
2903
3537
|
const parts = expression.split(".");
|
|
2904
3538
|
let current = globalThis;
|
|
@@ -2931,7 +3565,9 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
2931
3565
|
await execution.networkTracker.attachPage(next);
|
|
2932
3566
|
if (step.url) {
|
|
2933
3567
|
const destination = validateUrl(new URL(step.url, page.url()).toString());
|
|
2934
|
-
if (execution.allowMutations && !localHostOnly(destination)
|
|
3568
|
+
if (execution.allowMutations && !localHostOnly(destination) && !isProfileOriginAllowed(execution.environmentProfile, destination)) {
|
|
3569
|
+
throw new JevBrowserError("allow_mutations navigation must stay on localhost or an application origin allow-listed by the selected DEV/HML profile");
|
|
3570
|
+
}
|
|
2935
3571
|
await next.goto(destination, { waitUntil: "domcontentloaded", timeout });
|
|
2936
3572
|
}
|
|
2937
3573
|
execution.capture.attachPage(next);
|
|
@@ -2956,9 +3592,20 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
2956
3592
|
if (!tabs[step.index]) throw new JevBrowserError(`switch_tab index ${step.index} does not exist; open tabs: ${tabs.length}`);
|
|
2957
3593
|
return { action: step.action, tab_index: step.index, activePage: tabs[step.index], activeContext: selectedContext };
|
|
2958
3594
|
}
|
|
2959
|
-
if (step.action === "wait_for_request") {
|
|
2960
|
-
const request = await waitForRequestAssertion(
|
|
2961
|
-
|
|
3595
|
+
if (step.action === "wait_for_request") {
|
|
3596
|
+
const request = await waitForRequestAssertion(
|
|
3597
|
+
execution.networkTracker,
|
|
3598
|
+
step,
|
|
3599
|
+
execution.flowNetworkMarker ?? execution.networkMarker,
|
|
3600
|
+
timeout,
|
|
3601
|
+
execution.requestObserver,
|
|
3602
|
+
);
|
|
3603
|
+
return {
|
|
3604
|
+
action: step.action, asserted: true, method: request.method, response_status: request.status,
|
|
3605
|
+
...(request.durationMs === undefined ? {} : { duration_ms: Math.round(request.durationMs * 10) / 10 }),
|
|
3606
|
+
...(request.responseHeaders ? { response_headers: request.responseHeaders } : {}),
|
|
3607
|
+
url: evidenceUrl(request.url, secrets, settings.jev.maxDiagnosticChars),
|
|
3608
|
+
};
|
|
2962
3609
|
}
|
|
2963
3610
|
if (step.action === "assert_ws") {
|
|
2964
3611
|
const frame = await waitForWebSocketAssertion(execution.networkTracker, step, execution.networkMarker, timeout, settings);
|
|
@@ -2969,14 +3616,21 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
2969
3616
|
if (step.action === "assert_network") {
|
|
2970
3617
|
const response = await waitForNetworkAssertion(
|
|
2971
3618
|
execution.networkTracker,
|
|
2972
|
-
step,
|
|
2973
|
-
execution.networkMarker,
|
|
2974
|
-
|
|
2975
|
-
|
|
3619
|
+
step,
|
|
3620
|
+
execution.networkMarker,
|
|
3621
|
+
execution.initialNavigationMarker,
|
|
3622
|
+
timeout,
|
|
3623
|
+
page,
|
|
2976
3624
|
settings,
|
|
2977
3625
|
secrets,
|
|
2978
3626
|
);
|
|
2979
|
-
return {
|
|
3627
|
+
return {
|
|
3628
|
+
action: step.action, asserted: true, url: evidenceUrl(response.url, secrets, settings.jev.maxDiagnosticChars),
|
|
3629
|
+
method: response.method, response_status: response.status,
|
|
3630
|
+
...(response.durationMs === undefined ? {} : { duration_ms: Math.round(response.durationMs * 10) / 10 }),
|
|
3631
|
+
...(response.responseHeaders ? { response_headers: response.responseHeaders } : {}),
|
|
3632
|
+
...(response.message ? { message: response.message } : {}),
|
|
3633
|
+
};
|
|
2980
3634
|
}
|
|
2981
3635
|
if (step.action === "confirm_modal") {
|
|
2982
3636
|
const trigger = await namedTarget(root, step, snapshot, secrets, timeout, settings);
|
|
@@ -3038,7 +3692,17 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
3038
3692
|
if (step.source === "url") value = page.url();
|
|
3039
3693
|
else if (step.source === "title") value = await page.title();
|
|
3040
3694
|
else if (step.source === "value") value = await control.inputValue({ timeout });
|
|
3041
|
-
else if (step.source === "attribute")
|
|
3695
|
+
else if (step.source === "attribute") {
|
|
3696
|
+
value = await control.getAttribute(step.attribute, { timeout }) || "";
|
|
3697
|
+
if (new Set(["href", "src", "action", "formaction", "poster"]).has(step.attribute.toLowerCase()) && value) {
|
|
3698
|
+
try {
|
|
3699
|
+
const parsed = new URL(value, page.url());
|
|
3700
|
+
parsed.search = "";
|
|
3701
|
+
parsed.hash = "";
|
|
3702
|
+
value = parsed.toString();
|
|
3703
|
+
} catch { value = "[redacted invalid URL attribute]"; }
|
|
3704
|
+
}
|
|
3705
|
+
}
|
|
3042
3706
|
else value = (await control.innerText({ timeout })).replace(/\s+/g, " ").trim();
|
|
3043
3707
|
if (value.length > settings.browser.maxTextEntryChars) {
|
|
3044
3708
|
throw new JevBrowserError("extracted value exceeds the configured character limit");
|
|
@@ -3297,14 +3961,30 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
3297
3961
|
...targetEvidence(step, secrets, await describeResolvedTarget(control, step, secrets, settings)),
|
|
3298
3962
|
};
|
|
3299
3963
|
}
|
|
3300
|
-
if (step.action === "assert_value") {
|
|
3301
|
-
await waitForValue(control, step.expected, timeout, `assert_value for ${describeTarget(step, secrets)}`, settings);
|
|
3302
|
-
return {
|
|
3303
|
-
action: step.action,
|
|
3304
|
-
asserted: true,
|
|
3305
|
-
...targetEvidence(step, secrets, await describeResolvedTarget(control, step, secrets, settings)),
|
|
3306
|
-
};
|
|
3307
|
-
}
|
|
3964
|
+
if (step.action === "assert_value") {
|
|
3965
|
+
await waitForValue(control, step.expected, timeout, `assert_value for ${describeTarget(step, secrets)}`, settings);
|
|
3966
|
+
return {
|
|
3967
|
+
action: step.action,
|
|
3968
|
+
asserted: true,
|
|
3969
|
+
...targetEvidence(step, secrets, await describeResolvedTarget(control, step, secrets, settings)),
|
|
3970
|
+
};
|
|
3971
|
+
}
|
|
3972
|
+
if (step.action === "assert_attribute") {
|
|
3973
|
+
const actual = await control.getAttribute(step.attribute, { timeout });
|
|
3974
|
+
const matches = actual !== null && (step.match === "contains"
|
|
3975
|
+
? actual.includes(step.expected)
|
|
3976
|
+
: actual === step.expected);
|
|
3977
|
+
if (!matches) {
|
|
3978
|
+
throw new JevBrowserError(`assert_attribute failed for ${describeTarget(step, secrets)}: attribute ${step.attribute} did not match the expected value`);
|
|
3979
|
+
}
|
|
3980
|
+
return {
|
|
3981
|
+
action: step.action,
|
|
3982
|
+
asserted: true,
|
|
3983
|
+
attribute: step.attribute,
|
|
3984
|
+
match: step.match,
|
|
3985
|
+
...targetEvidence(step, secrets, await describeResolvedTarget(control, step, secrets, settings)),
|
|
3986
|
+
};
|
|
3987
|
+
}
|
|
3308
3988
|
if (step.action === "assert_enabled") {
|
|
3309
3989
|
if (!(await control.isEnabled())) throw new JevBrowserError(`assert_enabled failed for ${describeTarget(step, secrets)}`);
|
|
3310
3990
|
return { action: step.action, asserted: true, enabled: true, ...targetEvidence(step, secrets, await describeResolvedTarget(control, step, secrets, settings)) };
|
|
@@ -3659,7 +4339,7 @@ async function writeFlowReport(result, reportPath, settings) {
|
|
|
3659
4339
|
return outputPath;
|
|
3660
4340
|
}
|
|
3661
4341
|
|
|
3662
|
-
async function collectPerformanceMetrics(page) {
|
|
4342
|
+
async function collectPerformanceMetrics(page) {
|
|
3663
4343
|
try {
|
|
3664
4344
|
return await page.evaluate(() => {
|
|
3665
4345
|
const navigation = performance.getEntriesByType("navigation")[0];
|
|
@@ -3677,9 +4357,52 @@ async function collectPerformanceMetrics(page) {
|
|
|
3677
4357
|
} catch {
|
|
3678
4358
|
return undefined;
|
|
3679
4359
|
}
|
|
3680
|
-
}
|
|
3681
|
-
|
|
3682
|
-
|
|
4360
|
+
}
|
|
4361
|
+
|
|
4362
|
+
function validateIntegratedActions(plans, options, settings) {
|
|
4363
|
+
const profile = options.environmentProfile
|
|
4364
|
+
? requireEnvironmentProfile(settings, options.environmentProfile, "integrated test actions")
|
|
4365
|
+
: undefined;
|
|
4366
|
+
for (const [planId, plan] of Object.entries(plans)) {
|
|
4367
|
+
for (const [index, step] of plan.steps.entries()) {
|
|
4368
|
+
const where = `candidate plan ${planId} step ${index + 1}`;
|
|
4369
|
+
if (profile?.environment === "prd" && step.mutating === true) throw new JevBrowserError(`${where} mutating actions are forbidden for PRD profiles`);
|
|
4370
|
+
if (["assert_db", "assert_log", "start_email_capture", "assert_email", "assert_remote_file", "delete_remote_file", "run_hook"].includes(step.action) && !profile) {
|
|
4371
|
+
throw new JevBrowserError(`${where} action ${step.action} requires options.environment_profile`);
|
|
4372
|
+
}
|
|
4373
|
+
if (step.action === "http_request") {
|
|
4374
|
+
if (!profile && !options.localOnly) throw new JevBrowserError(`${where} http_request requires local_only or a configured environment_profile`);
|
|
4375
|
+
if (!profile && step.method !== "GET" && step.method !== "HEAD") throw new JevBrowserError(`${where} mutating http_request requires a configured DEV or HML profile`);
|
|
4376
|
+
if (profile?.environment === "prd" && !["GET", "HEAD"].includes(step.method)) throw new JevBrowserError(`${where} mutating http_request is forbidden for PRD`);
|
|
4377
|
+
if (!["GET", "HEAD"].includes(step.method) && !options.allowMutations) throw new JevBrowserError(`${where} mutating http_request requires explicit options.allow_mutations authorization`);
|
|
4378
|
+
}
|
|
4379
|
+
if (step.action === "assert_db" && !profile?.database) throw new JevBrowserError(`${where} needs a database connection in environment profile ${profile?.id || ""}`);
|
|
4380
|
+
if (step.action === "assert_db") assertReadOnlySql(step.sql, settings.browser.maxDatabaseQueryChars);
|
|
4381
|
+
if (step.action === "assert_log" && !Object.hasOwn(profile?.logSources || {}, step.source)) throw new JevBrowserError(`${where} log source ${step.source} is not allow-listed`);
|
|
4382
|
+
if (["start_email_capture", "assert_email"].includes(step.action) && !profile?.smtpCapture) throw new JevBrowserError(`${where} needs smtp_capture in environment profile ${profile?.id || ""}`);
|
|
4383
|
+
if (step.action === "assert_remote_file" || step.action === "delete_remote_file") {
|
|
4384
|
+
if (!Object.hasOwn(profile?.remotes || {}, step.server)) throw new JevBrowserError(`${where} remote server ${step.server} is not allow-listed`);
|
|
4385
|
+
if (step.action === "delete_remote_file" && profile.environment !== "dev") throw new JevBrowserError(`${where} delete_remote_file is restricted to DEV profiles`);
|
|
4386
|
+
if (step.action === "delete_remote_file" && !options.allowMutations) throw new JevBrowserError(`${where} delete_remote_file requires explicit options.allow_mutations authorization`);
|
|
4387
|
+
}
|
|
4388
|
+
if (step.action === "run_hook") {
|
|
4389
|
+
if (!Object.hasOwn(profile?.hooks || {}, step.hook)) throw new JevBrowserError(`${where} hook ${step.hook} is not allow-listed`);
|
|
4390
|
+
if (profile.environment !== "dev") throw new JevBrowserError(`${where} run_hook is restricted to DEV profiles`);
|
|
4391
|
+
if (!options.allowMutations) throw new JevBrowserError(`${where} run_hook requires explicit options.allow_mutations authorization`);
|
|
4392
|
+
}
|
|
4393
|
+
if (step.action === "generate_fixture") {
|
|
4394
|
+
const extension = path.extname(step.fileName).toLowerCase();
|
|
4395
|
+
if (step.kind === "pdf" && extension !== ".pdf") throw new JevBrowserError(`${where} PDF fixtures must use a .pdf filename`);
|
|
4396
|
+
if (step.kind === "text" && extension !== ".txt") throw new JevBrowserError(`${where} text fixtures must use a .txt filename`);
|
|
4397
|
+
if (/[\\/\0]/.test(step.fileName) || step.fileName === "." || step.fileName === "..") throw new JevBrowserError(`${where} fixture file_name cannot contain a path`);
|
|
4398
|
+
}
|
|
4399
|
+
}
|
|
4400
|
+
}
|
|
4401
|
+
if (options.allowMutations && profile?.environment === "prd") throw new JevBrowserError("allow_mutations is forbidden for PRD profiles");
|
|
4402
|
+
return profile;
|
|
4403
|
+
}
|
|
4404
|
+
|
|
4405
|
+
async function redactObject(value, secrets) {
|
|
3683
4406
|
if (typeof value === "string") return redact(value, secrets);
|
|
3684
4407
|
if (Array.isArray(value)) return Promise.all(value.map((item) => redactObject(item, secrets)));
|
|
3685
4408
|
if (isRecord(value)) {
|
|
@@ -3709,43 +4432,53 @@ function snapshotDiff(initialSnapshot, finalSnapshot) {
|
|
|
3709
4432
|
return added.length ? added.join("\n") : "[no new snapshot lines]";
|
|
3710
4433
|
}
|
|
3711
4434
|
|
|
3712
|
-
function
|
|
3713
|
-
return snapshot.replace(/\n\n\[interactive controls without accessible names;[^\]]+\][\s\S]*$/i, "").trim();
|
|
3714
|
-
}
|
|
3715
|
-
|
|
3716
|
-
export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, candidatePlans, params: rawParams, options: rawOptions, settings, browserPool, fetchImpl = fetch, serverVersion, mutationAuthorizationStore = defaultMutationAuthorizationStore }) {
|
|
4435
|
+
export function validateBrowserFlowRequest({ flow, initialUrl, expectedOutcome, candidatePlans, params: rawParams, options: rawOptions, settings }) {
|
|
3717
4436
|
if (typeof flow !== "string" || (expectedOutcome !== undefined && typeof expectedOutcome !== "string" && !isRecord(expectedOutcome))) {
|
|
3718
4437
|
throw new JevBrowserError("flow must be text and expected_outcome must be text or a structured assertion object");
|
|
3719
4438
|
}
|
|
3720
|
-
|
|
4439
|
+
const variables = validateParams(rawParams, settings);
|
|
3721
4440
|
const requestedFlow = replaceKnownParams(flow, variables).trim();
|
|
3722
4441
|
const expected = normalizeExpectedOutcome(expectedOutcome, variables, settings);
|
|
3723
|
-
const outcome = expected.description;
|
|
3724
|
-
const outcomeText = expected.text;
|
|
3725
4442
|
if (!requestedFlow) throw new JevBrowserError("flow must not be empty");
|
|
3726
4443
|
if (requestedFlow.length > settings.jev.maxFlowChars) throw new JevBrowserError("flow exceeds the configured character limit");
|
|
3727
|
-
if (
|
|
3728
|
-
const url = initialUrl === undefined ? undefined : validateUrl(initialUrl);
|
|
4444
|
+
if (expected.description.length > settings.jev.maxActionDescriptionChars) throw new JevBrowserError("expected_outcome exceeds the configured character limit");
|
|
4445
|
+
const url = initialUrl === undefined ? undefined : validateUrl(initialUrl);
|
|
3729
4446
|
const options = resolveFlowOptions(rawOptions, settings);
|
|
3730
4447
|
const plans = validateCandidatePlans(replaceKnownParams(candidatePlans, variables, true), settings, options.maxFlowSteps);
|
|
4448
|
+
const selectedProfile = validateIntegratedActions(plans, options, settings);
|
|
3731
4449
|
assertPlanPlaceholders(plans, variables);
|
|
3732
4450
|
if (!url && !options.reusePage) throw new JevBrowserError("initial_url is required when continue_from_current_page is false");
|
|
3733
4451
|
if (options.waitForHttp && !localHostOnly(options.waitForHttp.url)
|
|
3734
4452
|
&& (!url || new URL(options.waitForHttp.url).hostname.toLowerCase() !== new URL(url).hostname.toLowerCase())) {
|
|
3735
4453
|
throw new JevBrowserError("wait_for_http must target localhost or the same host as initial_url");
|
|
3736
4454
|
}
|
|
3737
|
-
if (!url && options.allowMutations) throw new JevBrowserError("allow_mutations needs an explicit localhost initial_url");
|
|
3738
|
-
resolveEnvironmentText(plans, settings);
|
|
3739
|
-
if (options.localOnly && Object.keys(plans).length > 0 && !(options.fastPath && Object.keys(plans).length === 1)) {
|
|
3740
|
-
throw new JevBrowserError("local_only does not call Jev; provide exactly one candidate plan and enable fast_path");
|
|
3741
|
-
}
|
|
3742
|
-
if (options.allowMutations && url && !localHostOnly(url)) {
|
|
3743
|
-
throw new JevBrowserError("allow_mutations
|
|
3744
|
-
}
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
}
|
|
4455
|
+
if (!url && options.allowMutations && !selectedProfile) throw new JevBrowserError("allow_mutations needs an explicit localhost initial_url or a configured environment_profile");
|
|
4456
|
+
resolveEnvironmentText(plans, settings);
|
|
4457
|
+
if (options.localOnly && Object.keys(plans).length > 0 && !(options.fastPath && Object.keys(plans).length === 1)) {
|
|
4458
|
+
throw new JevBrowserError("local_only does not call Jev; provide exactly one candidate plan and enable fast_path");
|
|
4459
|
+
}
|
|
4460
|
+
if (options.allowMutations && url && !localHostOnly(url) && !isProfileOriginAllowed(selectedProfile, url)) {
|
|
4461
|
+
throw new JevBrowserError("allow_mutations URL must be localhost or an application origin allow-listed by the selected DEV/HML profile");
|
|
4462
|
+
}
|
|
4463
|
+
if (options.freshContext && !url) throw new JevBrowserError("fresh_context requires initial_url so the isolated context never inherits a previous page or account");
|
|
4464
|
+
const secretBearingFlow = Object.values(plans).some((plan) => plan.steps.some((step) => step.action === "type" && step.sensitive));
|
|
4465
|
+
if (options.recordVideo && secretBearingFlow) throw new JevBrowserError("record_video is disabled for flows that type sensitive text");
|
|
4466
|
+
return { variables, requestedFlow, expected, url, options, plans, selectedProfile, secretBearingFlow };
|
|
4467
|
+
}
|
|
4468
|
+
|
|
4469
|
+
function stripUnnamedControlsSection(snapshot) {
|
|
4470
|
+
return snapshot.replace(/\n\n\[interactive controls without accessible names;[^\]]+\][\s\S]*$/i, "").trim();
|
|
4471
|
+
}
|
|
4472
|
+
|
|
4473
|
+
async function executeBrowserFlowCore({ flow, initialUrl, expectedOutcome, candidatePlans, params: rawParams, options: rawOptions, settings, browserPool, fetchImpl = fetch, serverVersion, integrationSession, mutationAuthorizationStore = defaultMutationAuthorizationStore }) {
|
|
4474
|
+
const preflight = validateBrowserFlowRequest({ flow, initialUrl, expectedOutcome, candidatePlans, params: rawParams, options: rawOptions, settings });
|
|
4475
|
+
const { requestedFlow, expected, url, options, plans, selectedProfile, secretBearingFlow } = preflight;
|
|
4476
|
+
let variables = preflight.variables;
|
|
4477
|
+
const outcome = expected.description;
|
|
4478
|
+
const outcomeText = expected.text;
|
|
4479
|
+
const networkOnlyFastPath = options.fastPath && Object.keys(plans).length === 1
|
|
4480
|
+
&& !Object.values(plans)[0].when
|
|
4481
|
+
&& Object.values(plans)[0].steps.every(({ action }) => ["assert_network", "wait_for_request"].includes(action));
|
|
3749
4482
|
const fingerprint = flowFingerprint({ flow, initialUrl: url, expectedOutcome, candidatePlans, params: rawParams, options });
|
|
3750
4483
|
const continuation = options.confirmationToken
|
|
3751
4484
|
? mutationAuthorizationStore.consume(options.confirmationToken, fingerprint)
|
|
@@ -3778,12 +4511,20 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
3778
4511
|
browserSessionMs = browser.sessionMs;
|
|
3779
4512
|
page = browser.page;
|
|
3780
4513
|
context = browser.context;
|
|
4514
|
+
if (browser.recovered && options.reusePage && !url) browserPool.setNavigationError?.("BROWSER_PAGE_NOT_RESTORED");
|
|
3781
4515
|
}
|
|
3782
4516
|
const initialPage = page;
|
|
3783
4517
|
const initialContext = context;
|
|
3784
|
-
const
|
|
4518
|
+
const networkWatchPatterns = Object.values(plans).flatMap(({ steps }) => steps.flatMap((step) => {
|
|
4519
|
+
if (step.action === "wait_for_request") return [step.urlPattern];
|
|
4520
|
+
if (step.action === "assert_network") return [step.urlContains];
|
|
4521
|
+
return [];
|
|
4522
|
+
})).concat(expected.assertions.filter(({ kind }) => kind === "request").map(({ request }) => request));
|
|
4523
|
+
const networkTracker = createNetworkTracker(page, settings, options.loginUrlContains, networkWatchPatterns);
|
|
4524
|
+
const capture = createDiagnostics(page, options, settings, secrets);
|
|
3785
4525
|
const result = {
|
|
3786
|
-
...(serverVersion ? { server_version: serverVersion } : {}),
|
|
4526
|
+
...(serverVersion ? { server_version: serverVersion } : {}),
|
|
4527
|
+
...(selectedProfile ? { environment_profile: { id: selectedProfile.id, environment: selectedProfile.environment } } : {}),
|
|
3787
4528
|
status: "failed",
|
|
3788
4529
|
expected_outcome_visible: false,
|
|
3789
4530
|
acceptance_criteria_met: false,
|
|
@@ -3796,11 +4537,31 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
3796
4537
|
final_snapshot: "",
|
|
3797
4538
|
};
|
|
3798
4539
|
result.snapshot_mode = options.returnSnapshot;
|
|
3799
|
-
if (options.allowMutations) result.mutation_authorization =
|
|
3800
|
-
|
|
3801
|
-
|
|
4540
|
+
if (options.allowMutations) result.mutation_authorization = selectedProfile
|
|
4541
|
+
? `allowed_on_${selectedProfile.environment}_profile`
|
|
4542
|
+
: "allowed_on_localhost";
|
|
4543
|
+
const downloadLedger = { count: 0, bytes: 0, files: [] };
|
|
3802
4544
|
await networkTracker.waitForWebSocketObservers();
|
|
3803
|
-
const initialNetworkMarker = networkTracker.mark();
|
|
4545
|
+
const initialNetworkMarker = networkTracker.mark();
|
|
4546
|
+
const requestObservers = new Map();
|
|
4547
|
+
const armAvailableRequestObservers = () => {
|
|
4548
|
+
for (const [planId, candidatePlan] of Object.entries(plans)) {
|
|
4549
|
+
candidatePlan.steps.forEach((templateStep, stepIndex) => {
|
|
4550
|
+
const observerKey = `${planId}:${stepIndex}`;
|
|
4551
|
+
if (templateStep.action !== "wait_for_request" || requestObservers.has(observerKey)) return;
|
|
4552
|
+
const unresolved = [...templateStep.urlPattern.matchAll(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g)]
|
|
4553
|
+
.some(([, name]) => !Object.hasOwn(variables, name));
|
|
4554
|
+
if (unresolved) return;
|
|
4555
|
+
const resolvedPattern = replaceKnownParams(templateStep.urlPattern, variables);
|
|
4556
|
+
const matcher = urlPatternMatcher(resolvedPattern);
|
|
4557
|
+
requestObservers.set(observerKey, networkTracker.observeResponseSince(initialNetworkMarker, (event) => matcher.test(event.url)
|
|
4558
|
+
&& (!templateStep.method || event.method === templateStep.method)
|
|
4559
|
+
&& (templateStep.status === undefined || event.status === templateStep.status)));
|
|
4560
|
+
});
|
|
4561
|
+
}
|
|
4562
|
+
};
|
|
4563
|
+
armAvailableRequestObservers();
|
|
4564
|
+
let initialNavigationMarker;
|
|
3804
4565
|
const timings = { navigationMs: 0, initialSnapshotMs: 0, readyMs: 0, decisionMs: 0, browserPlanMs: 0 };
|
|
3805
4566
|
const routeCleanups = [];
|
|
3806
4567
|
const contextStack = [context];
|
|
@@ -3855,17 +4616,27 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
3855
4616
|
addDiagnostic(result.warnings, "trace, video, and screenshots were suppressed because this flow contains sensitive text", settings.jev.maxDiagnosticItems);
|
|
3856
4617
|
}
|
|
3857
4618
|
|
|
3858
|
-
let reusedPage = false;
|
|
4619
|
+
let reusedPage = false;
|
|
3859
4620
|
result.navigation_strategy = options.freshContext ? "fresh_isolated_context" : "navigated_to_initial_url";
|
|
3860
|
-
const plansBeginWithNavigation = Object.values(plans).every(({ steps }) => steps[0]?.action === "navigate");
|
|
4621
|
+
const plansBeginWithNavigation = Object.values(plans).every(({ steps }) => steps[0]?.action === "navigate");
|
|
3861
4622
|
if (options.reusePage && !options.freshContext) {
|
|
3862
|
-
navigationError = browserPool.getNavigationError?.();
|
|
3863
|
-
if (navigationError) {
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
4623
|
+
navigationError = browserPool.getNavigationError?.();
|
|
4624
|
+
if (navigationError) {
|
|
4625
|
+
if (navigationError === "BROWSER_PAGE_NOT_RESTORED" && url) {
|
|
4626
|
+
result.navigation_strategy = "recovered_with_initial_url";
|
|
4627
|
+
} else {
|
|
4628
|
+
result.navigation_error = navigationError;
|
|
4629
|
+
const detail = navigationError === "BROWSER_PAGE_NOT_RESTORED"
|
|
4630
|
+
? "the previous page was not restored after reconnecting; provide initial_url to navigate again"
|
|
4631
|
+
: `the previous navigation failed with ${navigationError}; run once with reuse_page: false to navigate again`;
|
|
4632
|
+
throw new JevBrowserError(`reuse_page cannot continue because ${detail}`);
|
|
4633
|
+
}
|
|
4634
|
+
}
|
|
4635
|
+
const currentUrl = typeof page.url === "function" ? page.url() : "about:blank";
|
|
4636
|
+
if (options.allowMutations && !localHostOnly(currentUrl) && !isProfileOriginAllowed(selectedProfile, currentUrl)) {
|
|
4637
|
+
throw new JevBrowserError("allow_mutations cannot continue on a page outside localhost or the selected profile's application origin allowlist");
|
|
4638
|
+
}
|
|
4639
|
+
if (!navigationError && currentUrl && currentUrl !== "about:blank") {
|
|
3869
4640
|
let currentRoute;
|
|
3870
4641
|
try {
|
|
3871
4642
|
currentRoute = normalizedRouteUrl(currentUrl);
|
|
@@ -3888,18 +4659,30 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
3888
4659
|
}
|
|
3889
4660
|
}
|
|
3890
4661
|
}
|
|
3891
|
-
if (!reusedPage) {
|
|
4662
|
+
if (!reusedPage) {
|
|
3892
4663
|
const destinationUrl = url;
|
|
3893
4664
|
if (!destinationUrl) throw new JevBrowserError("initial_url is required because there is no open page to continue");
|
|
3894
4665
|
const navigationStarted = performance.now();
|
|
4666
|
+
initialNavigationMarker = networkTracker.mark();
|
|
3895
4667
|
try {
|
|
3896
|
-
await page.goto(destinationUrl, { waitUntil: "domcontentloaded", timeout: settings.browser.actionTimeoutMs * 4 });
|
|
4668
|
+
const response = await page.goto(destinationUrl, { waitUntil: "domcontentloaded", timeout: settings.browser.actionTimeoutMs * 4 });
|
|
4669
|
+
if (response?.status() >= 400) result.navigation_http_status = response.status();
|
|
3897
4670
|
if (!options.freshContext) browserPool.setNavigationError?.(null);
|
|
3898
4671
|
} catch (error) {
|
|
3899
|
-
|
|
3900
|
-
|
|
3901
|
-
|
|
3902
|
-
|
|
4672
|
+
const failedDocument = networkOnlyFastPath
|
|
4673
|
+
? networkTracker.historySince(initialNavigationMarker)
|
|
4674
|
+
.filter((entry) => entry.type === "response" && entry.resourceType === "document" && entry.status >= 400)
|
|
4675
|
+
.at(-1)
|
|
4676
|
+
: undefined;
|
|
4677
|
+
if (failedDocument) {
|
|
4678
|
+
result.navigation_http_status = failedDocument.status;
|
|
4679
|
+
if (!options.freshContext) browserPool.setNavigationError?.(null);
|
|
4680
|
+
} else {
|
|
4681
|
+
navigationError = String(error?.message || "").match(/\b(?:net::)?(ERR_[A-Z0-9_]+)\b/)?.[1] || "NAVIGATION_FAILED";
|
|
4682
|
+
if (navigationError && !options.freshContext) browserPool.setNavigationError?.(navigationError);
|
|
4683
|
+
throw error;
|
|
4684
|
+
}
|
|
4685
|
+
}
|
|
3903
4686
|
timings.navigationMs = performance.now() - navigationStarted;
|
|
3904
4687
|
}
|
|
3905
4688
|
if (options.clearStorage) {
|
|
@@ -3909,11 +4692,15 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
3909
4692
|
}
|
|
3910
4693
|
reusedPage = false;
|
|
3911
4694
|
result.navigation_strategy = "storage_cleared_and_reloaded";
|
|
3912
|
-
}
|
|
3913
|
-
const snapshotStarted = performance.now();
|
|
3914
|
-
try {
|
|
3915
|
-
const readiness =
|
|
3916
|
-
|
|
4695
|
+
}
|
|
4696
|
+
const snapshotStarted = performance.now();
|
|
4697
|
+
try {
|
|
4698
|
+
const readiness = networkOnlyFastPath
|
|
4699
|
+
? { snapshot: "", readyMs: 0 }
|
|
4700
|
+
: await waitForPageReady(page, options, settings, secrets, reusedPage);
|
|
4701
|
+
const enriched = networkOnlyFastPath
|
|
4702
|
+
? { snapshot: "", unnamedControls: [] }
|
|
4703
|
+
: await enrichSnapshot(page, readiness.snapshot, settings, secrets, options.snapshotScope, options.snapshotIncludeHidden);
|
|
3917
4704
|
result.final_snapshot = enriched.snapshot;
|
|
3918
4705
|
initialSnapshot = continuation?.initialSnapshot || enriched.snapshot;
|
|
3919
4706
|
if (options.includeUnnamedControls) result.unnamed_controls_initial = enriched.unnamedControls;
|
|
@@ -3921,13 +4708,16 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
3921
4708
|
navigationError = "AUTHENTICATION_REQUIRED";
|
|
3922
4709
|
throw new JevBrowserError("the configured login text is visible; sign in to the application, then run the flow again");
|
|
3923
4710
|
}
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
4711
|
+
if (enriched.unnamedControls.length) {
|
|
4712
|
+
const examples = enriched.unnamedControls.slice(0, 3)
|
|
4713
|
+
.map(({ role, nearest_label }) => `${role}${nearest_label ? ` near ${JSON.stringify(nearest_label)}` : ""}`)
|
|
4714
|
+
.join("; ");
|
|
4715
|
+
addDiagnostic(
|
|
4716
|
+
result.warnings,
|
|
4717
|
+
`${enriched.unnamedControls.length} interactive control(s) have no accessible name${examples ? `; examples: ${examples}` : ""}; set include_unnamed_controls to return sanitized HTML evidence`,
|
|
4718
|
+
settings.jev.maxDiagnosticItems,
|
|
4719
|
+
);
|
|
4720
|
+
}
|
|
3931
4721
|
timings.readyMs = readiness.readyMs;
|
|
3932
4722
|
if (!options.freshContext) browserPool.setNavigationError?.(null);
|
|
3933
4723
|
if (continuation) {
|
|
@@ -4007,8 +4797,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
4007
4797
|
result.accessibility_audits = continuation?.accessibilityAudits || [];
|
|
4008
4798
|
if (continuation?.downloadLedger) Object.assign(downloadLedger, continuation.downloadLedger);
|
|
4009
4799
|
const planStarted = performance.now();
|
|
4010
|
-
const runAllSteps = plan.steps.some(({ action, expectDownload }) => action.startsWith("assert_")
|
|
4011
|
-
|| ["navigate", "clear", "clear_storage", "new_tab", "new_context", "switch_tab", "screenshot", "wait_for_request", "upload_file", "select_option", "hover", "like_comment", "unlike_comment", "audit_accessibility"].includes(action)
|
|
4800
|
+
const runAllSteps = plan.steps.some(({ action, expectDownload }) => action.startsWith("assert_")
|
|
4801
|
+
|| ["navigate", "clear", "clear_storage", "new_tab", "new_context", "switch_tab", "screenshot", "wait_for_request", "upload_file", "generate_fixture", "http_request", "assert_db", "assert_log", "start_email_capture", "assert_email", "assert_remote_file", "delete_remote_file", "run_hook", "select_option", "hover", "like_comment", "unlike_comment", "audit_accessibility"].includes(action)
|
|
4012
4802
|
|| expectDownload === true);
|
|
4013
4803
|
let approvedMutationIndex = continuation?.nextStep;
|
|
4014
4804
|
for (let index = continuation?.nextStep || 0; index < plan.steps.length; index += 1) {
|
|
@@ -4060,7 +4850,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
4060
4850
|
if (index === approvedMutationIndex) approvedMutationIndex = undefined;
|
|
4061
4851
|
if (step.fragileName) addDiagnostic(result.warnings, `step ${index + 1} uses an empty accessible name with index ${step.index}; this target is fragile`, settings.jev.maxDiagnosticItems);
|
|
4062
4852
|
if (step.fragileSelector) addDiagnostic(result.warnings, `step ${index + 1} uses an explicit CSS/XPath selector as a last resort`, settings.jev.maxDiagnosticItems);
|
|
4063
|
-
|
|
4853
|
+
await integrationSession?.markStep?.(index + 1);
|
|
4854
|
+
if (step.action === "click" && plan.steps[index + 1]?.action === "confirm_dialog") {
|
|
4064
4855
|
nativeDialog.expectedStep = interpolateStep(plan.steps[index + 1], variables);
|
|
4065
4856
|
nativeDialog.result = undefined;
|
|
4066
4857
|
} else if (step.action !== "confirm_dialog") {
|
|
@@ -4068,17 +4859,25 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
4068
4859
|
nativeDialog.result = undefined;
|
|
4069
4860
|
}
|
|
4070
4861
|
const actionMarker = networkTracker.mark();
|
|
4071
|
-
const execution = {
|
|
4072
|
-
networkTracker,
|
|
4073
|
-
networkMarker,
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4862
|
+
const execution = {
|
|
4863
|
+
networkTracker,
|
|
4864
|
+
networkMarker,
|
|
4865
|
+
initialNavigationMarker,
|
|
4866
|
+
flowNetworkMarker: initialNetworkMarker,
|
|
4867
|
+
autoAngularIdle: options.autoAngularIdle,
|
|
4868
|
+
readyStableMs: options.readyStableMs,
|
|
4869
|
+
localOnly: options.localOnly,
|
|
4870
|
+
allowMutations: options.allowMutations || index === approvedMutationIndex,
|
|
4871
|
+
environmentProfile: selectedProfile,
|
|
4872
|
+
integrationSession,
|
|
4873
|
+
downloadLedger,
|
|
4077
4874
|
browserPool,
|
|
4078
4875
|
nativeDialog,
|
|
4079
|
-
preResolvedTarget: mutationInspection.target,
|
|
4080
|
-
|
|
4081
|
-
|
|
4876
|
+
preResolvedTarget: mutationInspection.target,
|
|
4877
|
+
requestObserver: requestObservers.get(`${planId}:${index}`),
|
|
4878
|
+
popupPage,
|
|
4879
|
+
variables,
|
|
4880
|
+
secrets,
|
|
4082
4881
|
context,
|
|
4083
4882
|
contextStack,
|
|
4084
4883
|
capture,
|
|
@@ -4096,6 +4895,7 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
4096
4895
|
},
|
|
4097
4896
|
};
|
|
4098
4897
|
const traceStep = await executeStep(stepPage, step, settings, snapshot, secrets, downloadLedger, execution);
|
|
4898
|
+
armAvailableRequestObservers();
|
|
4099
4899
|
popupPage = execution.popupPage;
|
|
4100
4900
|
if (traceStep.activeContext) context = traceStep.activeContext;
|
|
4101
4901
|
const nextActivePage = traceStep.activePage;
|
|
@@ -4130,18 +4930,20 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
4130
4930
|
failure = `accessibility audit reported ${traceStep.accessibility_audit.blocking_violations_count} violation(s) at or above ${traceStep.accessibility_audit.fail_on_impact} impact`;
|
|
4131
4931
|
}
|
|
4132
4932
|
}
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4933
|
+
if (!networkOnlyFastPath) {
|
|
4934
|
+
snapshot = await snapshotAfterStep(
|
|
4935
|
+
activePage,
|
|
4936
|
+
settings,
|
|
4937
|
+
secrets,
|
|
4938
|
+
options.snapshotScope,
|
|
4939
|
+
snapshot,
|
|
4940
|
+
index + 1,
|
|
4941
|
+
result.warnings,
|
|
4942
|
+
options.snapshotIncludeHidden,
|
|
4943
|
+
);
|
|
4944
|
+
}
|
|
4143
4945
|
if (failure) break;
|
|
4144
|
-
|
|
4946
|
+
if (options.stopOnExpected && outcome && !runAllSteps && containsExpectedOutcome(snapshot, outcome)) break;
|
|
4145
4947
|
} catch (error) {
|
|
4146
4948
|
environmentFailure = browserDisconnected(error);
|
|
4147
4949
|
failure = error instanceof JevBrowserError
|
|
@@ -4160,7 +4962,7 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
4160
4962
|
break;
|
|
4161
4963
|
}
|
|
4162
4964
|
}
|
|
4163
|
-
if (failure || trace.length === 0) {
|
|
4965
|
+
if ((failure || trace.length === 0) && !networkOnlyFastPath) {
|
|
4164
4966
|
snapshot = await snapshotAfterStep(
|
|
4165
4967
|
activePage,
|
|
4166
4968
|
settings,
|
|
@@ -4172,14 +4974,16 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
4172
4974
|
options.snapshotIncludeHidden,
|
|
4173
4975
|
);
|
|
4174
4976
|
}
|
|
4175
|
-
const finalDetails =
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
4977
|
+
const finalDetails = networkOnlyFastPath
|
|
4978
|
+
? { snapshot, unnamedControls: [] }
|
|
4979
|
+
: await enrichSnapshot(
|
|
4980
|
+
activePage,
|
|
4981
|
+
snapshot,
|
|
4982
|
+
settings,
|
|
4983
|
+
secrets,
|
|
4984
|
+
options.snapshotScope,
|
|
4985
|
+
options.snapshotIncludeHidden,
|
|
4986
|
+
);
|
|
4183
4987
|
snapshot = finalDetails.snapshot;
|
|
4184
4988
|
if (options.includeUnnamedControls) result.unnamed_controls_final = finalDetails.unnamedControls;
|
|
4185
4989
|
timings.browserPlanMs = performance.now() - planStarted;
|
|
@@ -4236,8 +5040,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
4236
5040
|
}
|
|
4237
5041
|
}
|
|
4238
5042
|
}
|
|
4239
|
-
} catch (error) {
|
|
4240
|
-
const authenticationFailure = networkTracker.authenticationFailureSince(initialNetworkMarker);
|
|
5043
|
+
} catch (error) {
|
|
5044
|
+
const authenticationFailure = networkTracker.authenticationFailureSince(initialNetworkMarker);
|
|
4241
5045
|
const disconnected = (!options.freshContext && browserPool.getNavigationError?.() === "BROWSER_DISCONNECTED") || browserDisconnected(error);
|
|
4242
5046
|
if (authenticationFailure) {
|
|
4243
5047
|
navigationError = "AUTHENTICATION_REQUIRED";
|
|
@@ -4339,7 +5143,7 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
4339
5143
|
await Promise.all(createdContexts.map(async (createdContext) => {
|
|
4340
5144
|
try { await browserPool.releaseContext?.(createdContext); } catch { artifactErrors.push("isolated browser context cleanup failed"); }
|
|
4341
5145
|
}));
|
|
4342
|
-
networkTracker.dispose();
|
|
5146
|
+
await networkTracker.dispose();
|
|
4343
5147
|
await capture.dispose();
|
|
4344
5148
|
initialPage.off("dialog", onNativeDialog);
|
|
4345
5149
|
}
|
|
@@ -4348,7 +5152,16 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
4348
5152
|
try { result.current_url = evidenceUrl(activePage.url(), secrets, settings.jev.maxDiagnosticChars); } catch { result.current_url = "unavailable"; }
|
|
4349
5153
|
result.console_errors = groupConsoleErrors([...(continuation?.consoleErrors || []), ...capture.diagnostics.console_errors]);
|
|
4350
5154
|
result.console_messages = groupConsoleMessages([...(continuation?.consoleMessages || []), ...capture.diagnostics.console_messages]);
|
|
4351
|
-
result.network_failures = [...(continuation?.networkFailures || []), ...capture.diagnostics.network_failures];
|
|
5155
|
+
result.network_failures = [...(continuation?.networkFailures || []), ...capture.diagnostics.network_failures];
|
|
5156
|
+
const blockedCookies = networkTracker.blockedCookiesSince(initialNetworkMarker, options.captureNetworkUrlContains)
|
|
5157
|
+
.map(({ url: requestUrl, ...event }) => ({ ...event, url: evidenceUrl(requestUrl, secrets, settings.jev.maxDiagnosticChars) }));
|
|
5158
|
+
if (blockedCookies.length) result.blocked_cookies = blockedCookies;
|
|
5159
|
+
if (options.captureNetwork === "all") {
|
|
5160
|
+
result.network_events = networkTracker.networkEventsSince(
|
|
5161
|
+
initialNetworkMarker,
|
|
5162
|
+
options.captureNetworkUrlContains,
|
|
5163
|
+
).map(({ url: requestUrl, ...event }) => ({ ...event, url: evidenceUrl(requestUrl, secrets, settings.jev.maxDiagnosticChars) }));
|
|
5164
|
+
}
|
|
4352
5165
|
result.warnings.push(...capture.diagnostics.warnings);
|
|
4353
5166
|
result.timings_ms = flowTimings(started, browserSessionMs, timings.navigationMs, timings.initialSnapshotMs, timings.readyMs, timings.decisionMs, timings.browserPlanMs);
|
|
4354
5167
|
const safeResult = await redactObject(result, secrets);
|
|
@@ -4359,7 +5172,7 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
4359
5172
|
safeResult.final_snapshot = stripUnnamedControlsSection(safeResult.final_snapshot);
|
|
4360
5173
|
initialSnapshot = stripUnnamedControlsSection(initialSnapshot);
|
|
4361
5174
|
}
|
|
4362
|
-
if (options.returnSnapshot === "diff" && typeof safeResult.final_snapshot === "string") {
|
|
5175
|
+
if (!networkOnlyFastPath && options.returnSnapshot === "diff" && typeof safeResult.final_snapshot === "string") {
|
|
4363
5176
|
safeResult.final_snapshot = snapshotDiff(initialSnapshot, safeResult.final_snapshot);
|
|
4364
5177
|
} else if (options.returnSnapshot === "none") {
|
|
4365
5178
|
delete safeResult.final_snapshot;
|
|
@@ -4371,10 +5184,64 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
4371
5184
|
safeResult.artifact_errors = [...(safeResult.artifact_errors || []), error instanceof JevBrowserError ? error.message : "flow report could not be saved"];
|
|
4372
5185
|
}
|
|
4373
5186
|
}
|
|
4374
|
-
return safeResult;
|
|
4375
|
-
}
|
|
4376
|
-
|
|
4377
|
-
|
|
5187
|
+
return safeResult;
|
|
5188
|
+
}
|
|
5189
|
+
|
|
5190
|
+
function candidatePlansIncludeUpload(candidatePlans) {
|
|
5191
|
+
const plans = isRecord(candidatePlans) && Array.isArray(candidatePlans.steps)
|
|
5192
|
+
? [candidatePlans]
|
|
5193
|
+
: isRecord(candidatePlans) ? Object.values(candidatePlans) : [];
|
|
5194
|
+
return plans.some((plan) => isRecord(plan)
|
|
5195
|
+
&& Array.isArray(plan.steps)
|
|
5196
|
+
&& plan.steps.some((step) => isRecord(step) && (
|
|
5197
|
+
step.action === "upload_file" || step.action === "generate_fixture"
|
|
5198
|
+
|| step.action === "http_request" && Array.isArray(step.multipart) && step.multipart.some((field) => isRecord(field) && field.file_path)
|
|
5199
|
+
)));
|
|
5200
|
+
}
|
|
5201
|
+
|
|
5202
|
+
export async function executeBrowserFlow(args) {
|
|
5203
|
+
const run = async () => {
|
|
5204
|
+
const ownsIntegrationSession = !args.integrationSession;
|
|
5205
|
+
let integrationSession = args.integrationSession;
|
|
5206
|
+
let result;
|
|
5207
|
+
let flowError;
|
|
5208
|
+
try {
|
|
5209
|
+
if (!integrationSession) integrationSession = createIntegrationSession(args.settings, args.options?.environment_profile);
|
|
5210
|
+
if (ownsIntegrationSession) await integrationSession.markFlowStart();
|
|
5211
|
+
result = await executeBrowserFlowCore({ ...args, integrationSession });
|
|
5212
|
+
} catch (error) {
|
|
5213
|
+
flowError = error;
|
|
5214
|
+
} finally {
|
|
5215
|
+
if (ownsIntegrationSession) await integrationSession?.close?.();
|
|
5216
|
+
}
|
|
5217
|
+
const retainFilesForConfirmation = result?.status === "confirmation_required" || result?.status === "dry_run";
|
|
5218
|
+
let cleanupError;
|
|
5219
|
+
if (candidatePlansIncludeUpload(args.candidatePlans) && !args.retainUploadStaging && !retainFilesForConfirmation && args.settings.browser.managedUploadStaging) {
|
|
5220
|
+
try {
|
|
5221
|
+
await cleanupUploadStagingDirectory(args.settings);
|
|
5222
|
+
} catch {
|
|
5223
|
+
cleanupError = true;
|
|
5224
|
+
}
|
|
5225
|
+
}
|
|
5226
|
+
if (flowError) {
|
|
5227
|
+
if (cleanupError) throw new JevBrowserError("the browser flow failed and managed upload staging cleanup also failed");
|
|
5228
|
+
throw flowError;
|
|
5229
|
+
}
|
|
5230
|
+
if (cleanupError) {
|
|
5231
|
+
result.status = "failed";
|
|
5232
|
+
result.acceptance_criteria_met = false;
|
|
5233
|
+
result.reason = "The browser flow finished, but managed upload staging cleanup failed.";
|
|
5234
|
+
result.artifact_errors = [...(result.artifact_errors || []), "managed upload staging cleanup failed"];
|
|
5235
|
+
}
|
|
5236
|
+
return result;
|
|
5237
|
+
};
|
|
5238
|
+
if (args.settings.browser.managedUploadStaging && candidatePlansIncludeUpload(args.candidatePlans)) {
|
|
5239
|
+
return withUploadStagingLock(args.settings, run);
|
|
5240
|
+
}
|
|
5241
|
+
return run();
|
|
5242
|
+
}
|
|
5243
|
+
|
|
5244
|
+
export async function chooseNextAction({ flow, pageSnapshot, actions, completedSteps = [], localOnly = false, settings, fetchImpl = fetch }) {
|
|
4378
5245
|
if (localOnly) throw new JevBrowserError("local_only is enabled; choose_next_action cannot call the Jev provider");
|
|
4379
5246
|
const { request, criteria } = buildChoicePayload({ flow, pageSnapshot, actions, completedSteps }, settings.jev);
|
|
4380
5247
|
return parseDecision(await requestJev(request, settings, fetchImpl), criteria);
|