@aarwitz/tapp 0.17.21 → 0.17.23

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.23",
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.23",
28
28
  "mcp"
29
29
  ],
30
30
  "cwd": "${CLAUDE_PROJECT_DIR}"
package/AGENTS.md CHANGED
@@ -68,6 +68,7 @@ installs, returns the bundle id) → `tapp_explore {appBundleId}`.
68
68
  | "Find/reach this named screen or control" | `tapp_session_start` with `focus`, or `tapp_focus`; plain CLI: `tapp focus` | screenshot-by-screenshot wandering |
69
69
  | "Tap through / drive / fill a form / log in" | `tapp_session_start` → `session_act` loop | repeated `open_app` calls (cold relaunch each time) |
70
70
  | "Is my app broken? Find bugs" | `tapp_explore` — `appBundleId` for iOS, `androidAppId` for Android, `url` for owned web apps; returns an observation (findings + evidence), not a ship verdict — gate a merge with the CI gate (`tapp ci` CLI / the GitHub Action) + a contract | a manual session (exploration is autonomous) |
71
+ | "Is this live site / production / a prospect's site broken?" | `tapp_audit` (CLI `tapp audit <url> --pages N`) — read-only: dead controls, broken images/links/assets, failed requests, JS errors, mixed content, overflow; writes a capture with evidence | `tapp_explore` (it clicks, types and submits) |
71
72
  | "Make this flow a repeatable test" | drive it in a session, then `tapp_flow_save`; replay with `tapp_flow_run` | re-driving it by hand every time |
72
73
  | "What's on screen right now?" | `tapp_screenshot` / `tapp_ui_tree` | relaunching the app |
73
74
 
@@ -138,11 +139,17 @@ advisory). Report the finding counts and coverage; do not invent a scalar or a s
138
139
  (e.g. `["--uitesting"]` if the app has a test bypass), and/or `appLaunchEnv` (e.g. a staging
139
140
  backend URL). If the result shows `inputFieldsEncountered` and you have no credentials, **ask
140
141
  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.
142
+ - `tapp audit <url>` / `tapp_audit` is the read-only pass: it renders the page and reads it but never
143
+ clicks, types, submits, or hovers, so it is the one analysis safe to point at production or at a
144
+ site you do not own. It finds controls that were never alive (dead in-page anchors,
145
+ `aria-controls` naming nothing, placeholder links, buttons with no handler/form/link — which Flows
146
+ structurally cannot find, because nobody writes a test for a button they believe does nothing),
147
+ images that failed to load, same-origin assets answering 404, 5xx/failed requests and uncaught JS
148
+ exceptions during load, same-origin links answering 404/410/5xx, mixed content, and a page wider
149
+ than its viewport. `--pages N` crawls same-origin links (robots.txt honoured, `--delay` between
150
+ pages); several URLs or `--urls FILE` batch sites. It writes a capture in the explore layout
151
+ (screenshots, per-finding evidence, `report.html`). Exit 1 when it finds anything. No findings
152
+ means the page is served without structural defects — not that the product works.
146
153
  - Credential surfaces are catalogue-only without credentials, on every platform: with no
147
154
  `testEmail`/`testPassword` supplied, exploration records a login/signup form's fields but does
148
155
  not type into them, submit them, or open recovery/third-party-auth flows — a bare-app run may
