@aarwitz/tapp 0.17.13 → 0.17.14

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.13",
4
+ "version": "0.17.14",
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.13",
27
+ "@aarwitz/tapp@0.17.14",
28
28
  "mcp"
29
29
  ],
30
30
  "cwd": "${CLAUDE_PROJECT_DIR}"
package/AGENTS.md CHANGED
@@ -133,6 +133,11 @@ advisory). Report the finding counts and coverage; do not invent a scalar or a s
133
133
  (e.g. `["--uitesting"]` if the app has a test bypass), and/or `appLaunchEnv` (e.g. a staging
134
134
  backend URL). If the result shows `inputFieldsEncountered` and you have no credentials, **ask
135
135
  the user** for them rather than re-running blind.
136
+ - `tapp audit <url>` is the read-only pass: it renders the page and reads the tree but never
137
+ clicks, so it is the one analysis safe to point at production. It finds controls that were
138
+ never alive — dead in-page anchors, `aria-controls` naming nothing, placeholder links, buttons
139
+ with no handler/form/link — which Flows structurally cannot find, because nobody writes a test
140
+ for a button they believe does nothing. Exit 1 when it finds any.
136
141
  - Credential surfaces are catalogue-only without credentials, on every platform: with no
137
142
  `testEmail`/`testPassword` supplied, exploration records a login/signup form's fields but does
138
143
  not type into them, submit them, or open recovery/third-party-auth flows — a bare-app run may
