Skip to content

Commit 4ddd29c

Browse files
authored
feat: add Metro reload command (#440)
1 parent 6c7e832 commit 4ddd29c

19 files changed

Lines changed: 415 additions & 76 deletions

skills/agent-device/SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Use this skill as a router with mandatory defaults. Read this file first. For no
1616
- Prefer `diff snapshot` after a nearby mutation when you only need to know what changed.
1717
- Avoid speculative mutations. You may take the smallest reversible UI action needed to unblock inspection or complete the requested task, such as dismissing a popup, closing an alert, or clearing an unintended surface.
1818
- In React Native dev or debug builds, check early for visible warning or error overlays, tooltips, and toasts that can steal focus or intercept taps. If they are not part of the requested behavior, dismiss them and continue. If you saw them, report them in the final summary.
19+
- In Metro-backed React Native dev loops, use `agent-device metro reload` for a JS app reload before falling back to `open <app> --relaunch`. It mirrors pressing `r` in the Metro terminal and preserves the native app process.
1920
- Do not browse the web or use external sources unless the user explicitly asks.
2021
- Re-snapshot after meaningful UI changes instead of reusing stale refs.
2122
- Treat refs in default snapshot output as actionable-now, not durable identities. If a target appears only in an off-screen summary, use `scroll <direction>` and re-snapshot until the target is visible.
@@ -60,6 +61,7 @@ Use this skill as a router with mandatory defaults. Read this file first. For no
6061
- If there is no simulator, no app install, or no open app session yet, switch to `bootstrap-install.md` instead of improvising setup steps.
6162
- Use the smallest unblock action first when transient UI blocks inspection, but do not navigate, search, or enter new text just to make the UI reveal data unless the user asked for that interaction.
6263
- In React Native dev or debug apps, treat visible warning or error overlays as transient blockers unless the user is explicitly asking you to diagnose them. Dismiss them when safe, then continue the requested flow.
64+
- For React Native code changes where the app is already connected to Metro, prefer `agent-device metro reload`, then wait and re-snapshot. Use `open <app> --relaunch` only when Metro reload does not reconnect or native startup state must reset.
6365
- Do not use external lookups to compensate for missing on-screen data unless the user asked for them.
6466
- If the needed information is not exposed on screen, say that plainly instead of compensating with extra navigation, text entry, or web search.
6567
- Prefer `@ref` or selector targeting over raw coordinates.

skills/agent-device/references/bootstrap-install.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ agent-device --session auth snapshot -i
115115
- Use semantic session names when you need multiple concurrent runs.
116116
- Use `--save-script=<path>` on `close` when you want to keep a replay script.
117117
- For dev loops where state can linger, prefer `open <app> --relaunch`.
118+
- For Metro-backed React Native JS changes with the app already running, prefer `metro reload` instead of `open <app> --relaunch`; it asks Metro to reload connected apps without restarting the native process.
118119
- In iOS sessions, use `open <app>` for the app itself. Use `open <url>` for deep links, and `open <app> <url>` when you need to launch the app and deep link in one step.
119120
- On iOS, `appstate` is session-scoped and requires the matching active session on the target device.
120121

skills/agent-device/references/exploration.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Open this file when the app or screen is already running and you need to discove
2121
- User asks for exact text from a known target: `get text`
2222
- User asks you to tap, type, or choose an element: `snapshot -i`, then act
2323
- User asks for the React Native component tree, props/state/hooks, or render profiling: use `agent-device react-devtools ...` and the `skills/react-devtools` workflow
24+
- User asks to reload a Metro-backed React Native app after JS changes: `agent-device metro reload`, then wait briefly and re-run `snapshot` or `snapshot -i`
2425
- React Native dev or debug build shows warning/error UI: capture enough evidence to identify it, dismiss it if it is not the requested behavior, then continue the flow and report it in the summary
2526
- The on-screen keyboard is blocking the next step: `keyboard dismiss`; on iOS do this only while an app session is active, and use `keyboard status|get` only on Android
2627
- UI does not expose the answer: say so plainly; do not browse or force the app into a new state unless asked
@@ -60,6 +61,16 @@ Open this file when the app or screen is already running and you need to discove
6061
- Blocking or recurring: switch to [debugging.md](debugging.md) and collect evidence.
6162
- Seen at any point: mention in the final summary even if dismissed.
6263

64+
**React Native Metro reload.** When a dev app is already running and connected to Metro, prefer a Metro reload over restarting the native app process:
65+
66+
```bash
67+
agent-device metro reload
68+
agent-device wait 1000
69+
agent-device snapshot -i
70+
```
71+
72+
Use `--metro-host`, `--metro-port`, or `--bundle-url` only when the active connection does not already carry the right runtime hints. Fall back to `open <app> --relaunch` when the app is not connected to Metro, Metro reload fails, or native startup state needs a clean process.
73+
6374
## Common example loops
6475

6576
These are examples, not required exact sequences. Adapt them to the app, state, and task at hand.

skills/agent-device/references/remote-tenancy.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Open this file for remote daemon HTTP flows that let an agent running in a Linux
99
- `agent-device connect --remote-config <path>`
1010
- `agent-device install-from-source <url> --remote-config <path> --platform android`
1111
- `agent-device open <package> --remote-config <path> --relaunch`
12+
- `agent-device metro reload --remote-config <path>`
1213
- `agent-device snapshot --remote-config <path> -i`
1314
- `agent-device disconnect --remote-config <path>`
1415
- `agent-device connection status`
@@ -36,6 +37,7 @@ agent-device connect --remote-config ./remote-config.json
3637
ARTIFACT_URL="<trusted-artifact-url>"
3738
agent-device install-from-source "$ARTIFACT_URL" --platform android
3839
agent-device open com.example.app --relaunch
40+
agent-device metro reload
3941
agent-device snapshot -i
4042
agent-device fill @e3 "test@example.com"
4143
agent-device disconnect
@@ -73,6 +75,7 @@ The first command that needs a lease or Metro runtime prepares and persists it.
7375
- `connect` stores local non-secret connection state and defers tenant lease allocation plus Metro preparation until a later command needs them.
7476
- Commands such as `install-from-source`, `open`, `snapshot`, and `apps` allocate or refresh the lease when needed.
7577
- `open` prepares Metro runtime hints when the remote profile has Metro fields and no compatible runtime is already saved.
78+
- `metro reload` reuses saved Metro runtime hints and asks Metro to reload connected React Native apps without restarting the native process.
7679
- `batch` also prepares Metro when any step opens an app and that step does not provide its own runtime.
7780
- `disconnect` closes the session when possible, stops the Metro companion owned by the connection, releases the lease when one was allocated, and removes local connection state.
7881

src/__tests__/cli-client-commands.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
AppInstallFromSourceOptions,
1111
AppOpenOptions,
1212
MetroPrepareOptions,
13+
MetroReloadOptions,
1314
} from '../client.ts';
1415
import { AppError } from '../utils/errors.ts';
1516
import { resolveCliOptions } from '../utils/cli-options.ts';
@@ -195,6 +196,50 @@ test('metro prepare rejects when no public or proxy base URL is provided', async
195196
);
196197
});
197198

