@aarwitz/tapp 0.17.0-rc.2 → 0.17.0-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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(""", '"').replaceAll("'", "'")
@@ -171,6 +181,13 @@ export class AndroidDriver {
171
181
  return r.code === 0 && /\d/.test(String(r.stdout || ""));
172
182
  }
173
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
+
174
191
  async clearData() {
175
192
  if (!this.appId) throw new Error("appId is required to clear Android app data");
176
193
  const r = await this.adb(["shell", "pm", "clear", this.appId]);
@@ -188,7 +205,22 @@ export class AndroidDriver {
188
205
  const r = await this.adb(["shell", "am", "start", "-W", "-n", component], { timeout: 30_000 });
189
206
  if (r.code !== 0 || !/Status:\s*ok/i.test(String(r.stdout))) throw new Error((r.stderr || r.stdout || `Could not launch ${this.appId}`).trim());
190
207
  await sleep(600);
191
- return this.snapshot();
208
+ return this.waitForOwnedSnapshot();
209
+ }
210
+
211
+ async waitForOwnedSnapshot(timeoutMs = 4_000) {
212
+ const deadline = Date.now() + timeoutMs;
213
+ let latest;
214
+ do {
215
+ latest = await this.snapshot();
216
+ if (isAndroidAppSnapshot(latest, this.appId)) return latest;
217
+ if (Date.now() >= deadline) break;
218
+ // Activity and UIAutomator are intentionally required to agree. During app
219
+ // launch they may momentarily describe opposite sides of the transition;
220
+ // retry that mixed snapshot instead of turning it into zero-screen evidence.
221
+ await sleep(150);
222
+ } while (Date.now() < deadline);
223
+ return latest;
192
224
  }
193
225
 
194
226
  async currentActivity() {
@@ -9,6 +9,7 @@ import { semanticUiKey } from "./ui-map.js";
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
11
  const AUTH_SUBMIT_RE = /\b(sign[ -]?in|log[ -]?in|continue|submit)\b/i;
12
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
12
13
 
13
14
  function stateHash(snap) {
14
15
  return snap.elements.map((e) => `${androidElementKey(e)}:${e.text}:${e.x},${e.y}`).join("|");
@@ -72,12 +73,26 @@ export async function exploreAndroid({ appId, apkPath, serial, maxActions = 40,
72
73
  const visited = new Map();
73
74
  let issues = 0;
74
75
  let actions = 0;
76
+ const crashExitBaseline = typeof d.latestCrashExitInfo === "function" ? await d.latestCrashExitInfo() : null;
75
77
  let snap = await d.launch({ clearData });
76
78
  let crashReported = false;
77
79
 
78
80
  const reportProcessExit = async (screen, step) => {
79
- const alive = typeof d.isProcessAlive === "function" ? await d.isProcessAlive() : true;
80
- if (alive || crashReported) return false;
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;
81
96
  crashReported = true;
82
97
  emit("ISSUE", { type: "crash", severity: "critical", title: "App process exited during exploration", screen: screen || "Launch", step });
83
98
  issues += 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aarwitz/tapp",
3
- "version": "0.17.0-rc.2",
3
+ "version": "0.17.0-rc.4",
4
4
  "mcpName": "io.github.aarwitz/tapp",
5
5
  "description": "Release contracts, autonomous QA, and evidence-backed CI gates for iOS, Android, and web.",
6
6
  "license": "MIT",
@@ -11,7 +11,7 @@
11
11
  #
12
12
  # Usage:
13
13
  # scripts/ci-gate.sh [--platform ios] --app <path/to/App.app> [--bundle-id <com.example.app>]
14
- # scripts/ci-gate.sh --platform android --apk <path/to/app.apk> --app-id <com.example.app>
14
+ # scripts/ci-gate.sh --platform android --apk <path/to/app.apk> --app-id <com.example.app> [--serial <adb-serial>]
15
15
  # scripts/ci-gate.sh --platform web [--url <http(s)://owned-app>]
16
16
  # # omit --url with --project-dir to detect/build/start/stop one owned web target
17
17
  # # bundle id is detected from the .app when omitted
@@ -31,6 +31,7 @@
31
31
  # [--json-out <file.json>] # write the full report (use as the next baseline)
32
32
  # [--md-out <file.md>] # write the rendered markdown report (for a PR comment)
33
33
  # [--device <name>] # simulator device to boot if none is (default "iPhone 16 Pro")
34
+ # [--serial <adb-serial>] # Android emulator/device (default: first connected device)
34
35
  #
35
36
  # The app must be a SIMULATOR build (xcodebuild ... -destination 'generic/platform=iOS Simulator').
36
37
  set -uo pipefail