@aarwitz/tapp 0.17.0-rc.6 → 0.17.0-rc.8

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.
@@ -1518,6 +1518,8 @@ class ExplorerTests: XCTestCase {
1518
1518
  if current.isEmpty || current == ph {
1519
1519
  reportedPersistenceKeys.insert(memKey)
1520
1520
  let t = "Entered value did not persist: '\(fieldKey)' on \(titleStr)"
1521
+ issues.append((type: "state_persistence", severity: "medium", title: t,
1522
+ desc: "Typed '\(typed)' into this field earlier in the run; after navigating away and returning, the field is empty — entered state was silently lost."))
1521
1523
  print("OCQA_ISSUE:{\"type\":\"state_persistence\",\"severity\":\"medium\",\"title\":\"\(escapeJSON(t))\",\"screen\":\"\(escapedTitle)\",\"control\":\"\(escapeJSON(fieldKey))\",\"step\":\(actionCount),\"desc\":\"Typed '\(escapeJSON(typed))' into this field earlier in the run; after navigating away and returning, the field is empty — entered state was silently lost.\"}")
1522
1524
  }
1523
1525
  }
@@ -1679,7 +1681,13 @@ class ExplorerTests: XCTestCase {
1679
1681
 
1680
1682
  // ---- Blank-screen detection ----
1681
1683
  // Distinguish between "no a11y labels / custom UI" vs genuinely empty.
1682
- if visibleTextInventory.isEmpty && interactable.count == 0 {
1684
+ // A system Back/Close affordance is navigation chrome, not screen content. Treat a
1685
+ // destination whose only usable control is that chrome as blank too; otherwise an
1686
+ // EmptyView pushed by NavigationStack looks like a healthy one-control screen.
1687
+ let contentInteractables = interactable.filter {
1688
+ !isNavBackButton($0) && !isLikelyGlobalNavigation($0, screenBounds: screenBounds)
1689
+ }
1690
+ if visibleTextInventory.isEmpty && contentInteractables.isEmpty {
1683
1691
  let blankKey = "blank:\(titleStr)"
1684
1692
  let blankCount = (actionCounts[blankKey] ?? 0) + 1
1685
1693
  actionCounts[blankKey] = blankCount
@@ -2273,12 +2281,21 @@ class ExplorerTests: XCTestCase {
2273
2281
  // the crash itself goes unreported. app.state is non-throwing even when the app is dead.
2274
2282
  // (Found on a real app: a login submit terminated the app; the run limped on but emitted
2275
2283
  // no crash finding.) Try one relaunch to distinguish a hard crash from a transient exit.
2276
- if app.state != .runningForeground {
2277
- print("OCQA_STATE:app_left_foreground step=\(actionCount) state=\(app.state.rawValue)")
2284
+ let stateAfterAction = app.state
2285
+ if stateAfterAction != .runningForeground {
2286
+ print("OCQA_STATE:app_left_foreground step=\(actionCount) state=\(stateAfterAction.rawValue)")
2287
+ let crashKey = "crash:\(titleStr)|\(key)"
2288
+ // A terminated process is already proof of an in-run crash. Relaunching it may
2289
+ // succeed, but that must not erase the user-visible failure that just happened.
2290
+ if stateAfterAction == .notRunning && !reportedIssueKeys.contains(crashKey) {
2291
+ reportedIssueKeys.insert(crashKey)
2292
+ issues.append((type: "crash", severity: "critical", title: "App crashed after \(actionType) on \(titleStr)",
2293
+ desc: "The app process terminated after \(actionType) '\(targetName)' on '\(titleStr)'."))
2294
+ print("OCQA_ISSUE:{\"type\":\"crash\",\"severity\":\"critical\",\"title\":\"\(escapeJSON("App crashed after \(actionType) on \(titleStr)"))\",\"screen\":\"\(escapedTitle)\",\"control\":\"\(escapedTarget)\",\"step\":\(actionCount)}")
2295
+ }
2278
2296
  app.activate()
2279
2297
  Thread.sleep(forTimeInterval: 3.0)
2280
2298
  if app.state != .runningForeground {
2281
- let crashKey = "crash:\(titleStr)|\(key)"
2282
2299
  if !reportedIssueKeys.contains(crashKey) {
2283
2300
  reportedIssueKeys.insert(crashKey)
2284
2301
  issues.append((type: "crash", severity: "critical", title: "App crashed after \(actionType) on \(titleStr)",
@@ -2382,16 +2399,28 @@ class ExplorerTests: XCTestCase {
2382
2399
 
2383
2400
  // ---- App left foreground / crash detection ----
2384
2401
  // Check both .exists and .state — external links may cause either to fail
2385
- let appInForeground = app.state == .runningForeground
2402
+ let stateAfterDelayedChecks = app.state
2403
+ let appInForeground = stateAfterDelayedChecks == .runningForeground
2386
2404
  if !appInForeground || !app.exists {
2387
- print("OCQA_STATE:app_left_foreground step=\(actionCount) state=\(app.state.rawValue)")
2405
+ print("OCQA_STATE:app_left_foreground step=\(actionCount) state=\(stateAfterDelayedChecks.rawValue)")
2406
+ let crashKey = "crash:\(titleStr)|\(key)"
2407
+ if stateAfterDelayedChecks == .notRunning && !reportedIssueKeys.contains(crashKey) {
2408
+ reportedIssueKeys.insert(crashKey)
2409
+ issues.append((type: "crash", severity: "critical",
2410
+ title: "App crashed after \(actionType) on \(titleStr)",
2411
+ desc: "The app process terminated after: \(actionDesc)"))
2412
+ print("OCQA_ISSUE:{\"type\":\"crash\",\"severity\":\"critical\",\"title\":\"\(escapeJSON("App crashed after \(actionType) on \(titleStr)"))\",\"screen\":\"\(escapedTitle)\",\"control\":\"\(escapedTarget)\",\"step\":\(actionCount)}")
2413
+ }
2388
2414
  app.activate()
2389
2415
  Thread.sleep(forTimeInterval: 3.0)
2390
2416
  if app.state != .runningForeground {
2391
- issues.append((type: "crash", severity: "critical",
2392
- title: "App not recoverable",
2393
- desc: "App left foreground after: \(actionDesc)"))
2394
- print("OCQA_ISSUE:{\"type\":\"crash\",\"severity\":\"critical\",\"title\":\"App not recoverable\",\"action\":\"\(escapedTarget)\",\"step\":\(actionCount)}")
2417
+ if !reportedIssueKeys.contains(crashKey) {
2418
+ reportedIssueKeys.insert(crashKey)
2419
+ issues.append((type: "crash", severity: "critical",
2420
+ title: "App not recoverable",
2421
+ desc: "App left foreground after: \(actionDesc)"))
2422
+ print("OCQA_ISSUE:{\"type\":\"crash\",\"severity\":\"critical\",\"title\":\"App not recoverable\",\"action\":\"\(escapedTarget)\",\"step\":\(actionCount)}")
2423
+ }
2395
2424
  break
2396
2425
  }
2397
2426
  print("OCQA_STATE:app_reactivated step=\(actionCount)")
package/bin/tapp.js CHANGED
@@ -235,6 +235,7 @@ function safeCommandUsage(verb) {
235
235
  pr: "tapp pr plan [--base REF|--changed-files FILE] [--head REF] [--platform PLATFORM] [--out FILE]\ntapp pr gate PLAN [gate target/options]\ntapp pr adopt PLAN --item ID [--project-dir DIR]",
236
236
  plan: "tapp plan show [FILE]\ntapp plan review [FILE] --approve NAME[,NAME] --reject NAME[,NAME] --defer NAME[,NAME]\ntapp plan generate|validate|promote [FILE] [options]",
237
237
  baseline: "tapp baseline create [repo] [--platform PLATFORM] [--target NAME] [--from GATE.json] [--replace]",
238
+ ci: "tapp ci ...\ntapp ci install [repo] [--out FILE] [--manifest FILE] [--dry-run] [--replace]",
238
239
  actor: "tapp actor set NAME --email-env ENV --password-env ENV [--project-dir DIR]\ntapp actor list [repo]",
239
240
  app: "tapp app [repo] [--no-open] [--port PORT]",
240
241
  report: "tapp report [captureId|latest]",
@@ -248,7 +249,10 @@ function safeCommandUsage(verb) {
248
249
  // Safe help: `--help`/`-h` on ANY verb prints the command reference and does NOTHING else — never
249
250
  // builds, launches, writes, or opens (ADR-0005 manual-testing requirement). `ci` keeps its own
250
251
  // richer `--help` (a safe usage print in ci-gate.sh); help/version don't need interception.
251
- if ((rest.includes("--help") || rest.includes("-h")) && !["help", "version", "--version", "-v", "ci"].includes(command)) {
252
+ const safeHelpRequested = (rest.includes("--help") || rest.includes("-h"))
253
+ && !["help", "version", "--version", "-v"].includes(command)
254
+ && (command !== "ci" || rest[0] === "install");
255
+ if (safeHelpRequested) {
252
256
  console.log(`Usage:\n ${safeCommandUsage(command).replaceAll("\n", "\n ")}\n\nℹ️ --help never builds, launches, writes, or opens. Full command reference:\n`);
253
257
  command = "help";
254
258
  rest = [];
@@ -1460,9 +1464,15 @@ switch (command) {
1460
1464
  const runs = roots
1461
1465
  .flatMap((root) => fs.readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => path.join(root, e.name)))
1462
1466
  .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
1463
- const wanted = rest[0] && rest[0] !== "latest" ? runs.find((r) => path.basename(r) === rest[0]) : runs[0];
1467
+ const hasMarkers = (dir) => fs.existsSync(path.join(dir, "ocqa-markers.txt"));
1468
+ // `latest` (default) resolves to the newest *exploration* capture — the captures directory is
1469
+ // also full of flow-*/scenario-* evidence dirs with no ocqa-markers.txt, and picking the newest
1470
+ // of those made `tapp report` fail even though valid exploration captures existed. An explicitly
1471
+ // named capture is honored as-is so a non-exploration capture still gets a clear "no markers".
1472
+ const explicit = rest[0] && rest[0] !== "latest";
1473
+ const wanted = explicit ? runs.find((r) => path.basename(r) === rest[0]) : runs.find(hasMarkers);
1464
1474
  if (!wanted) {
1465
- bad("No captures found", rest[0] ? `no capture named "${rest[0]}"` : "run a QA exploration first");
1475
+ bad("No captures found", explicit ? `no capture named "${rest[0]}"` : "run an exploration first (no capture with exploration markers was found)");
1466
1476
  process.exit(1);
1467
1477
  }
1468
1478
  const { writeHtmlReport } = await import(path.join(packageRoot, "mcp-server", "src", "html-report.js"));
@@ -1472,7 +1482,9 @@ switch (command) {
1472
1482
  process.exit(1);
1473
1483
  }
1474
1484
  ok("Evidence report", out);
1475
- spawnSync("open", [out], { stdio: "ignore" });
1485
+ // Only launch a browser from an interactive terminal — agents, scripts, and tests that invoke
1486
+ // `tapp report` non-interactively get the path without a surprise GUI window.
1487
+ if (process.stdout.isTTY) spawnSync("open", [out], { stdio: "ignore" });
1476
1488
  break;
1477
1489
  }
1478
1490
 
@@ -282,6 +282,19 @@ export class AndroidDriver {
282
282
  return { screenTitle: detectAndroidScreen(elements, activity), elements, activity, xml };
283
283
  }
284
284
 
285
+ async observeActivityTransition(beforeActivity, timeoutMs = 700) {
286
+ const initial = String(beforeActivity || "");
287
+ if (!initial) return false;
288
+ const deadline = Date.now() + timeoutMs;
289
+ do {
290
+ const current = await this.currentActivity();
291
+ if (current && current !== initial) return true;
292
+ if (Date.now() >= deadline) break;
293
+ await sleep(60);
294
+ } while (Date.now() < deadline);
295
+ return false;
296
+ }
297
+
285
298
  async screenshot(filePath) {
286
299
  const r = await this.adb(["exec-out", "screencap", "-p"], { encoding: "buffer", timeout: 30_000, maxBuffer: 32 * 1024 * 1024 });
287
300
  if (r.code !== 0 || !r.stdout?.length) throw new Error("Android screenshot failed");
@@ -57,8 +57,9 @@ export function isAndroidBlankSnapshot(snapshot) {
57
57
  return !meaningful && !interactive;
58
58
  }
59
59
 
60
- export async function exploreAndroid({ appId, apkPath, serial, maxActions = 40, timeoutSec = 300, outDir, testEmail = "", testPassword = "", clearData = true, seedTargets = [], onProgress = () => {}, driver }) {
60
+ export async function exploreAndroid({ appId, apkPath, serial, maxActions = 40, timeoutSec = 300, outDir, testEmail = "", testPassword = "", clearData = true, seedTargets = [], onProgress = () => {}, driver, screenshotDelayMs }) {
61
61
  const d = driver || new AndroidDriver({ appId, serial });
62
+ const visualSettleMs = Number.isFinite(screenshotDelayMs) ? Math.max(0, screenshotDelayMs) : (driver ? 0 : 350);
62
63
  d.appId = appId;
63
64
  await d.ensureDevice();
64
65
  if (apkPath) await d.install(apkPath);
@@ -112,6 +113,10 @@ export async function exploreAndroid({ appId, apkPath, serial, maxActions = 40,
112
113
  const screen = snapshot.screenTitle;
113
114
  if (!visited.has(hash)) {
114
115
  visited.set(hash, screen);
116
+ // UIAutomator can expose a fully populated hierarchy a fraction before
117
+ // SurfaceFlinger composites the first app frame. Give real devices one
118
+ // bounded draw interval so retained PNG evidence matches the tree.
119
+ if (visualSettleMs) await sleep(visualSettleMs);
115
120
  await d.screenshot(path.join(screenshots, `${String(visited.size).padStart(2, "0")}-${screen.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}.png`)).catch(() => {});
116
121
  }
117
122
  const inputs = inputDescriptors(snapshot.elements);
@@ -209,7 +214,14 @@ export async function exploreAndroid({ appId, apkPath, serial, maxActions = 40,
209
214
  const before = hash;
210
215
  const r = await d.tap(target, snap);
211
216
  actions += 1;
212
- snap = await d.settle();
217
+ // UIAutomator can miss a short-lived Activity that opens and cleanly
218
+ // returns before its next hierarchy dump. Observe the cheaper Activity
219
+ // signal in parallel so a real transient response is not called dead.
220
+ const activityEffect = r.status === "ok" && typeof d.observeActivityTransition === "function"
221
+ ? d.observeActivityTransition(snap.activity)
222
+ : Promise.resolve(false);
223
+ const [settledSnap, activityEffectObserved] = await Promise.all([d.settle(), activityEffect]);
224
+ snap = settledSnap;
213
225
  const after = stateHash(snap);
214
226
  emit("ACTION", { type: loginSubmit ? "login_submit" : "tap", target, reason: "untried_control", step: actions, screen, status: r.status });
215
227
  if (!isAndroidAppSnapshot(snap, appId)) {
@@ -221,7 +233,7 @@ export async function exploreAndroid({ appId, apkPath, serial, maxActions = 40,
221
233
  if (r.status === "ok" && authFailed) {
222
234
  emit("ISSUE", { type: "auth_failed", severity: "high", title: "Sign-in attempt remained on the login screen", screen, target, step: actions });
223
235
  issues += 1;
224
- } else if (r.status === "ok" && before === after && candidate.clickable) {
236
+ } else if (r.status === "ok" && before === after && candidate.clickable && !activityEffectObserved) {
225
237
  emit("ISSUE", { type: "unresponsive_element", severity: "medium", title: `Control did not respond: ${target}`, screen, target, step: actions });
226
238
  issues += 1;
227
239
  }
@@ -14,6 +14,22 @@ const SEV_COLOR = { critical: "#cf222e", high: "#bc4c00", medium: "#9a6700", low
14
14
 
15
15
  const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
16
16
 
17
+ const VALID_PLATFORMS = new Set(["web", "android", "ios"]);
18
+
19
+ // Recover a capture's platform when no in-memory report is available. Prefer the run's persisted
20
+ // metadata (ui-map.json → app.platforms) — it is authoritative and survives a renamed folder — and
21
+ // fall back to the capture-id prefix (web-*/android-*, ios unprefixed) only for legacy captures that
22
+ // predate the map. Defaults to ios if nothing is resolvable, matching buildQaReport's own default.
23
+ export function capturePlatform(captureDir) {
24
+ try {
25
+ const map = JSON.parse(fs.readFileSync(path.join(captureDir, "ui-map.json"), "utf8"));
26
+ const fromMap = (map?.app?.platforms || []).find((p) => VALID_PLATFORMS.has(p));
27
+ if (fromMap) return fromMap;
28
+ } catch { /* no map, unreadable, or no valid platform — fall back to the id prefix */ }
29
+ const base = path.basename(captureDir);
30
+ return base.startsWith("web-") ? "web" : base.startsWith("android-") ? "android" : "ios";
31
+ }
32
+
17
33
  // Two capture layouts exist: web runs write state_*.png at the capture root; iOS runs
18
34
  // export XCUITest attachments into screenshots/ as UUID files with a manifest carrying
19
35
  // the human-readable state_N_<Screen> names.
@@ -50,7 +66,11 @@ function collectShots(captureDir) {
50
66
  }
51
67
 
52
68
  export function writeHtmlReport(captureDir, { report, label = "", recordingWarning = "" } = {}) {
53
- const r = report || buildQaReport(path.join(captureDir, "ocqa-markers.txt"));
69
+ // Rebuild path (e.g. `tapp report`): no report object is passed, so buildQaReport would default to
70
+ // native — rendering a web/android page with the wrong "Checked / Not checked" scope, overclaiming
71
+ // native checks it never ran. Recover the run's real platform (metadata first, id prefix as a
72
+ // legacy fallback). When a report object IS passed (explore/init), it is already correct.
73
+ const r = report || buildQaReport(path.join(captureDir, "ocqa-markers.txt"), { platform: capturePlatform(captureDir) });
54
74
  if (!r) return null;
55
75
 
56
76
  const shots = collectShots(captureDir);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aarwitz/tapp",
3
- "version": "0.17.0-rc.6",
3
+ "version": "0.17.0-rc.8",
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",
@@ -26,7 +26,7 @@ def _ctx():
26
26
 
27
27
 
28
28
  SSL_CTX = _ctx()
29
- MODEL = os.environ.get("TAPP_VISION_MODEL") or os.environ.get("AUTOTAP_VISION_MODEL", "claude-haiku-4-5-20251001")
29
+ MODEL = os.environ.get("TAPP_VISION_MODEL", "claude-haiku-4-5-20251001")
30
30
  SYSTEM = (
31
31
  "You are a QA test oracle. You are shown one iOS app screenshot and a CLAIM the test asserts about "
32
32
  "it. Decide if the claim is TRUE of what is actually visible. Be strict and literal — only pass if "