@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.
@@ -0,0 +1,655 @@
1
+ import { createHash, timingSafeEqual } from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ import { lstat, open, realpath, stat } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { PassThrough } from "node:stream";
6
+ import { setTimeout as delay } from "node:timers/promises";
7
+ import { JevBrowserError } from "./config.mjs";
8
+ import { isProfileOriginAllowed, requireEnvironmentProfile, resolveRemotePath } from "./environment-profiles.mjs";
9
+
10
+ const SENSITIVE_KEY = /(?:pass(?:word)?|secret|token|authorization|cookie|credential|api[_-]?key|private[_-]?key|email|cpf|cnpj|matricula|identity|phone|telefone|address|endereco|user(?:name)?|usuario)/i;
11
+ const SECRET_VALUE = /(?:Bearer\s+\S+|(?:password|token|secret|api[_-]?key)\s*[:=]\s*[^\s,;]+)/ig;
12
+ const SAFE_RESPONSE_HEADERS = new Set(["cache-control", "content-length", "content-type", "date", "etag", "last-modified", "retry-after", "vary"]);
13
+
14
+ function isRecord(value) {
15
+ return value !== null && typeof value === "object" && !Array.isArray(value);
16
+ }
17
+
18
+ function redactValue(value, depth = 0) {
19
+ if (depth > 8) return "[omitted]";
20
+ if (Array.isArray(value)) return value.slice(0, 100).map((item) => redactValue(item, depth + 1));
21
+ if (!isRecord(value)) return typeof value === "string" ? value.replace(SECRET_VALUE, "[redacted]").slice(0, 4000) : value;
22
+ return Object.fromEntries(Object.entries(value).slice(0, 100).map(([key, item]) => [key, SENSITIVE_KEY.test(key) ? "[redacted]" : redactValue(item, depth + 1)]));
23
+ }
24
+
25
+ function envValue(settings, name) {
26
+ const value = settings.env[name];
27
+ if (typeof value !== "string" || !value.trim()) throw new JevBrowserError(`required environment variable ${name} is not configured`);
28
+ return value;
29
+ }
30
+
31
+ function safeFilename(value, maxChars) {
32
+ if (typeof value !== "string" || !value.trim() || value.length > maxChars || value.includes("\0")
33
+ || value === "." || value === ".." || /[\\/<>:"|?*\u0000-\u001f\u007f]/u.test(value)) {
34
+ throw new JevBrowserError("fixture file_name must be a safe filename without path separators");
35
+ }
36
+ return value.normalize("NFC");
37
+ }
38
+
39
+ function mimeType(filename) {
40
+ const extension = path.extname(filename).toLowerCase();
41
+ return new Map([
42
+ [".pdf", "application/pdf"], [".txt", "text/plain"], [".csv", "text/csv"],
43
+ [".json", "application/json"], [".png", "image/png"], [".jpg", "image/jpeg"],
44
+ ]).get(extension) || "application/octet-stream";
45
+ }
46
+
47
+ function makePdf(text) {
48
+ const escaped = text.replace(/[^\x20-\x7e]/g, "?").replace(/[\\()]/g, "\\$&");
49
+ const objects = [
50
+ "<< /Type /Catalog /Pages 2 0 R >>",
51
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
52
+ "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
53
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
54
+ `<< /Length ${Buffer.byteLength(`BT /F1 12 Tf 72 720 Td (${escaped}) Tj ET\n`, "ascii")} >>\nstream\nBT /F1 12 Tf 72 720 Td (${escaped}) Tj ET\nendstream`,
55
+ ];
56
+ let document = "%PDF-1.4\n";
57
+ const offsets = [0];
58
+ for (let index = 0; index < objects.length; index += 1) {
59
+ offsets.push(Buffer.byteLength(document, "ascii"));
60
+ document += `${index + 1} 0 obj\n${objects[index]}\nendobj\n`;
61
+ }
62
+ const xrefOffset = Buffer.byteLength(document, "ascii");
63
+ document += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
64
+ for (const offset of offsets.slice(1)) document += `${String(offset).padStart(10, "0")} 00000 n \n`;
65
+ document += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
66
+ return Buffer.from(document, "ascii");
67
+ }
68
+
69
+ function deterministicBytes(size) {
70
+ const output = Buffer.alloc(size);
71
+ let offset = 0;
72
+ let counter = 0;
73
+ while (offset < size) {
74
+ const block = createHash("sha256").update(`jev-browser-fixture:${counter}`).digest();
75
+ block.copy(output, offset, 0, Math.min(block.length, size - offset));
76
+ offset += block.length;
77
+ counter += 1;
78
+ }
79
+ return output;
80
+ }
81
+
82
+ async function verifyManagedFile(filePath, settings) {
83
+ const roots = [settings.browser.uploadStagingDirectory, settings.browser.uploadRoot].filter(Boolean).map((root) => path.resolve(root));
84
+ const resolved = await realpath(filePath).catch(() => { throw new JevBrowserError("fixture file could not be read from a permitted upload directory"); });
85
+ const allowed = roots.some((root) => {
86
+ const relative = path.relative(root, resolved);
87
+ return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
88
+ });
89
+ const info = await stat(resolved);
90
+ if (!allowed || !info.isFile() || info.size > settings.browser.maxUploadFileBytes) {
91
+ throw new JevBrowserError("file path must resolve to a regular file within the configured upload root and size limit");
92
+ }
93
+ const handle = await open(resolved, "r");
94
+ try { return { path: resolved, info, buffer: await handle.readFile() }; }
95
+ finally { await handle.close(); }
96
+ }
97
+
98
+ function jsonPathValue(value, jsonPath) {
99
+ const parts = jsonPath.split(".");
100
+ let current = value;
101
+ for (const part of parts) {
102
+ if (!part || part === "__proto__" || part === "constructor" || part === "prototype") return undefined;
103
+ if (Array.isArray(current) && /^\d+$/.test(part)) current = current[Number(part)];
104
+ else if (isRecord(current) && Object.hasOwn(current, part)) current = current[part];
105
+ else return undefined;
106
+ }
107
+ return current;
108
+ }
109
+
110
+ function normalizedComparable(value) {
111
+ if (Array.isArray(value)) return value.map(normalizedComparable);
112
+ if (isRecord(value)) return Object.fromEntries(Object.keys(value).sort().map((key) => [key, normalizedComparable(value[key])]));
113
+ return value;
114
+ }
115
+
116
+ function matchesExpected(actual, expected) {
117
+ return Object.entries(expected).every(([key, value]) => JSON.stringify(normalizedComparable(actual?.[key])) === JSON.stringify(normalizedComparable(value)));
118
+ }
119
+
120
+ export function assertReadOnlySql(sql, maxQueryChars) {
121
+ if (!Number.isSafeInteger(maxQueryChars) || maxQueryChars <= 0 || typeof sql !== "string" || sql.length > maxQueryChars || !/^\s*select\b/i.test(sql)
122
+ || sql.includes(";") || /\b(?:insert|update|delete|merge|alter|drop|truncate|create|grant|revoke|commit|rollback|execute|call|begin|declare)\b/i.test(sql)) {
123
+ throw new JevBrowserError("assert_db accepts one read-only SELECT statement without PL/SQL or additional statements");
124
+ }
125
+ }
126
+
127
+ function variableMap(step) {
128
+ return isRecord(step.binds) ? step.binds : {};
129
+ }
130
+
131
+ async function assertDatabase(step, settings, profile, variables, secrets, adapters = {}) {
132
+ if (!profile.database) throw new JevBrowserError(`environment profile ${profile.id} has no read-only database configuration`);
133
+ assertReadOnlySql(step.sql, settings.browser.maxDatabaseQueryChars);
134
+ const binds = variableMap(step);
135
+ let rows;
136
+ if (profile.database.engine === "postgresql") {
137
+ const Pool = adapters.pgPool || (await import("pg")).Pool;
138
+ const pool = new Pool({
139
+ connectionString: envValue(settings, profile.database.connectionEnv),
140
+ max: 1,
141
+ connectionTimeoutMillis: profile.database.timeoutMs,
142
+ statement_timeout: profile.database.timeoutMs,
143
+ query_timeout: profile.database.timeoutMs,
144
+ allowExitOnIdle: true,
145
+ });
146
+ let client;
147
+ try {
148
+ client = await pool.connect();
149
+ await client.query("BEGIN READ ONLY");
150
+ await client.query("SELECT set_config('statement_timeout', $1, true)", [`${profile.database.timeoutMs}ms`]);
151
+ const boundedSql = `SELECT * FROM (${step.sql}) AS jev_browser_bounded_result LIMIT ${profile.database.maxRows + 1}`;
152
+ const reply = await client.query({ text: boundedSql, values: Object.values(binds), rowMode: "array" });
153
+ rows = reply.rows.slice(0, profile.database.maxRows).map((values) => Object.fromEntries(reply.fields.map((field, index) => [field.name, values[index]])));
154
+ if (reply.rowCount > profile.database.maxRows) throw new JevBrowserError("assert_db result exceeded the configured row limit");
155
+ await client.query("ROLLBACK");
156
+ } catch (error) {
157
+ await client?.query("ROLLBACK").catch(() => {});
158
+ if (error instanceof JevBrowserError) throw error;
159
+ throw new JevBrowserError(`assert_db PostgreSQL query failed (${error?.code || "database error"})`);
160
+ } finally {
161
+ client?.release();
162
+ await pool.end();
163
+ }
164
+ } else {
165
+ const oracledb = adapters.oracledb || (await import("oracledb")).default;
166
+ const connection = await oracledb.getConnection({
167
+ connectString: envValue(settings, profile.database.connectEnv),
168
+ user: envValue(settings, profile.database.userEnv),
169
+ password: envValue(settings, profile.database.passwordEnv),
170
+ });
171
+ connection.callTimeout = profile.database.timeoutMs;
172
+ try {
173
+ await connection.execute("SET TRANSACTION READ ONLY");
174
+ const reply = await connection.execute(step.sql, binds, {
175
+ outFormat: oracledb.OUT_FORMAT_OBJECT,
176
+ maxRows: profile.database.maxRows + 1,
177
+ fetchArraySize: Math.min(100, profile.database.maxRows),
178
+ });
179
+ rows = (reply.rows || []).slice(0, profile.database.maxRows);
180
+ if ((reply.rows || []).length > profile.database.maxRows) throw new JevBrowserError("assert_db result exceeded the configured row limit");
181
+ await connection.rollback();
182
+ } catch (error) {
183
+ await connection.rollback().catch(() => {});
184
+ if (error instanceof JevBrowserError) throw error;
185
+ throw new JevBrowserError(`assert_db Oracle query failed (${error?.errorNum || "database error"})`);
186
+ } finally {
187
+ await connection.close();
188
+ }
189
+ }
190
+ const expected = step.expected;
191
+ const countMatched = expected.row_count === undefined || rows.length === expected.row_count;
192
+ const firstRowMatched = expected.first_row === undefined || matchesExpected(rows[0], expected.first_row);
193
+ const allRowsMatched = expected.rows === undefined || JSON.stringify(normalizedComparable(rows)) === JSON.stringify(normalizedComparable(expected.rows));
194
+ const matched = countMatched && firstRowMatched && allRowsMatched;
195
+ if (!matched) throw new JevBrowserError(`assert_db failed for profile ${profile.id}: expected result did not match (${rows.length} row(s))`);
196
+ for (const [as, extraction] of Object.entries(step.extract || {})) {
197
+ const extracted = jsonPathValue(rows[extraction.row ?? 0], extraction.column);
198
+ if (extracted === undefined) throw new JevBrowserError(`assert_db extract ${as} did not resolve to a selected column`);
199
+ variables[as] = String(extracted);
200
+ if (SENSITIVE_KEY.test(extraction.column) && String(extracted) && !secrets.includes(String(extracted))) secrets.push(String(extracted));
201
+ }
202
+ return { action: step.action, asserted: true, engine: profile.database.engine, row_count: rows.length };
203
+ }
204
+
205
+ async function readLogSince(session, source, since, maxBytes) {
206
+ const configuredPath = session.profile.logSources[source];
207
+ if (!configuredPath) throw new JevBrowserError(`log source ${source} is not allow-listed in environment profile ${session.profile.id}`);
208
+ const info = await lstat(configuredPath).catch(() => { throw new JevBrowserError(`configured log source ${source} is unavailable`); });
209
+ if (info.isSymbolicLink() || !info.isFile()) throw new JevBrowserError(`configured log source ${source} must be a regular non-symlink file`);
210
+ const realFile = await realpath(configuredPath);
211
+ if (realFile !== path.resolve(configuredPath)) throw new JevBrowserError(`configured log source ${source} must not use symlinks`);
212
+ const currentSize = (await stat(realFile)).size;
213
+ const initial = session.logFlowOffsets[source] ?? 0;
214
+ const fromStep = session.logStepOffsets.get(Number(String(since).slice(5)))?.[source];
215
+ const offset = since === "flow" ? initial : fromStep;
216
+ if (offset === undefined) throw new JevBrowserError("assert_log since must be flow or a previously executed step:N");
217
+ const start = Math.max(offset, currentSize - maxBytes);
218
+ const length = Math.max(0, currentSize - start);
219
+ const handle = await open(realFile, "r");
220
+ try {
221
+ const buffer = Buffer.alloc(length);
222
+ if (length) await handle.read(buffer, 0, length, start);
223
+ return { text: buffer.toString("utf8"), truncated: start > offset };
224
+ } finally { await handle.close(); }
225
+ }
226
+
227
+ async function startMailbox(session, settings) {
228
+ if (session.smtpServer) return session.smtpAddress;
229
+ const smtp = session.profile.smtpCapture;
230
+ if (!smtp) throw new JevBrowserError(`environment profile ${session.profile.id} has no smtp_capture configuration`);
231
+ const [{ SMTPServer }, { simpleParser }] = await Promise.all([import("smtp-server"), import("mailparser")]);
232
+ session.mailParser = simpleParser;
233
+ const maxBytes = settings.browser.maxNetworkErrorBodyBytes;
234
+ session.smtpServer = new SMTPServer({
235
+ disabledCommands: ["AUTH", "STARTTLS"],
236
+ authOptional: true,
237
+ size: maxBytes,
238
+ onData(stream, _session, callback) {
239
+ const chunks = [];
240
+ let total = 0;
241
+ let overflow = false;
242
+ stream.on("data", (chunk) => {
243
+ total += chunk.length;
244
+ if (total <= maxBytes) chunks.push(chunk);
245
+ else overflow = true;
246
+ });
247
+ stream.on("error", (error) => callback(error));
248
+ stream.on("end", async () => {
249
+ if (overflow) return callback(new Error("message exceeds configured SMTP capture limit"));
250
+ try {
251
+ const parsed = await session.mailParser(Buffer.concat(chunks, total));
252
+ if (session.mailbox.length >= smtp.maxMessages) return callback(new Error("SMTP capture mailbox is full"));
253
+ session.mailbox.push({
254
+ from: String(parsed.from?.text || "").slice(0, 512),
255
+ to: (parsed.to?.value || []).map(({ address }) => String(address || "").slice(0, 254)).slice(0, 50),
256
+ subject: String(parsed.subject || "").slice(0, 1000),
257
+ text: String(parsed.text || "").slice(0, maxBytes),
258
+ html: String(parsed.html || "").slice(0, maxBytes),
259
+ received_at: new Date().toISOString(),
260
+ });
261
+ callback(null, "message captured by jev-browser test mailbox");
262
+ } catch {
263
+ callback(new Error("SMTP message could not be parsed"));
264
+ }
265
+ });
266
+ },
267
+ });
268
+ await new Promise((resolve, reject) => {
269
+ const fail = (error) => reject(error);
270
+ session.smtpServer.once("error", fail);
271
+ session.smtpServer.listen(smtp.port, smtp.bindAddress, () => {
272
+ session.smtpServer.removeListener("error", fail);
273
+ resolve();
274
+ });
275
+ }).catch((error) => {
276
+ session.smtpServer = undefined;
277
+ throw new JevBrowserError(`test SMTP capture could not bind to ${smtp.bindAddress}:${smtp.port} (${error?.code || "bind error"})`);
278
+ });
279
+ const address = session.smtpServer.server.address();
280
+ session.smtpAddress = { host: smtp.bindAddress, port: address.port };
281
+ return session.smtpAddress;
282
+ }
283
+
284
+ async function assertEmail(step, session, settings) {
285
+ if (!session.profile.smtpCapture) throw new JevBrowserError("assert_email needs an environment profile with smtp_capture configured");
286
+ if (!session.smtpServer) throw new JevBrowserError("assert_email requires start_email_capture before the application sends its message");
287
+ const timeoutMs = step.timeoutMs ?? settings.browser.actionTimeoutMs;
288
+ const deadline = Date.now() + timeoutMs;
289
+ while (Date.now() <= deadline) {
290
+ const message = session.mailbox.find((item) => (!step.to || item.to.some((to) => to.toLowerCase() === step.to.toLowerCase()))
291
+ && (!step.subjectContains || item.subject.toLowerCase().includes(step.subjectContains.toLowerCase()))
292
+ && (!step.bodyContains || `${item.text}\n${item.html}`.toLowerCase().includes(step.bodyContains.toLowerCase())));
293
+ if (message) return { action: step.action, asserted: true, recipient_matched: Boolean(step.to), subject_matched: Boolean(step.subjectContains), body_matched: Boolean(step.bodyContains), received_at: message.received_at };
294
+ await delay(Math.min(100, Math.max(1, deadline - Date.now())));
295
+ }
296
+ throw new JevBrowserError(`assert_email did not observe a matching message within ${timeoutMs}ms`);
297
+ }
298
+
299
+ function requiredRemoteCredentials(remote, settings) {
300
+ return {
301
+ user: envValue(settings, remote.usernameEnv),
302
+ password: envValue(settings, remote.passwordEnv),
303
+ };
304
+ }
305
+
306
+ function verifiedHostFingerprint(value) {
307
+ return String(value || "").replace(/^SHA256:/i, "").replace(/=+$/, "");
308
+ }
309
+
310
+ async function withRemote(remoteId, profile, settings, operation) {
311
+ const remote = profile.remotes[remoteId];
312
+ if (!remote) throw new JevBrowserError(`remote server ${remoteId} is not configured in environment profile ${profile.id}`);
313
+ const credentials = requiredRemoteCredentials(remote, settings);
314
+ if (remote.protocol === "sftp") {
315
+ const module = await import("ssh2-sftp-client");
316
+ const Client = module.default;
317
+ const client = new Client("jev-browser-test");
318
+ const expected = verifiedHostFingerprint(envValue(settings, remote.hostFingerprintEnv));
319
+ if (!expected) throw new JevBrowserError("SFTP host fingerprint is empty");
320
+ await client.connect({
321
+ host: remote.host,
322
+ port: remote.port,
323
+ username: credentials.user,
324
+ password: credentials.password,
325
+ hostHash: "sha256",
326
+ hostVerifier: (hash) => {
327
+ const actual = verifiedHostFingerprint(Buffer.isBuffer(hash) ? hash.toString("base64") : hash);
328
+ const actualBytes = Buffer.from(actual);
329
+ const expectedBytes = Buffer.from(expected);
330
+ return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes);
331
+ },
332
+ readyTimeout: settings.browser.actionTimeoutMs,
333
+ });
334
+ try { return await operation(client, remote); } finally { await client.end().catch(() => {}); }
335
+ }
336
+ const module = await import("basic-ftp");
337
+ const client = new module.Client(settings.browser.actionTimeoutMs);
338
+ try {
339
+ await client.access({
340
+ host: remote.host,
341
+ port: remote.port,
342
+ user: credentials.user,
343
+ password: credentials.password,
344
+ secure: remote.protocol === "ftps",
345
+ });
346
+ return await operation(client, remote);
347
+ } finally { client.close(); }
348
+ }
349
+
350
+ async function assertRemoteFile(step, profile, settings) {
351
+ return withRemote(step.server, profile, settings, async (client, remote) => {
352
+ const remotePath = resolveRemotePath(remote, step.path);
353
+ let size;
354
+ try {
355
+ if (remote.protocol === "sftp") {
356
+ const exists = await client.exists(remotePath);
357
+ if (exists === false) {
358
+ if (step.exists) throw new JevBrowserError("assert_remote_file expected the remote file to exist");
359
+ return { action: step.action, asserted: true, exists: false };
360
+ }
361
+ const info = await client.stat(remotePath);
362
+ if (!info.isFile) throw new JevBrowserError("assert_remote_file target is not a regular file");
363
+ size = info.size;
364
+ if (step.sha256) {
365
+ if (size > settings.browser.maxDownloadFileBytes) throw new JevBrowserError("remote file exceeds the configured verification size limit");
366
+ const bytes = await client.get(remotePath);
367
+ const actual = createHash("sha256").update(bytes).digest("hex");
368
+ if (actual !== step.sha256) throw new JevBrowserError("assert_remote_file SHA-256 did not match");
369
+ }
370
+ } else {
371
+ const parent = path.posix.dirname(remotePath);
372
+ const name = path.posix.basename(remotePath);
373
+ const entry = (await client.list(parent)).find((candidate) => candidate.name === name);
374
+ if (!entry || entry.type !== 1) {
375
+ if (step.exists) throw new JevBrowserError("assert_remote_file expected the remote file to exist");
376
+ return { action: step.action, asserted: true, exists: false };
377
+ }
378
+ size = entry.size;
379
+ if (step.sha256) {
380
+ if (size > settings.browser.maxDownloadFileBytes) throw new JevBrowserError("remote file exceeds the configured verification size limit");
381
+ const chunks = [];
382
+ const output = new PassThrough();
383
+ output.on("data", (chunk) => chunks.push(chunk));
384
+ await client.downloadTo(output, remotePath);
385
+ const actual = createHash("sha256").update(Buffer.concat(chunks)).digest("hex");
386
+ if (actual !== step.sha256) throw new JevBrowserError("assert_remote_file SHA-256 did not match");
387
+ }
388
+ }
389
+ if (step.sizeBytes !== undefined && size !== step.sizeBytes) throw new JevBrowserError("assert_remote_file size did not match");
390
+ return { action: step.action, asserted: true, exists: true, size_bytes: size, ...(step.sha256 ? { sha256_matched: true } : {}) };
391
+ } catch (error) {
392
+ if (error instanceof JevBrowserError) throw error;
393
+ throw new JevBrowserError(`assert_remote_file failed for ${step.server} (${error?.code || "remote file error"})`);
394
+ }
395
+ });
396
+ }
397
+
398
+ async function deleteRemoteFile(step, profile, settings, allowMutations) {
399
+ if (profile.environment !== "dev") throw new JevBrowserError("delete_remote_file is restricted to DEV profiles");
400
+ if (!allowMutations) throw new JevBrowserError("delete_remote_file requires explicit allow_mutations authorization");
401
+ return withRemote(step.server, profile, settings, async (client, remote) => {
402
+ const remotePath = resolveRemotePath(remote, step.path);
403
+ try {
404
+ if (remote.protocol === "sftp") await client.delete(remotePath);
405
+ else await client.remove(remotePath);
406
+ return { action: step.action, deleted: true, server: step.server };
407
+ } catch (error) {
408
+ throw new JevBrowserError(`delete_remote_file failed for ${step.server} (${error?.code || "remote delete error"})`);
409
+ }
410
+ });
411
+ }
412
+
413
+ function safeHookArguments(hook, provided) {
414
+ if (!isRecord(provided)) throw new JevBrowserError("run_hook args must be an object");
415
+ const unknown = Object.keys(provided).filter((key) => !Object.hasOwn(hook.arguments, key));
416
+ const missing = Object.entries(hook.arguments).filter(([key, descriptor]) => descriptor.required && !Object.hasOwn(provided, key)).map(([key]) => key);
417
+ if (unknown.length || missing.length) throw new JevBrowserError(`run_hook arguments invalid${unknown.length ? `; unsupported: ${unknown.join(", ")}` : ""}${missing.length ? `; missing: ${missing.join(", ")}` : ""}`);
418
+ const args = [];
419
+ for (const [key, value] of Object.entries(provided)) {
420
+ const schema = hook.arguments[key];
421
+ if (schema.type === "string") {
422
+ if (typeof value !== "string" || value.length > schema.maxLength || /[\0\r\n]/.test(value)) throw new JevBrowserError(`run_hook argument ${key} must be a string within its configured limit`);
423
+ args.push(`--${key}`, value);
424
+ } else if (schema.type === "integer") {
425
+ if (!Number.isSafeInteger(value)) throw new JevBrowserError(`run_hook argument ${key} must be an integer`);
426
+ args.push(`--${key}`, String(value));
427
+ } else {
428
+ if (typeof value !== "boolean") throw new JevBrowserError(`run_hook argument ${key} must be a boolean`);
429
+ args.push(`--${key}`, String(value));
430
+ }
431
+ }
432
+ return args;
433
+ }
434
+
435
+ async function runAllowlistedHook(step, settings, profile, allowMutations) {
436
+ if (profile.environment !== "dev") throw new JevBrowserError("run_hook is restricted to DEV profiles");
437
+ if (!allowMutations) throw new JevBrowserError("run_hook requires explicit allow_mutations authorization");
438
+ const hook = profile.hooks[step.hook];
439
+ if (!hook) throw new JevBrowserError(`hook ${step.hook} is not allow-listed in environment profile ${profile.id}`);
440
+ const target = await realpath(hook.script).catch(() => { throw new JevBrowserError(`allow-listed hook ${step.hook} is unavailable`); });
441
+ const info = await lstat(hook.script);
442
+ if (info.isSymbolicLink() || !info.isFile() || target !== path.resolve(hook.script)) throw new JevBrowserError(`allow-listed hook ${step.hook} must be a regular non-symlink file`);
443
+ const args = safeHookArguments(hook, step.args);
444
+ const timeoutMs = Math.min(step.timeoutMs, hook.timeoutMs);
445
+ const outcome = await new Promise((resolve) => {
446
+ const child = spawn(process.execPath, [target, ...args], {
447
+ cwd: settings.browser.projectRoot,
448
+ shell: false,
449
+ windowsHide: true,
450
+ stdio: ["ignore", "pipe", "pipe"],
451
+ env: {
452
+ PATH: process.env.PATH || "",
453
+ ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}),
454
+ },
455
+ });
456
+ let outputBytes = 0;
457
+ let timedOut = false;
458
+ child.stdout.on("data", (chunk) => { outputBytes += chunk.length; });
459
+ child.stderr.on("data", (chunk) => { outputBytes += chunk.length; });
460
+ const timer = setTimeout(() => { timedOut = true; child.kill(); }, timeoutMs);
461
+ child.once("error", () => { clearTimeout(timer); resolve({ code: null, timedOut, outputBytes }); });
462
+ child.once("close", (code) => { clearTimeout(timer); resolve({ code, timedOut, outputBytes }); });
463
+ });
464
+ if (outcome.timedOut) throw new JevBrowserError(`allow-listed hook ${step.hook} exceeded its timeout`);
465
+ const passed = outcome.code === step.expectedExitCode;
466
+ if (!passed) throw new JevBrowserError(`allow-listed hook ${step.hook} exited with code ${outcome.code ?? "unknown"}; expected ${step.expectedExitCode}`);
467
+ return { action: step.action, asserted: true, hook: step.hook, exit_code: outcome.code, output_bytes: Math.min(outcome.outputBytes, settings.browser.maxHookOutputBytes) };
468
+ }
469
+
470
+ export function createIntegrationSession(settings, profileId) {
471
+ const profile = profileId ? requireEnvironmentProfile(settings, profileId, "integrated test actions") : undefined;
472
+ const session = {
473
+ settings,
474
+ profile,
475
+ mailbox: [],
476
+ smtpServer: undefined,
477
+ smtpAddress: undefined,
478
+ mailParser: undefined,
479
+ logFlowOffsets: {},
480
+ logStepOffsets: new Map(),
481
+ async markFlowStart() {
482
+ if (!profile) return;
483
+ for (const [name, filePath] of Object.entries(profile.logSources)) {
484
+ this.logFlowOffsets[name] = await stat(filePath).then(({ size }) => size).catch(() => 0);
485
+ }
486
+ },
487
+ async markStep(stepIndex) {
488
+ if (!profile) return;
489
+ const offsets = {};
490
+ for (const [name, filePath] of Object.entries(profile.logSources)) offsets[name] = await stat(filePath).then(({ size }) => size).catch(() => 0);
491
+ this.logStepOffsets.set(stepIndex, offsets);
492
+ },
493
+ async close() {
494
+ if (this.smtpServer) await new Promise((resolve) => this.smtpServer.close(() => resolve())).catch(() => {});
495
+ this.smtpServer = undefined;
496
+ this.mailbox.length = 0;
497
+ },
498
+ };
499
+ return session;
500
+ }
501
+
502
+ export async function executeIntegrationStep(step, context) {
503
+ const { settings, profile, session, variables, page, execution, secrets, adapters } = context;
504
+ if (step.action === "generate_fixture") {
505
+ const filename = safeFilename(step.fileName, settings.browser.maxUploadPathChars);
506
+ let buffer;
507
+ if (step.kind === "text") buffer = Buffer.from(step.content ?? "Jev Browser test fixture\n", "utf8");
508
+ else if (step.kind === "pdf") buffer = makePdf(step.content ?? "Jev Browser test fixture");
509
+ else buffer = deterministicBytes(step.sizeBytes);
510
+ if (buffer.length > settings.browser.maxUploadFileBytes) throw new JevBrowserError("generate_fixture exceeds the configured per-file upload limit; Jev also imposes a 47 MiB provider limit");
511
+ const { prepareUploadStagingDirectory } = await import("./upload-staging.mjs");
512
+ const directory = await prepareUploadStagingDirectory(settings);
513
+ if (!directory) throw new JevBrowserError("project-local fixture staging is unavailable");
514
+ const filePath = path.join(directory, filename);
515
+ const { writeFile } = await import("node:fs/promises");
516
+ await writeFile(filePath, buffer, { flag: "wx", mode: 0o600 });
517
+ variables[step.as] = filePath;
518
+ return {
519
+ action: step.action,
520
+ fixture: step.as,
521
+ file_name: filename,
522
+ size_bytes: buffer.length,
523
+ sha256: createHash("sha256").update(buffer).digest("hex"),
524
+ mime_type: mimeType(filename),
525
+ };
526
+ }
527
+ if (step.action === "assert_download") {
528
+ const source = await verifyManagedFile(step.filePath, settings);
529
+ const downloaded = execution.downloadLedger.files.at(-1);
530
+ if (!downloaded) throw new JevBrowserError("assert_download needs a completed download earlier in the same flow");
531
+ const downloadedHandle = await open(downloaded.path, "r");
532
+ let bytes;
533
+ try { bytes = await downloadedHandle.readFile(); }
534
+ finally { await downloadedHandle.close(); }
535
+ const sourceHash = createHash("sha256").update(source.buffer).digest("hex");
536
+ const downloadedHash = createHash("sha256").update(bytes).digest("hex");
537
+ const sourceMime = mimeType(path.basename(source.path));
538
+ const downloadMime = downloaded.mimeType || mimeType(downloaded.name);
539
+ const matches = source.info.size === downloaded.sizeBytes && sourceHash === downloadedHash && sourceMime === downloadMime
540
+ && (!step.mimeType || step.mimeType === downloadMime);
541
+ if (!matches) throw new JevBrowserError("assert_download file integrity, size, or MIME type did not match the fixture");
542
+ return { action: step.action, asserted: true, sha256: downloadedHash, size_bytes: bytes.length, mime_type: downloadMime };
543
+ }
544
+ if (step.action === "http_request") {
545
+ const pageUrl = new URL(page.url());
546
+ const target = new URL(step.url, pageUrl);
547
+ const timeoutMs = step.timeoutMs ?? settings.browser.actionTimeoutMs;
548
+ if (target.origin !== pageUrl.origin) throw new JevBrowserError("http_request must stay on the current application origin");
549
+ if (profile ? !isProfileOriginAllowed(profile, target.toString()) : (!execution.localOnly || !/^https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/|$)/i.test(target.toString()))) {
550
+ throw new JevBrowserError("http_request origin is not allowed by local_only or the selected environment profile");
551
+ }
552
+ if (step.method !== "GET" && (!execution.allowMutations || !profile || profile.environment === "prd")) {
553
+ throw new JevBrowserError("mutating http_request requires allow_mutations and an explicit DEV or HML profile");
554
+ }
555
+ const multipart = [];
556
+ for (const field of step.multipart || []) {
557
+ if (Object.hasOwn(field, "filePath")) {
558
+ const file = await verifyManagedFile(field.filePath, settings);
559
+ multipart.push({ name: field.name, fileName: field.fileName || path.basename(file.path), mimeType: mimeType(field.fileName || file.path), base64: file.buffer.toString("base64") });
560
+ } else multipart.push({ name: field.name, value: String(field.value ?? "") });
561
+ }
562
+ const reply = await page.evaluate(async ({ url, method, json, multipart: fields, includeBody, maxBytes, timeoutMs: requestTimeoutMs }) => {
563
+ const controller = new AbortController();
564
+ const timeout = setTimeout(() => controller.abort(), requestTimeoutMs);
565
+ try {
566
+ const options = { method, credentials: "same-origin", redirect: "error", cache: "no-store", signal: controller.signal };
567
+ if (json !== undefined) {
568
+ options.headers = { "content-type": "application/json" };
569
+ options.body = JSON.stringify(json);
570
+ } else if (fields.length) {
571
+ const form = new FormData();
572
+ for (const field of fields) {
573
+ if (field.base64 !== undefined) {
574
+ const binary = atob(field.base64);
575
+ const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
576
+ form.append(field.name, new File([bytes], field.fileName, { type: field.mimeType }));
577
+ } else form.append(field.name, field.value);
578
+ }
579
+ options.body = form;
580
+ }
581
+ const response = await fetch(url, options);
582
+ const headers = {};
583
+ for (const name of ["cache-control", "content-length", "content-type", "date", "etag", "last-modified", "retry-after", "vary"]) {
584
+ const value = response.headers.get(name);
585
+ if (value !== null) headers[name] = value.slice(0, 256);
586
+ }
587
+ const reader = response.body?.getReader();
588
+ const chunks = [];
589
+ let size = 0;
590
+ let truncated = false;
591
+ if (reader) {
592
+ while (size <= maxBytes) {
593
+ const { done, value } = await reader.read();
594
+ if (done) break;
595
+ const chunk = value.slice(0, Math.max(0, maxBytes + 1 - size));
596
+ chunks.push(chunk);
597
+ size += chunk.length;
598
+ if (size > maxBytes || chunk.length < value.length) { truncated = true; await reader.cancel(); break; }
599
+ }
600
+ }
601
+ const bytes = new Uint8Array(Math.min(size, maxBytes));
602
+ let offset = 0;
603
+ for (const chunk of chunks) { const accepted = chunk.subarray(0, Math.max(0, bytes.length - offset)); bytes.set(accepted, offset); offset += accepted.length; }
604
+ const body = new TextDecoder().decode(bytes);
605
+ let parsed;
606
+ try { parsed = JSON.parse(body); } catch { parsed = body; }
607
+ return { status: response.status, ok: response.ok, headers, body: includeBody ? parsed : undefined, body_truncated: truncated };
608
+ } finally {
609
+ clearTimeout(timeout);
610
+ }
611
+ }, {
612
+ url: target.toString(), method: step.method, json: step.json, multipart,
613
+ includeBody: step.includeBody || Object.keys(step.extract || {}).length > 0,
614
+ maxBytes: settings.browser.maxNetworkErrorBodyBytes,
615
+ timeoutMs,
616
+ });
617
+ if (Object.keys(step.extract || {}).length && (reply.body === undefined || typeof reply.body === "string" || reply.body_truncated)) {
618
+ throw new JevBrowserError("http_request extract requires a complete JSON response body");
619
+ }
620
+ for (const [as, extraction] of Object.entries(step.extract || {})) {
621
+ const value = jsonPathValue(reply.body, extraction.jsonPath);
622
+ if (value === undefined || isRecord(value) || Array.isArray(value)) throw new JevBrowserError(`http_request extract ${as} did not resolve to a scalar JSON value`);
623
+ variables[as] = String(value);
624
+ if (SENSITIVE_KEY.test(extraction.jsonPath.split(".").at(-1)) && String(value) && !secrets.includes(String(value))) secrets.push(String(value));
625
+ }
626
+ if (step.expectedStatus !== undefined && reply.status !== step.expectedStatus) throw new JevBrowserError(`http_request expected status ${step.expectedStatus}, received ${reply.status}`);
627
+ const safeBody = step.includeBody ? redactValue(reply.body) : undefined;
628
+ return {
629
+ action: step.action,
630
+ method: step.method,
631
+ url: target.origin + target.pathname,
632
+ status: reply.status,
633
+ ok: reply.ok,
634
+ response_headers: reply.headers,
635
+ ...(step.includeBody ? { response_body: safeBody, body_truncated: reply.body_truncated } : {}),
636
+ ...(Object.keys(step.extract || {}).length ? { extracted_fields: Object.keys(step.extract) } : {}),
637
+ };
638
+ }
639
+ if (step.action === "assert_db") return assertDatabase(step, settings, profile, variables, secrets, adapters);
640
+ if (step.action === "assert_log") {
641
+ const { text, truncated } = await readLogSince(session, step.source, step.since, settings.browser.maxNetworkErrorBodyBytes);
642
+ const passed = text.includes(step.contains) && (!step.notContains || !text.includes(step.notContains));
643
+ if (!passed) throw new JevBrowserError(`assert_log did not find the expected log condition in source ${step.source}${truncated ? " (bounded tail scanned)" : ""}`);
644
+ return { action: step.action, asserted: true, source: step.source, since: step.since, scanned_bytes: Buffer.byteLength(text), truncated };
645
+ }
646
+ if (step.action === "start_email_capture") {
647
+ const address = await startMailbox(session, settings);
648
+ return { action: step.action, host: address.host, port: address.port, mailbox: "in-memory; cleared when the flow ends" };
649
+ }
650
+ if (step.action === "assert_email") return assertEmail(step, session, settings);
651
+ if (step.action === "assert_remote_file") return assertRemoteFile(step, profile, settings);
652
+ if (step.action === "delete_remote_file") return deleteRemoteFile(step, profile, settings, execution.allowMutations);
653
+ if (step.action === "run_hook") return runAllowlistedHook(step, settings, profile, execution.allowMutations);
654
+ throw new JevBrowserError(`unsupported integrated test action ${step.action}`);
655
+ }