Skip to content

Commit 7ebb5c8

Browse files
committed
fix: cache Android snapshot helper installs
1 parent d1a7641 commit 7ebb5c8

7 files changed

Lines changed: 321 additions & 5 deletions

File tree

.github/workflows/android.yml

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

32-
- name: Run Android smoke replay
32+
- name: Run Android smoke checks
3333
uses: reactivecircus/android-emulator-runner@b530d96654c385303d652368551fb075bc2f0b6b # v2.35.0
3434
with:
3535
api-level: 36
3636
arch: x86_64
3737
profile: pixel_7
3838
target: google_apis_playstore
3939
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
40+
script: |
41+
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
42+
pnpm package:android-snapshot-helper:npm
43+
sh ./test/scripts/android-snapshot-helper-release-smoke.sh
4144
4245
- name: Upload Android artifacts
4346
if: always()

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

Lines changed: 73 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,72 @@ 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+
215287
test('verifyAndroidSnapshotHelperArtifact rejects checksum mismatch', async () => {
216288
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapshot-helper-sha-'));
217289
const apkPath = path.join(tmpDir, 'helper.apk');

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

Lines changed: 6 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);

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
export async function ensureAndroidSnapshotHelper(options: {
1414
adb: AndroidAdbExecutor;
1515
artifact: AndroidSnapshotHelperArtifact;
16+
deviceKey?: string;
1617
installPolicy?: AndroidSnapshotHelperInstallPolicy;
1718
timeoutMs?: number;
1819
}): Promise<AndroidSnapshotHelperInstallResult> {
@@ -28,10 +29,26 @@ export async function ensureAndroidSnapshotHelper(options: {
2829
reason: 'skipped',
2930
};
3031
}
32+
const installCacheKey = getInstallCacheKey(options.deviceKey, packageName, versionCode);
33+
if (
34+
installCacheKey &&
35+
installPolicy !== 'always' &&
36+
installedSnapshotHelpers.has(installCacheKey)
37+
) {
38+
const installedVersionCode = installedSnapshotHelpers.get(installCacheKey);
39+
return {
40+
packageName,
41+
versionCode,
42+
installedVersionCode,
43+
installed: false,
44+
reason: 'current',
45+
};
46+
}
3147
const installedVersionCode = await readInstalledVersionCode(adb, packageName, options.timeoutMs);
3248
const reason = getInstallReason(installPolicy, installedVersionCode, versionCode);
3349

3450
if (reason === 'current') {
51+
rememberInstalledSnapshotHelper(installCacheKey, installedVersionCode ?? versionCode);
3552
return {
3653
packageName,
3754
versionCode,
@@ -51,6 +68,7 @@ export async function ensureAndroidSnapshotHelper(options: {
5168
timeoutMs: options.timeoutMs,
5269
});
5370
if (result.exitCode !== 0) {
71+
forgetInstalledSnapshotHelper(installCacheKey);
5472
throw new AppError('COMMAND_FAILED', 'Failed to install Android snapshot helper', {
5573
packageName,
5674
versionCode,
@@ -60,6 +78,7 @@ export async function ensureAndroidSnapshotHelper(options: {
6078
});
6179
}
6280

81+
rememberInstalledSnapshotHelper(installCacheKey, versionCode);
6382
return {
6483
packageName,
6584
versionCode,
@@ -69,6 +88,45 @@ export async function ensureAndroidSnapshotHelper(options: {
6988
};
7089
}
7190

91+
const installedSnapshotHelpers = new Map<string, number>();
92+
93+
export function forgetAndroidSnapshotHelperInstall(options: {
94+
deviceKey: string;
95+
packageName: string;
96+
versionCode: number;
97+
}): void {
98+
forgetInstalledSnapshotHelper(
99+
getInstallCacheKey(options.deviceKey, options.packageName, options.versionCode),
100+
);
101+
}
102+
103+
export function resetAndroidSnapshotHelperInstallCache(): void {
104+
installedSnapshotHelpers.clear();
105+
}
106+
107+
function getInstallCacheKey(
108+
deviceKey: string | undefined,
109+
packageName: string,
110+
versionCode: number,
111+
): string | undefined {
112+
return deviceKey ? `${deviceKey}\0${packageName}\0${versionCode}` : undefined;
113+
}
114+
115+
function rememberInstalledSnapshotHelper(
116+
cacheKey: string | undefined,
117+
installedVersionCode: number,
118+
): void {
119+
if (cacheKey) {
120+
installedSnapshotHelpers.set(cacheKey, installedVersionCode);
121+
}
122+
}
123+
124+
function forgetInstalledSnapshotHelper(cacheKey: string | undefined): void {
125+
if (cacheKey) {
126+
installedSnapshotHelpers.delete(cacheKey);
127+
}
128+
}
129+
72130
async function readInstalledVersionCode(
73131
adb: AndroidAdbExecutor,
74132
packageName: string,

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,

src/platforms/android/snapshot.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
captureAndroidSnapshotWithHelper,
2828
ANDROID_SNAPSHOT_HELPER_WAIT_FOR_IDLE_TIMEOUT_MS,
2929
ensureAndroidSnapshotHelper,
30+
forgetAndroidSnapshotHelperInstall,
3031
parseAndroidSnapshotHelperManifest,
3132
type AndroidAdbExecutor,
3233
type AndroidSnapshotHelperArtifact,
@@ -89,11 +90,13 @@ async function captureAndroidUiHierarchy(
8990
): Promise<{ xml: string; metadata: AndroidSnapshotBackendMetadata }> {
9091
const helper = await resolveAndroidSnapshotHelperArtifact(options.helperArtifact);
9192
if (helper.artifact) {
93+
const helperDeviceKey = getAndroidSnapshotHelperDeviceKey(device);
9294
try {
9395
const adb = options.helperAdb ?? createDeviceAdbExecutor(device);
9496
const install = await ensureAndroidSnapshotHelper({
9597
adb,
9698
artifact: helper.artifact,
99+
deviceKey: helperDeviceKey,
97100
installPolicy: options.helperInstallPolicy,
98101
timeoutMs: HELPER_INSTALL_TIMEOUT_MS,
99102
});
@@ -125,6 +128,11 @@ async function captureAndroidUiHierarchy(
125128
},
126129
};
127130
} catch (error) {
131+
forgetAndroidSnapshotHelperInstall({
132+
deviceKey: helperDeviceKey,
133+
packageName: helper.artifact.manifest.packageName,
134+
versionCode: helper.artifact.manifest.versionCode,
135+
});
128136
return await captureStockUiHierarchy(device, normalizeError(error).message);
129137
}
130138
}
@@ -184,6 +192,10 @@ function createDeviceAdbExecutor(device: DeviceInfo): AndroidAdbExecutor {
184192
return async (args, options) => await runCmd('adb', adbArgs(device, args), options);
185193
}
186194

195+
function getAndroidSnapshotHelperDeviceKey(device: DeviceInfo): string {
196+
return `${device.platform}:${device.id}`;
197+
}
198+
187199
async function deriveScrollableContentHintsIfNeeded(
188200
device: DeviceInfo,
189201
nodes: RawSnapshotNode[],

0 commit comments

Comments
 (0)