From afc72eef34e7aa4d991fc540c600146119c2c69d Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Fri, 31 Jul 2026 18:53:32 +0200 Subject: [PATCH] Tighten up the tests further, try to get them passing on the full matrix --- test/android/device.ts | 54 +++++++++++++++++++++------------- test/android/setup-emulator.sh | 9 ++++-- test/android/test.ts | 26 +++++++++++----- 3 files changed, 58 insertions(+), 31 deletions(-) diff --git a/test/android/device.ts b/test/android/device.ts index 4e09ccc..167fea3 100644 --- a/test/android/device.ts +++ b/test/android/device.ts @@ -111,10 +111,6 @@ export class DeviceApp { this.appId = appId; } - async apiLevel() { - return parseInt(await adb('shell', 'getprop', 'ro.build.version.sdk'), 10); - } - /** * Anything Android itself logged about the app crashing or hanging. When something goes wrong * this usually explains it far better than the symptoms we can see from outside. @@ -180,14 +176,15 @@ export class DeviceApp { if (error) throw new Error(`Android reported a problem with the app: "${error}"`); } + // N.b. this can legitimately be missing for a moment: a toast (this app shows one for every + // failed request) is a separate window, and while it's up the app may not appear in the UI at + // all. Callers wait for the app to come back rather than treating that as fatal. private scrollable(nodes: UiNode[]) { - const scrollable = this.ownNodes(nodes).find((node) => node.scrollable); - if (!scrollable) throw new Error(`No scrollable view found in ${this.appId}`); - return scrollable; + return this.ownNodes(nodes).find((node) => node.scrollable); } - private async swipe(nodes: UiNode[], direction: 'up' | 'down') { - const [left, top, right, bottom] = this.scrollable(nodes).bounds; + private async swipe(scrollable: UiNode, direction: 'up' | 'down') { + const [left, top, right, bottom] = scrollable.bounds; // We swipe within the middle of the view, to stay clear of the system gesture areas at // the very top & bottom of the screen: @@ -200,16 +197,34 @@ export class DeviceApp { await adb('shell', 'input', 'swipe', `${x}`, `${from}`, `${x}`, `${to}`, '300'); } + /** + * 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(); + this.assertNotCrashed(nodes); + + const scrollable = this.scrollable(nodes); + if (scrollable) return { nodes, scrollable }; + + await delay(500); + } + + throw new Error(`${this.appId} was not on screen`); + } + async scrollToTop() { let previousPosition = ''; for (let i = 0; i < 30; i++) { - const nodes = await readUi(); - const position = positionSummary(nodes); + const { nodes, scrollable } = await this.readApp(); + const position = positionSummary(this.ownNodes(nodes)); if (position === previousPosition) return; previousPosition = position; - await this.swipe(nodes, 'down'); + await this.swipe(scrollable, 'down'); } } @@ -227,17 +242,17 @@ export class DeviceApp { // We're at the end once scrolling stops changing what's on screen. Two unchanged pages // are required, so a single swipe that doesn't register can't quietly cut the list short: for (let i = 0; i < 30 && unchangedPages < 2; i++) { - const nodes = await readUi(); + const { nodes, scrollable } = await this.readApp(); for (const button of this.buttons(nodes)) { if (button.text && !buttons.includes(button.text)) buttons.push(button.text); } - const position = positionSummary(nodes); + const position = positionSummary(this.ownNodes(nodes)); unchangedPages = position === previousPosition ? unchangedPages + 1 : 0; previousPosition = position; - await this.swipe(nodes, 'up'); + await this.swipe(scrollable, 'up'); } return buttons; @@ -251,18 +266,17 @@ export class DeviceApp { let direction: 'up' | 'down' = 'up'; for (let i = 0; i < 40; i++) { - const nodes = await readUi(); - this.assertNotCrashed(nodes); + const { nodes, scrollable } = await this.readApp(); const button = this.buttons(nodes).find((b) => b.text === text); - if (button && contains(this.scrollable(nodes).bounds, button.bounds)) return button; + if (button && contains(scrollable.bounds, button.bounds)) return button; // If we've hit the end of the list without finding it, turn around and search back: - const position = positionSummary(nodes); + const position = positionSummary(this.ownNodes(nodes)); if (position === previousPosition) direction = direction === 'up' ? 'down' : 'up'; previousPosition = position; - await this.swipe(nodes, direction); + await this.swipe(scrollable, direction); } throw new Error(`Could not scroll to button '${text}'`); diff --git a/test/android/setup-emulator.sh b/test/android/setup-emulator.sh index d259c96..5767b7f 100755 --- a/test/android/setup-emulator.sh +++ b/test/android/setup-emulator.sh @@ -24,11 +24,14 @@ if [ "$INSTALLED_VERSION" != "$APK_VERSION" ]; then wget -q $APK_URL -O $APK_PATH fi # A locally built (or otherwise differently signed) copy of the app can't be upgraded in - # place, so if the install is rejected we replace it outright: - if ! adb install -r $APK_PATH; then + # place, so in that specific case we replace it outright. Anything else (e.g. the device + # having no matching ABI) we report as-is, rather than muddying it with a failed uninstall: + INSTALL_RESULT=$(adb install -r $APK_PATH 2>&1) || { + echo "$INSTALL_RESULT" + echo "$INSTALL_RESULT" | grep -q INSTALL_FAILED_UPDATE_INCOMPATIBLE adb uninstall $PACKAGE adb install $APK_PATH - fi + } echo "APK v$APK_VERSION installed (previously: ${INSTALLED_VERSION:-not installed})." else echo "APK v$APK_VERSION already installed." diff --git a/test/android/test.ts b/test/android/test.ts index 3b855f1..940a308 100644 --- a/test/android/test.ts +++ b/test/android/test.ts @@ -209,7 +209,13 @@ describe('Test Android unpinning', function () { }); expect(description).to.be.a('string', `Button ${text} did not respond`); - expect(description).to.include(expected, `Button ${text} was not ${expected}:`); + + // Matching the app's exact wording, so that a failure whose error message happens to + // mention success can't be read as one: + expect(description).to.include( + expected === 'Success' ? ' - Success' : ' - Failed with error:', + `Button ${text} was not ${expected}:` + ); }; // Test every button in the app, expecting the given result for each, except for the buttons @@ -218,7 +224,16 @@ describe('Test Android unpinning', function () { expected: Result, { exceptions = [] }: { exceptions?: string[] } = {} ) => { - const buttons = await app.findAllButtons(); + // If the app dies mid-test we see only its absence, so we say what Android saw too: + const buttons = await app.findAllButtons().catch(async (e) => { + const androidLogs = await app.recentFailureLogs(); + throw new Error( + `${e.message}` + + (await app.isRunning() ? '' : '. The app is no longer running') + + (androidLogs ? `.\nAndroid logged:\n${androidLogs}` : '') + ); + }); + console.log(`Testing ${buttons.length} buttons: ${buttons.join(', ')}`); // Without this, a scenario with no exceptions would pass having tested nothing at all: @@ -282,7 +297,7 @@ describe('Test Android unpinning', function () { describe("given basic interception", () => { - beforeEach(async function () { + beforeEach(async () => { await launchFrida([ './test/android/tmp/config.js', // Our custom config // Otherwise just the basic Android settings injection scripts to set the @@ -290,11 +305,6 @@ describe('Test Android unpinning', function () { './android/android-proxy-override.js', './android/android-system-certificate-injection.js' ]); - - // Android <10 uses X509TrustManager (not the cert stores hooked by system-certificate-injection) - // so this fails without the unpinning scripts - not really a problem in practice, but unhelpful - // for testing. - if (await app.apiLevel() <= 28) return this.skip(); }); it("everything should succeed except the explicitly pinned requests", async () => {