@nyx-intelligence/val-mcp 0.5.5 → 0.6.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/dist/index.js +21 -3
- package/dist/report.js +2 -1
- package/dist/scanner.js +111 -2
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { scanSite, checkPage, screenshot } from "./scanner.js";
|
|
2
|
+
import { scanSite, scanSiteMulti, checkPage, screenshot } from "./scanner.js";
|
|
3
3
|
import { formatReport, topFindings } from "./report.js";
|
|
4
4
|
import { session } from "./session.js";
|
|
5
5
|
function fmtAction(r) {
|
|
@@ -28,6 +28,12 @@ async function runCli(args) {
|
|
|
28
28
|
const { findings, pagesVisited } = await scanSite(url, { maxPages: Number(arg2) || 12, device: arg3 || undefined });
|
|
29
29
|
console.log(formatReport(findings, pagesVisited));
|
|
30
30
|
}
|
|
31
|
+
else if (cmd === "scan-devices" && url) {
|
|
32
|
+
// arg2 = comma-separated device list, arg3 = maxPages
|
|
33
|
+
const devs = (arg2 || "desktop,iPhone 14").split(",").map((s) => s.trim()).filter(Boolean);
|
|
34
|
+
const { findings, pagesVisited } = await scanSiteMulti(url, { maxPages: Number(arg3) || 12, devices: devs });
|
|
35
|
+
console.log(formatReport(findings, pagesVisited));
|
|
36
|
+
}
|
|
31
37
|
else if (cmd === "exercise" && url) {
|
|
32
38
|
console.log(fmtAction(await session.open(url, arg2 || undefined)));
|
|
33
39
|
const res = await session.exercise();
|
|
@@ -78,9 +84,21 @@ async function runServer() {
|
|
|
78
84
|
const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
|
|
79
85
|
const { chromium } = await import("playwright");
|
|
80
86
|
const { z } = await import("zod");
|
|
81
|
-
const server = new McpServer({ name: "val", version: "0.
|
|
87
|
+
const server = new McpServer({ name: "val", version: "0.6.0" });
|
|
82
88
|
const text = (t) => ({ content: [{ type: "text", text: t }] });
|
|
83
89
|
// ── Passive crawl ──
|
|
90
|
+
server.registerTool("val_scan_devices", {
|
|
91
|
+
title: "Scan an app on multiple devices (desktop + mobile)",
|
|
92
|
+
description: "Crawl the app under MULTIPLE device profiles in parallel (e.g. ['desktop', 'iPhone 14', 'Pixel 7']) and report findings. Bugs that appear on every device are tagged 'all'; mobile-only or desktop-only bugs keep their device tag so the agent knows where to fix. Use this instead of val_scan when responsive behavior matters.",
|
|
93
|
+
inputSchema: {
|
|
94
|
+
url: z.string(),
|
|
95
|
+
devices: z.array(z.string()).min(1).describe("Device names: 'desktop', 'iPhone 14', 'Pixel 7', etc."),
|
|
96
|
+
maxPages: z.number().int().min(1).max(50).optional(),
|
|
97
|
+
},
|
|
98
|
+
}, async ({ url, devices: devs, maxPages }) => {
|
|
99
|
+
const { findings, pagesVisited } = await scanSiteMulti(url, { maxPages: maxPages ?? 12, devices: devs });
|
|
100
|
+
return text(`${formatReport(findings, pagesVisited)}\n\nJSON:\n${JSON.stringify(topFindings(findings))}`);
|
|
101
|
+
});
|
|
84
102
|
server.registerTool("val_scan", {
|
|
85
103
|
title: "Scan an app for UX bugs (crawl)",
|
|
86
104
|
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, horizontal layout overflow, clipped text, and janky load (layout shift / CLS). Pass `device` to test the MOBILE rendering (e.g. 'iPhone 14'). Good first pass. Does NOT click buttons — use val_open/val_click/val_type/val_exercise for that.",
|
|
@@ -180,7 +198,7 @@ async function runServer() {
|
|
|
180
198
|
console.error("val-mcp server running on stdio");
|
|
181
199
|
}
|
|
182
200
|
const args = process.argv.slice(2);
|
|
183
|
-
if (args[0] === "scan" || args[0] === "exercise" || args[0] === "exercise-forms") {
|
|
201
|
+
if (args[0] === "scan" || args[0] === "scan-devices" || args[0] === "exercise" || args[0] === "exercise-forms") {
|
|
184
202
|
runCli(args).catch((e) => {
|
|
185
203
|
console.error(e);
|
|
186
204
|
process.exit(1);
|
package/dist/report.js
CHANGED
|
@@ -20,7 +20,8 @@ export function formatReport(findings, pagesVisited) {
|
|
|
20
20
|
lines.push("");
|
|
21
21
|
const shown = topFindings(findings);
|
|
22
22
|
for (const f of shown) {
|
|
23
|
-
|
|
23
|
+
const dev = f.device && f.device !== "all" && f.device !== "desktop" ? ` [${f.device}]` : "";
|
|
24
|
+
lines.push(`[${f.severity.toUpperCase()}] ${f.type}${dev} — ${f.page}`);
|
|
24
25
|
lines.push(` ${f.detail}${f.evidence ? ` (${f.evidence})` : ""}`);
|
|
25
26
|
}
|
|
26
27
|
if (findings.length > shown.length) {
|
package/dist/scanner.js
CHANGED
|
@@ -69,6 +69,64 @@ export async function checkPage(browser, url, opts = {}) {
|
|
|
69
69
|
return i.complete && i.naturalWidth === 0 && !!(i.currentSrc || i.src);
|
|
70
70
|
})
|
|
71
71
|
.map((img) => img.currentSrc || img.src);
|
|
72
|
+
// ── A11y essentials ────────────────────────────────────────────────
|
|
73
|
+
// Cheap-to-detect, high-signal accessibility violations. Vibecoded
|
|
74
|
+
// apps routinely ship these. We do NOT replicate Lighthouse — just
|
|
75
|
+
// catch the obvious stuff so screen readers and keyboard users have
|
|
76
|
+
// a fighting chance.
|
|
77
|
+
const isVisible = (el) => {
|
|
78
|
+
const r = el.getBoundingClientRect();
|
|
79
|
+
const s = getComputedStyle(el);
|
|
80
|
+
return r.width > 0 && r.height > 0 && s.visibility !== "hidden" && s.display !== "none";
|
|
81
|
+
};
|
|
82
|
+
const imgsNoAlt = [];
|
|
83
|
+
for (const img of Array.from(document.querySelectorAll("img"))) {
|
|
84
|
+
if (!isVisible(img))
|
|
85
|
+
continue;
|
|
86
|
+
// Missing alt attribute entirely is a bug. alt="" is intentional
|
|
87
|
+
// (decorative image) and we leave it alone.
|
|
88
|
+
if (!img.hasAttribute("alt")) {
|
|
89
|
+
const src = img.currentSrc || img.src || "(no src)";
|
|
90
|
+
imgsNoAlt.push(src.split("/").pop().slice(0, 50));
|
|
91
|
+
if (imgsNoAlt.length >= 8)
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const buttonsNoName = [];
|
|
96
|
+
for (const btn of Array.from(document.querySelectorAll("button, [role=button]"))) {
|
|
97
|
+
if (!isVisible(btn))
|
|
98
|
+
continue;
|
|
99
|
+
const txt = (btn.innerText || "").trim();
|
|
100
|
+
const aria = btn.getAttribute("aria-label") || btn.getAttribute("aria-labelledby") || btn.getAttribute("title") || "";
|
|
101
|
+
if (!txt && !aria.trim()) {
|
|
102
|
+
// Identify it the best we can without a name.
|
|
103
|
+
const cls = (btn.getAttribute("class") || "").slice(0, 30);
|
|
104
|
+
const id = btn.getAttribute("id") || "";
|
|
105
|
+
buttonsNoName.push(id ? `#${id}` : cls ? `.${cls}` : btn.tagName.toLowerCase());
|
|
106
|
+
if (buttonsNoName.length >= 8)
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const inputsNoLabel = [];
|
|
111
|
+
for (const input of Array.from(document.querySelectorAll("input, textarea, select"))) {
|
|
112
|
+
if (!isVisible(input))
|
|
113
|
+
continue;
|
|
114
|
+
const i = input;
|
|
115
|
+
const t = (i.type || "").toLowerCase();
|
|
116
|
+
if (["hidden", "submit", "reset", "button", "image"].indexOf(t) >= 0)
|
|
117
|
+
continue;
|
|
118
|
+
// A label exists if: explicit <label for=id>, wrapping <label>, aria-label, aria-labelledby, or a title.
|
|
119
|
+
const id = i.id;
|
|
120
|
+
const hasFor = id ? !!document.querySelector(`label[for="${CSS.escape(id)}"]`) : false;
|
|
121
|
+
const hasWrap = !!i.closest("label");
|
|
122
|
+
const hasAria = !!(i.getAttribute("aria-label") || i.getAttribute("aria-labelledby") || i.getAttribute("title"));
|
|
123
|
+
if (!hasFor && !hasWrap && !hasAria) {
|
|
124
|
+
inputsNoLabel.push(i.name || i.placeholder || t || "input");
|
|
125
|
+
if (inputsNoLabel.length >= 8)
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const htmlNoLang = !document.documentElement.getAttribute("lang");
|
|
72
130
|
// Horizontal overflow: content wider than the viewport (a classic broken layout).
|
|
73
131
|
const vw = w.innerWidth;
|
|
74
132
|
const docW = document.documentElement.scrollWidth;
|
|
@@ -97,17 +155,28 @@ export async function checkPage(browser, url, opts = {}) {
|
|
|
97
155
|
if (clipped.length >= 8)
|
|
98
156
|
break;
|
|
99
157
|
}
|
|
100
|
-
return {
|
|
158
|
+
return {
|
|
159
|
+
links, brokenImgs, horizontalOverflow, clipped, cls: w.__valCLS || 0,
|
|
160
|
+
imgsNoAlt, buttonsNoName, inputsNoLabel, htmlNoLang,
|
|
161
|
+
};
|
|
101
162
|
});
|
|
102
163
|
links = dom.links;
|
|
103
164
|
for (const src of new Set(dom.brokenImgs))
|
|
104
165
|
findings.push(finding("broken-image", "medium", url, "Image failed to load", src));
|
|
105
166
|
if (dom.horizontalOverflow > 0)
|
|
106
|
-
findings.push(finding("layout-overflow", "medium", url, `Page scrolls horizontally
|
|
167
|
+
findings.push(finding("layout-overflow", "medium", url, `Page scrolls horizontally, content ${Math.round(dom.horizontalOverflow)}px wider than the viewport`));
|
|
107
168
|
for (const t of new Set(dom.clipped))
|
|
108
169
|
findings.push(finding("clipped-text", "low", url, "Text appears clipped by its container", t));
|
|
109
170
|
if (dom.cls > 0.25)
|
|
110
171
|
findings.push(finding("layout-shift", "medium", url, `Janky load: cumulative layout shift ${dom.cls.toFixed(2)} (target < 0.1)`));
|
|
172
|
+
for (const img of new Set(dom.imgsNoAlt))
|
|
173
|
+
findings.push(finding("a11y-img-no-alt", "medium", url, "Image missing alt attribute (screen readers will skip it)", img));
|
|
174
|
+
for (const btn of new Set(dom.buttonsNoName))
|
|
175
|
+
findings.push(finding("a11y-button-no-name", "medium", url, "Button has no accessible name (no text, aria-label, or title)", btn));
|
|
176
|
+
for (const input of new Set(dom.inputsNoLabel))
|
|
177
|
+
findings.push(finding("a11y-input-no-label", "low", url, "Form control has no associated label", input));
|
|
178
|
+
if (dom.htmlNoLang)
|
|
179
|
+
findings.push(finding("a11y-html-no-lang", "low", url, "<html> missing lang attribute"));
|
|
111
180
|
}
|
|
112
181
|
catch (e) {
|
|
113
182
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -122,9 +191,49 @@ export async function checkPage(browser, url, opts = {}) {
|
|
|
122
191
|
continue;
|
|
123
192
|
findings.push(finding("network-error", s >= 500 ? "high" : "medium", url, `Resource returned HTTP ${s}`, u));
|
|
124
193
|
}
|
|
194
|
+
const deviceTag = opts.device || "desktop";
|
|
195
|
+
for (const f of findings)
|
|
196
|
+
f.device = deviceTag;
|
|
125
197
|
await ctx.close();
|
|
126
198
|
return { findings, links };
|
|
127
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* Crawl the site once per device profile, merge findings, dedupe duplicates
|
|
202
|
+
* that appear identically on every device. Findings unique to a specific
|
|
203
|
+
* device keep their `device` tag, telling the agent "this is a mobile-only
|
|
204
|
+
* bug" or "desktop-only".
|
|
205
|
+
*/
|
|
206
|
+
export async function scanSiteMulti(startUrl, opts) {
|
|
207
|
+
if (!opts.devices.length)
|
|
208
|
+
throw new Error("scanSiteMulti needs at least one device");
|
|
209
|
+
const results = await Promise.all(opts.devices.map((d) => scanSite(startUrl, {
|
|
210
|
+
maxPages: opts.maxPages,
|
|
211
|
+
timeoutMs: opts.timeoutMs,
|
|
212
|
+
device: d === "desktop" ? undefined : d,
|
|
213
|
+
})));
|
|
214
|
+
// Dedupe: same page + type + detail + evidence seen on every device
|
|
215
|
+
// gets folded into one finding tagged "all"; otherwise each device keeps its own.
|
|
216
|
+
const key = (f) => `${f.page}::${f.type}::${f.detail}::${f.evidence || ""}`;
|
|
217
|
+
const byKey = new Map();
|
|
218
|
+
for (const r of results)
|
|
219
|
+
for (const f of r.findings) {
|
|
220
|
+
const k = key(f);
|
|
221
|
+
if (!byKey.has(k))
|
|
222
|
+
byKey.set(k, []);
|
|
223
|
+
byKey.get(k).push(f);
|
|
224
|
+
}
|
|
225
|
+
const merged = [];
|
|
226
|
+
for (const group of byKey.values()) {
|
|
227
|
+
if (group.length === results.length) {
|
|
228
|
+
merged.push({ ...group[0], device: "all" });
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
merged.push(...group);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const pagesVisited = Array.from(new Set(results.flatMap((r) => r.pagesVisited)));
|
|
235
|
+
return { findings: merged, pagesVisited };
|
|
236
|
+
}
|
|
128
237
|
export async function scanSite(startUrl, opts = {}) {
|
|
129
238
|
const maxPages = opts.maxPages ?? 12;
|
|
130
239
|
const start = normalize(startUrl);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nyx-intelligence/val-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|