@nyx-intelligence/val-mcp 0.3.0 → 0.4.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 CHANGED
@@ -9,7 +9,9 @@ does the reasoning and the fixing on your own key.
9
9
  ## What it catches
10
10
 
11
11
  Passive (crawl): broken links & 404s, uncaught JS errors, console errors,
12
- failed network requests (4xx/5xx), broken images, missing `<title>`.
12
+ failed network requests (4xx/5xx), broken images, missing `<title>`,
13
+ horizontal layout overflow, clipped text, and janky load (layout shift / CLS).
14
+ Pass a `device` (e.g. `iPhone 14`, `Pixel 7`) to test the **mobile** rendering.
13
15
 
14
16
  Interactive (the agent drives the browser): **dead buttons** (clicks every
15
17
  button, flags the ones with no effect or that throw) and **broken funnels**
package/dist/index.js CHANGED
@@ -23,13 +23,13 @@ function fmtSnapshot(els) {
23
23
  }
24
24
  // ───────────────────────── CLI (testing without an MCP client) ─────────────────────────
25
25
  async function runCli(args) {
26
- const [cmd, url, arg2] = args;
26
+ const [cmd, url, arg2, arg3] = args;
27
27
  if (cmd === "scan" && url) {
28
- const { findings, pagesVisited } = await scanSite(url, { maxPages: Number(arg2) || 12 });
28
+ const { findings, pagesVisited } = await scanSite(url, { maxPages: Number(arg2) || 12, device: arg3 || undefined });
29
29
  console.log(formatReport(findings, pagesVisited));
30
30
  }
31
31
  else if (cmd === "exercise" && url) {
32
- console.log(fmtAction(await session.open(url)));
32
+ console.log(fmtAction(await session.open(url, arg2 || undefined)));
33
33
  const res = await session.exercise();
34
34
  console.log(`\nexercised ${res.tested} button(s): ${res.dead.length} dead, ${res.errored.length} errored`);
35
35
  for (const d of res.dead)
@@ -39,7 +39,7 @@ async function runCli(args) {
39
39
  await session.close();
40
40
  }
41
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)");
42
+ console.error("Usage:\n val-mcp scan <url> [maxPages] [device]\n val-mcp exercise <url> [device]\n val-mcp (start MCP server on stdio)");
43
43
  process.exit(1);
44
44
  }
45
45
  process.exit(0);
@@ -66,25 +66,25 @@ async function runServer() {
66
66
  const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
67
67
  const { chromium } = await import("playwright");
68
68
  const { z } = await import("zod");
69
- const server = new McpServer({ name: "val", version: "0.3.0" });
69
+ const server = new McpServer({ name: "val", version: "0.4.0" });
70
70
  const text = (t) => ({ content: [{ type: "text", text: t }] });
71
71
  // ── Passive crawl ──
72
72
  server.registerTool("val_scan", {
73
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() },
76
- }, async ({ url, maxPages }) => {
77
- const { findings, pagesVisited } = await scanSite(url, { maxPages: maxPages ?? 12 });
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, 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.",
75
+ inputSchema: { url: z.string(), maxPages: z.number().int().min(1).max(50).optional(), device: z.string().optional().describe("Emulate a mobile device, e.g. 'iPhone 14' or 'Pixel 7'") },
76
+ }, async ({ url, maxPages, device }) => {
77
+ const { findings, pagesVisited } = await scanSite(url, { maxPages: maxPages ?? 12, device });
78
78
  return text(`${formatReport(findings, pagesVisited)}\n\nJSON:\n${JSON.stringify(findings)}`);
79
79
  });
