Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions src/__tests__/cli-agent-cdp-session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, test, vi } from 'vitest';
import assert from 'node:assert/strict';

vi.mock('../cli/commands/agent-cdp.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../cli/commands/agent-cdp.ts')>();
return {
...actual,
runAgentCdpCommand: vi.fn(async () => 0),
};
});

import { runCli } from '../cli.ts';
import { runAgentCdpCommand } from '../cli/commands/agent-cdp.ts';
import { installIsolatedCliTestEnv } from './cli-test-env.ts';
import { hashRemoteConfigFile, writeRemoteConnectionState } from '../remote-connection-state.ts';
import type { DaemonResponse } from '../daemon-client.ts';

afterEach(() => {
vi.clearAllMocks();
});

test('cdp receives active remote connection session and runtime after defaults are merged', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-agent-cdp-session-'));
const stateDir = path.join(tempRoot, 'state');
const remoteConfigPath = path.join(tempRoot, 'remote.json');
fs.writeFileSync(
remoteConfigPath,
JSON.stringify({
daemonBaseUrl: 'https://daemon.example.test',
platform: 'android',
metroProxyBaseUrl: 'https://bridge.example.test',
metroBearerToken: 'token',
}),
);
const runtime = {
platform: 'android' as const,
bundleUrl: 'https://bridge.example.test/api/metro/runtimes/runtime-1/index.bundle',
};
writeRemoteConnectionState({
stateDir,
state: {
version: 1,
session: 'adc-android',
remoteConfigPath,
remoteConfigHash: hashRemoteConfigFile(remoteConfigPath),
daemon: { baseUrl: 'https://daemon.example.test', transport: 'http' },
tenant: 'tenant-1',
runId: 'run-1',
leaseId: 'lease-1',
leaseBackend: 'android-instance',
platform: 'android',
runtime,
connectedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
});

const originalExit = process.exit;
let exitCode: number | undefined;
const restoreEnv = installIsolatedCliTestEnv();
(process as any).exit = ((code?: number) => {
exitCode = code ?? 0;
}) as typeof process.exit;

const sendToDaemon = async (req: { command: string }): Promise<DaemonResponse> => {
if (req.command === 'lease_heartbeat') {
return {
ok: true,
data: {
lease: {
leaseId: 'lease-1',
tenantId: 'tenant-1',
runId: 'run-1',
backend: 'android-instance',
},
},
};
}
return { ok: true, data: {} };
};

try {
await runCli(['--state-dir', stateDir, 'cdp', 'target', 'list'], { sendToDaemon });
} finally {
restoreEnv();
process.exit = originalExit;
fs.rmSync(tempRoot, { recursive: true, force: true });
}

assert.equal(exitCode, 0);
assert.equal(vi.mocked(runAgentCdpCommand).mock.calls.length, 1);
assert.deepEqual(vi.mocked(runAgentCdpCommand).mock.calls[0]?.[0], ['target', 'list']);
assert.equal(vi.mocked(runAgentCdpCommand).mock.calls[0]?.[1]?.flags?.session, 'adc-android');
assert.deepEqual(vi.mocked(runAgentCdpCommand).mock.calls[0]?.[1]?.runtime, runtime);
});
94 changes: 94 additions & 0 deletions src/__tests__/cli-agent-cdp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { runCmdStreaming } from '../utils/exec.ts';
import {
AGENT_CDP_PACKAGE,
buildAgentCdpNpmExecArgs,
buildAgentCdpPassthroughArgs,
runAgentCdpCommand,
} from '../cli/commands/agent-cdp.ts';

Expand Down Expand Up @@ -90,3 +91,96 @@ test('cdp wrapper streams through npm exec and returns downstream exit code', as
assert.equal(vi.mocked(runCmdStreaming).mock.calls[0]?.[2]?.env, env);
assert.equal(vi.mocked(runCmdStreaming).mock.calls[0]?.[2]?.allowFailure, true);
});

test('cdp injects remote Metro public url for target discovery', async () => {
const args = buildAgentCdpPassthroughArgs(['target', 'list'], {
flags: {
leaseBackend: 'android-instance',
metroProxyBaseUrl: 'https://bridge.example.test',
metroPublicBaseUrl: 'http://127.0.0.1:8081/',
},
runtime: {
platform: 'android',
bundleUrl:
'https://bridge.example.test/api/metro/runtimes/runtime-1/index.bundle?platform=android&dev=true',
},
});

assert.deepEqual(args, ['target', 'list', '--url', 'http://127.0.0.1:8081']);
});

test('cdp preserves explicit target url for remote sessions', () => {
const args = buildAgentCdpPassthroughArgs(
['target', 'select', 'react-native:a:b', '--url', 'https://custom.example.test'],
{
flags: {
leaseBackend: 'ios-instance',
metroProxyBaseUrl: 'https://bridge.example.test',
},
runtime: {
platform: 'ios',
bundleUrl: 'https://bridge.example.test/api/metro/runtimes/runtime-1/index.bundle',
},
},
);

assert.deepEqual(args, [
'target',
'select',
'react-native:a:b',
'--url',
'https://custom.example.test',
]);
});

test('cdp rejects remote bridge target discovery without Metro public url', () => {
assert.throws(
() =>
buildAgentCdpPassthroughArgs(['target', 'list'], {
flags: {
leaseBackend: 'android-instance',
metroProxyBaseUrl: 'https://bridge.example.test',
},
runtime: {
platform: 'android',
bundleUrl:
'https://bridge.example.test/api/metro/runtimes/runtime-1/index.bundle?platform=android&dev=true',
},
}),
/cdp remote bridge target discovery requires a Metro public base URL/,
);
});

