@aarwitz/tapp 0.16.3 → 0.16.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -11,7 +11,7 @@ the app already on the simulator); it also accepts a repo dir, a `path/to/App.ap
11
11
  bundle id, or (qa only) an http(s) URL. You never need to know a bundle id up front.
12
12
 
13
13
  ```bash
14
- npx -y @aarwitz/tapp qa [target] # autonomous QA → ship/no-ship verdict + findings (≈ tapp_run_qa)
14
+ npx -y @aarwitz/tapp qa [target] # autonomous QA → scoped release verdict + findings (≈ tapp_run_qa)
15
15
  npx -y @aarwitz/tapp open [target] # launch + screen summary + screenshot saved to a file (≈ tapp_open_app)
16
16
  npx -y @aarwitz/tapp tree [target] # accessibility tree, --json for every element (≈ tapp_ui_tree)
17
17
  npx -y @aarwitz/tapp shot # screenshot the booted sim → file path (≈ tapp_screenshot)
@@ -44,7 +44,7 @@ installs, returns the bundle id) → `tapp_run_qa {appBundleId}`.
44
44
  |---|---|---|
45
45
  | "Show me / screenshot a screen" | `tapp_open_app` (launch + screenshot + tree, ~15s) | `tapp_run_qa` (a full multi-minute QA exploration) |
46
46
  | "Tap through / drive / fill a form / log in" | `tapp_session_start` → `session_act` loop | repeated `open_app` calls (cold relaunch each time) |
47
- | "Is my app broken? Is it ship-ready? Find bugs" | `tapp_run_qa` — `appBundleId` for iOS, `androidAppId` for Android, `url` for owned web apps | a manual session (QA exploration is autonomous) |
47
+ | "Is my app broken? Is it ship-ready? Find bugs" | `tapp_run_qa` — `appBundleId` for iOS, `androidAppId` for Android, `url` for owned web apps; web `ready` means the disclosed automated checks passed, not that copy/privacy/brand claims were reviewed | a manual session (QA exploration is autonomous) |
48
48
  | "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 |
49
49
  | "What's on screen right now?" | `tapp_screenshot` / `tapp_ui_tree` | relaunching the app |
50
50
 
@@ -82,10 +82,13 @@ Rules that prevent 90% of failures:
82
82
 
83
83
  ## Autonomous QA (`tapp_run_qa`)
84
84
 
85
- Returns `{verdict, confidence, headline, screensExplored, actionsPerformed, findings[]}`.
85
+ Returns `{verdict, confidence, releaseScore, headline, screensExplored, actionsPerformed, findings[]}`.
86
+ Exploratory web runs set `confidence` and `releaseScore` to `null`; report their deterministic
87
+ finding counts, advisory sampled-probe counts, and coverage instead of inventing a scalar.
86
88
 
87
- - `verdict`: `ready` | `caution` | `blocked`. **Trust it it's deterministic.** Report it to the
88
- user as-is; never soften a `blocked` or inflate a `caution`.
89
+ - `verdict`: `ready` | `caution` | `blocked`. Report it as-is; never soften a `blocked` or inflate a
90
+ `caution`. Judgment is deterministic for a given evidence trace, while adaptive exploration and
91
+ live target state can still change which evidence a run observes.
89
92
  - `inconclusive: true` means the run couldn't see enough (crash on launch, login wall). That is
90
93
  **not a pass** — tell the user what blocked exploration and what would unblock it.
91
94
  - Login walls: pass `testEmail`/`testPassword` (auto-typed into login forms), `appLaunchArgs`
@@ -94,6 +97,11 @@ Returns `{verdict, confidence, headline, screensExplored, actionsPerformed, find
94
97
  the user** for them rather than re-running blind.
95
98
  - Diff two runs: pass the previous run's `findings` as `baselineFindings` → you get a
96
99
  `regression` block (`new` / `persisting` / `resolved`, plus a CI `gate` signal).
100
+ - On web, report the exact verdict but preserve its scope: Tapp deterministically checks technical
101
+ behavior such as failed requests, missing assets, and placeholder links. Dead-control probes are
102
+ budget-capped advisory findings and do not drive the verdict. Tapp does
103
+ not validate marketing claims against APIs, API field privacy, brand consistency, or subjective
104
+ marketplace credibility unless an explicit reviewed test/contract or verifier covers them.
97
105
 
98
106
  ## Flows (deterministic E2E tests)
99
107
 
package/README.md CHANGED
@@ -26,7 +26,7 @@ Three platforms, one judgment layer:
26
26
  does not link a Tapp SDK.
27
27
  - **Web (beta)** — built *on* Playwright. Your agent already has browser hands; tapp adds the
28
28
  autonomous exploration, the deterministic detectors (uncaught exceptions, failed requests,
29
- dead buttons, broken links, error pages), and the same verdict.
29
+ dead buttons, broken links, placeholder `href="#"` links, error pages), and the same verdict.
30
30
 
31
31
  ```
32
32
  you: "Add a logout button to the settings screen"
@@ -168,7 +168,7 @@ Then ask your agent:
168
168
  | 📸 | `tapp_screenshot` | Whatever's on the sim right now, as an inline image. |
169
169
  | 🌳 | `tapp_ui_tree` | The accessibility tree of the current screen (ids, labels, hittability). |
170
170
  | 🕹 | `tapp_session_start/act/end` | **Interactive driving** — the Playwright loop. App launches once; each act (tap/type/swipe/back/wait) returns the fresh tree. |
171
- | 🧪 | `tapp_run_qa` | **Autonomous QA** — explores with no authored test, returns `{verdict, releaseScore, findings[]}`. Takes `appBundleId` (iOS), `androidAppId` (Android), or `url` (web). |
171
+ | 🧪 | `tapp_run_qa` | **Autonomous QA** — explores with no authored test, returns `{verdict, releaseScore, findings[]}` (`releaseScore` is `null` for exploratory web). Takes `appBundleId` (iOS), `androidAppId` (Android), or `url` (web). |
172
172
  | 🧭 | `tapp_init` | **Repository import** — detect targets; optionally explore a real surface; persist the shared UI Map; construct the evidence-classified model and grounded release plan. |
