From c3a53fe37987c228a565a1d69d6f07ed8cb7b398 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 30 Apr 2026 16:08:45 -0400 Subject: [PATCH] fix: persist remote react devtools companion --- src/__tests__/cli-react-devtools.test.ts | 60 +++++++++++++++++++----- src/cli/commands/react-devtools.ts | 28 ++++++----- src/utils/command-schema.ts | 4 +- website/docs/docs/commands.md | 2 +- 4 files changed, 68 insertions(+), 26 deletions(-) diff --git a/src/__tests__/cli-react-devtools.test.ts b/src/__tests__/cli-react-devtools.test.ts index aa6c6af8..91e38372 100644 --- a/src/__tests__/cli-react-devtools.test.ts +++ b/src/__tests__/cli-react-devtools.test.ts @@ -78,10 +78,6 @@ function mockRemoteCompanionSuccess(): void { statePath: '/tmp/state.json', logPath: '/tmp/companion.log', }); - vi.mocked(stopReactDevtoolsCompanion).mockResolvedValueOnce({ - stopped: true, - statePath: '/tmp/state.json', - }); } function assertNoRemoteCompanion(): void { @@ -125,17 +121,11 @@ function assertRemoteCompanionStarted(env: NodeJS.ProcessEnv): void { 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', - }); + assert.equal(vi.mocked(stopReactDevtoolsCompanion).mock.calls.length, 0); } for (const { label, leaseBackend } of remoteBridgeBackends) { - test(`react-devtools starts remote ${label} companion around passthrough command`, async () => { + test(`react-devtools keeps remote ${label} companion after passthrough command`, async () => { const env = { ...process.env }; mockRemoteCompanionSuccess(); @@ -157,6 +147,52 @@ for (const { label, leaseBackend } of remoteBridgeBackends) { }); } +test('react-devtools stop cleans up remote companion', async () => { + const env = { ...process.env }; + vi.mocked(runCmdStreaming).mockResolvedValueOnce({ + exitCode: 0, + stdout: '', + stderr: '', + }); + vi.mocked(stopReactDevtoolsCompanion).mockResolvedValueOnce({ + stopped: true, + statePath: '/tmp/state.json', + }); + + const exitCode = await runReactDevtoolsCommand(['stop'], { + stateDir: '/tmp/agent-device-state', + session: 'default', + cwd: '/tmp/project', + env, + flags: { + ...remoteBridgeScope, + leaseBackend: 'ios-instance', + remoteConfig: '/tmp/remote.json', + session: 'default', + }, + }); + + assert.equal(exitCode, 0); + assert.equal(vi.mocked(ensureReactDevtoolsCompanion).mock.calls.length, 0); + assert.equal(vi.mocked(runCmdStreaming).mock.calls[0]?.[0], 'npm'); + assert.deepEqual(vi.mocked(runCmdStreaming).mock.calls[0]?.[1], [ + 'exec', + '--yes', + '--package', + 'agent-react-devtools@0.4.0', + '--', + 'agent-react-devtools', + 'stop', + ]); + 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', + }); +}); + test('react-devtools skips companion for non-bridge remote sessions', async () => { await runStatusWithoutCompanion({ ...remoteBridgeScope, diff --git a/src/cli/commands/react-devtools.ts b/src/cli/commands/react-devtools.ts index 1a46df74..96120935 100644 --- a/src/cli/commands/react-devtools.ts +++ b/src/cli/commands/react-devtools.ts @@ -85,6 +85,7 @@ function resolveRemoteBridgeConfig( } async function withRemoteDevtoolsCompanion( + args: string[], options: ReactDevtoolsCommandOptions, action: () => Promise, ): Promise { @@ -96,6 +97,20 @@ async function withRemoteDevtoolsCompanion( const session = options.session ?? flags?.session ?? 'default'; const profileKey = flags?.remoteConfig ?? `${bridgeConfig.tenantId}:${bridgeConfig.runId}:${bridgeConfig.leaseId}`; + + if (args[0] === 'stop') { + try { + return await action(); + } finally { + await stopReactDevtoolsCompanion({ + projectRoot: options.cwd ?? process.cwd(), + stateDir, + profileKey, + consumerKey: session, + }); + } + } + await ensureReactDevtoolsCompanion({ projectRoot: options.cwd ?? process.cwd(), stateDir, @@ -111,16 +126,7 @@ async function withRemoteDevtoolsCompanion( consumerKey: session, env: options.env ?? process.env, }); - try { - return await action(); - } finally { - await stopReactDevtoolsCompanion({ - projectRoot: options.cwd ?? process.cwd(), - stateDir, - profileKey, - consumerKey: session, - }); - } + return await action(); } export async function runReactDevtoolsCommand( @@ -129,7 +135,7 @@ export async function runReactDevtoolsCommand( ): Promise { const cwd = options.cwd ?? process.cwd(); const env = options.env ?? process.env; - return await withRemoteDevtoolsCompanion(options, async () => { + return await withRemoteDevtoolsCompanion(args, options, async () => { const result = await runCmdStreaming('npm', buildReactDevtoolsNpmExecArgs(args), { cwd, env, diff --git a/src/utils/command-schema.ts b/src/utils/command-schema.ts index 5cd14a23..b018fe95 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 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. + Remote Android and iOS bridge runs normally through agent-device react-devtools; the CLI keeps the needed local service tunnel alive until agent-device react-devtools stop or disconnect. Expo support depends on the SDK's bundled React Native runtime. Example: agent-device react-devtools status @@ -496,7 +496,7 @@ Rules: For self-contained scripts, pass the same --remote-config to every operational command, including disconnect; a preceding connect is optional but not required. For remote artifact installs, use install-from-source or install-from-source --github-actions-artifact org/repo:artifact; do not download CI artifacts locally first. After connect, let the active remote connection supply runtime hints. - For remote Android and iOS bridge React DevTools, run agent-device react-devtools normally. The CLI opens the needed local service tunnel for the DevTools daemon and cleans it up when the command exits. + For remote Android and iOS bridge React DevTools, run agent-device react-devtools normally. The CLI opens the needed local service tunnel for the DevTools daemon and keeps it alive until agent-device react-devtools stop or disconnect. Use --debug when remote connection or transport errors need diagnostic ids and remote log hints.`, }, macos: { diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 8ed1a8a7..7d9249ac 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -590,7 +590,7 @@ agent-device react-devtools profile rerenders --limit 5 - Use it when a React Native workflow needs component hierarchy, props, state, hooks, render causes, slow components, or re-render counts. - Keep using `snapshot`, `press`, `fill`, `logs`, `network`, and `perf` for device/app runtime evidence. Use `react-devtools` for React internals. - React Native development builds can connect to the DevTools daemon on port 8097. For Android emulators or physical devices, run `adb reverse tcp:8097 tcp:8097` if the app cannot reach the host. If Metro is local, also run `adb reverse tcp:8081 tcp:8081`. -- For Android and iOS sessions connected through a remote bridge profile, `react-devtools` registers a lease-scoped companion tunnel to the sandbox-local DevTools daemon at `127.0.0.1:8097`. Android bridge profiles use the bridge-owned remote `adb reverse` mapping; iOS bridge profiles use the bridge-owned wildcard Metro host tunnel. The CLI unregisters the companion when the command exits. +- For Android and iOS sessions connected through a remote bridge profile, `react-devtools` registers a lease-scoped companion tunnel to the sandbox-local DevTools daemon at `127.0.0.1:8097`. Android bridge profiles use the bridge-owned remote `adb reverse` mapping; iOS bridge profiles use the bridge-owned wildcard Metro host tunnel. The CLI keeps the companion alive until `agent-device react-devtools stop` or `agent-device disconnect`. - Remote bridge React DevTools assumes the React Native-bundled DevTools behavior in React Native 0.83+. Older browser/Chromium DevTools workflows are not assumed to exist inside remote sandboxes. Expo projects should be verified against the SDK's bundled React Native version before relying on this path; this release does not claim a separately verified Expo SDK version. - For cross-platform validation with explicit target selectors, prefer an isolated `--state-dir` over separate named sessions. Named sessions enable bound-session locks during setup. Restart `react-devtools` between iOS and Android runs.