diff --git a/src/__tests__/cli-react-devtools.test.ts b/src/__tests__/cli-react-devtools.test.ts index f61ee59c..aa6c6af8 100644 --- a/src/__tests__/cli-react-devtools.test.ts +++ b/src/__tests__/cli-react-devtools.test.ts @@ -22,6 +22,22 @@ import { runReactDevtoolsCommand, } from '../cli/commands/react-devtools.ts'; +type ReactDevtoolsOptions = NonNullable[1]>; +type ReactDevtoolsFlags = NonNullable; + +const remoteBridgeScope = { + metroProxyBaseUrl: 'https://bridge.example.test', + metroBearerToken: 'token', + tenant: 'tenant-1', + runId: 'run-1', + leaseId: 'lease-1', +} as const; + +const remoteBridgeBackends = [ + { label: 'Android', leaseBackend: 'android-instance' }, + { label: 'iOS', leaseBackend: 'ios-instance' }, +] as const; + afterEach(() => { vi.clearAllMocks(); }); @@ -50,8 +66,7 @@ test('react-devtools docs mention the pinned package version', () => { } }); -test('react-devtools starts remote Android companion around passthrough command', async () => { - const env = { ...process.env }; +function mockRemoteCompanionSuccess(): void { vi.mocked(runCmdStreaming).mockResolvedValueOnce({ exitCode: 0, stdout: '', @@ -67,91 +82,30 @@ test('react-devtools starts remote Android companion around passthrough command' stopped: true, statePath: '/tmp/state.json', }); +} - const exitCode = await runReactDevtoolsCommand(['status'], { - stateDir: '/tmp/agent-device-state', - session: 'default', - cwd: '/tmp/project', - env, - flags: { - platform: 'android', - leaseBackend: 'android-instance', - metroProxyBaseUrl: 'https://bridge.example.test', - metroBearerToken: 'token', - tenant: 'tenant-1', - runId: 'run-1', - leaseId: 'lease-1', - remoteConfig: '/tmp/remote.json', - session: 'default', - }, - }); - - assert.equal(exitCode, 0); - assert.equal(vi.mocked(ensureReactDevtoolsCompanion).mock.calls.length, 1); - assert.deepEqual(vi.mocked(ensureReactDevtoolsCompanion).mock.calls[0]?.[0], { - projectRoot: '/tmp/project', - stateDir: '/tmp/agent-device-state', - serverBaseUrl: 'https://bridge.example.test', - bearerToken: 'token', - bridgeScope: { - tenantId: 'tenant-1', - runId: 'run-1', - leaseId: 'lease-1', - }, - session: 'default', - profileKey: '/tmp/remote.json', - consumerKey: 'default', - env, - }); - assert.equal(vi.mocked(runCmdStreaming).mock.calls[0]?.[0], 'npm'); - assert.equal(vi.mocked(runCmdStreaming).mock.calls[0]?.[2]?.cwd, '/tmp/project'); - assert.equal(vi.mocked(runCmdStreaming).mock.calls[0]?.[2]?.env, env); - assert.equal(vi.mocked(stopReactDevtoolsCompanion).mock.calls.length, 1); - assert.deepEqual(vi.mocked(stopReactDevtoolsCompanion).mock.calls[0]?.[0], { - projectRoot: '/tmp/project', - stateDir: '/tmp/agent-device-state', - profileKey: '/tmp/remote.json', - consumerKey: 'default', - }); -}); +function assertNoRemoteCompanion(): void { + assert.equal(vi.mocked(ensureReactDevtoolsCompanion).mock.calls.length, 0); + assert.equal(vi.mocked(stopReactDevtoolsCompanion).mock.calls.length, 0); +} -test('react-devtools starts remote iOS companion around passthrough command', async () => { - const env = { ...process.env }; +async function runStatusWithoutCompanion(flags: ReactDevtoolsFlags): Promise { vi.mocked(runCmdStreaming).mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '', }); - vi.mocked(ensureReactDevtoolsCompanion).mockResolvedValueOnce({ - pid: 123, - spawned: true, - statePath: '/tmp/state.json', - logPath: '/tmp/companion.log', - }); - vi.mocked(stopReactDevtoolsCompanion).mockResolvedValueOnce({ - stopped: true, - statePath: '/tmp/state.json', - }); - const exitCode = await runReactDevtoolsCommand(['status'], { + await runReactDevtoolsCommand(['status'], { stateDir: '/tmp/agent-device-state', session: 'default', - cwd: '/tmp/project', - env, - flags: { - platform: 'ios', - leaseBackend: 'ios-instance', - metroProxyBaseUrl: 'https://bridge.example.test', - metroBearerToken: 'token', - tenant: 'tenant-1', - runId: 'run-1', - leaseId: 'lease-1', - remoteConfig: '/tmp/remote.json', - session: 'default', - }, + flags, }); - assert.equal(exitCode, 0); + assertNoRemoteCompanion(); +} + +function assertRemoteCompanionStarted(env: NodeJS.ProcessEnv): void { assert.equal(vi.mocked(ensureReactDevtoolsCompanion).mock.calls.length, 1); assert.deepEqual(vi.mocked(ensureReactDevtoolsCompanion).mock.calls[0]?.[0], { projectRoot: '/tmp/project', @@ -178,73 +132,61 @@ test('react-devtools starts remote iOS companion around passthrough command', as profileKey: '/tmp/remote.json', consumerKey: 'default', }); -}); +} -test('react-devtools skips companion for non-bridge remote sessions', async () => { - vi.mocked(runCmdStreaming).mockResolvedValueOnce({ - exitCode: 0, - stdout: '', - stderr: '', - }); +for (const { label, leaseBackend } of remoteBridgeBackends) { + test(`react-devtools starts remote ${label} companion around passthrough command`, async () => { + const env = { ...process.env }; + mockRemoteCompanionSuccess(); - await runReactDevtoolsCommand(['status'], { - stateDir: '/tmp/agent-device-state', - session: 'default', - flags: { - platform: 'ios', - leaseBackend: 'ios-simulator', - metroProxyBaseUrl: 'https://bridge.example.test', - metroBearerToken: 'token', - tenant: 'tenant-1', - runId: 'run-1', - leaseId: 'lease-1', - }, + const exitCode = await runReactDevtoolsCommand(['status'], { + stateDir: '/tmp/agent-device-state', + session: 'default', + cwd: '/tmp/project', + env, + flags: { + ...remoteBridgeScope, + leaseBackend, + remoteConfig: '/tmp/remote.json', + session: 'default', + }, + }); + + assert.equal(exitCode, 0); + assertRemoteCompanionStarted(env); }); +} - assert.equal(vi.mocked(ensureReactDevtoolsCompanion).mock.calls.length, 0); - assert.equal(vi.mocked(stopReactDevtoolsCompanion).mock.calls.length, 0); +test('react-devtools skips companion for non-bridge remote sessions', async () => { + await runStatusWithoutCompanion({ + ...remoteBridgeScope, + leaseBackend: 'ios-simulator', + }); }); -test('react-devtools fails clearly when remote Android bridge scope is incomplete', async () => { - await assert.rejects( - () => - runReactDevtoolsCommand(['status'], { - stateDir: '/tmp/agent-device-state', - session: 'default', - flags: { - platform: 'android', - leaseBackend: 'android-instance', - metroProxyBaseUrl: 'https://bridge.example.test', - tenant: 'tenant-1', - runId: 'run-1', - leaseId: 'lease-1', - }, - }), - /react-devtools remote bridge requires metroBearerToken/, - ); - - assert.equal(vi.mocked(runCmdStreaming).mock.calls.length, 0); - assert.equal(vi.mocked(ensureReactDevtoolsCompanion).mock.calls.length, 0); +test('react-devtools skips companion when remote bridge backend is missing', async () => { + await runStatusWithoutCompanion(remoteBridgeScope); }); -test('react-devtools fails clearly when remote iOS bridge scope is incomplete', async () => { - await assert.rejects( - () => - runReactDevtoolsCommand(['status'], { - stateDir: '/tmp/agent-device-state', - session: 'default', - flags: { - platform: 'ios', - leaseBackend: 'ios-instance', - metroProxyBaseUrl: 'https://bridge.example.test', - tenant: 'tenant-1', - runId: 'run-1', - leaseId: 'lease-1', - }, - }), - /react-devtools remote bridge requires metroBearerToken/, - ); - - assert.equal(vi.mocked(runCmdStreaming).mock.calls.length, 0); - assert.equal(vi.mocked(ensureReactDevtoolsCompanion).mock.calls.length, 0); -}); +for (const { label, leaseBackend } of remoteBridgeBackends) { + test(`react-devtools fails clearly when remote ${label} bridge scope is incomplete`, async () => { + await assert.rejects( + () => + runReactDevtoolsCommand(['status'], { + stateDir: '/tmp/agent-device-state', + session: 'default', + flags: { + leaseBackend, + metroProxyBaseUrl: 'https://bridge.example.test', + tenant: 'tenant-1', + runId: 'run-1', + leaseId: 'lease-1', + }, + }), + /react-devtools remote bridge requires metroBearerToken/, + ); + + assert.equal(vi.mocked(runCmdStreaming).mock.calls.length, 0); + assertNoRemoteCompanion(); + }); +} diff --git a/src/cli/commands/react-devtools.ts b/src/cli/commands/react-devtools.ts index 4ab6b968..1a46df74 100644 --- a/src/cli/commands/react-devtools.ts +++ b/src/cli/commands/react-devtools.ts @@ -13,7 +13,6 @@ const AGENT_REACT_DEVTOOLS_BIN = 'agent-react-devtools'; type ReactDevtoolsCommandOptions = { flags?: Pick< CliFlags, - | 'platform' | 'leaseBackend' | 'metroProxyBaseUrl' | 'metroBearerToken' @@ -49,29 +48,32 @@ export function buildReactDevtoolsNpmExecArgs(args: string[]): string[] { ]; } -function isSupportedRemoteBridge(flags: ReactDevtoolsCommandOptions['flags']): boolean { - if (!flags?.metroProxyBaseUrl) return false; - if (flags.leaseBackend) { - return flags.leaseBackend === 'android-instance' || flags.leaseBackend === 'ios-instance'; - } - return flags.platform === 'android' || flags.platform === 'ios'; +function isRemoteBridgeBackend(leaseBackend: CliFlags['leaseBackend']): boolean { + return leaseBackend === 'android-instance' || leaseBackend === 'ios-instance'; +} + +function readRemoteBridgeField( + missing: string[], + field: string, + value: string | undefined, +): string { + if (value) return value; + missing.push(field); + return ''; } function resolveRemoteBridgeConfig( flags: ReactDevtoolsCommandOptions['flags'], ): RemoteBridgeConfig | null { - if (!isSupportedRemoteBridge(flags)) return null; - const serverBaseUrl = flags?.metroProxyBaseUrl; - const bearerToken = flags?.metroBearerToken; - const tenantId = flags?.tenant; - const runId = flags?.runId; - const leaseId = flags?.leaseId; + if (!flags?.metroProxyBaseUrl || !isRemoteBridgeBackend(flags.leaseBackend)) return null; const missing: string[] = []; - if (!serverBaseUrl) missing.push('metroProxyBaseUrl'); - if (!bearerToken) missing.push('metroBearerToken'); - if (!tenantId) missing.push('tenant'); - if (!runId) missing.push('runId'); - if (!leaseId) missing.push('leaseId'); + const config = { + serverBaseUrl: readRemoteBridgeField(missing, 'metroProxyBaseUrl', flags.metroProxyBaseUrl), + bearerToken: readRemoteBridgeField(missing, 'metroBearerToken', flags.metroBearerToken), + tenantId: readRemoteBridgeField(missing, 'tenant', flags.tenant), + runId: readRemoteBridgeField(missing, 'runId', flags.runId), + leaseId: readRemoteBridgeField(missing, 'leaseId', flags.leaseId), + }; if (missing.length > 0) { throw new AppError( 'INVALID_ARGS', @@ -79,16 +81,7 @@ function resolveRemoteBridgeConfig( { missing }, ); } - if (!serverBaseUrl || !bearerToken || !tenantId || !runId || !leaseId) { - throw new AppError('INVALID_ARGS', 'react-devtools remote bridge is incomplete.'); - } - return { - serverBaseUrl, - bearerToken, - tenantId, - runId, - leaseId, - }; + return config; } async function withRemoteDevtoolsCompanion( diff --git a/src/utils/command-schema.ts b/src/utils/command-schema.ts index 70b03d47..5cd14a23 100644 --- a/src/utils/command-schema.ts +++ b/src/utils/command-schema.ts @@ -459,7 +459,7 @@ Rules: @c refs reset after reload/remount. After reload, wait --connected and inspect again. Keep the profile window narrow; unrelated navigation makes render data noisy. For cross-platform validation with explicit device selectors, prefer isolated --state-dir and restart react-devtools between platforms. - Remote Android and iOS bridge runs normally through agent-device react-devtools; the CLI manages the needed local service tunnel. Expo support depends on the SDK's bundled React Native runtime. + Remote bridge sessions (Android and iOS) run normally through agent-device react-devtools; the CLI manages the needed local service tunnel. Expo support depends on the SDK's bundled React Native runtime. Example: agent-device react-devtools status