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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ agent-device react-devtools profile slow --limit 5

`react-devtools` dynamically runs pinned `agent-react-devtools@0.4.0` commands 1:1, so `agent-device` covers both the device/app runtime layer and React component internals without making React DevTools part of the daemon.

When an Android session is connected through a remote bridge profile, `react-devtools` automatically opens a lease-scoped companion tunnel for the local DevTools daemon on port 8097 and cleans it up when the command exits.

Remote Android 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.

## Command Flow

The canonical loop is:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agent-device",
"version": "0.12.9",
"version": "0.13.0",
"description": "Agent-driven CLI for mobile UI automation, network inspection, and performance diagnostics across iOS, Android, tvOS, and macOS.",
"license": "MIT",
"author": "Callstack",
Expand Down
2 changes: 2 additions & 0 deletions skills/react-devtools/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ agent-device react-devtools profile rerenders --limit 5
- Labels like `@c5` reset when the app reloads or components remount. After reload, run `wait --connected` and inspect again.
- Profiling only captures renders between `profile start` and `profile stop`.
- On Android, set `adb reverse tcp:8097 tcp:8097` for React DevTools. If Metro is local, also set `adb reverse tcp:8081 tcp:8081`.
- For Android sessions connected through `agent-device connect --remote-config`, run `agent-device react-devtools ...` normally. The CLI registers a bridge companion tunnel to the local DevTools daemon on `127.0.0.1:8097` and unregisters it when the command exits.
- Remote Android React DevTools assumes the React Native-bundled DevTools behavior in React Native 0.83+. Do not assume older browser/Chromium DevTools workflows exist in remote sandboxes. For Expo apps, verify the SDK's bundled React Native version and runtime behavior first; no Expo SDK version is separately verified by this skill.

## References

Expand Down
72 changes: 72 additions & 0 deletions src/__tests__/cli-react-devtools-session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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/react-devtools.ts', () => ({
runReactDevtoolsCommand: vi.fn(async () => 0),
}));

import { runCli } from '../cli.ts';
import { runReactDevtoolsCommand } from '../cli/commands/react-devtools.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('react-devtools uses active remote connection session after defaults are merged', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-react-devtools-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',
}),
);
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',
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 (): Promise<DaemonResponse> => ({ ok: true, data: {} });

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

assert.equal(exitCode, 0);
assert.equal(vi.mocked(runReactDevtoolsCommand).mock.calls.length, 1);
assert.equal(vi.mocked(runReactDevtoolsCommand).mock.calls[0]?.[1]?.session, 'adc-android');
});
136 changes: 135 additions & 1 deletion src/__tests__/cli-react-devtools.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
import fs from 'node:fs';
import { test } from 'vitest';
import { afterEach, test, vi } from 'vitest';
import assert from 'node:assert/strict';

vi.mock('../utils/exec.ts', () => ({
runCmdStreaming: vi.fn(),
}));

vi.mock('../client-metro-companion.ts', () => ({
ensureMetroCompanion: vi.fn(),
stopMetroCompanion: vi.fn(),
}));

import { runCmdStreaming } from '../utils/exec.ts';
import { ensureMetroCompanion, stopMetroCompanion } from '../client-metro-companion.ts';
import {
AGENT_REACT_DEVTOOLS_PACKAGE,
buildReactDevtoolsNpmExecArgs,
runReactDevtoolsCommand,
} from '../cli/commands/react-devtools.ts';

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

