@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
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import { lstat, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import packageManifest from "../../../package.json" with { type: "json" };
|
|
4
|
+
import { JevBrowserError } from "./config.mjs";
|
|
5
|
+
import { createIntegrationSession } from "./test-integrations.mjs";
|
|
6
|
+
import { validateBrowserFlowRequest } from "./flow.mjs";
|
|
7
|
+
|
|
8
|
+
const SUITE_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$/;
|
|
9
|
+
const suiteDirectoryName = ".jev-browser/suites";
|
|
10
|
+
const reportDirectoryName = ".jev-browser/reports";
|
|
11
|
+
|
|
12
|
+
function isRecord(value) {
|
|
13
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function exactFields(value, required, optional, label) {
|
|
17
|
+
if (!isRecord(value)) throw new JevBrowserError(`${label} must be an object`);
|
|
18
|
+
const allowed = new Set([...required, ...optional]);
|
|
19
|
+
const missing = required.filter((key) => !Object.hasOwn(value, key));
|
|
20
|
+
const unsupported = Object.keys(value).filter((key) => !allowed.has(key));
|
|
21
|
+
if (missing.length || unsupported.length) {
|
|
22
|
+
throw new JevBrowserError(`${label} has invalid fields${missing.length ? `; missing: ${missing.join(", ")}` : ""}${unsupported.length ? `; unsupported: ${unsupported.join(", ")}` : ""}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function validateFlowDefinition(definition, label) {
|
|
27
|
+
exactFields(definition, ["flow", "candidate_plans"], ["initial_url", "expected_outcome", "params", "options"], label);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function safeProjectPath(projectRoot, relativePath) {
|
|
31
|
+
if (typeof relativePath !== "string" || !relativePath.trim() || path.isAbsolute(relativePath) || path.win32.isAbsolute(relativePath)
|
|
32
|
+
|| relativePath.split(/[\\/]+/).includes("..")) throw new JevBrowserError("suite/report path must be relative and stay inside the project");
|
|
33
|
+
const root = path.resolve(projectRoot);
|
|
34
|
+
const target = path.resolve(root, relativePath);
|
|
35
|
+
const relative = path.relative(root, target);
|
|
36
|
+
if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
37
|
+
throw new JevBrowserError("suite/report path must stay inside the project");
|
|
38
|
+
}
|
|
39
|
+
return target;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function ensureSafeParent(projectRoot, target) {
|
|
43
|
+
const root = await realpath(projectRoot);
|
|
44
|
+
let current = root;
|
|
45
|
+
for (const part of path.relative(root, path.dirname(target)).split(path.sep).filter(Boolean)) {
|
|
46
|
+
current = path.join(current, part);
|
|
47
|
+
try {
|
|
48
|
+
const info = await lstat(current);
|
|
49
|
+
if (info.isSymbolicLink() || !info.isDirectory()) throw new JevBrowserError("suite/report directory must not contain symlinks");
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (error?.code !== "ENOENT") throw error;
|
|
52
|
+
await mkdir(current, { mode: 0o700 });
|
|
53
|
+
}
|
|
54
|
+
const resolved = await realpath(current);
|
|
55
|
+
const relative = path.relative(root, resolved);
|
|
56
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
57
|
+
throw new JevBrowserError("suite/report directory resolves outside the project");
|
|
58
|
+
}
|
|
59
|
+
current = resolved;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function preflightReportTarget(projectRoot, target) {
|
|
64
|
+
const root = await realpath(projectRoot);
|
|
65
|
+
let current = root;
|
|
66
|
+
const parentParts = path.relative(root, path.dirname(target)).split(path.sep).filter(Boolean);
|
|
67
|
+
for (const part of parentParts) {
|
|
68
|
+
current = path.join(current, part);
|
|
69
|
+
try {
|
|
70
|
+
const info = await lstat(current);
|
|
71
|
+
if (info.isSymbolicLink() || !info.isDirectory()) throw new JevBrowserError("suite/report directory must not contain symlinks or non-directories");
|
|
72
|
+
current = await realpath(current);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error?.code === "ENOENT") break;
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const info = await lstat(target);
|
|
80
|
+
if (info.isSymbolicLink() || !info.isFile()) throw new JevBrowserError("report destination must be a regular non-symlink file");
|
|
81
|
+
throw new JevBrowserError("report destination already exists");
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (error?.code !== "ENOENT") throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function validateSuiteDocument(document, suiteName, settings) {
|
|
88
|
+
exactFields(document, ["version", "id", "criteria"], ["description", "environment_profile", "params", "flows", "setup", "teardown"], "test suite");
|
|
89
|
+
if (document.version !== 1) throw new JevBrowserError("test suite version must be 1");
|
|
90
|
+
if (document.id !== suiteName) throw new JevBrowserError("test suite id must match suite_name");
|
|
91
|
+
if (document.description !== undefined && (typeof document.description !== "string" || document.description.length > 2000)) throw new JevBrowserError("test suite description must be at most 2000 characters");
|
|
92
|
+
if (document.environment_profile !== undefined && (typeof document.environment_profile !== "string" || !document.environment_profile.trim())) throw new JevBrowserError("test suite environment_profile must be a non-empty profile name");
|
|
93
|
+
if (document.params !== undefined && !isRecord(document.params)) throw new JevBrowserError("test suite params must be an object");
|
|
94
|
+
if (document.flows !== undefined && !isRecord(document.flows)) throw new JevBrowserError("test suite flows must be an object of reusable flow definitions");
|
|
95
|
+
for (const key of ["setup", "teardown"]) {
|
|
96
|
+
if (document[key] !== undefined && typeof document[key] !== "string" && !isRecord(document[key])) throw new JevBrowserError(`test suite ${key} must reference a reusable flow or define one inline`);
|
|
97
|
+
}
|
|
98
|
+
if (!Array.isArray(document.criteria) || !document.criteria.length || document.criteria.length > settings.browser.maxTestSuiteCriteria) throw new JevBrowserError("test suite criteria count exceeds the configured suite limit");
|
|
99
|
+
for (const [name, definition] of Object.entries(document.flows || {})) {
|
|
100
|
+
if (!SUITE_NAME.test(name)) throw new JevBrowserError(`invalid reusable flow name ${name}`);
|
|
101
|
+
validateFlowDefinition(definition, `flows.${name}`);
|
|
102
|
+
}
|
|
103
|
+
const ids = new Set();
|
|
104
|
+
for (const [index, criterion] of document.criteria.entries()) {
|
|
105
|
+
exactFields(criterion, ["id", "description", "flow"], ["initial_url", "expected_outcome", "params", "options"], `criteria[${index}]`);
|
|
106
|
+
if (typeof criterion.id !== "string" || !SUITE_NAME.test(criterion.id) || ids.has(criterion.id)) throw new JevBrowserError(`criteria[${index}].id must be unique and valid`);
|
|
107
|
+
ids.add(criterion.id);
|
|
108
|
+
if (typeof criterion.description !== "string" || !criterion.description.trim() || criterion.description.length > 2000) throw new JevBrowserError(`criteria[${index}].description is required and limited to 2000 characters`);
|
|
109
|
+
if (typeof criterion.flow !== "string" && !isRecord(criterion.flow)) throw new JevBrowserError(`criteria[${index}].flow must reference a reusable flow or define one inline`);
|
|
110
|
+
if (isRecord(criterion.flow)) validateFlowDefinition(criterion.flow, `criteria[${index}].flow`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function readTestSuite(settings, suiteName) {
|
|
115
|
+
if (typeof suiteName !== "string" || !SUITE_NAME.test(suiteName)) throw new JevBrowserError("suite_name must use letters, numbers, hyphen, or underscore");
|
|
116
|
+
const filePath = safeProjectPath(settings.browser.projectRoot, path.join(suiteDirectoryName, `${suiteName}.json`));
|
|
117
|
+
try {
|
|
118
|
+
const info = await lstat(filePath);
|
|
119
|
+
if (info.isSymbolicLink() || !info.isFile() || info.size > settings.browser.maxTestSuiteFileBytes) throw new JevBrowserError("suite file must be a regular non-symlink JSON file within the configured size limit");
|
|
120
|
+
if (await realpath(filePath) !== path.resolve(filePath)) throw new JevBrowserError("suite file must not use symlinks");
|
|
121
|
+
const document = JSON.parse(await readFile(filePath, "utf8"));
|
|
122
|
+
validateSuiteDocument(document, suiteName, settings);
|
|
123
|
+
return document;
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (error instanceof JevBrowserError) throw error;
|
|
126
|
+
if (error?.code === "ENOENT") throw new JevBrowserError(`test suite ${suiteName} was not found at ${path.join(suiteDirectoryName, `${suiteName}.json`)}`);
|
|
127
|
+
throw new JevBrowserError("test suite file is unreadable or invalid JSON");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function resolveFlowDefinition(document, source, defaults, overrides = {}) {
|
|
132
|
+
const base = typeof source === "string" ? document.flows?.[source] : source;
|
|
133
|
+
if (!base) throw new JevBrowserError(`unknown reusable flow ${JSON.stringify(source)}`);
|
|
134
|
+
const combined = { ...base, ...overrides };
|
|
135
|
+
const mergedParams = { ...(document.params || {}), ...(base.params || {}), ...(overrides.params || {}) };
|
|
136
|
+
const profile = overrides.options?.environment_profile || base.options?.environment_profile || defaults.environmentProfile;
|
|
137
|
+
const options = { ...(base.options || {}), ...(overrides.options || {}) };
|
|
138
|
+
if (profile && !options.environment_profile) options.environment_profile = profile;
|
|
139
|
+
return {
|
|
140
|
+
flow: combined.flow,
|
|
141
|
+
initialUrl: combined.initial_url,
|
|
142
|
+
expectedOutcome: combined.expected_outcome,
|
|
143
|
+
candidatePlans: combined.candidate_plans,
|
|
144
|
+
params: mergedParams,
|
|
145
|
+
options,
|
|
146
|
+
settings: defaults.settings,
|
|
147
|
+
browserPool: defaults.browserPool,
|
|
148
|
+
fetchImpl: defaults.fetchImpl,
|
|
149
|
+
serverVersion: defaults.serverVersion,
|
|
150
|
+
mutationAuthorizationStore: defaults.mutationAuthorizationStore,
|
|
151
|
+
integrationSession: defaults.integrationSession,
|
|
152
|
+
retainUploadStaging: true,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function referencedDefinition(document, reference) {
|
|
157
|
+
if (typeof reference === "string") return document.flows?.[reference];
|
|
158
|
+
return reference;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function collectReportSecrets(settings, suite, params) {
|
|
162
|
+
const references = new Set([settings.jev.credentialEnv]);
|
|
163
|
+
const add = (value) => { if (typeof value === "string" && value.length >= 4) references.add(value); };
|
|
164
|
+
const profiles = settings.browser.environmentProfiles.profiles;
|
|
165
|
+
for (const profile of Object.values(profiles)) {
|
|
166
|
+
add(profile.database?.connectionEnv);
|
|
167
|
+
add(profile.database?.connectEnv);
|
|
168
|
+
add(profile.database?.userEnv);
|
|
169
|
+
add(profile.database?.passwordEnv);
|
|
170
|
+
for (const remote of Object.values(profile.remotes)) {
|
|
171
|
+
add(remote.usernameEnv);
|
|
172
|
+
add(remote.passwordEnv);
|
|
173
|
+
add(remote.hostFingerprintEnv);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const secrets = new Set([...references].map((name) => settings.env[name]).filter((value) => typeof value === "string" && value.length >= 4));
|
|
177
|
+
const inspect = (value, sensitiveParent = false) => {
|
|
178
|
+
if (typeof value === "string") {
|
|
179
|
+
if (sensitiveParent && value.length >= 4) secrets.add(value);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (Array.isArray(value)) { for (const item of value) inspect(item, sensitiveParent); return; }
|
|
183
|
+
if (!isRecord(value)) return;
|
|
184
|
+
if (value.action === "type" && value.sensitive !== false) addSensitiveText(value.text);
|
|
185
|
+
if (typeof value.text_env === "string") {
|
|
186
|
+
const textSecret = settings.env[value.text_env];
|
|
187
|
+
if (typeof textSecret === "string" && textSecret.length >= 4) secrets.add(textSecret);
|
|
188
|
+
}
|
|
189
|
+
for (const [key, item] of Object.entries(value)) {
|
|
190
|
+
inspect(item, sensitiveParent || /pass(?:word)?|secret|token|authorization|cookie|credential|api[_-]?key|private[_-]?key/i.test(key));
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
const addSensitiveText = (value) => { if (typeof value === "string" && value.length >= 4) secrets.add(value); };
|
|
194
|
+
inspect(suite);
|
|
195
|
+
inspect(params);
|
|
196
|
+
return [...secrets].sort((left, right) => right.length - left.length);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function redactReportText(value, secrets) {
|
|
200
|
+
let result = String(value);
|
|
201
|
+
for (const secret of secrets) if (secret) result = result.replaceAll(secret, "[redacted]");
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function boundedReport(text, maxBytes) {
|
|
206
|
+
const bytes = Buffer.from(text, "utf8");
|
|
207
|
+
if (bytes.length <= maxBytes) return { text, truncated: false };
|
|
208
|
+
const marker = "\n\n[Report truncated at the configured byte limit.]\n";
|
|
209
|
+
const available = Math.max(0, maxBytes - Buffer.byteLength(marker));
|
|
210
|
+
const prefix = bytes.subarray(0, available).toString("utf8").replace(/\uFFFD$/, "");
|
|
211
|
+
return { text: `${prefix}${marker}`, truncated: true };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function reportNetworkUrl(value, secrets = []) {
|
|
215
|
+
try {
|
|
216
|
+
const url = new URL(value);
|
|
217
|
+
url.username = "";
|
|
218
|
+
url.password = "";
|
|
219
|
+
url.search = "";
|
|
220
|
+
url.hash = "";
|
|
221
|
+
url.pathname = url.pathname.split("/").map((part) => /^\d{4,}$/.test(part) || /^[0-9a-f]{8}-[0-9a-f-]{27,}$/i.test(part) || /^[A-Za-z0-9_-]{32,}$/.test(part) ? "[redacted]" : part).join("/");
|
|
222
|
+
return redactReportText(url.toString(), secrets);
|
|
223
|
+
} catch {
|
|
224
|
+
return "[redacted]";
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function renderReport(suite, results, cleanup, startedAt, finishedAt, secrets) {
|
|
229
|
+
const lines = [
|
|
230
|
+
`# Jev Browser test suite: ${suite.id}`,
|
|
231
|
+
"",
|
|
232
|
+
redactReportText(suite.description || "Automated suite report.", secrets),
|
|
233
|
+
"",
|
|
234
|
+
`- Server: ${packageManifest.version}`,
|
|
235
|
+
`- Started: ${startedAt}`,
|
|
236
|
+
`- Finished: ${finishedAt}`,
|
|
237
|
+
`- Result: ${results.every((item) => item.status === "passed") && cleanup.status === "passed" ? "PASSED" : "FAILED"}`,
|
|
238
|
+
"",
|
|
239
|
+
"## Acceptance criteria",
|
|
240
|
+
"",
|
|
241
|
+
];
|
|
242
|
+
for (const item of results) {
|
|
243
|
+
lines.push(`### ${item.id}: ${item.status.toUpperCase()}`, "", redactReportText(item.description, secrets), "");
|
|
244
|
+
if (item.reason) lines.push(`Failure: ${redactReportText(item.reason.replace(/\s+/g, " ").slice(0, 1000), secrets)}`, "");
|
|
245
|
+
for (const step of item.steps || []) {
|
|
246
|
+
const details = [
|
|
247
|
+
step.action && `action=${step.action}`,
|
|
248
|
+
step.duration_ms !== undefined && `duration_ms=${step.duration_ms}`,
|
|
249
|
+
step.status && `status=${step.status}`,
|
|
250
|
+
step.method && `method=${step.method}`,
|
|
251
|
+
step.url && `url=${reportNetworkUrl(step.url, secrets)}`,
|
|
252
|
+
step.response_status !== undefined && `http=${step.response_status}`,
|
|
253
|
+
step.engine && `database=${step.engine}`,
|
|
254
|
+
step.row_count !== undefined && `rows=${step.row_count}`,
|
|
255
|
+
step.scanned_bytes !== undefined && `scanned_bytes=${step.scanned_bytes}`,
|
|
256
|
+
step.truncated !== undefined && `truncated=${step.truncated}`,
|
|
257
|
+
step.sha256_matched !== undefined && `sha256_matched=${step.sha256_matched}`,
|
|
258
|
+
step.sha256 && `sha256=${step.sha256}`,
|
|
259
|
+
step.size_bytes !== undefined && `size_bytes=${step.size_bytes}`,
|
|
260
|
+
step.mime_type && `mime=${step.mime_type}`,
|
|
261
|
+
step.source && `source=${step.source}`,
|
|
262
|
+
step.server && `server=${step.server}`,
|
|
263
|
+
step.exists !== undefined && `exists=${step.exists}`,
|
|
264
|
+
step.recipient_matched !== undefined && `recipient_matched=${step.recipient_matched}`,
|
|
265
|
+
step.subject_matched !== undefined && `subject_matched=${step.subject_matched}`,
|
|
266
|
+
step.body_matched !== undefined && `body_matched=${step.body_matched}`,
|
|
267
|
+
step.received_at && `received_at=${step.received_at}`,
|
|
268
|
+
step.hook && `hook=${step.hook}`,
|
|
269
|
+
step.asserted !== undefined && `asserted=${step.asserted}`,
|
|
270
|
+
step.screenshot_path && `screenshot=${redactReportText(step.screenshot_path, secrets)}`,
|
|
271
|
+
].filter(Boolean).join(", ");
|
|
272
|
+
lines.push(`- Step ${step.step || "?"}: ${details || "completed"}`);
|
|
273
|
+
}
|
|
274
|
+
for (const itemNetwork of item.network_events || []) {
|
|
275
|
+
lines.push(`- Network: ${itemNetwork.method || "?"} ${reportNetworkUrl(itemNetwork.url || "", secrets)} → ${itemNetwork.status ?? "failed"}`);
|
|
276
|
+
}
|
|
277
|
+
if (!(item.steps || []).length) lines.push("- No browser steps executed.");
|
|
278
|
+
lines.push("");
|
|
279
|
+
}
|
|
280
|
+
lines.push("## Cleanup", "", `- ${cleanup.status}${cleanup.files_removed ? "; project-local temporary files removed" : ""}${cleanup.reason ? `: ${redactReportText(cleanup.reason, secrets)}` : ""}`, "");
|
|
281
|
+
return `${lines.join("\n")}\n`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function criterionEvidence(definition, result) {
|
|
285
|
+
const steps = result?.steps || result?.trace || [];
|
|
286
|
+
return {
|
|
287
|
+
id: definition.id,
|
|
288
|
+
description: definition.description,
|
|
289
|
+
status: result?.status === "passed" ? "passed" : result?.status === "skipped" ? "skipped" : "failed",
|
|
290
|
+
reason: result?.reason,
|
|
291
|
+
steps,
|
|
292
|
+
network_events: result?.network_events || result?.network_failures || [],
|
|
293
|
+
screenshots: steps.map((step) => step.screenshot_path).filter(Boolean),
|
|
294
|
+
result,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export async function executeTestSuite({ suiteName, params, environmentProfile, reportPath, settings, browserPool, executeFlow, fetchImpl, serverVersion, mutationAuthorizationStore }) {
|
|
299
|
+
const suite = await readTestSuite(settings, suiteName);
|
|
300
|
+
const destination = reportPath || path.join(reportDirectoryName, `${suite.id}-${Date.now()}.md`);
|
|
301
|
+
const reportTarget = safeProjectPath(settings.browser.projectRoot, destination);
|
|
302
|
+
await preflightReportTarget(settings.browser.projectRoot, reportTarget);
|
|
303
|
+
const definitions = [];
|
|
304
|
+
const defaults = { settings, browserPool, fetchImpl, serverVersion, mutationAuthorizationStore, environmentProfile: environmentProfile || suite.environment_profile };
|
|
305
|
+
const addDefinition = (id, description, source, overrides) => {
|
|
306
|
+
const resolved = resolveFlowDefinition(suite, source, defaults, { ...overrides, params: { ...(suite.params || {}), ...(params || {}), ...(overrides?.params || {}) } });
|
|
307
|
+
validateBrowserFlowRequest(resolved);
|
|
308
|
+
definitions.push({ id, description, resolved });
|
|
309
|
+
};
|
|
310
|
+
let setupDefinition;
|
|
311
|
+
let teardownDefinition;
|
|
312
|
+
if (suite.setup !== undefined) {
|
|
313
|
+
const source = referencedDefinition(suite, suite.setup);
|
|
314
|
+
if (!source) throw new JevBrowserError(`unknown setup flow ${JSON.stringify(suite.setup)}`);
|
|
315
|
+
if (typeof suite.setup !== "string") validateFlowDefinition(source, "setup");
|
|
316
|
+
setupDefinition = resolveFlowDefinition(suite, source, defaults, { params: { ...(suite.params || {}), ...(params || {}) } });
|
|
317
|
+
validateBrowserFlowRequest(setupDefinition);
|
|
318
|
+
}
|
|
319
|
+
for (const criterion of suite.criteria) {
|
|
320
|
+
const source = criterion.flow;
|
|
321
|
+
const criterionOverrides = Object.fromEntries(["initial_url", "expected_outcome", "params", "options"].filter((key) => Object.hasOwn(criterion, key)).map((key) => [key, criterion[key]]));
|
|
322
|
+
const overrides = typeof criterion.flow === "string"
|
|
323
|
+
? criterionOverrides
|
|
324
|
+
: { ...criterion.flow, ...criterionOverrides };
|
|
325
|
+
addDefinition(criterion.id, criterion.description, source, overrides);
|
|
326
|
+
}
|
|
327
|
+
if (suite.teardown !== undefined) {
|
|
328
|
+
const source = referencedDefinition(suite, suite.teardown);
|
|
329
|
+
if (!source) throw new JevBrowserError(`unknown teardown flow ${JSON.stringify(suite.teardown)}`);
|
|
330
|
+
if (typeof suite.teardown !== "string") validateFlowDefinition(source, "teardown");
|
|
331
|
+
teardownDefinition = resolveFlowDefinition(suite, source, defaults, { params: { ...(suite.params || {}), ...(params || {}) } });
|
|
332
|
+
validateBrowserFlowRequest(teardownDefinition);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const profileIds = new Set([
|
|
336
|
+
environmentProfile,
|
|
337
|
+
suite.environment_profile,
|
|
338
|
+
setupDefinition?.options.environment_profile,
|
|
339
|
+
...definitions.map((item) => item.resolved.options.environment_profile),
|
|
340
|
+
teardownDefinition?.options.environment_profile,
|
|
341
|
+
].filter(Boolean));
|
|
342
|
+
if (profileIds.size > 1) throw new JevBrowserError("one test suite must use a single environment profile");
|
|
343
|
+
const profileId = [...profileIds][0];
|
|
344
|
+
const session = createIntegrationSession(settings, profileId);
|
|
345
|
+
const reportSecrets = collectReportSecrets(settings, suite, params);
|
|
346
|
+
const startedAt = new Date().toISOString();
|
|
347
|
+
const results = [];
|
|
348
|
+
let suiteFailed = false;
|
|
349
|
+
let setupPassed = !setupDefinition;
|
|
350
|
+
let cleanup = { status: "not_required" };
|
|
351
|
+
let reportError;
|
|
352
|
+
let reportTruncated = false;
|
|
353
|
+
try {
|
|
354
|
+
await session.markFlowStart();
|
|
355
|
+
if (setupDefinition) {
|
|
356
|
+
const result = await executeFlow({ ...setupDefinition, integrationSession: session, retainUploadStaging: true });
|
|
357
|
+
setupPassed = result.status === "passed";
|
|
358
|
+
if (!setupPassed) suiteFailed = true;
|
|
359
|
+
results.push({ id: "setup", description: "Suite setup", ...criterionEvidence({ id: "setup", description: "Suite setup" }, result) });
|
|
360
|
+
}
|
|
361
|
+
for (const item of definitions) {
|
|
362
|
+
if (!setupPassed) {
|
|
363
|
+
results.push({ id: item.id, description: item.description, status: "skipped", reason: "setup did not pass", steps: [] });
|
|
364
|
+
suiteFailed = true;
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
try {
|
|
368
|
+
const result = await executeFlow({ ...item.resolved, integrationSession: session, retainUploadStaging: true });
|
|
369
|
+
const evidence = criterionEvidence(item, result);
|
|
370
|
+
results.push(evidence);
|
|
371
|
+
if (evidence.status !== "passed") suiteFailed = true;
|
|
372
|
+
} catch (error) {
|
|
373
|
+
results.push({ id: item.id, description: item.description, status: "failed", reason: error?.message || "criterion flow failed", steps: [] });
|
|
374
|
+
suiteFailed = true;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
} catch (error) {
|
|
378
|
+
suiteFailed = true;
|
|
379
|
+
results.push({ id: "setup", description: "Suite setup", status: "failed", reason: error?.message || "suite setup failed", steps: [] });
|
|
380
|
+
} finally {
|
|
381
|
+
if (teardownDefinition) {
|
|
382
|
+
try {
|
|
383
|
+
const result = await executeFlow({ ...teardownDefinition, integrationSession: session, retainUploadStaging: true });
|
|
384
|
+
const teardown = criterionEvidence({ id: "teardown", description: "Suite teardown" }, result);
|
|
385
|
+
results.push(teardown);
|
|
386
|
+
cleanup = { status: result.status === "passed" ? "passed" : "failed", ...(result.reason ? { reason: result.reason } : {}) };
|
|
387
|
+
if (cleanup.status !== "passed") suiteFailed = true;
|
|
388
|
+
} catch (error) {
|
|
389
|
+
cleanup = { status: "failed", reason: error?.message || "suite teardown failed" };
|
|
390
|
+
results.push({ id: "teardown", description: "Suite teardown", status: "failed", reason: cleanup.reason, steps: [] });
|
|
391
|
+
suiteFailed = true;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
await session.close();
|
|
395
|
+
try {
|
|
396
|
+
if (settings.browser.managedUploadStaging) {
|
|
397
|
+
const { cleanupUploadStagingDirectory } = await import("./upload-staging.mjs");
|
|
398
|
+
await cleanupUploadStagingDirectory(settings);
|
|
399
|
+
cleanup.status = cleanup.status === "failed" ? cleanup.status : "passed";
|
|
400
|
+
cleanup.files_removed = true;
|
|
401
|
+
}
|
|
402
|
+
} catch {
|
|
403
|
+
cleanup.status = "failed";
|
|
404
|
+
cleanup.reason = "temporary fixture cleanup failed";
|
|
405
|
+
suiteFailed = true;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const finishedAt = new Date().toISOString();
|
|
410
|
+
try {
|
|
411
|
+
await ensureSafeParent(settings.browser.projectRoot, reportTarget);
|
|
412
|
+
try {
|
|
413
|
+
const existing = await lstat(reportTarget);
|
|
414
|
+
if (existing.isSymbolicLink() || !existing.isFile()) throw new JevBrowserError("report destination must be a regular non-symlink file");
|
|
415
|
+
} catch (error) { if (error?.code !== "ENOENT") throw error; }
|
|
416
|
+
const report = boundedReport(renderReport(suite, results, cleanup, startedAt, finishedAt, reportSecrets), settings.browser.maxTestSuiteReportBytes);
|
|
417
|
+
await writeFile(reportTarget, report.text, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
418
|
+
reportTruncated = report.truncated;
|
|
419
|
+
} catch (error) {
|
|
420
|
+
reportError = error instanceof Error ? error.message : "report could not be saved";
|
|
421
|
+
suiteFailed = true;
|
|
422
|
+
}
|
|
423
|
+
return {
|
|
424
|
+
status: suiteFailed ? "failed" : "passed",
|
|
425
|
+
suite_id: suite.id,
|
|
426
|
+
server_version: serverVersion || packageManifest.version,
|
|
427
|
+
environment_profile: profileId,
|
|
428
|
+
started_at: startedAt,
|
|
429
|
+
finished_at: finishedAt,
|
|
430
|
+
acceptance_criteria: results.map(({ result: _result, ...item }) => item),
|
|
431
|
+
cleanup,
|
|
432
|
+
...(reportTarget && !reportError ? { report_path: reportTarget } : {}),
|
|
433
|
+
...(reportTruncated ? { report_truncated: true } : {}),
|
|
434
|
+
...(reportError ? { report_error: reportError } : {}),
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export const testSuiteLocation = (name) => path.join(suiteDirectoryName, `${name}.json`);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { lstat, mkdir, readdir, realpath, rm, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { JevBrowserError } from "./config.mjs";
|
|
4
|
+
|
|
5
|
+
const stagingQueues = new Map();
|
|
6
|
+
|
|
7
|
+
function isWithin(parent, child) {
|
|
8
|
+
const relative = path.relative(parent, child);
|
|
9
|
+
return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function checkedStagingDirectory(settings, { create, missingIsAllowed = false }) {
|
|
13
|
+
const projectRoot = path.resolve(settings.browser.projectRoot);
|
|
14
|
+
const stagingDirectory = path.resolve(settings.browser.uploadStagingDirectory);
|
|
15
|
+
if (!settings.browser.managedUploadStaging || !isWithin(projectRoot, stagingDirectory)) {
|
|
16
|
+
throw new JevBrowserError("managed upload staging directory must stay inside the configured project");
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const projectInfo = await stat(projectRoot);
|
|
20
|
+
if (!projectInfo.isDirectory()) throw new Error("project root is not a directory");
|
|
21
|
+
const realProjectRoot = await realpath(projectRoot);
|
|
22
|
+
let current = realProjectRoot;
|
|
23
|
+
const relative = path.relative(projectRoot, stagingDirectory);
|
|
24
|
+
for (const part of relative.split(path.sep)) {
|
|
25
|
+
current = path.join(current, part);
|
|
26
|
+
try {
|
|
27
|
+
const info = await lstat(current);
|
|
28
|
+
if (info.isSymbolicLink() || !info.isDirectory()) throw new Error("staging path is not a real directory");
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (error?.code !== "ENOENT") throw error;
|
|
31
|
+
if (!create) {
|
|
32
|
+
if (missingIsAllowed) return undefined;
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
await mkdir(current, { mode: 0o700 });
|
|
37
|
+
} catch (createError) {
|
|
38
|
+
if (createError?.code !== "EEXIST") throw createError;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const resolved = await realpath(current);
|
|
42
|
+
if (!isWithin(realProjectRoot, resolved)) throw new Error("staging path resolves outside the project");
|
|
43
|
+
current = resolved;
|
|
44
|
+
}
|
|
45
|
+
return current;
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (missingIsAllowed && error?.code === "ENOENT") return undefined;
|
|
48
|
+
throw new JevBrowserError("managed upload staging directory is unavailable or resolves outside the project");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function prepareUploadStagingDirectory(settings) {
|
|
53
|
+
if (!settings.browser.managedUploadStaging) return undefined;
|
|
54
|
+
return checkedStagingDirectory(settings, { create: true });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function cleanupUploadStagingDirectory(settings) {
|
|
58
|
+
if (!settings.browser.managedUploadStaging) return false;
|
|
59
|
+
const stagingDirectory = await checkedStagingDirectory(settings, { create: false, missingIsAllowed: true });
|
|
60
|
+
if (!stagingDirectory) return false;
|
|
61
|
+
const entries = await readdir(stagingDirectory);
|
|
62
|
+
await Promise.all(entries.map((entry) => rm(path.join(stagingDirectory, entry), { recursive: true, force: true })));
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function withUploadStagingLock(settings, operation) {
|
|
67
|
+
if (!settings.browser.managedUploadStaging) return operation();
|
|
68
|
+
const key = path.resolve(settings.browser.uploadStagingDirectory);
|
|
69
|
+
const previous = stagingQueues.get(key) || Promise.resolve();
|
|
70
|
+
let release;
|
|
71
|
+
const current = new Promise((resolve) => { release = resolve; });
|
|
72
|
+
stagingQueues.set(key, current);
|
|
73
|
+
await previous;
|
|
74
|
+
try {
|
|
75
|
+
return await operation();
|
|
76
|
+
} finally {
|
|
77
|
+
release();
|
|
78
|
+
if (stagingQueues.get(key) === current) stagingQueues.delete(key);
|
|
79
|
+
}
|
|
80
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@diegosouzacdv/jev-browser-mcp",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "Portable MCP server for bounded Playwright screen flows selected by Jev",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -26,7 +26,13 @@
|
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@axe-core/playwright": "4.13.0",
|
|
28
28
|
"@modelcontextprotocol/server": "2.1.0",
|
|
29
|
+
"basic-ftp": "6.2.1",
|
|
30
|
+
"mailparser": "3.9.33",
|
|
31
|
+
"oracledb": "7.0.1",
|
|
32
|
+
"pg": "8.23.1",
|
|
29
33
|
"playwright": "1.63.0",
|
|
34
|
+
"smtp-server": "3.19.16",
|
|
35
|
+
"ssh2-sftp-client": "12.1.1",
|
|
30
36
|
"zod": "4.6.5"
|
|
31
37
|
}
|
|
32
38
|
}
|