@aarwitz/tapp 0.17.21 → 0.17.22

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tapp",
3
3
  "description": "Give Claude hands and eyes on iOS, Android, and web apps, with exploration, replayable flows, evidence, and deterministic CI gates.",
4
- "version": "0.17.21",
4
+ "version": "0.17.22",
5
5
  "author": {
6
6
  "name": "Aaron Horowitz",
7
7
  "url": "https://github.com/aarwitz"
@@ -24,7 +24,7 @@
24
24
  "command": "npx",
25
25
  "args": [
26
26
  "-y",
27
- "@aarwitz/tapp@0.17.21",
27
+ "@aarwitz/tapp@0.17.22",
28
28
  "mcp"
29
29
  ],
30
30
  "cwd": "${CLAUDE_PROJECT_DIR}"
package/AGENTS.md CHANGED
@@ -138,11 +138,17 @@ advisory). Report the finding counts and coverage; do not invent a scalar or a s
138
138
  (e.g. `["--uitesting"]` if the app has a test bypass), and/or `appLaunchEnv` (e.g. a staging
139
139
  backend URL). If the result shows `inputFieldsEncountered` and you have no credentials, **ask
140
140
  the user** for them rather than re-running blind.
141
- - `tapp audit <url>` is the read-only pass: it renders the page and reads the tree but never
142
- clicks, so it is the one analysis safe to point at production. It finds controls that were
143
- never alive — dead in-page anchors, `aria-controls` naming nothing, placeholder links, buttons
144
- with no handler/form/link — which Flows structurally cannot find, because nobody writes a test
145
- for a button they believe does nothing. Exit 1 when it finds any.
141
+ - `tapp audit <url>` / `tapp_audit` is the read-only pass: it renders the page and reads it but never
142
+ clicks, types, submits, or hovers, so it is the one analysis safe to point at production or at a
143
+ site you do not own. It finds controls that were never alive (dead in-page anchors,
144
+ `aria-controls` naming nothing, placeholder links, buttons with no handler/form/link — which Flows
145
+ structurally cannot find, because nobody writes a test for a button they believe does nothing),
146
+ images that failed to load, same-origin assets answering 404, 5xx/failed requests and uncaught JS
147
+ exceptions during load, same-origin links answering 404/410/5xx, mixed content, and a page wider
148
+ than its viewport. `--pages N` crawls same-origin links (robots.txt honoured, `--delay` between
149
+ pages); several URLs or `--urls FILE` batch sites. It writes a capture in the explore layout
150
+ (screenshots, per-finding evidence, `report.html`). Exit 1 when it finds anything. No findings
151
+ means the page is served without structural defects — not that the product works.
146
152
  - Credential surfaces are catalogue-only without credentials, on every platform: with no
147
153
  `testEmail`/`testPassword` supplied, exploration records a login/signup form's fields but does
148
154
  not type into them, submit them, or open recovery/third-party-auth flows — a bare-app run may