199+
test('metro reload forwards host, port, bundle URL, and timeout to client.metro.reload', async () => {
200+
let observed: MetroReloadOptions | undefined;
201+
const client = createStubClient({
202+
installFromSource: async () => {
203+
throw new Error('unexpected install call');
204+
},
205+
reloadMetro: async (options) => {
206+
observed = options;
207+
return {
208+
reloaded: true,
209+
reloadUrl: 'http://127.0.0.1:9090/reload',
210+
status: 200,
211+
body: 'OK',
212+
};
213+
},
214+
});
215+
216+
const stdout = await captureStdout(async () => {
217+
const handled = await tryRunClientBackedCommand({
218+
command: 'metro',
219+
positionals: ['reload'],
220+
flags: {
221+
json: false,
222+
help: false,
223+
version: false,
224+
metroHost: '127.0.0.1',
225+
metroPort: 9090,
226+
bundleUrl: 'http://127.0.0.1:9090/index.bundle?platform=ios',
227+
metroProbeTimeoutMs: 1500,
228+
},
229+
client,
230+
});
231+
assert.equal(handled, true);
232+
});
233+
234+
assert.deepEqual(observed, {
235+
metroHost: '127.0.0.1',
236+
metroPort: 9090,
237+
bundleUrl: 'http://127.0.0.1:9090/index.bundle?platform=ios',
238+
timeoutMs: 1500,
239+
});
240+
assert.equal(stdout, 'Reloaded React Native apps via http://127.0.0.1:9090/reload\n');
241+
});
242+
198243
test('screenshot forwards --overlay-refs to the client capture API', async () => {
199244
let observed:
200245
| {
@@ -578,6 +623,7 @@ async function captureStdout(run: () => Promise<void>): Promise<string> {
578623
function createStubClient(params: {
579624
installFromSource: AgentDeviceClient['apps']['installFromSource'];
580625
prepareMetro?: AgentDeviceClient['metro']['prepare'];
626+
reloadMetro?: AgentDeviceClient['metro']['reload'];
581627
open?: AgentDeviceClient['apps']['open'];
582628
screenshot?: AgentDeviceClient['capture']['screenshot'];
583629
}): AgentDeviceClient {
@@ -683,6 +729,14 @@ function createStubClient(params: {
683729
},
684730
bridge: null,
685731
})),
732+
reload:
733+
params.reloadMetro ??
734+
(async () => ({
735+
reloaded: true,
736+
reloadUrl: 'http://127.0.0.1:8081/reload',
737+
status: 200,
738+
body: 'OK',
739+
})),
686740
},
687741
capture: {
688742
snapshot: async () => ({

src/__tests__/client-metro.test.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { Socket } from 'node:net';
88
import net from 'node:net';
99
import os from 'node:os';
1010
import path from 'node:path';
11-
import { prepareMetroRuntime } from '../client-metro.ts';
11+
import { prepareMetroRuntime, reloadMetro } from '../client-metro.ts';
1212
import { AppError } from '../utils/errors.ts';
1313

1414
const TEST_TOKEN = 'agent-device-proxy-test-token';
@@ -199,6 +199,65 @@ test('prepareMetroRuntime rejects incomplete proxy configuration', async () => {
199199
);
200200
});
201201

202+
test('reloadMetro preserves the bundle URL route prefix', async () => {
203+
const requests: string[] = [];
204+
const server = createServer((req, res) => {
205+
requests.push(req.url ?? '');
206+
if (req.url === '/metro/runtime-1/reload') {
207+
res.statusCode = 200;
208+
res.end('OK');
209+
return;
210+
}
211+
res.statusCode = 404;
212+
res.end('not found');
213+
});
214+
server.listen(0, '127.0.0.1');
215+
await once(server, 'listening');
216+
const address = server.address();
217+
assert.ok(address && typeof address !== 'string');
218+
219+
try {
220+
const result = await reloadMetro({
221+
bundleUrl: `http://127.0.0.1:${address.port}/metro/runtime-1/index.bundle?platform=ios&dev=true`,
222+
timeoutMs: 1_000,
223+
});
224+
225+
assert.deepEqual(requests, ['/metro/runtime-1/reload']);
226+
assert.deepEqual(result, {
227+
reloaded: true,
228+
reloadUrl: `http://127.0.0.1:${address.port}/metro/runtime-1/reload`,
229+
status: 200,
230+
body: 'OK',
231+
});
232+
} finally {
233+
await closeServer(server);
234+
}
235+
});
236+
237+
test('reloadMetro defaults to local Metro host and port', async () => {
238+
const server = createServer((req, res) => {
239+
if (req.url === '/reload') {
240+
res.statusCode = 200;
241+
res.end('OK');
242+
return;
243+
}
244+
res.statusCode = 404;
245+
res.end('not found');
246+
});
247+
server.listen(0);
248+
await once(server, 'listening');
249+
const address = server.address();
250+
assert.ok(address && typeof address !== 'string');
251+
252+
try {
253+
const result = await reloadMetro({ metroPort: address.port, timeoutMs: 1_000 });
254+
assert.equal(result.reloadUrl, `http://localhost:${address.port}/reload`);
255+
assert.equal(result.body, 'OK');
256+
} finally {
257+
await closeServer(server);
258+
}
259+
});
260+
202261
function writeFakeNpx(binDir: string): void {
203262
const filePath = path.join(binDir, 'npx');
204263
writeFileSync(

src/__tests__/metro-public.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ vi.mock('../client-metro.ts', async () => {
66
return {
77
...actual,
88
prepareMetroRuntime: vi.fn(),
9+
reloadMetro: vi.fn(),
910
};
1011
});
1112

@@ -14,13 +15,14 @@ vi.mock('../client-metro-companion.ts', () => ({
1415
stopMetroCompanion: vi.fn(),
1516
}));
1617

17-
import { prepareMetroRuntime } from '../client-metro.ts';
18+
import { prepareMetroRuntime, reloadMetro } from '../client-metro.ts';
1819
import { ensureMetroCompanion, stopMetroCompanion } from '../client-metro-companion.ts';
1920
import {
2021
buildAndroidRuntimeHints,
2122
buildIosRuntimeHints,
2223
ensureMetroTunnel,
2324
prepareRemoteMetro,
25+
reloadRemoteMetro,
2426
resolveRuntimeTransport,
2527
stopMetroTunnel,
2628
} from '../metro.ts';
@@ -65,6 +67,12 @@ test('public metro helpers expose stable Node-facing wrappers', async () => {
6567
stopped: true,
6668
statePath: '/tmp/project/.agent-device/metro-companion.json',
6769
});
70+
vi.mocked(reloadMetro).mockResolvedValue({
71+
reloaded: true,
72+
reloadUrl: 'http://127.0.0.1:8081/reload',
73+
status: 200,
74+
body: 'OK',
75+
});
6876

6977
const prepared = await prepareRemoteMetro({
7078
projectRoot: '/tmp/project',
@@ -87,11 +95,15 @@ test('public metro helpers expose stable Node-facing wrappers', async () => {
8795
await stopMetroTunnel({
8896
projectRoot: '/tmp/project',
8997
});
98+
const reloaded = await reloadRemoteMetro({
99+
runtime: { platform: 'ios', bundleUrl: 'http://127.0.0.1:8081/index.bundle?platform=ios' },
100+
});
90101

91102
assert.equal(prepared.reused, true);
92103
assert.equal(prepared.logPath, '/tmp/project/.agent-device/metro.log');
93104
assert.equal(tunnel.started, true);
94105
assert.equal(tunnel.logPath, '/tmp/project/.agent-device/metro-companion.log');
106+
assert.equal(reloaded.reloaded, true);
95107
assert.deepEqual(vi.mocked(prepareMetroRuntime).mock.calls[0]?.[0], {
96108
projectRoot: '/tmp/project',
97109
kind: 'react-native',
@@ -113,6 +125,9 @@ test('public metro helpers expose stable Node-facing wrappers', async () => {
113125
logPath: undefined,
114126
env: undefined,
115127
});
128+
assert.deepEqual(vi.mocked(reloadMetro).mock.calls[0]?.[0], {
129+
runtime: { platform: 'ios', bundleUrl: 'http://127.0.0.1:8081/index.bundle?platform=ios' },
130+
});
116131
assert.equal(
117132
buildIosRuntimeHints('https://public.example.test').bundleUrl,
118133
'https://public.example.test/index.bundle?platform=ios&dev=true&minify=false',

src/cli/commands/metro.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,18 @@ import type { ClientCommandHandler } from './router-types.ts';
44

55
export const metroCommand: ClientCommandHandler = async ({ positionals, flags, client }) => {
66
const action = (positionals[0] ?? '').toLowerCase();
7+
if (action === 'reload') {
8+
const result = await client.metro.reload({
9+
metroHost: flags.metroHost,
10+
metroPort: flags.metroPort,
11+
bundleUrl: flags.bundleUrl,
12+
timeoutMs: flags.metroProbeTimeoutMs,
13+
});
14+
writeCommandOutput(flags, result, () => `Reloaded React Native apps via ${result.reloadUrl}`);
15+
return true;
16+
}
717
if (action !== 'prepare') {
8-
throw new AppError('INVALID_ARGS', 'metro only supports prepare');
18+
throw new AppError('INVALID_ARGS', 'metro requires a subcommand: prepare or reload');
919
}
1020
if (!flags.metroPublicBaseUrl && !flags.metroProxyBaseUrl) {
1121
throw new AppError(

0 commit comments

Comments
 (0)