Skip to content

Commit 57e0dfd

Browse files
kraenhansenclaude
andcommitted
test-app: fail the Android run as soon as the app crashes
`mocha-remote` waits indefinitely for a client to connect and has no notion of the app dying. When the test app crashed on startup, nothing ever connected: the run sat idle until the 75 minute step timeout, with the actual cause — a `FATAL EXCEPTION` one second after `am start` — only visible by downloading the logcat artifact afterwards. Add a watchdog that follows `adb logcat -b crash` alongside the app and exits non-zero when the crash buffer names the test app, printing the stack trace inline. `concurrently --kill-others-on-fail` then tears down Metro and the app run, and `mocha-remote` inherits the failing exit code, so a startup crash fails the job in seconds rather than in an hour. It deliberately only reacts to crashes — an app that hangs or never launches still falls back to the job timeout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent f913ca5 commit 57e0dfd

3 files changed

Lines changed: 94 additions & 2 deletions

File tree

apps/test-app/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@
66
"scripts": {
77
"metro": "react-native start --no-interactive",
88
"android": "react-native run-android --no-packager --active-arch-only",
9+
"android:crash-watchdog": "node scripts/android-crash-watchdog.mts",
910
"ios": "react-native run-ios --no-packager",
1011
"pod-install": "cd ios && pod install",
1112
"mocha-and-metro": "mocha-remote --watch -- react-native start",
12-
"test:android": "mocha-remote --exit-on-error -- concurrently --kill-others-on-fail --passthrough-arguments npm:metro 'npm:android -- {@}' --",
13+
"test:android": "mocha-remote --exit-on-error -- concurrently --kill-others-on-fail --passthrough-arguments npm:metro 'npm:android -- {@}' npm:android:crash-watchdog --",
1314
"test:android:allTests": "MOCHA_REMOTE_CONTEXT=allTests node --run test:android -- ",
1415
"test:android:nodeAddonExamples": "MOCHA_REMOTE_CONTEXT=nodeAddonExamples node --run test:android -- ",
1516
"test:android:nodeTests": "MOCHA_REMOTE_CONTEXT=nodeTests node --run test:android -- ",
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* Fails the Android test run as soon as the app crashes.
3+
*
4+
* `mocha-remote` waits indefinitely for a client to connect and has no notion
5+
* of the app dying: when the app crashes on startup, nothing ever connects and
6+
* the run hangs until the CI job hits its timeout — 68 minutes of an emulator
7+
* idling for a crash that happened one second after `am start`.
8+
*
9+
* Run alongside the app (through `concurrently --kill-others-on-fail`), this
10+
* turns such a crash into an immediate failure with the stack trace inlined in
11+
* the log, instead of a timeout with the cause buried in a logcat artifact.
12+
*
13+
* It only reacts to crashes: a hung or never-launched app still relies on the
14+
* job timeout.
15+
*/
16+
import cp from "node:child_process";
17+
import readline from "node:readline";
18+
19+
// The application id used by react-native-test-app, which the CI workflow also
20+
// hardcodes when uninstalling any leftover copy of the app.
21+
const APP_ID = "com.microsoft.reacttestapp";
22+
23+
// How long to keep reading after the first line mentioning the app, to capture
24+
// the rest of the stack trace before exiting.
25+
const TRACE_GRACE_MS = 1000;
26+
27+
/**
28+
* Runs adb, resolving false if it couldn't run at all (not installed, no
29+
* device, etc). The watchdog stays out of the way in that case: the build or
30+
* the run itself will fail with a better message than anything we could add.
31+
*/
32+
function adb(...args: string[]): Promise<boolean> {
33+
return new Promise((resolve) => {
34+
const child = cp.spawn("adb", args, { stdio: "ignore" });
35+
child.on("error", () => resolve(false));
36+
child.on("close", (code) => resolve(code === 0));
37+
});
38+
}
39+
40+
function skip(reason: string): never {
41+
console.warn(`[crash-watchdog] Not watching for crashes: ${reason}`);
42+
process.exit(0);
43+
}
44+
45+
async function main() {
46+
if (!(await adb("wait-for-device"))) {
47+
skip("failed to wait for an adb device");
48+
}
49+
50+
// Drop any crash from an earlier run, so we only react to this one. The app
51+
// hasn't been installed yet at this point, so this can't discard a crash we
52+
// care about.
53+
await adb("logcat", "-b", "crash", "-c");
54+
55+
const logcat = cp.spawn("adb", ["logcat", "-b", "crash"], {
56+
stdio: ["ignore", "pipe", "inherit"],
57+
});
58+
59+
// The line naming the app is preceded by the header of the crash it belongs
60+
// to ("FATAL EXCEPTION: main"), so keep a few lines of lead-in around.
61+
const LEAD_IN_LINES = 5;
62+
const trace: string[] = [];
63+
let crashed = false;
64+
65+
logcat.on("error", () => skip("failed to spawn adb logcat"));
66+
logcat.on("close", () => {
67+
// Getting killed once the tests pass is the expected way for this to end.
68+
if (!crashed) {
69+
skip("adb logcat exited");
70+
}
71+
});
72+
73+
for await (const line of readline.createInterface({ input: logcat.stdout })) {
74+
trace.push(line);
75+
if (crashed) {
76+
continue;
77+
} else if (line.includes(APP_ID)) {
78+
crashed = true;
79+
// Give the rest of the stack trace a moment to arrive before printing it.
80+
setTimeout(() => {
81+
console.error(`\n[crash-watchdog] ${APP_ID} crashed:\n`);
82+
console.error(trace.join("\n"));
83+
process.exit(1);
84+
}, TRACE_GRACE_MS);
85+
} else if (trace.length > LEAD_IN_LINES) {
86+
trace.shift();
87+
}
88+
}
89+
}
90+
91+
await main();

apps/test-app/tsconfig.node-scripts.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,5 @@
77
"rootDir": "scripts",
88
"types": ["node"]
99
},
10-
"include": ["scripts/**/*.ts"]
10+
"include": ["scripts"]
1111
}

0 commit comments

Comments
 (0)