@diegosouzacdv/jev-browser-mcp 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +359 -71
- package/config/ui-testing.json +31 -10
- package/docs/jev-browser-mcp.md +359 -71
- package/mcp_servers/jev-browser-npm/bin/jev-browser-mcp.cjs +4 -1
- package/mcp_servers/jev-browser-npm/src/browser-flow-runs.mjs +57 -0
- package/mcp_servers/jev-browser-npm/src/config.mjs +136 -37
- package/mcp_servers/jev-browser-npm/src/environment-profiles.mjs +241 -0
- package/mcp_servers/jev-browser-npm/src/flow.mjs +1159 -292
- package/mcp_servers/jev-browser-npm/src/server.mjs +281 -126
- package/mcp_servers/jev-browser-npm/src/test-integrations.mjs +655 -0
- package/mcp_servers/jev-browser-npm/src/test-suites.mjs +438 -0
- package/mcp_servers/jev-browser-npm/src/upload-staging.mjs +80 -0
- package/package.json +7 -1
|
@@ -1,79 +1,43 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { readlink } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { McpServer } from "@modelcontextprotocol/server";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
6
6
|
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
7
7
|
import { chromium } from "playwright";
|
|
8
8
|
import * as z from "zod/v4";
|
|
9
9
|
import packageManifest from "../../../package.json" with { type: "json" };
|
|
10
10
|
import { loadSettings, JevBrowserError } from "./config.mjs";
|
|
11
|
-
import { chooseNextAction, createMutationAuthorizationStore, describeBrowserActions, executeBrowserFlow } from "./flow.mjs";
|
|
11
|
+
import { chooseNextAction, collectFlowContractIssues, createMutationAuthorizationStore, describeBrowserActions, executeBrowserFlow } from "./flow.mjs";
|
|
12
|
+
import { createBrowserFlowRunStore } from "./browser-flow-runs.mjs";
|
|
13
|
+
import { executeTestSuite } from "./test-suites.mjs";
|
|
14
|
+
import { cleanupUploadStagingDirectory, prepareUploadStagingDirectory } from "./upload-staging.mjs";
|
|
12
15
|
|
|
13
16
|
const browserContract = describeBrowserActions();
|
|
14
|
-
const actionNames = Object.keys(browserContract.actions);
|
|
15
17
|
const jsonObject = z.record(z.string(), z.unknown());
|
|
16
|
-
function browserStepFieldSchema(field) {
|
|
17
|
-
if (["mutating", "screenshot", "expect_download", "expect_popup", "blur", "sensitive", "full_page"].includes(field)) return z.boolean();
|
|
18
|
-
if (["timeout_ms", "timeout_seconds"].includes(field)) return z.number().positive();
|
|
19
|
-
if (["index", "context_index", "ms", "duration_ms", "status", "close_code"].includes(field)) return z.number().int();
|
|
20
|
-
if (field === "file_paths") return z.union([z.string(), z.array(z.string())]);
|
|
21
|
-
if (["near", "within", "if_visible", "json"].includes(field)) return jsonObject;
|
|
22
|
-
if (field === "name_match") return z.enum(["exact", "contains", "regex"]);
|
|
23
|
-
if (field === "condition") return z.enum(["network_idle", "angular_idle", "url", "hidden"]);
|
|
24
|
-
if (field === "standard") return z.enum(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa", "section508"]);
|
|
25
|
-
return z.string();
|
|
26
|
-
}
|
|
27
|
-
function browserStepVariant(action, contract, aliases = []) {
|
|
28
|
-
const required = new Set(contract.required);
|
|
29
|
-
const optional = new Set([...contract.optional, "timeout_ms", "mutating", "screenshot", ...aliases]);
|
|
30
|
-
if (action === "wait") { required.delete("ms"); optional.add("ms"); }
|
|
31
|
-
if (action === "wait_for_text") { required.delete("text"); optional.add("text"); }
|
|
32
|
-
if (action === "assert_text" || action === "assert_value") { required.delete("expected"); optional.add("expected"); }
|
|
33
|
-
if (action === "upload_file") { required.delete("file_paths"); optional.add("file_paths"); }
|
|
34
|
-
if (action === "wait") optional.add("duration_ms");
|
|
35
|
-
if (action === "wait_for_text" || action === "assert_text" || action === "assert_value") optional.add("value");
|
|
36
|
-
if (action === "upload_file") optional.add("file_path");
|
|
37
|
-
if (action === "confirm_modal") {
|
|
38
|
-
required.delete("button");
|
|
39
|
-
optional.add("button");
|
|
40
|
-
optional.add("trigger");
|
|
41
|
-
optional.add("confirm");
|
|
42
|
-
}
|
|
43
|
-
const shape = { action: z.literal(action) };
|
|
44
|
-
for (const key of new Set([...required, ...optional])) {
|
|
45
|
-
const field = browserStepFieldSchema(key);
|
|
46
|
-
shape[key] = required.has(key) ? field : field.optional();
|
|
47
|
-
}
|
|
48
|
-
return z.object(shape).strict();
|
|
49
|
-
}
|
|
50
|
-
const browserStepVariants = Object.entries(browserContract.actions).map(([action, contract]) => browserStepVariant(action, contract));
|
|
51
|
-
browserStepVariants.push(
|
|
52
|
-
browserStepVariant("fill", browserContract.actions.type, ["value"]),
|
|
53
|
-
browserStepVariant("press_key", browserContract.actions.press),
|
|
54
|
-
);
|
|
55
|
-
const browserStepSchema = z.discriminatedUnion("action", browserStepVariants);
|
|
56
|
-
const candidatePlanSchema = z.object({
|
|
57
|
-
description: z.string(),
|
|
58
|
-
steps: z.array(browserStepSchema).min(1),
|
|
59
|
-
comment: z.string().optional(),
|
|
60
|
-
when: z.object({ visible: z.string() }).strict().optional(),
|
|
61
|
-
}).strict();
|
|
62
18
|
const browserOptionsShape = Object.fromEntries(browserContract.options.map((key) => {
|
|
63
19
|
const value = key === "return_snapshot" ? z.enum(["diff", "full", "none"])
|
|
20
|
+
: key === "capture_network" ? z.enum(["none", "errors", "all"])
|
|
64
21
|
: key === "reuse_page_match" ? z.enum(["url", "path"])
|
|
65
22
|
: key === "color_scheme" ? z.enum(["light", "dark", "no-preference"])
|
|
66
23
|
: key === "permissions" ? z.array(z.enum(["microphone", "geolocation"]))
|
|
67
24
|
: key === "console_levels" ? z.array(z.enum(["log", "info", "debug", "warn", "error"]))
|
|
68
25
|
: key === "ready" ? z.union([z.string(), z.record(z.string(), z.unknown()), z.array(z.union([z.string(), z.record(z.string(), z.unknown())]))])
|
|
69
26
|
: ["allow_mutations", "block_trackers", "capture_console_errors", "capture_network_error_bodies", "capture_network_errors", "auto_angular_idle", "block_fonts", "continue_from_current_page", "dry_run", "fast_path", "local_only", "ready_network_idle", "reuse_page", "screenshot_on_failure", "screenshot_on_success", "record_video", "mobile", "snapshot_include_hidden", "include_unnamed_controls", "stop_on_expected", "trace_on_failure", "trace_on_success", "fresh_context", "clear_storage"].includes(key) ? z.boolean()
|
|
70
|
-
:
|
|
27
|
+
: key === "max_flow_steps" ? z.number().int().positive()
|
|
28
|
+
: ["ready_stable_ms", "ready_timeout_seconds", "step_timeout_seconds"].includes(key) ? z.number().positive()
|
|
71
29
|
: ["busy_selectors", "login_text", "login_url_contains"].includes(key) ? z.array(z.string())
|
|
72
30
|
: ["viewport", "geolocation", "wait_for_http", "fake_media"].includes(key) ? z.union([z.string(), jsonObject])
|
|
73
31
|
: z.string();
|
|
74
32
|
return [key, value.optional()];
|
|
75
33
|
}));
|
|
76
34
|
const browserOptionsSchema = z.object(browserOptionsShape).passthrough();
|
|
35
|
+
const candidatePlanSchema = z.object({
|
|
36
|
+
description: z.string().optional(),
|
|
37
|
+
steps: z.array(z.record(z.string(), z.unknown())).optional(),
|
|
38
|
+
comment: z.string().optional(),
|
|
39
|
+
when: z.unknown().optional(),
|
|
40
|
+
}).strict();
|
|
77
41
|
const expectedOutcomeSchema = z.union([
|
|
78
42
|
z.string(),
|
|
79
43
|
z.object({
|
|
@@ -85,7 +49,7 @@ const expectedOutcomeSchema = z.union([
|
|
|
85
49
|
}).strict().refine((value) => Boolean(value.text || value.request), "expected_outcome needs text or request"),
|
|
86
50
|
]);
|
|
87
51
|
|
|
88
|
-
const browserPools = new
|
|
52
|
+
const browserPools = new Map();
|
|
89
53
|
|
|
90
54
|
class BrowserPool {
|
|
91
55
|
#settings;
|
|
@@ -97,8 +61,9 @@ class BrowserPool {
|
|
|
97
61
|
#contextBrowsers = new Map();
|
|
98
62
|
#page;
|
|
99
63
|
#startedAt;
|
|
100
|
-
#everStarted = false;
|
|
101
|
-
#navigationError;
|
|
64
|
+
#everStarted = false;
|
|
65
|
+
#navigationError;
|
|
66
|
+
#unrestoredPage = false;
|
|
102
67
|
#flowQueue = Promise.resolve();
|
|
103
68
|
#configurationKey;
|
|
104
69
|
#lastConfiguration = {};
|
|
@@ -107,12 +72,13 @@ class BrowserPool {
|
|
|
107
72
|
this.#settings = settings;
|
|
108
73
|
}
|
|
109
74
|
|
|
110
|
-
getNavigationError() {
|
|
111
|
-
return this.#navigationError;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
setNavigationError(error) {
|
|
115
|
-
this.#navigationError = error || undefined;
|
|
75
|
+
getNavigationError() {
|
|
76
|
+
return this.#unrestoredPage ? "BROWSER_PAGE_NOT_RESTORED" : this.#navigationError;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
setNavigationError(error) {
|
|
80
|
+
this.#navigationError = error || undefined;
|
|
81
|
+
this.#unrestoredPage = error === "BROWSER_PAGE_NOT_RESTORED";
|
|
116
82
|
}
|
|
117
83
|
|
|
118
84
|
async runFlow(work) {
|
|
@@ -125,9 +91,10 @@ class BrowserPool {
|
|
|
125
91
|
} finally {
|
|
126
92
|
release();
|
|
127
93
|
}
|
|
128
|
-
}
|
|
129
|
-
|
|
94
|
+
}
|
|
95
|
+
|
|
130
96
|
async getPage(configuration) {
|
|
97
|
+
const hadStartedBeforeRequest = this.#everStarted;
|
|
131
98
|
if (configuration === undefined) configuration = this.#lastConfiguration;
|
|
132
99
|
else this.#lastConfiguration = configuration;
|
|
133
100
|
const configurationKey = JSON.stringify(configuration);
|
|
@@ -156,7 +123,12 @@ class BrowserPool {
|
|
|
156
123
|
throw error;
|
|
157
124
|
}
|
|
158
125
|
}
|
|
159
|
-
return {
|
|
126
|
+
return {
|
|
127
|
+
page: this.#page,
|
|
128
|
+
context: this.#context,
|
|
129
|
+
sessionMs: isOpening ? performance.now() - this.#startedAt : 0,
|
|
130
|
+
recovered: isOpening && hadStartedBeforeRequest,
|
|
131
|
+
};
|
|
160
132
|
}
|
|
161
133
|
|
|
162
134
|
#invalidate(reason) {
|
|
@@ -290,17 +262,47 @@ class BrowserPool {
|
|
|
290
262
|
this.#contextPromise = undefined;
|
|
291
263
|
this.#navigationError = undefined;
|
|
292
264
|
this.#configurationKey = undefined;
|
|
265
|
+
this.#unrestoredPage = false;
|
|
293
266
|
}
|
|
294
267
|
}
|
|
295
268
|
|
|
296
|
-
function
|
|
297
|
-
let message = error instanceof JevBrowserError ? error.message : error?.message || `Jev browser flow failed (${error?.name || "Error"})`;
|
|
298
|
-
const credential = settings.env?.[settings.jev.credentialEnv];
|
|
299
|
-
if (credential) message = message.split(credential).join("[redacted]");
|
|
300
|
-
message = message.replace(/\bBearer\s+\S+/gi, "Bearer [redacted]")
|
|
301
|
-
.replace(/([?&](?:token|api[_-]?key|password|secret)=)[^&\s]+/gi, "$1[redacted]");
|
|
302
|
-
return
|
|
303
|
-
}
|
|
269
|
+
function safeErrorMessage(error, settings) {
|
|
270
|
+
let message = error instanceof JevBrowserError ? error.message : error?.message || `Jev browser flow failed (${error?.name || "Error"})`;
|
|
271
|
+
const credential = settings.env?.[settings.jev.credentialEnv];
|
|
272
|
+
if (credential) message = message.split(credential).join("[redacted]");
|
|
273
|
+
message = message.replace(/\bBearer\s+\S+/gi, "Bearer [redacted]")
|
|
274
|
+
.replace(/([?&](?:token|api[_-]?key|password|secret)=)[^&\s]+/gi, "$1[redacted]");
|
|
275
|
+
return message;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function toolError(error, settings) {
|
|
279
|
+
const message = safeErrorMessage(error, settings);
|
|
280
|
+
return { isError: true, content: [{ type: "text", text: `[jev-browser ${packageManifest.version}] ${message}` }] };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function isBrowserDisconnectError(error) {
|
|
284
|
+
return /(?:target page, context or browser has been closed|browser has been closed|browser disconnected|context has been closed|connection closed|connection is closed)/i
|
|
285
|
+
.test(String(error?.message || error?.name || error || ""));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function isBrowserStartupError(error) {
|
|
289
|
+
return /(?:could not start the configured .* browser|browser profile is in use by PID \d+)/i
|
|
290
|
+
.test(String(error?.message || error?.name || error || ""));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function browserEnvironmentError(reason, navigationError, extra = {}) {
|
|
294
|
+
return {
|
|
295
|
+
server_version: packageManifest.version,
|
|
296
|
+
status: "environment_error",
|
|
297
|
+
navigation_error: navigationError,
|
|
298
|
+
reason,
|
|
299
|
+
acceptance_criteria_met: false,
|
|
300
|
+
expected_outcome_visible: false,
|
|
301
|
+
steps_executed: 0,
|
|
302
|
+
steps: [],
|
|
303
|
+
...extra,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
304
306
|
|
|
305
307
|
export function fitToolResponse(value, maxBytes) {
|
|
306
308
|
const clone = structuredClone(value && typeof value === "object" ? value : { result: value });
|
|
@@ -389,46 +391,66 @@ async function activeProfileOwnerPid(profileDir) {
|
|
|
389
391
|
}
|
|
390
392
|
}
|
|
391
393
|
|
|
392
|
-
export function createJevBrowserServer({ env = process.env, configPath, fetchImpl = fetch } = {}) {
|
|
393
|
-
const settings = loadSettings({ env, ...(configPath ? { configPath } : {}) });
|
|
394
|
-
const browserPool = new BrowserPool(settings);
|
|
395
|
-
const mutationAuthorizationStore = createMutationAuthorizationStore();
|
|
396
|
-
|
|
394
|
+
export function createJevBrowserServer({ env = process.env, configPath, fetchImpl = fetch } = {}) {
|
|
395
|
+
const settings = loadSettings({ env, ...(configPath ? { configPath } : {}) });
|
|
396
|
+
const browserPool = new BrowserPool(settings);
|
|
397
|
+
const mutationAuthorizationStore = createMutationAuthorizationStore();
|
|
398
|
+
const flowRunStore = createBrowserFlowRunStore({
|
|
399
|
+
maxEntries: settings.browser.maxBackgroundFlowRuns,
|
|
400
|
+
retentionMs: settings.browser.backgroundFlowResultTtlMs,
|
|
401
|
+
});
|
|
402
|
+
browserPools.set(browserPool, settings);
|
|
397
403
|
const server = new McpServer({
|
|
398
404
|
name: "jev-browser",
|
|
399
405
|
version: packageManifest.version,
|
|
400
406
|
description: "Bounded Playwright screen flows selected by Jev through the OpenRouter Decisions API.",
|
|
401
407
|
});
|
|
402
408
|
const closeServer = server.close.bind(server);
|
|
403
|
-
server.close = async () => {
|
|
404
|
-
try {
|
|
405
|
-
await closeServer();
|
|
406
|
-
} finally {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
409
|
+
server.close = async () => {
|
|
410
|
+
try {
|
|
411
|
+
await closeServer();
|
|
412
|
+
} finally {
|
|
413
|
+
try {
|
|
414
|
+
await browserPool.close();
|
|
415
|
+
} finally {
|
|
416
|
+
browserPools.delete(browserPool);
|
|
417
|
+
await cleanupUploadStagingDirectory(settings);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
};
|
|
411
421
|
|
|
412
422
|
server.registerTool(
|
|
413
423
|
"browser_health",
|
|
414
424
|
{
|
|
415
425
|
description: "Check the managed browser connection; set restart=true to close and restart only the browser session owned by this MCP process.",
|
|
416
426
|
inputSchema: { restart: z.boolean().optional() },
|
|
417
|
-
},
|
|
427
|
+
},
|
|
418
428
|
async ({ restart = false } = {}) => {
|
|
419
429
|
const health = restart ? await browserPool.restart() : await browserPool.health();
|
|
430
|
+
const uploadStagingDirectory = settings.browser.managedUploadStaging
|
|
431
|
+
? await prepareUploadStagingDirectory(settings)
|
|
432
|
+
: undefined;
|
|
420
433
|
return toolText({
|
|
421
434
|
...health,
|
|
422
435
|
server_version: packageManifest.version,
|
|
423
436
|
browser_mode: settings.browser.mode,
|
|
424
437
|
session_id: settings.browser.sessionId,
|
|
438
|
+
...(uploadStagingDirectory ? { upload_staging_directory: uploadStagingDirectory } : {}),
|
|
425
439
|
capabilities: {
|
|
426
440
|
actions: Object.keys(describeBrowserActions().actions),
|
|
427
441
|
options: describeBrowserActions().options,
|
|
442
|
+
limits: describeBrowserActions(settings).limits,
|
|
443
|
+
environment_profiles: Object.keys(settings.browser.environmentProfiles.profiles),
|
|
444
|
+
profile_environments: Object.fromEntries(Object.entries(settings.browser.environmentProfiles.profiles).map(([name, profile]) => [name, profile.environment])),
|
|
445
|
+
test_suite_directory: ".jev-browser/suites",
|
|
446
|
+
suite_report_format: "Markdown",
|
|
447
|
+
database_engines: ["postgresql", "oracle"],
|
|
448
|
+
remote_file_protocols: ["ftp", "ftps", "sftp"],
|
|
428
449
|
media_permissions: ["microphone", "geolocation"],
|
|
429
450
|
fake_media: "WAV audio input",
|
|
430
451
|
storage_state: "named account snapshots",
|
|
431
452
|
contexts: ["current", "new isolated context"],
|
|
453
|
+
file_upload_staging: settings.browser.managedUploadStaging ? "private project-local fixtures and uploads; managed files are cleaned after flows and suites" : "managed project-local staging unavailable",
|
|
432
454
|
},
|
|
433
455
|
}, settings.browser.maxToolResponseBytes);
|
|
434
456
|
},
|
|
@@ -440,7 +462,7 @@ export function createJevBrowserServer({ env = process.env, configPath, fetchImp
|
|
|
440
462
|
description: "Return the live JSON contract for every supported Jev Browser MCP action, option, alias, timeout and placeholder rule.",
|
|
441
463
|
inputSchema: {},
|
|
442
464
|
},
|
|
443
|
-
async () => toolText({ server_version: packageManifest.version, ...describeBrowserActions() }, settings.browser.maxToolResponseBytes),
|
|
465
|
+
async () => toolText({ server_version: packageManifest.version, ...describeBrowserActions(settings) }, settings.browser.maxToolResponseBytes),
|
|
444
466
|
);
|
|
445
467
|
|
|
446
468
|
server.registerTool(
|
|
@@ -470,53 +492,186 @@ export function createJevBrowserServer({ env = process.env, configPath, fetchImp
|
|
|
470
492
|
server.registerTool(
|
|
471
493
|
"run_browser_flow",
|
|
472
494
|
{
|
|
473
|
-
description: "Run a bounded screen flow in an isolated per-server browser profile. Continues the current SPA page by default. Supports media fakes, tabs and contexts, read-only page inspection, conditional plans, HTTP/network/WebSocket assertions, named storage state, guarded mutation confirmation, per-step timeouts and evidence. expected_outcome accepts descriptive text or structured text/request assertions. Use describe_actions for required and optional fields and the exact options contract. Jev chooses among eligible supplied plans; a single plan skips the decision call when fast_path is enabled.",
|
|
495
|
+
description: "Run a bounded screen flow in an isolated per-server browser profile. Set background=true to return a run_id immediately and poll get_browser_flow_result instead of holding a long MCP call open. Continues the current SPA page by default. Supports media fakes, tabs and contexts, read-only page inspection, conditional plans, HTTP/network/WebSocket assertions, named storage state, guarded mutation confirmation, per-step timeouts and evidence. expected_outcome accepts descriptive text or structured text/request assertions. Use describe_actions for required and optional fields and the exact options contract. Jev chooses among eligible supplied plans; a single plan skips the decision call when fast_path is enabled.",
|
|
474
496
|
inputSchema: {
|
|
475
|
-
flow: z.string(),
|
|
497
|
+
flow: z.string().optional(),
|
|
476
498
|
initial_url: z.string().optional(),
|
|
477
499
|
expected_outcome: expectedOutcomeSchema.optional(),
|
|
478
|
-
candidate_plans: z.record(z.string(), candidatePlanSchema),
|
|
500
|
+
candidate_plans: z.record(z.string(), candidatePlanSchema).optional(),
|
|
479
501
|
params: z.record(z.string(), z.unknown()).optional(),
|
|
480
502
|
options: browserOptionsSchema.optional(),
|
|
503
|
+
background: z.boolean().optional(),
|
|
481
504
|
},
|
|
482
|
-
},
|
|
483
|
-
async ({ flow, initial_url: initialUrl, expected_outcome: expectedOutcome, candidate_plans: candidatePlans, params, options }) => {
|
|
484
|
-
try {
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
505
|
+
},
|
|
506
|
+
async ({ flow, initial_url: initialUrl, expected_outcome: expectedOutcome, candidate_plans: candidatePlans, params, options, background = false }) => {
|
|
507
|
+
try {
|
|
508
|
+
const contractIssues = collectFlowContractIssues({ flow, candidatePlans });
|
|
509
|
+
if (contractIssues.length) throw new JevBrowserError(`flow contract validation failed:\n- ${contractIssues.join("\n- ")}`);
|
|
510
|
+
const execute = () => browserPool.runFlow(async () => {
|
|
511
|
+
const args = {
|
|
512
|
+
flow,
|
|
513
|
+
initialUrl,
|
|
514
|
+
expectedOutcome,
|
|
515
|
+
candidatePlans,
|
|
516
|
+
params,
|
|
517
|
+
options,
|
|
518
|
+
settings,
|
|
519
|
+
browserPool,
|
|
520
|
+
fetchImpl,
|
|
521
|
+
serverVersion: packageManifest.version,
|
|
522
|
+
mutationAuthorizationStore,
|
|
523
|
+
};
|
|
524
|
+
const retryAfterRestart = async (first) => {
|
|
525
|
+
const recovery = await browserPool.restart();
|
|
526
|
+
if (recovery.reason) recovery.reason = safeErrorMessage(new Error(recovery.reason), settings);
|
|
527
|
+
if (!recovery.connected) {
|
|
528
|
+
if (!initialUrl) browserPool.setNavigationError("BROWSER_PAGE_NOT_RESTORED");
|
|
529
|
+
return {
|
|
530
|
+
...first,
|
|
531
|
+
...(initialUrl ? {} : { navigation_error: "BROWSER_PAGE_NOT_RESTORED" }),
|
|
532
|
+
browser_restart: recovery,
|
|
533
|
+
retry_count: 0,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
if (!initialUrl) {
|
|
537
|
+
browserPool.setNavigationError("BROWSER_PAGE_NOT_RESTORED");
|
|
538
|
+
return {
|
|
539
|
+
...first,
|
|
540
|
+
navigation_error: "BROWSER_PAGE_NOT_RESTORED",
|
|
541
|
+
reason: "The browser reconnected, but the previous page was not restored. Provide initial_url to start a new navigation.",
|
|
542
|
+
browser_restart: recovery,
|
|
543
|
+
retry_count: 0,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
try {
|
|
547
|
+
const retry = await executeBrowserFlow({ ...args, options: { ...options, reuse_page: false } });
|
|
548
|
+
return { ...retry, browser_restart: recovery, retry_count: 1 };
|
|
549
|
+
} catch (error) {
|
|
550
|
+
if (!isBrowserDisconnectError(error) && !isBrowserStartupError(error)) throw error;
|
|
551
|
+
return {
|
|
552
|
+
...browserEnvironmentError(
|
|
553
|
+
`The browser reconnected, but the flow could not restart: ${safeErrorMessage(error, settings)}`,
|
|
554
|
+
"BROWSER_RESTART_FAILED",
|
|
555
|
+
),
|
|
556
|
+
browser_restart: recovery,
|
|
557
|
+
retry_count: 1,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
let first;
|
|
563
|
+
try {
|
|
564
|
+
first = await executeBrowserFlow(args);
|
|
565
|
+
} catch (error) {
|
|
566
|
+
if (isBrowserStartupError(error)) {
|
|
567
|
+
const profileInUse = /browser profile is in use by PID \d+/i.test(String(error.message || ""));
|
|
568
|
+
return browserEnvironmentError(
|
|
569
|
+
safeErrorMessage(error, settings),
|
|
570
|
+
profileInUse ? "BROWSER_PROFILE_IN_USE" : "BROWSER_START_FAILED",
|
|
571
|
+
{ retry_count: 0 },
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
if (!isBrowserDisconnectError(error)) throw error;
|
|
575
|
+
const failedBeforeSteps = browserEnvironmentError(
|
|
576
|
+
"The browser disconnected before the flow could obtain its page.",
|
|
577
|
+
"BROWSER_DISCONNECTED",
|
|
578
|
+
);
|
|
579
|
+
return retryAfterRestart(failedBeforeSteps);
|
|
580
|
+
}
|
|
581
|
+
const disconnected = /^(?:BROWSER_DISCONNECTED|CONNECTION_CLOSED)$/.test(String(first.navigation_error || ""));
|
|
582
|
+
if (first.status !== "environment_error" || !disconnected
|
|
583
|
+
|| first.steps_executed > 0 || first.mutating_steps?.length || options?.confirmation_token) return first;
|
|
584
|
+
return retryAfterRestart(first);
|
|
585
|
+
});
|
|
586
|
+
if (background) {
|
|
587
|
+
const started = flowRunStore.start(async () => {
|
|
588
|
+
try {
|
|
589
|
+
return fitToolResponse(await execute(), Math.floor(settings.browser.maxToolResponseBytes * 0.8));
|
|
590
|
+
} catch (error) {
|
|
591
|
+
throw new Error(safeErrorMessage(error, settings));
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
return toolText(started, settings.browser.maxToolResponseBytes);
|
|
595
|
+
}
|
|
596
|
+
return toolText(await execute(), settings.browser.maxToolResponseBytes);
|
|
597
|
+
} catch (error) {
|
|
598
|
+
return toolError(error, settings);
|
|
599
|
+
}
|
|
600
|
+
},
|
|
601
|
+
);
|
|
602
|
+
|
|
603
|
+
server.registerTool(
|
|
604
|
+
"run_test_suite",
|
|
605
|
+
{
|
|
606
|
+
description: "Run a validated JSON suite from .jev-browser/suites/<suite_name>.json sequentially, retaining the browser and integration session across setup, acceptance criteria and always-attempted teardown. Produces a Markdown evidence report and cleans project-local fixtures after teardown. Set background=true to poll the long run with get_browser_flow_result.",
|
|
607
|
+
inputSchema: {
|
|
608
|
+
suite_name: z.string().min(1).max(80),
|
|
609
|
+
params: z.record(z.string(), z.unknown()).optional(),
|
|
610
|
+
environment_profile: z.string().min(1).max(48).optional(),
|
|
611
|
+
report_path: z.string().min(1).max(2048).optional(),
|
|
612
|
+
background: z.boolean().optional(),
|
|
613
|
+
},
|
|
614
|
+
},
|
|
615
|
+
async ({ suite_name: suiteName, params, environment_profile: environmentProfile, report_path: reportPath, background = false }) => {
|
|
616
|
+
try {
|
|
617
|
+
const execute = () => browserPool.runFlow(() => executeTestSuite({
|
|
618
|
+
suiteName,
|
|
619
|
+
params,
|
|
620
|
+
environmentProfile,
|
|
621
|
+
reportPath,
|
|
622
|
+
settings,
|
|
623
|
+
browserPool,
|
|
624
|
+
fetchImpl,
|
|
625
|
+
serverVersion: packageManifest.version,
|
|
626
|
+
mutationAuthorizationStore,
|
|
627
|
+
executeFlow: executeBrowserFlow,
|
|
628
|
+
}));
|
|
629
|
+
if (background) {
|
|
630
|
+
const started = flowRunStore.start(async () => {
|
|
631
|
+
try { return fitToolResponse(await execute(), Math.floor(settings.browser.maxToolResponseBytes * 0.8)); }
|
|
632
|
+
catch (error) { throw new Error(safeErrorMessage(error, settings)); }
|
|
633
|
+
});
|
|
634
|
+
return toolText(started, settings.browser.maxToolResponseBytes);
|
|
635
|
+
}
|
|
636
|
+
return toolText(await execute(), settings.browser.maxToolResponseBytes);
|
|
637
|
+
} catch (error) {
|
|
638
|
+
return toolError(error, settings);
|
|
639
|
+
}
|
|
640
|
+
},
|
|
641
|
+
);
|
|
642
|
+
|
|
643
|
+
server.registerTool(
|
|
644
|
+
"get_browser_flow_result",
|
|
645
|
+
{
|
|
646
|
+
description: "Poll a background run_browser_flow call. Set wait_ms up to 30000 to long-poll; completed results are retained for 30 minutes in this MCP process.",
|
|
647
|
+
inputSchema: {
|
|
648
|
+
run_id: z.string().min(1),
|
|
649
|
+
wait_ms: z.number().int().min(0).max(30000).optional(),
|
|
650
|
+
},
|
|
651
|
+
},
|
|
652
|
+
async ({ run_id: runId, wait_ms: waitMs = 0 }) => {
|
|
653
|
+
const result = await flowRunStore.get(runId, waitMs);
|
|
654
|
+
if (!result) return toolError(new JevBrowserError("background flow run was not found or its result expired"), settings);
|
|
655
|
+
return toolText(result, settings.browser.maxToolResponseBytes);
|
|
656
|
+
},
|
|
657
|
+
);
|
|
658
|
+
|
|
659
|
+
return server;
|
|
660
|
+
}
|
|
515
661
|
|
|
516
|
-
export async function closeBrowserSessions() {
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
662
|
+
export async function closeBrowserSessions() {
|
|
663
|
+
try {
|
|
664
|
+
await Promise.all([...browserPools].map(async ([pool, settings]) => {
|
|
665
|
+
try {
|
|
666
|
+
await pool.close();
|
|
667
|
+
} finally {
|
|
668
|
+
await cleanupUploadStagingDirectory(settings);
|
|
669
|
+
}
|
|
670
|
+
}));
|
|
671
|
+
} finally {
|
|
672
|
+
browserPools.clear();
|
|
673
|
+
}
|
|
674
|
+
}
|
|
520
675
|
|
|
521
676
|
export function installConfiguredBrowser({ env = process.env } = {}) {
|
|
522
677
|
const settings = loadSettings({ env });
|