More test hardening to make CI reliable

This commit is contained in:
Tim Perry
2026-07-31 19:46:44 +02:00
parent 3696f6674f
commit bc73628efc
2 changed files with 63 additions and 18 deletions
+57 -15
View File
@@ -64,9 +64,11 @@ const parseNodes = (xml: string) => {
*/
export const readUi = async (): Promise<UiNode[]> => {
// Dumping is rejected while the UI is busy or wedged. That's worth waiting out rather than
// failing immediately: if the app has hung, Android puts an ANR dialog up within a few
// seconds, and reporting that is far more useful than reporting a failed dump.
const ATTEMPTS = 20;
// failing immediately: if something has hung, Android takes ~15s to declare the ANR (5s of
// unhandled input, then collecting stack traces) and only then puts up a dialog and logs the
// reason. Waiting past that means we report which app hung and why, instead of just a dump
// that didn't work.
const ATTEMPTS = 60;
for (let attempt = 1; ; attempt++) {
// Dumping to /dev/tty streams the XML straight back to us, avoiding a second round trip
@@ -106,9 +108,11 @@ export class DeviceApp {
// N.b. no parameter properties - Node's type stripping only erases types, it can't
// generate the assignment those imply:
private appId: string;
private appName: string; // As shown in the UI, e.g. in Android's own dialogs about the app
constructor(appId: string) {
constructor(appId: string, appName: string) {
this.appId = appId;
this.appName = appName;
}
/**
@@ -156,19 +160,46 @@ export class DeviceApp {
return this.ownNodes(nodes).filter((node) => node.className === 'android.widget.Button');
}
hasText(nodes: UiNode[], text: string) {
return this.ownNodes(nodes).some((node) => node.text === text);
isOnScreen(nodes: UiNode[]) {
return this.ownNodes(nodes).some((node) => node.text === this.appName);
}
private errorDialogs(nodes: UiNode[]) {
return nodes.filter((node) =>
node.packageName === 'android' &&
/isn't responding|keeps stopping|has stopped/i.test(node.text)
);
}
/**
* Android's own crash & ANR dialogs. These cover the app, hiding it from everything we query,
* so without this a crash looks like a button that never responded.
* Android's own crash & ANR dialog for the app under test. These cover the app, hiding it from
* everything we query, so without this a crash looks like a button that never responded.
*/
systemErrorDialog(nodes: UiNode[]) {
return nodes.find((node) =>
node.packageName === 'android' &&
/isn't responding|keeps stopping|has stopped/i.test(node.text)
)?.text;
// N.b. it must name our app: dialogs about anything else aren't a failure of ours.
return this.errorDialogs(nodes).find((node) => node.text.includes(this.appName))?.text;
}
/**
* The same dialogs, but about some other app - a launcher that's hung on a slow emulator,
* typically. Not our problem, except that they sit on top of the app and hide it, so we
* dismiss them and carry on.
*/
async dismissOtherAppErrors(nodes: UiNode[]) {
const otherError = this.errorDialogs(nodes)
.find((node) => !node.text.includes(this.appName));
if (!otherError) return false;
const dismiss = (text: string) => nodes.find((node) =>
node.packageName === 'android' && node.text === text
);
// 'Wait' leaves the other app alone, so we prefer it to closing it:
const button = dismiss('Wait') ?? dismiss('Close app');
if (!button) return false;
console.log(`Dismissing an unrelated system dialog: "${otherError.text}"`);
await this.tap(button);
return true;
}
private assertNotCrashed(nodes: UiNode[]) {
@@ -197,19 +228,30 @@ export class DeviceApp {
await adb('shell', 'input', 'swipe', `${x}`, `${from}`, `${x}`, `${to}`, '300');
}
/**
* A UI read that explains itself: if the screen can't be read at all, that's usually because
* something has hung, and Android will have logged which app & why.
*/
private async readUiOrExplain() {
return readUi().catch(async (e) => {
const androidLogs = await this.recentFailureLogs();
throw new Error(e.message + (androidLogs ? `\nAndroid logged:\n${androidLogs}` : ''));
});
}
/**
* Read the app's own view of the screen, waiting for it if something (e.g. a toast) is
* currently on top of it.
*/
private async readApp() {
for (let i = 0; i < 20; i++) {
const nodes = await readUi();
const nodes = await this.readUiOrExplain();
this.assertNotCrashed(nodes);
const scrollable = this.scrollable(nodes);
if (scrollable) return { nodes, scrollable };
await delay(500);
if (!await this.dismissOtherAppErrors(nodes)) await delay(500);
}
throw new Error(`${this.appId} was not on screen`);
@@ -299,7 +341,7 @@ export class DeviceApp {
let retapped = false;
while (true) {
const nodes = await readUi();
const nodes = await this.readUiOrExplain();
this.assertNotCrashed(nodes);
const button = this.buttons(nodes).find((b) => b.text === text);
+6 -3
View File
@@ -7,7 +7,7 @@ import { DeviceApp, delay, readUi } from './device.ts';
const APP_ID = 'tech.httptoolkit.pinning_demo';
const app = new DeviceApp(APP_ID);
const app = new DeviceApp(APP_ID, 'SSL Pinning Demo');
type Result = 'Success' | 'Failed';
@@ -174,15 +174,18 @@ describe('Test Android unpinning', function () {
const ui = await readUi();
// A crash or ANR dialog covers the app, so report that rather than just timing out:
// A crash or ANR dialog for our app covers it, so report that rather than just
// timing out. Dialogs about other apps (a launcher that's hung on a slow emulator,
// say) only get in the way, so those we dismiss and keep waiting:
const systemError = app.systemErrorDialog(ui);
if (systemError) return `Android reported: "${systemError}"`;
await app.dismissOtherAppErrors(ui);
// The previous instance's window can linger on screen briefly after it's killed,
// so we wait for Frida to confirm the launch, not just for the app to be visible:
if (
output().includes(`Spawned \`${APP_ID}\``) &&
app.hasText(ui, 'SSL Pinning Demo')
app.isOnScreen(ui)
) return undefined;
// N.b. this must await something on every pass, or we'd starve the event loop and