Skip to content

Commit 6df2d93

Browse files
committed
refactor(runtime): construct the Android runtime module with its adb host binding
The Android runtime now calls adb from inside its package for listApps and appState, which needs the process-wide adb host port bound. That dependency was hidden in a registry wrapper doing a side-effect import, with a paragraph explaining why it and loadAndroidMechanics did not subsume each other and an import-order test pinning the ordering. The package now declares the dependency: createAndroidRuntimeModule({ bindAdbHost }) awaits the binding before the runtime loads, and the composition root supplies the one binding implementation (evaluating its adb host module). The wrapper, the paragraph and the import-order test are gone; the routed listApps test stays and a routed appState test joins it.
1 parent 558560a commit 6df2d93

4 files changed

Lines changed: 86 additions & 63 deletions

File tree

packages/platform-android/src/index.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,27 @@ export async function readAndroidAppStateWithExecutor(
4848
return await read(run, signal);
4949
}
5050

51-
export const runtimeModule = Object.freeze({
52-
...metadata,
53-
loadRuntime: async (host) => {
54-
const { createAndroidPlatformRuntime } = await import('./runtime.ts');
55-
return createAndroidPlatformRuntime(host);
56-
},
57-
} satisfies PlatformRuntimeModule);
51+
/** What the composition root supplies before this package's runtime can reach a device. */
52+
export type AndroidRuntimeModuleDependencies = Readonly<{
53+
/**
54+
* Binds the process-wide adb host port (`bindAndroidAdbHost`) the runtime's mechanics run
55+
* through. Awaited before the runtime loads, so no caller has to import anything first.
56+
*/
57+
bindAdbHost(): Promise<void>;
58+
}>;
59+
60+
export function createAndroidRuntimeModule(
61+
dependencies: AndroidRuntimeModuleDependencies,
62+
): PlatformRuntimeModule {
63+
return Object.freeze({
64+
...metadata,
65+
loadRuntime: async (host) => {
66+
await dependencies.bindAdbHost();
67+
const { createAndroidPlatformRuntime } = await import('./runtime.ts');
68+
return createAndroidPlatformRuntime(host);
69+
},
70+
} satisfies PlatformRuntimeModule);
71+
}
5872

5973
export function createAndroidInventoryModule(
6074
config: AndroidInventoryConfig,

packages/platform-android/src/runtime-facade.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,36 @@ vi.mock('./logs/runtime.ts', async (loadOriginal) => {
77
return await loadOriginal();
88
});
99

10-
import { runtimeModule } from './index.ts';
10+
import { createAndroidRuntimeModule } from './index.ts';
1111

1212
test('defers Android app-log mechanics until runtime load', async () => {
13+
const runtimeModule = createAndroidRuntimeModule({ bindAdbHost: async () => {} });
1314
expect(mechanics.evaluations).toBe(0);
1415
expect(runtimeModule.family).toBe('android');
1516
await runtimeModule.loadRuntime({} as never);
1617
expect(mechanics.evaluations).toBe(1);
1718
});
19+
20+
test('binds the adb host it was constructed with before the runtime loads', async () => {
21+
const order: string[] = [];
22+
const bindAdbHost = vi.fn(async () => {
23+
order.push('bind-adb-host');
24+
});
25+
const runtimeModule = createAndroidRuntimeModule({ bindAdbHost });
26+
27+
expect(bindAdbHost).not.toHaveBeenCalled();
28+
await runtimeModule.loadRuntime({} as never);
29+
order.push('runtime-loaded');
30+
31+
expect(order).toEqual(['bind-adb-host', 'runtime-loaded']);
32+
});
33+
34+
test('a binding that fails keeps the runtime unloaded', async () => {
35+
const runtimeModule = createAndroidRuntimeModule({
36+
bindAdbHost: async () => {
37+
throw new Error('adb host unavailable');
38+
},
39+
});
40+
41+
await expect(runtimeModule.loadRuntime({} as never)).rejects.toThrow('adb host unavailable');
42+
});
Lines changed: 34 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations';
21
import type { DeviceInfo } from '@agent-device/kernel/device';
3-
import { afterEach, expect, test, vi } from 'vitest';
2+
import { expect, test, vi } from 'vitest';
43

54
const adb = vi.hoisted(() => ({ calls: [] as string[][] }));
65

@@ -11,9 +10,17 @@ vi.mock('@agent-device/host-kit/command', async (importOriginal) => {
1110
whichCmd: async (executable: string) => `/usr/bin/${executable}`,
1211
runCmd: async (cmd: string, args: string[]) => {
1312
adb.calls.push([cmd, ...args]);
14-
return args.includes('query-activities')
15-
? { stdout: 'com.example.app/.MainActivity\n', stderr: '', exitCode: 0 }
16-
: { stdout: '', stderr: '', exitCode: 0 };
13+
if (args.includes('query-activities')) {
14+
return { stdout: 'com.example.app/.MainActivity\n', stderr: '', exitCode: 0 };
15+
}
16+
if (args.includes('dumpsys')) {
17+
return {
18+
stdout: 'mCurrentFocus=Window{1 u0 com.example.app/.MainActivity}\n',
19+
stderr: '',
20+
exitCode: 0,
21+
};
22+
}
23+
return { stdout: '', stderr: '', exitCode: 0 };
1724
},
1825
};
1926
});
@@ -28,6 +35,12 @@ const sessionArtifacts = {
2835
}),
2936
};
3037

