@nyx-intelligence/val-mcp 0.1.0 → 0.3.0
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 +19 -10
- package/dist/index.js +115 -34
- package/dist/session.js +209 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,14 +6,15 @@ them as tools your coding assistant can call. Val itself spends **no AI tokens**
|
|
|
6
6
|
detection is deterministic and local; your agent (Claude Code, Cursor, Windsurf)
|
|
7
7
|
does the reasoning and the fixing on your own key.
|
|
8
8
|
|
|
9
|
-
## What it catches
|
|
9
|
+
## What it catches
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
Passive (crawl): broken links & 404s, uncaught JS errors, console errors,
|
|
12
|
+
failed network requests (4xx/5xx), broken images, missing `<title>`.
|
|
13
|
+
|
|
14
|
+
Interactive (the agent drives the browser): **dead buttons** (clicks every
|
|
15
|
+
button, flags the ones with no effect or that throw) and **broken funnels**
|
|
16
|
+
(walk signup / checkout step by step, filling forms, catching errors at each
|
|
17
|
+
step).
|
|
17
18
|
|
|
18
19
|
UX/functional only — **not** security/pentest.
|
|
19
20
|
|
|
@@ -33,15 +34,23 @@ First run downloads Chromium if needed (`npx playwright install chromium`).
|
|
|
33
34
|
|
|
34
35
|
## Tools
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
Passive crawl:
|
|
38
|
+
- `val_scan({ url, maxPages? })` — crawl an app and report all passive findings. Run first.
|
|
37
39
|
- `val_check({ url })` — re-check one page after a fix (the "verify" step).
|
|
38
40
|
- `val_screenshot({ url, fullPage? })` — PNG of a page for visual reasoning.
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
+
Live interaction (drive these to click everything and walk funnels):
|
|
43
|
+
- `val_open({ url })` — start a browser session at a URL.
|
|
44
|
+
- `val_snapshot()` — list interactive elements, each with a `[ref]`.
|
|
45
|
+
- `val_click({ target })` — click a `[ref]` or visible text; reports dead controls + errors.
|
|
46
|
+
- `val_type({ target, text })` — fill a field (signup / checkout form).
|
|
47
|
+
- `val_exercise()` — click EVERY button on the page; report which are dead or throw.
|
|
48
|
+
- `val_state()` — current URL + errors since the last action.
|
|
49
|
+
- `val_close()` — end the session.
|
|
42
50
|
|
|
43
51
|
## Test without an MCP client
|
|
44
52
|
|
|
45
53
|
```bash
|
|
46
54
|
val-mcp scan http://localhost:3000 20
|
|
55
|
+
val-mcp exercise http://localhost:3000
|
|
47
56
|
```
|
package/dist/index.js
CHANGED
|
@@ -1,76 +1,157 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { scanSite, checkPage, screenshot } from "./scanner.js";
|
|
3
3
|
import { formatReport } from "./report.js";
|
|
4
|
-
|
|
4
|
+
import { session } from "./session.js";
|
|
5
|
+
function fmtAction(r) {
|
|
6
|
+
const lines = [`${r.ok ? "ok" : "warn"} · ${r.title || "(no title)"} · ${r.url}`];
|
|
7
|
+
if (r.note)
|
|
8
|
+
lines.push(`note: ${r.note}`);
|
|
9
|
+
if (r.navigatedTo && r.navigatedTo !== r.url)
|
|
10
|
+
lines.push(`navigated: ${r.navigatedTo}`);
|
|
11
|
+
for (const e of r.newPageErrors)
|
|
12
|
+
lines.push(`JS error: ${e}`);
|
|
13
|
+
for (const e of r.newConsoleErrors)
|
|
14
|
+
lines.push(`console error: ${e}`);
|
|
15
|
+
for (const n of r.newNetworkErrors)
|
|
16
|
+
lines.push(`network ${n.status}: ${n.url}`);
|
|
17
|
+
return lines.join("\n");
|
|
18
|
+
}
|
|
19
|
+
function fmtSnapshot(els) {
|
|
20
|
+
if (els.length === 0)
|
|
21
|
+
return "No interactive elements found.";
|
|
22
|
+
return els.map((e) => `[${e.ref}] ${e.kind} ${e.text || "—"}${e.attrs ? ` (${e.attrs})` : ""}`).join("\n");
|
|
23
|
+
}
|
|
24
|
+
// ───────────────────────── CLI (testing without an MCP client) ─────────────────────────
|
|
5
25
|
async function runCli(args) {
|
|
6
|
-
const [cmd, url,
|
|
26
|
+
const [cmd, url, arg2] = args;
|
|
7
27
|
if (cmd === "scan" && url) {
|
|
8
|
-
const
|
|
9
|
-
const { findings, pagesVisited } = await scanSite(url, { maxPages });
|
|
28
|
+
const { findings, pagesVisited } = await scanSite(url, { maxPages: Number(arg2) || 12 });
|
|
10
29
|
console.log(formatReport(findings, pagesVisited));
|
|
11
|
-
process.exit(0);
|
|
12
30
|
}
|
|
13
|
-
|
|
14
|
-
|
|
31
|
+
else if (cmd === "exercise" && url) {
|
|
32
|
+
console.log(fmtAction(await session.open(url)));
|
|
33
|
+
const res = await session.exercise();
|
|
34
|
+
console.log(`\nexercised ${res.tested} button(s): ${res.dead.length} dead, ${res.errored.length} errored`);
|
|
35
|
+
for (const d of res.dead)
|
|
36
|
+
console.log(` DEAD: ${d}`);
|
|
37
|
+
for (const e of res.errored)
|
|
38
|
+
console.log(` ERROR: ${e.el} — ${e.error}`);
|
|
39
|
+
await session.close();
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
console.error("Usage:\n val-mcp scan <url> [maxPages]\n val-mcp exercise <url>\n val-mcp (start MCP server on stdio)");
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
process.exit(0);
|
|
15
46
|
}
|
|
16
|
-
|
|
47
|
+
// ───────────────────────── MCP server ─────────────────────────
|
|
17
48
|
async function runServer() {
|
|
49
|
+
// License gate — no active subscription, no tools.
|
|
50
|
+
const apiBase = process.env.VAL_API_BASE || "https://val.nyx-intelligence.com";
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetch(`${apiBase}/api/license/validate`, {
|
|
53
|
+
headers: { Authorization: `Bearer ${process.env.VAL_LICENSE_KEY || ""}` },
|
|
54
|
+
});
|
|
55
|
+
const lic = (await res.json());
|
|
56
|
+
if (!lic.valid) {
|
|
57
|
+
console.error("Val: no active subscription for this VAL_LICENSE_KEY. Subscribe at https://val.nyx-intelligence.com");
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
console.error("Val: could not verify your license (check VAL_LICENSE_KEY and connectivity).");
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
18
65
|
const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
|
|
19
66
|
const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
|
|
20
67
|
const { chromium } = await import("playwright");
|
|
21
68
|
const { z } = await import("zod");
|
|
22
|
-
const server = new McpServer({ name: "val", version: "0.
|
|
69
|
+
const server = new McpServer({ name: "val", version: "0.3.0" });
|
|
70
|
+
const text = (t) => ({ content: [{ type: "text", text: t }] });
|
|
71
|
+
// ── Passive crawl ──
|
|
23
72
|
server.registerTool("val_scan", {
|
|
24
|
-
title: "Scan an app for UX bugs",
|
|
25
|
-
description: "Crawl an app (localhost or live
|
|
26
|
-
inputSchema: {
|
|
27
|
-
url: z.string().describe("Start URL, e.g. http://localhost:3000"),
|
|
28
|
-
maxPages: z.number().int().min(1).max(50).optional().describe("Max pages to crawl (default 12)"),
|
|
29
|
-
},
|
|
73
|
+
title: "Scan an app for UX bugs (crawl)",
|
|
74
|
+
description: "Crawl an app (localhost or live), following same-origin links, and report passive bugs: broken links/404s, uncaught JS errors, console errors, failed network requests, broken images, missing titles. Good first pass. Does NOT click buttons or fill forms — use the val_open/val_click/val_type/val_exercise tools for that.",
|
|
75
|
+
inputSchema: { url: z.string(), maxPages: z.number().int().min(1).max(50).optional() },
|
|
30
76
|
}, async ({ url, maxPages }) => {
|
|
31
77
|
const { findings, pagesVisited } = await scanSite(url, { maxPages: maxPages ?? 12 });
|
|
32
|
-
return {
|
|
33
|
-
content: [
|
|
34
|
-
{ type: "text", text: `${formatReport(findings, pagesVisited)}\n\nJSON:\n${JSON.stringify(findings)}` },
|
|
35
|
-
],
|
|
36
|
-
};
|
|
78
|
+
return text(`${formatReport(findings, pagesVisited)}\n\nJSON:\n${JSON.stringify(findings)}`);
|
|
37
79
|
});
|
|
38
80
|
server.registerTool("val_check", {
|
|
39
81
|
title: "Re-check one page (verify a fix)",
|
|
40
|
-
description: "Run the
|
|
41
|
-
inputSchema: { url: z.string()
|
|
82
|
+
description: "Run the passive checks on a SINGLE page. Use after fixing a bug to confirm it is gone.",
|
|
83
|
+
inputSchema: { url: z.string() },
|
|
42
84
|
}, async ({ url }) => {
|
|
43
85
|
const browser = await chromium.launch({ headless: true });
|
|
44
86
|
try {
|
|
45
87
|
const { findings } = await checkPage(browser, url);
|
|
46
|
-
return {
|
|
47
|
-
content: [
|
|
48
|
-
{ type: "text", text: `${formatReport(findings, [url])}\n\nJSON:\n${JSON.stringify(findings)}` },
|
|
49
|
-
],
|
|
50
|
-
};
|
|
88
|
+
return text(`${formatReport(findings, [url])}\n\nJSON:\n${JSON.stringify(findings)}`);
|
|
51
89
|
}
|
|
52
90
|
finally {
|
|
53
91
|
await browser.close();
|
|
54
92
|
}
|
|
55
93
|
});
|
|
94
|
+
// ── Interactive session (walk funnels, click everything) ──
|
|
95
|
+
server.registerTool("val_open", {
|
|
96
|
+
title: "Open a page in the live session",
|
|
97
|
+
description: "Start (or reuse) the browser session and navigate to a URL. Returns the page state and any load errors. This begins an interactive session you drive with val_snapshot / val_click / val_type to walk a funnel (e.g. sign up, checkout).",
|
|
98
|
+
inputSchema: { url: z.string() },
|
|
99
|
+
}, async ({ url }) => text(fmtAction(await session.open(url))));
|
|
100
|
+
server.registerTool("val_snapshot", {
|
|
101
|
+
title: "List the interactive elements on the current page",
|
|
102
|
+
description: "Return every clickable/fillable element on the current page with a [ref] you can pass to val_click / val_type, plus its kind and label. Call this to see what you can act on before clicking or typing.",
|
|
103
|
+
inputSchema: {},
|
|
104
|
+
}, async () => text(fmtSnapshot(await session.snapshot())));
|
|
105
|
+
server.registerTool("val_click", {
|
|
106
|
+
title: "Click an element",
|
|
107
|
+
description: "Click an element by its [ref] (from val_snapshot) or by visible text. Returns whether anything changed, where it navigated, and any errors triggered. If nothing changes and no error fires, the control is flagged as a possible dead button.",
|
|
108
|
+
inputSchema: { target: z.string().describe("A ref like 'v3' from val_snapshot, or the visible button/link text") },
|
|
109
|
+
}, async ({ target }) => text(fmtAction(await session.click(target))));
|
|
110
|
+
server.registerTool("val_type", {
|
|
111
|
+
title: "Type into a field",
|
|
112
|
+
description: "Fill an input/textarea (by [ref] or label) with text. Use to fill a signup or checkout form, then val_click the submit button.",
|
|
113
|
+
inputSchema: { target: z.string().describe("A ref like 'v2' or the field label/placeholder"), text: z.string() },
|
|
114
|
+
}, async ({ target, text: value }) => text(fmtAction(await session.type(target, value))));
|
|
115
|
+
server.registerTool("val_exercise", {
|
|
116
|
+
title: "Click every button on the current page",
|
|
117
|
+
description: "Deterministically click every visible button on the current page (returning to the page after any navigation) and report which ones are DEAD (no effect, no error, no network) or which throw a JS error. The fast way to answer 'is any button broken on this screen?'.",
|
|
118
|
+
inputSchema: {},
|
|
119
|
+
}, async () => {
|
|
120
|
+
const r = await session.exercise();
|
|
121
|
+
const lines = [`Exercised ${r.tested} button(s): ${r.dead.length} dead, ${r.errored.length} errored.`];
|
|
122
|
+
for (const d of r.dead)
|
|
123
|
+
lines.push(`DEAD: ${d}`);
|
|
124
|
+
for (const e of r.errored)
|
|
125
|
+
lines.push(`ERROR: ${e.el} — ${e.error}`);
|
|
126
|
+
return text(lines.join("\n"));
|
|
127
|
+
});
|
|
128
|
+
server.registerTool("val_state", {
|
|
129
|
+
title: "Current page state + new errors",
|
|
130
|
+
description: "Return the current URL/title and any console/JS/network errors captured since the last action. Use to verify a step did not break anything.",
|
|
131
|
+
inputSchema: {},
|
|
132
|
+
}, async () => text(fmtAction(await session.state())));
|
|
56
133
|
server.registerTool("val_screenshot", {
|
|
57
134
|
title: "Screenshot a page",
|
|
58
|
-
description: "Capture a PNG
|
|
59
|
-
inputSchema: {
|
|
60
|
-
url: z.string().describe("The page URL to screenshot"),
|
|
61
|
-
fullPage: z.boolean().optional().describe("Capture the full scrollable page (default false)"),
|
|
62
|
-
},
|
|
135
|
+
description: "Capture a PNG of a URL so you can reason about layout/visual issues.",
|
|
136
|
+
inputSchema: { url: z.string(), fullPage: z.boolean().optional() },
|
|
63
137
|
}, async ({ url, fullPage }) => {
|
|
64
138
|
const data = await screenshot(url, fullPage ?? false);
|
|
65
139
|
return { content: [{ type: "image", data, mimeType: "image/png" }] };
|
|
66
140
|
});
|
|
141
|
+
server.registerTool("val_close", {
|
|
142
|
+
title: "Close the session",
|
|
143
|
+
description: "Close the browser session when finished walking a flow.",
|
|
144
|
+
inputSchema: {},
|
|
145
|
+
}, async () => {
|
|
146
|
+
await session.close();
|
|
147
|
+
return text("session closed");
|
|
148
|
+
});
|
|
67
149
|
const transport = new StdioServerTransport();
|
|
68
150
|
await server.connect(transport);
|
|
69
|
-
// stdout is the MCP channel; logs go to stderr.
|
|
70
151
|
console.error("val-mcp server running on stdio");
|
|
71
152
|
}
|
|
72
153
|
const args = process.argv.slice(2);
|
|
73
|
-
if (args[0] === "scan") {
|
|
154
|
+
if (args[0] === "scan" || args[0] === "exercise") {
|
|
74
155
|
runCli(args).catch((e) => {
|
|
75
156
|
console.error(e);
|
|
76
157
|
process.exit(1);
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { chromium } from "playwright";
|
|
2
|
+
function dedupe(a) {
|
|
3
|
+
return Array.from(new Set(a));
|
|
4
|
+
}
|
|
5
|
+
class ValSession {
|
|
6
|
+
browser;
|
|
7
|
+
page;
|
|
8
|
+
consoleErrors = [];
|
|
9
|
+
pageErrors = [];
|
|
10
|
+
netErrors = [];
|
|
11
|
+
async ensure() {
|
|
12
|
+
if (this.browser && this.page)
|
|
13
|
+
return this.page;
|
|
14
|
+
this.browser = await chromium.launch({ headless: true });
|
|
15
|
+
const ctx = await this.browser.newContext();
|
|
16
|
+
this.page = await ctx.newPage();
|
|
17
|
+
this.page.on("console", (m) => {
|
|
18
|
+
if (m.type() === "error" && !/Failed to load resource/i.test(m.text())) {
|
|
19
|
+
this.consoleErrors.push(m.text().slice(0, 300));
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
this.page.on("pageerror", (e) => this.pageErrors.push((e.message || String(e)).slice(0, 300)));
|
|
23
|
+
this.page.on("response", (r) => {
|
|
24
|
+
const s = r.status();
|
|
25
|
+
if (s >= 400)
|
|
26
|
+
this.netErrors.push({ url: r.url(), status: s });
|
|
27
|
+
});
|
|
28
|
+
return this.page;
|
|
29
|
+
}
|
|
30
|
+
/** Pull and clear the errors captured since the last drain. */
|
|
31
|
+
drain() {
|
|
32
|
+
return {
|
|
33
|
+
newConsoleErrors: dedupe(this.consoleErrors.splice(0)),
|
|
34
|
+
newPageErrors: dedupe(this.pageErrors.splice(0)),
|
|
35
|
+
newNetworkErrors: this.netErrors.splice(0),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** A cheap signature of the page to detect whether an action changed anything. */
|
|
39
|
+
async sig() {
|
|
40
|
+
return this.page.evaluate(() => ({
|
|
41
|
+
url: location.href,
|
|
42
|
+
nodes: document.querySelectorAll("*").length,
|
|
43
|
+
text: document.body ? document.body.innerText.length : 0,
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
async resolve(refOrText) {
|
|
47
|
+
const page = this.page;
|
|
48
|
+
const byRef = page.locator(`[data-val-ref="${refOrText}"]`);
|
|
49
|
+
if ((await byRef.count()) > 0)
|
|
50
|
+
return byRef.first();
|
|
51
|
+
return page.getByText(refOrText, { exact: false }).first();
|
|
52
|
+
}
|
|
53
|
+
async open(url) {
|
|
54
|
+
const page = await this.ensure();
|
|
55
|
+
this.drain();
|
|
56
|
+
let note;
|
|
57
|
+
try {
|
|
58
|
+
const resp = await page.goto(url, { waitUntil: "load", timeout: 25000 });
|
|
59
|
+
await page.waitForTimeout(800);
|
|
60
|
+
if (resp && resp.status() >= 400)
|
|
61
|
+
note = `Page returned HTTP ${resp.status()}`;
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
note = `load failed: ${e instanceof Error ? e.message : String(e)}`;
|
|
65
|
+
}
|
|
66
|
+
return { ok: !note, url: page.url(), title: await safeTitle(page), changed: true, navigatedTo: page.url(), ...this.drain(), note };
|
|
67
|
+
}
|
|
68
|
+
async snapshot() {
|
|
69
|
+
const page = await this.ensure();
|
|
70
|
+
return page.evaluate(() => {
|
|
71
|
+
const isVisible = (el) => {
|
|
72
|
+
const r = el.getBoundingClientRect();
|
|
73
|
+
const s = getComputedStyle(el);
|
|
74
|
+
return r.width > 0 && r.height > 0 && s.visibility !== "hidden" && s.display !== "none";
|
|
75
|
+
};
|
|
76
|
+
const sel = "a[href], button, [role=button], input:not([type=hidden]), select, textarea, [onclick]";
|
|
77
|
+
const els = Array.from(document.querySelectorAll(sel)).filter(isVisible).slice(0, 150);
|
|
78
|
+
return els.map((el, i) => {
|
|
79
|
+
const ref = `v${i}`;
|
|
80
|
+
el.setAttribute("data-val-ref", ref);
|
|
81
|
+
const tag = el.tagName.toLowerCase();
|
|
82
|
+
let kind = tag === "a" ? "link" : tag;
|
|
83
|
+
if (tag === "input")
|
|
84
|
+
kind = el.type || "text";
|
|
85
|
+
if (el.getAttribute("role") === "button")
|
|
86
|
+
kind = "button";
|
|
87
|
+
const input = el;
|
|
88
|
+
const text = (el.innerText ||
|
|
89
|
+
input.value ||
|
|
90
|
+
el.getAttribute("aria-label") ||
|
|
91
|
+
input.placeholder ||
|
|
92
|
+
"")
|
|
93
|
+
.trim()
|
|
94
|
+
.slice(0, 60);
|
|
95
|
+
const attrs = [
|
|
96
|
+
tag === "a" ? `href=${el.getAttribute("href")}` : "",
|
|
97
|
+
input.name ? `name=${input.name}` : "",
|
|
98
|
+
input.type ? `type=${input.type}` : "",
|
|
99
|
+
]
|
|
100
|
+
.filter(Boolean)
|
|
101
|
+
.join(" ");
|
|
102
|
+
return { ref, kind, text, attrs };
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
async click(refOrText) {
|
|
107
|
+
const page = await this.ensure();
|
|
108
|
+
this.drain();
|
|
109
|
+
const before = await this.sig();
|
|
110
|
+
let note;
|
|
111
|
+
let changed = false;
|
|
112
|
+
try {
|
|
113
|
+
const loc = await this.resolve(refOrText);
|
|
114
|
+
if ((await loc.count()) === 0) {
|
|
115
|
+
return { ok: false, url: before.url, title: await safeTitle(page), changed: false, ...this.drain(), note: `element not found: ${refOrText}` };
|
|
116
|
+
}
|
|
117
|
+
await loc.click({ timeout: 8000 });
|
|
118
|
+
await page.waitForTimeout(700);
|
|
119
|
+
const after = await this.sig();
|
|
120
|
+
changed = after.url !== before.url || after.nodes !== before.nodes || Math.abs(after.text - before.text) > 3;
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
note = `click failed: ${e instanceof Error ? e.message : String(e)}`;
|
|
124
|
+
}
|
|
125
|
+
const errs = this.drain();
|
|
126
|
+
if (!note && !changed && errs.newPageErrors.length === 0 && errs.newConsoleErrors.length === 0 && errs.newNetworkErrors.length === 0) {
|
|
127
|
+
note = "no visible effect — possible dead control";
|
|
128
|
+
}
|
|
129
|
+
return { ok: !note, url: page.url(), title: await safeTitle(page), changed, navigatedTo: page.url() !== before.url ? page.url() : undefined, ...errs, note };
|
|
130
|
+
}
|
|
131
|
+
async type(refOrText, text) {
|
|
132
|
+
const page = await this.ensure();
|
|
133
|
+
this.drain();
|
|
134
|
+
let note;
|
|
135
|
+
try {
|
|
136
|
+
const loc = await this.resolve(refOrText);
|
|
137
|
+
await loc.fill(text, { timeout: 8000 });
|
|
138
|
+
}
|
|
139
|
+
catch (e) {
|
|
140
|
+
note = `type failed: ${e instanceof Error ? e.message : String(e)}`;
|
|
141
|
+
}
|
|
142
|
+
return { ok: !note, url: page.url(), title: await safeTitle(page), changed: true, ...this.drain(), note };
|
|
143
|
+
}
|
|
144
|
+
/** Click every visible button on the current page; report dead + erroring ones. */
|
|
145
|
+
async exercise() {
|
|
146
|
+
const page = await this.ensure();
|
|
147
|
+
const tag = () => page.evaluate(() => {
|
|
148
|
+
const isVisible = (el) => {
|
|
149
|
+
const r = el.getBoundingClientRect();
|
|
150
|
+
return r.width > 0 && r.height > 0;
|
|
151
|
+
};
|
|
152
|
+
const els = Array.from(document.querySelectorAll("button, [role=button]")).filter(isVisible);
|
|
153
|
+
els.forEach((el, i) => el.setAttribute("data-val-ex", `e${i}`));
|
|
154
|
+
return els.map((el, i) => ({ ref: `e${i}`, text: (el.innerText || el.getAttribute("aria-label") || "").trim().slice(0, 40) }));
|
|
155
|
+
});
|
|
156
|
+
const buttons = await tag();
|
|
157
|
+
const dead = [];
|
|
158
|
+
const errored = [];
|
|
159
|
+
const startUrl = page.url();
|
|
160
|
+
for (const b of buttons) {
|
|
161
|
+
this.drain();
|
|
162
|
+
const before = await this.sig();
|
|
163
|
+
try {
|
|
164
|
+
const loc = page.locator(`[data-val-ex="${b.ref}"]`);
|
|
165
|
+
if ((await loc.count()) === 0)
|
|
166
|
+
continue;
|
|
167
|
+
await loc.click({ timeout: 4000 });
|
|
168
|
+
await page.waitForTimeout(400);
|
|
169
|
+
if (page.url() !== startUrl) {
|
|
170
|
+
await page.goBack({ waitUntil: "load" }).catch(() => { });
|
|
171
|
+
await page.waitForTimeout(300);
|
|
172
|
+
await tag(); // re-tag after navigating back
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const after = await this.sig();
|
|
176
|
+
const errs = this.drain();
|
|
177
|
+
const label = b.text || b.ref;
|
|
178
|
+
const moved = after.nodes !== before.nodes || Math.abs(after.text - before.text) > 3;
|
|
179
|
+
if (errs.newPageErrors.length)
|
|
180
|
+
errored.push({ el: label, error: errs.newPageErrors[0] });
|
|
181
|
+
else if (!moved && errs.newConsoleErrors.length === 0 && errs.newNetworkErrors.length === 0)
|
|
182
|
+
dead.push(label);
|
|
183
|
+
}
|
|
184
|
+
catch (e) {
|
|
185
|
+
errored.push({ el: b.text || b.ref, error: e instanceof Error ? e.message : String(e) });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return { tested: buttons.length, dead, errored };
|
|
189
|
+
}
|
|
190
|
+
async state() {
|
|
191
|
+
const page = await this.ensure();
|
|
192
|
+
return { ok: true, url: page.url(), title: await safeTitle(page), changed: false, ...this.drain() };
|
|
193
|
+
}
|
|
194
|
+
async close() {
|
|
195
|
+
await this.browser?.close().catch(() => { });
|
|
196
|
+
this.browser = undefined;
|
|
197
|
+
this.page = undefined;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async function safeTitle(page) {
|
|
201
|
+
try {
|
|
202
|
+
return await page.title();
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return "";
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/** One session per server process. */
|
|
209
|
+
export const session = new ValSession();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nyx-intelligence/val-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Val — a 100% MCP QA agent for vibecoders. Drives a real browser to catch UX bugs (broken links, 404s, console errors, broken images) so your coding agent can fix them.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|