Skip to content

Commit be85970

Browse files
committed
perf: cache app resolution
1 parent d1a7641 commit be85970

8 files changed

Lines changed: 443 additions & 56 deletions

File tree

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

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
getAndroidKeyboardState,
1111
inferAndroidAppName,
1212
installAndroidApp,
13+
installAndroidInstallablePath,
1314
isAmStartError,
1415
listAndroidApps,
1516
openAndroidApp,
@@ -978,6 +979,82 @@ test('resolveAndroidApp does not treat file paths as package names', async () =>
978979
);
979980
});
980981

982+
test('resolveAndroidApp caches display-name package matches but bypasses exact package ids', async () => {
983+
await withMockedAdb(
984+
'agent-device-android-resolve-cache-',
985+
[
986+
'#!/bin/sh',
987+
'printf "%s\\n" "$*" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
988+
'if [ "$1" = "-s" ]; then shift; shift; fi',
989+
'if [ "$1" = "shell" ] && [ "$2" = "pm" ] && [ "$3" = "list" ] && [ "$4" = "packages" ]; then',
990+
' echo "package:com.example.cachemaps"',
991+
' exit 0',
992+
'fi',
993+
'exit 1',
994+
'',
995+
].join('\n'),
996+
async ({ argsLogPath, device }) => {
997+
const first = await resolveAndroidApp(device, 'cachemaps');
998+
const second = await resolveAndroidApp(device, 'cachemaps');
999+
const exact = await resolveAndroidApp(device, 'com.example.cachemaps');
1000+
1001+
assert.deepEqual(first, { type: 'package', value: 'com.example.cachemaps' });
1002+
assert.deepEqual(second, first);
1003+
assert.deepEqual(exact, { type: 'package', value: 'com.example.cachemaps' });
1004+
1005+
const logged = await fs.readFile(argsLogPath, 'utf8');
1006+
assert.equal((logged.match(/pm list packages/g) ?? []).length, 1);
1007+
},
1008+
);
1009+
});
1010+
1011+
test('installAndroidInstallablePath invalidates cached display-name package matches', async () => {
1012+
await withMockedAdb(
1013+
'agent-device-android-install-cache-',
1014+
[
1015+
'#!/bin/sh',
1016+
'printf "%s\\n" "$*" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
1017+
'if [ "$1" = "-s" ]; then shift; shift; fi',
1018+
'if [ "$1" = "shell" ] && [ "$2" = "pm" ] && [ "$3" = "list" ] && [ "$4" = "packages" ]; then',
1019+
' if [ -f "$AGENT_DEVICE_TEST_INSTALL_MARKER" ]; then',
1020+
' echo "package:com.example.installedcachemaps"',
1021+
' else',
1022+
' echo "package:com.example.cachemaps"',
1023+
' fi',
1024+
' exit 0',
1025+
'fi',
1026+
'if [ "$1" = "install" ] && [ "$2" = "-r" ]; then',
1027+
' : > "$AGENT_DEVICE_TEST_INSTALL_MARKER"',
1028+
' exit 0',
1029+
'fi',
1030+
'exit 1',
1031+
'',
1032+
].join('\n'),
1033+
async ({ device }) => {
1034+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-android-cache-apk-'));
1035+
const apkPath = path.join(tmpDir, 'App.apk');
1036+
const previousMarker = process.env.AGENT_DEVICE_TEST_INSTALL_MARKER;
1037+
process.env.AGENT_DEVICE_TEST_INSTALL_MARKER = path.join(tmpDir, 'installed.marker');
1038+
try {
1039+
await fs.writeFile(apkPath, '', 'utf8');
1040+
const before = await resolveAndroidApp(device, 'cachemaps');
1041+
await installAndroidInstallablePath(device, apkPath);
1042+
const after = await resolveAndroidApp(device, 'cachemaps');
1043+
1044+
assert.deepEqual(before, { type: 'package', value: 'com.example.cachemaps' });
1045+
assert.deepEqual(after, { type: 'package', value: 'com.example.installedcachemaps' });
1046+
} finally {
1047+
if (previousMarker === undefined) {
1048+
delete process.env.AGENT_DEVICE_TEST_INSTALL_MARKER;
1049+
} else {
1050+
process.env.AGENT_DEVICE_TEST_INSTALL_MARKER = previousMarker;
1051+
}
1052+
await fs.rm(tmpDir, { recursive: true, force: true });
1053+
}
1054+
},
1055+
);
1056+
});
1057+
9811058
test('openAndroidApp default launch uses -p package flag', async () => {
9821059
await withMockedAdb(
9831060
'agent-device-android-open-default-',

src/platforms/android/app-lifecycle.ts

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import os from 'node:os';
33
import path from 'node:path';
44
import { resolveFileOverridePath, runCmd, whichCmd } from '../../utils/exec.ts';
55
import { AppError } from '../../utils/errors.ts';
6+
import { createTtlCache } from '../../utils/ttl-cache.ts';
67
import type { DeviceInfo } from '../../utils/device.ts';
78
import { isDeepLinkTarget } from '../../core/open-target.ts';
9+
import { APP_RESOLUTION_CACHE_TTL_MS } from '../app-resolution-cache.ts';
810
import { waitForAndroidBoot } from './devices.ts';
911
import { adbArgs } from './adb.ts';
1012
import { classifyAndroidAppTarget } from './open-target.ts';
@@ -34,16 +36,51 @@ const ANDROID_APPS_DISCOVERY_HINT =
3436
const ANDROID_AMBIGUOUS_APP_HINT =
3537
'Run agent-device apps --platform android to see the exact installed package names before retrying open.';
3638

39+
type AndroidAppResolution = { type: 'intent' | 'package'; value: string };
40+
41+
const androidAppResolutionCache = createTtlCache<AndroidAppResolution>();
42+
43+
function buildAndroidAppResolutionCacheKey(device: DeviceInfo, target: string): string {
44+
return ['android', device.id, device.target ?? '', target.trim().toLowerCase()].join('\0');
45+
}
46+
47+
function readCachedAndroidAppResolution(
48+
device: DeviceInfo,
49+
target: string,
50+
): AndroidAppResolution | undefined {
51+
return androidAppResolutionCache.get(buildAndroidAppResolutionCacheKey(device, target));
52+
}
53+
54+
function cacheAndroidAppResolution(
55+
device: DeviceInfo,
56+
target: string,
57+
resolved: AndroidAppResolution,
58+
): AndroidAppResolution {
59+
return androidAppResolutionCache.set(
60+
buildAndroidAppResolutionCacheKey(device, target),
61+
resolved,
62+
APP_RESOLUTION_CACHE_TTL_MS,
63+
);
64+
}
65+
66+
function clearAndroidAppResolutionCache(device: DeviceInfo): void {
67+
const prefix = ['android', device.id, ''].join('\0');
68+
androidAppResolutionCache.deleteWhere((key) => key.startsWith(prefix));
69+
}
70+
3771
export async function resolveAndroidApp(
3872
device: DeviceInfo,
3973
app: string,
40-
): Promise<{ type: 'intent' | 'package'; value: string }> {
74+
): Promise<AndroidAppResolution> {
4175
const trimmed = app.trim();
4276
if (classifyAndroidAppTarget(trimmed) === 'package') return { type: 'package', value: trimmed };
4377

4478
const alias = ALIASES[trimmed.toLowerCase()];
4579
if (alias) return alias;
4680

81+
const cached = readCachedAndroidAppResolution(device, trimmed);
82+
if (cached) return cached;
83+
4784
const result = await runCmd('adb', adbArgs(device, ['shell', 'pm', 'list', 'packages']));
4885
const packages = result.stdout
4986
.split('\n')
@@ -54,7 +91,7 @@ export async function resolveAndroidApp(
5491
pkg.toLowerCase().includes(trimmed.toLowerCase()),
5592
);
5693
if (matches.length === 1) {
57-
return { type: 'package', value: matches[0] };
94+
return cacheAndroidAppResolution(device, trimmed, { type: 'package', value: matches[0] });
5895
}
5996

6097
if (matches.length > 1) {
@@ -560,10 +597,16 @@ export async function installAndroidInstallablePath(
560597
device: DeviceInfo,
561598
installablePath: string,
562599
): Promise<void> {
600+
clearAndroidAppResolutionCache(device);
563601
if (!device.booted) {
564602
await waitForAndroidBoot(device.id);
565603
}
566-
await installAndroidAppFiles(device, installablePath);
604+
try {
605+
await installAndroidAppFiles(device, installablePath);
606+
} finally {
607+
// A concurrent name lookup can finish after the initial clear and repopulate stale data.
608+
clearAndroidAppResolutionCache(device);
609+
}
567610
}
568611

569612
export async function installAndroidInstallablePathAndResolvePackageName(
@@ -617,18 +660,23 @@ export async function reinstallAndroidApp(
617660
app: string,
618661
appPath: string,
619662
): Promise<{ package: string }> {
663+
clearAndroidAppResolutionCache(device);
620664
if (!device.booted) {
621665
await waitForAndroidBoot(device.id);
622666
}
623-
const { package: pkg } = await uninstallAndroidApp(device, app);
624-
const prepared = await prepareAndroidInstallArtifact(
625-
{ kind: 'path', path: appPath },
626-
{ resolveIdentity: false },
627-
);
628667
try {
629-
await installAndroidInstallablePath(device, prepared.installablePath);
668+
const { package: pkg } = await uninstallAndroidApp(device, app);
669+
const prepared = await prepareAndroidInstallArtifact(
670+
{ kind: 'path', path: appPath },
671+
{ resolveIdentity: false },
672+
);
673+
try {
674+
await installAndroidInstallablePath(device, prepared.installablePath);
675+
} finally {
676+
await prepared.cleanup();
677+
}
630678
return { package: pkg };
631679
} finally {
632-
await prepared.cleanup();
680+
clearAndroidAppResolutionCache(device);
633681
}
634682
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const APP_RESOLUTION_CACHE_TTL_MS = 30_000;

src/platforms/ios/__tests__/index.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ const screenshotStatusBarActual = await vi.importActual<
4646
import {
4747
closeIosApp,
4848
installIosApp,
49+
installIosInstallablePath,
4950
listIosApps,
5051
openIosApp,
5152
parseIosDeviceAppsPayload,
@@ -1708,6 +1709,128 @@ test('resolveIosApp resolves app display name on iOS physical devices', async ()
17081709
}
17091710
});
17101711

1712+
test('resolveIosApp caches display-name bundle matches but bypasses exact bundle ids', async () => {
1713+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-resolve-cache-'));
1714+
const xcrunPath = path.join(tmpDir, 'xcrun');
1715+
const argsLogPath = path.join(tmpDir, 'args.log');
1716+
await fs.writeFile(
1717+
xcrunPath,
1718+
[
1719+
'#!/bin/sh',
1720+
'printf "%s\\n" "$*" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
1721+
'if [ "$1" = "simctl" ] && [ "$2" = "listapps" ]; then',
1722+
" cat <<'JSON'",
1723+
'{"com.example.cachemaps":{"CFBundleDisplayName":"Cache Maps"}}',
1724+
'JSON',
1725+
' exit 0',
1726+
'fi',
1727+
'exit 1',
1728+
'',
1729+
].join('\n'),
1730+
'utf8',
1731+
);
1732+
await fs.chmod(xcrunPath, 0o755);
1733+
1734+
const previousPath = process.env.PATH;
1735+
const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE;
1736+
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;
1737+
process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath;
1738+
1739+
const device: DeviceInfo = {
1740+
platform: 'ios',
1741+
id: 'sim-cache-1',
1742+
name: 'iPhone Cache',
1743+
kind: 'simulator',
1744+
booted: true,
1745+
};
1746+
1747+
try {
1748+
const first = await resolveIosApp(device, 'Cache Maps');
1749+
const second = await resolveIosApp(device, 'Cache Maps');
1750+
const exact = await resolveIosApp(device, 'com.example.cachemaps');
1751+
1752+
assert.equal(first, 'com.example.cachemaps');
1753+
assert.equal(second, 'com.example.cachemaps');
1754+
assert.equal(exact, 'com.example.cachemaps');
1755+
1756+
const logged = await fs.readFile(argsLogPath, 'utf8');
1757+
assert.equal((logged.match(/simctl listapps/g) ?? []).length, 1);
1758+
} finally {
1759+
process.env.PATH = previousPath;
1760+
if (previousArgsFile === undefined) {
1761+
delete process.env.AGENT_DEVICE_TEST_ARGS_FILE;
1762+
} else {
1763+
process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile;
1764+
}
1765+
await fs.rm(tmpDir, { recursive: true, force: true });
1766+
}
1767+
});
1768+
1769+
test('installIosInstallablePath invalidates cached display-name bundle matches', async () => {
1770+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-install-cache-'));
1771+
const xcrunPath = path.join(tmpDir, 'xcrun');
1772+
const appPath = path.join(tmpDir, 'Cache.app');
1773+
const markerPath = path.join(tmpDir, 'installed.marker');
1774+
await fs.writeFile(
1775+
xcrunPath,
1776+
[
1777+
'#!/bin/sh',
1778+
'if [ "$1" = "simctl" ] && [ "$2" = "listapps" ]; then',
1779+
' if [ -f "$AGENT_DEVICE_TEST_INSTALL_MARKER" ]; then',
1780+
" cat <<'JSON'",
1781+
'{"com.example.installedcachemaps":{"CFBundleDisplayName":"Cache Maps"}}',
1782+
'JSON',
1783+
' else',
1784+
" cat <<'JSON'",
1785+
'{"com.example.cachemaps":{"CFBundleDisplayName":"Cache Maps"}}',
1786+
'JSON',
1787+
' fi',
1788+
' exit 0',
1789+
'fi',
1790+
'if [ "$1" = "simctl" ] && [ "$2" = "install" ]; then',
1791+
' : > "$AGENT_DEVICE_TEST_INSTALL_MARKER"',
1792+
' exit 0',
1793+
'fi',
1794+
'exit 1',
1795+
'',
1796+
].join('\n'),
1797+
'utf8',
1798+
);
1799+
await fs.chmod(xcrunPath, 0o755);
1800+
await fs.mkdir(appPath);
1801+
1802+
const previousPath = process.env.PATH;
1803+
const previousMarker = process.env.AGENT_DEVICE_TEST_INSTALL_MARKER;
1804+
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;
1805+
process.env.AGENT_DEVICE_TEST_INSTALL_MARKER = markerPath;
1806+
1807+
const device: DeviceInfo = {
1808+
platform: 'ios',
1809+
id: 'sim-cache-install-1',
1810+
name: 'iPhone Cache',
1811+
kind: 'simulator',
1812+
booted: true,
1813+
};
1814+
mockEnsureBootedSimulator.mockResolvedValue(undefined);
1815+
1816+
try {
1817+
const before = await resolveIosApp(device, 'Cache Maps');
1818+
await installIosInstallablePath(device, appPath);
1819+
const after = await resolveIosApp(device, 'Cache Maps');
1820+
1821+
assert.equal(before, 'com.example.cachemaps');
1822+
assert.equal(after, 'com.example.installedcachemaps');
1823+
} finally {
1824+
process.env.PATH = previousPath;
1825+
if (previousMarker === undefined) {
1826+
delete process.env.AGENT_DEVICE_TEST_INSTALL_MARKER;
1827+
} else {
1828+
process.env.AGENT_DEVICE_TEST_INSTALL_MARKER = previousMarker;
1829+
}
1830+
await fs.rm(tmpDir, { recursive: true, force: true });
1831+
}
1832+
});
1833+
17111834
test('listIosApps applies user-installed filter on simulator', async () => {
17121835
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-list-sim-'));
17131836
const xcrunPath = path.join(tmpDir, 'xcrun');

0 commit comments

Comments
 (0)