Skip to content

Commit b4e4fcc

Browse files
committed
feat: support agent-cdp remote bridge sessions
1 parent be1e1c9 commit b4e4fcc

10 files changed

Lines changed: 449 additions & 19 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import fs from 'node:fs';
2+
import os from 'node:os';
3+
import path from 'node:path';
4+
import { afterEach, test, vi } from 'vitest';
5+
import assert from 'node:assert/strict';
6+
7+
vi.mock('../cli/commands/agent-cdp.ts', async (importOriginal) => {
8+
const actual = await importOriginal<typeof import('../cli/commands/agent-cdp.ts')>();
9+
return {
10+
...actual,
11+
runAgentCdpCommand: vi.fn(async () => 0),
12+
};
13+
});
14+
15+
import { runCli } from '../cli.ts';
16+
import { runAgentCdpCommand } from '../cli/commands/agent-cdp.ts';
17+
import { installIsolatedCliTestEnv } from './cli-test-env.ts';
18+
import { hashRemoteConfigFile, writeRemoteConnectionState } from '../remote-connection-state.ts';
19+
import type { DaemonResponse } from '../daemon-client.ts';
20+
21+
afterEach(() => {
22+
vi.clearAllMocks();
23+
});
24+
25+
test('cdp receives active remote connection session and runtime after defaults are merged', async () => {
26+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-agent-cdp-session-'));
27+
const stateDir = path.join(tempRoot, 'state');
28+
const remoteConfigPath = path.join(tempRoot, 'remote.json');
29+
fs.writeFileSync(
30+
remoteConfigPath,
31+
JSON.stringify({
32+
daemonBaseUrl: 'https://daemon.example.test',
33+
platform: 'android',
34+
metroProxyBaseUrl: 'https://bridge.example.test',
35+
metroBearerToken: 'token',
36+
}),
37+
);
38+
const runtime = {
39+
platform: 'android' as const,
40+
bundleUrl: 'https://bridge.example.test/api/metro/runtimes/runtime-1/index.bundle',
41+
};
42+
writeRemoteConnectionState({
43+
stateDir,
44+
state: {
45+
version: 1,
46+
session: 'adc-android',
47+
remoteConfigPath,
48+
remoteConfigHash: hashRemoteConfigFile(remoteConfigPath),
49+
daemon: { baseUrl: 'https://daemon.example.test', transport: 'http' },
50+
tenant: 'tenant-1',
51+
runId: 'run-1',
52+
leaseId: 'lease-1',
53+
leaseBackend: 'android-instance',
54+
platform: 'android',
55+
runtime,
56+
connectedAt: new Date().toISOString(),
57+
updatedAt: new Date().toISOString(),
58+
},
59+
});
60+
61+
const originalExit = process.exit;
62+
let exitCode: number | undefined;
63+
const restoreEnv = installIsolatedCliTestEnv();
64+
(process as any).exit = ((code?: number) => {
65+
exitCode = code ?? 0;
66+
}) as typeof process.exit;
67+
68+
const sendToDaemon = async (req: { command: string }): Promise<DaemonResponse> => {
69+
if (req.command === 'lease_heartbeat') {
70+
return {
71+
ok: true,
72+
data: {
73+
lease: {
74+
leaseId: 'lease-1',
75+
tenantId: 'tenant-1',
76+
runId: 'run-1',
77+
backend: 'android-instance',
78+
},
79+
},
80+
};
81+
}
82+
return { ok: true, data: {} };
83+
};
84+
85+
try {
86+
await runCli(['--state-dir', stateDir, 'cdp', 'target', 'list'], { sendToDaemon });
87+
} finally {
88+
restoreEnv();
89+
process.exit = originalExit;
90+
fs.rmSync(tempRoot, { recursive: true, force: true });
91+
}
92+
93+
assert.equal(exitCode, 0);
94+
assert.equal(vi.mocked(runAgentCdpCommand).mock.calls.length, 1);
95+
assert.deepEqual(vi.mocked(runAgentCdpCommand).mock.calls[0]?.[0], ['target', 'list']);
96+
assert.equal(vi.mocked(runAgentCdpCommand).mock.calls[0]?.[1]?.flags?.session, 'adc-android');
97+
assert.deepEqual(vi.mocked(runAgentCdpCommand).mock.calls[0]?.[1]?.runtime, runtime);
98+
});

src/__tests__/cli-agent-cdp.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { runCmdStreaming } from '../utils/exec.ts';
1010
import {
1111
AGENT_CDP_PACKAGE,
1212
buildAgentCdpNpmExecArgs,
13+
buildAgentCdpPassthroughArgs,
1314
runAgentCdpCommand,
1415
} from '../cli/commands/agent-cdp.ts';
1516

