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
6 changes: 4 additions & 2 deletions skills/react-devtools/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Router for React Native internals. Private setup before using this skill:
agent-device --version
```

Require `agent-device >= 0.14.0`; older CLIs lack these help topics. If older, run `npm install -g agent-device@latest`, recheck, then continue. If you cannot upgrade, stop and tell the user. Do not include version/upgrade commands in final plans.
Require `agent-device >= 0.14.2`; older CLIs lack the remote/cloud React DevTools guidance. If older, run `npm install -g agent-device@latest`, recheck, then continue. If you cannot upgrade, stop and tell the user. Do not include version/upgrade commands in final plans.

Read current CLI guidance:

Expand All @@ -21,6 +21,8 @@ agent-device help react-devtools

Use `agent-device react-devtools ...` for component tree, props, state, hooks, render ownership, performance profiling, slow components, or rerenders. It dynamically runs pinned `agent-react-devtools@0.4.0`. Use normal `agent-device` commands for visible UI, refs, screenshots, logs, network, or device-level perf.

Remote sessions: Android cloud devices work through the CLI-managed service tunnel when the remote profile includes DevTools tunnel settings. Remote iOS cloud devices are not available for React DevTools in the current cloud service; use normal device evidence or local iOS when React internals are required.

Core loop:

```bash
Expand All @@ -36,4 +38,4 @@ agent-device react-devtools profile rerenders --limit 5

Rules:

Keep reads bounded with `--depth`/`find`, treat `@c` refs as reload-local, profile only the investigated interaction, and run the same command in remote Android sessions; the CLI manages the needed local service tunnel.
Keep reads bounded with `--depth`/`find`, treat `@c` refs as reload-local, profile only the investigated interaction, and use the same command in supported remote Android sessions; the CLI manages the needed local service tunnel.
71 changes: 43 additions & 28 deletions src/__tests__/cli-react-devtools.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { afterEach, test, vi } from 'vitest';
import assert from 'node:assert/strict';

