Skip to content
Merged
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
216 changes: 79 additions & 137 deletions src/__tests__/cli-react-devtools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,22 @@ import {
runReactDevtoolsCommand,
} from '../cli/commands/react-devtools.ts';

type ReactDevtoolsOptions = NonNullable<Parameters<typeof runReactDevtoolsCommand>[1]>;
type ReactDevtoolsFlags = NonNullable<ReactDevtoolsOptions['flags']>;

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();
});
Expand Down Expand Up @@ -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: '',
Expand All @@ -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<void> {
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',
Expand All @@ -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();
});
}
49 changes: 21 additions & 28 deletions src/cli/commands/react-devtools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ const AGENT_REACT_DEVTOOLS_BIN = 'agent-react-devtools';
type ReactDevtoolsCommandOptions = {
flags?: Pick<
CliFlags,
| 'platform'
| 'leaseBackend'
| 'metroProxyBaseUrl'
| 'metroBearerToken'
Expand Down Expand Up @@ -49,46 +48,40 @@ 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',
`react-devtools remote bridge requires ${missing.join(', ')}.`,
{ 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<T>(
Expand Down
2 changes: 1 addition & 1 deletion src/utils/command-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading