@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 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.5.5" });
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
- lines.push(`[${f.severity.toUpperCase()}] ${f.type} ${f.page}`);
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 { links, brokenImgs, horizontalOverflow, clipped, cls: w.__valCLS || 0 };
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 content ${Math.round(dom.horizontalOverflow)}px wider than the viewport`));
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.5.5",
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",