@aarwitz/tapp 0.17.20 → 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.20",
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.20",
27
+ "@aarwitz/tapp@0.17.22",
28
28
  "mcp"
29
29
  ],
30
30
  "cwd": "${CLAUDE_PROJECT_DIR}"
package/AGENTS.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Tapp agent playbook
2
2
 
3
3
  You (the agent) have Tapp: hands and eyes on real app surfaces — iOS simulators, Android
4
- emulators/devices, plus (beta) web apps in a real browser. Release judgment belongs only to Tapp's
4
+ emulators/devices, plus web apps in a real browser. Release judgment belongs only to Tapp's
5
5
  repository-connected deterministic gate.
6
6
 
7
7
  ## No MCP connected? Just run the CLI
@@ -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
@@ -5172,12 +5172,23 @@ class ExplorerTests: XCTestCase {
5172
5172
  // deleting only the prefix, then prove it is empty before inserting new text.
5173
5173
  element.typeKey("a", modifierFlags: .command)
5174
5174
  element.typeText(XCUIKeyboardKey.delete.rawValue)
5175
- if hasContent(), let remaining = element.value as? String {
5176
- // Some simulator keyboard configurations ignore Command-A. Clear on both
5177
- // sides of the cursor in that case; backspace alone leaves the suffix intact.
5178
- let count = remaining.utf16.count
5179
- element.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: count)
5180
- + String(repeating: XCUIKeyboardKey.forwardDelete.rawValue, count: count))
5175
+ // Command-A and forward delete are hardware-keyboard keys, and the simulator's
5176
+ // hardware keyboard is disconnected by default — which is how CI runs. XCUITest
5177
+ // cannot press a key the software keyboard does not have: the selection never
5178
+ // happens, and forwardDelete's raw value (U+F728, a private-use character) is
5179
+ // INSERTED as literal text. Clearing a 54-character field therefore left it holding
5180
+ // 54 unrenderable glyphs and reported "could not be cleared". Backspace IS on the
5181
+ // software keyboard, so clear with backspace alone, re-anchoring the caret past the
5182
+ // end of the visible text each pass to reach a suffix that was scrolled out of view.
5183
+ var passes = 0
5184
+ while hasContent(), passes < 4 {
5185
+ passes += 1
5186
+ guard let remaining = element.value as? String else { break }
5187
+ element.coordinate(withNormalizedOffset: CGVector(dx: 0.97, dy: 0.5)).tap()
5188
+ element.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue,
5189
+ count: remaining.utf16.count + 2))
5190
+ // An empty field reports its placeholder, so this compares unequal once cleared.
5191
+ if (element.value as? String) == remaining { break }
5181
5192
  }
5182
5193
  let deadline = Date().addingTimeInterval(1.0)
5183
5194
  while hasContent() && Date() < deadline { Thread.sleep(forTimeInterval: 0.1) }