38+
const scope = {
39+
signal: new AbortController().signal,
40+
diagnostics: { emit: () => {} },
41+
progress: { report: () => {} },
42+
};
43+
3144
const device: DeviceInfo = {
3245
platform: 'android',
3346
id: 'emulator-5554',
@@ -39,15 +52,7 @@ const device: DeviceInfo = {
3952

4053
test('the composed gateway lists Android apps from inside the platform package', async () => {
4154
const gateway = createPlatformRuntimeGateway(sessionArtifacts);
42-
const binding = await gateway.bind({
43-
device,
44-
intent: { kind: 'ordinary' },
45-
scope: {
46-
signal: new AbortController().signal,
47-
diagnostics: { emit: () => {} },
48-
progress: { report: () => {} },
49-
},
50-
});
55+
const binding = await gateway.bind({ device, intent: { kind: 'ordinary' }, scope });
5156

5257
await expect(binding.operations.listApps?.({ device, filter: 'all' })).resolves.toEqual([
5358
{ id: 'com.example.app', name: 'Example' },
@@ -70,37 +75,23 @@ test('the composed gateway lists Android apps from inside the platform package',
7075
await gateway.shutdown();
7176
});
7277

73-
afterEach(() => {
74-
vi.doUnmock('./platform-runtime-android-adb-host.ts');
75-
vi.doUnmock('@agent-device/platform-android');
76-
vi.resetModules();
77-
});
78+
test('the composed gateway reads Android app state from inside the platform package', async () => {
79+
const gateway = createPlatformRuntimeGateway(sessionArtifacts);
80+
const binding = await gateway.bind({ device, intent: { kind: 'ordinary' }, scope });
7881

79-
test('the root binds the adb host before it loads the Android runtime module', async () => {
80-
const order: string[] = [];
81-
vi.resetModules();
82-
vi.doMock('./platform-runtime-android-adb-host.ts', () => {
83-
order.push('adb-host');
84-
return {};
85-
});
86-
vi.doMock('@agent-device/platform-android', async (importOriginal) => {
87-
const original = await importOriginal<typeof import('@agent-device/platform-android')>();
88-
return {
89-
...original,
90-
runtimeModule: Object.freeze({
91-
...original.runtimeModule,
92-
loadRuntime: async (host: PlatformRuntimeHost) => {
93-
order.push('android-runtime');
94-
return await original.runtimeModule.loadRuntime(host);
95-
},
96-
}),
97-
};
82+
await expect(binding.operations.appState?.()).resolves.toEqual({
83+
package: 'com.example.app',
84+
activity: '.MainActivity',
9885
});
86+
expect(adb.calls).toContainEqual([
87+
'adb',
88+
'-s',
89+
device.id,
90+
'shell',
91+
'dumpsys',
92+
'window',
93+
'windows',
94+
]);
9995

100-
const { createPlatformRuntimeGateway: create } = await import('./platform-runtime.ts');
101-
const gateway = create(sessionArtifacts);
102-
await gateway.inspectFacts(device).catch(() => {});
103-
104-
expect(order).toEqual(['adb-host', 'android-runtime']);
10596
await gateway.shutdown();
10697
});

src/platform-runtime.ts

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ import {
2828
import {
2929
createAndroidObservationAdapter as createPackageAndroidObservationAdapter,
3030
createAndroidInventoryModule,
31+
createAndroidRuntimeModule,
3132
readAndroidAppStateWithExecutor,
3233
loadShutdownRuntime as loadAndroidShutdownRuntime,
33-
runtimeModule as androidRuntimeModule,
3434
} from '@agent-device/platform-android';
3535
import {
3636
createHarmonyInventoryModule,
@@ -114,17 +114,10 @@ export function createPlatformDeviceInventoryGateways(
114114
});
115115
}
116116

117-
/**
118-
* Android mechanics call adb through a process-wide host that only this root can bind. Root binds it
119-
* from two places: this registry entry, which covers everything the Android runtime reaches from
120-
* inside its own package, and `loadAndroidMechanics`, which covers the root host ports that call
121-
* into mechanics without ever binding a runtime. Neither one subsumes the other.
122-
*/
123-
const androidRuntimeModuleWithBoundAdbHost: PlatformRuntimeModule = Object.freeze({
124-
...androidRuntimeModule,
125-
loadRuntime: async (host) => {
117+
const androidRuntimeModule = createAndroidRuntimeModule({
118+
// Evaluating the root's adb host module binds the process-wide port exactly once.
119+
bindAdbHost: async () => {
126120
await import('./platform-runtime-android-adb-host.ts');
127-
return await androidRuntimeModule.loadRuntime(host);
128121
},
129122
});
130123

@@ -134,7 +127,7 @@ export const platformRuntimeModules: ReadonlyMap<Platform, PlatformRuntimeModule
134127
PlatformRuntimeModule
135128
>([
136129
['apple', appleRuntimeModule],
137-
['android', androidRuntimeModuleWithBoundAdbHost],
130+
['android', androidRuntimeModule],
138131
['harmonyos', harmonyosRuntimeModule],
139132
['vega', vegaRuntimeModule],
140133
['linux', linuxRuntimeModule],

0 commit comments

Comments
 (0)