Tighten up the tests further, try to get them passing on the full matrix

This commit is contained in:
Tim Perry
2026-07-31 18:53:34 +02:00
parent 123745265d
commit afc72eef34
3 changed files with 58 additions and 31 deletions
+34 -20
View File
@@ -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}'`);
+6 -3
View File
@@ -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."
+18 -8
View File
@@ -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 () => {