@diegosouzacdv/jev-browser-mcp 0.7.1 → 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 +280 -55
- package/config/ui-testing.json +31 -10
- package/docs/jev-browser-mcp.md +280 -55
- 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 +650 -201
- package/mcp_servers/jev-browser-npm/src/server.mjs +136 -35
- 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,6 +64,7 @@ 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",
|
|
@@ -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,14 +234,23 @@ 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
240
|
navigate_menu: { required: [], optional: ["path", "screen", "toggle_selector", "timeout_seconds"] },
|
|
230
241
|
assert_network: { required: ["url_contains", "method", "status"], optional: ["message_contains", "timeout_seconds", "timeout_ms"] },
|
|
231
242
|
assert_ws: { required: ["url_contains"], optional: ["message_contains", "json", "close_code", "timeout_seconds", "timeout_ms"] },
|
|
232
243
|
wait_for_request: { required: ["url"], optional: ["method", "status", "timeout_seconds", "timeout_ms"] },
|
|
233
|
-
http_request: { required: ["url"], optional: ["method", "include_body", "timeout_seconds", "timeout_ms"] },
|
|
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"] },
|
|
234
254
|
inspect_cookies: { required: [], optional: ["timeout_seconds", "timeout_ms"] },
|
|
235
255
|
read_angular_state: { required: [], optional: ["timeout_seconds", "timeout_ms"] },
|
|
236
256
|
extract: { required: ["as"], optional: [...semanticTarget, "source", "attribute", "sensitive", "timeout_seconds", "timeout_ms"] },
|
|
@@ -248,8 +268,9 @@ function actionSchemas(semanticTarget) {
|
|
|
248
268
|
press: { required: ["key"], optional: [...semanticTarget, "timeout_seconds"] },
|
|
249
269
|
select_option: { required: [], optional: [...semanticTarget, "option", "value", "label_contains", "timeout_seconds"] },
|
|
250
270
|
hover: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
|
|
251
|
-
assert_text: { required: ["expected"], optional: [...semanticTarget, "timeout_seconds"] },
|
|
252
|
-
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"] },
|
|
253
274
|
assert_enabled: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
|
|
254
275
|
assert_visible: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
|
|
255
276
|
assert_hidden: { required: [], optional: [...semanticTarget, "timeout_seconds"] },
|
|
@@ -260,7 +281,7 @@ function actionSchemas(semanticTarget) {
|
|
|
260
281
|
};
|
|
261
282
|
}
|
|
262
283
|
|
|
263
|
-
export function describeBrowserActions() {
|
|
284
|
+
export function describeBrowserActions(settings) {
|
|
264
285
|
const semanticTarget = ["frame", "comment", "within", "index", "near", "label", "placeholder", "title", "text", "test_id", "selector", "target_text", "role", "name", "name_match", "target_page"];
|
|
265
286
|
const schemas = actionSchemas(semanticTarget);
|
|
266
287
|
return {
|
|
@@ -272,16 +293,71 @@ export function describeBrowserActions() {
|
|
|
272
293
|
optional: [...new Set([...schema.optional, "timeout_ms", "mutating", "screenshot"])],
|
|
273
294
|
};
|
|
274
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" };
|
|
275
297
|
if (action === "type") contract.conditional_required = { text: "exactly one of text or text_env is required" };
|
|
276
298
|
if (action === "navigate_menu") contract.conditional_required = { path: "exactly one of path or screen is required" };
|
|
277
299
|
if (action === "select_option") contract.conditional_required = { option: "exactly one of option, value or label_contains is required" };
|
|
278
300
|
return [action, contract];
|
|
279
301
|
})),
|
|
280
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
|
+
} } : {}),
|
|
281
356
|
option_shapes: {
|
|
282
357
|
capture_network: ["errors", "all", "none"],
|
|
283
358
|
capture_network_url_contains: "optional URL substring filter for capture_network=all",
|
|
284
|
-
|
|
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",
|
|
285
361
|
ready: ["network_idle", { hidden_text: "text that must disappear" }, { selector: "CSS selector", has_value: true }],
|
|
286
362
|
return_snapshot: ["diff", "full", "none"],
|
|
287
363
|
include_unnamed_controls: "boolean; false by default",
|
|
@@ -301,7 +377,7 @@ export function describeBrowserActions() {
|
|
|
301
377
|
duration_ms: "wait.ms",
|
|
302
378
|
value: "type.text or wait_for_text.text",
|
|
303
379
|
timeout_ms: "per-step timeout in milliseconds",
|
|
304
|
-
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" },
|
|
305
381
|
},
|
|
306
382
|
placeholders: "Use {name}; declare params.name or extract it before use. Unknown and {{double-brace}} placeholders are rejected.",
|
|
307
383
|
};
|
|
@@ -601,7 +677,7 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
|
|
|
601
677
|
if (hasTextEnv) step.textEnv = rawStep.text_env;
|
|
602
678
|
step.mode = rawStep.mode || "fill";
|
|
603
679
|
step.blur = rawStep.blur ?? false;
|
|
604
|
-
step.sensitive = rawStep.sensitive ?? true;
|
|
680
|
+
step.sensitive = rawStep.sensitive ?? true;
|
|
605
681
|
if (hasTextEnv && rawStep.sensitive === false) throw new JevBrowserError("type text_env is always sensitive");
|
|
606
682
|
}
|
|
607
683
|
if (action === "clear" && step.role && !TEXT_ROLES.has(step.role)) {
|
|
@@ -675,13 +751,128 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
|
|
|
675
751
|
if (action === "http_request") {
|
|
676
752
|
step.url = requiredText(rawStep.url, "http_request URL", settings.jev.maxActionDescriptionChars);
|
|
677
753
|
step.method = rawStep.method === undefined ? "GET" : requiredText(rawStep.method, "http_request method", 16).toUpperCase();
|
|
678
|
-
if (
|
|
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
|
+
});
|
|
679
777
|
if (rawStep.include_body !== undefined && typeof rawStep.include_body !== "boolean") {
|
|
680
778
|
throw new JevBrowserError("http_request include_body must be a boolean");
|
|
681
779
|
}
|
|
682
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 = {};
|
|
683
795
|
}
|
|
684
|
-
if (action === "
|
|
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") {
|
|
685
876
|
step.expression = requiredText(rawStep.expression, "read-only expression", 256);
|
|
686
877
|
if (!/^(?:window|document|navigator|location)(?:\.[A-Za-z_$][A-Za-z0-9_$]*){1,8}$/.test(step.expression)) {
|
|
687
878
|
throw new JevBrowserError("evaluate accepts read-only property paths such as navigator.mediaDevices or document.readyState; calls and assignments are not supported");
|
|
@@ -727,10 +918,12 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
|
|
|
727
918
|
if (["url", "title"].includes(step.source) && hasLocatorField(rawStep)) {
|
|
728
919
|
throw new JevBrowserError(`extract source ${step.source} does not use a locator`);
|
|
729
920
|
}
|
|
730
|
-
if (rawStep.sensitive !== undefined && typeof rawStep.sensitive !== "boolean") {
|
|
731
|
-
throw new JevBrowserError("extract sensitive must be a boolean");
|
|
732
|
-
}
|
|
733
|
-
|
|
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);
|
|
734
927
|
}
|
|
735
928
|
if (action === "wait_for_condition") {
|
|
736
929
|
if (!new Set(["network_idle", "url", "hidden", "text_hidden", "angular_idle"]).has(rawStep.condition)) {
|
|
@@ -794,9 +987,20 @@ export function validateCandidatePlans(candidatePlans, settings, maxFlowSteps =
|
|
|
794
987
|
}
|
|
795
988
|
step.key = rawStep.key;
|
|
796
989
|
}
|
|
797
|
-
if (action === "assert_text" || action === "assert_value") {
|
|
798
|
-
step.expected = requiredText(rawStep.expected, "expected value", settings.browser.maxTextEntryChars);
|
|
799
|
-
}
|
|
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
|
+
}
|
|
800
1004
|
if (action === "upload_file") {
|
|
801
1005
|
const target = rawStep.target || (hasLocatorField(step) ? "input"
|
|
802
1006
|
: step.role && DROPZONE_ROLES.has(step.role) && step.role !== "button" ? "dropzone"
|
|
@@ -880,6 +1084,11 @@ export function normalizeStepAliases(rawStep) {
|
|
|
880
1084
|
step[canonical] = step[alias];
|
|
881
1085
|
delete step[alias];
|
|
882
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
|
+
}
|
|
883
1092
|
if (step.name !== undefined && step.role === undefined) step.role = "button";
|
|
884
1093
|
}
|
|
885
1094
|
return step;
|
|
@@ -933,7 +1142,16 @@ function resolveFlowOptions(rawOptions, settings) {
|
|
|
933
1142
|
}
|
|
934
1143
|
const scope = rawOptions.snapshot_scope ?? settings.browser.defaults.snapshotScope;
|
|
935
1144
|
if (!new Set(["body", "main", "dialog"]).has(scope)) throw new JevBrowserError("options.snapshot_scope must be body, main, or dialog");
|
|
936
|
-
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
|
+
}
|
|
937
1155
|
const stepTimeoutSeconds = rawOptions.step_timeout_seconds ?? settings.browser.actionTimeoutMs / 1000;
|
|
938
1156
|
if (typeof stepTimeoutSeconds !== "number" || !Number.isFinite(stepTimeoutSeconds) || stepTimeoutSeconds <= 0
|
|
939
1157
|
|| stepTimeoutSeconds > settings.browser.maxStepTimeoutSeconds) {
|
|
@@ -1092,7 +1310,8 @@ function resolveFlowOptions(rawOptions, settings) {
|
|
|
1092
1310
|
captureNetworkUrlContains,
|
|
1093
1311
|
dryRun: rawOptions.dry_run ?? false,
|
|
1094
1312
|
fastPath: rawOptions.fast_path ?? defaults.fastPath,
|
|
1095
|
-
localOnly: rawOptions.local_only ?? defaults.localOnly,
|
|
1313
|
+
localOnly: rawOptions.local_only ?? defaults.localOnly,
|
|
1314
|
+
environmentProfile,
|
|
1096
1315
|
loginText: Object.freeze(loginText),
|
|
1097
1316
|
loginUrlContains: Object.freeze(loginUrlContains),
|
|
1098
1317
|
permissions: Object.freeze([...permissions]),
|
|
@@ -1137,11 +1356,29 @@ function escapeRegex(value) {
|
|
|
1137
1356
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1138
1357
|
}
|
|
1139
1358
|
|
|
1140
|
-
function planSecrets(plans) {
|
|
1141
|
-
const secrets = new Set();
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
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);
|
|
1145
1382
|
if (step.action === "upload_file") {
|
|
1146
1383
|
for (const filePath of step.filePaths) {
|
|
1147
1384
|
secrets.add(filePath);
|
|
@@ -1287,24 +1524,24 @@ function redactLocalPayload(value) {
|
|
|
1287
1524
|
]));
|
|
1288
1525
|
}
|
|
1289
1526
|
|
|
1290
|
-
async function resolveFakeMediaPath(fakeMedia, settings) {
|
|
1291
|
-
if (!fakeMedia) return undefined;
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
const
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
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");
|
|
1308
1545
|
if (path.extname(audio).toLowerCase() !== ".wav") throw new JevBrowserError("fake_media accepts WAV files only");
|
|
1309
1546
|
const file = await stat(audio);
|
|
1310
1547
|
if (!file.isFile() || file.size > settings.browser.maxUploadFileBytes) {
|
|
@@ -1807,6 +2044,9 @@ function createNetworkTracker(page, settings, loginUrlContains = [], watchPatter
|
|
|
1807
2044
|
const active = new Map();
|
|
1808
2045
|
const history = [];
|
|
1809
2046
|
const watchedHistory = [];
|
|
2047
|
+
const watchedResponses = new Map();
|
|
2048
|
+
const responseWaiters = new Set();
|
|
2049
|
+
const responseObservers = new Set();
|
|
1810
2050
|
const websocketHistory = [];
|
|
1811
2051
|
const websocketUrls = new Map();
|
|
1812
2052
|
const websocketObserverPromises = new Set();
|
|
@@ -1855,12 +2095,29 @@ function createNetworkTracker(page, settings, loginUrlContains = [], watchPatter
|
|
|
1855
2095
|
if (!entry) return;
|
|
1856
2096
|
entry.response = response;
|
|
1857
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
|
+
}
|
|
1858
2107
|
const requestEntry = active.get(response.request());
|
|
1859
2108
|
if (requestEntry && entry.durationMs !== undefined) requestEntry.durationMs = entry.durationMs;
|
|
1860
2109
|
const headerRead = Promise.resolve().then(() => response.allHeaders())
|
|
1861
2110
|
.then((headers) => { entry.responseHeaders = safeResponseHeaders(headers); }).catch(() => {});
|
|
1862
2111
|
pendingHeaderReads.add(headerRead);
|
|
1863
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);
|
|
1864
2121
|
};
|
|
1865
2122
|
const onFinished = (request) => record("finished", request);
|
|
1866
2123
|
const onFailed = (request) => record("failed", request);
|
|
@@ -1958,7 +2215,7 @@ function createNetworkTracker(page, settings, loginUrlContains = [], watchPatter
|
|
|
1958
2215
|
websocketObserverPromises.add(ready);
|
|
1959
2216
|
return ready;
|
|
1960
2217
|
};
|
|
1961
|
-
const historySince = (marker) => [...new Map([...history, ...watchedHistory]
|
|
2218
|
+
const historySince = (marker) => [...new Map([...history, ...watchedHistory, ...watchedResponses.values()]
|
|
1962
2219
|
.filter((entry) => entry.sequence > marker)
|
|
1963
2220
|
.map((entry) => [entry.sequence, entry])).values()].sort((left, right) => left.sequence - right.sequence);
|
|
1964
2221
|
attachPage(page);
|
|
@@ -1966,6 +2223,61 @@ function createNetworkTracker(page, settings, loginUrlContains = [], watchPatter
|
|
|
1966
2223
|
mark: () => sequence,
|
|
1967
2224
|
attachPage,
|
|
1968
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
|
+
},
|
|
1969
2281
|
since: (marker, urlContains) => history.filter((entry) => entry.sequence > marker
|
|
1970
2282
|
&& (urlContains === undefined || entry.url.includes(urlContains))),
|
|
1971
2283
|
responsesSince: (marker, urlContains, method) => historySince(marker).filter((entry) =>
|
|
@@ -2031,6 +2343,8 @@ function createNetworkTracker(page, settings, loginUrlContains = [], watchPatter
|
|
|
2031
2343
|
trackedPages.clear();
|
|
2032
2344
|
active.clear();
|
|
2033
2345
|
ignored.clear();
|
|
2346
|
+
for (const observer of [...responseObservers]) observer.dispose();
|
|
2347
|
+
responseObservers.clear();
|
|
2034
2348
|
await Promise.allSettled([...pendingHeaderReads]);
|
|
2035
2349
|
pendingHeaderReads.clear();
|
|
2036
2350
|
cdpRequestUrls.clear();
|
|
@@ -2178,17 +2492,15 @@ function urlPatternMatcher(pattern) {
|
|
|
2178
2492
|
return new RegExp(`^${pattern.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`, "i");
|
|
2179
2493
|
}
|
|
2180
2494
|
|
|
2181
|
-
async function waitForRequestAssertion(tracker, step, marker, timeout,
|
|
2182
|
-
const matcher = urlPatternMatcher(step.urlPattern);
|
|
2183
|
-
const
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
await new Promise((resolve) => setTimeout(resolve, Math.min(settings.browser.visibilityPollMs, remainingMs(started + timeout))));
|
|
2191
|
-
}
|
|
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;
|
|
2192
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`);
|
|
2193
2505
|
}
|
|
2194
2506
|
|
|
@@ -2714,12 +3026,13 @@ function xpathRolePredicate(role) {
|
|
|
2714
3026
|
return native ? `@role='${role}' or ${native}` : `@role='${role}'`;
|
|
2715
3027
|
}
|
|
2716
3028
|
|
|
2717
|
-
function relativeTarget(anchor, role) {
|
|
2718
|
-
const
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
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}`;
|
|
2723
3036
|
return anchor.locator(selector);
|
|
2724
3037
|
}
|
|
2725
3038
|
|
|
@@ -2754,8 +3067,10 @@ async function targetLocator(root, step, timeout, settings) {
|
|
|
2754
3067
|
locator = step.action === "type" && step.role ? relativeTarget(anchor, step.role) : anchor;
|
|
2755
3068
|
} else if (field === "test_id") {
|
|
2756
3069
|
locator = targetRoot.getByTestId(step.testId);
|
|
2757
|
-
} else if (step.near) {
|
|
2758
|
-
|
|
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)));
|
|
2759
3074
|
} else if (step.role) {
|
|
2760
3075
|
locator = targetRoot.getByRole(step.role, roleOptions(step));
|
|
2761
3076
|
if (step.name && (!step.nameMatch || step.nameMatch === "exact") && await locator.count() === 0) {
|
|
@@ -2936,17 +3251,18 @@ function fileMimeType(fileName) {
|
|
|
2936
3251
|
return MIME_TYPES.get(path.extname(fileName).toLowerCase()) || "application/octet-stream";
|
|
2937
3252
|
}
|
|
2938
3253
|
|
|
2939
|
-
async function uploadFiles(step, settings) {
|
|
2940
|
-
const
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
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");
|
|
2950
3266
|
let totalBytes = 0;
|
|
2951
3267
|
const files = [];
|
|
2952
3268
|
for (const requestedPath of step.filePaths) {
|
|
@@ -2957,9 +3273,12 @@ async function uploadFiles(step, settings) {
|
|
|
2957
3273
|
} catch {
|
|
2958
3274
|
throw new JevBrowserError("upload_file could not read one of the configured files");
|
|
2959
3275
|
}
|
|
2960
|
-
const
|
|
2961
|
-
|
|
2962
|
-
|
|
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");
|
|
2963
3282
|
}
|
|
2964
3283
|
const fileInfo = await stat(resolved);
|
|
2965
3284
|
if (!fileInfo.isFile()) throw new JevBrowserError("upload_file accepts regular files only");
|
|
@@ -3009,7 +3328,7 @@ async function removeBrowserDownload(download) {
|
|
|
3009
3328
|
}
|
|
3010
3329
|
}
|
|
3011
3330
|
|
|
3012
|
-
async function saveDownload(download, settings, ledger) {
|
|
3331
|
+
async function saveDownload(download, settings, ledger, networkTracker) {
|
|
3013
3332
|
if (ledger.count >= settings.browser.maxDownloadFiles) {
|
|
3014
3333
|
await removeBrowserDownload(download);
|
|
3015
3334
|
throw new JevBrowserError("download exceeds the configured file-count limit");
|
|
@@ -3070,7 +3389,14 @@ async function saveDownload(download, settings, ledger) {
|
|
|
3070
3389
|
ledger.count += 1;
|
|
3071
3390
|
ledger.bytes += savedInfo.size;
|
|
3072
3391
|
await removeBrowserDownload(download);
|
|
3073
|
-
|
|
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;
|
|
3074
3400
|
} catch (error) {
|
|
3075
3401
|
await rm(destination, { force: true }).catch(() => {});
|
|
3076
3402
|
await removeBrowserDownload(download);
|
|
@@ -3139,6 +3465,16 @@ async function navigateMenu(page, root, step, settings, execution) {
|
|
|
3139
3465
|
async function executeStep(page, step, settings, snapshot, secrets, downloadLedger, execution = {}) {
|
|
3140
3466
|
const root = await stepRoot(page, step.frame);
|
|
3141
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
|
+
}
|
|
3142
3478
|
if (step.action === "click" && step.ifVisible && !await conditionIsVisible(root, step.ifVisible)) {
|
|
3143
3479
|
return { action: step.action, skipped: true, skip_reason: "if_visible condition was not visible" };
|
|
3144
3480
|
}
|
|
@@ -3148,69 +3484,13 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
3148
3484
|
throw new JevBrowserError("navigate URL must be absolute or relative to the current page");
|
|
3149
3485
|
}
|
|
3150
3486
|
destination = validateUrl(destination);
|
|
3151
|
-
if (execution.allowMutations && !localHostOnly(destination)) {
|
|
3152
|
-
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");
|
|
3153
3489
|
}
|
|
3154
3490
|
await page.goto(destination, { waitUntil: "domcontentloaded", timeout });
|
|
3155
3491
|
execution.browserPool?.setNavigationError?.(null);
|
|
3156
3492
|
return { action: step.action, url: evidenceUrl(destination, secrets, settings.jev.maxDiagnosticChars) };
|
|
3157
3493
|
}
|
|
3158
|
-
if (step.action === "http_request") {
|
|
3159
|
-
assertLocalDebugPage(page, execution.localOnly, "http_request");
|
|
3160
|
-
const pageUrl = new URL(page.url());
|
|
3161
|
-
const requestUrl = new URL(step.url, pageUrl);
|
|
3162
|
-
if (requestUrl.origin !== pageUrl.origin || !localHostOnly(requestUrl.toString())) {
|
|
3163
|
-
throw new JevBrowserError("http_request must target the current localhost origin");
|
|
3164
|
-
}
|
|
3165
|
-
const reply = await page.evaluate(async ({ url, includeBody, maxBytes }) => {
|
|
3166
|
-
const response = await fetch(url, {
|
|
3167
|
-
method: "GET", credentials: "same-origin", redirect: "error", cache: "no-store",
|
|
3168
|
-
});
|
|
3169
|
-
const headers = {};
|
|
3170
|
-
for (const name of ["cache-control", "content-length", "content-type", "date", "etag", "last-modified", "retry-after", "vary"]) {
|
|
3171
|
-
const value = response.headers.get(name);
|
|
3172
|
-
if (value !== null) headers[name] = value.slice(0, 256);
|
|
3173
|
-
}
|
|
3174
|
-
let body = "";
|
|
3175
|
-
let truncated = false;
|
|
3176
|
-
if (includeBody && response.body) {
|
|
3177
|
-
const reader = response.body.getReader();
|
|
3178
|
-
const chunks = [];
|
|
3179
|
-
let size = 0;
|
|
3180
|
-
while (size <= maxBytes) {
|
|
3181
|
-
const { done, value } = await reader.read();
|
|
3182
|
-
if (done) break;
|
|
3183
|
-
const chunk = value.slice(0, Math.max(0, maxBytes + 1 - size));
|
|
3184
|
-
chunks.push(chunk);
|
|
3185
|
-
size += chunk.length;
|
|
3186
|
-
if (size > maxBytes || chunk.length < value.length) {
|
|
3187
|
-
truncated = true;
|
|
3188
|
-
await reader.cancel();
|
|
3189
|
-
break;
|
|
3190
|
-
}
|
|
3191
|
-
}
|
|
3192
|
-
const bytes = new Uint8Array(size);
|
|
3193
|
-
let offset = 0;
|
|
3194
|
-
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }
|
|
3195
|
-
body = new TextDecoder().decode(bytes.slice(0, maxBytes));
|
|
3196
|
-
}
|
|
3197
|
-
return { status: response.status, ok: response.ok, headers, body, body_truncated: truncated };
|
|
3198
|
-
}, { url: requestUrl.toString(), includeBody: step.includeBody, maxBytes: settings.browser.maxNetworkErrorBodyBytes });
|
|
3199
|
-
let responseBody;
|
|
3200
|
-
if (step.includeBody && reply.body) {
|
|
3201
|
-
try { responseBody = redactLocalPayload(JSON.parse(reply.body)); }
|
|
3202
|
-
catch { responseBody = sanitizeDiagnosticText(reply.body, secrets, settings.browser.maxNetworkErrorMessageChars); }
|
|
3203
|
-
}
|
|
3204
|
-
return {
|
|
3205
|
-
action: step.action,
|
|
3206
|
-
method: "GET",
|
|
3207
|
-
url: evidenceUrl(requestUrl.toString(), secrets, settings.jev.maxDiagnosticChars),
|
|
3208
|
-
status: reply.status,
|
|
3209
|
-
ok: reply.ok,
|
|
3210
|
-
response_headers: reply.headers,
|
|
3211
|
-
...(step.includeBody ? { response_body: responseBody ?? "", body_truncated: reply.body_truncated } : {}),
|
|
3212
|
-
};
|
|
3213
|
-
}
|
|
3214
3494
|
if (step.action === "inspect_cookies") {
|
|
3215
3495
|
assertLocalDebugPage(page, execution.localOnly, "inspect_cookies");
|
|
3216
3496
|
const cookies = await execution.context.cookies([page.url()]);
|
|
@@ -3285,7 +3565,9 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
3285
3565
|
await execution.networkTracker.attachPage(next);
|
|
3286
3566
|
if (step.url) {
|
|
3287
3567
|
const destination = validateUrl(new URL(step.url, page.url()).toString());
|
|
3288
|
-
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
|
+
}
|
|
3289
3571
|
await next.goto(destination, { waitUntil: "domcontentloaded", timeout });
|
|
3290
3572
|
}
|
|
3291
3573
|
execution.capture.attachPage(next);
|
|
@@ -3311,9 +3593,15 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
3311
3593
|
return { action: step.action, tab_index: step.index, activePage: tabs[step.index], activeContext: selectedContext };
|
|
3312
3594
|
}
|
|
3313
3595
|
if (step.action === "wait_for_request") {
|
|
3314
|
-
const request = await waitForRequestAssertion(
|
|
3596
|
+
const request = await waitForRequestAssertion(
|
|
3597
|
+
execution.networkTracker,
|
|
3598
|
+
step,
|
|
3599
|
+
execution.flowNetworkMarker ?? execution.networkMarker,
|
|
3600
|
+
timeout,
|
|
3601
|
+
execution.requestObserver,
|
|
3602
|
+
);
|
|
3315
3603
|
return {
|
|
3316
|
-
action: step.action, asserted: true, method: request.method,
|
|
3604
|
+
action: step.action, asserted: true, method: request.method, response_status: request.status,
|
|
3317
3605
|
...(request.durationMs === undefined ? {} : { duration_ms: Math.round(request.durationMs * 10) / 10 }),
|
|
3318
3606
|
...(request.responseHeaders ? { response_headers: request.responseHeaders } : {}),
|
|
3319
3607
|
url: evidenceUrl(request.url, secrets, settings.jev.maxDiagnosticChars),
|
|
@@ -3404,7 +3692,17 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
3404
3692
|
if (step.source === "url") value = page.url();
|
|
3405
3693
|
else if (step.source === "title") value = await page.title();
|
|
3406
3694
|
else if (step.source === "value") value = await control.inputValue({ timeout });
|
|
3407
|
-
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
|
+
}
|
|
3408
3706
|
else value = (await control.innerText({ timeout })).replace(/\s+/g, " ").trim();
|
|
3409
3707
|
if (value.length > settings.browser.maxTextEntryChars) {
|
|
3410
3708
|
throw new JevBrowserError("extracted value exceeds the configured character limit");
|
|
@@ -3663,14 +3961,30 @@ async function executeStep(page, step, settings, snapshot, secrets, downloadLedg
|
|
|
3663
3961
|
...targetEvidence(step, secrets, await describeResolvedTarget(control, step, secrets, settings)),
|
|
3664
3962
|
};
|
|
3665
3963
|
}
|
|
3666
|
-
if (step.action === "assert_value") {
|
|
3667
|
-
await waitForValue(control, step.expected, timeout, `assert_value for ${describeTarget(step, secrets)}`, settings);
|
|
3668
|
-
return {
|
|
3669
|
-
action: step.action,
|
|
3670
|
-
asserted: true,
|
|
3671
|
-
...targetEvidence(step, secrets, await describeResolvedTarget(control, step, secrets, settings)),
|
|
3672
|
-
};
|
|
3673
|
-
}
|
|
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
|
+
}
|
|
3674
3988
|
if (step.action === "assert_enabled") {
|
|
3675
3989
|
if (!(await control.isEnabled())) throw new JevBrowserError(`assert_enabled failed for ${describeTarget(step, secrets)}`);
|
|
3676
3990
|
return { action: step.action, asserted: true, enabled: true, ...targetEvidence(step, secrets, await describeResolvedTarget(control, step, secrets, settings)) };
|
|
@@ -4025,7 +4339,7 @@ async function writeFlowReport(result, reportPath, settings) {
|
|
|
4025
4339
|
return outputPath;
|
|
4026
4340
|
}
|
|
4027
4341
|
|
|
4028
|
-
async function collectPerformanceMetrics(page) {
|
|
4342
|
+
async function collectPerformanceMetrics(page) {
|
|
4029
4343
|
try {
|
|
4030
4344
|
return await page.evaluate(() => {
|
|
4031
4345
|
const navigation = performance.getEntriesByType("navigation")[0];
|
|
@@ -4043,9 +4357,52 @@ async function collectPerformanceMetrics(page) {
|
|
|
4043
4357
|
} catch {
|
|
4044
4358
|
return undefined;
|
|
4045
4359
|
}
|
|
4046
|
-
}
|
|
4047
|
-
|
|
4048
|
-
|
|
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) {
|
|
4049
4406
|
if (typeof value === "string") return redact(value, secrets);
|
|
4050
4407
|
if (Array.isArray(value)) return Promise.all(value.map((item) => redactObject(item, secrets)));
|
|
4051
4408
|
if (isRecord(value)) {
|
|
@@ -4075,46 +4432,53 @@ function snapshotDiff(initialSnapshot, finalSnapshot) {
|
|
|
4075
4432
|
return added.length ? added.join("\n") : "[no new snapshot lines]";
|
|
4076
4433
|
}
|
|
4077
4434
|
|
|
4078
|
-
function
|
|
4079
|
-
return snapshot.replace(/\n\n\[interactive controls without accessible names;[^\]]+\][\s\S]*$/i, "").trim();
|
|
4080
|
-
}
|
|
4081
|
-
|
|
4082
|
-
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 }) {
|
|
4083
4436
|
if (typeof flow !== "string" || (expectedOutcome !== undefined && typeof expectedOutcome !== "string" && !isRecord(expectedOutcome))) {
|
|
4084
4437
|
throw new JevBrowserError("flow must be text and expected_outcome must be text or a structured assertion object");
|
|
4085
4438
|
}
|
|
4086
|
-
|
|
4439
|
+
const variables = validateParams(rawParams, settings);
|
|
4087
4440
|
const requestedFlow = replaceKnownParams(flow, variables).trim();
|
|
4088
4441
|
const expected = normalizeExpectedOutcome(expectedOutcome, variables, settings);
|
|
4089
|
-
const outcome = expected.description;
|
|
4090
|
-
const outcomeText = expected.text;
|
|
4091
4442
|
if (!requestedFlow) throw new JevBrowserError("flow must not be empty");
|
|
4092
4443
|
if (requestedFlow.length > settings.jev.maxFlowChars) throw new JevBrowserError("flow exceeds the configured character limit");
|
|
4093
|
-
if (
|
|
4444
|
+
if (expected.description.length > settings.jev.maxActionDescriptionChars) throw new JevBrowserError("expected_outcome exceeds the configured character limit");
|
|
4094
4445
|
const url = initialUrl === undefined ? undefined : validateUrl(initialUrl);
|
|
4095
4446
|
const options = resolveFlowOptions(rawOptions, settings);
|
|
4096
4447
|
const plans = validateCandidatePlans(replaceKnownParams(candidatePlans, variables, true), settings, options.maxFlowSteps);
|
|
4097
|
-
const
|
|
4098
|
-
&& !Object.values(plans)[0].when
|
|
4099
|
-
&& Object.values(plans)[0].steps.every(({ action }) => ["assert_network", "wait_for_request"].includes(action));
|
|
4448
|
+
const selectedProfile = validateIntegratedActions(plans, options, settings);
|
|
4100
4449
|
assertPlanPlaceholders(plans, variables);
|
|
4101
4450
|
if (!url && !options.reusePage) throw new JevBrowserError("initial_url is required when continue_from_current_page is false");
|
|
4102
4451
|
if (options.waitForHttp && !localHostOnly(options.waitForHttp.url)
|
|
4103
4452
|
&& (!url || new URL(options.waitForHttp.url).hostname.toLowerCase() !== new URL(url).hostname.toLowerCase())) {
|
|
4104
4453
|
throw new JevBrowserError("wait_for_http must target localhost or the same host as initial_url");
|
|
4105
4454
|
}
|
|
4106
|
-
if (!url && options.allowMutations) throw new JevBrowserError("allow_mutations needs an explicit localhost initial_url");
|
|
4107
|
-
resolveEnvironmentText(plans, settings);
|
|
4108
|
-
if (options.localOnly && Object.keys(plans).length > 0 && !(options.fastPath && Object.keys(plans).length === 1)) {
|
|
4109
|
-
throw new JevBrowserError("local_only does not call Jev; provide exactly one candidate plan and enable fast_path");
|
|
4110
|
-
}
|
|
4111
|
-
if (options.allowMutations && url && !localHostOnly(url)) {
|
|
4112
|
-
throw new JevBrowserError("allow_mutations
|
|
4113
|
-
}
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
}
|
|
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));
|
|
4118
4482
|
const fingerprint = flowFingerprint({ flow, initialUrl: url, expectedOutcome, candidatePlans, params: rawParams, options });
|
|
4119
4483
|
const continuation = options.confirmationToken
|
|
4120
4484
|
? mutationAuthorizationStore.consume(options.confirmationToken, fingerprint)
|
|
@@ -4159,7 +4523,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
4159
4523
|
const networkTracker = createNetworkTracker(page, settings, options.loginUrlContains, networkWatchPatterns);
|
|
4160
4524
|
const capture = createDiagnostics(page, options, settings, secrets);
|
|
4161
4525
|
const result = {
|
|
4162
|
-
...(serverVersion ? { server_version: serverVersion } : {}),
|
|
4526
|
+
...(serverVersion ? { server_version: serverVersion } : {}),
|
|
4527
|
+
...(selectedProfile ? { environment_profile: { id: selectedProfile.id, environment: selectedProfile.environment } } : {}),
|
|
4163
4528
|
status: "failed",
|
|
4164
4529
|
expected_outcome_visible: false,
|
|
4165
4530
|
acceptance_criteria_met: false,
|
|
@@ -4172,10 +4537,30 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
4172
4537
|
final_snapshot: "",
|
|
4173
4538
|
};
|
|
4174
4539
|
result.snapshot_mode = options.returnSnapshot;
|
|
4175
|
-
if (options.allowMutations) result.mutation_authorization =
|
|
4176
|
-
|
|
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: [] };
|
|
4177
4544
|
await networkTracker.waitForWebSocketObservers();
|
|
4178
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();
|
|
4179
4564
|
let initialNavigationMarker;
|
|
4180
4565
|
const timings = { navigationMs: 0, initialSnapshotMs: 0, readyMs: 0, decisionMs: 0, browserPlanMs: 0 };
|
|
4181
4566
|
const routeCleanups = [];
|
|
@@ -4248,6 +4633,9 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
4248
4633
|
}
|
|
4249
4634
|
}
|
|
4250
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
|
+
}
|
|
4251
4639
|
if (!navigationError && currentUrl && currentUrl !== "about:blank") {
|
|
4252
4640
|
let currentRoute;
|
|
4253
4641
|
try {
|
|
@@ -4409,8 +4797,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
4409
4797
|
result.accessibility_audits = continuation?.accessibilityAudits || [];
|
|
4410
4798
|
if (continuation?.downloadLedger) Object.assign(downloadLedger, continuation.downloadLedger);
|
|
4411
4799
|
const planStarted = performance.now();
|
|
4412
|
-
const runAllSteps = plan.steps.some(({ action, expectDownload }) => action.startsWith("assert_")
|
|
4413
|
-
|| ["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)
|
|
4414
4802
|
|| expectDownload === true);
|
|
4415
4803
|
let approvedMutationIndex = continuation?.nextStep;
|
|
4416
4804
|
for (let index = continuation?.nextStep || 0; index < plan.steps.length; index += 1) {
|
|
@@ -4462,7 +4850,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
4462
4850
|
if (index === approvedMutationIndex) approvedMutationIndex = undefined;
|
|
4463
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);
|
|
4464
4852
|
if (step.fragileSelector) addDiagnostic(result.warnings, `step ${index + 1} uses an explicit CSS/XPath selector as a last resort`, settings.jev.maxDiagnosticItems);
|
|
4465
|
-
|
|
4853
|
+
await integrationSession?.markStep?.(index + 1);
|
|
4854
|
+
if (step.action === "click" && plan.steps[index + 1]?.action === "confirm_dialog") {
|
|
4466
4855
|
nativeDialog.expectedStep = interpolateStep(plan.steps[index + 1], variables);
|
|
4467
4856
|
nativeDialog.result = undefined;
|
|
4468
4857
|
} else if (step.action !== "confirm_dialog") {
|
|
@@ -4478,12 +4867,17 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
4478
4867
|
autoAngularIdle: options.autoAngularIdle,
|
|
4479
4868
|
readyStableMs: options.readyStableMs,
|
|
4480
4869
|
localOnly: options.localOnly,
|
|
4481
|
-
allowMutations: options.allowMutations,
|
|
4870
|
+
allowMutations: options.allowMutations || index === approvedMutationIndex,
|
|
4871
|
+
environmentProfile: selectedProfile,
|
|
4872
|
+
integrationSession,
|
|
4873
|
+
downloadLedger,
|
|
4482
4874
|
browserPool,
|
|
4483
4875
|
nativeDialog,
|
|
4484
|
-
preResolvedTarget: mutationInspection.target,
|
|
4485
|
-
|
|
4486
|
-
|
|
4876
|
+
preResolvedTarget: mutationInspection.target,
|
|
4877
|
+
requestObserver: requestObservers.get(`${planId}:${index}`),
|
|
4878
|
+
popupPage,
|
|
4879
|
+
variables,
|
|
4880
|
+
secrets,
|
|
4487
4881
|
context,
|
|
4488
4882
|
contextStack,
|
|
4489
4883
|
capture,
|
|
@@ -4501,6 +4895,7 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
4501
4895
|
},
|
|
4502
4896
|
};
|
|
4503
4897
|
const traceStep = await executeStep(stepPage, step, settings, snapshot, secrets, downloadLedger, execution);
|
|
4898
|
+
armAvailableRequestObservers();
|
|
4504
4899
|
popupPage = execution.popupPage;
|
|
4505
4900
|
if (traceStep.activeContext) context = traceStep.activeContext;
|
|
4506
4901
|
const nextActivePage = traceStep.activePage;
|
|
@@ -4548,7 +4943,7 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
4548
4943
|
);
|
|
4549
4944
|
}
|
|
4550
4945
|
if (failure) break;
|
|
4551
|
-
|
|
4946
|
+
if (options.stopOnExpected && outcome && !runAllSteps && containsExpectedOutcome(snapshot, outcome)) break;
|
|
4552
4947
|
} catch (error) {
|
|
4553
4948
|
environmentFailure = browserDisconnected(error);
|
|
4554
4949
|
failure = error instanceof JevBrowserError
|
|
@@ -4645,8 +5040,8 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
4645
5040
|
}
|
|
4646
5041
|
}
|
|
4647
5042
|
}
|
|
4648
|
-
} catch (error) {
|
|
4649
|
-
const authenticationFailure = networkTracker.authenticationFailureSince(initialNetworkMarker);
|
|
5043
|
+
} catch (error) {
|
|
5044
|
+
const authenticationFailure = networkTracker.authenticationFailureSince(initialNetworkMarker);
|
|
4650
5045
|
const disconnected = (!options.freshContext && browserPool.getNavigationError?.() === "BROWSER_DISCONNECTED") || browserDisconnected(error);
|
|
4651
5046
|
if (authenticationFailure) {
|
|
4652
5047
|
navigationError = "AUTHENTICATION_REQUIRED";
|
|
@@ -4789,10 +5184,64 @@ export async function executeBrowserFlow({ flow, initialUrl, expectedOutcome, ca
|
|
|
4789
5184
|
safeResult.artifact_errors = [...(safeResult.artifact_errors || []), error instanceof JevBrowserError ? error.message : "flow report could not be saved"];
|
|
4790
5185
|
}
|
|
4791
5186
|
}
|
|
4792
|
-
return safeResult;
|
|
4793
|
-
}
|
|
4794
|
-
|
|
4795
|
-
|
|
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 }) {
|
|
4796
5245
|
if (localOnly) throw new JevBrowserError("local_only is enabled; choose_next_action cannot call the Jev provider");
|
|
4797
5246
|
const { request, criteria } = buildChoicePayload({ flow, pageSnapshot, actions, completedSteps }, settings.jev);
|
|
4798
5247
|
return parseDecision(await requestJev(request, settings, fetchImpl), criteria);
|