test('react-devtools passthrough pins agent-react-devtools package version', () => {
assert.equal(AGENT_REACT_DEVTOOLS_PACKAGE, 'agent-react-devtools@0.4.0');
assert.deepEqual(buildReactDevtoolsNpmExecArgs(['get', 'tree', '--depth', '3']), [
Expand All @@ -29,3 +46,120 @@ test('react-devtools docs mention the pinned package version', () => {
assert.match(fs.readFileSync(file, 'utf8'), new RegExp(AGENT_REACT_DEVTOOLS_PACKAGE));
}
});

test('react-devtools starts remote Android companion around passthrough command', async () => {
const env = { ...process.env };
vi.mocked(runCmdStreaming).mockResolvedValueOnce({
exitCode: 0,
stdout: '',
stderr: '',
});
vi.mocked(ensureMetroCompanion).mockResolvedValueOnce({
pid: 123,
spawned: true,
statePath: '/tmp/state.json',
logPath: '/tmp/companion.log',
});
vi.mocked(stopMetroCompanion).mockResolvedValueOnce({
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(ensureMetroCompanion).mock.calls.length, 1);
assert.deepEqual(vi.mocked(ensureMetroCompanion).mock.calls[0]?.[0], {
projectRoot: '/tmp/project',
stateDir: '/tmp/agent-device-state',
kind: 'react-devtools',
serverBaseUrl: 'https://bridge.example.test',
bearerToken: 'token',
localBaseUrl: 'http://127.0.0.1:8097',
bridgeScope: {
tenantId: 'tenant-1',
runId: 'run-1',
leaseId: 'lease-1',
},
registerPath: '/api/react-devtools/companion/register',
unregisterPath: '/api/react-devtools/companion/unregister',
devicePort: 8097,
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(stopMetroCompanion).mock.calls.length, 1);
assert.deepEqual(vi.mocked(stopMetroCompanion).mock.calls[0]?.[0], {
projectRoot: '/tmp/project',
stateDir: '/tmp/agent-device-state',
kind: 'react-devtools',
profileKey: '/tmp/remote.json',
consumerKey: 'default',
});
});

test('react-devtools skips companion for non-Android remote sessions', async () => {
vi.mocked(runCmdStreaming).mockResolvedValueOnce({
exitCode: 0,
stdout: '',
stderr: '',
});

await runReactDevtoolsCommand(['status'], {
stateDir: '/tmp/agent-device-state',
session: 'default',
flags: {
platform: 'ios',
metroProxyBaseUrl: 'https://bridge.example.test',
metroBearerToken: 'token',
tenant: 'tenant-1',
runId: 'run-1',
leaseId: 'lease-1',
},
});

assert.equal(vi.mocked(ensureMetroCompanion).mock.calls.length, 0);
assert.equal(vi.mocked(stopMetroCompanion).mock.calls.length, 0);
});

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 Android bridge requires metroBearerToken/,
);

assert.equal(vi.mocked(runCmdStreaming).mock.calls.length, 0);
assert.equal(vi.mocked(ensureMetroCompanion).mock.calls.length, 0);
});
28 changes: 28 additions & 0 deletions src/__tests__/client-metro-companion-worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import path from 'node:path';
import type { Duplex } from 'node:stream';
import { setTimeout as delay } from 'node:timers/promises';
import { afterEach, test } from 'vitest';
import { buildCompanionPayload } from '../client-metro-companion-worker.ts';

type Deferred<T> = {
promise: Promise<T>;
Expand Down Expand Up @@ -174,6 +175,33 @@ afterEach(async () => {
}
});

test('companion payload includes React DevTools session and device port', () => {
assert.deepEqual(
buildCompanionPayload({
serverBaseUrl: 'https://bridge.example.test',
bearerToken: 'token',
localBaseUrl: 'http://127.0.0.1:8097/',
bridgeScope: {
tenantId: 'tenant-1',
runId: 'run-1',
leaseId: 'lease-1',
},
session: 'default',
devicePort: 8097,
registerPath: '/api/react-devtools/companion/register',
unregisterPath: '/api/react-devtools/companion/unregister',
}),
{
tenantId: 'tenant-1',
runId: 'run-1',
leaseId: 'lease-1',
session: 'default',
local_base_url: 'http://127.0.0.1:8097',
device_port: 8097,
},
);
});

test('metro companion worker proxies websocket frames to the local upstream server', async () => {
const upstreamMessage = createDeferred<string>();
const bridgePong = createDeferred<void>();
Expand Down
30 changes: 11 additions & 19 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,25 +145,6 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
}

const { command, positionals } = parsed;
if (command === 'react-devtools') {
try {
const exitCode = await runReactDevtoolsCommand(positionals);
process.exit(exitCode);
return;
} catch (error) {
const normalized = normalizeError(error, {
diagnosticId: getDiagnosticsMeta().diagnosticId,
logPath: flushDiagnosticsToSessionFile({ force: true }) ?? undefined,
});
if (parsed.flags.json) {
printJson({ success: false, error: normalized });
} else {
printHumanError(normalized, { showDetails: parsed.flags.verbose });
}
process.exit(1);
return;
}
}
let binding: ReturnType<typeof resolveBindingSettings>;
let flags: typeof parsed.flags;
let daemonPaths: ReturnType<typeof resolveDaemonPaths>;
Expand Down Expand Up @@ -212,6 +193,17 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
}
let logTailStopper: (() => void) | null = null;
try {
if (command === 'react-devtools') {
const exitCode = await runReactDevtoolsCommand(positionals, {
flags: effectiveFlags,
stateDir: daemonPaths.baseDir,
session: effectiveFlags.session ?? sessionName,
cwd: process.cwd(),
env: process.env,
});
process.exit(exitCode);
return;
}
maybeRunUpgradeNotifier({
command,
currentVersion: version,
Expand Down
18 changes: 18 additions & 0 deletions src/cli/commands/connection-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { resolveDaemonPaths } from '../../daemon/config.ts';
import { stopMetroCompanion } from '../../client-metro-companion.ts';
import { stopMetroTunnel } from '../../metro.ts';
import { resolveRemoteConfigProfile } from '../../remote-config.ts';
import type { MetroBridgeScope } from '../../client-metro-companion-contract.ts';
Expand Down Expand Up @@ -236,6 +237,23 @@ export async function stopMetroCleanup(
}
}

export async function stopReactDevtoolsCleanup(options: {
stateDir: string;
state: Pick<RemoteConnectionState, 'remoteConfigPath' | 'session'>;
}): Promise<void> {
try {
await stopMetroCompanion({
projectRoot: process.cwd(),
stateDir: options.stateDir,
kind: 'react-devtools',
profileKey: options.state.remoteConfigPath,
consumerKey: options.state.session,
});
} catch {
// Connection lifecycle cleanup must stay best-effort.
}
}

export async function releasePreviousLease(
client: AgentDeviceClient,
previous: RemoteConnectionState,
Expand Down
3 changes: 3 additions & 0 deletions src/cli/commands/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
releasePreviousLease,
resolveRequestedLeaseBackend,
stopMetroCleanup,
stopReactDevtoolsCleanup,
} from './connection-runtime.ts';
import { writeCommandOutput } from './shared.ts';
import type { LeaseBackend } from '../../contracts.ts';
Expand Down Expand Up @@ -104,6 +105,7 @@ export const connectCommand: ClientCommandHandler = async ({ flags, client }) =>
writeRemoteConnectionState({ stateDir, state });
if (previous && flags.force) {
await stopMetroCleanup(previous.metro);
await stopReactDevtoolsCleanup({ stateDir, state: previous });
await releasePreviousLease(client, previous);
}
const runtimePreparation = buildRuntimePreparationNotice(flags, state);
Expand Down Expand Up @@ -143,6 +145,7 @@ export const disconnectCommand: ClientCommandHandler = async ({ flags, client })
// Disconnect is idempotent; the session may already be closed.
}
await stopMetroCleanup(state.metro);
await stopReactDevtoolsCleanup({ stateDir, state });
let released = false;
if (state.leaseId) {
try {
Expand Down
Loading
Loading