From 32a6c3847198180acea6f252aef891ccbf4ce637 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 7 Jul 2026 14:33:19 +0200 Subject: [PATCH] feat: support repack dev server prepare --- src/__tests__/cli-agent-cdp.test.ts | 6 +- .../client-metro-startup-cleanup.test.ts | 2 +- src/__tests__/client-metro.test.ts | 70 ++++++++++++++ src/__tests__/remote-config-public.test.ts | 2 + src/__tests__/remote-connection.test.ts | 8 +- src/cli/commands/agent-cdp.ts | 4 +- src/cli/commands/connection-runtime.ts | 10 +- .../__tests__/args-parse-session.test.ts | 4 +- .../__tests__/cli-help-command-usage.test.ts | 2 +- .../parser/__tests__/cli-help-topics.test.ts | 11 ++- src/cli/parser/cli-flags.ts | 21 +++-- src/cli/parser/cli-help.ts | 28 +++--- src/commands/management/doctor.ts | 2 +- src/commands/metro/index.test.ts | 6 +- src/commands/metro/index.ts | 16 +--- src/commands/metro/prepare-kind.ts | 20 ++++ .../__tests__/session-doctor-metro.test.ts | 5 +- src/daemon/handlers/session-doctor-metro.ts | 12 +-- src/daemon/handlers/session-doctor-types.ts | 2 +- src/metro/client-metro.ts | 94 ++++++++++++++++--- src/remote/remote-config-schema.ts | 2 +- src/utils/project-runtime.ts | 3 +- 22 files changed, 242 insertions(+), 88 deletions(-) create mode 100644 src/commands/metro/prepare-kind.ts diff --git a/src/__tests__/cli-agent-cdp.test.ts b/src/__tests__/cli-agent-cdp.test.ts index 1fbb1ece3..45cc29fa8 100644 --- a/src/__tests__/cli-agent-cdp.test.ts +++ b/src/__tests__/cli-agent-cdp.test.ts @@ -92,7 +92,7 @@ test('cdp wrapper streams through npm exec and returns downstream exit code', as assert.equal(vi.mocked(runCmdStreaming).mock.calls[0]?.[2]?.allowFailure, true); }); -test('cdp injects remote Metro public url for target discovery', async () => { +test('cdp injects remote React Native dev-server public url for target discovery', async () => { const args = buildAgentCdpPassthroughArgs(['target', 'list'], { flags: { leaseBackend: 'android-instance', @@ -133,7 +133,7 @@ test('cdp preserves explicit target url for remote sessions', () => { ]); }); -test('cdp rejects remote bridge target discovery without Metro public url', () => { +test('cdp rejects remote bridge target discovery without React Native dev-server public url', () => { assert.throws( () => buildAgentCdpPassthroughArgs(['target', 'list'], { @@ -147,7 +147,7 @@ test('cdp rejects remote bridge target discovery without Metro public url', () = 'https://bridge.example.test/api/metro/runtimes/runtime-1/index.bundle?platform=android&dev=true', }, }), - /cdp remote bridge target discovery requires a Metro public base URL/, + /cdp remote bridge target discovery requires a React Native dev-server public base URL/, ); }); diff --git a/src/__tests__/client-metro-startup-cleanup.test.ts b/src/__tests__/client-metro-startup-cleanup.test.ts index f59614760..a628a8aa0 100644 --- a/src/__tests__/client-metro-startup-cleanup.test.ts +++ b/src/__tests__/client-metro-startup-cleanup.test.ts @@ -64,7 +64,7 @@ test('prepareMetroRuntime stops a spawned Metro process when startup readiness t const expectedFailure = assert.rejects( preparePromise, - /Metro did not become ready at http:\/\/127\.0\.0\.1:8081\/status within 30000ms/, + /React Native dev server did not become ready at http:\/\/127\.0\.0\.1:8081\/status within 30000ms/, ); await vi.advanceTimersByTimeAsync(30_000); await expectedFailure; diff --git a/src/__tests__/client-metro.test.ts b/src/__tests__/client-metro.test.ts index 122068e4f..3c99f5fc6 100644 --- a/src/__tests__/client-metro.test.ts +++ b/src/__tests__/client-metro.test.ts @@ -169,6 +169,72 @@ test('prepareMetroRuntime starts Metro, bridges through proxy, and writes runtim } }); +for (const { configFileName, commandName } of [ + { configFileName: 'rspack.config.ts', commandName: 'rspack-start' }, + { configFileName: 'webpack.config.js', commandName: 'webpack-start' }, +]) { + test(`prepareMetroRuntime starts Re.Pack with ${commandName} for ${configFileName}`, async () => { + const tempRoot = path.join(os.tmpdir(), `agent-device-repack-${randomUUID()}`); + const projectRoot = path.join(tempRoot, 'project'); + const binDir = path.join(tempRoot, 'bin'); + const argsFile = path.join(tempRoot, 'npx-args.json'); + const metroPort = await findFreePort(); + + mkdirSync(projectRoot, { recursive: true }); + mkdirSync(binDir, { recursive: true }); + writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ + name: 'repack-runtime-test', + private: true, + dependencies: { + 'react-native': '0.0.0-test', + }, + devDependencies: { + '@callstack/repack': '5.2.5', + }, + }), + ); + writeFileSync(path.join(projectRoot, configFileName), 'module.exports = {};\n'); + writeFakeNpx(binDir); + + let pid = 0; + try { + const result = await prepareMetroRuntime({ + projectRoot, + publicBaseUrl: `http://127.0.0.1:${metroPort}`, + metroPort, + reuseExisting: false, + installDependenciesIfNeeded: false, + env: { + ...process.env, + AGENT_DEVICE_TEST_NPX_ARGS_FILE: argsFile, + PATH: `${binDir}:${process.env.PATH || ''}`, + }, + }); + + pid = result.pid; + assert.equal(result.kind, 'repack'); + assert.equal(result.started, true); + assert.deepEqual(JSON.parse(readFileSync(argsFile, 'utf8')), [ + 'react-native', + commandName, + '--host', + '0.0.0.0', + '--port', + String(metroPort), + ]); + assert.equal( + result.iosRuntime.bundleUrl, + `http://127.0.0.1:${metroPort}/index.bundle?platform=ios&dev=true&minify=false`, + ); + } finally { + await stopProcess(pid); + rmSync(tempRoot, { recursive: true, force: true }); + } + }); +} + test('prepareMetroRuntime rejects incomplete proxy configuration', async () => { await assert.rejects( () => @@ -304,8 +370,12 @@ function writeFakeNpx(binDir: string): void { writeFileSync( filePath, `#!/usr/bin/env node +const fs = require("node:fs") const http = require("node:http") const args = process.argv.slice(2) +if (process.env.AGENT_DEVICE_TEST_NPX_ARGS_FILE) { + fs.writeFileSync(process.env.AGENT_DEVICE_TEST_NPX_ARGS_FILE, JSON.stringify(args)) +} const portIndex = args.indexOf("--port") const hostIndex = args.indexOf("--host") const port = portIndex === -1 ? 8081 : Number(args[portIndex + 1] || "8081") diff --git a/src/__tests__/remote-config-public.test.ts b/src/__tests__/remote-config-public.test.ts index 3c066190b..39f82f6f2 100644 --- a/src/__tests__/remote-config-public.test.ts +++ b/src/__tests__/remote-config-public.test.ts @@ -19,6 +19,7 @@ test('public remote-config helpers resolve file paths and merged profiles', () = JSON.stringify({ platform: 'ios', metroProjectRoot: '../project', + metroKind: 'repack', metroPublicBaseUrl: 'https://public.example.test', }), 'utf8', @@ -35,6 +36,7 @@ test('public remote-config helpers resolve file paths and merged profiles', () = profile: { platform: 'ios', metroProjectRoot: projectRoot, + metroKind: 'repack', metroPublicBaseUrl: 'https://public.example.test', metroPreparePort: 9090, }, diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index c9266b5df..e587ea2ea 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -58,7 +58,7 @@ test('deferred Metro config ignores perf-style kind values', () => { json: true, help: false, version: false, - metroKind: 'expo', + metroKind: 'repack', }), true, ); @@ -1119,7 +1119,7 @@ test('deferred materialization re-prepares runtime when explicit Metro overrides session: 'adc-android', platform: 'android', metroProjectRoot: '/tmp/project-new', - metroKind: 'expo', + metroKind: 'repack', metroPublicBaseUrl: 'https://sandbox.example.test', metroProxyBaseUrl: 'https://proxy.example.test', launchUrl: 'myapp://open', @@ -1129,7 +1129,7 @@ test('deferred materialization re-prepares runtime when explicit Metro overrides prepareRequest = options; return { projectRoot: '/tmp/project-new', - kind: 'expo', + kind: 'repack', dependenciesInstalled: false, packageManager: null, started: false, @@ -1151,7 +1151,7 @@ test('deferred materialization re-prepares runtime when explicit Metro overrides }); assert.equal(prepareRequest?.projectRoot, '/tmp/project-new'); - assert.equal(prepareRequest?.kind, 'expo'); + assert.equal(prepareRequest?.kind, 'repack'); assert.equal(prepareRequest?.publicBaseUrl, 'https://sandbox.example.test'); assert.equal(prepareRequest?.proxyBaseUrl, 'https://proxy.example.test'); assert.equal(prepareRequest?.launchUrl, 'myapp://open'); diff --git a/src/cli/commands/agent-cdp.ts b/src/cli/commands/agent-cdp.ts index 600d0c189..b3a784140 100644 --- a/src/cli/commands/agent-cdp.ts +++ b/src/cli/commands/agent-cdp.ts @@ -69,9 +69,9 @@ export function buildAgentCdpPassthroughArgs( if (!cdpUrl) { throw new AppError( 'INVALID_ARGS', - 'cdp remote bridge target discovery requires a Metro public base URL.', + 'cdp remote bridge target discovery requires a React Native dev-server public base URL.', { - hint: 'Include metroPublicBaseUrl in the remote config so cdp can reach the local or tunneled Metro CDP endpoint without bridge proxy authentication.', + hint: 'Include metroPublicBaseUrl in the remote config so cdp can reach the local or tunneled Metro/Re.Pack CDP endpoint without bridge proxy authentication.', }, ); } diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 0d5318991..64cace126 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -27,8 +27,8 @@ import type { LeaseBackend, SessionRuntimeHints } from '../../kernel/contracts.t import type { CliFlags } from '../parser/cli-flags.ts'; import type { AgentDeviceClient, Lease } from '../../client/client.ts'; import type { CloudProviderSessionResult } from '../../cloud-artifacts.ts'; -import type { MetroPrepareKind } from '../../metro/client-metro.ts'; import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import { readMetroPrepareKind } from '../../commands/metro/prepare-kind.ts'; import { connectionProviderRequiresRemoteDaemon } from '../connection/provider-policy.ts'; import { isCloudWebDriverProviderName } from '../../cloud-webdriver/providers.ts'; @@ -423,7 +423,7 @@ async function prepareConnectedMetro( } const prepared = await client.metro.prepare({ projectRoot: flags.metroProjectRoot, - kind: readDeferredMetroKind(flags.metroKind), + kind: readMetroPrepareKind(flags.metroKind), publicBaseUrl: flags.metroPublicBaseUrl, proxyBaseUrl: flags.metroProxyBaseUrl, bearerToken: flags.metroBearerToken, @@ -587,12 +587,6 @@ function isRuntimeCompatibleWithPlatform( return runtime.platform === platform; } -function readDeferredMetroKind(value: string | undefined): MetroPrepareKind | undefined { - if (value === undefined) return undefined; - if (value === 'auto' || value === 'react-native' || value === 'expo') return value; - throw new AppError('INVALID_ARGS', 'metro prepare --kind must be auto, react-native, or expo'); -} - function isSameMetroCleanup( left: RemoteConnectionState['metro'] | undefined, right: RemoteConnectionState['metro'] | undefined, diff --git a/src/cli/parser/__tests__/args-parse-session.test.ts b/src/cli/parser/__tests__/args-parse-session.test.ts index 856714d9b..50fda9ea1 100644 --- a/src/cli/parser/__tests__/args-parse-session.test.ts +++ b/src/cli/parser/__tests__/args-parse-session.test.ts @@ -508,7 +508,7 @@ test('parseArgs accepts metro prepare arguments', () => { '--port', '9090', '--kind', - 'expo', + 'repack', '--runtime-file', './.agent-device/metro-runtime.json', '--no-reuse-existing', @@ -524,7 +524,7 @@ test('parseArgs accepts metro prepare arguments', () => { assert.equal(parsed.flags.metroProxyBaseUrl, 'https://proxy.example.test'); assert.equal(parsed.flags.metroBearerToken, 'secret'); assert.equal(parsed.flags.metroPreparePort, 9090); - assert.equal(parsed.flags.kind, 'expo'); + assert.equal(parsed.flags.kind, 'repack'); assert.equal(parsed.flags.metroRuntimeFile, './.agent-device/metro-runtime.json'); assert.equal(parsed.flags.metroNoReuseExisting, true); assert.equal(parsed.flags.metroNoInstallDeps, true); diff --git a/src/cli/parser/__tests__/cli-help-command-usage.test.ts b/src/cli/parser/__tests__/cli-help-command-usage.test.ts index 355a12cf8..1de5c68e1 100644 --- a/src/cli/parser/__tests__/cli-help-command-usage.test.ts +++ b/src/cli/parser/__tests__/cli-help-command-usage.test.ts @@ -307,7 +307,7 @@ test('command usage shows record touch-overlay opt-out flag', async () => { test('command usage keeps detailed descriptions', async () => { const help = await usageForCommand('metro'); if (help === null) throw new Error('Expected command help text'); - assert.match(help, /Prepare a local Metro runtime or ask Metro to reload/); + assert.match(help, /Prepare a local React Native dev-server runtime/); assert.match(help, /metro reload/); assert.match(help, /--metro-host /); assert.match(help, /AGENT_DEVICE_METRO_BEARER_TOKEN/); diff --git a/src/cli/parser/__tests__/cli-help-topics.test.ts b/src/cli/parser/__tests__/cli-help-topics.test.ts index d3c302a31..660b3a025 100644 --- a/src/cli/parser/__tests__/cli-help-topics.test.ts +++ b/src/cli/parser/__tests__/cli-help-topics.test.ts @@ -11,7 +11,7 @@ test('usage includes concise top-level commands', async () => { assert.match(usageText, /prepare\s{2,}Pre-warm platform helpers/); assert.match( usageText, - /metro\s{2,}Prepare Metro reachability for React Native\/Expo apps or trigger app reloads/, + /metro\s{2,}Prepare Metro\/Re\.Pack reachability for React Native\/Expo apps or trigger app reloads/, ); assert.match(usageText, /batch --steps \| --steps-file /); assert.match(usageText, /network\s{2,}Inspect HTTP\(S\) traffic parsed from session app logs/); @@ -224,7 +224,7 @@ test('usageForCommand resolves workflow help topic', async () => { assert.match(help, /instead of exact numeric deltas/); assert.match(help, /prefer isolated gesture pan, gesture pinch, or gesture rotate/); assert.match(help, /longpress accepts coordinates, @refs, or selectors/); - assert.match(help, /use help react-native for Metro\/Fast Refresh/); + assert.match(help, /use help react-native for Metro\/Re\.Pack Fast Refresh/); assert.match(help, /iOS Allow Paste prompt cannot be exercised under XCUITest/); assert.match(help, /Empty replacement is not a supported clear-field command/); assert.match(help, /do not plan fill ""/); @@ -518,7 +518,7 @@ test('usageForCommand resolves react-native help topic', async () => { assert.match(help, /agent-device doctor --platform android --app com\.example\.app/); assert.match(help, /agent-device doctor --platform ios/); assert.match(help, /agent-device doctor --remote --remote-config \.\/remote\.json/); - assert.match(help, /same host context that owns Metro/); + assert.match(help, /same host context that owns the dev server/); assert.match(help, /sandbox probe is not authoritative/); assert.match(help, /adb reverse only affects Android device-to-host traffic/); assert.match(help, /Multiple local worktrees can reuse one native iOS simulator build/); @@ -564,7 +564,10 @@ test('usage renders concise commands inline with descriptions', async () => { assert.match(help, /Commands:[\s\S]*\n boot\s{2,}Boot target device\/simulator/); assert.match(help, /Commands:[\s\S]*\n shutdown\s{2,}Shutdown target simulator\/emulator/); assert.match(help, / prepare\s{2,}Pre-warm platform helpers/); - assert.match(help, / metro\s{2,}Prepare Metro reachability for React Native\/Expo apps/); + assert.match( + help, + / metro\s{2,}Prepare Metro\/Re\.Pack reachability for React Native\/Expo apps/, + ); assert.match(help, / perf\s{2,}Check runtime metrics, frames, memory, CPU profiles/); assert.match(help, / cdp\s{2,}Inspect React Native CDP targets, JS heap growth/); assert.match(help, / react-devtools\s{2,}Inspect React Native components, props, hooks/); diff --git a/src/cli/parser/cli-flags.ts b/src/cli/parser/cli-flags.ts index 2bccc5a49..53076cc0e 100644 --- a/src/cli/parser/cli-flags.ts +++ b/src/cli/parser/cli-flags.ts @@ -537,7 +537,7 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ key: 'kind', names: ['--kind'], type: 'enum', - enumValues: ['auto', 'react-native', 'expo', ...PERF_KIND_VALUES], + enumValues: ['auto', 'react-native', 'expo', 'repack', ...PERF_KIND_VALUES], usageLabel: '--kind ', usageDescription: 'Kind selector for commands that support it, such as metro prepare or perf artifact collectors', @@ -553,23 +553,23 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ key: 'metroKind', names: ['--metro-kind'], type: 'enum', - enumValues: ['auto', 'react-native', 'expo'], - usageLabel: '--metro-kind auto|react-native|expo', - usageDescription: 'metro prepare: detect or force the Metro launcher kind', + enumValues: ['auto', 'react-native', 'expo', 'repack'], + usageLabel: '--metro-kind auto|react-native|expo|repack', + usageDescription: 'metro prepare: detect or force the React Native dev-server launcher kind', }, { key: 'metroPublicBaseUrl', names: ['--public-base-url'], type: 'string', usageLabel: '--public-base-url ', - usageDescription: 'metro prepare: public base URL used for direct bundle hints', + usageDescription: 'metro prepare: public base URL used for direct dev-server bundle hints', }, { key: 'metroProxyBaseUrl', names: ['--proxy-base-url'], type: 'string', usageLabel: '--proxy-base-url ', - usageDescription: 'metro prepare: optional bridge origin for remote Metro access', + usageDescription: 'metro prepare: optional bridge origin for remote dev-server access', }, { key: 'metroBearerToken', @@ -586,21 +586,22 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ min: 1, max: 65535, usageLabel: '--port ', - usageDescription: 'metro prepare: local Metro port (default: 8081)', + usageDescription: 'metro prepare: local dev-server port (default: 8081)', }, { key: 'metroListenHost', names: ['--listen-host'], type: 'string', usageLabel: '--listen-host ', - usageDescription: 'metro prepare: host Metro listens on (default: 0.0.0.0)', + usageDescription: 'metro prepare: host dev server listens on (default: 0.0.0.0)', }, { key: 'metroStatusHost', names: ['--status-host'], type: 'string', usageLabel: '--status-host ', - usageDescription: 'metro prepare: host used for local /status polling (default: 127.0.0.1)', + usageDescription: + 'metro prepare: host used for local dev-server /status polling (default: 127.0.0.1)', }, { key: 'metroStartupTimeoutMs', @@ -608,7 +609,7 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ type: 'int', min: 1, usageLabel: '--startup-timeout-ms ', - usageDescription: 'metro prepare: timeout while waiting for Metro to become ready', + usageDescription: 'metro prepare: timeout while waiting for the dev server to become ready', }, { key: 'metroProbeTimeoutMs', diff --git a/src/cli/parser/cli-help.ts b/src/cli/parser/cli-help.ts index c148300cd..d3746b977 100644 --- a/src/cli/parser/cli-help.ts +++ b/src/cli/parser/cli-help.ts @@ -75,9 +75,9 @@ const AGENT_QUICKSTART_LINES = [ 'Read-only visible/state question: use snapshot/get/is/find; use snapshot -i only when refs are needed.', 'Anti-pattern: snapshot -i followed by snapshot -i | grep ... or hiding command output with 2>/dev/null | jq ...; inspect raw agent-device output first, since hints and errors are part of the planning contract.', 'Truncated text/input preview: expand first with snapshot -s @e12, not get text.', - 'React Native apps: read help react-native for Metro, DevTools routing, and RN-specific blockers; use react-native dismiss-overlay for LogBox/RedBox overlays.', + 'React Native apps: read help react-native for Metro/Re.Pack, DevTools routing, and RN-specific blockers; use react-native dismiss-overlay for LogBox/RedBox overlays.', 'React Native JS memory leaks: read help cdp; use heap usage samples for a quick signal, then snapshot diff/leak-triplet for retained object proof.', - 'Android RN/Expo Metro: direct Android localhost URL opens with a port auto-configure host reachability.', + 'Android RN/Expo/Re.Pack dev server: direct Android localhost URL opens with a port auto-configure host reachability.', 'Expo Go/dev clients: use the provided URL when given; on iOS use open "Expo Go" --platform ios, then snapshot -i --platform ios to verify project UI. Do not use plain snapshot or snapshot --diff for this recovery check. Android URL opens infer the foreground package for logs/perf when possible.', 'Install flows: install/install-from-source first, then open the installed id with --relaunch.', 'Text: fill \'id="field-email"\' "qa@example.com" replaces; type appends after press.', @@ -320,12 +320,12 @@ Validation and evidence: agent-device test ./e2e/maestro --maestro --device udid1,emulator-5554 --shard-all 2 React Native dev loop: - JS-only change with Metro connected: + JS-only change with Metro or Re.Pack connected: agent-device metro reload agent-device find "Home" Do not use agent-device reload. Use open --relaunch for native startup reset. - React Native apps: use help react-native for Metro/Fast Refresh, DevTools routing, and RN-specific blockers; use react-native dismiss-overlay for LogBox/RedBox overlays. - Android RN/Expo Metro: direct Android URL opens to localhost/127.0.0.1/[::1] with a port auto-configure host reachability. Manual adb reverse tcp: tcp: is only needed for app/package launches or unsupported flows where the app cannot reach local Metro. + React Native apps: use help react-native for Metro/Re.Pack Fast Refresh, DevTools routing, and RN-specific blockers; use react-native dismiss-overlay for LogBox/RedBox overlays. + Android RN/Expo/Re.Pack dev server: direct Android URL opens to localhost/127.0.0.1/[::1] with a port auto-configure host reachability. Manual adb reverse tcp: tcp: is only needed for app/package launches or unsupported flows where the app cannot reach the local dev server. Expo Go is a host shell. Use a provided project URL instead of inventing a bundle id; if no URL is provided but a target/app name is provided, open that target and do not inspect project files to find one. On iOS, prefer host + URL when the host shell is known because direct URL open can report success while leaving the runner/shell focused; verify with snapshot -i after opening: agent-device open "Expo Go" exp://127.0.0.1:8081 --platform ios agent-device snapshot -i --platform ios @@ -337,7 +337,9 @@ React Native dev loop: agent-device open exp://127.0.0.1:8081 --platform android Android URL/deep-link opens infer the foreground package after launch when possible, so logs/perf can remain package-bound. If perf still says no package is associated, open the host package/app id first, then open the URL in the same session. If apps lookup misses the project but shows Expo Go/dev-client and a project URL is available, open the URL/host shell; if no URL is available, ask instead of inventing an app id. - Expo Dev Client/development builds: open the installed dev-client app id/name; if a dev-client URL is provided, open that URL next. For Metro setup use metro prepare --kind expo. + Expo Dev Client/development builds: open the installed dev-client app id/name; if a dev-client URL is provided, open that URL next. For Expo setup use metro prepare --kind expo. + Re.Pack/Rspack apps: use metro prepare --kind repack, or rely on auto-detection when @callstack/repack is in the selected package.json. The command name remains metro for compatibility, but prepare/reload use the shared React Native dev-server /status, /reload, and bundle URL protocol. prepare runs react-native rspack-start when rspack.config.* exists, and react-native webpack-start when webpack.config.* exists. + Module Federation super-apps: treat the native host and each JS-only remote as separate dev-server endpoints. Prepare or reload the host/root with its port, and pass a remote's --bundle-url or --metro-port when you need to target that remote's Re.Pack server. Guarantees: Statements of fact for agents to reason from without probing them via trial commands. Each is backed by source in the agent-device repo; behavior changes land with an updated statement here. @@ -352,7 +354,7 @@ Guarantees: Escalate: help debugging logs, network, alerts, traces, flaky runtime failures help react-devtools React Native performance, profiling, props/state/hooks, slow renders, rerenders - help react-native React Native app automation hazards, overlays, Metro, and routing + help react-native React Native app automation hazards, overlays, Metro/Re.Pack, and routing help remote remote/cloud config, tenant, lease, local service tunnels help macos desktop, frontmost-app, menu bar surfaces help dogfood exploratory QA report workflow`, @@ -609,14 +611,14 @@ React Native dev loop: agent-device doctor --platform android --app com.example.app agent-device doctor --remote --remote-config ./remote.json For "start from screen X" flows, prefer open --relaunch before the first snapshot so the app does not reuse a prior in-progress navigation state. - JS-only change with Metro connected: + JS-only change with Metro or Re.Pack connected: agent-device metro reload agent-device find "Home" Do not use agent-device reload. Use open --relaunch for native startup reset. - Android RN/Expo Metro: direct Android localhost URL opens with a port auto-configure host reachability. For app/package launches, run metro prepare when the app cannot reach local Metro. - Verify Metro from the same host context that owns Metro. If a sandboxed shell cannot curl localhost:8081/status but an unrestricted host shell can, Metro is running and the sandbox probe is not authoritative. - adb reverse only affects Android device-to-host traffic. It does not prove host-to-Metro reachability, and it does not fix a redbox caused by a stale or wrong Metro/app state. - Multiple local worktrees can reuse one native iOS simulator build by running each worktree's Metro on a different port and opening the same installed app on different simulators with explicit runtime hints: + Android RN/Expo/Re.Pack dev server: direct Android localhost URL opens with a port auto-configure host reachability. For app/package launches, run metro prepare when the app cannot reach the local dev server. + Verify Metro/Re.Pack from the same host context that owns the dev server. If a sandboxed shell cannot curl localhost:8081/status but an unrestricted host shell can, the dev server is running and the sandbox probe is not authoritative. + adb reverse only affects Android device-to-host traffic. It does not prove host-to-dev-server reachability, and it does not fix a redbox caused by a stale or wrong bundle/app state. + Multiple local worktrees can reuse one native iOS simulator build by running each worktree's dev server on a different port and opening the same installed app on different simulators with explicit runtime hints: agent-device open "React Navigation Example" --platform ios --device "iPhone 17" --session rn-a --metro-host 127.0.0.1 --metro-port 8081 --relaunch agent-device open "React Navigation Example" --platform ios --device "iPhone 17 Pro" --session rn-b --metro-host 127.0.0.1 --metro-port 8082 --relaunch iOS simulator opens write React Native's per-simulator debug server settings before launch, so those ports do not conflict across simulators. Use separate sessions/devices, close both sessions when done, and rebuild only for native changes or dependency changes that affect the binary. One simulator cannot run two copies of the same bundle id. @@ -894,7 +896,7 @@ Coverage: Navigation, forms, empty/error/loading states, offline or retry behavior, permissions, settings, accessibility labels, orientation/keyboard, and obvious performance stalls. React Native warning/error overlays can be real findings or test blockers. Capture them, use react-native dismiss-overlay if unrelated, re-snapshot, and report them. Expo Go/dev-client shells: use the provided exp:// or dev-client URL and record whether the shell, project load, or app UI is being tested. On iOS dogfood, prefer agent-device open "Expo Go" when Expo Go is the known shell, then snapshot -i to confirm the project UI rather than the runner splash. - Android RN/Expo Metro: direct Android localhost URL opens with a port auto-configure host reachability. + Android RN/Expo/Re.Pack dev server: direct Android localhost URL opens with a port auto-configure host reachability. Categories: visual, functional, UX, content, performance, diagnostics, permissions, accessibility. Severity: critical blocks a core flow/data/crashes; high breaks a major feature; medium has friction or workaround; low is polish. diff --git a/src/commands/management/doctor.ts b/src/commands/management/doctor.ts index ae08f5279..83a6b7111 100644 --- a/src/commands/management/doctor.ts +++ b/src/commands/management/doctor.ts @@ -30,7 +30,7 @@ const doctorCliSchema = { 'doctor [--platform ios|android|macos|linux|web|apple] [--app ] [--remote]', helpDescription: 'Preflight for QA and dogfood runs. Reports local device inventory, active sessions, optional app discovery, scoped toolchain info, and Metro reachability inferred from cwd/runtime. On iOS simulators it also warms the XCTest runner build cache in the background when missing, so the first open on a fresh machine skips the runner build — run doctor first on new setups. Pass --app to verify a target app on the one matching booted device without opening a session. Use --remote to check remote connection setup without probing local devices. Default output is compact; use --json for full checks and evidence.', - summary: 'Preflight device, app, Metro, and RN/Expo readiness', + summary: 'Preflight device, app, dev-server, and RN/Expo readiness', allowedFlags: ['targetApp', 'remote'], } as const satisfies CommandSchemaOverride; diff --git a/src/commands/metro/index.test.ts b/src/commands/metro/index.test.ts index 97460127f..1de54aed4 100644 --- a/src/commands/metro/index.test.ts +++ b/src/commands/metro/index.test.ts @@ -38,7 +38,7 @@ describe('metro command interface', () => { metroRuntimeFile: './runtime.json', metroNoReuseExisting: true, metroNoInstallDeps: true, - kind: 'expo', + kind: 'repack', tenant: 'tenant-a', runId: 'run-a', leaseId: 'lease-a', @@ -58,7 +58,7 @@ describe('metro command interface', () => { runtimeFilePath: './runtime.json', reuseExisting: false, installDependenciesIfNeeded: false, - kind: 'expo', + kind: 'repack', bridgeScope: { tenantId: 'tenant-a', runId: 'run-a', @@ -95,7 +95,7 @@ describe('metro command interface', () => { ); expectInvalidArgs( () => metroCliReader(['prepare'], flags({ metroPublicBaseUrl: 'https://x', kind: 'web' })), - 'metro prepare --kind must be', + 'metro prepare --kind must be auto, react-native, expo, or repack', ); }); }); diff --git a/src/commands/metro/index.ts b/src/commands/metro/index.ts index b6eeda85e..05d1136be 100644 --- a/src/commands/metro/index.ts +++ b/src/commands/metro/index.ts @@ -1,4 +1,3 @@ -import type { MetroPrepareKind } from '../../metro/client-metro.ts'; import type { MetroPrepareOptions, MetroPrepareResult, @@ -22,11 +21,12 @@ import { defineFieldCommandMetadata } from '../field-command-contract.ts'; import type { CliReader } from '../cli-grammar/types.ts'; import { METRO_PREPARE_FLAGS, METRO_RELOAD_FLAGS } from '../../cli/parser/cli-flags.ts'; import { metroCliOutputFormatters } from './output.ts'; +import { readMetroPrepareKind } from './prepare-kind.ts'; const METRO_COMMAND_NAME = 'metro'; const METRO_ACTION_VALUES = ['prepare', 'reload'] as const; -const metroCommandDescription = 'Prepare Metro runtime or reload React Native apps.'; +const metroCommandDescription = 'Prepare React Native dev-server runtime or reload apps.'; export const metroCommandMetadata = defineFieldCommandMetadata( METRO_COMMAND_NAME, @@ -71,11 +71,11 @@ export const metroCommandDefinition = defineExecutableCommand( const metroCliSchema = { usageOverride: - 'metro prepare (--public-base-url | --proxy-base-url ) [--project-root ] [--port ] [--kind auto|react-native|expo]\n agent-device metro reload [--metro-host ] [--metro-port ] [--bundle-url ]', + 'metro prepare (--public-base-url | --proxy-base-url ) [--project-root ] [--port ] [--kind auto|react-native|expo|repack]\n agent-device metro reload [--metro-host ] [--metro-port ] [--bundle-url ]', listUsageOverride: 'metro', helpDescription: - 'Prepare a local Metro runtime or ask Metro to reload connected React Native apps', - summary: 'Prepare Metro reachability for React Native/Expo apps or trigger app reloads', + 'Prepare a local React Native dev-server runtime or ask connected apps to reload', + summary: 'Prepare Metro/Re.Pack reachability for React Native/Expo apps or trigger app reloads', positionalArgs: ['prepare|reload'], allowedFlags: [...METRO_RELOAD_FLAGS, ...METRO_PREPARE_FLAGS], } as const satisfies CommandSchemaOverride; @@ -179,9 +179,3 @@ function toMetroReloadOptions(input: MetroInput): MetroReloadOptions { timeoutMs: input.timeoutMs, }; } - -function readMetroPrepareKind(value: string | undefined): MetroPrepareKind | undefined { - if (value === undefined) return undefined; - if (value === 'auto' || value === 'react-native' || value === 'expo') return value; - throw new AppError('INVALID_ARGS', 'metro prepare --kind must be auto, react-native, or expo'); -} diff --git a/src/commands/metro/prepare-kind.ts b/src/commands/metro/prepare-kind.ts new file mode 100644 index 000000000..d4f7ee83f --- /dev/null +++ b/src/commands/metro/prepare-kind.ts @@ -0,0 +1,20 @@ +import { AppError } from '../../kernel/errors.ts'; +import type { MetroPrepareKind } from '../../metro/client-metro.ts'; + +const METRO_PREPARE_KIND_VALUES: readonly MetroPrepareKind[] = [ + 'auto', + 'react-native', + 'expo', + 'repack', +]; + +export function readMetroPrepareKind(value: string | undefined): MetroPrepareKind | undefined { + if (value === undefined) return undefined; + if ((METRO_PREPARE_KIND_VALUES as readonly string[]).includes(value)) { + return value as MetroPrepareKind; + } + throw new AppError( + 'INVALID_ARGS', + 'metro prepare --kind must be auto, react-native, expo, or repack', + ); +} diff --git a/src/daemon/handlers/__tests__/session-doctor-metro.test.ts b/src/daemon/handlers/__tests__/session-doctor-metro.test.ts index 36edd4746..75a2011c5 100644 --- a/src/daemon/handlers/__tests__/session-doctor-metro.test.ts +++ b/src/daemon/handlers/__tests__/session-doctor-metro.test.ts @@ -29,7 +29,10 @@ test('probeMetro ignores local process lookup failures', async () => { }); assert.equal(check.status, 'pass'); - assert.equal(check.summary, `Metro is reachable at http://127.0.0.1:${server.port}/status.`); + assert.equal( + check.summary, + `React Native dev server is reachable at http://127.0.0.1:${server.port}/status.`, + ); assert.equal(check.evidence?.process, undefined); } finally { await server.close(); diff --git a/src/daemon/handlers/session-doctor-metro.ts b/src/daemon/handlers/session-doctor-metro.ts index 0c0cb2725..94965fdfa 100644 --- a/src/daemon/handlers/session-doctor-metro.ts +++ b/src/daemon/handlers/session-doctor-metro.ts @@ -32,10 +32,10 @@ export async function probeMetro( status: running ? 'pass' : 'warn', summary: running ? metroRunningSummary(url, processInfo) - : `Metro responded at ${url}, but did not report packager-status:running.`, + : `React Native dev server responded at ${url}, but did not report packager-status:running.`, hint: running ? undefined - : 'Verify this is the Metro instance for the target app, or restart Metro.', + : 'Verify this is the Metro/Re.Pack instance for the target app, or restart the dev server.', evidence: { url, statusCode: response.status, @@ -48,8 +48,8 @@ export async function probeMetro( return { id: 'metro', status: kind === 'auto' ? 'warn' : 'fail', - summary: `Metro is not reachable at ${url}.`, - hint: 'Start Metro for this project. For non-default endpoints, launch with open --metro-host/--metro-port, or run metro prepare with --public-base-url/--proxy-base-url before retrying doctor.', + summary: `React Native dev server is not reachable at ${url}.`, + hint: 'Start Metro or Re.Pack for this project. For non-default endpoints, launch with open --metro-host/--metro-port, or run metro prepare with --public-base-url/--proxy-base-url before retrying doctor.', command: `curl -fsS ${url}`, evidence: { url, error: error instanceof Error ? error.message : String(error), kind }, }; @@ -70,9 +70,9 @@ async function resolveMetroProcessInfoSafely( function metroRunningSummary(url: string, processInfo: MetroProcessInfo | undefined): string { if (processInfo?.cwd) { - return `Metro is reachable at ${url} (cwd: ${processInfo.cwd}).`; + return `React Native dev server is reachable at ${url} (cwd: ${processInfo.cwd}).`; } - return `Metro is reachable at ${url}.`; + return `React Native dev server is reachable at ${url}.`; } async function resolveMetroProcessInfo( diff --git a/src/daemon/handlers/session-doctor-types.ts b/src/daemon/handlers/session-doctor-types.ts index 1a46c114c..131d5b562 100644 --- a/src/daemon/handlers/session-doctor-types.ts +++ b/src/daemon/handlers/session-doctor-types.ts @@ -1,6 +1,6 @@ export type DoctorStatus = 'pass' | 'warn' | 'fail' | 'info'; -export type DoctorKind = 'auto' | 'react-native' | 'expo'; +export type DoctorKind = 'auto' | 'react-native' | 'expo' | 'repack'; export type DoctorOptions = { targetApp?: string; diff --git a/src/metro/client-metro.ts b/src/metro/client-metro.ts index aaac1429d..0556c4d59 100644 --- a/src/metro/client-metro.ts +++ b/src/metro/client-metro.ts @@ -26,12 +26,14 @@ import { const DEFAULT_METRO_HOST = 'localhost'; const DEFAULT_METRO_PORT = 8081; +const DEV_SERVER_STATUS_READY_TEXT = 'packager-status:running'; const METRO_TERM_TIMEOUT_MS = 1_000; const METRO_KILL_TIMEOUT_MS = 1_000; -export type MetroPrepareKind = 'auto' | 'react-native' | 'expo'; +export type MetroPrepareKind = 'auto' | 'react-native' | 'expo' | 'repack'; type ResolvedMetroKind = Exclude; type EnvSource = NodeJS.ProcessEnv | Record; +type RepackBundlerKind = 'rspack' | 'webpack'; export type { CompanionTunnelScope, @@ -51,6 +53,7 @@ type ResolvedMetroPrepareSettings = { env: EnvSource; projectRoot: string; kind: ResolvedMetroKind; + repackBundler: RepackBundlerKind | null; metroPort: number; listenHost: string; statusHost: string; @@ -186,13 +189,42 @@ function detectPackageManager(projectRoot: string): PackageManagerConfig { return { command: 'npm', installArgs: ['install'] }; } -function detectMetroKind(projectRoot: string, requestedKind: MetroPrepareKind): ResolvedMetroKind { +function detectMetroKind( + packageJson: PackageJsonShape, + requestedKind: MetroPrepareKind, +): ResolvedMetroKind { if (requestedKind !== 'auto') { return requestedKind; } - const detected = detectProjectRuntimeKindFromPackageJson(readPackageJson(projectRoot)); - return detected === 'expo' ? 'expo' : 'react-native'; + const detected = detectProjectRuntimeKindFromPackageJson(packageJson); + if (detected === 'expo' || detected === 'repack') return detected; + return 'react-native'; +} + +function hasPackageDependency(packageJson: PackageJsonShape, dependencyName: string): boolean { + const dependencies = { + ...(packageJson.dependencies ?? {}), + ...(packageJson.devDependencies ?? {}), + }; + return typeof dependencies[dependencyName] === 'string'; +} + +function hasBundlerConfig(projectRoot: string, basename: RepackBundlerKind): boolean { + return ['js', 'mjs', 'cjs', 'ts', 'mts', 'cts'].some((extension) => + fileExists(path.join(projectRoot, `${basename}.config.${extension}`)), + ); +} + +function detectRepackBundler( + projectRoot: string, + packageJson: PackageJsonShape, +): RepackBundlerKind { + if (hasBundlerConfig(projectRoot, 'rspack')) return 'rspack'; + if (hasBundlerConfig(projectRoot, 'webpack')) return 'webpack'; + if (hasPackageDependency(packageJson, '@rspack/core')) return 'rspack'; + if (hasPackageDependency(packageJson, 'webpack')) return 'webpack'; + return 'rspack'; } function parseTimeout( @@ -287,7 +319,7 @@ async function fetchText( async function isMetroReady(statusUrl: string, timeoutMs: number): Promise { try { const response = await fetchText(statusUrl, timeoutMs); - return response.ok && response.body.includes('packager-status:running'); + return response.ok && response.body.includes(DEV_SERVER_STATUS_READY_TEXT); } catch { return false; } @@ -354,6 +386,7 @@ function resolveMetroReloadUrl(input: ReloadMetroOptions): string { function buildMetroCommand( kind: ResolvedMetroKind, + repackBundler: RepackBundlerKind | null, port: number, listenHost: string, ): PackageManagerConfig { @@ -363,6 +396,13 @@ function buildMetroCommand( installArgs: ['expo', 'start', '--host', 'lan', '--port', String(port)], }; } + if (kind === 'repack') { + const commandName = repackBundler === 'webpack' ? 'webpack-start' : 'rspack-start'; + return { + command: 'npx', + installArgs: ['react-native', commandName, '--host', listenHost, '--port', String(port)], + }; + } return { command: 'npx', @@ -373,12 +413,13 @@ function buildMetroCommand( function startMetroProcess( projectRoot: string, kind: ResolvedMetroKind, + repackBundler: RepackBundlerKind | null, port: number, listenHost: string, logPath: string, env: EnvSource, ): MetroProcessResult { - const metro = buildMetroCommand(kind, port, listenHost); + const metro = buildMetroCommand(kind, repackBundler, port, listenHost); fs.mkdirSync(path.dirname(logPath), { recursive: true }); const logFd = fs.openSync(logPath, 'a'); let pid = 0; @@ -393,7 +434,10 @@ function startMetroProcess( } if (!Number.isInteger(pid) || pid <= 0) { - throw new AppError('COMMAND_FAILED', 'Failed to start Metro. Expected a detached child PID.'); + throw new AppError( + 'COMMAND_FAILED', + 'Failed to start React Native dev server. Expected a detached child PID.', + ); } return { @@ -689,6 +733,20 @@ function resolveMetroPrepareSettings( const env = input.env ?? process.env; const cwd = process.cwd(); const projectRoot = resolvePath(input.projectRoot ?? cwd, env, cwd); + const requestedKind = input.kind ?? 'auto'; + let packageJson: PackageJsonShape | null = null; + let kind: ResolvedMetroKind; + if (requestedKind === 'auto') { + packageJson = readPackageJson(projectRoot); + kind = detectMetroKind(packageJson, requestedKind); + } else { + kind = requestedKind; + } + let repackBundler: RepackBundlerKind | null = null; + if (kind === 'repack') { + packageJson ??= readPackageJson(projectRoot); + repackBundler = detectRepackBundler(projectRoot, packageJson); + } const publicBaseUrl = normalizeOptionalBaseUrl(input.publicBaseUrl); const proxyBaseUrlInput = normalizeOptionalBaseUrl(input.proxyBaseUrl); requireMetroBaseUrl(publicBaseUrl, proxyBaseUrlInput); @@ -702,7 +760,8 @@ function resolveMetroPrepareSettings( return { env, projectRoot, - kind: detectMetroKind(projectRoot, input.kind ?? 'auto'), + kind, + repackBundler, metroPort: parsePort(input.metroPort ?? 8081, 8081), listenHost: normalizeOptionalString(input.listenHost) ?? '0.0.0.0', statusHost: normalizeOptionalString(input.statusHost) ?? '127.0.0.1', @@ -814,6 +873,7 @@ async function ensureMetroProcessReady( const startedProcess = startMetroProcess( settings.projectRoot, settings.kind, + settings.repackBundler, settings.metroPort, settings.listenHost, settings.logPath, @@ -827,7 +887,7 @@ async function ensureMetroProcessReady( await stopSpawnedMetroProcess(startedProcess.pid).catch(() => {}); throw new AppError( 'COMMAND_FAILED', - `Metro did not become ready at ${statusUrl} within ${settings.startupTimeoutMs}ms. Check ${settings.logPath}.`, + `React Native dev server did not become ready at ${statusUrl} within ${settings.startupTimeoutMs}ms. Check ${settings.logPath}.`, { logPath: settings.logPath }, ); } @@ -984,12 +1044,16 @@ export async function reloadMetro(input: ReloadMetroOptions = {}): Promise; @@ -21,6 +21,7 @@ export function detectProjectRuntimeKindFromPackageJson( ...(packageJson.dependencies ?? {}), ...(packageJson.devDependencies ?? {}), }; + if (typeof dependencies['@callstack/repack'] === 'string') return 'repack'; if (typeof dependencies.expo === 'string') return 'expo'; if (typeof dependencies['react-native'] === 'string') return 'react-native'; return 'auto';