test('cdp passes injected remote target url to npm exec', async () => {
vi.mocked(runCmdStreaming).mockResolvedValueOnce({
exitCode: 0,
stdout: '',
stderr: '',
});

const exitCode = await runAgentCdpCommand(['target', 'list'], {
flags: {
leaseBackend: 'ios-instance',
metroProxyBaseUrl: 'https://bridge.example.test',
metroPublicBaseUrl: 'http://127.0.0.1:8081',
},
runtime: {
platform: 'ios',
bundleUrl: 'https://bridge.example.test/api/metro/runtimes/runtime-2/index.bundle',
},
});

assert.equal(exitCode, 0);
assert.deepEqual(vi.mocked(runCmdStreaming).mock.calls[0]?.[1], [
'exec',
'--yes',
'--package',
'agent-cdp@1.6.0',
'--',
'agent-cdp',
'target',
'list',
'--url',
'http://127.0.0.1:8081',
]);
});
155 changes: 155 additions & 0 deletions src/__tests__/remote-connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,161 @@ test('deferred materialization re-prepares runtime when explicit Metro overrides
fs.rmSync(tempRoot, { recursive: true, force: true });
});

test('cdp remote materialization prepares Metro runtime for bridge target discovery', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-agent-cdp-runtime-'));
const stateDir = path.join(tempRoot, '.state');
const remoteConfigPath = path.join(tempRoot, 'remote.json');
fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' }));
let prepareRequest: Parameters<AgentDeviceClient['metro']['prepare']>[0] | undefined;

const materialized = await materializeRemoteConnectionForCommand({
command: 'cdp',
positionals: ['target', 'list'],
flags: {
json: true,
help: false,
version: false,
stateDir,
remoteConfig: remoteConfigPath,
daemonBaseUrl: 'https://daemon.example',
tenant: 'acme',
runId: 'run-123',
session: 'adc-android',
platform: 'android',
leaseBackend: 'android-instance',
metroProjectRoot: '/tmp/project',
metroProxyBaseUrl: 'https://proxy.example.test',
metroPublicBaseUrl: 'https://sandbox.example.test',
},
client: createTestClient({
prepare: async (options) => {
prepareRequest = options;
return {
projectRoot: '/tmp/project',
kind: 'react-native',
dependenciesInstalled: false,
packageManager: null,
started: false,
reused: true,
pid: 0,
logPath: '/tmp/project/.agent-device/metro.log',
statusUrl: 'http://127.0.0.1:8081/status',
runtimeFilePath: null,
iosRuntime: { platform: 'ios' },
androidRuntime: {
platform: 'android',
bundleUrl:
'https://proxy.example.test/api/metro/runtimes/runtime-1/index.bundle?platform=android',
},
bridge: null,
};
},
}),
});

assert.equal(prepareRequest?.proxyBaseUrl, 'https://proxy.example.test');
assert.deepEqual(prepareRequest?.bridgeScope, {
tenantId: 'acme',
runId: 'run-123',
leaseId: 'lease-1',
});
assert.deepEqual(materialized.runtime, {
platform: 'android',
bundleUrl:
'https://proxy.example.test/api/metro/runtimes/runtime-1/index.bundle?platform=android',
});
assert.deepEqual(readRemoteConnectionState({ stateDir, session: 'adc-android' })?.metro, {
projectRoot: '/tmp/project',
profileKey: remoteConfigPath,
consumerKey: 'adc-android',
});

fs.rmSync(tempRoot, { recursive: true, force: true });
});

test('cdp remote materialization skips Metro runtime for non-target commands', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-agent-cdp-memory-'));
const stateDir = path.join(tempRoot, '.state');
const remoteConfigPath = path.join(tempRoot, 'remote.json');
fs.writeFileSync(path.join(tempRoot, 'remote.json'), JSON.stringify({}));
let prepared = false;

try {
const materialized = await materializeRemoteConnectionForCommand({
command: 'cdp',
positionals: ['memory', 'usage', 'sample'],
flags: {
json: true,
help: false,
version: false,
stateDir,
remoteConfig: remoteConfigPath,
daemonBaseUrl: 'https://daemon.example',
tenant: 'acme',
runId: 'run-123',
session: 'adc-android',
platform: 'android',
leaseBackend: 'android-instance',
metroProjectRoot: '/tmp/project',
metroProxyBaseUrl: 'https://proxy.example.test',
metroPublicBaseUrl: 'https://sandbox.example.test',
},
client: createTestClient({
prepare: async () => {
prepared = true;
throw new Error('prepare should not be called');
},
}),
});

assert.equal(prepared, false);
assert.equal(materialized.runtime, undefined);
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});

test('cdp remote materialization skips Metro runtime without public CDP url', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-agent-cdp-no-public-'));
const stateDir = path.join(tempRoot, '.state');
const remoteConfigPath = path.join(tempRoot, 'remote.json');
fs.writeFileSync(remoteConfigPath, JSON.stringify({}));
let prepared = false;

try {
const materialized = await materializeRemoteConnectionForCommand({
command: 'cdp',
positionals: ['target', 'list'],
flags: {
json: true,
help: false,
version: false,
stateDir,
remoteConfig: remoteConfigPath,
daemonBaseUrl: 'https://daemon.example',
tenant: 'acme',
runId: 'run-123',
session: 'adc-android',
platform: 'android',
leaseBackend: 'android-instance',
metroProjectRoot: '/tmp/project',
metroProxyBaseUrl: 'https://proxy.example.test',
},
client: createTestClient({
prepare: async () => {
prepared = true;
throw new Error('prepare should not be called');
},
}),
});

assert.equal(prepared, false);
assert.equal(materialized.runtime, undefined);
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});

test('deferred materialization heartbeats an existing lease before dispatch', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-heartbeat-'));
const stateDir = path.join(tempRoot, '.state');
Expand Down
Loading
Loading