package/bin/tapp.js CHANGED
@@ -287,7 +287,7 @@ function safeCommandUsage(verb) {
287
287
  app: "tapp app [repo] [--no-open] [--port PORT]",
288
288
  report: "tapp report [captureId|latest]",
289
289
  feedback: "tapp feedback \"short title\" [--body TEXT|--body-file FILE] [--type bug|idea|question] [--capture ID|latest|none] [--as human] [--submit] [--json]\n Drafts a public GitHub issue on aarwitz/tapp about tapp itself; --submit files it with the authenticated gh CLI.\n Exit codes: 0 drafted or filed · 1 gh submission failed · 2 usage error",
290
- audit: "tapp audit <url> [--json] [--device NAME] [--viewport WxH]\n Read-only: renders the page and reads the tree, never clicks — safe to point at production.\n Exit codes: 0 no dead controls · 1 dead controls found · 2 usage/infrastructure",
290
+ audit: "tapp audit <url> [<url>...] [--urls FILE] [--pages N] [--delay MS] [--links N] [--json [FILE]] [--no-capture] [--device NAME] [--viewport WxH]\n Read-only: renders each page and reads it, never clicks — safe to point at production or a site you do not own.\n Finds dead controls, broken images/assets/links, 5xx/failed requests, JS exceptions, mixed content, horizontal overflow.\n --pages N crawls same-origin links (robots.txt honoured, --delay between pages). Writes a capture with screenshots, evidence and report.html.\n Exit codes: 0 nothing broken · 1 defects found · 2 usage/infrastructure",
291
291
  doctor: "tapp doctor [--json]\n Exit codes: 0 environment healthy · 1 blocked (fix ❌ items)",
292
292
  install: "tapp install",
293
293
  mcp: "tapp mcp",
@@ -1513,30 +1513,71 @@ switch (command) {
1513
1513
 
1514
1514
  case "audit": {
1515
1515
  const { flags, positionals } = parseVerbArgs(rest);
1516
- const url = positionals[0] || "";
1517
- if (!/^https?:\/\//i.test(url)) {
1518
- console.error("usage: tapp audit <http(s) url> [--json] [--device NAME] [--viewport WxH]\n Read-only structural audit: never clicks, safe against production.");
1516
+ const urls = [...positionals];
1517
+ if (typeof flags.urls === "string") {
1518
+ try {
1519
+ for (const line of fs.readFileSync(flags.urls, "utf8").split(/\r?\n/)) {
1520
+ const trimmed = line.trim();
1521
+ if (trimmed && !trimmed.startsWith("#")) urls.push(trimmed);
1522
+ }
1523
+ } catch (error) {
1524
+ console.error(`❌ Could not read --urls ${flags.urls}: ${error.message}`);
1525
+ process.exit(2);
1526
+ }
1527
+ }
1528
+ if (!urls.length || urls.some((u) => !/^https?:\/\//i.test(u))) {
1529
+ console.error(`usage: ${USAGE.audit}`);
1519
1530
  process.exit(2);
1520
1531
  }
1532
+ const jsonMode = flags.json === true || typeof flags.json === "string";
1533
+ const say = (line) => { if (!jsonMode || typeof flags.json === "string") console.log(line); };
1521
1534
  try {
1522
- const { auditWebPage } = await import(path.join(packageRoot, "mcp-server", "src", "web-explorer.js"));
1523
- const result = await auditWebPage({
1524
- url,
1535
+ const { auditWebSite } = await import(path.join(packageRoot, "mcp-server", "src", "web-explorer.js"));
1536
+ const { auditCaptureId } = await import(path.join(packageRoot, "mcp-server", "src", "web-audit.js"));
1537
+ const capturesDir = flags["no-capture"] === true ? "" : path.join(tappHome, "captures");
1538
+ const common = {
1539
+ pages: Math.max(1, Number(flags.pages) || 1),
1540
+ delayMs: flags.delay !== undefined ? Math.max(0, Number(flags.delay) || 0) : undefined,
1541
+ linkCheckLimit: flags.links !== undefined ? Math.max(0, Number(flags.links) || 0) : undefined,
1525
1542
  device: typeof flags.device === "string" ? flags.device : "",
1526
1543
  viewport: typeof flags.viewport === "string" ? flags.viewport : "",
1527
- });
1528
- if (flags.json === true) {
1529
- console.log(JSON.stringify({ kind: "tapp-structural-audit", readOnly: true, ...result }, null, 2));
1530
- } else {
1531
- console.log(`🔎 Structural audit — ${result.url}\n ${result.controlsExamined} control(s) examined · nothing was clicked`);
1532
- if (!result.findings.length) console.log("\n✅ No structurally dead controls found.");
1533
- else {
1534
- console.log("");
1535
- for (const finding of result.findings) console.log(` ⚠️ ${finding.title}`);
1536
- console.log(`\n${result.findings.length} dead control(s). These are controls no Flow would cover — nobody writes a test for a button they believe does nothing.`);
1544
+ capturesDir,
1545
+ };
1546
+ const sites = [];
1547
+ for (const [index, url] of urls.entries()) {
1548
+ say(`🔎 Read-only audit — ${url}${common.pages > 1 ? ` (up to ${common.pages} pages)` : ""}`);
1549
+ let site;
1550
+ try {
1551
+ site = await auditWebSite({ ...common, url, captureId: capturesDir ? `${auditCaptureId()}${urls.length > 1 ? `-${index + 1}` : ""}` : "",
1552
+ onPage: (page, n) => say(` ${n}. ${page.url} — ${page.controlsExamined} control(s), ${page.linksChecked.checked} link(s) checked, ${page.findings.length} finding(s)`) });
1553
+ } catch (error) {
1554
+ if (urls.length === 1) throw error;
1555
+ site = { kind: "tapp-structural-audit", readOnly: true, url, error: error.message || String(error), pagesAudited: 0, findingCounts: { critical: 0, high: 0, medium: 0, low: 0, total: 0 }, pages: [], capture: null };
1556
+ say(` ❌ ${site.error}`);
1537
1557
  }
1558
+ sites.push(site);
1559
+ if (!jsonMode) {
1560
+ const findings = site.pages.flatMap((p) => p.findings);
1561
+ if (!site.error && !findings.length) console.log(" ✅ Nothing broken observed. Nothing was clicked — this is not a pass on behaviour.");
1562
+ for (const finding of findings) console.log(` ${finding.severity === "high" || finding.severity === "critical" ? "🔴" : finding.severity === "medium" ? "🟠" : "🟡"} [${finding.type}] ${finding.title}`);
1563
+ if (site.robotsBlocked?.length) console.log(` robots.txt kept ${site.robotsBlocked.length} page(s) out of the crawl`);
1564
+ if (site.capture) console.log(` 📁 ${site.capture.path}\n 📄 ${site.capture.report}`);
1565
+ }
1566
+ }
1567
+ const totalFindings = sites.reduce((n, site) => n + site.findingCounts.total, 0);
1568
+ const payload = urls.length === 1 ? sites[0] : {
1569
+ kind: "tapp-structural-audit", readOnly: true, sites,
1570
+ totals: { sites: sites.length, pagesAudited: sites.reduce((n, site) => n + site.pagesAudited, 0), findings: totalFindings, failed: sites.filter((site) => site.error).length },
1571
+ };
1572
+ if (typeof flags.json === "string") {
1573
+ fs.writeFileSync(flags.json, JSON.stringify(payload, null, 2));
1574
+ console.log(`\n📄 Audit JSON: ${flags.json}`);
1575
+ } else if (flags.json === true) {
1576
+ console.log(JSON.stringify(payload, null, 2));
1577
+ } else if (urls.length > 1) {
1578
+ console.log(`\n${totalFindings} finding(s) across ${sites.length} site(s).`);
1538
1579
  }
1539
- process.exit(result.findings.length ? 1 : 0);
1580
+ process.exit(totalFindings ? 1 : sites.some((site) => site.error) ? 2 : 0);
1540
1581
  } catch (error) {
1541
1582
  console.error(`❌ ${error.message || String(error)}`);
1542
1583
  process.exit(2);
@@ -2046,9 +2087,10 @@ Primitives — an agent's eyes and hands:
2046
2087
  (web: --tap TEXT · --wait-for TEXT · --out FILE)
2047
2088
  tapp tree [target] Accessibility tree of the current screen (--json for every element)
2048
2089
  (web: --tap TEXT · --wait-for TEXT)
2049
- tapp audit <url> Read-only structural audit — dead anchors, placeholder links, buttons
2050
- with nothing behind them. Never clicks, so it is safe against
2051
- production (exit 1 when dead controls are found)
2090
+ tapp audit <url>... Read-only audit — dead controls, broken images/links/assets, failed
2091
+ requests, JS exceptions, mixed content, overflow. Never clicks, so it
2092
+ is safe against production or a prospect's site (exit 1 on defects)
2093
+ (--pages N crawls same-origin · --urls FILE batches · --json [FILE])
2052
2094
 
2053
2095
  Repository & release:
2054
2096
  tapp init [repo] Detect targets and write the application model + reviewable release plan
@@ -2809,6 +2809,34 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2809
2809
  },
2810
2810
  },
2811
2811
  },
2812
+ {
2813
+ name: "tapp_audit",
2814
+ title: "Audit (read-only)",
2815
+ description:
2816
+ "Read-only audit of a web page or site: renders it and reads it, NEVER clicks, types, submits or hovers — " +
2817
+ "the one analysis safe to point at production or at a site you do not own. Finds controls that were never " +
2818
+ "alive (dead in-page anchors, placeholder links, aria-controls naming nothing, buttons with no handler/form/link), " +
2819
+ "images that failed to load, same-origin assets answering 404, 5xx/failed requests and uncaught JS exceptions " +
2820
+ "during load, same-origin links answering 404/410/5xx, mixed content, and a page wider than its viewport. " +
2821
+ "pages > 1 crawls same-origin links breadth-first with robots.txt honoured. Writes a capture (screenshots, " +
2822
+ "per-finding evidence, report.html) like an explore run. Returns {kind:'tapp-structural-audit', readOnly:true, " +
2823
+ "pagesAudited, findingCounts, pages:[{url,title,controlsExamined,linksChecked,findings}], checkedFor, notChecked, capture}. " +
2824
+ "An observation, never a verdict; absence of findings says the page is served without structural defects, not that the product works.",
2825
+ inputSchema: {
2826
+ type: "object",
2827
+ properties: {
2828
+ authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
2829
+ url: { type: "string", description: "http(s) URL of the page to audit (the crawl start page when pages > 1)" },
2830
+ pages: { type: "integer", default: 1, minimum: 1, maximum: 200, description: "How many same-origin pages to audit at most (1 = this page only)" },
2831
+ delayMs: { type: "integer", default: 1000, minimum: 0, description: "Pause between pages when crawling — be polite to sites you do not own" },
2832
+ linkCheckLimit: { type: "integer", default: 30, minimum: 0, description: "Same-origin links to HEAD-check per page" },
2833
+ device: { type: "string", description: "Playwright device profile, e.g. 'iPhone 13' — audit the mobile rendering" },
2834
+ viewport: { type: "string", description: "WIDTHxHEIGHT viewport override" },
2835
+ capture: { type: "boolean", default: true, description: "Write a capture directory (screenshots, evidence, report.html)" },
2836
+ },
2837
+ required: ["url"],
2838
+ },
2839
+ },
2812
2840
  {
2813
2841
  name: "tapp_init",
2814
2842
  title: "Inspect or explore a repository and create the Tapp application model and release plan",
@@ -3609,6 +3637,40 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3609
3637
  return richResult(L.join("\n"), summary);
3610
3638
  }
3611
3639
 
3640
+ if (name === "tapp_audit") {
3641
+ const unauthorized = ensureAuthorized(args);
3642
+ if (unauthorized) return unauthorized;
3643
+ if (!isNonEmptyString(args.url)) return errorResult("url is required");
3644
+ try {
3645
+ const { auditWebSite } = await import("./web-audit.js");
3646
+ const site = await auditWebSite({
3647
+ url: args.url,
3648
+ pages: asInteger(args.pages, 1),
3649
+ delayMs: asInteger(args.delayMs, 1000),
3650
+ linkCheckLimit: asInteger(args.linkCheckLimit, 30),
3651
+ device: isNonEmptyString(args.device) ? args.device : "",
3652
+ viewport: isNonEmptyString(args.viewport) ? args.viewport : "",
3653
+ capturesDir: args.capture === false ? "" : capturesDir,
3654
+ });
3655
+ const findings = site.pages.flatMap((p) => p.findings);
3656
+ const c = site.findingCounts;
3657
+ const lines = [
3658
+ `🔎 Read-only audit of ${site.url} — ${site.pagesAudited} page(s), nothing clicked`,
3659
+ findings.length
3660
+ ? `${findings.length} defect(s): ${c.critical} critical · ${c.high} high · ${c.medium} medium · ${c.low} low`
3661
+ : "✅ Nothing broken observed. Not a pass on behaviour — nothing was exercised.",
3662
+ ...findings.slice(0, 25).map((f) => ` ${SEV[f.severity] || "·"} [${f.type}] ${f.title}${f.screen ? ` — on ${f.screen}` : ""}`),
3663
+ ...(findings.length > 25 ? [` … ${findings.length - 25} more in structuredContent`] : []),
3664
+ ...(site.robotsBlocked?.length ? [`robots.txt kept ${site.robotsBlocked.length} page(s) out of the crawl`] : []),
3665
+ ...(site.capture ? [`📁 ${site.capture.path}`, `📄 ${site.capture.report}`] : []),
3666
+ "Next: to exercise the controls (clicks, forms) run tapp_explore — only on an environment you own.",
3667
+ ];
3668
+ return richResult(lines.join("\n"), site);
3669
+ } catch (error) {
3670
+ return errorResult(error.message || String(error));
3671
+ }
3672
+ }
3673
+
3612
3674
  if (name === "tapp_explore" || name === "tapp_run_qa") { // tapp_run_qa: deprecated alias
3613
3675
  const unauthorized = ensureAuthorized(args);
3614
3676
  if (unauthorized) return unauthorized;
@@ -0,0 +1,423 @@
1
+ // Read-only web audit: render a page, read it, never click it.
2
+ //
3
+ // This is the one analysis safe to point at a site you do not own, which is what makes it the
4
+ // primitive behind the outreach motion (find a prospect's broken site, show them proof). Everything
5
+ // here is an observation of a page as served: structurally dead controls, assets that failed to
6
+ // load, links that answer 404, mixed content, a layout wider than its viewport. No form is
7
+ // submitted, no button pressed, no navigation beyond the pages the caller asked for.
8
+ //
9
+ // The capture it writes uses the same layout as an explore run (state_*.png at the root,
10
+ // ocqa-markers.txt, report.html) so every existing consumer of a capture — `tapp report`, the
11
+ // Studio, Clien's evidence videos — reads an audit without a second code path.
12
+ import fs from "fs";
13
+ import path from "path";
14
+ import { createRequire } from "module";
15
+ import {
16
+ NAV_TIMEOUT_MS, loadPlaywright, webBrowserLaunchOptions, webContextOptions, installWebListenerTracking,
17
+ waitForWebStability, captureElementEvidence, shouldReportWebRequestFailure, auditStructuralControls,
18
+ auditFindingsFromControls, slug,
19
+ } from "./web-explorer.js";
20
+ import { buildQaReport } from "./report.js";
21
+ import { writeHtmlReport } from "./html-report.js";
22
+ import { buildUiMapFromMarkers, writeUiMap } from "./ui-map.js";
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const VERSION = (() => { try { return require("../../package.json").version; } catch { return "0.0.0"; } })();
26
+
27
+ export const AUDIT_LINK_CHECK_LIMIT = 30;
28
+ export const AUDIT_CRAWL_DELAY_MS = 1000;
29
+ const LINK_CHECK_CONCURRENCY = 4;
30
+ const LINK_CHECK_TIMEOUT_MS = 10_000;
31
+
32
+ // What an audit does and does not look at. Spelled out so a report never borrows the exploration
33
+ // run's "checked for" list and claims clicks that never happened.
34
+ export function auditScope({ linksChecked = { total: 0, checked: 0 }, pages = 1 } = {}) {
35
+ const checkedFor = [
36
+ "controls that were never alive: dead in-page anchors, placeholder links, aria-controls naming nothing, buttons with no handler/form/link",
37
+ "images that failed to load",
38
+ "same-origin assets answering 404 and requests answering 5xx or failing during page load",
39
+ "uncaught JavaScript exceptions during page load",
40
+ "mixed content (http:// resources on an https:// page)",
41
+ "page wider than its viewport (horizontal scrolling) and a missing viewport meta tag",
42
+ ];
43
+ if (linksChecked.total) {
44
+ checkedFor.push(linksChecked.total > linksChecked.checked
45
+ ? `same-origin links answering 404/410/5xx (first ${linksChecked.checked} of ${linksChecked.total}, HEAD then GET)`
46
+ : "same-origin links answering 404/410/5xx (HEAD then GET)");
47
+ }
48
+ const notChecked = [
49
+ "anything that needs a click: nothing was pressed, typed, submitted, or hovered (run `tapp explore` on an environment you own for that)",
50
+ "off-site links (only same-origin links are fetched; outbound reachability is an explore check)",
51
+ "app-specific business logic, content accuracy, privacy, brand, visual quality",
52
+ pages > 1 ? `pages beyond the ${pages} audited` : "pages other than the one audited (pass --pages N to crawl same-origin links)",
53
+ ];
54
+ return { checkedFor, notChecked };
55
+ }
56
+
57
+ // robots.txt, the polite minimum: honour Disallow for `*` and for our own agent, longest match wins,
58
+ // Allow beats Disallow at equal length. Unparseable or unreachable → everything allowed.
59
+ export function parseRobots(text = "") {
60
+ const groups = [];
61
+ let current = null;
62
+ for (const raw of String(text).split(/\r?\n/)) {
63
+ const line = raw.replace(/#.*$/, "").trim();
64
+ if (!line) continue;
65
+ const m = line.match(/^([a-z-]+)\s*:\s*(.*)$/i);
66
+ if (!m) continue;
67
+ const field = m[1].toLowerCase();
68
+ const value = m[2].trim();
69
+ if (field === "user-agent") {
70
+ if (!current || current.rules.length) { current = { agents: [], rules: [] }; groups.push(current); }
71
+ current.agents.push(value.toLowerCase());
72
+ } else if ((field === "disallow" || field === "allow") && current) {
73
+ current.rules.push({ allow: field === "allow", prefix: value });
74
+ }
75
+ }
76
+ return groups;
77
+ }
78
+
79
+ export function robotsAllows(groups, pathname, agent = "tapp") {
80
+ const mine = groups.filter((g) => g.agents.some((a) => a === agent || agent.startsWith(a)));
81
+ const applicable = mine.length ? mine : groups.filter((g) => g.agents.includes("*"));
82
+ let best = null;
83
+ for (const group of applicable) {
84
+ for (const rule of group.rules) {
85
+ if (!rule.prefix) { if (!rule.allow && !best) best = { allow: true, len: 0 }; continue; }
86
+ const re = new RegExp("^" + rule.prefix.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*").replace(/\\\$$/, "$"));
87
+ if (re.test(pathname) && (!best || rule.prefix.length > best.len || (rule.prefix.length === best.len && rule.allow))) {
88
+ best = { allow: rule.allow, len: rule.prefix.length };
89
+ }
90
+ }
91
+ }
92
+ return best ? best.allow : true;
93
+ }
94
+
95
+ // Page-level health, read from the DOM after load. Flagged elements are tagged like the structural
96
+ // scan does so the evidence shot is of the element the finding names.
97
+ async function auditPageHealth(page) {
98
+ return page.evaluate(() => {
99
+ const findings = [];
100
+ let n = 0;
101
+ const tag = (el) => { n += 1; el.setAttribute("data-tapp-audit-health", String(n)); return `[data-tapp-audit-health="${n}"]`; };
102
+ const visible = (el) => el.getClientRects().length > 0;
103
+ for (const img of document.images) {
104
+ const src = img.getAttribute("src") || "";
105
+ if (!src || src.startsWith("data:")) continue;
106
+ if (img.complete && img.naturalWidth === 0) {
107
+ findings.push({ kind: "broken_image", src: src.slice(0, 160), alt: (img.getAttribute("alt") || "").slice(0, 80), selector: visible(img) ? tag(img) : null });
108
+ }
109
+ }
110
+ if (location.protocol === "https:") {
111
+ for (const el of document.querySelectorAll("img[src], script[src], iframe[src], video[src], audio[src], source[src], link[rel~=stylesheet][href]")) {
112
+ const value = el.getAttribute("src") || el.getAttribute("href") || "";
113
+ if (/^http:\/\//i.test(value)) findings.push({ kind: "mixed_content", tagName: el.tagName.toLowerCase(), src: value.slice(0, 160), selector: visible(el) ? tag(el) : null });
114
+ }
115
+ }
116
+ const doc = document.documentElement;
117
+ const overflow = Math.max(doc.scrollWidth, document.body ? document.body.scrollWidth : 0) - window.innerWidth;
118
+ if (overflow > 2) findings.push({ kind: "horizontal_overflow", overflow, viewport: window.innerWidth });
119
+ if (!document.querySelector('meta[name="viewport"]')) findings.push({ kind: "no_viewport_meta" });
120
+ const links = [];
121
+ for (const a of document.querySelectorAll("a[href]")) {
122
+ const href = a.getAttribute("href") || "";
123
+ if (!href || href.startsWith("#") || /^(mailto|tel|javascript|sms):/i.test(href)) continue;
124
+ let abs;
125
+ try { abs = new URL(href, location.href); } catch { continue; }
126
+ if (!/^https?:$/.test(abs.protocol)) continue;
127
+ abs.hash = "";
128
+ links.push({ href: abs.href, label: (a.getAttribute("aria-label") || a.textContent || "").replace(/\s+/g, " ").trim().slice(0, 80), sameOrigin: abs.origin === location.origin });
129
+ }
130
+ return { findings, links };
131
+ });
132
+ }
133
+
134
+ function healthFindings(raw = []) {
135
+ const out = [];
136
+ for (const h of raw) {
137
+ if (h.kind === "broken_image") {
138
+ out.push({ type: "broken_image", severity: "medium", title: `Image failed to load: ${h.src}${h.alt ? ` (alt “${h.alt}”)` : ""}`, target: h.src, selector: h.selector });
139
+ } else if (h.kind === "mixed_content") {
140
+ out.push({ type: "mixed_content", severity: "medium", title: `Mixed content: <${h.tagName}> loads ${h.src} over plain http on an https page (browsers block or warn)`, target: h.src, selector: h.selector });
141
+ } else if (h.kind === "horizontal_overflow") {
142
+ out.push({ type: "horizontal_overflow", severity: "low", title: `Page is ${h.overflow}px wider than the ${h.viewport}px viewport — it scrolls sideways on this screen size`, target: `overflow:${h.overflow}` });
143
+ } else if (h.kind === "no_viewport_meta") {
144
+ out.push({ type: "no_viewport_meta", severity: "low", title: "No <meta name=\"viewport\"> — phones render the page zoomed out at desktop width", target: "meta[name=viewport]" });
145
+ }
146
+ }
147
+ return out;
148
+ }
149
+
150
+ // HEAD each same-origin link (GET when HEAD is refused). A link the server cannot answer is
151
+ // reported low — a WAF or rate limit answers that way too — while 404/410 and 5xx are the server's
152
+ // own word.
153
+ async function checkLinks(context, links, { limit, pageUrl }) {
154
+ const unique = new Map();
155
+ for (const link of links) {
156
+ if (!link.sameOrigin || link.href === pageUrl) continue;
157
+ if (!unique.has(link.href)) unique.set(link.href, link.label);
158
+ }
159
+ const targets = [...unique.entries()].slice(0, Math.max(0, limit));
160
+ const findings = [];
161
+ let cursor = 0;
162
+ const worker = async () => {
163
+ while (cursor < targets.length) {
164
+ const [href, label] = targets[cursor++];
165
+ const where = label ? `“${label}”` : href;
166
+ const pathOf = (() => { try { return new URL(href).pathname; } catch { return href; } })();
167
+ try {
168
+ let res = await context.request.fetch(href, { method: "HEAD", maxRedirects: 5, timeout: LINK_CHECK_TIMEOUT_MS, failOnStatusCode: false });
169
+ if ([405, 501, 403].includes(res.status())) {
170
+ res = await context.request.fetch(href, { method: "GET", maxRedirects: 5, timeout: LINK_CHECK_TIMEOUT_MS, failOnStatusCode: false });
171
+ }
172
+ const status = res.status();
173
+ if (status === 404 || status === 410) findings.push({ type: "broken_link", severity: "medium", title: `Link ${where} → ${pathOf} answers ${status}`, target: href, url: href });
174
+ else if (status >= 500) findings.push({ type: "broken_link", severity: "high", title: `Link ${where} → ${pathOf} answers ${status}`, target: href, url: href });
175
+ } catch (error) {
176
+ findings.push({ type: "broken_link", severity: "low", title: `Link ${where} → ${pathOf} could not be fetched (${String(error?.message || error).split("\n")[0].slice(0, 80)})`, target: href, url: href });
177
+ }
178
+ }
179
+ };
180
+ await Promise.all(Array.from({ length: Math.min(LINK_CHECK_CONCURRENCY, targets.length) }, worker));
181
+ findings.sort((a, b) => String(a.target).localeCompare(String(b.target)));
182
+ return { findings, total: unique.size, checked: targets.length };
183
+ }
184
+
185
+ // One page, inside an already-open context. Collects everything, then (if a capture is open) shoots
186
+ // the page and each named element and appends the markers.
187
+ async function auditOnePage(context, target, { timeoutMs, linkCheckLimit, capture }) {
188
+ const page = await context.newPage();
189
+ const bounded = Math.max(1000, Math.min(60_000, Number(timeoutMs) || NAV_TIMEOUT_MS));
190
+ page.setDefaultTimeout(bounded);
191
+ const origin = target.origin;
192
+ const loadFindings = [];
193
+ const seenLoad = new Set();
194
+ const loadIssue = (type, severity, title, key) => {
195
+ if (seenLoad.has(key)) return;
196
+ seenLoad.add(key);
197
+ loadFindings.push({ type, severity, title, target: key });
198
+ };
199
+ page.on("pageerror", (err) => loadIssue("js_exception", "high", `Uncaught JS exception: ${String(err.message || err).slice(0, 120)}`, `js:${String(err.message || err).slice(0, 120)}`));
200
+ page.on("response", (res) => {
201
+ try {
202
+ const u = new URL(res.url());
203
+ if (u.origin !== origin) return;
204
+ if (res.status() >= 500) loadIssue("network_error", "high", `${res.status()} from ${u.pathname.slice(0, 80)}`, u.pathname);
205
+ else if (res.status() === 404 && res.request().resourceType() !== "document") loadIssue("missing_asset", "medium", `404 asset: ${u.pathname.slice(0, 80)}`, u.pathname);
206
+ } catch { /* unparseable url */ }
207
+ });
208
+ page.on("requestfailed", (req) => {
209
+ try {
210
+ const u = new URL(req.url());
211
+ if (u.origin !== origin) return;
212
+ const errorText = req.failure()?.errorText || "?";
213
+ if (!shouldReportWebRequestFailure(errorText)) return;
214
+ loadIssue("network_error", "medium", `Request failed: ${u.pathname.slice(0, 80)} (${errorText})`, u.pathname);
215
+ } catch { /* unparseable url */ }
216
+ });
217
+
218
+ try {
219
+ const response = await page.goto(target.href, { waitUntil: "domcontentloaded", timeout: bounded });
220
+ if (response && response.status() >= 400) throw new Error(`Could not open ${target.href}: HTTP ${response.status()}`);
221
+ await waitForWebStability(page, { timeoutMs: Math.min(5_000, bounded) });
222
+
223
+ const controls = await auditStructuralControls(page);
224
+ const health = await auditPageHealth(page);
225
+ const links = await checkLinks(context, health.links, { limit: linkCheckLimit, pageUrl: page.url() });
226
+
227
+ const structural = auditFindingsFromControls(controls.dead).map((f, i) => ({ ...f, selector: controls.dead[i].selector }));
228
+ const findings = [...structural, ...healthFindings(health.findings), ...loadFindings, ...links.findings];
229
+ const screen = target.pathname + target.search;
230
+ for (const f of findings) { f.screen = screen; if (!f.url) f.url = page.url(); }
231
+
232
+ if (capture) {
233
+ capture.states += 1;
234
+ const stateName = `state_${capture.states}_${slug(target.pathname === "/" ? (controls.title || "home") : target.pathname)}.png`;
235
+ await page.screenshot({ path: path.join(capture.dir, stateName), fullPage: true }).catch(() => page.screenshot({ path: path.join(capture.dir, stateName) }).catch(() => {}));
236
+ capture.emit("STATE", { screen, url: page.url(), elements: controls.controlCount, role: "page", controls: controls.dead.map((d) => d.label).filter(Boolean).slice(0, 40), inputs: [], settled: true, screenshot: stateName });
237
+ for (const f of findings) {
238
+ if (f.selector) {
239
+ capture.evidence += 1;
240
+ const name = `evidence_${capture.evidence}_${slug(f.type)}.png`;
241
+ const shot = await captureElementEvidence(page, page.locator(f.selector), capture.dir, name);
242
+ if (shot) f.evidence = shot;
243
+ }
244
+ capture.emit("ISSUE", { type: f.type, severity: f.severity, title: f.title, screen, ...(f.target ? { target: f.target } : {}), url: f.url, ...(f.evidence ? { evidence: f.evidence } : {}) });
245
+ }
246
+ }
247
+ for (const f of findings) delete f.selector;
248
+
249
+ return {
250
+ url: page.url(),
251
+ title: controls.title,
252
+ controlsExamined: controls.controlCount,
253
+ linksChecked: { total: links.total, checked: links.checked },
254
+ findings,
255
+ links: health.links,
256
+ };
257
+ } finally {
258
+ await page.close().catch(() => {});
259
+ }
260
+ }
261
+
262
+ function openCapture(capturesDir, id) {
263
+ const dir = path.join(capturesDir, id);
264
+ fs.mkdirSync(dir, { recursive: true });
265
+ const markersFd = fs.openSync(path.join(dir, "ocqa-markers.txt"), "w");
266
+ return {
267
+ id, dir, states: 0, evidence: 0, markersFd,
268
+ emit(kind, payload) { fs.writeSync(markersFd, `OCQA_${kind}:${JSON.stringify(payload)}\n`); },
269
+ close() { try { fs.closeSync(markersFd); } catch { /* already closed */ } },
270
+ };
271
+ }
272
+
273
+ export function auditCaptureId(now = new Date()) {
274
+ const p = (n) => String(n).padStart(2, "0");
275
+ return `web-audit-${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
276
+ }
277
+
278
+ function finalizeCapture(capture, { startUrl, pages, findings, robotsBlocked, linksChecked }) {
279
+ capture.emit("COMPLETE", { actions: 0, screens: pages.length, credentialsProvided: false, credentialsUsed: false, timedOut: false, stop: "audit-complete", readOnly: true, robotsBlocked });
280
+ capture.close();
281
+ const markers = path.join(capture.dir, "ocqa-markers.txt");
282
+ const report = buildQaReport(markers, { platform: "web", target: startUrl });
283
+ if (!report) return null;
284
+ // The exploration report's coverage floor and wording assume clicks; an audit has none by design.
285
+ const scope = auditScope({ linksChecked, pages: pages.length });
286
+ const counts = report.deterministicFindingCounts || report.findingCounts || { critical: 0, high: 0, medium: 0, low: 0 };
287
+ Object.assign(report, {
288
+ kind: "tapp-structural-audit",
289
+ readOnly: true,
290
+ inconclusive: false,
291
+ runStatus: "completed",
292
+ stopReason: "audit-complete",
293
+ headline: findings.length === 0
294
+ ? `Read-only audit of ${pages.length} page(s) — nothing broken was observed. Nothing was clicked; this says the page is served without structural defects, not that the product works.`
295
+ : `${findings.length} defect(s) observed on ${pages.length} page(s) without clicking anything (${counts.critical} critical, ${counts.high} high, ${counts.medium} medium, ${counts.low} low).`,
296
+ checkedFor: scope.checkedFor,
297
+ notChecked: scope.notChecked,
298
+ conditionsNotReached: [],
299
+ });
300
+ fs.writeFileSync(path.join(capture.dir, "report.json"), JSON.stringify(report, null, 2));
301
+ // The capture-local UI map is what ties a page screenshot to the findings on that page for
302
+ // every consumer that reads explore captures (the Studio, Clien's evidence slides).
303
+ try { writeUiMap(path.join(capture.dir, "ui-map.json"), buildUiMapFromMarkers({ markersPath: markers, platform: "web", target: startUrl, runId: capture.id })); }
304
+ catch { /* the map is a convenience; the report and markers are the evidence */ }
305
+ writeHtmlReport(capture.dir, { report, label: "Read-only audit" });
306
+ return report;
307
+ }
308
+
309
+ // Audit a site: the start page and, with pages > 1, same-origin links discovered on audited pages
310
+ // (breadth-first, robots.txt honoured, one request at a time with a pause between pages).
311
+ export async function auditWebSite({
312
+ url, pages = 1, timeoutMs = NAV_TIMEOUT_MS, device = "", viewport = "",
313
+ linkCheckLimit = AUDIT_LINK_CHECK_LIMIT, delayMs = AUDIT_CRAWL_DELAY_MS,
314
+ capturesDir = "", captureId = "", onPage = null,
315
+ }) {
316
+ let start;
317
+ try { start = new URL(url); }
318
+ catch { throw new Error("Audit needs a valid http(s) URL"); }
319
+ if (!/^https?:$/.test(start.protocol)) throw new Error("Audit needs a valid http(s) URL");
320
+ const maxPages = Math.max(1, Math.min(200, Number(pages) || 1));
321
+
322
+ const { chromium, devices } = await loadPlaywright();
323
+ const browser = await chromium.launch(webBrowserLaunchOptions(process.env, {}));
324
+ const capture = capturesDir ? openCapture(capturesDir, captureId || auditCaptureId()) : null;
325
+ try {
326
+ const contextOptions = webContextOptions({ device, viewport, devices });
327
+ // Identify ourselves to sites we do not own; the default UA is kept so rendering is unchanged.
328
+ const probe = await browser.newContext(contextOptions);
329
+ const defaultUa = contextOptions.userAgent || await (await probe.newPage()).evaluate(() => navigator.userAgent).catch(() => "");
330
+ await probe.close();
331
+ const context = await browser.newContext({ ...contextOptions, userAgent: `${defaultUa} tapp-audit/${VERSION} (+https://runtapp.com)`.trim() });
332
+ await installWebListenerTracking(context);
333
+ if (capture) capture.emit("CONTEXT", { ...(String(device || "").trim() ? { device: String(device).trim() } : {}), viewport: contextOptions.viewport, deviceScaleFactor: contextOptions.deviceScaleFactor ?? 1, mode: "audit", readOnly: true });
334
+
335
+ let robots = [];
336
+ if (maxPages > 1) {
337
+ try {
338
+ const res = await context.request.get(new URL("/robots.txt", start).href, { timeout: 5_000, failOnStatusCode: false });
339
+ if (res.ok()) robots = parseRobots(await res.text());
340
+ } catch { /* unreachable robots.txt: everything allowed */ }
341
+ }
342
+
343
+ const results = [];
344
+ const robotsBlocked = [];
345
+ const queued = new Set([start.href]);
346
+ const queue = [start];
347
+ let linksTotal = 0;
348
+ let linksChecked = 0;
349
+ while (queue.length && results.length < maxPages) {
350
+ const target = queue.shift();
351
+ if (results.length > 0) {
352
+ if (!robotsAllows(robots, target.pathname)) { robotsBlocked.push(target.href); continue; }
353
+ if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
354
+ }
355
+ let result;
356
+ try {
357
+ result = await auditOnePage(context, target, { timeoutMs, linkCheckLimit, capture });
358
+ } catch (error) {
359
+ if (results.length === 0) throw error;
360
+ result = { url: target.href, title: "", controlsExamined: 0, linksChecked: { total: 0, checked: 0 }, links: [],
361
+ findings: [{ type: "broken_link", severity: "medium", title: `Page ${target.pathname} did not open: ${String(error?.message || error).slice(0, 120)}`, target: target.href, url: target.href, screen: target.pathname }] };
362
+ }
363
+ linksTotal += result.linksChecked.total;
364
+ linksChecked += result.linksChecked.checked;
365
+ const { links, ...pageResult } = result;
366
+ results.push(pageResult);
367
+ if (typeof onPage === "function") onPage(pageResult, results.length);
368
+ // A link the HEAD check already found broken is reported; opening it would only repeat the finding.
369
+ const broken = new Set(pageResult.findings.filter((f) => f.type === "broken_link").map((f) => f.target));
370
+ for (const link of links) {
371
+ if (!link.sameOrigin || queued.has(link.href) || broken.has(link.href) || queued.size >= maxPages * 4) continue;
372
+ let next;
373
+ try { next = new URL(link.href); } catch { continue; }
374
+ if (/\.(pdf|zip|png|jpe?g|gif|svg|webp|mp4|mp3|css|js|xml|ico)$/i.test(next.pathname)) continue;
375
+ queued.add(next.href);
376
+ queue.push(next);
377
+ }
378
+ }
379
+
380
+ const findings = results.flatMap((r) => r.findings);
381
+ const bySeverity = { critical: 0, high: 0, medium: 0, low: 0 };
382
+ for (const f of findings) if (f.severity in bySeverity) bySeverity[f.severity] += 1;
383
+ const scope = auditScope({ linksChecked: { total: linksTotal, checked: linksChecked }, pages: results.length });
384
+ const summary = {
385
+ kind: "tapp-structural-audit",
386
+ readOnly: true,
387
+ url: start.href,
388
+ pagesAudited: results.length,
389
+ pagesQueued: queue.length,
390
+ robotsBlocked,
391
+ findingCounts: { ...bySeverity, total: findings.length },
392
+ checkedFor: scope.checkedFor,
393
+ notChecked: scope.notChecked,
394
+ pages: results,
395
+ capture: null,
396
+ };
397
+ if (capture) {
398
+ finalizeCapture(capture, { startUrl: start.href, pages: results, findings, robotsBlocked, linksChecked: { total: linksTotal, checked: linksChecked } });
399
+ summary.capture = { id: capture.id, path: capture.dir, report: path.join(capture.dir, "report.html") };
400
+ fs.writeFileSync(path.join(capture.dir, "audit.json"), JSON.stringify(summary, null, 2));
401
+ }
402
+ return summary;
403
+ } finally {
404
+ if (capture) capture.close();
405
+ await browser.close().catch(() => {});
406
+ }
407
+ }
408
+
409
+ // One page. The shape existing callers and tests rely on, plus `capture` when a captures dir is given.
410
+ export async function auditWebPage({ url, timeoutMs = NAV_TIMEOUT_MS, device = "", viewport = "", linkCheckLimit = AUDIT_LINK_CHECK_LIMIT, capturesDir = "", captureId = "" }) {
411
+ const site = await auditWebSite({ url, pages: 1, timeoutMs, device, viewport, linkCheckLimit, capturesDir, captureId });
412
+ const page = site.pages[0];
413
+ return {
414
+ url: page.url,
415
+ title: page.title,
416
+ controlsExamined: page.controlsExamined,
417
+ linksChecked: page.linksChecked,
418
+ findings: page.findings,
419
+ checkedFor: site.checkedFor,
420
+ notChecked: site.notChecked,
421
+ capture: site.capture,
422
+ };
423
+ }
@@ -21,7 +21,7 @@ import { createRequire } from "module";
21
21
  import { execFileSync } from "child_process";
22
22
 
23
23
  const CLICK_SETTLE_MS = 700;
24
- const NAV_TIMEOUT_MS = 15_000;
24
+ export const NAV_TIMEOUT_MS = 15_000;
25
25
  const BUTTONS_PER_PAGE = 4;
26
26
  const OUTBOUND_LINK_LIMIT = 10;
27
27
  const WATCH_ACTION_DELAY_MS = 350;
@@ -136,7 +136,7 @@ function cssAttrEscape(value) {
136
136
  return String(value).replace(/["\\]/g, (c) => "\\" + c);
137
137
  }
138
138
 
139
- async function installWebListenerTracking(context) {
139
+ export async function installWebListenerTracking(context) {
140
140
  await context.addInitScript(() => {
141
141
  const key = Symbol.for("tapp.clickListeners");
142
142
  // Delegated handlers live on document/window, not on the control, so an
@@ -626,7 +626,7 @@ export function webScreenRole(screen, inputs = []) {
626
626
  return "screen";
627
627
  }
628
628
 
629
- function slug(s) {
629
+ export function slug(s) {
630
630
  return String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "page";
631
631
  }
632
632
 
@@ -1212,122 +1212,106 @@ export function auditFindingsFromControls(controls = []) {
1212
1212
  return findings;
1213
1213
  }
1214
1214
 
1215
- // Collected inside the page: every structurally dead control, WITHOUT interacting with any.
1216
- export async function auditWebPage({ url, timeoutMs = NAV_TIMEOUT_MS, device = "", viewport = "" }) {
1217
- let target;
1218
- try { target = new URL(url); }
1219
- catch { throw new Error("Audit needs a valid http(s) URL"); }
1220
- if (!/^https?:$/.test(target.protocol)) throw new Error("Audit needs a valid http(s) URL");
1221
- const { chromium, devices } = await loadPlaywright();
1222
- const browser = await chromium.launch(webBrowserLaunchOptions(process.env, {}));
1223
- try {
1224
- const context = await browser.newContext(webContextOptions({ device, viewport, devices }));
1225
- await installWebListenerTracking(context);
1226
- const page = await context.newPage();
1227
- const bounded = Math.max(1000, Math.min(60_000, Number(timeoutMs) || NAV_TIMEOUT_MS));
1228
- page.setDefaultTimeout(bounded);
1229
- const response = await page.goto(target.href, { waitUntil: "domcontentloaded", timeout: bounded });
1230
- if (response && response.status() >= 400) throw new Error(`Could not open ${target.href}: HTTP ${response.status()}`);
1231
- await waitForWebStability(page, { timeoutMs: Math.min(5_000, bounded) });
1232
- const controls = await page.evaluate(() => {
1233
- const key = Symbol.for("tapp.clickListeners");
1234
- const visible = (el) => {
1235
- const style = window.getComputedStyle(el);
1236
- return style.visibility !== "hidden" && style.display !== "none" && el.getClientRects().length > 0;
1237
- };
1238
- const name = (el) => (el.getAttribute("aria-label") || el.textContent || el.getAttribute("value") || "").replace(/\s+/g, " ").trim().slice(0, 80);
1239
- const selectorFor = (el) => el.id ? `#${el.id}` : el.getAttribute("data-testid") ? `[data-testid="${el.getAttribute("data-testid")}"]` : el.tagName.toLowerCase();
1240
- // Some controls are wired in CSS, not JavaScript: a menu that opens while
1241
- // its trigger is hovered or focused has no listener to find, and calling
1242
- // it dead is wrong. Collect the selectors that some rule reacts to — the
1243
- // trigger side of any `X:hover Y` / `X:focus-within Y` rule whose
1244
- // declarations change whether Y can be seen. A rule that only restyles
1245
- // the trigger itself (`button:hover { background: … }`) is not behaviour
1246
- // and is ignored, which is why the descendant part must be present.
1247
- const REVEALS = /(^|;)\s*(display|visibility|opacity|height|max-height|transform|pointer-events|clip-path)\s*:/i;
1248
- const revealTriggers = [];
1249
- const collectTriggers = (rules) => {
1250
- for (const rule of rules || []) {
1251
- // A plain style rule also exposes .cssRules now that CSS nesting is
1252
- // supported — an empty list, which is truthy. Recursing on that and
1253
- // skipping the rule would walk straight past every selector there is.
1254
- if (rule.cssRules && rule.cssRules.length) collectTriggers(rule.cssRules);
1255
- const selector = rule.selectorText;
1256
- if (!selector || !REVEALS.test(rule.style?.cssText || "")) continue;
1257
- for (const part of selector.split(",")) {
1258
- const match = part.match(/^(.*?):(?:hover|focus-within|focus-visible|focus)\b(.+)$/);
1259
- if (!match) continue;
1260
- const trigger = match[1].trim();
1261
- if (trigger && match[2].trim()) revealTriggers.push(trigger);
1262
- }
1215
+ // The structural scan, run inside the page: every control that was never alive, WITHOUT
1216
+ // interacting with any. Each flagged element is tagged with data-tapp-audit="<n>" so evidence can be
1217
+ // shot of exactly that element (a bare tag-name selector would photograph the first match instead).
1218
+ export async function auditStructuralControls(page) {
1219
+ return page.evaluate(() => {
1220
+ const key = Symbol.for("tapp.clickListeners");
1221
+ const visible = (el) => {
1222
+ const style = window.getComputedStyle(el);
1223
+ return style.visibility !== "hidden" && style.display !== "none" && el.getClientRects().length > 0;
1224
+ };
1225
+ const name = (el) => (el.getAttribute("aria-label") || el.textContent || el.getAttribute("value") || "").replace(/\s+/g, " ").trim().slice(0, 80);
1226
+ const selectorFor = (el) => el.id ? `#${el.id}` : el.getAttribute("data-testid") ? `[data-testid="${el.getAttribute("data-testid")}"]` : el.tagName.toLowerCase();
1227
+ // Some controls are wired in CSS, not JavaScript: a menu that opens while
1228
+ // its trigger is hovered or focused has no listener to find, and calling
1229
+ // it dead is wrong. Collect the selectors that some rule reacts to — the
1230
+ // trigger side of any `X:hover Y` / `X:focus-within Y` rule whose
1231
+ // declarations change whether Y can be seen. A rule that only restyles
1232
+ // the trigger itself (`button:hover { background: … }`) is not behaviour
1233
+ // and is ignored, which is why the descendant part must be present.
1234
+ const REVEALS = /(^|;)\s*(display|visibility|opacity|height|max-height|transform|pointer-events|clip-path)\s*:/i;
1235
+ const revealTriggers = [];
1236
+ const collectTriggers = (rules) => {
1237
+ for (const rule of rules || []) {
1238
+ // A plain style rule also exposes .cssRules now that CSS nesting is
1239
+ // supported — an empty list, which is truthy. Recursing on that and
1240
+ // skipping the rule would walk straight past every selector there is.
1241
+ if (rule.cssRules && rule.cssRules.length) collectTriggers(rule.cssRules);
1242
+ const selector = rule.selectorText;
1243
+ if (!selector || !REVEALS.test(rule.style?.cssText || "")) continue;
1244
+ for (const part of selector.split(",")) {
1245
+ const match = part.match(/^(.*?):(?:hover|focus-within|focus-visible|focus)\b(.+)$/);
1246
+ if (!match) continue;
1247
+ const trigger = match[1].trim();
1248
+ if (trigger && match[2].trim()) revealTriggers.push(trigger);
1263
1249
  }
1264
- };
1265
- for (const sheet of document.styleSheets) {
1266
- try { collectTriggers(sheet.cssRules); } catch { /* cross-origin sheet */ }
1267
1250
  }
1268
- const opensSomethingOnHoverOrFocus = (el) => revealTriggers.some((trigger) => {
1269
- try { return el.matches(trigger) || Boolean(el.closest(trigger)); } catch { return false; }
1270
- });
1251
+ };
1252
+ for (const sheet of document.styleSheets) {
1253
+ try { collectTriggers(sheet.cssRules); } catch { /* cross-origin sheet */ }
1254
+ }
1255
+ const opensSomethingOnHoverOrFocus = (el) => revealTriggers.some((trigger) => {
1256
+ try { return el.matches(trigger) || Boolean(el.closest(trigger)); } catch { return false; }
1257
+ });
1271
1258
 
1272
- // What a delegated handler on document/window claims. The dominant idiom is
1273
- // `e.target.closest(SELECTOR)` / `.matches(SELECTOR)`, so pull those
1274
- // selectors out and test controls against them. A handler we cannot read
1275
- // this way (an outside-click closer, say) claims nothing and is ignored,
1276
- // rather than excusing every unwired control on the page.
1277
- const delegatedSelectors = [];
1278
- for (const source of [
1279
- ...(document[Symbol.for("tapp.delegatedClickHandlers")] || []),
1280
- ...(window[Symbol.for("tapp.delegatedClickHandlers")] || []),
1281
- ]) {
1282
- for (const m of String(source).matchAll(/\.(?:closest|matches)\(\s*["'`]([^"'`]+)["'`]/g)) {
1283
- delegatedSelectors.push(m[1]);
1284
- }
1259
+ // What a delegated handler on document/window claims. The dominant idiom is
1260
+ // `e.target.closest(SELECTOR)` / `.matches(SELECTOR)`, so pull those
1261
+ // selectors out and test controls against them. A handler we cannot read
1262
+ // this way (an outside-click closer, say) claims nothing and is ignored,
1263
+ // rather than excusing every unwired control on the page.
1264
+ const delegatedSelectors = [];
1265
+ for (const source of [
1266
+ ...(document[Symbol.for("tapp.delegatedClickHandlers")] || []),
1267
+ ...(window[Symbol.for("tapp.delegatedClickHandlers")] || []),
1268
+ ]) {
1269
+ for (const m of String(source).matchAll(/\.(?:closest|matches)\(\s*["'`]([^"'`]+)["'`]/g)) {
1270
+ delegatedSelectors.push(m[1]);
1285
1271
  }
1286
- const claimedByDelegate = (el) => delegatedSelectors.some((selector) => {
1287
- try { return el.matches(selector) || Boolean(el.closest(selector)); } catch { return false; }
1288
- });
1272
+ }
1273
+ const claimedByDelegate = (el) => delegatedSelectors.some((selector) => {
1274
+ try { return el.matches(selector) || Boolean(el.closest(selector)); } catch { return false; }
1275
+ });
1289
1276
 
1290
- const dead = [];
1291
- for (const el of document.querySelectorAll("a[href], button, [role=button], [aria-controls]")) {
1292
- if (!visible(el)) continue;
1293
- const label = name(el);
1294
- const href = el.getAttribute("href");
1295
- const ariaControls = el.getAttribute("aria-controls");
1296
- if (ariaControls && !document.getElementById(ariaControls)) {
1297
- dead.push({ kind: "dangling_aria_controls", label, selector: selectorFor(el), controls: ariaControls });
1298
- continue;
1299
- }
1300
- if (href != null) {
1301
- if (href === "#" || href.trim() === "" || /^javascript:\s*(void\(0\))?;?$/i.test(href)) {
1302
- dead.push({ kind: "placeholder_link", label, selector: selectorFor(el), href });
1303
- } else if (href.startsWith("#")) {
1304
- const fragment = decodeURIComponent(href.slice(1));
1305
- const found = fragment && (document.getElementById(fragment) || document.getElementsByName(fragment).length > 0);
1306
- if (!found) dead.push({ kind: "dead_anchor", label, selector: selectorFor(el), href, fragment });
1307
- }
1308
- continue;
1309
- }
1310
- // A button with nothing behind it: no listener on itself or an ancestor, no inline
1311
- // handler, and not a submit inside a form.
1312
- let wired = false;
1313
- for (let node = el; node && node !== document.body; node = node.parentElement) {
1314
- if ((node[key] && node[key].size > 0) || typeof node.onclick === "function" || node.hasAttribute("onclick")) { wired = true; break; }
1277
+ const dead = [];
1278
+ for (const el of document.querySelectorAll("a[href], button, [role=button], [aria-controls]")) {
1279
+ if (!visible(el)) continue;
1280
+ const label = name(el);
1281
+ const href = el.getAttribute("href");
1282
+ const ariaControls = el.getAttribute("aria-controls");
1283
+ if (ariaControls && !document.getElementById(ariaControls)) {
1284
+ dead.push({ element: el, kind: "dangling_aria_controls", label, selector: selectorFor(el), controls: ariaControls });
1285
+ continue;
1286
+ }
1287
+ if (href != null) {
1288
+ if (href === "#" || href.trim() === "" || /^javascript:\s*(void\(0\))?;?$/i.test(href)) {
1289
+ dead.push({ element: el, kind: "placeholder_link", label, selector: selectorFor(el), href });
1290
+ } else if (href.startsWith("#")) {
1291
+ const fragment = decodeURIComponent(href.slice(1));
1292
+ const found = fragment && (document.getElementById(fragment) || document.getElementsByName(fragment).length > 0);
1293
+ if (!found) dead.push({ element: el, kind: "dead_anchor", label, selector: selectorFor(el), href, fragment });
1315
1294
  }
1316
- if (!wired && el.matches("button[type=submit], input[type=submit]") && el.closest("form")) wired = true;
1317
- if (!wired && el.closest("label")) wired = true; // a label drives its own control
1318
- if (!wired && opensSomethingOnHoverOrFocus(el)) wired = true;
1319
- if (!wired && claimedByDelegate(el)) wired = true;
1320
- if (!wired) dead.push({ kind: "unwired_control", label, selector: selectorFor(el) });
1295
+ continue;
1321
1296
  }
1322
- return { dead, controlCount: document.querySelectorAll("a[href], button, [role=button]").length, title: document.title };
1323
- });
1324
- return {
1325
- url: page.url(),
1326
- title: controls.title,
1327
- controlsExamined: controls.controlCount,
1328
- findings: auditFindingsFromControls(controls.dead),
1329
- };
1330
- } finally {
1331
- await browser.close().catch(() => {});
1332
- }
1297
+ // A button with nothing behind it: no listener on itself or an ancestor, no inline
1298
+ // handler, and not a submit inside a form.
1299
+ let wired = false;
1300
+ for (let node = el; node && node !== document.body; node = node.parentElement) {
1301
+ if ((node[key] && node[key].size > 0) || typeof node.onclick === "function" || node.hasAttribute("onclick")) { wired = true; break; }
1302
+ }
1303
+ if (!wired && el.matches("button[type=submit], input[type=submit]") && el.closest("form")) wired = true;
1304
+ if (!wired && el.closest("label")) wired = true; // a label drives its own control
1305
+ if (!wired && opensSomethingOnHoverOrFocus(el)) wired = true;
1306
+ if (!wired && claimedByDelegate(el)) wired = true;
1307
+ if (!wired) dead.push({ element: el, kind: "unwired_control", label, selector: selectorFor(el) });
1308
+ }
1309
+
1310
+ for (let i = 0; i < dead.length; i += 1) {
1311
+ if (dead[i].element) { dead[i].element.setAttribute("data-tapp-audit", String(i + 1)); dead[i].selector = `[data-tapp-audit="${i + 1}"]`; delete dead[i].element; }
1312
+ }
1313
+ return { dead, controlCount: document.querySelectorAll("a[href], button, [role=button]").length, title: document.title };
1314
+ });
1333
1315
  }
1316
+
1317
+ export { auditWebPage, auditWebSite } from "./web-audit.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aarwitz/tapp",
3
- "version": "0.17.21",
3
+ "version": "0.17.22",
4
4
  "mcpName": "io.github.aarwitz/tapp",
5
5
  "description": "Let coding agents verify UI changes on real iOS, Android, and web surfaces, then enforce reviewed proof in deterministic CI.",
6
6
  "license": "MIT",
@@ -89,7 +89,7 @@
89
89
  "mobile"
90
90
  ],
91
91
  "scripts": {
92
- "test": "node --test tests/report.test.js tests/regression.test.js tests/engine.test.js tests/project-config.test.js tests/application-model.test.js tests/ui-map.test.js tests/focused-navigation.test.js tests/task-runtime.test.js tests/release-contract.test.js tests/pr-selection.test.js tests/flow-runtime.test.js tests/web-explorer.test.js tests/web-session.test.js tests/web-link-audit.test.js tests/web-flow.test.js tests/scenario-runtime.test.js tests/android-driver.test.js tests/android-explorer.test.js tests/android-flow.test.js tests/android-primitives-protocol.test.js tests/managed-web.test.js tests/product-operations.test.js tests/browser-product.test.js tests/browser-onboarding.test.js tests/managed-operation.test.js tests/cloud-runner.test.js tests/ci-setup.test.js tests/ci-install.test.js tests/cli.test.js tests/mcp-workspace.test.js tests/action.test.js tests/package-surface.test.js tests/agent-surface.test.js tests/presentation-contract.test.js tests/landing-brand.test.js tests/ci-gate.test.js tests/ci-report.test.js tests/desktop-protocol.test.js tests/ios-flow-protocol.test.js tests/feedback-triage-0919.test.js vscode-extension/test/bridge.test.js",
92
+ "test": "node --test tests/report.test.js tests/regression.test.js tests/engine.test.js tests/project-config.test.js tests/application-model.test.js tests/ui-map.test.js tests/focused-navigation.test.js tests/task-runtime.test.js tests/release-contract.test.js tests/pr-selection.test.js tests/flow-runtime.test.js tests/web-explorer.test.js tests/web-session.test.js tests/web-link-audit.test.js tests/web-audit.test.js tests/web-flow.test.js tests/scenario-runtime.test.js tests/android-driver.test.js tests/android-explorer.test.js tests/android-flow.test.js tests/android-primitives-protocol.test.js tests/managed-web.test.js tests/product-operations.test.js tests/browser-product.test.js tests/browser-onboarding.test.js tests/managed-operation.test.js tests/cloud-runner.test.js tests/ci-setup.test.js tests/ci-install.test.js tests/cli.test.js tests/mcp-workspace.test.js tests/action.test.js tests/package-surface.test.js tests/agent-surface.test.js tests/presentation-contract.test.js tests/landing-brand.test.js tests/ci-gate.test.js tests/ci-report.test.js tests/desktop-protocol.test.js tests/ios-flow-protocol.test.js tests/feedback-triage-0919.test.js vscode-extension/test/bridge.test.js",
93
93
  "test:browser-journey": "node --test tests/browser-journey.test.js",
94
94
  "test:browser-native": "TAPP_RUN_NATIVE_BROWSER=1 node --test tests/browser-native-journey.test.js"
95
95
  }
@@ -43,7 +43,7 @@ than starting another broad exploration. If `.tapp/ui-map.json` does not exist y
43
43
  with `init . --explore`; source alone can locate a surface but cannot authorize unobserved taps.
44
44
  Targets may be a repository path, Xcode container, `.app`, iOS bundle id, APK plus Android app id,
45
45
  or owned HTTP(S) URL. Never explore a third-party web property without authorization: exploration
46
- clicks and types.
46
+ clicks and types. For production or a site you do not own use `audit` / `tapp_audit` — read-only.
47
47
 
48
48
  ## Navigate like a source-connected expert
49
49