@@ -1389,6 +1389,10 @@ class ExplorerTests: XCTestCase {
1389
1389
  // say "navigation-trap" or "frontier-drained" instead of blessing a 15/20 run "completed".
1390
1390
  var explorationStopCause = "action-budget"
1391
1391
  var leftAppObservations = 0
1392
+ // A loop the EXPLORER created by going back is its own traversal strategy, not an app
1393
+ // defect (field issue #9: "Personal Info ↔ back" filed as a finding). Remember when we
1394
+ // last navigated backwards so the loop detector can tell the two apart.
1395
+ var lastExplorerBackStep = -99
1392
1396
  while actionCount < maxActions {
1393
1397
  // Subtract time spent paused for interactive input so human typing never eats the budget.
1394
1398
  if Date().timeIntervalSince(startTime) - totalWaitSeconds > timeoutSeconds {
@@ -1889,10 +1893,11 @@ class ExplorerTests: XCTestCase {
1889
1893
  recent[recent.count - 3] == recent[recent.count - 6] &&
1890
1894
  Set(recent.suffix(3)).count == 3 &&
1891
1895
  Set(recentScreenTitles.suffix(3)).count == 3
1892
- if (hasLoop2 || hasLoop3) && !(authSucceeded && detectedInputs.contains { $0.secure }) {
1896
+ let period = hasLoop2 ? 2 : 3
1897
+ let explorerDroveTheLoop = actionCount - lastExplorerBackStep <= period * 2
1898
+ if (hasLoop2 || hasLoop3) && !explorerDroveTheLoop && !(authSucceeded && detectedInputs.contains { $0.secure }) {
1893
1899
  let loopKey = "nav_loop:\(titleStr)"
1894
1900
  if actionCounts[loopKey] == nil {
1895
- let period = hasLoop2 ? 2 : 3
1896
1901
  issues.append((type: "navigation_loop", severity: "low", title: "Navigation loop detected (period \(period))", desc: "Exploration is cycling between the same \(period) screens"))
1897
1902
  print("OCQA_ISSUE:{\"type\":\"navigation_loop\",\"severity\":\"low\",\"title\":\"Navigation loop\",\"screen\":\"\(escapedTitle)\",\"period\":\(period),\"step\":\(actionCount)}")
1898
1903
  actionCounts[loopKey] = 1
@@ -1947,6 +1952,7 @@ class ExplorerTests: XCTestCase {
1947
1952
  // tryGoBack does swipe-down as its last resort (sheet dismiss)
1948
1953
  let preBackTitle = titleStr
1949
1954
  let backWorked = tryGoBack()
1955
+ lastExplorerBackStep = actionCount
1950
1956
  actionCount += 1
1951
1957
  Thread.sleep(forTimeInterval: 0.3)
1952
1958
  let postElements = readUITree(app)
@@ -2214,6 +2220,7 @@ class ExplorerTests: XCTestCase {
2214
2220
  // Scrolling done — try to go back and verify the screen actually changed
2215
2221
  let preBackTitle = titleStr
2216
2222
  let backResult = tryGoBack()
2223
+ lastExplorerBackStep = actionCount
2217
2224
  actionCount += 1
2218
2225
  Thread.sleep(forTimeInterval: 0.3)
2219
2226
  let postBackElements = readUITree(app)
@@ -2270,6 +2277,7 @@ class ExplorerTests: XCTestCase {
2270
2277
  let swipeStart = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3))
2271
2278
  let swipeEnd = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.9))
2272
2279
  swipeStart.press(forDuration: 0.1, thenDragTo: swipeEnd)
2280
+ lastExplorerBackStep = actionCount
2273
2281
  actionCount += 1
2274
2282
  print("OCQA_ACTION:{\"type\":\"swipe_dismiss\",\"reason\":\"escape_stuck\",\"step\":\(actionCount),\"screen\":\"\(escapedTitle)\",\"narrative\":\"\(escapeJSON(recoveryNarrative("swipe_dismiss", screen: titleStr)))\"}")
2275
2283
  Thread.sleep(forTimeInterval: 0.5)
package/bin/tapp.js CHANGED
@@ -283,6 +283,7 @@ function safeCommandUsage(verb) {
283
283
  app: "tapp app [repo] [--no-open] [--port PORT]",
284
284
  report: "tapp report [captureId|latest]",
285
285
  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",
286
+ 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",
286
287
  doctor: "tapp doctor [--json]\n Exit codes: 0 environment healthy · 1 blocked (fix ❌ items)",
287
288
  install: "tapp install",
288
289
  mcp: "tapp mcp",
@@ -302,7 +303,7 @@ if (["--help", "-h"].includes(command)) {
302
303
  const knownCommands = new Set([
303
304
  "help", "version", "--version", "-v", "mcp", "init", "focus", "explore", "qa", "open",
304
305
  "tree", "shot", "screenshot", "apps", "build", "flow", "task", "contract", "scenario", "map",
305
- "pr", "plan", "baseline", "ci", "actor", "app", "studio", "report", "doctor", "install", "feedback",
306
+ "pr", "plan", "baseline", "ci", "actor", "app", "studio", "report", "doctor", "install", "feedback", "audit",
306
307
  ]);
307
308
  if (!knownCommands.has(command)) {
308
309
  console.error(`❌ Unknown command: ${command}`);
@@ -1150,9 +1151,48 @@ switch (command) {
1150
1151
  break;
1151
1152
  }
1152
1153
  if (!["run", "validate"].includes(verb) || !flowPath) {
1153
- console.error("usage: tapp flow example\n tapp flow steps [--json]\n tapp flow run <flow.yml> [--platform ios|android|web] [--actor NAME] [--email VALUE] [--password VALUE] [--url URL] [--app-id ID] [--apk FILE] [--serial ID]\n tapp flow validate <flow.yml>");
1154
+ console.error("usage: tapp flow example\n tapp flow steps [--json]\n tapp flow run <flow.yml|glob> [...more] [--platform ios|android|web] [--actor NAME] [--email VALUE] [--password VALUE] [--url URL] [--app-id ID] [--apk FILE] [--serial ID]\n tapp flow validate <flow.yml|glob> [...more]\n Several Flows (or a glob) run in sequence and print one line per Flow; exit 1 if any failed.");
1154
1155
  process.exit(2);
1155
1156
  }
1157
+ // Several Flows in one command (field issue #9): the shell usually expands the glob, but a
1158
+ // quoted pattern arrives whole. Everyone writes this loop by hand otherwise.
1159
+ const expandFlowPattern = (pattern) => {
1160
+ if (!/[*?]/.test(pattern)) return [pattern];
1161
+ const dir = path.dirname(pattern);
1162
+ const rx = new RegExp(`^${path.basename(pattern).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
1163
+ try {
1164
+ return fs.readdirSync(dir).filter((name) => rx.test(name)).sort().map((name) => path.join(dir, name));
1165
+ } catch { return []; }
1166
+ };
1167
+ const flowPaths = positionals.slice(1).flatMap(expandFlowPattern);
1168
+ if (flowPaths.length > 1) {
1169
+ const passThrough = Object.entries(flags).flatMap(([key, value]) =>
1170
+ value === true ? [`--${key}`] : typeof value === "string" ? [`--${key}`, value] : []);
1171
+ const rows = [];
1172
+ for (const candidate of flowPaths) {
1173
+ const name = path.basename(candidate).replace(/\.(ya?ml|json)$/i, "");
1174
+ process.stderr.write(`▶️ ${name}\n`);
1175
+ const child = spawnSync(process.execPath, [path.join(packageRoot, "bin", "tapp.js"), "flow", verb, candidate, ...passThrough], { encoding: "utf8", env: process.env });
1176
+ const output = `${child.stdout || ""}\n${child.stderr || ""}`;
1177
+ // Prefer the cause tapp already distilled; never the last line of a boxed error (#23).
1178
+ const lines = output.split("\n");
1179
+ const errorAt = lines.findIndex((line) => line.trim().startsWith("❌"));
1180
+ // A validation error puts its reasons on the bullet lines under the ❌ headline; a
1181
+ // headline alone ("Invalid web Flow — b-bad:") names no cause.
1182
+ const headline = errorAt >= 0
1183
+ ? [lines[errorAt], ...(/[::]\s*$/.test(lines[errorAt]) ? [lines[errorAt + 1] || ""] : [])].map((line) => line.trim()).filter(Boolean).join(" ")
1184
+ : "";
1185
+ const cause = (output.match(/^\s*(?:\*\*)?First failure:.*$/m)?.[0] || headline || "")
1186
+ .replace(/\*\*/g, "").replace(/\s+/g, " ").trim().slice(0, 160);
1187
+ rows.push({ name, ok: (child.status ?? 1) === 0, cause });
1188
+ }
1189
+ const width = Math.max(...rows.map((row) => row.name.length));
1190
+ console.log("");
1191
+ for (const row of rows) console.log(`${row.name.padEnd(width)} ${row.ok ? "PASS" : "FAIL"}${row.ok || !row.cause ? "" : ` ${row.cause}`}`);
1192
+ const failed = rows.filter((row) => !row.ok).length;
1193
+ console.log(`\n${rows.length - failed}/${rows.length} ${verb === "validate" ? "valid" : "passed"}`);
1194
+ process.exit(failed ? 1 : 0);
1195
+ }
1156
1196
  const absolute = path.resolve(flowPath);
1157
1197
  if (!fs.existsSync(absolute)) {
1158
1198
  console.error(`❌ Flow not found: ${absolute}`);
@@ -1463,6 +1503,38 @@ switch (command) {
1463
1503
  process.exit(2);
1464
1504
  }
1465
1505
 
1506
+ case "audit": {
1507
+ const { flags, positionals } = parseVerbArgs(rest);
1508
+ const url = positionals[0] || "";
1509
+ if (!/^https?:\/\//i.test(url)) {
1510
+ console.error("usage: tapp audit <http(s) url> [--json] [--device NAME] [--viewport WxH]\n Read-only structural audit: never clicks, safe against production.");
1511
+ process.exit(2);
1512
+ }
1513
+ try {
1514
+ const { auditWebPage } = await import(path.join(packageRoot, "mcp-server", "src", "web-explorer.js"));
1515
+ const result = await auditWebPage({
1516
+ url,
1517
+ device: typeof flags.device === "string" ? flags.device : "",
1518
+ viewport: typeof flags.viewport === "string" ? flags.viewport : "",
1519
+ });
1520
+ if (flags.json === true) {
1521
+ console.log(JSON.stringify({ kind: "tapp-structural-audit", readOnly: true, ...result }, null, 2));
1522
+ } else {
1523
+ console.log(`🔎 Structural audit — ${result.url}\n ${result.controlsExamined} control(s) examined · nothing was clicked`);
1524
+ if (!result.findings.length) console.log("\n✅ No structurally dead controls found.");
1525
+ else {
1526
+ console.log("");
1527
+ for (const finding of result.findings) console.log(` ⚠️ ${finding.title}`);
1528
+ 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.`);
1529
+ }
1530
+ }
1531
+ process.exit(result.findings.length ? 1 : 0);
1532
+ } catch (error) {
1533
+ console.error(`❌ ${error.message || String(error)}`);
1534
+ process.exit(2);
1535
+ }
1536
+ }
1537
+
1466
1538
  case "doctor": {
1467
1539
  const { flags: doctorFlags } = parseVerbArgs(rest);
1468
1540
  const jsonMode = doctorFlags.json === true;
@@ -1541,14 +1613,29 @@ switch (command) {
1541
1613
 
1542
1614
  try {
1543
1615
  const { chromium } = await import("playwright");
1616
+ // Launch for real rather than stat the path executablePath() reports.
1617
+ // They are not the same question: web Flows launch headless, Playwright
1618
+ // may serve that from a separate binary, and executablePath() answers
1619
+ // for the full browser either way — so a stat can say Web is ready while
1620
+ // every launch fails on a binary nobody was asked to install. Whatever a
1621
+ // future Playwright resolves headless to, opening and closing one is the
1622
+ // only answer that stays true.
1544
1623
  let executable = "";
1545
- try { executable = chromium.executablePath(); } catch { /* report the missing browser below */ }
1546
- if (executable && fs.existsSync(executable)) {
1624
+ try { executable = chromium.executablePath(); } catch { /* the launch below is the real check */ }
1625
+ let launchError = "";
1626
+ try {
1627
+ const probe = await chromium.launch({ headless: true });
1628
+ await probe.close();
1629
+ } catch (error) {
1630
+ launchError = String(error?.message || error).split("\n").find((line) => line.trim()) || "";
1631
+ }
1632
+ if (!launchError) {
1547
1633
  report.platforms.web = { available: true, chromium: executable };
1548
- sayOk("Web", `Playwright + Chromium (${executable})`);
1634
+ sayOk("Web", `Playwright + Chromium${executable ? ` (${executable})` : ""}`);
1549
1635
  } else {
1550
- report.platforms.web = { available: false, reason: "Chromium browser missing (run: npx playwright install chromium)" };
1636
+ report.platforms.web = { available: false, reason: "Chromium browser missing (run: npx playwright install chromium)", detail: launchError };
1551
1637
  say(" ⬜ Web — Playwright installed; Chromium browser missing (run: npx playwright install chromium)");
1638
+ say(` ${launchError}`);
1552
1639
  }
1553
1640
  } catch {
1554
1641
  report.platforms.web = { available: false, reason: "Playwright not installed" };
@@ -1951,6 +2038,9 @@ Primitives — an agent's eyes and hands:
1951
2038
  (web: --tap TEXT · --wait-for TEXT · --out FILE)
1952
2039
  tapp tree [target] Accessibility tree of the current screen (--json for every element)
1953
2040
  (web: --tap TEXT · --wait-for TEXT)
2041
+ tapp audit <url> Read-only structural audit — dead anchors, placeholder links, buttons
2042
+ with nothing behind them. Never clicks, so it is safe against
2043
+ production (exit 1 when dead controls are found)
1954
2044
 
1955
2045
  Repository & release:
1956
2046
  tapp init [repo] Detect targets and write the application model + reviewable release plan
@@ -917,7 +917,7 @@ const SESSION_ACT_ARGS = Object.freeze({
917
917
  login: "{email?, password?} (defaults to the session's credentials)",
918
918
  swipe: "{direction: up|down|left|right}",
919
919
  back: "{}",
920
- tree: "{verbose?: true}",
920
+ tree: "{verbose?: true} — verbose returns every element and the complete control list",
921
921
  screenshot: "{label?}",
922
922
  });
923
923
  export function sessionActUsageError(cmd = {}) {
@@ -1769,13 +1769,15 @@ function elementBreakdown(elements) {
1769
1769
  }
1770
1770
 
1771
1771
  /** Scannable "Read screen X — N elements (...)" readout, plus the tappable/typeable controls. */
1772
- export function formatScreen(screenTitle, elements) {
1772
+ export function formatScreen(screenTitle, elements, { full = false } = {}) {
1773
1773
  const els = elements || [];
1774
1774
  const interactable = els.filter((e) => e.isEnabled !== false && (String(e.type).includes("Button") || String(e.type).includes("Link") || String(e.type).includes("rawValue: 9") || String(e.type).includes("TextField") || String(e.type).includes("rawValue: 49") || String(e.type).includes("rawValue: 50") || String(e.type).includes("Cell") || String(e.type).includes("rawValue: 75")));
1775
1775
  const allLabels = interactable
1776
1776
  .map((e) => (e.label || e.identifier || "").trim())
1777
1777
  .filter((s) => s && s.length <= 40 && !s.includes("."));
1778
- const labels = allLabels.slice(0, 8);
1778
+ // A verbose/full readout must actually be full: the 8-item cap hid the fifth tab's label
1779
+ // and left tapping by coordinate as the only way to reach it (field issue #9).
1780
+ const labels = full ? allLabels : allLabels.slice(0, 8);
1779
1781
  const L = [`🌳 Read screen **${screenTitle || "Unknown"}** — ${els.length} elements (${elementBreakdown(els)})`];
1780
1782
  // A silently cut list reads as complete; say when it isn't.
1781
1783
  if (labels.length) L.push("", "**Controls:** " + labels.map((l) => `\`${l}\``).join(" · ")
@@ -4523,8 +4525,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4523
4525
  const detailNote = !ok && r.detail ? ` — ${r.detail}` : "";
4524
4526
  const head = `${did} — ${ok ? "ok" : `⚠️ ${r.status}${detailNote}`} → now on **${r.screenTitle || "Unknown"}**`;
4525
4527
  const rec = typeof r.recordedSteps === "number" ? `\n\n🔴 Recording — ${r.recordedSteps} step(s). \`tapp_flow_save\` to keep it as a test.` : "";
4526
- const screen = agentScreenProjection(r, { full: action === "tree" && args.full === true });
4527
- const result = richResult(head + "\n\n" + formatScreen(screen.screenTitle, screen.elements) + rec, { ...r, ...screen });
4528
+ // `verbose` is the documented spelling (SESSION_ACT_ARGS.tree); `full` is the original.
4529
+ // Accepting only `full` meant the documented flag silently did nothing (field issue #9).
4530
+ const fullTree = action === "tree" && (args.full === true || args.verbose === true || cmd.verbose === true || cmd.full === true);
4531
+ const screen = agentScreenProjection(r, { full: fullTree });
4532
+ const result = richResult(head + "\n\n" + formatScreen(screen.screenTitle, screen.elements, { full: fullTree }) + rec, { ...r, ...screen });
4528
4533
  if (!ok) result.isError = true;
4529
4534
  return result;
4530
4535
  }
@@ -1175,3 +1175,97 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
1175
1175
  }
1176
1176
  return { markersPath, outDir, actions, screens: screenCount, seedRoutes: normalizedSeeds, seedTargets: normalizedTargets };
1177
1177
  }
1178
+
1179
+ // ---- Read-only structural audit (field issue #24) -------------------------------------
1180
+ // Flows hold known-good behaviour fixed; they cannot find a control that was NEVER alive,
1181
+ // because nobody writes a flow for a button they believe does nothing. This pass renders the
1182
+ // page and reads the tree — it never clicks — so it is the one analysis that can be pointed
1183
+ // at production, which is where dead controls actually live.
1184
+ export function auditFindingsFromControls(controls = []) {
1185
+ const findings = [];
1186
+ for (const control of controls) {
1187
+ const where = control.label || control.identifier || control.selector || "(unlabelled)";
1188
+ if (control.kind === "dead_anchor") {
1189
+ findings.push({ type: "anchor_missing", severity: "medium",
1190
+ title: `In-page link “${where}” points at #${control.fragment}, which is not on the page`, target: control.href });
1191
+ } else if (control.kind === "placeholder_link") {
1192
+ findings.push({ type: "placeholder_link", severity: "medium",
1193
+ title: `Link “${where}” has no destination (href="${control.href}")`, target: control.href });
1194
+ } else if (control.kind === "dangling_aria_controls") {
1195
+ findings.push({ type: "dangling_aria_controls", severity: "medium",
1196
+ title: `“${where}” declares aria-controls="${control.controls}", but no such element exists`, target: control.controls });
1197
+ } else if (control.kind === "unwired_control") {
1198
+ findings.push({ type: "unwired_control", severity: "medium",
1199
+ title: `Button “${where}” has no click handler, form, or link behind it`, target: control.selector });
1200
+ }
1201
+ }
1202
+ return findings;
1203
+ }
1204
+
1205
+ // Collected inside the page: every structurally dead control, WITHOUT interacting with any.
1206
+ export async function auditWebPage({ url, timeoutMs = NAV_TIMEOUT_MS, device = "", viewport = "" }) {
1207
+ let target;
1208
+ try { target = new URL(url); }
1209
+ catch { throw new Error("Audit needs a valid http(s) URL"); }
1210
+ if (!/^https?:$/.test(target.protocol)) throw new Error("Audit needs a valid http(s) URL");
1211
+ const { chromium, devices } = await loadPlaywright();
1212
+ const browser = await chromium.launch(webBrowserLaunchOptions(process.env, {}));
1213
+ try {
1214
+ const context = await browser.newContext(webContextOptions({ device, viewport, devices }));
1215
+ await installWebListenerTracking(context);
1216
+ const page = await context.newPage();
1217
+ const bounded = Math.max(1000, Math.min(60_000, Number(timeoutMs) || NAV_TIMEOUT_MS));
1218
+ page.setDefaultTimeout(bounded);
1219
+ const response = await page.goto(target.href, { waitUntil: "domcontentloaded", timeout: bounded });
1220
+ if (response && response.status() >= 400) throw new Error(`Could not open ${target.href}: HTTP ${response.status()}`);
1221
+ await waitForWebStability(page, { timeoutMs: Math.min(5_000, bounded) });
1222
+ const controls = await page.evaluate(() => {
1223
+ const key = Symbol.for("tapp.clickListeners");
1224
+ const visible = (el) => {
1225
+ const style = window.getComputedStyle(el);
1226
+ return style.visibility !== "hidden" && style.display !== "none" && el.getClientRects().length > 0;
1227
+ };
1228
+ const name = (el) => (el.getAttribute("aria-label") || el.textContent || el.getAttribute("value") || "").replace(/\s+/g, " ").trim().slice(0, 80);
1229
+ const selectorFor = (el) => el.id ? `#${el.id}` : el.getAttribute("data-testid") ? `[data-testid="${el.getAttribute("data-testid")}"]` : el.tagName.toLowerCase();
1230
+ const dead = [];
1231
+ for (const el of document.querySelectorAll("a[href], button, [role=button], [aria-controls]")) {
1232
+ if (!visible(el)) continue;
1233
+ const label = name(el);
1234
+ const href = el.getAttribute("href");
1235
+ const ariaControls = el.getAttribute("aria-controls");
1236
+ if (ariaControls && !document.getElementById(ariaControls)) {
1237
+ dead.push({ kind: "dangling_aria_controls", label, selector: selectorFor(el), controls: ariaControls });
1238
+ continue;
1239
+ }
1240
+ if (href != null) {
1241
+ if (href === "#" || href.trim() === "" || /^javascript:\s*(void\(0\))?;?$/i.test(href)) {
1242
+ dead.push({ kind: "placeholder_link", label, selector: selectorFor(el), href });
1243
+ } else if (href.startsWith("#")) {
1244
+ const fragment = decodeURIComponent(href.slice(1));
1245
+ const found = fragment && (document.getElementById(fragment) || document.getElementsByName(fragment).length > 0);
1246
+ if (!found) dead.push({ kind: "dead_anchor", label, selector: selectorFor(el), href, fragment });
1247
+ }
1248
+ continue;
1249
+ }
1250
+ // A button with nothing behind it: no listener on itself or an ancestor, no inline
1251
+ // handler, and not a submit inside a form.
1252
+ let wired = false;
1253
+ for (let node = el; node && node !== document.body; node = node.parentElement) {
1254
+ if ((node[key] && node[key].size > 0) || typeof node.onclick === "function" || node.hasAttribute("onclick")) { wired = true; break; }
1255
+ }
1256
+ if (!wired && el.matches("button[type=submit], input[type=submit]") && el.closest("form")) wired = true;
1257
+ if (!wired && el.closest("label")) wired = true; // a label drives its own control
1258
+ if (!wired) dead.push({ kind: "unwired_control", label, selector: selectorFor(el) });
1259
+ }
1260
+ return { dead, controlCount: document.querySelectorAll("a[href], button, [role=button]").length, title: document.title };
1261
+ });
1262
+ return {
1263
+ url: page.url(),
1264
+ title: controls.title,
1265
+ controlsExamined: controls.controlCount,
1266
+ findings: auditFindingsFromControls(controls.dead),
1267
+ };
1268
+ } finally {
1269
+ await browser.close().catch(() => {});
1270
+ }
1271
+ }
@@ -10,9 +10,13 @@ const DEFAULT_TIMEOUT = 6000;
10
10
  // What WAS visible when a wait missed — points a failure report at the fix (renamed label,
11
11
  // error state, wrong page) without a separate `tapp tree` run (field issue #20).
12
12
  async function visibleLabelsHint(page, limit = 8) {
13
+ const collect = async (scope) => {
14
+ try { return String(await scope.locator("body").innerText({ timeout: 1000 })); } catch { return ""; }
15
+ };
13
16
  try {
14
- const text = await page.locator("body").innerText({ timeout: 1000 });
15
- const labels = [...new Set(String(text).split(/\n+/).map((l) => l.trim()).filter((l) => l.length >= 2 && l.length <= 60))].slice(0, limit);
17
+ // Include same-origin frames: the text a reader can see may live inside an embedded modal.
18
+ const texts = [await collect(page), ...(await Promise.all(sameOriginFrames(page).map(collect)))];
19
+ const labels = [...new Set(texts.join("\n").split(/\n+/).map((l) => l.trim()).filter((l) => l.length >= 2 && l.length <= 60))].slice(0, limit);
16
20
  return labels.length ? ` — visible: ${labels.join(" · ")}` : "";
17
21
  } catch { return ""; }
18
22
  }
@@ -31,19 +35,49 @@ function cssId(value) {
31
35
  return "#" + String(value).replace(/([^a-zA-Z0-9_-])/g, "\\$1");
32
36
  }
33
37
 
34
- export async function locateWebElement(page, target) {
38
+ // A frame whose content the page's own origin may script. An `about:blank`/`srcdoc` frame
39
+ // inherits its parent's origin. Cross-origin frames (third-party widgets) stay out of scope:
40
+ // a Flow should not assert on content the page itself cannot read.
41
+ function sameOriginFrames(page) {
42
+ if (typeof page.frames !== "function") return []; // non-Playwright page (tests, fakes)
43
+ let origin;
44
+ try { origin = new URL(page.url()).origin; } catch { return []; }
45
+ const main = typeof page.mainFrame === "function" ? page.mainFrame() : null;
46
+ return page.frames().filter((frame) => {
47
+ if (frame === main) return false;
48
+ const url = frame.url() || "";
49
+ if (!url || url === "about:blank" || url.startsWith("about:srcdoc")) return true;
50
+ try { return new URL(url).origin === origin; } catch { return false; }
51
+ });
52
+ }
53
+
54
+ function targetCandidates(scope, target) {
35
55
  const exact = { exact: true };
36
- const candidates = [
37
- page.getByTestId(target),
38
- page.locator(cssId(target)),
39
- page.getByLabel(target, exact),
40
- page.getByRole("button", { name: target, exact: true }),
41
- page.getByRole("link", { name: target, exact: true }),
42
- page.getByText(target, exact),
43
- page.getByLabel(target),
44
- page.getByText(target),
56
+ return [
57
+ scope.getByTestId(target),
58
+ scope.locator(cssId(target)),
59
+ scope.getByLabel(target, exact),
60
+ scope.getByRole("button", { name: target, exact: true }),
61
+ scope.getByRole("link", { name: target, exact: true }),
62
+ scope.getByText(target, exact),
63
+ scope.getByLabel(target),
64
+ scope.getByText(target),
45
65
  ];
46
- return firstVisible(candidates);
66
+ }
67
+
68
+ export async function locateWebElement(page, target) {
69
+ const top = await firstVisible(targetCandidates(page, target));
70
+ if (top) return top;
71
+ // A modal rendered into a same-origin <iframe> is visibly on screen but invisible to a
72
+ // top-document-only search, so `wait_for` timed out on text the screenshot plainly showed
73
+ // (field issue #14). Top document first, then each same-origin frame in document order.
74
+ for (const frame of sameOriginFrames(page)) {
75
+ try {
76
+ const inFrame = await firstVisible(targetCandidates(frame, target));
77
+ if (inFrame) return inFrame;
78
+ } catch { /* a frame can detach mid-search — keep looking */ }
79
+ }
80
+ return null;
47
81
  }
48
82
 
49
83
  async function screenCandidates(page) {
@@ -125,6 +159,21 @@ export async function runWebRequestStep({ step, startUrl, vars = {}, timeout = D
125
159
  return { action: "request", target: `${method} ${targetUrl.pathname}`, status: "pass", detail: "" };
126
160
  }
127
161
 
162
+ // Playwright (and its install prompt) wrap guidance in box-drawing art spanning many lines.
163
+ // Consumers that keep ONE line of an error — a scoreboard row, a CI table — kept the box's
164
+ // bottom border `╚═══╝` and threw the cause away (field issue #23). Put the cause first and
165
+ // carry any actionable instruction with it, so the first AND last line are both useful.
166
+ export function distillErrorMessage(message) {
167
+ const lines = String(message || "").split("\n")
168
+ .map((line) => line.replace(/[╔╗╚╝═║│┌┐└┘─]/g, " ").trim())
169
+ .filter(Boolean);
170
+ if (!lines.length) return String(message || "").trim();
171
+ const headline = lines[0];
172
+ // A boxed Playwright prompt carries the fix as a bare command line inside the art.
173
+ const fix = lines.slice(1).find((line) => /^(npx|npm|pip3?|python3|yarn|pnpm) /.test(line));
174
+ return fix && !headline.includes(fix) ? `${headline} — fix: ${fix}` : headline;
175
+ }
176
+
128
177
  // Playwright's timeout errors bury the actual cause ("<div id=…> intercepts pointer events",
129
178
  // "element is not visible") in a multi-line retry log; single-line consumers kept only
130
179
  // "Timeout 6000ms exceeded" and the report read like the element did not exist (field issue
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aarwitz/tapp",
3
- "version": "0.17.13",
3
+ "version": "0.17.14",
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",
@@ -3,8 +3,9 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { fileURLToPath } from "node:url";
6
- import { loadFlowFile } from "../mcp-server/src/flow-runtime.js";
7
- import { runWebFlow } from "../mcp-server/src/web-flow.js";
6
+ import fs from "node:fs";
7
+ import { FlowLog, loadFlowFile } from "../mcp-server/src/flow-runtime.js";
8
+ import { distillErrorMessage, runWebFlow } from "../mcp-server/src/web-flow.js";
8
9
 
9
10
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
10
11
  const flowPath = process.argv[2];
@@ -30,6 +31,16 @@ try {
30
31
  process.stdout.write((report.stdout || "").trim() + "\n");
31
32
  process.exit(result.passed ? 0 : 1);
32
33
  } catch (error) {
33
- console.error(`❌ ${error.message || error}`);
34
+ // A run that dies before step 1 (no browser installed, bad url) still owes a structured
35
+ // report: without one the scoreboard shows an empty row and consumers keep whatever text
36
+ // line they can reach — for a boxed Playwright prompt, its bottom border (field issue #23).
37
+ const cause = distillErrorMessage(error?.message || String(error));
38
+ console.error(`❌ ${cause}`);
39
+ try {
40
+ fs.rmSync(logPath, { force: true });
41
+ const log = new FlowLog({ logPath, flow });
42
+ log.step({ index: 1, action: "harness", target: flow.name || "flow", status: "fail", detail: cause });
43
+ log.finish({ abortReason: cause });
44
+ } catch { /* the console line above is still the answer */ }
34
45
  process.exit(2);
35
46
  }
@@ -11,6 +11,7 @@ npx -y @aarwitz/tapp@latest explore [target]
11
11
  npx -y @aarwitz/tapp@latest focus "Save storefront settings visible above keyboard" [target]
12
12
  npx -y @aarwitz/tapp@latest open [target]
13
13
  npx -y @aarwitz/tapp@latest tree [target] --json
14
+ npx -y @aarwitz/tapp@latest audit https://example.com --json # read-only: never clicks, safe against production
14
15
  npx -y @aarwitz/tapp@latest shot
15
16
  npx -y @aarwitz/tapp@latest report latest
16
17
  npx -y @aarwitz/tapp@latest doctor