vi.mock('../utils/exec.ts', () => ({
runCmdStreaming: vi.fn(),
Expand All @@ -26,16 +26,15 @@ type ReactDevtoolsOptions = NonNullable<Parameters<typeof runReactDevtoolsComman
type ReactDevtoolsFlags = NonNullable<ReactDevtoolsOptions['flags']>;

const remoteBridgeScope = {
metroProxyBaseUrl: 'https://bridge.example.test',
metroProxyBaseUrl: 'https://cloud.example.test',
metroBearerToken: 'token',
tenant: 'tenant-1',
runId: 'run-1',
leaseId: 'lease-1',
} as const;

const remoteBridgeBackends = [
const remoteAndroidBridgeBackends = [
{ label: 'Android', leaseBackend: 'android-instance' },
{ label: 'iOS', leaseBackend: 'ios-instance' },
] as const;

afterEach(() => {
Expand Down Expand Up @@ -110,7 +109,7 @@ function assertRemoteCompanionStarted(env: NodeJS.ProcessEnv): void {
assert.deepEqual(vi.mocked(ensureReactDevtoolsCompanion).mock.calls[0]?.[0], {
projectRoot: '/tmp/project',
stateDir: '/tmp/agent-device-state',
serverBaseUrl: 'https://bridge.example.test',
serverBaseUrl: 'https://cloud.example.test',
bearerToken: 'token',
bridgeScope: {
tenantId: 'tenant-1',
Expand All @@ -134,7 +133,7 @@ function assertRemoteCompanionStarted(env: NodeJS.ProcessEnv): void {
});
}

for (const { label, leaseBackend } of remoteBridgeBackends) {
for (const { label, leaseBackend } of remoteAndroidBridgeBackends) {
test(`react-devtools starts remote ${label} companion around passthrough command`, async () => {
const env = { ...process.env };
mockRemoteCompanionSuccess();
Expand Down Expand Up @@ -168,25 +167,41 @@ test('react-devtools skips companion when remote bridge backend is missing', asy
await runStatusWithoutCompanion(remoteBridgeScope);
});

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();
});
}
test('react-devtools fails clearly for remote iOS service sessions', async () => {
await assert.rejects(
() =>
runReactDevtoolsCommand(['status'], {
stateDir: '/tmp/agent-device-state',
session: 'default',
flags: {
leaseBackend: 'ios-instance',
...remoteBridgeScope,
},
}),
/react-devtools is not available for remote iOS devices/,
);

assert.equal(vi.mocked(runCmdStreaming).mock.calls.length, 0);
assertNoRemoteCompanion();
});

test('react-devtools fails clearly when remote Android service tunnel scope is incomplete', async () => {
await assert.rejects(
() =>
runReactDevtoolsCommand(['status'], {
stateDir: '/tmp/agent-device-state',
session: 'default',
flags: {
leaseBackend: 'android-instance',
metroProxyBaseUrl: remoteBridgeScope.metroProxyBaseUrl,
tenant: 'tenant-1',
runId: 'run-1',
leaseId: 'lease-1',
},
}),
/react-devtools remote service tunnel requires metroBearerToken/,
);

assert.equal(vi.mocked(runCmdStreaming).mock.calls.length, 0);
assertNoRemoteCompanion();
});
15 changes: 14 additions & 1 deletion src/cli/commands/react-devtools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ function isRemoteBridgeBackend(leaseBackend: CliFlags['leaseBackend']): boolean
return leaseBackend === 'android-instance' || leaseBackend === 'ios-instance';
}

function isRemoteIosBridgeBackend(leaseBackend: CliFlags['leaseBackend']): boolean {
return leaseBackend === 'ios-instance';
}

function readRemoteBridgeField(
missing: string[],
field: string,
Expand All @@ -66,6 +70,15 @@ function resolveRemoteBridgeConfig(
flags: ReactDevtoolsCommandOptions['flags'],
): RemoteBridgeConfig | null {
if (!flags?.metroProxyBaseUrl || !isRemoteBridgeBackend(flags.leaseBackend)) return null;
if (isRemoteIosBridgeBackend(flags.leaseBackend)) {
throw new AppError(
'UNSUPPORTED_OPERATION',
'react-devtools is not available for remote iOS devices in the current cloud service.',
{
leaseBackend: flags.leaseBackend,
},
);
}
const missing: string[] = [];
const config = {
serverBaseUrl: readRemoteBridgeField(missing, 'metroProxyBaseUrl', flags.metroProxyBaseUrl),
Expand All @@ -77,7 +90,7 @@ function resolveRemoteBridgeConfig(
if (missing.length > 0) {
throw new AppError(
'INVALID_ARGS',
`react-devtools remote bridge requires ${missing.join(', ')}.`,
`react-devtools remote service tunnel requires ${missing.join(', ')}.`,
{ missing },
);
}
Expand Down
22 changes: 22 additions & 0 deletions src/utils/__tests__/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -872,6 +872,8 @@ test('usageForCommand resolves workflow help topic', () => {
assert.match(help, /if no URL is provided but a target\/app name is provided, open that target/);
assert.match(help, /do not split clear\/restart/);
assert.match(help, /do not write network log headers/);
assert.match(help, /Remote profiles own device lifecycle/);
assert.match(help, /Runner-backed commands can be unavailable for remote devices/);
assert.match(help, /agent-device open exp:\/\/127\.0\.0\.1:8081 --platform ios/);
assert.match(help, /agent-device open "Expo Go" exp:\/\/127\.0\.0\.1:8081 --platform ios/);
assert.match(help, /direct URL open can report success while leaving the runner\/shell focused/);
Expand Down Expand Up @@ -902,6 +904,10 @@ test('usageForCommand resolves remote help topic', () => {
assert.match(help, /Script flow, per-command config/);
assert.match(help, /same --remote-config to every operational command/);
assert.match(help, /install-from-source --github-actions-artifact org\/repo:artifact/);
assert.match(help, /Do not run boot or ensure-simulator when a remote\/cloud service/);
assert.match(help, /alert and iOS keyboard dismiss can be unavailable remotely/);
assert.match(help, /For remote Android React DevTools, run agent-device react-devtools normally/);
assert.match(help, /Remote iOS is not available for React DevTools/);
});

test('usageForCommand resolves macos help topic', () => {
Expand Down Expand Up @@ -948,6 +954,8 @@ test('usageForCommand resolves react-devtools help topic', () => {
assert.match(help, /@c refs reset after reload\/remount/);
assert.match(help, /isolated --state-dir/);
assert.match(help, /local service tunnel/);
assert.match(help, /Remote Android sessions run normally through agent-device react-devtools/);
assert.match(help, /Remote iOS is not available for React DevTools/);
});

test('apps defaults to --all filter and allows overrides', () => {
Expand Down Expand Up @@ -1243,6 +1251,20 @@ test('keyboard command usage is documented', () => {
if (help === null) throw new Error('Expected command help text');
assert.match(help, /keyboard \[status\|get\|dismiss\]/);
assert.match(help, /Inspect Android keyboard visibility\/type or dismiss the device keyboard/);
assert.match(help, /runner-backed paths may be unavailable for remote devices/);
});

test('remote-inapplicable command usage is marked', () => {
const bootHelp = usageForCommand('boot');
const ensureHelp = usageForCommand('ensure-simulator');
const alertHelp = usageForCommand('alert');
if (bootHelp === null || ensureHelp === null || alertHelp === null) {
throw new Error('Expected command help text');
}

assert.match(bootHelp, /not for remote\/cloud device profiles/);
assert.match(ensureHelp, /not for remote\/cloud device profiles/);
assert.match(alertHelp, /may be unavailable for remote devices/);
});

test('rotate command usage is documented', () => {
Expand Down
34 changes: 21 additions & 13 deletions src/utils/command-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ const ENVIRONMENT_LINES = [
},
{
label: 'AGENT_DEVICE_CLOUD_BASE_URL',
description: 'Bridge/control-plane API origin for cloud auth and /api-keys',
description: 'Cloud/control-plane API origin for auth and /api-keys',
},
] as const;

Expand Down Expand Up @@ -333,7 +333,9 @@ Validation and evidence:
Android animations: settings animations off/on, not animations disable/restore.
Debug logs: logs clear --restart, logs mark, reproduce, then logs path; do not split clear/restart into separate stop/start commands.
Network headers: network dump --include headers; do not write network log headers.
Remote config: connect --remote-config ./remote-config.json, open, snapshot, disconnect.
Remote/cloud devices: connect --remote-config ./remote-config.json, open, snapshot, disconnect.
Remote profiles own device lifecycle. Do not plan local-only setup commands such as boot or ensure-simulator when the device is provided by a remote service; use connect/open and follow the service error if a device is unavailable.
Runner-backed commands can be unavailable for remote devices. If alert or keyboard returns unsupported/no alert remotely, inspect with snapshot -i and use visible UI controls instead of retrying the runner command.
macOS menu bar: open ... --platform macos --surface menubar; snapshot -i --platform macos --surface menubar.

React Native dev loop:
Expand Down Expand Up @@ -459,7 +461,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 sessions run normally through agent-device react-devtools when the profile includes DevTools tunnel settings. The CLI manages the local service tunnel. Remote iOS is not available for React DevTools in the current cloud service; use normal device evidence or local iOS when React internals are required. Expo support depends on the SDK's bundled React Native runtime.

Example:
agent-device react-devtools status
Expand Down Expand Up @@ -496,7 +498,9 @@ 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 <url> 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.
Remote profiles own device boot/provisioning. Do not run boot or ensure-simulator when a remote/cloud service provides the device; start with connect/open and report the service error if no device is leased.
Runner-backed helpers such as alert and iOS keyboard dismiss can be unavailable remotely. When they report unsupported or no alert, use snapshot -i plus visible refs/selectors instead of looping on the helper.
For remote Android 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. Remote iOS is not available for React DevTools in the current cloud service.
Use --debug when remote connection or transport errors need diagnostic ids and remote log hints.`,
},
macos: {
Expand Down Expand Up @@ -821,15 +825,15 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [
names: ['--proxy-base-url'],
type: 'string',
usageLabel: '--proxy-base-url <url>',
usageDescription: 'metro prepare: optional bridge origin for remote Metro access',
usageDescription: 'metro prepare: optional remote service origin for Metro access',
},
{
key: 'metroBearerToken',
names: ['--bearer-token'],
type: 'string',
usageLabel: '--bearer-token <token>',
usageDescription:
'metro prepare: host bridge bearer token (prefer AGENT_DEVICE_PROXY_TOKEN or AGENT_DEVICE_METRO_BEARER_TOKEN)',
'metro prepare: remote service bearer token (prefer AGENT_DEVICE_PROXY_TOKEN or AGENT_DEVICE_METRO_BEARER_TOKEN)',
},
{
key: 'metroPreparePort',
Expand Down Expand Up @@ -868,7 +872,7 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [
type: 'int',
min: 1,
usageLabel: '--probe-timeout-ms <ms>',
usageDescription: 'metro prepare: timeout for /status and proxy bridge calls',
usageDescription: 'metro prepare: timeout for /status and remote service calls',
},
{
key: 'metroRuntimeFile',
Expand Down Expand Up @@ -1423,7 +1427,8 @@ export const GLOBAL_FLAG_KEYS = new Set<FlagKey>([

const COMMAND_SCHEMAS: Record<string, CommandSchema> = {
boot: {
helpDescription: 'Ensure target device/simulator is booted and ready',
helpDescription:
'Ensure local target device/simulator is booted and ready (not for remote/cloud device profiles)',
summary: 'Boot target device/simulator',
positionalArgs: [],
allowedFlags: ['headless'],
Expand All @@ -1439,7 +1444,7 @@ const COMMAND_SCHEMAS: Record<string, CommandSchema> = {
usageOverride:
'connect --remote-config <path> [--tenant <id>] [--run-id <id>] [--lease-backend <backend>] [--force] [--no-login]',
helpDescription:
'Connect to a remote daemon, authenticate when needed, and save remote session state. AGENT_DEVICE_CLOUD_BASE_URL is the bridge/control-plane API origin; use AGENT_DEVICE_DAEMON_AUTH_TOKEN=adc_live_... for CI/service-token automation.',
'Connect to a remote daemon, authenticate when needed, and save remote session state. AGENT_DEVICE_CLOUD_BASE_URL is the cloud/control-plane API origin; use AGENT_DEVICE_DAEMON_AUTH_TOKEN=adc_live_... for CI/service-token automation.',
summary: 'Connect to remote daemon',
positionalArgs: [],
allowedFlags: [
Expand Down Expand Up @@ -1542,7 +1547,8 @@ const COMMAND_SCHEMAS: Record<string, CommandSchema> = {
allowedFlags: [...SNAPSHOT_FLAGS, 'baseline', 'threshold', 'out', 'overlayRefs'],
},
'ensure-simulator': {
helpDescription: 'Ensure an iOS simulator exists in a device set (create if missing)',
helpDescription:
'Ensure a local iOS simulator exists in a device set (create if missing; not for remote/cloud device profiles)',
summary: 'Ensure iOS simulator exists',
positionalArgs: [],
allowedFlags: ['runtime', 'boot', 'reuseExisting'],
Expand Down Expand Up @@ -1606,7 +1612,8 @@ const COMMAND_SCHEMAS: Record<string, CommandSchema> = {
},
keyboard: {
usageOverride: 'keyboard [status|get|dismiss]',
helpDescription: 'Inspect Android keyboard visibility/type or dismiss the device keyboard',
helpDescription:
'Inspect Android keyboard visibility/type or dismiss the device keyboard (runner-backed paths may be unavailable for remote devices)',
summary: 'Inspect or dismiss the device keyboard',
positionalArgs: ['action?'],
allowedFlags: [],
Expand All @@ -1622,7 +1629,7 @@ const COMMAND_SCHEMAS: Record<string, CommandSchema> = {
usageOverride: 'react-devtools [...args]',
listUsageOverride: 'react-devtools [...args]',
helpDescription:
'Run pinned agent-react-devtools commands for React Native performance profiling, component trees, props/state/hooks, and render analysis',
'Run pinned agent-react-devtools commands for React Native performance profiling, component trees, props/state/hooks, and render analysis (remote Android supported when the profile includes DevTools tunnel settings; remote iOS unavailable in the current cloud service)',
summary: 'Profile React Native performance and component renders',
positionalArgs: ['args?'],
allowsExtraPositionals: true,
Expand Down Expand Up @@ -1665,7 +1672,8 @@ const COMMAND_SCHEMAS: Record<string, CommandSchema> = {
},
alert: {
usageOverride: 'alert [get|accept|dismiss|wait] [timeout]',
helpDescription: 'Inspect or handle alert (iOS simulator and macOS desktop)',
helpDescription:
'Inspect or handle runner-backed alerts (iOS simulator and macOS desktop; may be unavailable for remote devices)',
summary: 'Inspect or handle iOS/macOS alerts',
positionalArgs: ['action?', 'timeout?'],
allowedFlags: [],
Expand Down
Loading
Loading