173
173
  | 👤 | `tapp_actor_config` | **Actor/session setup** — store roles, isolation/provisioning, and environment-variable names without accepting or persisting credential values. |
174
174
  | ✅ | `tapp_release_plan` | **Release-plan lifecycle** — inspect, approve/reject/defer, generate, real-target validate, and explicitly promote proposed guarantees without silent test edits. |
@@ -193,7 +193,7 @@ deterministic per-platform navigation root used for bounded changed-surface repl
193
193
 
194
194
  **Adaptive exploration, deterministic judgment.** Exploration is adaptive — two runs may
195
195
  traverse different paths through your app. Judgment is deterministic: the same evidence
196
- trace always produces the same findings, the same score, and the same verdict — no LLM variability
196
+ trace always produces the same findings and verdict — no LLM variability
197
197
  in the decision loop. PR gating keys on the **regression diff**
198
198
  (stable finding signatures vs. a baseline), so it reacts to what *changed*, not to
199
199
  run-to-run path variance. For critical user journeys, committed **Tasks and Flows** provide the stable CI
@@ -201,22 +201,38 @@ suite: reusable semantic actions, exact assertions, condition-based waits, fresh
201
201
  and evidence on failure. We call this *flake-resistant*, not magically flake-free—backend outages,
202
202
  unstable test data, and poorly identified controls can still make any E2E test fail.
203
203
 
204
- **A release score, not "confidence."** The 0–100 number is a heuristic quality score from
205
- fixed, documented deductions we don't call it confidence because it isn't calibrated
206
- probability. Calibrating it against seeded-fault benchmarks is ongoing work; until then it
207
- ranks runs, it doesn't promise odds.
204
+ **Native has a heuristic release score; exploratory web does not.** The native 0–100 number comes
205
+ from fixed deductions and is not calibrated probability. Web reports deterministic findings,
206
+ advisory budget-capped control probes, and concrete coverage instead of compressing those unlike
207
+ signals into a scalar. Committed Flows, Tasks, contracts, and baseline regressions provide the web
208
+ merge decision.
208
209
 
209
210
  `tapp_run_qa` explores like a user — accessibility surfaces on iOS/Android and a real browser on web —
210
211
  and detects crashes, failed sign-ins, dead buttons, stuck loading screens, error surfaces,
211
212
  navigation loops, and dead ends (plus, on web: uncaught JS exceptions, failed/5xx requests,
212
- broken links and assets). The verdict is **deterministic** (no LLM in the run loop) and **honest**:
213
+ broken links and assets, and visible placeholder links with no destination). The verdict is
214
+ **deterministic** (no LLM in the run loop) and **honest**:
213
215
 
214
216
  - `blocked` — a release-blocking issue was found.
215
217
  - `caution` — issues to review, or the run couldn't see enough.
216
- - `ready` — genuinely explored with no blockers. **A shallow run is never `ready`** — if the
218
+ - `ready` — genuinely explored with no detected blockers in the checks that ran. **A shallow run
219
+ is never `ready`** — if the
217
220
  app crashed on launch or a login wall blocked exploration, you get `inconclusive: true`,
218
221
  not a false pass. Absence of findings is not a pass.
219
222
 
223
+ Web beta presents a `ready` result as **AUTOMATED CHECKS COMPLETE**, not “ship-ready,” and displays
224
+ no scalar score. Exhaustive checks on each exercised page drive the verdict; sampled control probes
225
+ remain visible findings but are advisory. The report
226
+ explicitly excludes content/claim accuracy, privacy and API data minimization, brand/SEO
227
+ consistency, and subjective visual credibility. Those require reviewed contracts, privacy review,
228
+ or human/vision judgment; an exploratory crawl must not imply they were validated.
229
+
230
+ For a business guarantee such as “every coach is insured,” use a deterministic app-owned verifier
231
+ endpoint that returns success only when the invariant holds, then require that status and the
232
+ customer-visible claim in a release contract. The current DSL does not yet read arbitrary JSON
233
+ response bodies or compare a cross-origin API payload directly with page copy; use a verifier or an
234
+ explicit CI preflight rather than assuming autonomous QA inferred the guarantee.
235
+
220
236
  Apps behind a login? Pass `testEmail`/`testPassword` (typed into the login form automatically),
221
237
  `appLaunchArgs` (e.g. `["--uitesting"]` if your app supports a bypass), or explicit `loginSteps`
222
238
  for custom login UIs.
@@ -26,7 +26,7 @@
26
26
  // any fail on any finding at all, or any flow failure. Strictest.
27
27
  import fs from "fs";
28
28
  import path from "node:path";
29
- import { buildQaReport, computeRegression, computeContentCollapse, computeReachabilityLoss } from "./report.js";
29
+ import { buildQaReport, computeRegression, computeContentCollapse, computeReachabilityLoss, qaScoreLabel, verdictBadge } from "./report.js";
30
30
  import { writeHtmlReport } from "./html-report.js";
31
31
  import { buildUiMapFromMarkers, writeUiMap } from "./ui-map.js";
32
32
  import { proposeSelectorMaintenance, validateWebMaintenanceProposal } from "./maintenance-proposal.js";
@@ -337,16 +337,18 @@ function enrichPrPlan(plan, contracts, currentUiMap = null, markersPath = "", pr
337
337
  return { ...plan, selected, explorationTargets, maintenanceCandidates, execution: counts };
338
338
  }
339
339
 
340
- const VERDICT_BADGE = { ready: "🟢 SHIP-READY", caution: "🟡 CAUTION", blocked: "🔴 BLOCKED" };
341
340
  const SEV_ICON = { critical: "🟥", high: "🟧", medium: "🟨", low: "🟩" };
342
341
 
