@aarwitz/tapp 0.17.0-rc.1 → 0.17.0-rc.3

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.
@@ -946,6 +946,7 @@ class ExplorerTests: XCTestCase {
946
946
  var elementScreenPresence: [String: Set<String>] = [:]
947
947
  var totalDistinctStates = 0
948
948
  var recentStateHashes: [String] = []
949
+ var recentScreenTitles: [String] = []
949
950
  var actionsSinceNewState = 0
950
951
  var sameScreenStreak = 0
951
952
  var screenTextEntryCount: [String: Int] = [:]
@@ -1238,6 +1239,15 @@ class ExplorerTests: XCTestCase {
1238
1239
  let initialControlsJson = mapControlsJSON(initialElements)
1239
1240
  print("OCQA_STATE:{\"screen\":\"\(escapeJSON(initialTitle))\",\"hash\":\"\(computeHash(initialElements))\",\"elements\":\(initialElements.count),\"action\":0,\"role\":\"\(escapeJSON(initialRole))\",\"summary\":\"\(escapeJSON(initialSummary))\",\"settled\":\(isScreenSettled() ? "true" : "false"),\"atext\":[\(initialAtext)],\"inputs\":[\(initialInputJson)],\"controls\":[\(initialControlsJson)]}")
1240
1241
 
1242
+ // The launch surface is real evidence even when root normalization immediately dismisses
1243
+ // it. Attach it before the first action so the HTML report's "every screen explored" claim
1244
+ // includes onboarding/login sheets rather than beginning at the post-dismiss destination.
1245
+ let initialScreenshot = app.screenshot()
1246
+ let initialAttachment = XCTAttachment(screenshot: initialScreenshot)
1247
+ initialAttachment.name = "state_0_\(initialTitle.replacingOccurrences(of: " ", with: "_"))"
1248
+ initialAttachment.lifetime = .keepAlways
1249
+ add(initialAttachment)
1250
+
1241
1251
  navigateToRootScreen(actionCount: &actionCount)
1242
1252
 
1243
1253
  // The first state is the true customer launch surface. Directed replay begins after
@@ -1386,8 +1396,10 @@ class ExplorerTests: XCTestCase {
1386
1396
  }
1387
1397
 
1388
1398
  recentStateHashes.append(stateHash)
1399
+ recentScreenTitles.append(titleStr)
1389
1400
  if recentStateHashes.count > 12 {
1390
1401
  recentStateHashes.removeFirst(recentStateHashes.count - 12)
1402
+ recentScreenTitles.removeFirst(recentScreenTitles.count - 12)
1391
1403
  }
1392
1404
 
1393
1405
  if previousStateHash == stateHash {
@@ -1474,10 +1486,19 @@ class ExplorerTests: XCTestCase {
1474
1486
  // is the fuller a11y text inventory that grounds the vision reviewer (see visionTextInventory).
1475
1487
  let escapedTitle = escapeJSON(titleStr)
1476
1488
  let settled = isScreenSettled()
1477
- let atextJson = visionTextInventory(elements).map { "\"\(escapeJSON($0))\"" }.joined(separator: ",")
1489
+ let visibleTextInventory = visionTextInventory(elements)
1490
+ let atextJson = visibleTextInventory.map { "\"\(escapeJSON($0))\"" }.joined(separator: ",")
1478
1491
  let controlsJson = mapControlsJSON(elements)
1479
1492
  print("OCQA_STATE:{\"screen\":\"\(escapedTitle)\",\"hash\":\"\(stateHash)\",\"elements\":\(elements.count),\"action\":\(actionCount),\"role\":\"\(escapeJSON(screenRole))\",\"summary\":\"\(escapeJSON(screenSummary))\",\"settled\":\(settled ? "true" : "false"),\"atext\":[\(atextJson)],\"inputs\":[\(inputJsonArray)],\"controls\":[\(controlsJson)]}")
1480
1493
 
1494
+ // Signing out after a successful login is a completed auth cycle, not a navigation
1495
+ // trap and not lost-form-state. Stop cleanly instead of probing the root login screen
1496
+ // for an impossible back path or expecting credentials to persist after logout.
1497
+ if authSucceeded && detectedInputs.contains(where: { $0.secure }) {
1498
+ print("OCQA_STATE:auth_cycle_complete screen=\(escapedTitle) step=\(actionCount)")
1499
+ break
1500
+ }
1501
+
1481
1502
  // ---- Persistence probe: on a fresh RE-ARRIVAL at a screen, fields we previously
1482
1503
  // typed into (and verified visible in the a11y value) should still hold their value.
1483
1504
  // An empty field here means the entered state was silently lost on navigation —
@@ -1591,14 +1612,14 @@ class ExplorerTests: XCTestCase {
1591
1612
  // content-feed app: post detail + replies-loading spinner flagged app_hang HIGH).
1592
1613
  let visibleTextCount = elements.filter { isStaticTextType($0.type) && normalizeVisibleText($0.label).count >= 3 }.count
1593
1614
  if screenVisitCount[titleStr] ?? 0 <= 1, visibleTextCount <= 4,
1594
- app.activityIndicators.firstMatch.exists || app.progressIndicators.firstMatch.exists {
1615
+ hasIndeterminateLoadingIndicator() {
1595
1616
  let loadingKey = "loading:\(titleStr)"
1596
1617
  if !reportedIssueKeys.contains(loadingKey) {
1597
1618
  var resolved = false
1598
1619
  let deadline = Date().addingTimeInterval(8.0)
1599
1620
  while Date() < deadline {
1600
1621
  Thread.sleep(forTimeInterval: 1.0)
1601
- if !(app.activityIndicators.firstMatch.exists || app.progressIndicators.firstMatch.exists) {
1622
+ if !hasIndeterminateLoadingIndicator() {
1602
1623
  resolved = true
1603
1624
  break
1604
1625
  }
@@ -1658,7 +1679,7 @@ class ExplorerTests: XCTestCase {
1658
1679
 
1659
1680
  // ---- Blank-screen detection ----
1660
1681
  // Distinguish between "no a11y labels / custom UI" vs genuinely empty.
1661
- if elements.count < 5 && interactable.count == 0 {
1682
+ if visibleTextInventory.isEmpty && interactable.count == 0 {
1662
1683
  let blankKey = "blank:\(titleStr)"
1663
1684
  let blankCount = (actionCounts[blankKey] ?? 0) + 1
1664
1685
  actionCounts[blankKey] = blankCount
@@ -1690,16 +1711,26 @@ class ExplorerTests: XCTestCase {
1690
1711
  }
1691
1712
 
1692
1713
  // ---- Navigation-loop detection ----
1693
- // Check if recentStateHashes has a repeating cycle of length 2 or 3
1714
+ // A cycle must actually move across distinct states. Four identical reads satisfy the
1715
+ // arithmetic shape A,A,A,A of the old period-2 check, which mislabeled ordinary
1716
+ // scroll/probe recovery on a stable screen as a navigation loop.
1694
1717
  if recentStateHashes.count >= 6 {
1695
1718
  let recent = recentStateHashes
1719
+ // Distinct structural hashes are not enough: a list and its detail rows can share
1720
+ // one navigation title and alternate A/B while the explorer intentionally samples
1721
+ // different rows. Calling that a navigation loop is a false positive. Require the
1722
+ // cycle to cross distinct user-visible screen titles as well.
1696
1723
  let hasLoop2 = recent.count >= 4 &&
1697
1724
  recent[recent.count - 1] == recent[recent.count - 3] &&
1698
- recent[recent.count - 2] == recent[recent.count - 4]
1725
+ recent[recent.count - 2] == recent[recent.count - 4] &&
1726
+ Set(recent.suffix(2)).count == 2 &&
1727
+ Set(recentScreenTitles.suffix(2)).count == 2
1699
1728
  let hasLoop3 = recent.count >= 6 &&
1700
1729
  recent[recent.count - 1] == recent[recent.count - 4] &&
1701
1730
  recent[recent.count - 2] == recent[recent.count - 5] &&
1702
- recent[recent.count - 3] == recent[recent.count - 6]
1731
+ recent[recent.count - 3] == recent[recent.count - 6] &&
1732
+ Set(recent.suffix(3)).count == 3 &&
1733
+ Set(recentScreenTitles.suffix(3)).count == 3
1703
1734
  if (hasLoop2 || hasLoop3) && !(authSucceeded && detectedInputs.contains { $0.secure }) {
1704
1735
  let loopKey = "nav_loop:\(titleStr)"
1705
1736
  if actionCounts[loopKey] == nil {
@@ -1711,17 +1742,10 @@ class ExplorerTests: XCTestCase {
1711
1742
  }
1712
1743
  }
1713
1744
 
1714
- // ---- Unresponsive-element detection ----
1715
- // Skip when we're merely re-poking a login screen we've already passed (Sign Out → re-login
1716
- // churn) that's an exploration artifact, not a frozen/broken screen.
1717
- if repeatedStateCount >= 5 && !(authSucceeded && detectedInputs.contains { $0.secure }) {
1718
- let unrespKey = "unresponsive:\(titleStr)"
1719
- if actionCounts[unrespKey] == nil {
1720
- issues.append((type: "unresponsive_element", severity: "medium", title: "Unresponsive UI on \(titleStr)", desc: "Actions are not changing app state — possible frozen or broken screen"))
1721
- print("OCQA_ISSUE:{\"type\":\"unresponsive_element\",\"severity\":\"medium\",\"title\":\"Unresponsive UI\",\"screen\":\"\(escapedTitle)\",\"repeated_state_count\":\(repeatedStateCount),\"step\":\(actionCount)}")
1722
- actionCounts[unrespKey] = 1
1723
- }
1724
- }
1745
+ // Do not infer an unresponsive app merely from an unchanged state streak: recovery
1746
+ // gestures (scroll, carousel probe, center probe) are expected to be no-ops on many
1747
+ // healthy screens. Labeled controls have a stronger detector below: a direct tap plus
1748
+ // two delayed, content-signature reads. Hangs have their own time-based detector.
1725
1749
 
1726
1750
  if interactable.count < 3 {
1727
1751
  print("OCQA_STATE:low_interactable screen=\(escapedTitle) total=\(elements.count) interactable=\(interactable.count) global=\(globalNavElements.count) nonGlobal=\(nonGlobalCandidates.count)")
@@ -1757,10 +1781,10 @@ class ExplorerTests: XCTestCase {
1757
1781
  break
1758
1782
  }
1759
1783
 
1760
- let issueTitle = "Dead end: \(titleStr)"
1761
- issues.append((type: "dead_end", severity: "medium", title: issueTitle, desc: "No interactable elements found"))
1762
- print("OCQA_ISSUE:{\"type\":\"dead_end\",\"severity\":\"medium\",\"title\":\"\(escapeJSON(issueTitle))\",\"screen\":\"\(escapedTitle)\",\"step\":\(actionCount)}")
1763
-
1784
+ // Exhausting Tapp's untried candidate pool is not itself a user-visible dead end:
1785
+ // leaf screens commonly have only a working Back control that was already mapped.
1786
+ // Recover first; only the stronger navigation-trap path below emits a finding when
1787
+ // every real back/dismiss route fails.
1764
1788
  // tryGoBack does swipe-down as its last resort (sheet dismiss)
1765
1789
  let preBackTitle = titleStr
1766
1790
  let backWorked = tryGoBack()
@@ -1772,6 +1796,7 @@ class ExplorerTests: XCTestCase {
1772
1796
  print("OCQA_ACTION:{\"type\":\"back\",\"reason\":\"dead_end_escape\",\"from\":\"\(escapedTitle)\",\"to\":\"\(escapeJSON(postTitle))\",\"step\":\(actionCount),\"screen\":\"\(escapedTitle)\",\"narrative\":\"\(escapeJSON(recoveryNarrative("back_dead_end", screen: titleStr, to: postTitle)))\"}")
1773
1797
  continue
1774
1798
  }
1799
+ if actionCount >= maxActions { break }
1775
1800
  // Swipe right (back gesture) as another option
1776
1801
  let swipeStart = app.coordinate(withNormalizedOffset: CGVector(dx: 0.02, dy: 0.5))
1777
1802
  let swipeEnd = app.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5))
@@ -2047,6 +2072,7 @@ class ExplorerTests: XCTestCase {
2047
2072
  }
2048
2073
  // Back didn't change screens — fall through to global nav
2049
2074
  print("OCQA_ACTION:{\"type\":\"back\",\"reason\":\"screen_exhausted_failed\",\"screen\":\"\(escapedTitle)\",\"step\":\(actionCount),\"narrative\":\"\(escapeJSON(recoveryNarrative("back_failed", screen: titleStr)))\"}")
2075
+ if actionCount >= maxActions { break }
2050
2076
  // Stuck on this screen — use global navigation (tab bar) to reach unexplored areas
2051
2077
  let globalNav = interactable
2052
2078
  .filter { isLikelyGlobalNavigation($0, screenBounds: screenBounds) }
@@ -3679,6 +3705,20 @@ class ExplorerTests: XCTestCase {
3679
3705
  return kb.exists ? kb.frame : .zero
3680
3706
  }
3681
3707
 
3708
+ /// Activity indicators are inherently indeterminate. `ProgressIndicator`, however, is also the
3709
+ /// XCTest type for legitimate determinate progress bars (loyalty points, upload percentage,
3710
+ /// onboarding completion). Only value-less/loading-valued progress indicators are hang signals.
3711
+ private func hasIndeterminateLoadingIndicator() -> Bool {
3712
+ if app.activityIndicators.allElementsBoundByIndex.contains(where: { $0.exists && $0.frame.width > 0 && $0.frame.height > 0 }) {
3713
+ return true
3714
+ }
3715
+ return app.progressIndicators.allElementsBoundByIndex.contains { indicator in
3716
+ guard indicator.exists, indicator.frame.width > 0, indicator.frame.height > 0 else { return false }
3717
+ let value = (indicator.value as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
3718
+ return value.isEmpty || value == "in progress" || value == "loading"
3719
+ }
3720
+ }
3721
+
3682
3722
  /// True when the screen is in a "settled" resting state — no on-screen keyboard and no open
3683
3723
  /// transient overlay (menu / dropdown / popover / sheet / picker wheel). A screenshot taken while
3684
3724
  /// one of these is up is inherently ambiguous to a visual reviewer (the keyboard "covers" the
package/bin/tapp.js CHANGED
@@ -130,6 +130,27 @@ function repeatedFlagValues(argv, name) {
130
130
  return values;
131
131
  }
132
132
 
133
+ function iosLaunchOptions(flags, argv) {
134
+ const appLaunchArgs = repeatedFlagValues(argv, "launch-arg");
135
+ let appLaunchEnv;
136
+ if (typeof flags["launch-env"] === "string") {
137
+ try {
138
+ const parsed = JSON.parse(flags["launch-env"]);
139
+ if (!parsed || Array.isArray(parsed) || typeof parsed !== "object" || Object.values(parsed).some((value) => typeof value !== "string")) {
140
+ throw new Error("expected a JSON object with string values");
141
+ }
142
+ appLaunchEnv = parsed;
143
+ } catch (error) {
144
+ console.error(`❌ --launch-env must be a JSON object with string values: ${error.message}`);
145
+ process.exit(2);
146
+ }
147
+ }
148
+ return {
149
+ ...(appLaunchArgs.length ? { appLaunchArgs } : {}),
150
+ ...(appLaunchEnv ? { appLaunchEnv } : {}),
151
+ };
152
+ }
153
+
133
154
  const engineImport = () => import(path.join(packageRoot, "mcp-server", "src", "index.js"));
134
155
 
135
156
  function requireMacFor(what) {
@@ -197,11 +218,38 @@ async function resolveTargetOrExit(engine, input) {
197
218
  return resolved.bundleId;
198
219
  }
199
220
 
221
+ function safeCommandUsage(verb) {
222
+ const usage = {
223
+ explore: "tapp explore [target] [--platform ios|android|web] [--actions N] [--timeout SEC] [--email VALUE] [--password VALUE] [--baseline FILE] [--json FILE]\n iOS launch configuration: [--launch-arg VALUE ...] [--launch-env '{\"KEY\":\"VALUE\"}']\n Android: [--app-id ID] [--apk FILE] [--serial ID] [--keep-data]",
224
+ init: "tapp init [repo] [--explore] [--refresh] [--platform PLATFORM] [--target NAME] [--url URL] [--dry-run]",
225
+ open: "tapp open [target] [--platform ios|android|web] [--out FILE] [--tap TEXT] [--wait-for TEXT]",
226
+ tree: "tapp tree [target] [--platform ios|android|web] [--json] [--tap TEXT] [--wait-for TEXT]",
227
+ shot: "tapp shot [--out FILE]",
228
+ apps: "tapp apps",
229
+ build: "tapp build [repo] [--scheme NAME] [--configuration NAME]",
230
+ flow: "tapp flow validate FILE [--platform PLATFORM] [--map FILE]\ntapp flow run FILE [--email VALUE] [--password VALUE]",
231
+ task: "tapp task validate FILE [--platform PLATFORM] [--map FILE]\ntapp task compile FILE --platform PLATFORM [--inputs JSON] [--out FILE]\ntapp task run FILE --platform PLATFORM [--url URL|--bundle-id ID|--app-id ID] [--inputs JSON]",
232
+ contract: "tapp contract validate FILE [--platform PLATFORM] [--map FILE]\ntapp contract compile FILE --platform PLATFORM [--out FILE]\ntapp contract run FILE --platform PLATFORM [--url URL|--bundle-id ID|--app-id ID]",
233
+ scenario: "tapp scenario validate FILE [--project-dir DIR]\ntapp scenario run FILE --platform web --url URL [--project-dir DIR]",
234
+ map: "tapp map build MARKERS [--platform PLATFORM] [--out FILE] [--replace]\ntapp map inspect [FILE]\ntapp map diff BEFORE AFTER [--comparable]",
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
+ 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
+ baseline: "tapp baseline create [repo] [--platform PLATFORM] [--target NAME] [--from GATE.json] [--replace]",
238
+ actor: "tapp actor set NAME --email-env ENV --password-env ENV [--project-dir DIR]\ntapp actor list [repo]",
239
+ app: "tapp app [repo] [--no-open] [--port PORT]",
240
+ report: "tapp report [captureId|latest]",
241
+ doctor: "tapp doctor",
242
+ install: "tapp install",
243
+ mcp: "tapp mcp",
244
+ };
245
+ return usage[verb] || `tapp ${verb}`;
246
+ }
247
+
200
248
  // Safe help: `--help`/`-h` on ANY verb prints the command reference and does NOTHING else — never
201
249
  // builds, launches, writes, or opens (ADR-0005 manual-testing requirement). `ci` keeps its own
202
250
  // richer `--help` (a safe usage print in ci-gate.sh); help/version don't need interception.
203
251
  if ((rest.includes("--help") || rest.includes("-h")) && !["help", "version", "--version", "-v", "ci"].includes(command)) {
204
- console.log(`ℹ️ '${command} --help' — showing the command reference (--help never builds, launches, writes, or opens):\n`);
252
+ console.log(`Usage:\n ${safeCommandUsage(command).replaceAll("\n", "\n ")}\n\nℹ️ --help never builds, launches, writes, or opens. Full command reference:\n`);
205
253
  command = "help";
206
254
  rest = [];
207
255
  }
@@ -271,7 +319,7 @@ switch (command) {
271
319
  testEmail: typeof flags.email === "string" ? flags.email : undefined,
272
320
  testPassword: typeof flags.password === "string" ? flags.password : undefined,
273
321
  runExploration: engine?.runInitExploration,
274
- onProgress: (progress) => process.stderr.write(`\r🔍 Import exploration… ${progress.action}/${progress.max || actions} actions · ${progress.states} ${platform === "web" ? "pages" : "screens"} reached `),
322
+ onProgress: (progress) => process.stderr.write(`\r🔍 Import exploration… ${progress.action}/${progress.max || actions} actions · ${progress.states} ${platform === "web" ? "pages reached" : platform === "ios" ? "structural states observed" : "screens reached"} `),
275
323
  onStatus: (status) => console.error(`⏳ ${status}`),
276
324
  outDir,
277
325
  maxContracts,
@@ -408,6 +456,7 @@ switch (command) {
408
456
  // hidden deprecated alias.
409
457
  if (command === "qa") console.error("note: 'qa' is now 'explore' — 'qa' still works for now.\n");
410
458
  const { flags, positionals } = parseVerbArgs(rest);
459
+ const launchOptions = iosLaunchOptions(flags, rest);
411
460
  let target = positionals[0] || "";
412
461
  let baselineFindings;
413
462
  if (flags.baseline) {
@@ -431,7 +480,7 @@ switch (command) {
431
480
  const modelPlatform = typeof flags.platform === "string" ? flags.platform.toLowerCase() : "";
432
481
  if (modelPlatform === "ios") requireMacFor("iOS testing");
433
482
  const onProgress = (p) =>
434
- process.stderr.write(`\r🔍 Exploring… ${p.action}/${p.max || flags.actions || 60} actions · ${p.states} reached `);
483
+ process.stderr.write(`\r🔍 Exploring… ${p.action}/${p.max || flags.actions || 60} actions · ${p.states} states observed `);
435
484
  const r = await engine.runExploreTarget({
436
485
  projectDir: process.cwd(),
437
486
  platform: modelPlatform,
@@ -440,6 +489,7 @@ switch (command) {
440
489
  timeout: flags.timeout,
441
490
  testEmail: flags.email,
442
491
  testPassword: flags.password,
492
+ ...launchOptions,
443
493
  baselineFindings,
444
494
  surface: "cli",
445
495
  onProgress,
@@ -461,15 +511,19 @@ switch (command) {
461
511
  process.exit(2);
462
512
  }
463
513
  if (platform === "ios") requireMacFor("iOS testing");
514
+ if (platform !== "ios" && Object.keys(launchOptions).length) {
515
+ console.error("❌ --launch-arg and --launch-env apply only to iOS targets");
516
+ process.exit(2);
517
+ }
464
518
  if (platform === "web" && !/^https?:\/\//i.test(target)) {
465
519
  console.error("❌ Web QA needs an http(s) URL");
466
520
  process.exit(2);
467
521
  }
468
522
  const bundleId = platform === "ios" ? await resolveTargetOrExit(engine, target) : null;
469
523
  const android = platform === "android" ? androidTarget(flags, target) : null;
470
- const unit = platform === "web" ? "pages" : "screens";
524
+ const progressMetric = platform === "web" ? "pages reached" : platform === "ios" ? "structural states observed" : "screens reached";
471
525
  const onProgress = (p) =>
472
- process.stderr.write(`\r🔍 Exploring… ${p.action}/${p.max || flags.actions || 60} actions · ${p.states} ${unit} reached `);
526
+ process.stderr.write(`\r🔍 Exploring… ${p.action}/${p.max || flags.actions || 60} actions · ${p.states} ${progressMetric} `);
473
527
  const r = platform === "web"
474
528
  ? await engine.runQaWeb({
475
529
  url: target,
@@ -497,7 +551,7 @@ switch (command) {
497
551
  bundleId,
498
552
  maxActions: flags.actions,
499
553
  timeout: flags.timeout,
500
- args: { testEmail: flags.email, testPassword: flags.password, baselineFindings },
554
+ args: { testEmail: flags.email, testPassword: flags.password, baselineFindings, ...launchOptions },
501
555
  surface: "cli",
502
556
  onProgress,
503
557
  });
@@ -555,7 +609,6 @@ switch (command) {
555
609
  if (target.apkPath) await driver.install(target.apkPath);
556
610
  const snap = await driver.launch({ clearData: flags["clear-data"] === true });
557
611
  const data = await driver.screenshot();
558
- await driver.forceStop();
559
612
  console.log(`🚀 Launched \`${target.appId}\` (Android)\n`);
560
613
  console.log(engine.formatScreen(snap.screenTitle, snap.elements));
561
614
  const out = typeof flags.out === "string" ? flags.out : path.join(tappHome, "shots", `${target.appId}-${Date.now()}.png`);
@@ -615,12 +668,17 @@ switch (command) {
615
668
  break;
616
669
  }
617
670
  if (platform === "android") {
618
- const target = androidTarget(flags, positionals[0] || "");
671
+ const input = positionals[0] || "";
672
+ const hasTarget = !!(input || flags["app-id"] || flags.apk);
673
+ const target = hasTarget
674
+ ? androidTarget(flags, input)
675
+ : { serial: typeof flags.serial === "string" ? flags.serial : undefined };
619
676
  const { AndroidDriver } = await import(path.join(packageRoot, "mcp-server", "src", "android-driver.js"));
620
677
  const driver = new AndroidDriver(target);
621
678
  await driver.ensureDevice();
622
- const snap = await driver.snapshot();
623
- if (flags.json) console.log(JSON.stringify({ screenTitle: snap.screenTitle, elements: snap.elements }, null, 2));
679
+ if (target.apkPath) await driver.install(target.apkPath);
680
+ const snap = target.appId ? await driver.launch() : await driver.snapshot();
681
+ if (flags.json) console.log(JSON.stringify({ platform: "android", appId: target.appId || null, activity: snap.activity, screenTitle: snap.screenTitle, elements: snap.elements }, null, 2));
624
682
  else console.log(engine.formatScreen(snap.screenTitle, snap.elements));
625
683
  break;
626
684
  }
@@ -41,6 +41,16 @@ function runFile(command, args, { encoding = "utf8", timeout = 30_000, maxBuffer
41
41
  });
42
42
  }
43
43
 
44
+ export function parseLatestAndroidCrashExitInfo(output) {
45
+ const blocks = String(output || "").split(/ApplicationExitInfo #\d+:/).slice(1);
46
+ for (const block of blocks) {
47
+ const reason = block.match(/\breason=(4|5)\s+\((?:APP CRASH|NATIVE CRASH)/);
48
+ const identity = block.match(/\btimestamp=([^\n]+?)\s+pid=(\d+)\b/);
49
+ if (reason && identity) return `${identity[1].trim()}|${identity[2]}|${reason[1]}`;
50
+ }
51
+ return null;
52
+ }
53
+
44
54
  function entityDecode(value) {
45
55
  return String(value || "")
46
56
  .replaceAll("&quot;", '"').replaceAll("&apos;", "'")
@@ -118,8 +128,15 @@ export function detectAndroidScreen(elements, activity = "") {
118
128
 
119
129
  export function isAndroidAppSnapshot(snapshot, appId) {
120
130
  if (!snapshot || !appId) return false;
121
- if (String(snapshot.activity || "").startsWith(`${appId}/`)) return true;
122
- return snapshot.elements.some((e) => e.package === appId);
131
+ const activity = String(snapshot.activity || "");
132
+ const ownsActivity = activity.startsWith(`${appId}/`);
133
+ const packages = new Set((snapshot.elements || []).map((e) => e.package).filter(Boolean));
134
+ const ownsElements = packages.has(appId);
135
+ // dumpXml and dumpsys used to run concurrently. If an app crashed while they were sampled, a
136
+ // stale activity from the dead app could be paired with another app's UI tree and Tapp would map
137
+ // that unrelated app as the crash destination. When both signals exist, require agreement.
138
+ if (activity && packages.size) return ownsActivity && ownsElements;
139
+ return ownsActivity || ownsElements;
123
140
  }
124
141
 
125
142
  export class AndroidDriver {
@@ -158,6 +175,19 @@ export class AndroidDriver {
158
175
  if (this.appId) await this.adb(["shell", "am", "force-stop", this.appId]);
159
176
  }
160
177
 
178
+ async isProcessAlive() {
179
+ if (!this.appId) return false;
180
+ const r = await this.adb(["shell", "pidof", this.appId]);
181
+ return r.code === 0 && /\d/.test(String(r.stdout || ""));
182
+ }
183
+
184
+ async latestCrashExitInfo() {
185
+ if (!this.appId) return null;
186
+ const r = await this.adb(["shell", "dumpsys", "activity", "exit-info", this.appId]);
187
+ if (r.code !== 0) return null;
188
+ return parseLatestAndroidCrashExitInfo(r.stdout);
189
+ }
190
+
161
191
  async clearData() {
162
192
  if (!this.appId) throw new Error("appId is required to clear Android app data");
163
193
  const r = await this.adb(["shell", "pm", "clear", this.appId]);
@@ -8,6 +8,8 @@ import { semanticUiKey } from "./ui-map.js";
8
8
 
9
9
  const ERROR_RE = /\b(something went wrong|internal server error|an error occurred|failed to load|unhandled exception|has stopped)\b/i;
10
10
  const DESTRUCTIVE_RE = /\b(delete|remove|purchase|buy now|pay now|reset|erase|unsubscribe|sign out|log out|logout)\b/i;
11
+ const AUTH_SUBMIT_RE = /\b(sign[ -]?in|log[ -]?in|continue|submit)\b/i;
12
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
11
13
 
12
14
  function stateHash(snap) {
13
15
  return snap.elements.map((e) => `${androidElementKey(e)}:${e.text}:${e.x},${e.y}`).join("|");
@@ -40,6 +42,21 @@ function isDestructive(e) {
40
42
  return DESTRUCTIVE_RE.test(`${e.label || ""} ${e.text || ""} ${e.description || ""} ${e.id || ""}`.replace(/[_-]+/g, " "));
41
43
  }
42
44
 
45
+ export function isAndroidAuthSubmit(element) {
46
+ return AUTH_SUBMIT_RE.test(`${element?.label || ""} ${element?.text || ""} ${element?.description || ""} ${element?.id || ""}`.replace(/[_-]+/g, " "));
47
+ }
48
+
49
+ export function isAndroidBlankSnapshot(snapshot) {
50
+ const elements = snapshot?.elements || [];
51
+ const meaningful = elements.some((element) =>
52
+ String(element.text || "").trim() ||
53
+ String(element.description || "").trim() ||
54
+ (String(element.id || "").trim() && !/^(android:)?id\/content$/i.test(String(element.id || "").trim()))
55
+ );
56
+ const interactive = elements.some((element) => element.hittable && (element.clickable || /Button|EditText|Tab|Switch|CheckBox/i.test(element.type)));
57
+ return !meaningful && !interactive;
58
+ }
59
+
43
60
  export async function exploreAndroid({ appId, apkPath, serial, maxActions = 40, timeoutSec = 300, outDir, testEmail = "", testPassword = "", clearData = true, seedTargets = [], onProgress = () => {}, driver }) {
44
61
  const d = driver || new AndroidDriver({ appId, serial });
45
62
  d.appId = appId;
@@ -56,7 +73,33 @@ export async function exploreAndroid({ appId, apkPath, serial, maxActions = 40,
56
73
  const visited = new Map();
57
74
  let issues = 0;
58
75
  let actions = 0;
76
+ const crashExitBaseline = typeof d.latestCrashExitInfo === "function" ? await d.latestCrashExitInfo() : null;
59
77
  let snap = await d.launch({ clearData });
78
+ let crashReported = false;
79
+
80
+ const reportProcessExit = async (screen, step) => {
81
+ if (typeof d.isProcessAlive !== "function") return false;
82
+ // Android may reveal the launcher before the crashing process disappears from
83
+ // pidof. A one-shot liveness sample made identical crashes scheduler-dependent.
84
+ // Poll only after app ownership is already lost: external intents and ordinary
85
+ // Back boundaries keep the originating process alive and remain boundaries.
86
+ let alive = await d.isProcessAlive();
87
+ let latestCrash = typeof d.latestCrashExitInfo === "function" ? await d.latestCrashExitInfo() : null;
88
+ const exitDeadline = Date.now() + 2_000;
89
+ while (alive && (!latestCrash || latestCrash === crashExitBaseline) && Date.now() < exitDeadline) {
90
+ await sleep(150);
91
+ alive = await d.isProcessAlive();
92
+ latestCrash = typeof d.latestCrashExitInfo === "function" ? await d.latestCrashExitInfo() : null;
93
+ }
94
+ const recordedCrash = !!latestCrash && latestCrash !== crashExitBaseline;
95
+ if ((alive && !recordedCrash) || crashReported) return false;
96
+ crashReported = true;
97
+ emit("ISSUE", { type: "crash", severity: "critical", title: "App process exited during exploration", screen: screen || "Launch", step });
98
+ issues += 1;
99
+ return true;
100
+ };
101
+
102
+ if (!isAndroidAppSnapshot(snap, appId)) await reportProcessExit("Launch", 0);
60
103
 
61
104
  const normalizedTargets = (Array.isArray(seedTargets) ? seedTargets : []).filter((target) =>
62
105
  target?.platform === "android" && target?.status === "planned" && target?.navigation?.status === "replayable" &&
@@ -95,7 +138,11 @@ export async function exploreAndroid({ appId, apkPath, serial, maxActions = 40,
95
138
  const before = state;
96
139
  snap = await d.settle();
97
140
  state = await recordState(snap);
98
- if (!state) { failure = "The UI Map path left the application foreground"; break; }
141
+ if (!state) {
142
+ const crashed = await reportProcessExit(before.screen, actions);
143
+ failure = crashed ? "The application process exited while replaying the UI Map path" : "The UI Map path left the application foreground";
144
+ break;
145
+ }
99
146
  emit("TRANSITION", { from: before.screen, to: state.screen, action: selector, changed: before.hash !== state.hash });
100
147
  onProgress({ action: actions, max: maxActions, states: visited.size });
101
148
  }
@@ -113,7 +160,10 @@ export async function exploreAndroid({ appId, apkPath, serial, maxActions = 40,
113
160
  // UIAutomator can still describe the launcher or a system surface after
114
161
  // Back/external navigation. Those are exploration boundaries, never nodes
115
162
  // in the application-owned UI Map.
116
- if (!isAndroidAppSnapshot(snap, appId)) break;
163
+ if (!isAndroidAppSnapshot(snap, appId)) {
164
+ await reportProcessExit(visited.size ? [...visited.values()].at(-1) : "Launch", actions);
165
+ break;
166
+ }
117
167
  const recorded = await recordState(snap);
118
168
  if (!recorded) break;
119
169
  const { hash, screen, inputs } = recorded;
@@ -124,7 +174,7 @@ export async function exploreAndroid({ appId, apkPath, serial, maxActions = 40,
124
174
  emit("ISSUE", { type: "error_surface", severity: "high", title: "Visible error surface", screen, step: actions });
125
175
  issues += 1;
126
176
  }
127
- if (snap.elements.filter((e) => e.hittable).length === 0) {
177
+ if (isAndroidBlankSnapshot(snap)) {
128
178
  emit("ISSUE", { type: "blank_screen", severity: "high", title: "No usable controls or content", screen, step: actions });
129
179
  issues += 1;
130
180
  }
@@ -154,16 +204,24 @@ export async function exploreAndroid({ appId, apkPath, serial, maxActions = 40,
154
204
  });
155
205
  if (candidate) {
156
206
  const target = controlLabel(candidate);
207
+ const loginSubmit = inputs.some((input) => input.secure) && isAndroidAuthSubmit(candidate);
157
208
  tried.add(`${hash}|tap|${target}`);
158
209
  const before = hash;
159
210
  const r = await d.tap(target, snap);
160
211
  actions += 1;
161
212
  snap = await d.settle();
162
213
  const after = stateHash(snap);
163
- emit("ACTION", { type: "tap", target, reason: "untried_control", step: actions, screen, status: r.status });
164
- if (!isAndroidAppSnapshot(snap, appId)) break;
214
+ emit("ACTION", { type: loginSubmit ? "login_submit" : "tap", target, reason: "untried_control", step: actions, screen, status: r.status });
215
+ if (!isAndroidAppSnapshot(snap, appId)) {
216
+ await reportProcessExit(screen, actions);
217
+ break;
218
+ }
165
219
  emit("TRANSITION", { from: screen, to: snap.screenTitle, action: target, changed: before !== after });
166
- if (r.status === "ok" && before === after && candidate.clickable) {
220
+ const authFailed = loginSubmit && inputDescriptors(snap.elements).some((input) => input.secure);
221
+ if (r.status === "ok" && authFailed) {
222
+ emit("ISSUE", { type: "auth_failed", severity: "high", title: "Sign-in attempt remained on the login screen", screen, target, step: actions });
223
+ issues += 1;
224
+ } else if (r.status === "ok" && before === after && candidate.clickable) {
167
225
  emit("ISSUE", { type: "unresponsive_element", severity: "medium", title: `Control did not respond: ${target}`, screen, target, step: actions });
168
226
  issues += 1;
169
227
  }
@@ -179,7 +237,10 @@ export async function exploreAndroid({ appId, apkPath, serial, maxActions = 40,
179
237
  actions += 1;
180
238
  const next = await d.settle();
181
239
  emit("ACTION", { type: "back", target: "system back", reason: "state_exhausted", step: actions, screen });
182
- if (!isAndroidAppSnapshot(next, appId)) break;
240
+ if (!isAndroidAppSnapshot(next, appId)) {
241
+ await reportProcessExit(screen, actions);
242
+ break;
243
+ }
183
244
  emit("TRANSITION", { from: screen, to: next.screenTitle, action: "back", changed: stateHash(next) !== hash });
184
245
  if (stateHash(next) !== hash) { snap = next; continue; }
185
246
  }
@@ -187,11 +248,7 @@ export async function exploreAndroid({ appId, apkPath, serial, maxActions = 40,
187
248
  }
188
249
 
189
250
  const timedOut = Date.now() >= deadline;
190
- if (timedOut) {
191
- emit("ISSUE", { type: "explore_timeout", severity: "medium", title: `Exploration timed out after ${timeoutSec}s`, screen: snap.screenTitle, step: actions });
192
- issues += 1;
193
- }
194
- emit("COMPLETE", { actions, states: visited.size, issues, screens: [...new Set(visited.values())].join(","), outcome: timedOut ? "timeout" : "complete" });
251
+ emit("COMPLETE", { actions, states: visited.size, issues, screens: [...new Set(visited.values())].join(","), outcome: timedOut ? "timeout" : "complete", timedOut, ...(timedOut ? { timeoutSeconds: timeoutSec } : {}) });
195
252
  onProgress({ action: actions, max: maxActions, states: visited.size });
196
253
  return { markersPath, outDir, actions, states: visited.size, issues, timedOut, seedTargets: normalizedTargets };
197
254
  }
@@ -402,7 +402,7 @@ function renderMarkdown(report, regression, flows, scenarios, contracts, prPlan,
402
402
  } else if (gate.policy === "gate") {
403
403
  lines.push("");
404
404
  lines.push("### Baseline — 🟡 not active yet");
405
- lines.push("No baseline was supplied, so this run used the blocked/inconclusive fallback. Save this report as a baseline—or run the GitHub Action on the default branch—to activate new-regression gating.");
405
+ lines.push("No baseline was supplied. This gate still enforces absolute blockers and reviewed suite failures, but it cannot identify new regressions until a baseline is saved.");
406
406
  }
407
407
  if (prPlan) {
408
408
  lines.push("");
@@ -487,7 +487,7 @@ function renderMarkdown(report, regression, flows, scenarios, contracts, prPlan,
487
487
  }
488
488
 
489
489
  const args = parseArgs(process.argv.slice(2));
490
- const report = buildQaReport(args.markers, { platform: args.platform || "ios" });
490
+ const report = buildQaReport(args.markers, { platform: args.platform || "ios", target: args.label || null });
491
491
  if (!report) {
492
492
  // Required evidence could not be obtained — this is inconclusive (fails closed), not a gate FAIL
493
493
  // and not a usage error. See the outcome model in report.js (GATE_EXIT).
@@ -561,7 +561,7 @@ try {
561
561
  const decision = evaluateGate({ report, regression, flows, scenarios, contracts, prPlan, baseline, failOn: args.failOn });
562
562
  const gate = {
563
563
  ...decision,
564
- target: args.targetKey || null,
564
+ target: args.targetKey || report.target || null,
565
565
  revision: gitRevision(args.projectDir),
566
566
  checked: report.checkedFor,
567
567
  notChecked: report.notChecked,
@@ -49,7 +49,7 @@ function collectShots(captureDir) {
49
49
  }
50
50
  }
51
51
 
52
- export function writeHtmlReport(captureDir, { report, label = "" } = {}) {
52
+ export function writeHtmlReport(captureDir, { report, label = "", recordingWarning = "" } = {}) {
53
53
  const r = report || buildQaReport(path.join(captureDir, "ocqa-markers.txt"));
54
54
  if (!r) return null;
55
55
 
@@ -78,7 +78,16 @@ export function writeHtmlReport(captureDir, { report, label = "" } = {}) {
78
78
 
79
79
  const videoHtml = video
80
80
  ? `<h2>Recording — the full exploration</h2>\n<video controls preload="metadata" style="width:100%;border:1px solid #d0d7de;border-radius:8px" src="${esc(video)}"></video>`
81
- : "";
81
+ : recordingWarning
82
+ ? `<h2>Recording</h2>\n<p class="warning">Unavailable: ${esc(recordingWarning)}. Screenshots were still captured.</p>`
83
+ : "";
84
+
85
+ const scopeList = (items) => (items || []).map((item) => `<li>${esc(item)}</li>`).join("\n");
86
+ const scopeHtml = `<section class="scope">
87
+ <div><h2>Checked this run</h2><ul>${scopeList(r.checkedFor) || "<li class='dim'>No automated checks completed.</li>"}</ul></div>
88
+ <div><h2>Not checked this run</h2><ul>${scopeList(r.notChecked) || "<li class='dim'>No additional limitations recorded.</li>"}</ul></div>
89
+ ${r.conditionsNotReached?.length ? `<div><h2>Conditions not reached</h2><ul>${scopeList(r.conditionsNotReached)}</ul></div>` : ""}
90
+ </section>`;
82
91
 
83
92
  const html = `<!doctype html>
84
93
  <html lang="en">
@@ -90,6 +99,10 @@ export function writeHtmlReport(captureDir, { report, label = "" } = {}) {
90
99
  h1 { font-size: 1.6rem; margin-bottom: 0.2rem; }
91
100
  .meta { color: #57606a; margin-bottom: 1.2rem; }
92
101
  .headline { background: #f6f8fa; border-radius: 8px; padding: 0.9rem 1.1rem; margin: 1rem 0; }
102
+ .warning { background: #fff8c5; border: 1px solid #d4a72c; border-radius: 8px; padding: 0.75rem 1rem; }
103
+ .scope { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 0.8rem 1.5rem; }
104
+ .scope h2 { font-size: 1.05rem; margin-bottom: 0.35rem; }
105
+ .scope ul { margin-top: 0; padding-left: 1.2rem; }
93
106
  .sev { color: #fff; border-radius: 4px; padding: 0.05rem 0.45rem; font-size: 0.78rem; font-weight: 600; margin-right: 0.4rem; }
94
107
  ul.findings { padding-left: 1.1rem; } ul.findings li { margin-bottom: 0.6rem; }
95
108
  .ai { color: #57606a; font-size: 0.88rem; margin: 0.15rem 0 0 0.2rem; }
@@ -105,6 +118,7 @@ export function writeHtmlReport(captureDir, { report, label = "" } = {}) {
105
118
  <div class="meta">${esc(label)} · evidence page (observation, not a release decision)</div>
106
119
  ${r.platform === "web" ? `<div class="meta">Deterministic basis: ${r.deterministicFindingCounts?.total || 0} deterministic finding(s); ${r.sampledFindingCounts?.total || 0} sampled probe finding(s) are advisory.</div>` : ""}
107
120
  <div class="headline">${esc(r.headline)}</div>
121
+ ${scopeHtml}
108
122
  <h2>Findings</h2>
109
123
  <ul class="findings">
110
124
  ${findingsHtml}