@aarwitz/tapp 0.16.3 → 0.16.4

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
 
@@ -94,6 +94,10 @@ Returns `{verdict, confidence, headline, screensExplored, actionsPerformed, find
94
94
  the user** for them rather than re-running blind.
95
95
  - Diff two runs: pass the previous run's `findings` as `baselineFindings` → you get a
96
96
  `regression` block (`new` / `persisting` / `resolved`, plus a CI `gate` signal).
97
+ - On web, report the exact verdict but preserve its scope: Tapp deterministically checks technical
98
+ behavior such as failed requests, missing assets, placeholder links, and inert controls. It does
99
+ not validate marketing claims against APIs, API field privacy, brand consistency, or subjective
100
+ marketplace credibility unless an explicit reviewed test/contract covers them.
97
101
 
98
102
  ## Flows (deterministic E2E tests)
99
103
 
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"
@@ -209,14 +209,21 @@ ranks runs, it doesn't promise odds.
209
209
  `tapp_run_qa` explores like a user — accessibility surfaces on iOS/Android and a real browser on web —
210
210
  and detects crashes, failed sign-ins, dead buttons, stuck loading screens, error surfaces,
211
211
  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**:
212
+ broken links and assets, and visible placeholder links with no destination). The verdict is
213
+ **deterministic** (no LLM in the run loop) and **honest**:
213
214
 
214
215
  - `blocked` — a release-blocking issue was found.
215
216
  - `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
217
+ - `ready` — genuinely explored with no detected blockers in the checks that ran. **A shallow run
218
+ is never `ready`** — if the
217
219
  app crashed on launch or a login wall blocked exploration, you get `inconclusive: true`,
218
220
  not a false pass. Absence of findings is not a pass.
219
221
 
222
+ Web beta presents a `ready` result as **AUTOMATED CHECKS PASSED**, not “ship-ready.” Its report
223
+ explicitly excludes content/claim accuracy, privacy and API data minimization, brand/SEO
224
+ consistency, and subjective visual credibility. Those require reviewed contracts, privacy review,
225
+ or human/vision judgment; a green technical crawl must not imply they were validated.
226
+
220
227
  Apps behind a login? Pass `testEmail`/`testPassword` (typed into the login form automatically),
221
228
  `appLaunchArgs` (e.g. `["--uitesting"]` if your app supports a bypass), or explicit `loginSteps`
222
229
  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, 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,12 +337,11 @@ 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("");
@@ -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, 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,7 +101,7 @@ 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">· release score ${r.releaseScore ?? r.confidence}/100</span></h1>
106
105
  <div class="meta">${esc(label)} · ${r.screensExplored} screens · ${r.actionsPerformed} actions · ${r.findingCounts.total} finding(s)</div>
107
106
  <div class="headline">${esc(r.headline)}</div>
108
107
  <h2>Findings</h2>
@@ -114,7 +113,7 @@ ${findingsHtml}
114
113
  ${shotsHtml || "<p class='dim'>No screenshots captured.</p>"}
115
114
  </div>
116
115
  ${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>
116
+ <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
117
  </body>
119
118
  </html>
120
119
  `;
@@ -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, 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,7 +1320,7 @@ 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)
@@ -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",
@@ -100,6 +101,11 @@ export function severityRank(s) {
100
101
  return { critical: 0, high: 1, medium: 2, low: 3 }[s] ?? 4;
101
102
  }
102
103
 
104
+ export function verdictBadge(report) {
105
+ if (report?.platform === "web" && report?.verdict === "ready") return "🟢 AUTOMATED CHECKS PASSED";
106
+ return { ready: "🟢 SHIP-READY", caution: "🟡 CAUTION", blocked: "🔴 BLOCKED" }[report?.verdict] || report?.verdict;
107
+ }
108
+
103
109
  // Turn a capture's OCQA markers into the same ship/no-ship report Tapp produces:
104
110
  // deduped findings + a trustworthy verdict with a coverage floor (mirrors OrchestratorService).
105
111
  export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
@@ -156,7 +162,9 @@ export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
156
162
  // Chromium can also surface one 404 through both response and requestfailed listeners;
157
163
  // keep the concrete missing-asset finding and discard that transport-level duplicate.
158
164
  const normalizedIssues = rawIssues.map((issue) => {
159
- if (platform !== "web" || !["missing_asset", "network_error"].includes(issue.type)) return issue;
165
+ if (platform !== "web") return issue;
166
+ if (issue.type === "placeholder_link" && issue.target) return { ...issue, screen: null };
167
+ if (!["missing_asset", "network_error"].includes(issue.type)) return issue;
160
168
  const title = String(issue.title || "");
161
169
  const match = issue.type === "missing_asset"
162
170
  ? title.match(/^404 asset:\s+(\S+)/i)
@@ -205,7 +213,9 @@ export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
205
213
  const headline = inconclusive
206
214
  ? `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
215
  : verdict === "ready"
208
- ? "Ship-ready no release-blocking issues found."
216
+ ? platform === "web"
217
+ ? "Automated web checks passed — no release-blocking technical issues found in the exercised surfaces. This is not a content, privacy, brand, or business-claim review."
218
+ : "Ship-ready — no release-blocking issues found."
209
219
  : verdict === "caution"
210
220
  ? `Proceed with caution — ${findings.length} issue(s) to review.`
211
221
  : `Not ready — ${findings.length} issue(s): ${crit} critical, ${high} high, ${med} medium, ${low} low.`;
@@ -220,11 +230,14 @@ export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
220
230
  if (platform === "web") {
221
231
  checkedFor = [
222
232
  "page errors (uncaught exceptions)", "failed/5xx requests", "broken links (404)",
223
- "dead buttons", "error text on pages", "load timeouts",
233
+ "placeholder links with no destination", "dead buttons", "error text on pages", "load timeouts",
224
234
  ];
225
235
  notChecked = [
226
236
  "app-specific business logic (cover with Flows: record or generate, then assert)",
227
- "visual correctness layout/images/clipping (vision review; needs an API key)",
237
+ "content and claim accuracy (including copy versus API data)",
238
+ "privacy or API data minimization",
239
+ "brand and SEO consistency",
240
+ "visual credibility or asset quality (vision review; needs an API key)",
228
241
  "only the first few visible buttons per page are probed (web beta)",
229
242
  "content & reachability regressions require a baseline",
230
243
  ];
@@ -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.4",
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",