343
342
  function renderMarkdown(report, regression, flows, scenarios, contracts, prPlan, gate) {
344
343
  const lines = [];
345
- lines.push(`## tapp release check — ${VERDICT_BADGE[report.verdict] || report.verdict}`);
344
+ lines.push(`## tapp release check — ${verdictBadge(report)}`);
346
345
  lines.push("");
347
346
  lines.push(report.headline);
348
347
  lines.push("");
349
- lines.push(`**release score ${report.confidence}/100** · ${report.screensExplored} screens · ${report.actionsPerformed} actions · ${report.findingCounts.total} finding(s)`);
348
+ lines.push(`**${qaScoreLabel(report)}** · ${report.screensExplored} screens · ${report.actionsPerformed} actions · ${report.findingCounts.total} finding(s)`);
349
+ if (report.platform === "web") {
350
+ lines.push(`**Verdict basis:** ${report.verdictFindingCounts?.total || 0} deterministic finding(s); ${report.sampledFindingCounts?.total || 0} sampled probe finding(s) are advisory.`);
351
+ }
350
352
  if (report.uiMap) lines.push(`**UI Map:** ${report.uiMap.nodeCount} states · ${report.uiMap.edgeCount} transitions · ${report.uiMap.controlCount} semantic controls`);
351
353
  if (report.findings.length) {
352
354
  lines.push("");
@@ -489,11 +491,17 @@ if (collapsed.length) {
489
491
  report.findings.push(...collapsed);
490
492
  report.findingCounts.high += collapsed.length;
491
493
  report.findingCounts.total += collapsed.length;
492
- // Keep the displayed verdict consistent with the merged findings (same scoring as report.js:
493
- // high costs 10 confidence; any high caps the verdict at caution).
494
- report.confidence = Math.max(0, report.confidence - collapsed.length * 10);
495
- report.releaseScore = report.confidence;
496
- if (report.verdict === "ready") report.verdict = report.confidence < 50 ? "blocked" : "caution";
494
+ report.verdictFindingCounts.high += collapsed.length;
495
+ report.verdictFindingCounts.total += collapsed.length;
496
+ // Keep native scoring compatible. Exploratory web deliberately has no scalar; deterministic
497
+ // baseline regressions still raise its verdict directly.
498
+ if (Number.isFinite(report.confidence)) {
499
+ report.confidence = Math.max(0, report.confidence - collapsed.length * 10);
500
+ report.releaseScore = report.confidence;
501
+ }
502
+ if (report.verdict === "ready") {
503
+ report.verdict = Number.isFinite(report.confidence) && report.confidence < 50 ? "blocked" : "caution";
504
+ }
497
505
  report.headline = `Proceed with caution — ${collapsed.length} screen(s) regressed vs. baseline (content collapsed or became unreachable).`;
498
506
  }
499
507
  const regression = computeRegression(report.findings, baseline?.findings ?? null);
@@ -8,9 +8,8 @@
8
8
 
9
9
  import fs from "fs";
10
10
  import path from "path";
11
- import { buildQaReport } from "./report.js";
11
+ import { buildQaReport, qaScoreLabel, verdictBadge } from "./report.js";
12
12
 
13
- const BADGE = { ready: "🟢 SHIP-READY", caution: "🟡 CAUTION", blocked: "🔴 BLOCKED" };
14
13
  const SEV_COLOR = { critical: "#cf222e", high: "#bc4c00", medium: "#9a6700", low: "#57606a" };
15
14
 
16
15
  const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
@@ -102,8 +101,9 @@ export function writeHtmlReport(captureDir, { report, label = "" } = {}) {
102
101
  </style>
103
102
  </head>
104
103
  <body>
105
- <h1>${BADGE[r.verdict] || esc(r.verdict)} <span class="dim">· release score ${r.releaseScore ?? r.confidence}/100</span></h1>
104
+ <h1>${esc(verdictBadge(r))} <span class="dim">· ${esc(qaScoreLabel(r))}</span></h1>
106
105
  <div class="meta">${esc(label)} · ${r.screensExplored} screens · ${r.actionsPerformed} actions · ${r.findingCounts.total} finding(s)</div>
106
+ ${r.platform === "web" ? `<div class="meta">Verdict basis: ${r.verdictFindingCounts?.total || 0} deterministic finding(s); ${r.sampledFindingCounts?.total || 0} sampled probe finding(s) are advisory.</div>` : ""}
107
107
  <div class="headline">${esc(r.headline)}</div>
108
108
  <h2>Findings</h2>
109
109
  <ul class="findings">
@@ -114,7 +114,7 @@ ${findingsHtml}
114
114
  ${shotsHtml || "<p class='dim'>No screenshots captured.</p>"}
115
115
  </div>
116
116
  ${videoHtml}
117
- <footer>Generated by <a href="https://github.com/aarwitz/tapp">Tapp</a> — autonomous QA with a deterministic ship/no-ship verdict. Ship with proof.</footer>
117
+ <footer>Generated by <a href="https://github.com/aarwitz/tapp">Tapp</a> — deterministic automated checks with explicit coverage limits. Review unchecked product risks before release.</footer>
118
118
  </body>
119
119
  </html>
120
120
  `;
@@ -12,7 +12,7 @@ import {
12
12
  ListToolsRequestSchema,
13
13
  } from "@modelcontextprotocol/sdk/types.js";
14
14
 
15
- import { parseOcqaMarkers, buildQaReport, computeRegression } from "./report.js";
15
+ import { parseOcqaMarkers, buildQaReport, computeRegression, qaScoreLabel, verdictBadge } from "./report.js";
16
16
  import { existingProjectArtifactPath, projectArtifactDirectory } from "./project-paths.js";
17
17
 
18
18
  const __filename = fileURLToPath(import.meta.url);
@@ -1289,7 +1289,6 @@ function errorResult(message, details = {}) {
1289
1289
  // programmatic use. This is what makes Tapp feel like a modern dev harness
1290
1290
  // ("Explored 14 screens · 3 issues · ship: caution") rather than a wall of JSON.
1291
1291
  const SEV = { critical: "🔴", high: "🟠", medium: "🟡", low: "⚪️" };
1292
- const VERDICT_BADGE = { ready: "🟢 SHIP-READY", caution: "🟡 CAUTION", blocked: "🔴 BLOCKED" };
1293
1292
 
1294
1293
  /** Result with a human-readable text block first and structured data attached for the agent. */
1295
1294
  function richResult(text, structured) {
@@ -1321,17 +1320,20 @@ export function qaNextSteps(report, surface = "mcp") {
1321
1320
 
1322
1321
  function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiConfigured, reportHtml, recording, uiMap, surface = "mcp" } = {}) {
1323
1322
  const c = report.findingCounts || {};
1324
- const badge = VERDICT_BADGE[report.verdict] || report.verdict;
1323
+ const badge = verdictBadge(report);
1325
1324
  const sevBits = ["critical", "high", "medium", "low"]
1326
1325
  .map((k) => (c[k] ? `${SEV[k]} ${c[k]} ${k}` : null))
1327
1326
  .filter(Boolean)
1328
1327
  .join(", ");
1329
1328
  const L = [];
1330
- L.push(`### 🧪 QA complete — ${badge} · release score ${report.confidence}/100${bundleId ? `\n\`${bundleId}\`` : ""}`);
1329
+ L.push(`### 🧪 QA complete — ${badge} · ${qaScoreLabel(report)}${bundleId ? `\n\`${bundleId}\`` : ""}`);
1331
1330
  L.push("");
1332
1331
  L.push(report.headline);
1333
1332
  L.push("");
1334
1333
  L.push(`**Coverage** — ${report.screensExplored} screens · ${report.actionsPerformed} actions${timedOut ? " · ⏱️ hit time limit" : ""}`);
1334
+ if (report.platform === "web") {
1335
+ L.push(`**Verdict basis** — ${report.verdictFindingCounts?.total || 0} deterministic finding(s); ${report.sampledFindingCounts?.total || 0} sampled probe finding(s) are advisory`);
1336
+ }
1335
1337
  if (uiMap) L.push(`**UI Map** — ${uiMap.nodeCount} states · ${uiMap.edgeCount} transitions · ${uiMap.controlCount} semantic controls · ${uiMap.path}`);
1336
1338
  if (reportHtml) L.push(`**Evidence** — 📄 ${reportHtml} (screenshots of every screen + findings, shareable)`);
1337
1339
  if (recording) L.push(`**Recording** — 🎬 ${recording} (full exploration, embedded in the evidence page)`);
@@ -2071,13 +2073,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2071
2073
  description:
2072
2074
  "Run autonomous QA against iOS (appBundleId), Android (androidAppId), OR a web app " +
2073
2075
  "(url — beta, requires Playwright installed) and return a structured " +
2074
- "ship/no-ship verdict. Use ONLY when the user wants a QA assessment / to find bugs / a verdict — this " +
2076
+ "QA verdict. Use ONLY when the user wants a QA assessment / to find bugs / a verdict — this " +
2075
2077
  "runs for MINUTES exploring the whole app. Do NOT use it just to view, screenshot, or reach a specific " +
2076
2078
  "screen — use tapp_open_app (launch + screenshot) or a session for that. Tapp explores the app " +
2077
2079
  "like a tester (taps, types, navigates, scrolls) and detects real issues — crashes, dead buttons, failed sign-ins, error screens, " +
2078
2080
  "stuck/hung screens; on web also uncaught JS exceptions, failed/5xx requests, broken links and assets. " +
2079
- "Returns {verdict: ready|caution|blocked, confidence, headline, screensExplored, " +
2080
- "actionsPerformed, findings:[{type,severity,category,title,screen}]}. The verdict has a coverage floor: " +
2081
+ "Returns {verdict: ready|caution|blocked, confidence, releaseScore, headline, screensExplored, " +
2082
+ "actionsPerformed, findings:[{type,severity,category,title,screen,evaluationTier}]}. Exploratory web " +
2083
+ "sets confidence/releaseScore to null and separates deterministic verdict findings from advisory " +
2084
+ "sampled control probes. The verdict has a coverage floor: " +
2081
2085
  "if the app barely explored (crash on launch / sign-in wall) it returns 'caution' + inconclusive, never a " +
2082
2086
  "false pass. For iOS the app must already be installed on a booted simulator (use tapp_list_simulators / " +
2083
2087
  "tapp_boot_simulator first). For web, only point it at an app/environment you own — it CLICKS things. " +
@@ -86,6 +86,7 @@ export const ISSUE_CATEGORY = {
86
86
  submit_failed: "unresponsive_element",
87
87
  error_surface: "network_error_surface",
88
88
  unresponsive_element: "unresponsive_element",
89
+ placeholder_link: "broken_link",
89
90
  dead_end: "navigation_dead_end",
90
91
  navigation_loop: "repeated_loop",
91
92
  navigation_trap: "navigation_dead_end",
@@ -95,11 +96,28 @@ export const ISSUE_CATEGORY = {
95
96
  explore_timeout: "performance_timeout",
96
97
  };
97
98
  export const CRITICAL_ISSUE_TYPES = new Set(["crash"]);
99
+ export const WEB_SAMPLED_ISSUE_TYPES = new Set(["unresponsive_element"]);
98
100
 
99
101
  export function severityRank(s) {
100
102
  return { critical: 0, high: 1, medium: 2, low: 3 }[s] ?? 4;
101
103
  }
102
104
 
105
+ export function findingEvaluationTier(finding, platform = "ios") {
106
+ return platform === "web" && WEB_SAMPLED_ISSUE_TYPES.has(finding?.type) ? "sampled" : "deterministic";
107
+ }
108
+
109
+ export function verdictBadge(report) {
110
+ if (report?.platform === "web" && report?.verdict === "ready") return "🔵 AUTOMATED CHECKS COMPLETE";
111
+ return { ready: "🟢 SHIP-READY", caution: "🟡 CAUTION", blocked: "🔴 BLOCKED" }[report?.verdict] || report?.verdict;
112
+ }
113
+
114
+ export function qaScoreLabel(report) {
115
+ const score = report?.releaseScore ?? report?.confidence;
116
+ if (Number.isFinite(score)) return `release score ${score}/100`;
117
+ if (report?.platform === "web") return "exploratory web · no scalar score";
118
+ return "score unavailable";
119
+ }
120
+
103
121
  // Turn a capture's OCQA markers into the same ship/no-ship report Tapp produces:
104
122
  // deduped findings + a trustworthy verdict with a coverage floor (mirrors OrchestratorService).
105
123
  export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
@@ -156,7 +174,9 @@ export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
156
174
  // Chromium can also surface one 404 through both response and requestfailed listeners;
157
175
  // keep the concrete missing-asset finding and discard that transport-level duplicate.
158
176
  const normalizedIssues = rawIssues.map((issue) => {
159
- if (platform !== "web" || !["missing_asset", "network_error"].includes(issue.type)) return issue;
177
+ if (platform !== "web") return issue;
178
+ if (issue.type === "placeholder_link" && issue.target) return { ...issue, screen: null };
179
+ if (!["missing_asset", "network_error"].includes(issue.type)) return issue;
160
180
  const title = String(issue.title || "");
161
181
  const match = issue.type === "missing_asset"
162
182
  ? title.match(/^404 asset:\s+(\S+)/i)
@@ -178,7 +198,11 @@ export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
178
198
  const key = `${i.type}|${i.screen}|${i.target ?? ""}`;
179
199
  if (seen.has(key)) continue;
180
200
  seen.add(key);
181
- findings.push({ ...i, category: ISSUE_CATEGORY[i.type] || i.type });
201
+ findings.push({
202
+ ...i,
203
+ category: ISSUE_CATEGORY[i.type] || i.type,
204
+ ...(platform === "web" ? { evaluationTier: findingEvaluationTier(i, platform) } : {}),
205
+ });
182
206
  }
183
207
  findings.sort((a, b) => severityRank(a.severity) - severityRank(b.severity));
184
208
 
@@ -189,23 +213,33 @@ export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
189
213
  const high = findings.filter((f) => f.severity === "high").length;
190
214
  const med = findings.filter((f) => f.severity === "medium").length;
191
215
  const low = findings.filter((f) => f.severity === "low").length;
216
+ const verdictFindings = platform === "web"
217
+ ? findings.filter((finding) => finding.evaluationTier !== "sampled")
218
+ : findings;
219
+ const verdictCrit = verdictFindings.filter((f) => f.severity === "critical").length;
220
+ const verdictHigh = verdictFindings.filter((f) => f.severity === "high").length;
221
+ const verdictMed = verdictFindings.filter((f) => f.severity === "medium").length;
222
+ const verdictLow = verdictFindings.filter((f) => f.severity === "low").length;
223
+ const sampledFindings = platform === "web" ? findings.filter((finding) => finding.evaluationTier === "sampled") : [];
192
224
 
193
225
  // Coverage floor: a verdict is only trustworthy if the app was actually exercised.
194
226
  const inconclusive = screensExplored < 2 || actionsPerformed < 3;
195
- let confidence = Math.max(0, Math.min(100, 100 - crit * 25 - high * 10 - med * 3));
196
- if (inconclusive) confidence = Math.min(confidence, 40);
227
+ let riskScore = Math.max(0, Math.min(100, 100 - verdictCrit * 25 - verdictHigh * 10 - verdictMed * 3));
228
+ if (inconclusive) riskScore = Math.min(riskScore, 40);
197
229
 
198
230
  let verdict;
199
- if (crit > 0) verdict = "blocked";
231
+ if (verdictCrit > 0) verdict = "blocked";
200
232
  else if (inconclusive) verdict = "caution";
201
- else if (confidence < 50) verdict = "blocked";
202
- else if (high > 0 || confidence < 80) verdict = "caution";
233
+ else if (riskScore < 50) verdict = "blocked";
234
+ else if (verdictHigh > 0 || riskScore < 80) verdict = "caution";
203
235
  else verdict = "ready";
204
236
 
205
237
  const headline = inconclusive
206
238
  ? `Inconclusive — only ${screensExplored} screen(s) / ${actionsPerformed} action(s) explored. The app may have crashed on launch, be stuck behind a sign-in wall, or otherwise prevent exploration. Absence of issues is NOT a pass.`
207
239
  : verdict === "ready"
208
- ? "Ship-ready no release-blocking issues found."
240
+ ? platform === "web"
241
+ ? "Automated web checks completed — no release-blocking deterministic findings in the exercised surfaces. Sampled control probes are advisory. This is not a content, privacy, brand, or business-claim review."
242
+ : "Ship-ready — no release-blocking issues found."
209
243
  : verdict === "caution"
210
244
  ? `Proceed with caution — ${findings.length} issue(s) to review.`
211
245
  : `Not ready — ${findings.length} issue(s): ${crit} critical, ${high} high, ${med} medium, ${low} low.`;
@@ -220,11 +254,14 @@ export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
220
254
  if (platform === "web") {
221
255
  checkedFor = [
222
256
  "page errors (uncaught exceptions)", "failed/5xx requests", "broken links (404)",
223
- "dead buttons", "error text on pages", "load timeouts",
257
+ "placeholder links with no destination", "sampled dead-button probes (advisory)", "error text on pages", "load timeouts",
224
258
  ];
225
259
  notChecked = [
226
260
  "app-specific business logic (cover with Flows: record or generate, then assert)",
227
- "visual correctness layout/images/clipping (vision review; needs an API key)",
261
+ "content and claim accuracy (including copy versus API data)",
262
+ "privacy or API data minimization",
263
+ "brand and SEO consistency",
264
+ "visual credibility or asset quality (vision review; needs an API key)",
228
265
  "only the first few visible buttons per page are probed (web beta)",
229
266
  "content & reachability regressions require a baseline",
230
267
  ];
@@ -259,11 +296,14 @@ export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
259
296
 
260
297
  return {
261
298
  verdict,
262
- // `releaseScore` is the honest name: a heuristic quality score from fixed deductions,
263
- // NOT calibrated statistical confidence. `confidence` is kept as an alias for
264
- // compatibility (baselines, desktop app, existing consumers).
265
- confidence,
266
- releaseScore: confidence,
299
+ // Exploratory web QA deliberately has no scalar. Its verdict derives from deterministic
300
+ // checks on exercised pages; budget-capped control probes remain visible but advisory.
301
+ // Native keeps the legacy heuristic score until it has an equivalent tier split.
302
+ confidence: platform === "web" ? null : riskScore,
303
+ releaseScore: platform === "web" ? null : riskScore,
304
+ scoreUnavailableReason: platform === "web"
305
+ ? "Exploratory web runs report deterministic findings, advisory sampled probes, and coverage instead of a scalar release score."
306
+ : null,
267
307
  headline,
268
308
  inconclusive,
269
309
  checkedFor,
@@ -273,6 +313,20 @@ export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
273
313
  screensExplored,
274
314
  actionsPerformed,
275
315
  findingCounts: { critical: crit, high, medium: med, low, total: findings.length },
316
+ verdictFindingCounts: {
317
+ critical: verdictCrit,
318
+ high: verdictHigh,
319
+ medium: verdictMed,
320
+ low: verdictLow,
321
+ total: verdictFindings.length,
322
+ },
323
+ sampledFindingCounts: {
324
+ critical: sampledFindings.filter((f) => f.severity === "critical").length,
325
+ high: sampledFindings.filter((f) => f.severity === "high").length,
326
+ medium: sampledFindings.filter((f) => f.severity === "medium").length,
327
+ low: sampledFindings.filter((f) => f.severity === "low").length,
328
+ total: sampledFindings.length,
329
+ },
276
330
  findings,
277
331
  screens: Array.from(screens),
278
332
  screenElementCounts,
@@ -40,6 +40,127 @@ export function webControlLabel({ text = "", value = "", ariaLabel = "", title =
40
40
  .find(Boolean) || "button";
41
41
  }
42
42
 
43
+ export function webPlaceholderLinkFindings(links = []) {
44
+ const findings = [];
45
+ const seen = new Set();
46
+ for (const link of links || []) {
47
+ const rawHref = String(link?.rawHref || "").trim().toLowerCase();
48
+ const placeholder = rawHref === "#" || /^javascript:(?:void\(0\);?|;?)$/.test(rawHref);
49
+ if (!placeholder || link?.handlerHint) continue;
50
+ const label = String(link?.label || "").replace(/\s+/g, " ").trim().slice(0, 100);
51
+ const fingerprint = String(link?.fingerprint || "link").replace(/\s+/g, " ").trim().slice(0, 100) || "link";
52
+ const target = label || `unlabeled:${fingerprint}`;
53
+ if (seen.has(target)) continue;
54
+ seen.add(target);
55
+ findings.push({
56
+ type: "placeholder_link",
57
+ severity: label ? "medium" : "low",
58
+ title: label
59
+ ? `Link "${label}" has no destination (${rawHref === "#" ? 'href="#"' : `href="${rawHref}"`})`
60
+ : `Unlabeled link has no destination (${rawHref === "#" ? 'href="#"' : `href="${rawHref}"`})`,
61
+ target,
62
+ });
63
+ }
64
+ return findings;
65
+ }
66
+
67
+ export function webControlHadEffect({ wired = false, before = {}, after = {} } = {}) {
68
+ if (wired) return true;
69
+ return ["url", "title", "heading", "dialogs", "local"]
70
+ .some((key) => String(before?.[key] ?? "") !== String(after?.[key] ?? ""));
71
+ }
72
+
73
+ export function shouldReportWebRequestFailure(errorText = "") {
74
+ // Chromium emits ERR_ABORTED when Tapp deliberately leaves a page while images/video are
75
+ // still loading. That is navigation lifecycle noise, not evidence that the resource is broken.
76
+ return !/\bnet::ERR_ABORTED\b/i.test(String(errorText));
77
+ }
78
+
79
+ export function webPageAppearsBlank({ textLen = 0, controlCount = 0, visualContentCount = 0 } = {}) {
80
+ return Number(textLen) === 0 && Number(controlCount) === 0 && Number(visualContentCount) === 0;
81
+ }
82
+
83
+ async function installWebListenerTracking(context) {
84
+ await context.addInitScript(() => {
85
+ const key = Symbol.for("tapp.clickListeners");
86
+ const add = EventTarget.prototype.addEventListener;
87
+ const remove = EventTarget.prototype.removeEventListener;
88
+ EventTarget.prototype.addEventListener = function tappTrackedAdd(type, listener, options) {
89
+ if (type === "click" && this instanceof Element && listener) {
90
+ if (!this[key]) Object.defineProperty(this, key, { value: new Set(), configurable: true });
91
+ this[key].add(listener);
92
+ }
93
+ return add.call(this, type, listener, options);
94
+ };
95
+ EventTarget.prototype.removeEventListener = function tappTrackedRemove(type, listener, options) {
96
+ if (type === "click" && this instanceof Element && this[key]) this[key].delete(listener);
97
+ return remove.call(this, type, listener, options);
98
+ };
99
+ });
100
+ }
101
+
102
+ async function captureWebControlState(page, locator) {
103
+ const global = await page.evaluate(() => {
104
+ const visible = (element) => {
105
+ const style = window.getComputedStyle(element);
106
+ return style.visibility !== "hidden" && style.display !== "none" && element.getClientRects().length > 0;
107
+ };
108
+ const dialogs = [...document.querySelectorAll("dialog[open], [role=dialog], [aria-modal=true]")]
109
+ .filter(visible)
110
+ .map((element) => (element.getAttribute("aria-label") || element.textContent || "dialog").replace(/\s+/g, " ").trim().slice(0, 160))
111
+ .sort();
112
+ return {
113
+ title: document.title,
114
+ heading: document.querySelector("h1")?.textContent?.replace(/\s+/g, " ").trim() || "",
115
+ dialogs: JSON.stringify(dialogs),
116
+ };
117
+ }).catch(() => ({ title: "", heading: "", dialogs: "" }));
118
+
119
+ const local = await locator.evaluate((element) => {
120
+ const key = Symbol.for("tapp.clickListeners");
121
+ const visible = (candidate) => {
122
+ const style = window.getComputedStyle(candidate);
123
+ return style.visibility !== "hidden" && style.display !== "none" && candidate.getClientRects().length > 0;
124
+ };
125
+ let wired = false;
126
+ for (let candidate = element; candidate && candidate !== document.body; candidate = candidate.parentElement) {
127
+ if ((candidate[key] && candidate[key].size > 0) || typeof candidate.onclick === "function" || candidate.hasAttribute("onclick")) {
128
+ wired = true;
129
+ break;
130
+ }
131
+ }
132
+ if (!wired && element.matches("button[type=submit], input[type=submit]") && element.closest("form")) wired = true;
133
+ const describe = (root) => ({
134
+ tag: root.tagName,
135
+ className: typeof root.className === "string" ? root.className : "",
136
+ hidden: root.hidden,
137
+ open: root.hasAttribute("open"),
138
+ ariaExpanded: root.getAttribute("aria-expanded"),
139
+ ariaPressed: root.getAttribute("aria-pressed"),
140
+ ariaSelected: root.getAttribute("aria-selected"),
141
+ text: (root.textContent || "").replace(/\s+/g, " ").trim().slice(0, 300),
142
+ controls: [...root.querySelectorAll("button, a[href], input, textarea, select, [role=button]")]
143
+ .slice(0, 40)
144
+ .map((control) => ({
145
+ tag: control.tagName,
146
+ visible: visible(control),
147
+ disabled: !!control.disabled || control.getAttribute("aria-disabled") === "true",
148
+ checked: "checked" in control ? !!control.checked : null,
149
+ expanded: control.getAttribute("aria-expanded"),
150
+ pressed: control.getAttribute("aria-pressed"),
151
+ selected: control.getAttribute("aria-selected"),
152
+ label: (control.getAttribute("aria-label") || control.textContent || control.getAttribute("value") || "").replace(/\s+/g, " ").trim().slice(0, 80),
153
+ })),
154
+ });
155
+ const region = element.parentElement || element;
156
+ const controlledId = element.getAttribute("aria-controls");
157
+ const controlled = controlledId ? document.getElementById(controlledId) : null;
158
+ return { wired, signature: JSON.stringify([describe(region), controlled ? describe(controlled) : null]) };
159
+ }).catch(() => ({ wired: false, signature: "detached" }));
160
+
161
+ return { url: page.url(), ...global, local: local.signature, wired: local.wired };
162
+ }
163
+
43
164
  // Wait for a page to stop presenting an explicit loading state and for its semantic
44
165
  // surface to remain unchanged across a couple of samples. This is intentionally bounded:
45
166
  // live counters and animation-heavy pages still return evidence, marked unsettled.
@@ -314,7 +435,9 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
314
435
 
315
436
  const { chromium } = await loadPlaywright();
316
437
  const browser = await chromium.launch(webBrowserLaunchOptions());
317
- const page = await (await browser.newContext({ viewport: { width: 1280, height: 900 } })).newPage();
438
+ const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
439
+ await installWebListenerTracking(context);
440
+ const page = await context.newPage();
318
441
  page.setDefaultTimeout(NAV_TIMEOUT_MS);
319
442
 
320
443
  const deadline = Date.now() + timeoutSec * 1000;
@@ -324,14 +447,9 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
324
447
  emit("ISSUE", { type, severity, title, screen, ...(target ? { target } : {}) });
325
448
  };
326
449
 
327
- // Request counter: cheap "did that click cause network activity" signal for the
328
- // dead-button check (a button that fires a request is not dead).
329
- let requestCount = 0;
330
-
331
450
  // Async defect listeners: attribute to whatever screen is current when they fire.
332
451
  let currentScreen = start.pathname;
333
452
  let lastActionTarget = "";
334
- page.on("request", () => { requestCount += 1; });
335
453
  page.on("pageerror", (err) => issue("js_exception", "high", `Uncaught JS exception: ${String(err.message || err).slice(0, 120)}`, currentScreen));
336
454
  page.on("response", (res) => {
337
455
  try {
@@ -347,7 +465,9 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
347
465
  try {
348
466
  const u = new URL(req.url());
349
467
  if (u.origin !== start.origin) return;
350
- issue("network_error", "medium", `Request failed: ${u.pathname.slice(0, 80)} (${req.failure()?.errorText || "?"})`, currentScreen, u.pathname);
468
+ const errorText = req.failure()?.errorText || "?";
469
+ if (!shouldReportWebRequestFailure(errorText)) return;
470
+ issue("network_error", "medium", `Request failed: ${u.pathname.slice(0, 80)} (${errorText})`, currentScreen, u.pathname);
351
471
  } catch {}
352
472
  });
353
473
 
@@ -363,6 +483,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
363
483
  ...normalizedSeeds.filter((target) => !targetRoutes.has(target)).map((target) => ({ target, action: `PR target ${target}`, fromScreen: null, prTarget: true })),
364
484
  ];
365
485
  const screenshotFor = new Map();
486
+ const placeholderLinksSeen = new Set();
366
487
  let actions = 0;
367
488
  let screenCount = 0;
368
489
  let lastScreen = null;
@@ -413,6 +534,11 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
413
534
  title: document.title.trim(),
414
535
  controlCount: document.querySelectorAll("a[href], button, [role=button], input, select, textarea").length,
415
536
  textLen: (document.body?.innerText || "").trim().length,
537
+ visualContentCount: [...document.querySelectorAll("img, picture, video, canvas, svg, iframe, object, embed")]
538
+ .filter((el) => {
539
+ const style = window.getComputedStyle(el);
540
+ return style.visibility !== "hidden" && style.display !== "none" && el.getClientRects().length > 0;
541
+ }).length,
416
542
  alertText: [...document.querySelectorAll("[role=alert], [aria-live=assertive]")]
417
543
  .map((el) => el.textContent.trim()).filter(Boolean).join(" ").slice(0, 120),
418
544
  errorCandidateTexts: [...document.querySelectorAll("h1, h2, h3, p, [data-error], [data-testid*=error i]")]
@@ -428,6 +554,25 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
428
554
  .filter((el) => el.offsetParent !== null)
429
555
  .map((el) => (el.textContent || "").trim())
430
556
  .some((text) => /^(loading|fetching|please wait|preparing|connecting)(?:[.…!]*|\s.*)$/i.test(text)),
557
+ placeholderLinks: [...document.querySelectorAll("a[href]")]
558
+ .filter((el) => el.offsetParent !== null)
559
+ .map((el, index) => {
560
+ const listenerKey = Symbol.for("tapp.clickListeners");
561
+ const dataHandler = [...el.attributes]
562
+ .some((attribute) => /^data-(action|toggle|target|modal|waitlist)(?:-|$)/i.test(attribute.name));
563
+ const svgPath = el.querySelector("svg path")?.getAttribute("d") || "";
564
+ return {
565
+ rawHref: el.getAttribute("href") || "",
566
+ label: (el.getAttribute("aria-label") || el.getAttribute("title") || el.innerText || el.textContent || "").replace(/\s+/g, " ").trim().slice(0, 100),
567
+ handlerHint: el.getAttribute("role") === "button"
568
+ || el.hasAttribute("onclick")
569
+ || typeof el.onclick === "function"
570
+ || !!el[listenerKey]?.size
571
+ || el.hasAttribute("aria-controls")
572
+ || dataHandler,
573
+ fingerprint: el.id || el.getAttribute("data-testid") || svgPath.slice(0, 80) || `link-${index + 1}`,
574
+ };
575
+ }),
431
576
  inputs,
432
577
  controls,
433
578
  };
@@ -461,11 +606,16 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
461
606
  screenCount = screenshotFor.size;
462
607
  await page.screenshot({ path: screenshotPath }).catch(() => {});
463
608
  // Deterministic per-page detectors run once per distinct screen.
464
- if (info.textLen < 10) issue("blank_screen", "high", "Page rendered no visible text", screen);
609
+ if (webPageAppearsBlank(info)) issue("blank_screen", "high", "Page rendered no visible content", screen);
465
610
  else {
466
611
  const errorText = webErrorSurfaceText({ alertText: info.alertText, candidateTexts: info.errorCandidateTexts });
467
612
  if (errorText) issue("error_surface", "high", `Error shown: ${errorText.slice(0, 80)}`, screen);
468
613
  }
614
+ for (const finding of webPlaceholderLinkFindings(info.placeholderLinks)) {
615
+ if (placeholderLinksSeen.has(finding.target)) continue;
616
+ placeholderLinksSeen.add(finding.target);
617
+ issue(finding.type, finding.severity, finding.title, screen, finding.target);
618
+ }
469
619
  }
470
620
  return { key, screen, info };
471
621
  }
@@ -626,20 +776,10 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
626
776
  id: await b.getAttribute("id").catch(() => ""),
627
777
  }).slice(0, 40);
628
778
  if (/log ?out|sign ?out|delete|remove/i.test(label)) continue; // don't destroy test state
629
- const beforeUrl = page.url();
630
- // Dead-button detection watches four real effect channels DOM mutations, dialogs,
631
- // network activity, and navigation instead of the fragile innerHTML-length proxy
632
- // (same length same page; unrelated tickers ≠ this button worked).
633
- await page
634
- .evaluate(() => {
635
- window.__tappMut = 0;
636
- if (window.__tappMo) window.__tappMo.disconnect();
637
- window.__tappMo = new MutationObserver((muts) => { window.__tappMut += muts.length; });
638
- window.__tappMo.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true });
639
- })
640
- .catch(() => {});
641
- const dialogsBefore = await page.locator("dialog[open], [role=dialog], [aria-modal=true]").count().catch(() => 0);
642
- const reqBefore = requestCount;
779
+ // Capture only durable, user-visible semantics around this control. A global
780
+ // MutationObserver is intentionally avoided: carousels, chat launchers, and live
781
+ // counters can mutate while an unrelated dead button is clicked, creating verdict jitter.
782
+ const beforeState = await captureWebControlState(page, b);
643
783
  actions += 1;
644
784
  lastActionTarget = label;
645
785
  emit("ACTION", { type: "tap", target: label, screen: webActionScreen(ob), narrative: `Tapped "${label}"` });
@@ -653,16 +793,15 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
653
793
  continue;
654
794
  }
655
795
  await waitForWebStability(page);
656
- if (page.url() !== beforeUrl) {
796
+ if (page.url() !== beforeState.url) {
657
797
  await observe();
658
798
  await page.goBack({ waitUntil: "domcontentloaded" }).catch(() => {});
659
799
  await waitForWebStability(page);
660
800
  } else {
661
- const mutations = await page.evaluate(() => window.__tappMut || 0).catch(() => 0);
662
- const dialogsAfter = await page.locator("dialog[open], [role=dialog], [aria-modal=true]").count().catch(() => 0);
663
- const hadEffect = mutations > 0 || dialogsAfter !== dialogsBefore || requestCount > reqBefore;
801
+ const afterState = await captureWebControlState(page, b);
802
+ const hadEffect = webControlHadEffect({ wired: beforeState.wired, before: beforeState, after: afterState });
664
803
  if (!hadEffect) {
665
- issue("unresponsive_element", "medium", `Button "${label}" does nothing`, ob.screen, label);
804
+ issue("unresponsive_element", "medium", `Button "${label}" has no wiring or observable effect`, ob.screen, label);
666
805
  } else {
667
806
  // Same-URL SPA transitions are real screens too; URL-only observation
668
807
  // under-counted coverage and made healthy applications inconclusive.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aarwitz/tapp",
3
- "version": "0.16.3",
3
+ "version": "0.16.5",
4
4
  "mcpName": "io.github.aarwitz/tapp",
5
5
  "description": "Release contracts, autonomous QA, and evidence-backed CI gates for iOS, Android, and web.",
6
6
  "license": "MIT",