package/bin/tapp.js CHANGED
@@ -163,7 +163,7 @@ const engineImport = () => import(path.join(packageRoot, "mcp-server", "src", "i
163
163
 
164
164
  function requireMacFor(what) {
165
165
  if (process.platform === "darwin") return;
166
- console.error(`❌ ${what} requires macOS (Xcode + iOS simulator). The web beta runs anywhere: npx -y @aarwitz/tapp@latest explore https://localhost:3000`);
166
+ console.error(`❌ ${what} requires macOS (Xcode + iOS simulator). Web runs anywhere: npx -y @aarwitz/tapp@latest explore https://localhost:3000`);
167
167
  process.exit(1);
168
168
  }
169
169
 
@@ -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
package/browser/app.css CHANGED
@@ -176,24 +176,35 @@ dialog { width:min(720px,calc(100vw - 40px)); max-height:min(760px,calc(100vh -
176
176
  }
177
177
 
178
178
  /* Desktop-parity dark product skin. The component structure remains shared by
179
- local and hosted Tapp; this palette follows the native Command Center. */
179
+ local and hosted Tapp; the palette comes from tokens.css so the hosted Studio,
180
+ the local Studio, and the macOS Command Center read as one product. */
180
181
  :root {
181
182
  color-scheme:dark;
182
- --ink:#edf2f8; --muted:#929ba8; --faint:#687280; --line:#303844; --paper:#171c24; --wash:#20262f;
183
- --green:#58a6ff; --green-2:#4f9cf3; --green-3:#7bb8ff; --lime:#102d4d; --amber:#e2a826;
184
- --amber-bg:#302817; --red:#ff5c57; --red-bg:#392125; --blue:#58a6ff; --sidebar:#0c1118;
185
- --shadow:0 18px 55px rgba(0,0,0,.28); --small-shadow:0 8px 24px rgba(0,0,0,.18);
183
+ --ink:var(--tapp-text); --muted:var(--tapp-text-soft); --faint:var(--tapp-text-faint);
184
+ --line:var(--tapp-border); --paper:var(--tapp-panel); --wash:var(--tapp-panel-2);
185
+ --green:var(--tapp-blue); --green-2:var(--tapp-blue-hover); --green-3:#7bb8ff;
186
+ --lime:var(--tapp-selected); --amber:var(--tapp-amber); --amber-bg:var(--tapp-amber-bg);
187
+ --red:var(--tapp-red); --red-bg:var(--tapp-red-bg); --blue:var(--tapp-blue);
188
+ --sidebar:var(--tapp-sidebar); --shadow:var(--tapp-shadow); --small-shadow:var(--tapp-shadow-small);
186
189
  }
187
- body { background:#0d1117; }
188
- .topbar { background:rgba(13,17,23,.96); border-color:var(--line); }
190
+ body { background:var(--tapp-bg); font-family:var(--tapp-font); }
191
+ code { font-family:var(--tapp-font-mono); }
192
+ .topbar { background:rgba(7,16,23,.96); border-color:var(--tapp-border-strong); }
189
193
  .brand-mark { width:auto; background:transparent; color:var(--green); font-family:inherit; font-size:24px; }
190
194
  .mode-pill,.status-chip,.repository-switcher,.source-card,.panel,.decision-card,.target-card,.field,
191
195
  .live-frame-shell,.live-controls,.coverage-summary,.transition-list,.plan-item,.pipeline article,
192
196
  .evidence-card,.search-input,.github-repositories,.github-repositories button,.manual-action input,
193
197
  .run-row,.finding-row,.live-control { background:var(--paper); color:var(--ink); border-color:var(--line); }
194
198
  .source-onboarding { min-height:calc(100vh - 64px); }
195
- .source-card:hover,.source-card.dragging { border-color:var(--green-2); box-shadow:0 20px 56px rgba(0,0,0,.36); }
196
- .source-card.dragging,.target-card.selected,.run-row.selected,.finding-row.selected { background:#132237; }
199
+ .source-card:hover,.source-card.dragging { border-color:var(--green-2); box-shadow:0 20px 56px rgba(0,0,0,.46); }
200
+ .source-card.dragging,.target-card.selected,.run-row.selected,.finding-row.selected { background:var(--tapp-selected); }
201
+ /* Controls match the Command Center: compact, 8px radius, muted surface. */
202
+ button { border-radius:var(--tapp-radius); }
203
+ button.primary { background:var(--tapp-blue); color:#fff; }
204
+ button.primary:hover { background:var(--tapp-blue-hover); }
205
+ button.secondary { color:var(--tapp-text-control); background:var(--tapp-control); border-color:var(--tapp-border); }
206
+ button.secondary:hover,button.quiet:hover { background:var(--tapp-panel-3); }
207
+ button.quiet { border-color:var(--tapp-border); color:var(--muted); }
197
208
  .platform-row span,.button-link { background:#131a23; border-color:#385a7c; color:var(--green); }
198
209
  button.primary { background:var(--green); color:#07111d; }
199
210
  button.primary:hover { background:#79b8ff; }
@@ -7,6 +7,7 @@
7
7
  <meta name="tapp-api-base" content="">
8
8
  <meta name="tapp-login-url" content="">
9
9
  <title>Tapp</title>
10
+ <link rel="stylesheet" href="./assets/tokens.css">
10
11
  <link rel="stylesheet" href="./assets/app.css">
11
12
  </head>
12
13
  <body>
@@ -0,0 +1,50 @@
1
+ /* Tapp design tokens — the single source of truth for what Tapp looks like.
2
+ Extracted from the Command Center shell (website/) so the hosted Studio
3
+ (app.runtapp.com), the local `tapp app` Studio, and the macOS app read as one
4
+ product. Surfaces consume these variables; none should hardcode a palette. */
5
+ :root {
6
+ color-scheme: dark;
7
+
8
+ /* Ground and surfaces, darkest to lightest */
9
+ --tapp-bg: #071017;
10
+ --tapp-bg-deep: #040b11;
11
+ --tapp-sidebar: #0b1218;
12
+ --tapp-sidebar-solid: #0c1117;
13
+ --tapp-panel: #0f1a22;
14
+ --tapp-panel-2: #111d26;
15
+ --tapp-panel-3: #192730;
16
+ --tapp-panel-soft: rgba(17, 22, 28, 0.8);
17
+ --tapp-control: #1a222b;
18
+
19
+ /* Lines */
20
+ --tapp-border: rgba(168, 182, 196, 0.12);
21
+ --tapp-border-strong: #343b43;
22
+
23
+ /* Type */
24
+ --tapp-text: #edf3f7;
25
+ --tapp-text-soft: #b7c6d1;
26
+ --tapp-text-faint: #7e8d99;
27
+ --tapp-text-control: #dce3e9;
28
+
29
+ /* Accent and status */
30
+ --tapp-blue: #1f79ff;
31
+ --tapp-blue-hover: #2d87ff;
32
+ --tapp-amber: #e2a826;
33
+ --tapp-amber-bg: #302817;
34
+ --tapp-red: #ff5c57;
35
+ --tapp-red-bg: #392125;
36
+ --tapp-selected: #132b44;
37
+
38
+ /* Depth */
39
+ --tapp-shadow: 0 18px 55px rgba(0, 0, 0, 0.42);
40
+ --tapp-shadow-small: 0 8px 24px rgba(0, 0, 0, 0.3);
41
+
42
+ /* Type families — Inter for UI, SF Mono for evidence and identifiers */
43
+ --tapp-font: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
44
+ --tapp-font-mono: "SFMono-Regular", ui-monospace, Consolas, Menlo, monospace;
45
+
46
+ /* Shell metrics */
47
+ --tapp-sidebar-width: 220px;
48
+ --tapp-radius: 8px;
49
+ --tapp-radius-lg: 12px;
50
+ }
@@ -2729,7 +2729,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2729
2729
  title: "Explore (autonomous)",
2730
2730
  description:
2731
2731
  "Autonomously explore iOS (appBundleId), Android (androidAppId), OR a web app " +
2732
- "(url — beta, requires Playwright installed) and return a structured EXPLORATION OBSERVATION. " +
2732
+ "(url — requires Playwright installed) and return a structured EXPLORATION OBSERVATION. " +
2733
2733
  "Exploration OBSERVES — it surfaces findings + coverage + evidence; it does NOT render a ship " +
2734
2734
  "verdict or score. To gate a merge, run the deterministic gate (contracts + baseline via CI). " +
2735
2735
  "Use when the user wants to find bugs / observe what breaks — this " +
@@ -2756,7 +2756,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2756
2756
  apkPath: { type: "string", description: "Android: optional APK to install before testing." },
2757
2757
  androidSerial: { type: "string", description: "Android: optional adb device serial; defaults to the first authorized device." },
2758
2758
  clearData: { type: "boolean", default: true, description: "Android: clear app data before launch for a repeatable starting state." },
2759
- url: { type: "string", description: "Web (beta): URL of the app to explore in a real browser (same-origin only; your own app/staging). Provide exactly one of appBundleId | url." },
2759
+ url: { type: "string", description: "Web: URL of the app to explore in a real browser (same-origin only; your own app/staging). Provide exactly one of appBundleId | url." },
2760
2760
  watch: { type: "boolean", default: false, description: "Web only: open Tapp's controlled Chromium window and show a cursor/HUD for each exploration action. Evidence screenshots exclude the overlay." },
2761
2761
  device: { type: "string", description: "Web only: render as a Playwright device profile (e.g. \"iPhone 13\") — viewport, user agent, touch" },
2762
2762
  viewport: { type: "string", description: "Web only: explicit viewport WIDTHxHEIGHT (e.g. \"390x844\"); overrides the device profile's viewport" },
@@ -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;
@@ -3616,7 +3678,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3616
3678
  const wantsAndroid = isNonEmptyString(args.androidAppId);
3617
3679
  const targets = [wantsWeb, wantsAndroid, isNonEmptyString(args.appBundleId)].filter(Boolean).length;
3618
3680
  if (targets !== 1) {
3619
- return errorResult("Provide exactly one of appBundleId (iOS), androidAppId (Android), or url (web beta)");
3681
+ return errorResult("Provide exactly one of appBundleId (iOS), androidAppId (Android), or url (web)");
3620
3682
  }
3621
3683
  if (args.watch === true && !wantsWeb) return errorResult("watch is currently available for web exploration only");
3622
3684
 
@@ -362,7 +362,7 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
362
362
  "privacy or API data minimization",
363
363
  "brand and SEO consistency",
364
364
  "visual credibility or asset quality (vision review; needs an API key)",
365
- "only the first few visible buttons per page are probed (web beta)",
365
+ "only the first few visible buttons per page are probed (web)",
366
366
  "content & reachability regressions require a baseline",
367
367
  ];
368
368
  if (outbound?.mailtoSkipped === "egress-policy") notChecked.push("mailto address domains (MX lookups are skipped by the public-egress policy)");
@@ -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
+ }
@@ -1,4 +1,4 @@
1
- // Web exploration driver (beta) — the second platform behind the OCQA marker protocol.
1
+ // Web exploration driver — the second platform behind the OCQA marker protocol.
2
2
  //
3
3
  // Drives a real browser via Playwright and emits the SAME OCQA_* marker lines the iOS
4
4
  // XCUITest harness emits, into the same captures/<id>/ocqa-markers.txt layout — so the
@@ -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.20",
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