@@ -90,3 +91,96 @@ test('cdp wrapper streams through npm exec and returns downstream exit code', as
9091
assert.equal(vi.mocked(runCmdStreaming).mock.calls[0]?.[2]?.env, env);
9192
assert.equal(vi.mocked(runCmdStreaming).mock.calls[0]?.[2]?.allowFailure, true);
9293
});
94+
95+
test('cdp injects remote Metro public url for target discovery', async () => {
96+
const args = buildAgentCdpPassthroughArgs(['target', 'list'], {
97+
flags: {
98+
leaseBackend: 'android-instance',
99+
metroProxyBaseUrl: 'https://bridge.example.test',
100+
metroPublicBaseUrl: 'http://127.0.0.1:8081/',
101+
},
102+
runtime: {
103+
platform: 'android',
104+
bundleUrl:
105+
'https://bridge.example.test/api/metro/runtimes/runtime-1/index.bundle?platform=android&dev=true',
106+
},
107+
});
108+
109+
assert.deepEqual(args, ['target', 'list', '--url', 'http://127.0.0.1:8081']);
110+
});
111+
112+
test('cdp preserves explicit target url for remote sessions', () => {
113+
const args = buildAgentCdpPassthroughArgs(
114+
['target', 'select', 'react-native:a:b', '--url', 'https://custom.example.test'],
115+
{
116+
flags: {
117+
leaseBackend: 'ios-instance',
118+
metroProxyBaseUrl: 'https://bridge.example.test',
119+
},
120+
runtime: {
121+
platform: 'ios',
122+
bundleUrl: 'https://bridge.example.test/api/metro/runtimes/runtime-1/index.bundle',
123+
},
124+
},
125+
);
126+
127+
assert.deepEqual(args, [
128+
'target',
129+
'select',
130+
'react-native:a:b',
131+
'--url',
132+
'https://custom.example.test',
133+
]);
134+
});
135+
136+
test('cdp rejects remote bridge target discovery without Metro public url', () => {
137+
assert.throws(
138+
() =>
139+
buildAgentCdpPassthroughArgs(['target', 'list'], {
140+
flags: {
141+
leaseBackend: 'android-instance',
142+
metroProxyBaseUrl: 'https://bridge.example.test',
143+
},
144+
runtime: {
145+
platform: 'android',
146+
bundleUrl:
147+
'https://bridge.example.test/api/metro/runtimes/runtime-1/index.bundle?platform=android&dev=true',
148+
},
149+
}),
150+
/cdp remote bridge target discovery requires a Metro public base URL/,
151+
);
152+
});
153+
154+
test('cdp passes injected remote target url to npm exec', async () => {
155+
vi.mocked(runCmdStreaming).mockResolvedValueOnce({
156+
exitCode: 0,
157+
stdout: '',
158+
stderr: '',
159+
});
160+
161+
const exitCode = await runAgentCdpCommand(['target', 'list'], {
162+
flags: {
163+
leaseBackend: 'ios-instance',
164+
metroProxyBaseUrl: 'https://bridge.example.test',
165+
metroPublicBaseUrl: 'http://127.0.0.1:8081',
166+
},
167+
runtime: {
168+
platform: 'ios',
169+
bundleUrl: 'https://bridge.example.test/api/metro/runtimes/runtime-2/index.bundle',
170+
},
171+
});
172+
173+
assert.equal(exitCode, 0);
174+
assert.deepEqual(vi.mocked(runCmdStreaming).mock.calls[0]?.[1], [
175+
'exec',
176+
'--yes',
177+
'--package',
178+
'agent-cdp@1.6.0',
179+
'--',
180+
'agent-cdp',
181+
'target',
182+
'list',
183+
'--url',
184+
'http://127.0.0.1:8081',
185+
]);
186+
});

src/__tests__/remote-connection.test.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,161 @@ test('deferred materialization re-prepares runtime when explicit Metro overrides
631631
fs.rmSync(tempRoot, { recursive: true, force: true });
632632
});
633633

