Skip to content

Commit e116976

Browse files
committed
feat: expose daemon embedding and Android ADB APIs
1 parent 72ba612 commit e116976

12 files changed

Lines changed: 427 additions & 6 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,13 +94,16 @@ Snapshots assign refs like `@e1`, `@e2`, and `@e3` to current-screen elements. R
9494

9595
`agent-device` runs session-aware commands through platform backends: XCTest for iOS and tvOS, ADB plus the Android snapshot helper for Android, a local helper for macOS desktop automation, and AT-SPI for Linux desktop targets. See [Introduction](https://incubator.callstack.com/agent-device/docs/introduction) and [Commands](https://incubator.callstack.com/agent-device/docs/commands) for platform details.
9696

97+
Node consumers can use the typed client and public subpaths for bridge integrations. `agent-device/android-adb` exposes the Android ADB provider contract and reusable helpers for ADB-backed app listing and foreground state. `agent-device/daemon` exposes the supported daemon embedding surface for integrations that intentionally reuse the upstream request router.
98+
9799
## Used By
98100

99101
Used by teams and developers at Callstack, Expensify, Shopify, Kindred, Total Wine & More, LegendList, HerLyfe, App & Flow, and more.
100102

101103
## Documentation
102104

103105
- [Installation](https://incubator.callstack.com/agent-device/docs/installation)
106+
- [Typed Client](https://incubator.callstack.com/agent-device/docs/client-api)
104107
- [Commands](https://incubator.callstack.com/agent-device/docs/commands)
105108
- [Replay & E2E](https://incubator.callstack.com/agent-device/docs/replay-e2e)
106109
- [Known limitations](https://incubator.callstack.com/agent-device/docs/known-limitations)

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@
5757
"import": "./dist/src/android-snapshot-helper.js",
5858
"types": "./dist/src/android-snapshot-helper.d.ts"
5959
},
60+
"./daemon": {
61+
"import": "./dist/src/daemon-embedding.js",
62+
"types": "./dist/src/daemon-embedding.d.ts"
63+
},
6064
"./contracts": {
6165
"import": "./dist/src/contracts.js",
6266
"types": "./dist/src/contracts.d.ts"

rslib.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export default defineConfig({
2626
'android-apps': 'src/android-apps.ts',
2727
'android-adb': 'src/android-adb.ts',
2828
'android-snapshot-helper': 'src/android-snapshot-helper.ts',
29+
'daemon-embedding': 'src/daemon-embedding.ts',
2930
contracts: 'src/contracts.ts',
3031
selectors: 'src/selectors.ts',
3132
finders: 'src/finders.ts',

src/android-adb.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,12 @@ export {
99
type AndroidAdbProvider,
1010
type AndroidAdbSpawner,
1111
} from './platforms/android/adb-executor.ts';
12+
export {
13+
getAndroidAppStateWithAdb,
14+
listAndroidAppsWithAdb,
15+
} from './platforms/android/app-helpers.ts';
16+
export type {
17+
AndroidAppListFilter,
18+
AndroidAppListOptions,
19+
AndroidAppListTarget,
20+
} from './platforms/android/app-helpers.ts';

src/core/__tests__/dispatch-resolve.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ vi.mock('../../platforms/ios/devices.ts', async (importOriginal) => {
1818
import {
1919
resolveIosDevice,
2020
resolveTargetDevice,
21+
withDeviceInventoryProvider,
2122
withResolveTargetDeviceCacheScope,
2223
} from '../dispatch-resolve.ts';
2324
import type { DeviceInfo } from '../../utils/device.ts';
@@ -173,3 +174,28 @@ test('resolveTargetDevice reuses cache across nested request scopes', async () =
173174

174175
assert.equal(mockListAppleDevices.mock.calls.length, 1);
175176
});
177+
178+
test('resolveTargetDevice uses injected device inventory without local discovery', async () => {
179+
const result = await withDeviceInventoryProvider(
180+
async (request) => {
181+
assert.equal(request.platform, 'ios');
182+
assert.equal(request.deviceName, 'Remote iPhone');
183+
return [{ ...bootedSimulator, id: 'remote-ios-1', name: 'Remote iPhone' }];
184+
},
185+
async () => await resolveTargetDevice({ platform: 'ios', device: 'Remote iPhone' }),
186+
);
187+
188+
assert.equal(result.id, 'remote-ios-1');
189+
assert.equal(mockListAppleDevices.mock.calls.length, 0);
190+
});
191+
192+
test('resolveTargetDevice treats empty injected inventory as authoritative', async () => {
193+
const err = await withDeviceInventoryProvider(
194+
async () => [],
195+
async () => await resolveTargetDevice({ platform: 'ios' }),
196+
).catch((error) => error);
197+
198+
assert.ok(err instanceof AppError);
199+
assert.equal(err.code, 'DEVICE_NOT_FOUND');
200+
assert.equal(mockListAppleDevices.mock.calls.length, 0);
201+
});

src/core/dispatch-resolve.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,21 @@ type ResolveDeviceFlags = Pick<
3030
>;
3131

3232
const resolveTargetDeviceCacheScope = new AsyncLocalStorage<Map<string, DeviceInfo>>();
33+
const deviceInventoryProviderScope = new AsyncLocalStorage<DeviceInventoryProvider>();
34+
35+
export type DeviceInventoryRequest = {
36+
platform?: PlatformSelector;
37+
target?: DeviceTarget;
38+
deviceName?: string;
39+
udid?: string;
40+
serial?: string;
41+
iosSimulatorSetPath?: string;
42+
androidSerialAllowlist?: string[];
43+
};
44+
45+
export type DeviceInventoryProvider = (
46+
request: DeviceInventoryRequest,
47+
) => Promise<DeviceInfo[] | null | undefined>;
3348

3449
type AppleDeviceSelector = {
3550
platform?: Exclude<PlatformSelector, 'android'>;
@@ -134,6 +149,20 @@ export async function resolveTargetDevice(flags: ResolveDeviceFlags): Promise<De
134149
);
135150
}
136151

152+
const injectedDevices = await readInjectedDeviceInventory({
153+
...selector,
154+
iosSimulatorSetPath,
155+
androidSerialAllowlist: androidSerialAllowlist
156+
? Array.from(androidSerialAllowlist).sort()
157+
: undefined,
158+
});
159+
if (injectedDevices) {
160+
return cacheResolvedTargetDevice(
161+
cacheKey,
162+
await resolveDevice(injectedDevices, selector, { simulatorSetPath: iosSimulatorSetPath }),
163+
);
164+
}
165+
137166
if (selector.platform === 'linux') {
138167
const devices = await listLinuxDevices();
139168
return cacheResolvedTargetDevice(cacheKey, await resolveDevice(devices, selector));
@@ -181,6 +210,34 @@ export async function withResolveTargetDeviceCacheScope<T>(task: () => Promise<T
181210
return await resolveTargetDeviceCacheScope.run(new Map(), task);
182211
}
183212

213+
export async function withDeviceInventoryProvider<T>(
214+
provider: DeviceInventoryProvider | undefined,
215+
task: () => Promise<T>,
216+
): Promise<T> {
217+
if (!provider) return await task();
218+
return await deviceInventoryProviderScope.run(provider, task);
219+
}
220+
221+
export async function withTargetDeviceResolutionScope<T>(
222+
provider: DeviceInventoryProvider | undefined,
223+
task: () => Promise<T>,
224+
): Promise<T> {
225+
return await withDeviceInventoryProvider(
226+
provider,
227+
async () => await withResolveTargetDeviceCacheScope(task),
228+
);
229+
}
230+
231+
async function readInjectedDeviceInventory(
232+
request: DeviceInventoryRequest,
233+
): Promise<DeviceInfo[] | null> {
234+
const provider = deviceInventoryProviderScope.getStore();
235+
if (!provider) return null;
236+
const devices = await provider(request);
237+
if (devices === undefined || devices === null) return null;
238+
return devices.map((device) => ({ ...device }));
239+
}
240+
184241
function readResolveTargetDeviceCache(cacheKey: string): DeviceInfo | undefined {
185242
const cache = resolveTargetDeviceCacheScope.getStore();
186243
const cached = cache?.get(cacheKey);

src/core/dispatch.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,13 @@ import {
3939
import { readNotificationPayload } from './dispatch-payload.ts';
4040
import { parseDeviceRotation } from './device-rotation.ts';
4141

42-
export { resolveTargetDevice, withResolveTargetDeviceCacheScope } from './dispatch-resolve.ts';
42+
export {
43+
resolveTargetDevice,
44+
withDeviceInventoryProvider,
45+
withResolveTargetDeviceCacheScope,
46+
withTargetDeviceResolutionScope,
47+
} from './dispatch-resolve.ts';
48+
export type { DeviceInventoryProvider, DeviceInventoryRequest } from './dispatch-resolve.ts';
4349
export { shouldUseIosTapSeries, shouldUseIosDragSeries };
4450

4551
export type BatchStep = {

src/daemon-embedding.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
export { createRequestHandler } from './daemon/request-router.ts';
2+
export type { RequestRouterDeps } from './daemon/request-router.ts';
3+
export { withDeviceInventoryProvider } from './core/dispatch.ts';
4+
export type { DeviceInventoryProvider, DeviceInventoryRequest } from './core/dispatch.ts';
5+
export { SessionStore } from './daemon/session-store.ts';
6+
export { LeaseRegistry } from './daemon/lease-registry.ts';
7+
export type {
8+
AdmissionRequest,
9+
AllocateLeaseRequest,
10+
HeartbeatLeaseRequest,
11+
LeaseRegistryOptions,
12+
ReleaseLeaseRequest,
13+
SimulatorLease,
14+
} from './daemon/lease-registry.ts';
15+
export {
16+
cleanupDownloadableArtifact,
17+
cleanupUploadedArtifact,
18+
prepareDownloadableArtifact,
19+
prepareUploadedArtifact,
20+
trackDownloadableArtifact,
21+
trackUploadedArtifact,
22+
} from './daemon/artifact-tracking.ts';
23+
export type {
24+
DaemonArtifact,
25+
DaemonInstallSource,
26+
DaemonRequest,
27+
DaemonResponse,
28+
DaemonResponseData,
29+
SessionRuntimeHints,
30+
SessionState,
31+
} from './daemon/types.ts';
32+
export type { DeviceInfo, Platform, PlatformSelector } from './utils/device.ts';
33+
export type {
34+
AndroidAdbExecutor,
35+
AndroidAdbExecutorOptions,
36+
AndroidAdbExecutorResult,
37+
AndroidAdbProvider,
38+
} from './android-adb.ts';

src/daemon/request-router.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import path from 'node:path';
2-
import type { CommandFlags } from '../core/dispatch.ts';
32
import {
43
withAndroidAdbProvider,
54
type AndroidAdbExecutor,
@@ -8,7 +7,9 @@ import {
87
import {
98
dispatchCommand,
109
resolveTargetDevice,
11-
withResolveTargetDeviceCacheScope,
10+
type CommandFlags,
11+
type DeviceInventoryProvider,
12+
withTargetDeviceResolutionScope,
1213
} from '../core/dispatch.ts';
1314
import { isCommandSupportedOnDevice } from '../core/capabilities.ts';
1415
import { AppError, normalizeError, toAppErrorCode } from '../utils/errors.ts';
@@ -603,6 +604,7 @@ export type RequestRouterDeps = {
603604
sessionStore: SessionStore;
604605
leaseRegistry: LeaseRegistry;
605606
androidAdbProvider?: AndroidAdbProviderResolver;
607+
deviceInventoryProvider?: DeviceInventoryProvider;
606608
trackDownloadableArtifact: (opts: {
607609
artifactPath: string;
608610
tenantId?: string;
@@ -619,6 +621,7 @@ export function createRequestHandler(
619621
sessionStore,
620622
leaseRegistry,
621623
androidAdbProvider,
624+
deviceInventoryProvider,
622625
trackDownloadableArtifact,
623626
} = deps;
624627

@@ -639,7 +642,7 @@ export function createRequestHandler(
639642
}
640643

641644
try {
642-
return await withResolveTargetDeviceCacheScope(async () => {
645+
return await withTargetDeviceResolutionScope(deviceInventoryProvider, async () => {
643646
const scopedReq = scopeRequestSession(req);
644647
emitDiagnostic({
645648
level: 'info',
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import assert from 'node:assert/strict';
2+
import { promises as fs } from 'node:fs';
3+
import os from 'node:os';
4+
import path from 'node:path';
5+
import { test } from 'vitest';
6+
import type { AndroidAdbExecutor } from '../adb-executor.ts';
7+
import { createDeviceAdbExecutor } from '../adb-executor.ts';
8+
import { getAndroidAppStateWithAdb, listAndroidAppsWithAdb } from '../app-helpers.ts';
9+
10+
async function withMockedAdbScript(script: string, run: () => Promise<void>): Promise<void> {
11+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-android-app-helpers-'));
12+
const adbPath = path.join(tmpDir, 'adb');
13+
await fs.writeFile(adbPath, script, 'utf8');
14+
await fs.chmod(adbPath, 0o755);
15+
16+
const previousPath = process.env.PATH;
17+
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;
18+
try {
19+
await run();
20+
} finally {
21+
process.env.PATH = previousPath;
22+
await fs.rm(tmpDir, { recursive: true, force: true });
23+
}
24+
}
25+
26+
test('listAndroidAppsWithAdb uses an injected executor', async () => {
27+
const calls: string[][] = [];
28+
const adb: AndroidAdbExecutor = async (args) => {
29+
calls.push(args);
30+
if (args.includes('query-activities')) {
31+
return {
32+
exitCode: 0,
33+
stdout: 'com.example.alpha/.MainActivity\ncom.example.beta/.MainActivity\n',
34+
stderr: '',
35+
};
36+
}
37+
return {
38+
exitCode: 0,
39+
stdout: 'package:com.example.beta\n',
40+
stderr: '',
41+
};
42+
};
43+
44+
const apps = await listAndroidAppsWithAdb(adb, { filter: 'user-installed', target: 'mobile' });
45+
46+
assert.deepEqual(apps, [{ package: 'com.example.beta', name: 'Beta' }]);
47+
assert.deepEqual(calls, [
48+
[
49+
'shell',
50+
'cmd',
51+
'package',
52+
'query-activities',
53+
'--brief',
54+
'-a',
55+
'android.intent.action.MAIN',
56+
'-c',
57+
'android.intent.category.LAUNCHER',
58+
],
59+
['shell', 'pm', 'list', 'packages', '-3'],
60+
]);
61+
});
62+
63+
test('Android app helpers work with a local ADB provider', async () => {
64+
await withMockedAdbScript(
65+
[
66+
'#!/bin/sh',
67+
'if [ "$1" = "-s" ]; then',
68+
' shift',
69+
' shift',
70+
'fi',
71+
'if [ "$1" = "shell" ] && [ "$3" = "window" ]; then',
72+
' echo "mCurrentFocus=Window{42 u0 com.example.app/.MainActivity}"',
73+
' exit 0',
74+
'fi',
75+
'if [ "$1" = "shell" ] && [ "$3" = "package" ]; then',
76+
' echo "com.example.app/.MainActivity"',
77+
' exit 0',
78+
'fi',
79+
'exit 0',
80+
'',
81+
].join('\n'),
82+
async () => {
83+
const adb = createDeviceAdbExecutor({
84+
platform: 'android',
85+
id: 'emulator-5554',
86+
name: 'Pixel',
87+
kind: 'emulator',
88+
booted: true,
89+
});
90+
91+
const [apps, state] = await Promise.all([
92+
listAndroidAppsWithAdb(adb, { target: 'mobile' }),
93+
getAndroidAppStateWithAdb(adb),
94+
]);
95+
96+
assert.deepEqual(apps, [{ package: 'com.example.app', name: 'Example' }]);
97+
assert.deepEqual(state, { package: 'com.example.app', activity: '.MainActivity' });
98+
},
99+
);
100+
});

0 commit comments

Comments
 (0)