Skip to content

Commit 9214366

Browse files
committed
feat: support agent-cdp remote bridge sessions
1 parent e4da259 commit 9214366

8 files changed

Lines changed: 367 additions & 10 deletions

File tree

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

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

Lines changed: 109 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

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

src/__tests__/remote-connection.test.ts

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

634+
test('agent-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: 'agent-cdp',
643+
flags: {
644+
json: true,
645+
help: false,
646+
version: false,
647+
stateDir,
648+
remoteConfig: remoteConfigPath,
649+
daemonBaseUrl: 'https://daemon.example',
650+
tenant: 'acme',
651+
runId: 'run-123',
652+
session: 'adc-android',
653+
platform: 'android',
654+
leaseBackend: 'android-instance',
655+
metroProjectRoot: '/tmp/project',
656+
metroProxyBaseUrl: 'https://proxy.example.test',
657+
metroPublicBaseUrl: 'https://sandbox.example.test',
658+
},
659+
client: createTestClient({
660+
prepare: async (options) => {
661+
prepareRequest = options;
662+
return {
663+
projectRoot: '/tmp/project',
664+
kind: 'react-native',
665+
dependenciesInstalled: false,
666+
packageManager: null,
667+
started: false,
668+
reused: true,
669+
pid: 0,
670+
logPath: '/tmp/project/.agent-device/metro.log',
671+
statusUrl: 'http://127.0.0.1:8081/status',
672+
runtimeFilePath: null,
673+
iosRuntime: { platform: 'ios' },
674+
androidRuntime: {
675+
platform: 'android',
676+
bundleUrl:
677+
'https://proxy.example.test/api/metro/runtimes/runtime-1/index.bundle?platform=android',
678+
},
679+
bridge: null,
680+
};
681+
},
682+
}),
683+
});
684+
685+
assert.equal(prepareRequest?.proxyBaseUrl, 'https://proxy.example.test');
686+
assert.deepEqual(prepareRequest?.bridgeScope, {
687+
tenantId: 'acme',
688+
runId: 'run-123',
689+
leaseId: 'lease-1',
690+
});
691+
assert.deepEqual(materialized.runtime, {
692+
platform: 'android',
693+
bundleUrl:
694+
'https://proxy.example.test/api/metro/runtimes/runtime-1/index.bundle?platform=android',
695+
});
696+
assert.deepEqual(readRemoteConnectionState({ stateDir, session: 'adc-android' })?.metro, {
697+
projectRoot: '/tmp/project',
698+
profileKey: remoteConfigPath,
699+
consumerKey: 'adc-android',
700+
});
701+
702+
fs.rmSync(tempRoot, { recursive: true, force: true });
703+
});
704+
634705
test('deferred materialization heartbeats an existing lease before dispatch', async () => {
635706
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-heartbeat-'));
636707
const stateDir = path.join(tempRoot, '.state');

src/cli.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -197,14 +197,6 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
197197
}
198198
let logTailStopper: (() => void) | null = null;
199199
try {
200-
if (command === 'agent-cdp') {
201-
const exitCode = await runAgentCdpCommand(positionals, {
202-
cwd: process.cwd(),
203-
env: process.env,
204-
});
205-
process.exit(exitCode);
206-
return;
207-
}
208200
if (command === 'react-devtools') {
209201
const exitCode = await runReactDevtoolsCommand(positionals, {
210202
flags: effectiveFlags,
@@ -302,6 +294,16 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
302294
'Warning: open is using explicit remote daemon or tenant flags without saved Metro runtime hints. React Native apps may launch without bundle/runtime hints; prefer connect --remote-config <path> first or pass --remote-config <path> on this command.\n',
303295
);
304296
}
297+
if (command === 'agent-cdp') {
298+
const exitCode = await runAgentCdpCommand(positionals, {
299+
flags: effectiveFlags,
300+
runtime: resolvedRuntime,
301+
cwd: process.cwd(),
302+
env: process.env,
303+
});
304+
process.exit(exitCode);
305+
return;
306+
}
305307
const remoteDaemonBaseUrl = effectiveFlags.daemonBaseUrl;
306308
logTailStopper =
307309
debugOutputEnabled && !effectiveFlags.json && !remoteDaemonBaseUrl

0 commit comments

Comments
 (0)