@@ -203,3 +210,5 @@ without a coding agent, model, subscription, or API key. AI generation and `asse
203
210
  of retrying variations.
204
211
  - When you show a screenshot as proof, say what it proves and what it doesn't ("login works;
205
212
  I haven't verified checkout").
213
+ - A clean `tapp_audit` means the page is served without structural defects. It says nothing about
214
+ behaviour, because nothing was clicked; never report it as "the site works".
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
@@ -2512,6 +2512,19 @@ const server = new Server(
2512
2512
  prompts: {},
2513
2513
  tools: {},
2514
2514
  },
2515
+ // Shown to the model by MCP clients at connect time: the routing rules that keep an agent from
2516
+ // reaching for the wrong tool, in the order the mistakes actually happen.
2517
+ instructions: [
2518
+ "Tapp gives you hands and eyes on real iOS, Android and web app surfaces. Pick the smallest operation:",
2519
+ "- see or screenshot one screen → tapp_open_app; what is on screen now → tapp_screenshot / tapp_ui_tree;",
2520
+ "- reach a named screen or drive a journey → tapp_session_start (+ focus) then session_act;",
2521
+ "- find bugs in an app or environment you own → tapp_explore (it clicks, types and submits; minutes);",
2522
+ "- check production or a site you do not own → tapp_audit only (read-only: never clicks);",
2523
+ "- a merge decision → the CI gate (tapp ci / GitHub Action), never exploration.",
2524
+ "Results are observations with findings, coverage and evidence paths — never a score or ship verdict.",
2525
+ "inconclusive:true is not a pass; say what blocked coverage. A clean audit says the page is served without structural defects, not that the product works.",
2526
+ "Open returned screenshot paths with your image tool before describing a screen. Never claim a flow works that you did not drive or see.",
2527
+ ].join("\n"),
2515
2528
  }
2516
2529
  );
2517
2530
 
@@ -2742,7 +2755,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2742
2755
  "verdict/releaseScore. Web separates deterministic findings from advisory sampled control probes. " +
2743
2756
  "There is a coverage floor: if the app barely explored (crash on launch / sign-in wall) it reports " +
2744
2757
  "`inconclusive` — absence of findings is NEVER a pass. For iOS the app must already be installed on a booted simulator (use tapp_list_simulators / " +
2745
- "tapp_boot_simulator first). For web, only point it at an app/environment you own — it CLICKS things. " +
2758
+ "tapp_boot_simulator first). For web, only point it at an app/environment you own — it CLICKS things; for production or a site you do not own use tapp_audit (read-only). " +
2746
2759
  "Tapp explores autonomously and does NOT pause to prompt for input — " +
2747
2760
  "it fills forms with safe defaults. The result includes `inputFieldsEncountered` (and `inputHint`): if " +
2748
2761
  "the app showed login/form fields and the user hasn't given you values, ASK THE USER what to enter (offer " +
@@ -2809,6 +2822,34 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2809
2822
  },
2810
2823
  },
2811
2824
  },
2825
+ {
2826
+ name: "tapp_audit",
2827
+ title: "Audit (read-only)",
2828
+ description:
2829
+ "Read-only audit of a web page or site: renders it and reads it, NEVER clicks, types, submits or hovers — " +
2830
+ "the one analysis safe to point at production or at a site you do not own. Finds controls that were never " +
2831
+ "alive (dead in-page anchors, placeholder links, aria-controls naming nothing, buttons with no handler/form/link), " +
2832
+ "images that failed to load, same-origin assets answering 404, 5xx/failed requests and uncaught JS exceptions " +
2833
+ "during load, same-origin links answering 404/410/5xx, mixed content, and a page wider than its viewport. " +
2834
+ "pages > 1 crawls same-origin links breadth-first with robots.txt honoured. Writes a capture (screenshots, " +
2835
+ "per-finding evidence, report.html) like an explore run. Returns {kind:'tapp-structural-audit', readOnly:true, " +
2836
+ "pagesAudited, findingCounts, pages:[{url,title,controlsExamined,linksChecked,findings}], checkedFor, notChecked, capture}. " +
2837
+ "An observation, never a verdict; absence of findings says the page is served without structural defects, not that the product works.",
2838
+ inputSchema: {
2839
+ type: "object",
2840
+ properties: {
2841
+ authToken: { type: "string", description: "Required when TAPP_MCP_TOKEN is set" },
2842
+ url: { type: "string", description: "http(s) URL of the page to audit (the crawl start page when pages > 1)" },
2843
+ pages: { type: "integer", default: 1, minimum: 1, maximum: 200, description: "How many same-origin pages to audit at most (1 = this page only)" },
2844
+ delayMs: { type: "integer", default: 1000, minimum: 0, description: "Pause between pages when crawling — be polite to sites you do not own" },
2845
+ linkCheckLimit: { type: "integer", default: 30, minimum: 0, description: "Same-origin links to HEAD-check per page" },
2846
+ device: { type: "string", description: "Playwright device profile, e.g. 'iPhone 13' — audit the mobile rendering" },
2847
+ viewport: { type: "string", description: "WIDTHxHEIGHT viewport override" },
2848
+ capture: { type: "boolean", default: true, description: "Write a capture directory (screenshots, evidence, report.html)" },
2849
+ },
2850
+ required: ["url"],
2851
+ },
2852
+ },
2812
2853
  {
2813
2854
  name: "tapp_init",
2814
2855
  title: "Inspect or explore a repository and create the Tapp application model and release plan",
@@ -3609,6 +3650,40 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3609
3650
  return richResult(L.join("\n"), summary);
3610
3651
  }
3611
3652
 
3653
+ if (name === "tapp_audit") {
3654
+ const unauthorized = ensureAuthorized(args);
3655
+ if (unauthorized) return unauthorized;
3656
+ if (!isNonEmptyString(args.url)) return errorResult("url is required");
3657
+ try {
3658
+ const { auditWebSite } = await import("./web-audit.js");
3659
+ const site = await auditWebSite({
3660
+ url: args.url,
3661
+ pages: asInteger(args.pages, 1),
3662
+ delayMs: asInteger(args.delayMs, 1000),
3663
+ linkCheckLimit: asInteger(args.linkCheckLimit, 30),
3664
+ device: isNonEmptyString(args.device) ? args.device : "",
3665
+ viewport: isNonEmptyString(args.viewport) ? args.viewport : "",
3666
+ capturesDir: args.capture === false ? "" : capturesDir,
3667
+ });
3668
+ const findings = site.pages.flatMap((p) => p.findings);
3669
+ const c = site.findingCounts;
3670
+ const lines = [
3671
+ `🔎 Read-only audit of ${site.url} — ${site.pagesAudited} page(s), nothing clicked`,
3672
+ findings.length
3673
+ ? `${findings.length} defect(s): ${c.critical} critical · ${c.high} high · ${c.medium} medium · ${c.low} low`
3674
+ : "✅ Nothing broken observed. Not a pass on behaviour — nothing was exercised.",
3675
+ ...findings.slice(0, 25).map((f) => ` ${SEV[f.severity] || "·"} [${f.type}] ${f.title}${f.screen ? ` — on ${f.screen}` : ""}`),
3676
+ ...(findings.length > 25 ? [` … ${findings.length - 25} more in structuredContent`] : []),
3677
+ ...(site.robotsBlocked?.length ? [`robots.txt kept ${site.robotsBlocked.length} page(s) out of the crawl`] : []),
3678
+ ...(site.capture ? [`📁 ${site.capture.path}`, `📄 ${site.capture.report}`] : []),
3679
+ "Next: to exercise the controls (clicks, forms) run tapp_explore — only on an environment you own.",
3680
+ ];
3681
+ return richResult(lines.join("\n"), site);
3682
+ } catch (error) {
3683
+ return errorResult(error.message || String(error));
3684
+ }
3685
+ }
3686
+
3612
3687
  if (name === "tapp_explore" || name === "tapp_run_qa") { // tapp_run_qa: deprecated alias
3613
3688
  const unauthorized = ensureAuthorized(args);
3614
3689
  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
 
@@ -668,7 +668,9 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
668
668
  // phone run legitimately disagree about which nav links exist, and a baseline diff across
669
669
  // them must be able to say so instead of reporting "resolved".
670
670
  emit("CONTEXT", { ...(String(device || "").trim() ? { device: String(device).trim() } : {}), viewport: captureProfile.viewport, deviceScaleFactor: captureProfile.deviceScaleFactor ?? 1 });
671
- const context = await browser.newContext(captureProfile);
671
+ // Playwright records natively (no ffmpeg transcode needed, unlike simctl's .mov output) —
672
+ // written to a Playwright-chosen filename in outDir, finalized only on context.close().
673
+ const context = await browser.newContext({ ...captureProfile, recordVideo: { dir: outDir, size: captureProfile.viewport } });
672
674
  await installWebListenerTracking(context);
673
675
  if (watch) await installWebWatchUi(context);
674
676
  const page = await context.newPage();
@@ -1127,7 +1129,10 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
1127
1129
  outbound.total = outboundLinks.size;
1128
1130
  outbound.mailtos = mailtoLinks.size;
1129
1131
  if (outboundLinks.size) {
1130
- const auditPage = await context.newPage();
1132
+ // A fresh, unrecorded context: outbound link checks navigate away to third-party sites and
1133
+ // must not inherit the exploration context's recordVideo (noise, not exploration evidence).
1134
+ const auditContext = await browser.newContext(captureProfile);
1135
+ const auditPage = await auditContext.newPage();
1131
1136
  auditPage.setDefaultTimeout(8000);
1132
1137
  const targets = [...outboundLinks.entries()].slice(0, OUTBOUND_LINK_LIMIT);
1133
1138
  outbound.checked = targets.length;
@@ -1153,7 +1158,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
1153
1158
  const phrase = webUnavailableShellPhrase(text);
1154
1159
  if (phrase) issue("outbound_unavailable", "medium", `Outbound link returns 200 but shows "${phrase}": ${href.slice(0, 100)}`, meta.screen, href, meta.sourceUrl);
1155
1160
  }
1156
- await auditPage.close().catch(() => {});
1161
+ await auditContext.close().catch(() => {});
1157
1162
  }
1158
1163
  if (mailtoLinks.size) {
1159
1164
  if (process.env.TAPP_ENFORCE_PUBLIC_EGRESS === "1") {
@@ -1181,6 +1186,15 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
1181
1186
  : "frontier-drained";
1182
1187
  emit("COMPLETE", { actions, screens: screenCount, credentialsProvided: !!(testEmail || testPassword), credentialsUsed: loginTried, timedOut, stop, outbound });
1183
1188
  fs.closeSync(markersFd);
1189
+ // Video finalizes on context.close(), before browser.close() tears down the recorder —
1190
+ // same "exploration.<ext>" name summarizeCapture() and the website replay already look for.
1191
+ const video = page.video();
1192
+ await context.close().catch(() => {});
1193
+ if (video) {
1194
+ await video.path()
1195
+ .then((p) => fs.renameSync(p, path.join(outDir, "exploration.webm")))
1196
+ .catch(() => {});
1197
+ }
1184
1198
  await browser.close().catch(() => {});
1185
1199
  }
1186
1200
  return { markersPath, outDir, actions, screens: screenCount, seedRoutes: normalizedSeeds, seedTargets: normalizedTargets };
@@ -1212,122 +1226,106 @@ export function auditFindingsFromControls(controls = []) {
1212
1226
  return findings;
1213
1227
  }
1214
1228
 
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
- }
1229
+ // The structural scan, run inside the page: every control that was never alive, WITHOUT
1230
+ // interacting with any. Each flagged element is tagged with data-tapp-audit="<n>" so evidence can be
1231
+ // shot of exactly that element (a bare tag-name selector would photograph the first match instead).
1232
+ export async function auditStructuralControls(page) {
1233
+ return page.evaluate(() => {
1234
+ const key = Symbol.for("tapp.clickListeners");
1235
+ const visible = (el) => {
1236
+ const style = window.getComputedStyle(el);
1237
+ return style.visibility !== "hidden" && style.display !== "none" && el.getClientRects().length > 0;
1238
+ };
1239
+ const name = (el) => (el.getAttribute("aria-label") || el.textContent || el.getAttribute("value") || "").replace(/\s+/g, " ").trim().slice(0, 80);
1240
+ const selectorFor = (el) => el.id ? `#${el.id}` : el.getAttribute("data-testid") ? `[data-testid="${el.getAttribute("data-testid")}"]` : el.tagName.toLowerCase();
1241
+ // Some controls are wired in CSS, not JavaScript: a menu that opens while
1242
+ // its trigger is hovered or focused has no listener to find, and calling
1243
+ // it dead is wrong. Collect the selectors that some rule reacts to — the
1244
+ // trigger side of any `X:hover Y` / `X:focus-within Y` rule whose
1245
+ // declarations change whether Y can be seen. A rule that only restyles
1246
+ // the trigger itself (`button:hover { background: … }`) is not behaviour
1247
+ // and is ignored, which is why the descendant part must be present.
1248
+ const REVEALS = /(^|;)\s*(display|visibility|opacity|height|max-height|transform|pointer-events|clip-path)\s*:/i;
1249
+ const revealTriggers = [];
1250
+ const collectTriggers = (rules) => {
1251
+ for (const rule of rules || []) {
1252
+ // A plain style rule also exposes .cssRules now that CSS nesting is
1253
+ // supported — an empty list, which is truthy. Recursing on that and
1254
+ // skipping the rule would walk straight past every selector there is.
1255
+ if (rule.cssRules && rule.cssRules.length) collectTriggers(rule.cssRules);
1256
+ const selector = rule.selectorText;
1257
+ if (!selector || !REVEALS.test(rule.style?.cssText || "")) continue;
1258
+ for (const part of selector.split(",")) {
1259
+ const match = part.match(/^(.*?):(?:hover|focus-within|focus-visible|focus)\b(.+)$/);
1260
+ if (!match) continue;
1261
+ const trigger = match[1].trim();
1262
+ if (trigger && match[2].trim()) revealTriggers.push(trigger);
1263
1263
  }
1264
- };
1265
- for (const sheet of document.styleSheets) {
1266
- try { collectTriggers(sheet.cssRules); } catch { /* cross-origin sheet */ }
1267
1264
  }
1268
- const opensSomethingOnHoverOrFocus = (el) => revealTriggers.some((trigger) => {
1269
- try { return el.matches(trigger) || Boolean(el.closest(trigger)); } catch { return false; }
1270
- });
1265
+ };
1266
+ for (const sheet of document.styleSheets) {
1267
+ try { collectTriggers(sheet.cssRules); } catch { /* cross-origin sheet */ }
1268
+ }
1269
+ const opensSomethingOnHoverOrFocus = (el) => revealTriggers.some((trigger) => {
1270
+ try { return el.matches(trigger) || Boolean(el.closest(trigger)); } catch { return false; }
1271
+ });
1271
1272
 
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
- }
1273
+ // What a delegated handler on document/window claims. The dominant idiom is
1274
+ // `e.target.closest(SELECTOR)` / `.matches(SELECTOR)`, so pull those
1275
+ // selectors out and test controls against them. A handler we cannot read
1276
+ // this way (an outside-click closer, say) claims nothing and is ignored,
1277
+ // rather than excusing every unwired control on the page.
1278
+ const delegatedSelectors = [];
1279
+ for (const source of [
1280
+ ...(document[Symbol.for("tapp.delegatedClickHandlers")] || []),
1281
+ ...(window[Symbol.for("tapp.delegatedClickHandlers")] || []),
1282
+ ]) {
1283
+ for (const m of String(source).matchAll(/\.(?:closest|matches)\(\s*["'`]([^"'`]+)["'`]/g)) {
1284
+ delegatedSelectors.push(m[1]);
1285
1285
  }
1286
- const claimedByDelegate = (el) => delegatedSelectors.some((selector) => {
1287
- try { return el.matches(selector) || Boolean(el.closest(selector)); } catch { return false; }
1288
- });
1286
+ }
1287
+ const claimedByDelegate = (el) => delegatedSelectors.some((selector) => {
1288
+ try { return el.matches(selector) || Boolean(el.closest(selector)); } catch { return false; }
1289
+ });
1289
1290
 
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; }
1291
+ const dead = [];
1292
+ for (const el of document.querySelectorAll("a[href], button, [role=button], [aria-controls]")) {
1293
+ if (!visible(el)) continue;
1294
+ const label = name(el);
1295
+ const href = el.getAttribute("href");
1296
+ const ariaControls = el.getAttribute("aria-controls");
1297
+ if (ariaControls && !document.getElementById(ariaControls)) {
1298
+ dead.push({ element: el, kind: "dangling_aria_controls", label, selector: selectorFor(el), controls: ariaControls });
1299
+ continue;
1300
+ }
1301
+ if (href != null) {
1302
+ if (href === "#" || href.trim() === "" || /^javascript:\s*(void\(0\))?;?$/i.test(href)) {
1303
+ dead.push({ element: el, kind: "placeholder_link", label, selector: selectorFor(el), href });
1304
+ } else if (href.startsWith("#")) {
1305
+ const fragment = decodeURIComponent(href.slice(1));
1306
+ const found = fragment && (document.getElementById(fragment) || document.getElementsByName(fragment).length > 0);
1307
+ if (!found) dead.push({ element: el, kind: "dead_anchor", label, selector: selectorFor(el), href, fragment });
1315
1308
  }
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) });
1309
+ continue;
1321
1310
  }
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
- }
1311
+ // A button with nothing behind it: no listener on itself or an ancestor, no inline
1312
+ // handler, and not a submit inside a form.
1313
+ let wired = false;
1314
+ for (let node = el; node && node !== document.body; node = node.parentElement) {
1315
+ if ((node[key] && node[key].size > 0) || typeof node.onclick === "function" || node.hasAttribute("onclick")) { wired = true; break; }
1316
+ }
1317
+ if (!wired && el.matches("button[type=submit], input[type=submit]") && el.closest("form")) wired = true;
1318
+ if (!wired && el.closest("label")) wired = true; // a label drives its own control
1319
+ if (!wired && opensSomethingOnHoverOrFocus(el)) wired = true;
1320
+ if (!wired && claimedByDelegate(el)) wired = true;
1321
+ if (!wired) dead.push({ element: el, kind: "unwired_control", label, selector: selectorFor(el) });
1322
+ }
1323
+
1324
+ for (let i = 0; i < dead.length; i += 1) {
1325
+ 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; }
1326
+ }
1327
+ return { dead, controlCount: document.querySelectorAll("a[href], button, [role=button]").length, title: document.title };
1328
+ });
1333
1329
  }
1330
+
1331
+ 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.23",
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
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: tapp
3
- description: Use Tapp to see, drive, explore, and verify real application surfaces on iOS simulators, Android emulators/devices, or the web. Use when a user asks an agent to test an app or UI change, find bugs, inspect or screenshot a screen, exercise a journey, create a replayable flow, gather release evidence, or run the deterministic Tapp gate. Also use when the user mentions Tapp, @aarwitz/tapp, tapp_* tools, .tapp artifacts, or asks whether agent-authored UI actually works.
3
+ description: Use Tapp to see, drive, explore, and verify real application surfaces on iOS simulators, Android emulators/devices, or the web. Use when a user asks an agent to test an app or UI change, find bugs, inspect or screenshot a screen, exercise a journey, create a replayable flow, gather release evidence, run the deterministic Tapp gate, or check a live website, production, or a site they do not own without touching it (read-only audit). Also use when the user mentions Tapp, @aarwitz/tapp, tapp_* tools, .tapp artifacts, or asks whether agent-authored UI actually works.
4
4
  ---
5
5
 
6
6
  # Tapp
@@ -16,7 +16,8 @@ screen or journey works from source inspection alone.
16
16
  | Inspect controls on the current screen | `tree` / `tapp_ui_tree` |
17
17
  | Reach a named screen/control | `focus` / `tapp_focus` (source + observed UI Map fast path) |
18
18
  | Drive a specific journey | MCP session start → focus or act → end |
19
- | Find bugs autonomously | `explore` / `tapp_explore` |
19
+ | Find bugs autonomously (owned app/environment; it clicks) | `explore` / `tapp_explore` |
20
+ | Check production or a site you do not own (never clicks) | `audit` / `tapp_audit` |
20
21
  | Preserve a journey | record and save a Flow; replay it deterministically |
21
22
  | Decide whether a merge passes policy | `ci`; exploration never decides this |
22
23
 
@@ -42,8 +43,7 @@ For a focused request in an already-grounded repository, use the requested targe
42
43
  than starting another broad exploration. If `.tapp/ui-map.json` does not exist yet, ground it once
43
44
  with `init . --explore`; source alone can locate a surface but cannot authorize unobserved taps.
44
45
  Targets may be a repository path, Xcode container, `.app`, iOS bundle id, APK plus Android app id,
45
- or owned HTTP(S) URL. Never explore a third-party web property without authorization: exploration
46
- clicks and types.
46
+ or owned HTTP(S) URL. Never explore a third-party web property: exploration clicks and types.
47
47
 
48
48
  ## Navigate like a source-connected expert
49
49
 
@@ -11,7 +11,8 @@ npx -y @aarwitz/tapp@latest explore [target]
11
11
  npx -y @aarwitz/tapp@latest focus "Save storefront settings visible above keyboard" [target]
12
12
  npx -y @aarwitz/tapp@latest open [target]
13
13
  npx -y @aarwitz/tapp@latest tree [target] --json
14
- npx -y @aarwitz/tapp@latest audit https://example.com --json # read-only: never clicks, safe against production
14
+ npx -y @aarwitz/tapp@latest audit https://example.com --pages 5 --json # read-only: never clicks; safe on production or third-party sites
15
+ # several URLs or --urls FILE batch sites · --device "iPhone 13" audits the mobile rendering · --json FILE writes the result · exit 1 = defects found
15
16
  npx -y @aarwitz/tapp@latest shot
16
17
  npx -y @aarwitz/tapp@latest report latest
17
18
  npx -y @aarwitz/tapp@latest doctor