Skip to content

Commit 3fee9d6

Browse files
authored
fix: cache Android snapshot helper installs (#470)
1 parent 6c368bf commit 3fee9d6

7 files changed

Lines changed: 453 additions & 5 deletions

File tree

.github/workflows/android.yml

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,35 @@ jobs:
2929
id: android-replay-host
3030
uses: ./.github/actions/setup-android-replay-host
3131

32-
- name: Run Android smoke replay
32+
- name: Install Android SDK packages
33+
run: |
34+
SDK_ROOT="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-/usr/local/lib/android/sdk}}"
35+
SDKMANAGER="$SDK_ROOT/cmdline-tools/latest/bin/sdkmanager"
36+
if [ ! -x "$SDKMANAGER" ]; then
37+
SDKMANAGER="$SDK_ROOT/cmdline-tools/bin/sdkmanager"
38+
fi
39+
if [ ! -x "$SDKMANAGER" ]; then
40+
echo "sdkmanager not found under $SDK_ROOT" >&2
41+
exit 1
42+
fi
43+
yes | "$SDKMANAGER" --licenses >/dev/null
44+
"$SDKMANAGER" "platforms;android-36" "build-tools;36.0.0"
45+
46+
- name: Package npm-bundled Android snapshot helper
47+
run: pnpm package:android-snapshot-helper:npm
48+
49+
- name: Run Android smoke checks
3350
uses: reactivecircus/android-emulator-runner@b530d96654c385303d652368551fb075bc2f0b6b # v2.35.0
3451
with:
3552
api-level: 36
3653
arch: x86_64
3754
profile: pixel_7
3855
target: google_apis_playstore
3956
emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -no-metrics
40-
script: node --experimental-strip-types src/bin.ts test test/integration/replays/android/01-settings.ad --retries 2 --report-junit test/artifacts/replays-android-smoke.junit.xml
57+
script: |
58+
set -e
59+
node --experimental-strip-types src/bin.ts test test/integration/replays/android/01-settings.ad --retries 2 --report-junit test/artifacts/replays-android-smoke.junit.xml
60+
sh ./test/scripts/android-snapshot-helper-release-smoke.sh
4161
4262
- name: Upload Android artifacts
4363
if: always()

src/platforms/android/__tests__/snapshot-helper.test.ts

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,16 @@ import crypto from 'node:crypto';
33
import { promises as fs } from 'node:fs';
44
import os from 'node:os';
55
import path from 'node:path';
6-
import { test } from 'vitest';
6+
import { beforeEach, test } from 'vitest';
77
import {
88
captureAndroidSnapshotWithHelper,
99
ensureAndroidSnapshotHelper,
10+
forgetAndroidSnapshotHelperInstall,
1011
parseAndroidSnapshotHelperManifest,
1112
parseAndroidSnapshotHelperOutput,
1213
parseAndroidSnapshotHelperXml,
1314
prepareAndroidSnapshotHelperArtifactFromManifestUrl,
15+
resetAndroidSnapshotHelperInstallCache,
1416
verifyAndroidSnapshotHelperArtifact,
1517
type AndroidAdbExecutor,
1618
type AndroidSnapshotHelperManifest,
@@ -31,6 +33,10 @@ const manifest: AndroidSnapshotHelperManifest = {
3133
installArgs: ['install', '-r', '-t'],
3234
};
3335

36+
beforeEach(() => {
37+
resetAndroidSnapshotHelperInstallCache();
38+
});
39+
3440
test('parseAndroidSnapshotHelperOutput reconstructs XML chunks and metadata', () => {
3541
const xml = '<?xml version="1.0"?><hierarchy><node text="first&#10;second" /></hierarchy>';
3642
const output = helperOutput({
@@ -212,6 +218,132 @@ test('ensureAndroidSnapshotHelper installs when missing and skips current versio
212218
assert.equal(skipped.reason, 'current');
213219
});
214220

221+
test('ensureAndroidSnapshotHelper caches successful install checks per device and helper version', async () => {
222+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapshot-helper-install-cache-'));
223+
const apkPath = path.join(tmpDir, 'helper.apk');
224+
await fs.writeFile(apkPath, 'helper-apk');
225+
const localManifest = {
226+
...manifest,
227+
sha256: sha256Text('helper-apk'),
228+
};
229+
const calls: string[][] = [];
230+
const adb: AndroidAdbExecutor = async (args) => {
231+
calls.push(args);
232+
if (args.includes('--show-versioncode')) {
233+
return { exitCode: 1, stdout: '', stderr: 'not found' };
234+
}
235+
return { exitCode: 0, stdout: '', stderr: '' };
236+
};
237+
const artifact = { apkPath, manifest: localManifest };
238+
239+
const installed = await ensureAndroidSnapshotHelper({
240+
adb,
241+
artifact,
242+
deviceKey: 'android:emulator-5554',
243+
});
244+
const cached = await ensureAndroidSnapshotHelper({
245+
adb,
246+
artifact,
247+
deviceKey: 'android:emulator-5554',
248+
});
249+
250+
assert.equal(installed.reason, 'missing');
251+
assert.equal(cached.reason, 'current');
252+
assert.equal(cached.installed, false);
253+
assert.equal(cached.installedVersionCode, localManifest.versionCode);
254+
assert.deepEqual(calls, [
255+
[
256+
'shell',
257+
'cmd',
258+
'package',
259+
'list',
260+
'packages',
261+
'--show-versioncode',
262+
localManifest.packageName,
263+
],
264+
['install', '-r', '-t', apkPath],
265+
]);
266+
267+
await ensureAndroidSnapshotHelper({
268+
adb,
269+
artifact,
270+
deviceKey: 'android:device-2',
271+
});
272+
assert.equal(calls.length, 4);
273+
274+
forgetAndroidSnapshotHelperInstall({
275+
deviceKey: 'android:emulator-5554',
276+
packageName: localManifest.packageName,
277+
versionCode: localManifest.versionCode,
278+
});
279+
await ensureAndroidSnapshotHelper({
280+
adb,
281+
artifact,
282+
deviceKey: 'android:emulator-5554',
283+
});
284+
assert.equal(calls.length, 6);
285+
});
286+
287+
test('ensureAndroidSnapshotHelper always policy bypasses cached install result', async () => {
288+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapshot-helper-install-always-'));
289+
const apkPath = path.join(tmpDir, 'helper.apk');
290+
await fs.writeFile(apkPath, 'helper-apk');
291+
const localManifest = {
292+
...manifest,
293+
sha256: sha256Text('helper-apk'),
294+
};
295+
const calls: string[][] = [];
296+
const adb: AndroidAdbExecutor = async (args) => {
297+
calls.push(args);
298+
if (args.includes('--show-versioncode')) {
299+
return {
300+
exitCode: 0,
301+
stdout: `package:${localManifest.packageName} versionCode:${localManifest.versionCode}`,
302+
stderr: '',
303+
};
304+
}
305+
return { exitCode: 0, stdout: '', stderr: '' };
306+
};
307+
const artifact = { apkPath, manifest: localManifest };
308+
309+
const cached = await ensureAndroidSnapshotHelper({
310+
adb,
311+
artifact,
312+
deviceKey: 'android:emulator-5554',
313+
});
314+
const forced = await ensureAndroidSnapshotHelper({
315+
adb,
316+
artifact,
317+
deviceKey: 'android:emulator-5554',
318+
installPolicy: 'always',
319+
});
320+
321+
assert.equal(cached.reason, 'current');
322+
assert.equal(forced.reason, 'forced');
323+
assert.equal(forced.installed, true);
324+
assert.deepEqual(calls, [
325+
[
326+
'shell',
327+
'cmd',
328+
'package',
329+
'list',
330+
'packages',
331+
'--show-versioncode',
332+
localManifest.packageName,
333+
],
334+
[
335+
'shell',
336+
'cmd',
337+
'package',
338+
'list',
339+
'packages',
340+
'--show-versioncode',
341+
localManifest.packageName,
342+
],
343+
['install', '-r', '-t', apkPath],
344+
]);
345+
});
346+
215347
test('verifyAndroidSnapshotHelperArtifact rejects checksum mismatch', async () => {
216348
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapshot-helper-sha-'));
217349
const apkPath = path.join(tmpDir, 'helper.apk');

src/platforms/android/__tests__/snapshot.test.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ import type { DeviceInfo } from '../../../utils/device.ts';
2020
import { AppError } from '../../../utils/errors.ts';
2121
import { runCmd } from '../../../utils/exec.ts';
2222
import { sleep } from '../adb.ts';
23-
import type { AndroidAdbExecutor, AndroidSnapshotHelperManifest } from '../snapshot-helper.ts';
23+
import {
24+
resetAndroidSnapshotHelperInstallCache,
25+
type AndroidAdbExecutor,
26+
type AndroidSnapshotHelperManifest,
27+
} from '../snapshot-helper.ts';
2428

2529
const VALID_PNG = Buffer.from(
2630
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+b9xkAAAAASUVORK5CYII=',
@@ -53,6 +57,7 @@ const helperManifest: AndroidSnapshotHelperManifest = {
5357
};
5458

5559
beforeEach(() => {
60+
resetAndroidSnapshotHelperInstallCache();
5661
mockRunCmd.mockReset();
5762
mockSleep.mockReset();
5863
mockSleep.mockResolvedValue(undefined);
@@ -294,6 +299,56 @@ test('snapshotAndroid falls back to stock uiautomator when helper fails', async
294299
);
295300
});
296301

302+
test('snapshotAndroid re-probes helper install after helper capture failure', async () => {
303+
let versionProbeCount = 0;
304+
let instrumentAttempts = 0;
305+
const helperAdb: AndroidAdbExecutor = async (args) => {
306+
if (args.includes('--show-versioncode')) {
307+
versionProbeCount += 1;
308+
return {
309+
exitCode: 0,
310+
stdout: 'package:com.callstack.agentdevice.snapshothelper versionCode:13003',
311+
stderr: '',
312+
};
313+
}
314+
if (args.includes('instrument')) {
315+
instrumentAttempts += 1;
316+
if (instrumentAttempts === 1) {
317+
return { exitCode: 1, stdout: '', stderr: 'instrumentation failed' };
318+
}
319+
return {
320+
exitCode: 0,
321+
stdout: helperOutput('<hierarchy><node text="helper" bounds="[0,0][10,10]" /></hierarchy>'),
322+
stderr: '',
323+
};
324+
}
325+
throw new Error(`unexpected helper adb args: ${args.join(' ')}`);
326+
};
327+
const stockXml =
328+
'<?xml version="1.0" encoding="UTF-8"?><hierarchy><node text="stock" bounds="[0,0][10,10]" /></hierarchy>';
329+
mockRunCmd.mockImplementation(async (_cmd, args) => {
330+
if (args.includes('exec-out')) {
331+
return { exitCode: 0, stdout: stockXml, stderr: '' };
332+
}
333+
return { exitCode: 0, stdout: '', stderr: '' };
334+
});
335+
const helperOptions = {
336+
helperAdb,
337+
helperArtifact: {
338+
apkPath: '/tmp/helper.apk',
339+
manifest: helperManifest,
340+
},
341+
};
342+
343+
const fallback = await snapshotAndroid(device, helperOptions);
344+
const helper = await snapshotAndroid(device, helperOptions);
345+
346+
assert.equal(fallback.androidSnapshot.backend, 'uiautomator-dump');
347+
assert.equal(helper.androidSnapshot.backend, 'android-helper');
348+
assert.equal(helper.nodes[0]?.label, 'helper');
349+
assert.equal(versionProbeCount, 2);
350+
});
351+
297352
test('dumpUiHierarchy reads fallback XML when dump exits non-zero', async () => {
298353
const xml =
299354
'<?xml version="1.0" encoding="UTF-8"?><hierarchy><node text="fallback"/></hierarchy>';

src/platforms/android/snapshot-helper-install.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,49 @@ import type {
1010
AndroidSnapshotHelperInstallResult,
1111
} from './snapshot-helper-types.ts';
1212

13+
const installedSnapshotHelpers = new Map<string, number>();
14+
15+
export function forgetAndroidSnapshotHelperInstall(options: {
16+
deviceKey: string;
17+
packageName: string;
18+
versionCode: number;
19+
}): void {
20+
forgetInstalledSnapshotHelper(
21+
getInstallCacheKey(options.deviceKey, options.packageName, options.versionCode),
22+
);
23+
}
24+
25+
export function resetAndroidSnapshotHelperInstallCache(): void {
26+
installedSnapshotHelpers.clear();
27+
}
28+
29+
function getInstallCacheKey(
30+
deviceKey: string | undefined,
31+
packageName: string,
32+
versionCode: number,
33+
): string | undefined {
34+
return deviceKey ? `${deviceKey}\0${packageName}\0${versionCode}` : undefined;
35+
}
36+
37+
function rememberInstalledSnapshotHelper(
38+
cacheKey: string | undefined,
39+
installedVersionCode: number,
40+
): void {
41+
if (cacheKey) {
42+
installedSnapshotHelpers.set(cacheKey, installedVersionCode);
43+
}
44+
}
45+
46+
function forgetInstalledSnapshotHelper(cacheKey: string | undefined): void {
47+
if (cacheKey) {
48+
installedSnapshotHelpers.delete(cacheKey);
49+
}
50+
}
51+
1352
export async function ensureAndroidSnapshotHelper(options: {
1453
adb: AndroidAdbExecutor;
1554
artifact: AndroidSnapshotHelperArtifact;
55+
deviceKey?: string;
1656
installPolicy?: AndroidSnapshotHelperInstallPolicy;
1757
timeoutMs?: number;
1858
}): Promise<AndroidSnapshotHelperInstallResult> {
@@ -28,10 +68,27 @@ export async function ensureAndroidSnapshotHelper(options: {
2868
reason: 'skipped',
2969
};
3070
}
71+
const installCacheKey = getInstallCacheKey(options.deviceKey, packageName, versionCode);
72+
const cachedVersionCode = installCacheKey
73+
? installedSnapshotHelpers.get(installCacheKey)
74+
: undefined;
75+
if (installCacheKey && installPolicy !== 'always' && cachedVersionCode !== undefined) {
76+
return {
77+
packageName,
78+
versionCode,
79+
installedVersionCode: cachedVersionCode,
80+
installed: false,
81+
reason: 'current',
82+
};
83+
}
3184
const installedVersionCode = await readInstalledVersionCode(adb, packageName, options.timeoutMs);
3285
const reason = getInstallReason(installPolicy, installedVersionCode, versionCode);
3386

3487
if (reason === 'current') {
88+
if (installedVersionCode === undefined) {
89+
throw new Error('Expected installed versionCode for current Android snapshot helper');
90+
}
91+
rememberInstalledSnapshotHelper(installCacheKey, installedVersionCode);
3592
return {
3693
packageName,
3794
versionCode,
@@ -51,6 +108,7 @@ export async function ensureAndroidSnapshotHelper(options: {
51108
timeoutMs: options.timeoutMs,
52109
});
53110
if (result.exitCode !== 0) {
111+
forgetInstalledSnapshotHelper(installCacheKey);
54112
throw new AppError('COMMAND_FAILED', 'Failed to install Android snapshot helper', {
55113
packageName,
56114
versionCode,
@@ -60,6 +118,7 @@ export async function ensureAndroidSnapshotHelper(options: {
60118
});
61119
}
62120

121+
rememberInstalledSnapshotHelper(installCacheKey, versionCode);
63122
return {
64123
packageName,
65124
versionCode,

src/platforms/android/snapshot-helper.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ export {
88
parseAndroidSnapshotHelperOutput,
99
parseAndroidSnapshotHelperXml,
1010
} from './snapshot-helper-capture.ts';
11-
export { ensureAndroidSnapshotHelper } from './snapshot-helper-install.ts';
11+
export {
12+
ensureAndroidSnapshotHelper,
13+
forgetAndroidSnapshotHelperInstall,
14+
resetAndroidSnapshotHelperInstallCache,
15+
} from './snapshot-helper-install.ts';
1216
export {
1317
ANDROID_SNAPSHOT_HELPER_NAME,
1418
ANDROID_SNAPSHOT_HELPER_OUTPUT_FORMAT,

0 commit comments

Comments
 (0)