634+
test('cdp remote materialization prepares Metro runtime for bridge target discovery', async () => {
635+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-agent-cdp-runtime-'));
636+
const stateDir = path.join(tempRoot, '.state');
637+
const remoteConfigPath = path.join(tempRoot, 'remote.json');
638+
fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' }));
639+
let prepareRequest: Parameters<AgentDeviceClient['metro']['prepare']>[0] | undefined;
640+
641+
const materialized = await materializeRemoteConnectionForCommand({
642+
command: 'cdp',
643+
positionals: ['target', 'list'],
644+
flags: {
645+
json: true,
646+
help: false,
647+
version: false,
648+
stateDir,
649+
remoteConfig: remoteConfigPath,
650+
daemonBaseUrl: 'https://daemon.example',
651+
tenant: 'acme',
652+
runId: 'run-123',
653+
session: 'adc-android',
654+
platform: 'android',
655+
leaseBackend: 'android-instance',
656+
metroProjectRoot: '/tmp/project',
657+
metroProxyBaseUrl: 'https://proxy.example.test',
658+
metroPublicBaseUrl: 'https://sandbox.example.test',
659+
},
660+
client: createTestClient({
661+
prepare: async (options) => {
662+
prepareRequest = options;
663+
return {
664+
projectRoot: '/tmp/project',
665+
kind: 'react-native',
666+
dependenciesInstalled: false,
667+
packageManager: null,
668+
started: false,
669+
reused: true,
670+
pid: 0,
671+
logPath: '/tmp/project/.agent-device/metro.log',
672+
statusUrl: 'http://127.0.0.1:8081/status',
673+
runtimeFilePath: null,
674+
iosRuntime: { platform: 'ios' },
675+
androidRuntime: {
676+
platform: 'android',
677+
bundleUrl:
678+
'https://proxy.example.test/api/metro/runtimes/runtime-1/index.bundle?platform=android',
679+
},
680+
bridge: null,
681+
};
682+
},
683+
}),
684+
});
685+
686+
assert.equal(prepareRequest?.proxyBaseUrl, 'https://proxy.example.test');
687+
assert.deepEqual(prepareRequest?.bridgeScope, {
688+
tenantId: 'acme',
689+
runId: 'run-123',
690+
leaseId: 'lease-1',
691+
});
692+
assert.deepEqual(materialized.runtime, {
693+
platform: 'android',
694+
bundleUrl:
695+
'https://proxy.example.test/api/metro/runtimes/runtime-1/index.bundle?platform=android',
696+
});
697+
assert.deepEqual(readRemoteConnectionState({ stateDir, session: 'adc-android' })?.metro, {
698+
projectRoot: '/tmp/project',
699+
profileKey: remoteConfigPath,
700+
consumerKey: 'adc-android',
701+
});
702+
703+
fs.rmSync(tempRoot, { recursive: true, force: true });
704+
});
705+
706+
test('cdp remote materialization skips Metro runtime for non-target commands', async () => {
707+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-agent-cdp-memory-'));
708+
const stateDir = path.join(tempRoot, '.state');
709+
const remoteConfigPath = path.join(tempRoot, 'remote.json');
710+
fs.writeFileSync(path.join(tempRoot, 'remote.json'), JSON.stringify({}));
711+
let prepared = false;
712+
713+
try {
714+
const materialized = await materializeRemoteConnectionForCommand({
715+
command: 'cdp',
716+
positionals: ['memory', 'usage', 'sample'],
717+
flags: {
718+
json: true,
719+
help: false,
720+
version: false,
721+
stateDir,
722+
remoteConfig: remoteConfigPath,
723+
daemonBaseUrl: 'https://daemon.example',
724+
tenant: 'acme',
725+
runId: 'run-123',
726+
session: 'adc-android',
727+
platform: 'android',
728+
leaseBackend: 'android-instance',
729+
metroProjectRoot: '/tmp/project',
730+
metroProxyBaseUrl: 'https://proxy.example.test',
731+
metroPublicBaseUrl: 'https://sandbox.example.test',
732+
},
733+
client: createTestClient({
734+
prepare: async () => {
735+
prepared = true;
736+
throw new Error('prepare should not be called');
737+
},
738+
}),
739+
});
740+
741+
assert.equal(prepared, false);
742+
assert.equal(materialized.runtime, undefined);
743+
} finally {
744+
fs.rmSync(tempRoot, { recursive: true, force: true });
745+
}
746+
});
747+
748+
test('cdp remote materialization skips Metro runtime without public CDP url', async () => {
749+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-agent-cdp-no-public-'));
750+
const stateDir = path.join(tempRoot, '.state');
751+
const remoteConfigPath = path.join(tempRoot, 'remote.json');
752+
fs.writeFileSync(remoteConfigPath, JSON.stringify({}));
753+
let prepared = false;
754+
755+
try {
756+
const materialized = await materializeRemoteConnectionForCommand({
757+
command: 'cdp',
758+
positionals: ['target', 'list'],
759+
flags: {
760+
json: true,
761+
help: false,
762+
version: false,
763+
stateDir,
764+
remoteConfig: remoteConfigPath,
765+
daemonBaseUrl: 'https://daemon.example',
766+
tenant: 'acme',
767+
runId: 'run-123',
768+
session: 'adc-android',
769+
platform: 'android',
770+
leaseBackend: 'android-instance',
771+
metroProjectRoot: '/tmp/project',
772+
metroProxyBaseUrl: 'https://proxy.example.test',
773+
},
774+
client: createTestClient({
775+
prepare: async () => {
776+
prepared = true;
777+
throw new Error('prepare should not be called');
778+
},
779+
}),
780+
});
781+
782+
assert.equal(prepared, false);
783+
assert.equal(materialized.runtime, undefined);
784+
} finally {
785+
fs.rmSync(tempRoot, { recursive: true, force: true });
786+
}
787+
});
788+
634789
test('deferred materialization heartbeats an existing lease before dispatch', async () => {
635790
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-heartbeat-'));
636791
const stateDir = path.join(tempRoot, '.state');

0 commit comments

Comments
 (0)