80
80
  server.registerTool("val_check", {
81
81
  title: "Re-check one page (verify a fix)",
82
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() },
84
- }, async ({ url }) => {
83
+ inputSchema: { url: z.string(), device: z.string().optional() },
84
+ }, async ({ url, device }) => {
85
85
  const browser = await chromium.launch({ headless: true });
86
86
  try {
87
- const { findings } = await checkPage(browser, url);
87
+ const { findings } = await checkPage(browser, url, { device });
88
88
  return text(`${formatReport(findings, [url])}\n\nJSON:\n${JSON.stringify(findings)}`);
89
89
  }
90
90
  finally {
@@ -95,8 +95,8 @@ async function runServer() {
95
95
  server.registerTool("val_open", {
96
96
  title: "Open a page in the live session",
97
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))));
98
+ inputSchema: { url: z.string(), device: z.string().optional().describe("Emulate a mobile device, e.g. 'iPhone 14'") },
99
+ }, async ({ url, device }) => text(fmtAction(await session.open(url, device))));
100
100
  server.registerTool("val_snapshot", {
101
101
  title: "List the interactive elements on the current page",
102
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.",
@@ -133,9 +133,9 @@ async function runServer() {
133
133
  server.registerTool("val_screenshot", {
134
134
  title: "Screenshot a page",
135
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() },
137
- }, async ({ url, fullPage }) => {
138
- const data = await screenshot(url, fullPage ?? false);
136
+ inputSchema: { url: z.string(), fullPage: z.boolean().optional(), device: z.string().optional() },
137
+ }, async ({ url, fullPage, device }) => {
138
+ const data = await screenshot(url, fullPage ?? false, device);
139
139
  return { content: [{ type: "image", data, mimeType: "image/png" }] };
140
140
  });
141
141
  server.registerTool("val_close", {
package/dist/scanner.js CHANGED
@@ -1,9 +1,8 @@
1
- import { chromium } from "playwright";
1
+ import { chromium, devices } from "playwright";
2
2
  let _seq = 0;
3
3
  function finding(type, severity, page, detail, evidence) {
4
4
  return { id: `f${++_seq}`, type, severity, page, detail, evidence };
5
5
  }
6
- /** Strip the hash and normalize so we don't visit the same page twice. */
7
6
  function normalize(u) {
8
7
  try {
9
8
  const url = new URL(u);
@@ -14,14 +13,21 @@ function normalize(u) {
14
13
  return "";
15
14
  }
16
15
  }
17
- /**
18
- * Load one page in a fresh context, capture browser-level signals
19
- * (HTTP status, uncaught errors, console errors, failed responses,
20
- * broken images, missing title) and the outgoing links.
21
- */
22
- export async function checkPage(browser, url, timeoutMs = 20000) {
23
- const ctx = await browser.newContext();
16
+ /** Build a context, optionally emulating a mobile device (e.g. "iPhone 14", "Pixel 7"). */
17
+ export async function makeContext(browser, device) {
18
+ if (device && devices[device])
19
+ return browser.newContext({ ...devices[device] });
20
+ if (device)
21
+ return browser.newContext({ ...devices["iPhone 14"] }); // unknown name → sensible mobile default
22
+ return browser.newContext();
23
+ }
24
+ // Registered before load so it captures the cumulative layout shift during paint.
25
+ const CLS_INIT = `(() => { try { window.__valCLS = 0; new PerformanceObserver((l) => { for (const e of l.getEntries()) { if (!e.hadRecentInput) window.__valCLS += e.value || 0; } }).observe({ type: "layout-shift", buffered: true }); } catch (e) {} })();`;
26
+ export async function checkPage(browser, url, opts = {}) {
27
+ const timeoutMs = opts.timeoutMs ?? 20000;
28
+ const ctx = await makeContext(browser, opts.device);
24
29
  const page = await ctx.newPage();
30
+ await page.addInitScript(CLS_INIT);
25
31
  const findings = [];
26
32
  const consoleErrors = new Set();
27
33
  const pageErrors = new Set();
@@ -30,8 +36,6 @@ export async function checkPage(browser, url, timeoutMs = 20000) {
30
36
  if (m.type() !== "error")
31
37
  return;
32
38
  const t = m.text();
33
- // Browser-generated "Failed to load resource" lines just duplicate the
34
- // network-error findings, so drop them to keep the report clean.
35
39
  if (/Failed to load resource/i.test(t))
36
40
  return;
37
41
  consoleErrors.add(t.slice(0, 300));
@@ -50,12 +54,12 @@ export async function checkPage(browser, url, timeoutMs = 20000) {
50
54
  const status = resp?.status() ?? 0;
51
55
  if (status >= 400)
52
56
  findings.push(finding("page-status", "high", url, `Page returned HTTP ${status}`, String(status)));
53
- // let late JS / lazy images settle
54
- await page.waitForTimeout(900);
57
+ await page.waitForTimeout(1100);
55
58
  const title = (await page.title()).trim();
56
59
  if (!title)
57
60
  findings.push(finding("missing-title", "low", url, "Page has no <title>"));
58
61
  const dom = await page.evaluate(() => {
62
+ const w = window;
59
63
  const links = Array.from(document.querySelectorAll("a[href]"))
60
64
  .map((a) => a.href)
61
65
  .filter((h) => h.startsWith("http"));
@@ -65,12 +69,45 @@ export async function checkPage(browser, url, timeoutMs = 20000) {
65
69
  return i.complete && i.naturalWidth === 0 && !!(i.currentSrc || i.src);
66
70
  })
67
71
  .map((img) => img.currentSrc || img.src);
68
- return { links, brokenImgs };
72
+ // Horizontal overflow: content wider than the viewport (a classic broken layout).
73
+ const vw = w.innerWidth;
74
+ const docW = document.documentElement.scrollWidth;
75
+ const horizontalOverflow = docW > vw + 2 ? docW - vw : 0;
76
+ // Clipped text: a container hides its own overflowing text (and not on purpose).
77
+ const clipped = [];
78
+ const nodes = Array.from(document.querySelectorAll("*")).slice(0, 4000);
79
+ for (const el of nodes) {
80
+ const e = el;
81
+ if (e.children.length !== 0)
82
+ continue; // leaf text only
83
+ const text = (e.textContent || "").trim();
84
+ if (text.length < 10)
85
+ continue;
86
+ const cs = getComputedStyle(e);
87
+ const hidden = cs.overflow === "hidden" || cs.overflowX === "hidden" || cs.overflowY === "hidden";
88
+ if (!hidden)
89
+ continue;
90
+ if (cs.textOverflow === "ellipsis" || cs.getPropertyValue("-webkit-line-clamp") !== "none")
91
+ continue; // intentional
92
+ if (e.scrollWidth > e.clientWidth + 4 || e.scrollHeight > e.clientHeight + 4) {
93
+ const r = e.getBoundingClientRect();
94
+ if (r.width > 0 && r.height > 0)
95
+ clipped.push(text.slice(0, 50));
96
+ }
97
+ if (clipped.length >= 8)
98
+ break;
99
+ }
100
+ return { links, brokenImgs, horizontalOverflow, clipped, cls: w.__valCLS || 0 };
69
101
  });
70
102
  links = dom.links;
71
- for (const src of new Set(dom.brokenImgs)) {
103
+ for (const src of new Set(dom.brokenImgs))
72
104
  findings.push(finding("broken-image", "medium", url, "Image failed to load", src));
73
- }
105
+ 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`));
107
+ for (const t of new Set(dom.clipped))
108
+ findings.push(finding("clipped-text", "low", url, "Text appears clipped by its container", t));
109
+ if (dom.cls > 0.25)
110
+ findings.push(finding("layout-shift", "medium", url, `Janky load: cumulative layout shift ${dom.cls.toFixed(2)} (target < 0.1)`));
74
111
  }
75
112
  catch (e) {
76
113
  const msg = e instanceof Error ? e.message : String(e);
@@ -82,16 +119,14 @@ export async function checkPage(browser, url, timeoutMs = 20000) {
82
119
  findings.push(finding("console-error", "medium", url, `Console error: ${t}`));
83
120
  for (const [u, s] of netErrors) {
84
121
  if (u === mainUrl)
85
- continue; // the main document status is already reported as page-status
122
+ continue;
86
123
  findings.push(finding("network-error", s >= 500 ? "high" : "medium", url, `Resource returned HTTP ${s}`, u));
87
124
  }
88
125
  await ctx.close();
89
126
  return { findings, links };
90
127
  }
91
- /** BFS-crawl same-origin pages from startUrl and check each one. */
92
128
  export async function scanSite(startUrl, opts = {}) {
93
129
  const maxPages = opts.maxPages ?? 12;
94
- const timeoutMs = opts.timeoutMs ?? 20000;
95
130
  const start = normalize(startUrl);
96
131
  if (!start)
97
132
  throw new Error(`Invalid URL: ${startUrl}`);
@@ -108,7 +143,7 @@ export async function scanSite(startUrl, opts = {}) {
108
143
  if (visited.has(url))
109
144
  continue;
110
145
  visited.add(url);
111
- const res = await checkPage(browser, url, timeoutMs);
146
+ const res = await checkPage(browser, url, { timeoutMs: opts.timeoutMs, device: opts.device });
112
147
  findings.push(...res.findings);
113
148
  pagesVisited.push(url);
114
149
  for (const link of res.links) {
@@ -122,7 +157,7 @@ export async function scanSite(startUrl, opts = {}) {
122
157
  }
123
158
  }
124
159
  catch {
125
- /* ignore unparseable links */
160
+ /* ignore */
126
161
  }
127
162
  }
128
163
  }
@@ -132,11 +167,11 @@ export async function scanSite(startUrl, opts = {}) {
132
167
  }
133
168
  return { findings, pagesVisited };
134
169
  }
135
- /** Screenshot a single page, return base64 PNG. */
136
- export async function screenshot(url, fullPage = false, timeoutMs = 20000) {
170
+ export async function screenshot(url, fullPage = false, device, timeoutMs = 20000) {
137
171
  const browser = await chromium.launch({ headless: true });
138
172
  try {
139
- const page = await browser.newPage();
173
+ const ctx = await makeContext(browser, device);
174
+ const page = await ctx.newPage();
140
175
  await page.goto(url, { waitUntil: "load", timeout: timeoutMs });
141
176
  await page.waitForTimeout(700);
142
177
  const buf = await page.screenshot({ fullPage, type: "png" });
package/dist/session.js CHANGED
@@ -1,10 +1,11 @@
1
- import { chromium } from "playwright";
1
+ import { chromium, devices } from "playwright";
2
2
  function dedupe(a) {
3
3
  return Array.from(new Set(a));
4
4
  }
5
5
  class ValSession {
6
6
  browser;
7
7
  page;
8
+ device;
8
9
  consoleErrors = [];
9
10
  pageErrors = [];
10
11
  netErrors = [];
@@ -12,7 +13,7 @@ class ValSession {
12
13
  if (this.browser && this.page)
13
14
  return this.page;
14
15
  this.browser = await chromium.launch({ headless: true });
15
- const ctx = await this.browser.newContext();
16
+ const ctx = await this.browser.newContext(this.device && devices[this.device] ? { ...devices[this.device] } : this.device ? { ...devices["iPhone 14"] } : {});
16
17
  this.page = await ctx.newPage();
17
18
  this.page.on("console", (m) => {
18
19
  if (m.type() === "error" && !/Failed to load resource/i.test(m.text())) {
@@ -50,7 +51,10 @@ class ValSession {
50
51
  return byRef.first();
51
52
  return page.getByText(refOrText, { exact: false }).first();
52
53
  }
53
- async open(url) {
54
+ async open(url, device) {
55
+ if (device !== this.device && this.browser)
56
+ await this.close();
57
+ this.device = device;
54
58
  const page = await this.ensure();
55
59
  this.drain();
56
60
  let note;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyx-intelligence/val-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",