Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
31ad3b8
feat(react-devtools): profile React renders through the Metro inspector
thymikee Oct 14, 2026
b38b207
fix(react-devtools): fail closed across devices, drop test seams, and…
thymikee Oct 14, 2026
65d61da
refactor(react-devtools): make react-devtools the built-in performanc…
thymikee Oct 14, 2026
87a657a
fix(react-devtools): list selectable device keys, cost each mount gro…
thymikee Oct 14, 2026
2ba6466
feat(react-devtools): drill into the saved React profile by component…
thymikee Oct 14, 2026
1d71c2d
fix(react-devtools): keep unnamed, Animated, and memo variants apart,…
thymikee Oct 14, 2026
d2f6d74
fix(react-devtools): report every component variant of a name, and sh…
thymikee Oct 14, 2026
f5100a8
refactor(react-devtools): save the lossless recording, keep component…
thymikee Oct 15, 2026
72dd2ad
feat(react-devtools): inspect React components over the profiler's co…
thymikee Oct 15, 2026
aa72d1a
fix(react-devtools): print capture notes when the profile recorded no…
thymikee Oct 15, 2026
e6d3f55
fix(react-devtools): address review on capture, slow-commit accountin…
thymikee Oct 15, 2026
c6be9d6
fix(react-devtools): reach the debugger through the listing server, p…
thymikee Oct 15, 2026
65bd452
fix(react-devtools): keep raw names for profile identity and stay on …
thymikee Oct 15, 2026
7da1ac0
fix(react-devtools): read the operations stream of React DevTools 7 a…
thymikee Oct 15, 2026
af282ef
test(react-devtools): import temp dirs from the test-kit entry
thymikee Oct 15, 2026
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ See the [Node.js API](https://oss.callstack.com/agent-device/docs/client-api), t

## What agents can do

- **Inspect app state** through accessibility snapshots, refs, selectors, and React Native component trees.
- **Inspect app state** through accessibility snapshots, refs, and selectors, and profile React Native renders to find what makes an interaction slow.
- **Act on visible UI** by tapping or pressing elements, filling fields, scrolling, making gestures, waiting, asserting state, and handling alerts.
- **Diagnose failures** with screenshots, video, logs, traces, network data, performance samples, crash details, and React profiles.
- **Repeat workflows** by saving working steps as `.ad` scripts for local use or CI. Export strict Maestro YAML when needed.
Expand Down
4 changes: 2 additions & 2 deletions docs/adr/0029-daemon-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,13 @@ Accepted (2026-09-29).
- public commands, by their own name;
- internal commands with platform execution, by the public command their
`catalog.servesPublicCommand` names (`install_source` as `install-from-source`), or else by
their own name (`runtime`, which `react-devtools` and Maestro flows send);
their own name (`runtime`, which Maestro flows send);
Comment thread
thymikee marked this conversation as resolved.
- internal commands with no platform execution (leases, `human_control`, session bookkeeping) are
protocol plumbing that no rule decides;
- any other name is decided by the rules, so an allow list fails closed for it.

Names are checked against that vocabulary at load; a local-cli name such as `react-devtools` is
refused with a pointer to the daemon command it sends. With `allow`, commands added by a later
refused, because it runs in the client. With `allow`, commands added by a later
upgrade are denied by default.
- `capabilities.deny` — operations denied whichever command reaches them. `device-shutdown` is the
only capability today.
Expand Down
23 changes: 1 addition & 22 deletions packages/command-registry/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,19 +150,6 @@ export type DescriptorSessionRouteCommandName =
: never
: never;

/**
* The literal union of every command the daemon routes. A command that never reaches a
* daemon route cannot be the target of a request, so this is the type a caller building
* one must name — an unregistered or CLI-only target is a compile error rather than a
* string the receiver rejects at runtime.
*/
export type DescriptorDaemonRouteCommandName =
Extract<(typeof commandDescriptors)[number], { daemon: object }> extends infer Descriptor
? Descriptor extends { name: infer Name extends string }
? Name
: never
: never;

// ---------------------------------------------------------------------------
// Daemon request-policy trait bundles — copied VERBATIM from
// packages/daemon/src/core/daemon-command-registry.ts (DAEMON_COMMAND_DESCRIPTORS).
Expand Down Expand Up @@ -1814,13 +1801,6 @@ export const RAW_COMMAND_DESCRIPTORS = [
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
batchable: false,
mcpExposed: false,
// Wave 6 residue: `react-devtools start` on a Limrun Android instance dispatches internal
// `runtime port-reverse` through the CLI-injected dispatch seam. `runtime` is itself fully
// migrated (`device-runtime`, R31), so the dispatched execution is accounted for under its
// own descriptor rather than hidden behind this one — this route owns no unmigrated platform
// execution of its own. The CLI-route dominance gate
// (`__tests__/platform-execution-cli-route.test.ts`) still rejects a `none` route whose
// CLI-injected dispatch target is still `legacy`.
platformExecution: NO_PLATFORM_EXECUTION,
},
{
Expand Down Expand Up @@ -1921,8 +1901,7 @@ type Equal<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
* Compile-time literalness of {@link Command}. The `as const` on {@link RAW_COMMAND_DESCRIPTORS}
* keeps each descriptor's literal `name` through the composition and the `.map` above; an array
* that lost it would widen this union to `string`, and nothing downstream would say so:
* `COMMAND_DEFAULTS` below and `Record<DescriptorCliCommandName, …>` in
* `src/cli/injected-daemon-dispatch.ts` stop rejecting an unknown key, and the `command: Command`
* `COMMAND_DEFAULTS` below stops rejecting an unknown key, and the `command: Command`
* parameters in `src/cli/command-explain.ts` and `owner-files.ts` would stop excluding a name no
* descriptor declares. That silence is why this is a type guard and not a value comparison.
*/
Expand Down
2 changes: 1 addition & 1 deletion skills/android-emulator/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,6 @@ Only when the task is specialized or a command shape is unclear, read the releva
agent-device help validate # engineering validation and build freshness
agent-device help debugging # screenshots, logs, traces, video, and failures
agent-device help react-native # React Native and Expo runtime guidance
agent-device help react-devtools # component tree, props/state/hooks, and renders
agent-device help react-devtools # React components, props/state/hooks, render profiling
agent-device help scripting # durable replay and CI workflows
```
2 changes: 1 addition & 1 deletion skills/ios-simulator/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,6 @@ Only when the task is specialized or a command shape is unclear, read the releva
agent-device help validate # engineering validation and build freshness
agent-device help debugging # screenshots, logs, traces, video, and failures
agent-device help react-native # React Native and Expo runtime guidance
agent-device help react-devtools # component tree, props/state/hooks, and renders
agent-device help react-devtools # React components, props/state/hooks, render profiling
agent-device help scripting # durable replay and CI workflows
```
5 changes: 3 additions & 2 deletions src/__tests__/cli-help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,9 @@ test('help react-devtools prints agent workflow topic and skips daemon dispatch'
assert.equal(result.code, 0);
assert.equal(result.calls.length, 0);
assert.match(result.stdout, /^agent-device \S+ — react-devtools/);
assert.match(result.stdout, /React Native performance\/profiling/);
assert.match(result.stdout, /agent-device react-devtools status/);
assert.match(result.stdout, /React Native internals the accessibility tree cannot show/);
assert.match(result.stdout, /agent-device react-devtools inspect <@ref\|Name>/);
assert.match(result.stdout, /agent-device react-devtools profile start/);
});

test('help workflow prints the compact workflow card with a version header and skips daemon dispatch', async () => {
Expand Down
100 changes: 5 additions & 95 deletions src/__tests__/cli-react-devtools-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,42 +15,12 @@ import {
hashRemoteConfigFile,
writeRemoteConnectionState,
} from '../remote/remote-connection-state.ts';
import type {
DaemonRequest,
DaemonResponse,
sendToDaemon as SendToDaemon,
} from '../daemon-client/daemon-client.ts';
import type { DaemonResponse } from '../daemon-client/daemon-client.ts';

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

function createLimrunConnectionFixture(): { tempRoot: string; stateDir: string } {
const tempRoot = mkdtempForTestSync('agent-device-react-devtools-limrun-');
const stateDir = path.join(tempRoot, 'state');
const remoteConfigPath = path.join(tempRoot, 'limrun.json');
fs.writeFileSync(remoteConfigPath, JSON.stringify({ platform: 'android' }));
writeRemoteConnectionState({
stateDir,
state: {
version: 1,
session: 'limrun-android',
remoteConfigPath,
remoteConfigHash: hashRemoteConfigFile(remoteConfigPath),
daemon: {},
tenant: 'limrun',
runId: 'run-1',
leaseId: 'lease-1',
leaseBackend: 'android-instance',
leaseProvider: 'limrun',
platform: 'android',
connectedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
});
return { tempRoot, stateDir };
}

test('react-devtools uses active remote connection session after defaults are merged', async () => {
const tempRoot = mkdtempForTestSync('agent-device-react-devtools-session-');
const stateDir = path.join(tempRoot, 'state');
Expand Down Expand Up @@ -92,7 +62,9 @@ test('react-devtools uses active remote connection session after defaults are me
const sendToDaemon = async (): Promise<DaemonResponse> => ({ ok: true, data: {} });

try {
await runCli(['react-devtools', 'status', '--state-dir', stateDir], { sendToDaemon });
await runCli(['react-devtools', 'profile', 'start', '--state-dir', stateDir], {
sendToDaemon,
});
} finally {
restoreEnv();
process.exit = originalExit;
Expand All @@ -101,68 +73,6 @@ test('react-devtools uses active remote connection session after defaults are me

assert.equal(exitCode, 0);
assert.equal(vi.mocked(runReactDevtoolsCommand).mock.calls.length, 1);
assert.deepEqual(vi.mocked(runReactDevtoolsCommand).mock.calls[0]?.[0], ['profile', 'start']);
assert.equal(vi.mocked(runReactDevtoolsCommand).mock.calls[0]?.[1]?.session, 'adc-android');
});

test('react-devtools starts Limrun port reverse through the daemon', async () => {
const { tempRoot, stateDir } = createLimrunConnectionFixture();
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 requests: Array<Omit<DaemonRequest, 'token'>> = [];
const transportOptions: Array<Parameters<typeof SendToDaemon>[1]> = [];
const sendToDaemon = async (
request: Omit<DaemonRequest, 'token'>,
options?: Parameters<typeof SendToDaemon>[1],
): Promise<DaemonResponse> => {
requests.push(request);
transportOptions.push(options);
return { ok: true, data: {} };
};
vi.mocked(runReactDevtoolsCommand).mockImplementationOnce(async (_args, options) => {
assert.ok(options?.configureDirectPortReverse);
await options.configureDirectPortReverse();
return 0;
});

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

assert.equal(exitCode, 0);
assert.equal(requests.length, 1);
const request = requests[0];
assert.ok(request);
assert.equal(request.command, 'runtime');
assert.deepEqual(request.positionals, ['port-reverse']);
assert.equal(request.session, 'limrun-android');
const flags = request.flags;
assert.ok(flags);
assert.equal(flags.platform, 'android');
assert.equal(flags.leaseId, 'lease-1');
assert.equal(flags.leaseProvider, 'limrun');
assert.equal(flags.devicePort, 8097);
assert.equal(flags.hostPort, 8097);
assert.equal(flags.portReverseName, 'react-devtools');
assert.equal(Object.hasOwn(flags, 'daemonAuthToken'), false);
assert.deepEqual(transportOptions, [{ authToken: 'synthetic-direct-token' }]);
const commandOptions = vi.mocked(runReactDevtoolsCommand).mock.calls[0]?.[1];
assert.equal(Object.hasOwn(commandOptions?.flags ?? {}, 'daemonAuthToken'), false);
});
Loading
Loading