@aarwitz/tapp 0.17.0-rc.7 → 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 = [];
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aarwitz/tapp",
3
- "version": "0.17.0-rc.7",
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 "