From 09d28de59ff9ca9f5975f1191d8d660a4be9f781 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 11:23:19 +0200 Subject: [PATCH 1/3] refactor(react-native): collocate Metro, companion, and DevTools --- .../0019-request-bound-platform-runtime.md | 5 +- .../contracts/src/companion-tunnel-scope.ts | 10 +- .../client-companion-tunnel-contract.ts | 0 ...client-companion-tunnel-worker.fixtures.ts | 2 +- .../client-companion-tunnel-worker.test.ts | 2 +- .../client-companion-tunnel-worker.ts | 2 +- .../src/companion}/client-companion-tunnel.ts | 2 +- .../src/companion}/companion-tunnel.ts | 0 .../react-native/src/metro}/base-url.test.ts | 0 .../react-native/src/metro}/base-url.ts | 0 .../src}/metro/bundle-url.test.ts | 0 .../react-native/src}/metro/bundle-url.ts | 2 +- .../metro/client-metro-auto-companion.test.ts | 0 .../src}/metro/client-metro-companion.test.ts | 22 +- .../src}/metro/client-metro-companion.ts | 4 +- .../client-metro-startup-cleanup.test.ts | 0 .../src}/metro/client-metro.test.ts | 424 +----------------- .../react-native/src}/metro/client-metro.ts | 2 +- packages/react-native/src/metro/index.ts | 33 ++ .../src}/metro/metro-reload-endpoints.test.ts | 0 .../src}/metro/metro-reload-endpoints.ts | 0 .../src}/metro/metro-session-hints.test.ts | 0 .../src}/metro/metro-session-hints.ts | 5 +- .../react-native/src}/metro/metro-types.ts | 0 .../react-native/src}/metro/metro.ts | 8 - .../src/react-devtools/index-inspect.test.ts | 19 +- .../src/react-devtools/index-profile.test.ts | 25 +- .../react-native/src/react-devtools/index.ts | 26 ++ .../react-devtools/metro-inspector.test.ts | 0 .../src}/react-devtools/metro-inspector.ts | 0 .../react-component-inspect.test.ts | 0 .../react-devtools/react-component-inspect.ts | 0 .../react-component-tree.test.ts | 0 .../react-devtools/react-component-tree.ts | 0 .../react-devtools/react-component.test.ts | 0 .../src}/react-devtools/react-component.ts | 0 .../react-devtools-command.fixtures.ts | 20 +- .../react-devtools-connection.ts | 2 +- .../react-devtools-inspect-command.ts | 9 +- .../react-devtools/react-devtools-output.ts | 4 + .../react-devtools-runtime.fixtures.ts | 0 .../react-devtools-runtime.test.ts | 0 .../react-devtools/react-devtools-runtime.ts | 0 .../react-devtools/react-devtools-text.ts | 0 .../react-profile-analysis.test.ts | 0 .../react-devtools/react-profile-analysis.ts | 0 .../react-devtools/react-profile-command.ts | 6 +- .../react-profile-format.test.ts | 0 .../react-devtools/react-profile-format.ts | 0 .../react-profile-renders.fixtures.ts | 0 .../react-profile-renders.test.ts | 0 .../react-devtools/react-profile-renders.ts | 0 packages/test-kit/src/index.ts | 6 + .../test-kit/src/metro-process.fixtures.ts | 74 +++ ...-connection-proxy-allocate-by-spec.test.ts | 5 +- .../remote-connection-proxy-lease-id.test.ts | 5 +- src/__tests__/remote-connection.test.ts | 84 +--- src/agent-device-client-metro.test.ts | 335 ++++++++++++++ src/agent-device-client.ts | 28 +- .../connection-runtime-metro-teardown.test.ts | 92 ++++ src/cli/commands/connection-runtime.ts | 2 +- .../react-devtools-connection.test.ts | 26 +- src/cli/commands/react-devtools.test.ts | 57 +++ src/cli/commands/react-devtools.ts | 23 +- src/commands/management/app.test.ts | 2 +- src/sdk/metro.ts | 20 +- 66 files changed, 775 insertions(+), 618 deletions(-) rename {src/client => packages/react-native/src/companion}/client-companion-tunnel-contract.ts (100%) rename {src/client => packages/react-native/src/companion}/client-companion-tunnel-worker.fixtures.ts (98%) rename {src/client => packages/react-native/src/companion}/client-companion-tunnel-worker.test.ts (99%) rename {src/client => packages/react-native/src/companion}/client-companion-tunnel-worker.ts (99%) rename {src/client => packages/react-native/src/companion}/client-companion-tunnel.ts (99%) rename {src/client => packages/react-native/src/companion}/companion-tunnel.ts (100%) rename {src/client => packages/react-native/src/metro}/base-url.test.ts (100%) rename {src/client => packages/react-native/src/metro}/base-url.ts (100%) rename {src => packages/react-native/src}/metro/bundle-url.test.ts (100%) rename {src => packages/react-native/src}/metro/bundle-url.ts (85%) rename {src => packages/react-native/src}/metro/client-metro-auto-companion.test.ts (100%) rename {src => packages/react-native/src}/metro/client-metro-companion.test.ts (95%) rename {src => packages/react-native/src}/metro/client-metro-companion.ts (86%) rename {src => packages/react-native/src}/metro/client-metro-startup-cleanup.test.ts (100%) rename {src => packages/react-native/src}/metro/client-metro.test.ts (60%) rename {src => packages/react-native/src}/metro/client-metro.ts (99%) create mode 100644 packages/react-native/src/metro/index.ts rename {src => packages/react-native/src}/metro/metro-reload-endpoints.test.ts (100%) rename {src => packages/react-native/src}/metro/metro-reload-endpoints.ts (100%) rename {src => packages/react-native/src}/metro/metro-session-hints.test.ts (100%) rename {src => packages/react-native/src}/metro/metro-session-hints.ts (96%) rename {src => packages/react-native/src}/metro/metro-types.ts (100%) rename {src => packages/react-native/src}/metro/metro.ts (84%) rename src/cli/react-devtools/react-devtools-inspect-command.test.ts => packages/react-native/src/react-devtools/index-inspect.test.ts (93%) rename src/cli/react-devtools/react-profile-command.test.ts => packages/react-native/src/react-devtools/index-profile.test.ts (93%) create mode 100644 packages/react-native/src/react-devtools/index.ts rename {src/cli => packages/react-native/src}/react-devtools/metro-inspector.test.ts (100%) rename {src/cli => packages/react-native/src}/react-devtools/metro-inspector.ts (100%) rename {src/cli => packages/react-native/src}/react-devtools/react-component-inspect.test.ts (100%) rename {src/cli => packages/react-native/src}/react-devtools/react-component-inspect.ts (100%) rename {src/cli => packages/react-native/src}/react-devtools/react-component-tree.test.ts (100%) rename {src/cli => packages/react-native/src}/react-devtools/react-component-tree.ts (100%) rename {src/cli => packages/react-native/src}/react-devtools/react-component.test.ts (100%) rename {src/cli => packages/react-native/src}/react-devtools/react-component.ts (100%) rename {src/cli => packages/react-native/src}/react-devtools/react-devtools-command.fixtures.ts (62%) rename {src/cli => packages/react-native/src}/react-devtools/react-devtools-connection.ts (98%) rename {src/cli => packages/react-native/src}/react-devtools/react-devtools-inspect-command.ts (97%) create mode 100644 packages/react-native/src/react-devtools/react-devtools-output.ts rename {src/cli => packages/react-native/src}/react-devtools/react-devtools-runtime.fixtures.ts (100%) rename {src/cli => packages/react-native/src}/react-devtools/react-devtools-runtime.test.ts (100%) rename {src/cli => packages/react-native/src}/react-devtools/react-devtools-runtime.ts (100%) rename {src/cli => packages/react-native/src}/react-devtools/react-devtools-text.ts (100%) rename {src/cli => packages/react-native/src}/react-devtools/react-profile-analysis.test.ts (100%) rename {src/cli => packages/react-native/src}/react-devtools/react-profile-analysis.ts (100%) rename {src/cli => packages/react-native/src}/react-devtools/react-profile-command.ts (98%) rename {src/cli => packages/react-native/src}/react-devtools/react-profile-format.test.ts (100%) rename {src/cli => packages/react-native/src}/react-devtools/react-profile-format.ts (100%) rename {src/cli => packages/react-native/src}/react-devtools/react-profile-renders.fixtures.ts (100%) rename {src/cli => packages/react-native/src}/react-devtools/react-profile-renders.test.ts (100%) rename {src/cli => packages/react-native/src}/react-devtools/react-profile-renders.ts (100%) create mode 100644 packages/test-kit/src/metro-process.fixtures.ts create mode 100644 src/agent-device-client-metro.test.ts create mode 100644 src/cli/commands/connection-runtime-metro-teardown.test.ts rename src/cli/{react-devtools => commands}/react-devtools-connection.test.ts (68%) create mode 100644 src/cli/commands/react-devtools.test.ts diff --git a/docs/adr/0019-request-bound-platform-runtime.md b/docs/adr/0019-request-bound-platform-runtime.md index f6074b484c..f396ce24fe 100644 --- a/docs/adr/0019-request-bound-platform-runtime.md +++ b/docs/adr/0019-request-bound-platform-runtime.md @@ -1000,9 +1000,12 @@ anchor, not as a proposed commitment. `packages/provider-limrun/src/`; these root files are the composition-time wiring, comparable in role to `src/platform-runtime.ts` itself. - `src/core` (R13-governed consumer seams), `src/commands`, `src/cli`, `src/cli-schema`, - `src/mcp`, `src/client`, `src/sdk`, `src/ai-sdk`, `src/remote`, `src/request`, `src/metro`. + `src/mcp`, `src/client`, `src/sdk`, `src/ai-sdk`, `src/remote`, `src/request`. Not migration targets: these are command-surface, protocol-projection, and daemon-owned zones this ADR keeps in `src/` by design, not residue awaiting a package. +- React Native development tooling lives in `@agent-device/react-native`: Metro preparation and + session bindings, companion tunnels, component inspection, and render profiling share one + package. CLI output and SDK session normalization remain application adapters in `src/`. Neither number has an owning issue, gate, or accepted budget yet. Treat both as inputs to a maintainer decision, not as a ratchet. diff --git a/packages/contracts/src/companion-tunnel-scope.ts b/packages/contracts/src/companion-tunnel-scope.ts index 4f27938d50..d63414e8a9 100644 --- a/packages/contracts/src/companion-tunnel-scope.ts +++ b/packages/contracts/src/companion-tunnel-scope.ts @@ -1,12 +1,6 @@ /** - * The identity a companion tunnel is scoped to. - * - * This shape is shared vocabulary rather than a client concern: the public API surface declares - * command options in terms of it, and `client/`, `metro/` and `cli/` all pass it around. It lived - * inside `client/client-companion-tunnel-contract.ts` next to the env-var names and worker - * options that really are client-local, which made every zone that merely needed the shape - * declare itself in terms of the client. Declaring it below them is what lets the contract be - * shared without the dependency. + * The tenant, run, and lease identity shared by public command options and the + * companion tunnel in `@agent-device/react-native`. */ export type CompanionTunnelScope = { tenantId: string; diff --git a/src/client/client-companion-tunnel-contract.ts b/packages/react-native/src/companion/client-companion-tunnel-contract.ts similarity index 100% rename from src/client/client-companion-tunnel-contract.ts rename to packages/react-native/src/companion/client-companion-tunnel-contract.ts diff --git a/src/client/client-companion-tunnel-worker.fixtures.ts b/packages/react-native/src/companion/client-companion-tunnel-worker.fixtures.ts similarity index 98% rename from src/client/client-companion-tunnel-worker.fixtures.ts rename to packages/react-native/src/companion/client-companion-tunnel-worker.fixtures.ts index 33a96dd7a4..83a421691c 100644 --- a/src/client/client-companion-tunnel-worker.fixtures.ts +++ b/packages/react-native/src/companion/client-companion-tunnel-worker.fixtures.ts @@ -4,7 +4,7 @@ import type { Duplex } from 'node:stream'; import { listenOnLoopback } from '@agent-device/test-kit'; /** - * Wire-level doubles for the companion tunnel worker (`src/client/client-companion-tunnel-worker.ts`). + * Wire-level doubles for the companion tunnel worker (`packages/react-native/src/companion/client-companion-tunnel-worker.ts`). * * The worker speaks raw WebSocket frames over an `http.Server` upgrade, so these helpers encode and * decode frames by hand instead of pulling in a WebSocket dependency, and a hand-written parser is diff --git a/src/client/client-companion-tunnel-worker.test.ts b/packages/react-native/src/companion/client-companion-tunnel-worker.test.ts similarity index 99% rename from src/client/client-companion-tunnel-worker.test.ts rename to packages/react-native/src/companion/client-companion-tunnel-worker.test.ts index 2631d2fcbe..76ff42db9f 100644 --- a/src/client/client-companion-tunnel-worker.test.ts +++ b/packages/react-native/src/companion/client-companion-tunnel-worker.test.ts @@ -106,7 +106,7 @@ function spawnMetroCompanionWorker(options: { process.execPath, [ '--experimental-strip-types', - 'src/client/companion-tunnel.ts', + 'packages/react-native/src/companion/companion-tunnel.ts', '--agent-device-run-metro-companion', ], { diff --git a/src/client/client-companion-tunnel-worker.ts b/packages/react-native/src/companion/client-companion-tunnel-worker.ts similarity index 99% rename from src/client/client-companion-tunnel-worker.ts rename to packages/react-native/src/companion/client-companion-tunnel-worker.ts index 49468826a3..956092d1b6 100644 --- a/src/client/client-companion-tunnel-worker.ts +++ b/packages/react-native/src/companion/client-companion-tunnel-worker.ts @@ -22,7 +22,7 @@ import type { MetroTunnelRequestMessage as MetroCompanionRequest, MetroTunnelResponseMessage, } from '../metro/metro.ts'; -import { normalizeBaseUrl } from './base-url.ts'; +import { normalizeBaseUrl } from '../metro/base-url.ts'; const COMPANION_REGISTER_TIMEOUT_MS = 5_000; const COMPANION_REGISTER_MAX_RETRY_DELAY_MS = 60_000; diff --git a/src/client/client-companion-tunnel.ts b/packages/react-native/src/companion/client-companion-tunnel.ts similarity index 99% rename from src/client/client-companion-tunnel.ts rename to packages/react-native/src/companion/client-companion-tunnel.ts index 92a7986fca..8506d79cb5 100644 --- a/src/client/client-companion-tunnel.ts +++ b/packages/react-native/src/companion/client-companion-tunnel.ts @@ -15,7 +15,7 @@ import { ENV_COMPANION_TUNNEL_STATE_PATH, ENV_COMPANION_TUNNEL_UNREGISTER_PATH, } from './client-companion-tunnel-contract.ts'; -import { normalizeBaseUrl } from './base-url.ts'; +import { normalizeBaseUrl } from '../metro/base-url.ts'; import { runCmdDetached } from '@agent-device/host-kit/command'; import { confirmedProcessStopDetails, diff --git a/src/client/companion-tunnel.ts b/packages/react-native/src/companion/companion-tunnel.ts similarity index 100% rename from src/client/companion-tunnel.ts rename to packages/react-native/src/companion/companion-tunnel.ts diff --git a/src/client/base-url.test.ts b/packages/react-native/src/metro/base-url.test.ts similarity index 100% rename from src/client/base-url.test.ts rename to packages/react-native/src/metro/base-url.test.ts diff --git a/src/client/base-url.ts b/packages/react-native/src/metro/base-url.ts similarity index 100% rename from src/client/base-url.ts rename to packages/react-native/src/metro/base-url.ts diff --git a/src/metro/bundle-url.test.ts b/packages/react-native/src/metro/bundle-url.test.ts similarity index 100% rename from src/metro/bundle-url.test.ts rename to packages/react-native/src/metro/bundle-url.test.ts diff --git a/src/metro/bundle-url.ts b/packages/react-native/src/metro/bundle-url.ts similarity index 85% rename from src/metro/bundle-url.ts rename to packages/react-native/src/metro/bundle-url.ts index 7b33ac60b2..a7c0e0e971 100644 --- a/src/metro/bundle-url.ts +++ b/packages/react-native/src/metro/bundle-url.ts @@ -1,4 +1,4 @@ -import { normalizeBaseUrl } from '../client/base-url.ts'; +import { normalizeBaseUrl } from './base-url.ts'; export function buildBundleUrl( baseUrl: string, diff --git a/src/metro/client-metro-auto-companion.test.ts b/packages/react-native/src/metro/client-metro-auto-companion.test.ts similarity index 100% rename from src/metro/client-metro-auto-companion.test.ts rename to packages/react-native/src/metro/client-metro-auto-companion.test.ts diff --git a/src/metro/client-metro-companion.test.ts b/packages/react-native/src/metro/client-metro-companion.test.ts similarity index 95% rename from src/metro/client-metro-companion.test.ts rename to packages/react-native/src/metro/client-metro-companion.test.ts index c56efb1535..5af9a5e4f5 100644 --- a/src/metro/client-metro-companion.test.ts +++ b/packages/react-native/src/metro/client-metro-companion.test.ts @@ -29,7 +29,7 @@ import { } from '@agent-device/host-kit/process'; import { ensureMetroCompanion, stopMetroCompanion } from './client-metro-companion.ts'; -import { METRO_COMPANION_RUN_ARG } from '../client/client-companion-tunnel-contract.ts'; +import { METRO_COMPANION_RUN_ARG } from '../companion/client-companion-tunnel-contract.ts'; const TEST_BRIDGE_SCOPE = { tenantId: 'tenant-1', @@ -47,7 +47,9 @@ function assertCompanionSpawnTarget(): void { assert.ok(firstCall, 'expected runCmdDetached to be called'); assert.equal(firstCall[0], process.execPath); assert.ok( - firstCall[1].some((arg) => arg.includes('src/client/companion-tunnel.ts')), + firstCall[1].some((arg) => + arg.includes('packages/react-native/src/companion/companion-tunnel.ts'), + ), `expected companion entry path in ${JSON.stringify(firstCall[1])}`, ); assert.equal(firstCall[1].at(-1), '--agent-device-run-metro-companion'); @@ -55,7 +57,7 @@ function assertCompanionSpawnTarget(): void { /** The stops the code asked host-kit for: the identity recorded beside the pid, and the budget. */ const COMPANION_STOP_BUDGET = { termTimeoutMs: 1_000, killTimeoutMs: 1_000 }; -const COMPANION_TUNNEL_COMMAND = `${process.execPath} src/client/companion-tunnel.ts ${METRO_COMPANION_RUN_ARG}`; +const COMPANION_TUNNEL_COMMAND = `${process.execPath} packages/react-native/src/companion/companion-tunnel.ts ${METRO_COMPANION_RUN_ARG}`; const UNRELATED_COMMAND = '/usr/bin/nginx -g daemon off;'; // A predicate compares by identity, so deepEqual cannot see whether it would accept the tunnel or // refuse a stranger. Projecting its verdicts is what makes a dropped or inverted matcher fail here. @@ -88,7 +90,8 @@ test('companion ownership is profile-scoped and consumer-counted', async () => { pid === 111 ? 'start-111' : 'start-222', ); vi.mocked(readProcessCommand).mockImplementation( - () => `${process.execPath} src/client/companion-tunnel.ts --agent-device-run-metro-companion`, + () => + `${process.execPath} packages/react-native/src/companion/companion-tunnel.ts --agent-device-run-metro-companion`, ); vi.mocked(stopProcessAndConfirmExit).mockResolvedValue(STOPPED); @@ -185,7 +188,8 @@ test('launchUrl changes force a companion respawn for the same profile', async ( pid === 333 ? 'start-333' : 'start-444', ); vi.mocked(readProcessCommand).mockImplementation( - () => `${process.execPath} src/client/companion-tunnel.ts --agent-device-run-metro-companion`, + () => + `${process.execPath} packages/react-native/src/companion/companion-tunnel.ts --agent-device-run-metro-companion`, ); vi.mocked(stopProcessAndConfirmExit).mockResolvedValue(STOPPED); @@ -231,7 +235,7 @@ test('spawned companion uses neutral env names', async () => { vi.mocked(runCmdDetached).mockReturnValueOnce(999); vi.mocked(readProcessStartTime).mockReturnValue('start-999'); vi.mocked(readProcessCommand).mockReturnValue( - `${process.execPath} src/client/companion-tunnel.ts --agent-device-run-metro-companion`, + `${process.execPath} packages/react-native/src/companion/companion-tunnel.ts --agent-device-run-metro-companion`, ); await ensureMetroCompanion({ @@ -272,7 +276,7 @@ test('state sentinel exists before spawning companion worker', async () => { }); vi.mocked(readProcessStartTime).mockReturnValue('start-1001'); vi.mocked(readProcessCommand).mockReturnValue( - `${process.execPath} src/client/companion-tunnel.ts --agent-device-run-metro-companion`, + `${process.execPath} packages/react-native/src/companion/companion-tunnel.ts --agent-device-run-metro-companion`, ); const spawned = await ensureMetroCompanion({ @@ -518,7 +522,7 @@ test('legacy state without bridge scope is stopped before respawn', async () => `${JSON.stringify({ pid: 555, startTime: 'start-555', - command: `${process.execPath} src/client/companion-tunnel.ts --agent-device-run-metro-companion`, + command: `${process.execPath} packages/react-native/src/companion/companion-tunnel.ts --agent-device-run-metro-companion`, serverBaseUrl: 'https://bridge.example.test', localBaseUrl: 'http://127.0.0.1:8081', tokenHash: 'legacy-token-hash', @@ -530,7 +534,7 @@ test('legacy state without bridge scope is stopped before respawn', async () => vi.mocked(isProcessAlive).mockReturnValue(true); vi.mocked(readProcessStartTime).mockReturnValue('start-555'); vi.mocked(readProcessCommand).mockReturnValue( - `${process.execPath} src/client/companion-tunnel.ts --agent-device-run-metro-companion`, + `${process.execPath} packages/react-native/src/companion/companion-tunnel.ts --agent-device-run-metro-companion`, ); vi.mocked(stopProcessAndConfirmExit).mockResolvedValue(STOPPED); diff --git a/src/metro/client-metro-companion.ts b/packages/react-native/src/metro/client-metro-companion.ts similarity index 86% rename from src/metro/client-metro-companion.ts rename to packages/react-native/src/metro/client-metro-companion.ts index 099ed8e22d..eab61a2acd 100644 --- a/src/metro/client-metro-companion.ts +++ b/packages/react-native/src/metro/client-metro-companion.ts @@ -4,8 +4,8 @@ import { type CompanionTunnelDefinition, type EnsureCompanionTunnelOptions, type StopCompanionTunnelOptions, -} from '../client/client-companion-tunnel.ts'; -import { METRO_COMPANION_RUN_ARG } from '../client/client-companion-tunnel-contract.ts'; +} from '../companion/client-companion-tunnel.ts'; +import { METRO_COMPANION_RUN_ARG } from '../companion/client-companion-tunnel-contract.ts'; const METRO_COMPANION_REGISTER_PATH = '/api/metro/companion/register'; diff --git a/src/metro/client-metro-startup-cleanup.test.ts b/packages/react-native/src/metro/client-metro-startup-cleanup.test.ts similarity index 100% rename from src/metro/client-metro-startup-cleanup.test.ts rename to packages/react-native/src/metro/client-metro-startup-cleanup.test.ts diff --git a/src/metro/client-metro.test.ts b/packages/react-native/src/metro/client-metro.test.ts similarity index 60% rename from src/metro/client-metro.test.ts rename to packages/react-native/src/metro/client-metro.test.ts index c191615bdd..349ca0f59b 100644 --- a/src/metro/client-metro.test.ts +++ b/packages/react-native/src/metro/client-metro.test.ts @@ -4,175 +4,20 @@ import { once } from 'node:events'; import { createHash } from 'node:crypto'; import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { createServer } from 'node:http'; -import type { Socket } from 'node:net'; -import net from 'node:net'; import path from 'node:path'; import { prepareMetroRuntime, reloadMetro } from './client-metro.ts'; import { resolveMetroReloadEndpoints } from './metro-reload-endpoints.ts'; -import { createAgentDeviceClient } from '../agent-device-client.ts'; -import { readMetroSessionHints } from './metro-session-hints.ts'; -import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { AppError } from '@agent-device/kernel/errors'; -import { isProcessAlive, waitForProcessExit } from '@agent-device/host-kit/process'; -import { closeLoopbackServer, listenOnLoopback, mkdtempForTestSync } from '@agent-device/test-kit'; +import { + closeLoopbackServer, + mkdtempForTestSync, + writeFakeMetroNpx, + findFreeMetroPort, + stopTestMetroProcess, +} from '@agent-device/test-kit'; const TEST_TOKEN = 'agent-device-proxy-test-token'; -test('client prepare retains local control and valid proxy runtimes when one device URL is malformed', async () => { - const tempRoot = mkdtempForTestSync('agent-device-metro'); - const projectRoot = path.join(tempRoot, 'project'); - const binDir = path.join(tempRoot, 'bin'); - const runtimeFilePath = path.join(projectRoot, '.agent-device', 'metro-runtime.json'); - const metroPort = await findFreePort(); - const proxyPort = await findFreePort(); - const requests: string[] = []; - const proxySockets = new Set(); - - mkdirSync(projectRoot, { recursive: true }); - mkdirSync(binDir, { recursive: true }); - writeFileSync( - path.join(projectRoot, 'package.json'), - JSON.stringify({ - name: 'metro-runtime-test', - private: true, - dependencies: { - 'react-native': '0.0.0-test', - }, - }), - ); - writeFakeNpx(binDir); - - const proxyServer = createServer(async (req, res) => { - if (req.headers.authorization !== `Bearer ${TEST_TOKEN}`) { - res.statusCode = 401; - res.end(JSON.stringify({ ok: false, error: 'unauthorized' })); - return; - } - - requests.push(req.url || ''); - const chunks: Buffer[] = []; - for await (const chunk of req) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - } - const body = JSON.parse(Buffer.concat(chunks).toString('utf8')) as { - tenantId?: string; - runId?: string; - leaseId?: string; - ios_runtime?: { metro_bundle_url?: string }; - }; - assert.equal(body.tenantId, 'tenant-1'); - assert.equal(body.runId, 'run-1'); - assert.equal(body.leaseId, 'lease-1'); - assert.equal(body.ios_runtime, undefined); - - res.statusCode = 200; - res.setHeader('content-type', 'application/json'); - if (req.url === '/api/metro/bridge') { - res.end( - JSON.stringify({ - ok: true, - data: { - enabled: true, - base_url: 'http://127.0.0.1:8081', - status_url: 'http://127.0.0.1:8081/status', - bundle_url: 'http://127.0.0.1:8081/index.bundle?platform=ios&dev=true&minify=false', - ios_runtime: { - metro_host: 'runtime-1.metro.agent-device.dev', - metro_port: 443, - metro_bundle_url: - 'https://runtime-1.metro.agent-device.dev/index.bundle?platform=ios&dev=true&minify=false', - }, - android_runtime: { - metro_host: 'bridge.example.test', - metro_port: 443, - metro_bundle_url: 'http://[broken', - }, - upstream: { - bundle_url: `http://127.0.0.1:${metroPort}/index.bundle?platform=ios&dev=true&minify=false`, - host: '127.0.0.1', - port: metroPort, - status_url: `http://127.0.0.1:${metroPort}/status`, - }, - probe: { - reachable: true, - status_code: 200, - latency_ms: 3, - detail: 'ok', - }, - }, - }), - ); - return; - } - - res.statusCode = 404; - res.end(JSON.stringify({ ok: false, error: 'not found' })); - }); - proxyServer.on('connection', (socket) => { - proxySockets.add(socket); - socket.on('close', () => proxySockets.delete(socket)); - }); - proxyServer.listen(proxyPort, '127.0.0.1'); - proxyServer.unref(); - await once(proxyServer, 'listening'); - - let pid = 0; - const previousPath = process.env.PATH; - process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ''}`; - const client = createAgentDeviceClient({ stateDir: tempRoot, session: 'bridge' }); - - try { - const result = await client.metro.prepare({ - projectRoot, - publicBaseUrl: `http://127.0.0.1:${metroPort}`, - proxyBaseUrl: `http://127.0.0.1:${proxyPort}`, - bearerToken: TEST_TOKEN, - bridgeScope: { - tenantId: 'tenant-1', - runId: 'run-1', - leaseId: 'lease-1', - }, - port: metroPort, - reuseExisting: false, - installDependenciesIfNeeded: false, - runtimeFilePath, - }); - - pid = result.pid; - assert.equal(result.kind, 'react-native'); - assert.equal(result.started, true); - assert.equal(result.reused, false); - assert.equal(result.bridge?.enabled, true); - assert.equal(result.iosRuntime.metroHost, 'runtime-1.metro.agent-device.dev'); - assert.equal(result.iosRuntime.metroPort, 443); - assert.equal(result.iosRuntime.platform, 'ios'); - assert.equal(result.androidRuntime.metroHost, 'bridge.example.test'); - assert.equal(result.androidRuntime.platform, 'android'); - assert.deepEqual(requests, ['/api/metro/bridge']); - - const written = JSON.parse(readFileSync(runtimeFilePath, 'utf8')) as typeof result; - assert.equal(written.iosRuntime.metroHost, 'runtime-1.metro.agent-device.dev'); - assert.equal(written.iosRuntime.metroPort, 443); - assert.equal(written.iosRuntime.platform, 'ios'); - assert.equal(written.androidRuntime.metroHost, 'bridge.example.test'); - assert.equal(written.androidRuntime.platform, 'android'); - assert.equal(written.runtimeFilePath, runtimeFilePath); - assert.deepEqual(readMetroSessionHints({ stateDir: tempRoot, session: 'bridge' }), { - controlBaseUrl: `http://127.0.0.1:${metroPort}/`, - deviceBaseUrls: ['https://runtime-1.metro.agent-device.dev/'], - }); - assert.equal((await client.metro.reload()).body, 'RELOADED'); - } finally { - process.env.PATH = previousPath; - for (const socket of proxySockets) { - socket.destroy(); - } - await closeLoopbackServer(proxyServer); - await stopProcess(pid); - rmSync(tempRoot, { recursive: true, force: true }); - } -}); - for (const { configFileName, commandName } of [ { configFileName: 'rspack.config.ts', commandName: 'rspack-start' }, { configFileName: 'webpack.config.js', commandName: 'webpack-start' }, @@ -182,7 +27,7 @@ for (const { configFileName, commandName } of [ const projectRoot = path.join(tempRoot, 'project'); const binDir = path.join(tempRoot, 'bin'); const argsFile = path.join(tempRoot, 'npx-args.json'); - const metroPort = await findFreePort(); + const metroPort = await findFreeMetroPort(); mkdirSync(projectRoot, { recursive: true }); mkdirSync(binDir, { recursive: true }); @@ -200,7 +45,7 @@ for (const { configFileName, commandName } of [ }), ); writeFileSync(path.join(projectRoot, configFileName), 'module.exports = {};\n'); - writeFakeNpx(binDir); + writeFakeMetroNpx(binDir); let pid = 0; try { @@ -233,7 +78,7 @@ for (const { configFileName, commandName } of [ `http://127.0.0.1:${metroPort}/index.bundle?platform=ios&dev=true&minify=false`, ); } finally { - await stopProcess(pid); + await stopTestMetroProcess(pid); rmSync(tempRoot, { recursive: true, force: true }); } }); @@ -243,7 +88,7 @@ test('prepareMetroRuntime maps kind=expo to the virtual-metro-entry bundle URL', const tempRoot = mkdtempForTestSync('agent-device-expo-kind'); const projectRoot = path.join(tempRoot, 'project'); const binDir = path.join(tempRoot, 'bin'); - const metroPort = await findFreePort(); + const metroPort = await findFreeMetroPort(); mkdirSync(projectRoot, { recursive: true }); mkdirSync(binDir, { recursive: true }); @@ -255,7 +100,7 @@ test('prepareMetroRuntime maps kind=expo to the virtual-metro-entry bundle URL', dependencies: { expo: '51.0.0', 'react-native': '0.0.0-test' }, }), ); - writeFakeNpx(binDir); + writeFakeMetroNpx(binDir); let pid = 0; try { @@ -284,7 +129,7 @@ test('prepareMetroRuntime maps kind=expo to the virtual-metro-entry bundle URL', ); assert.ok(!result.iosRuntime.bundleUrl.includes('index.bundle')); } finally { - await stopProcess(pid); + await stopTestMetroProcess(pid); rmSync(tempRoot, { recursive: true, force: true }); } }); @@ -293,7 +138,7 @@ test('prepareMetroRuntime keeps index.bundle for non-expo kinds', async () => { const tempRoot = mkdtempForTestSync('agent-device-rn-kind'); const projectRoot = path.join(tempRoot, 'project'); const binDir = path.join(tempRoot, 'bin'); - const metroPort = await findFreePort(); + const metroPort = await findFreeMetroPort(); mkdirSync(projectRoot, { recursive: true }); mkdirSync(binDir, { recursive: true }); @@ -305,7 +150,7 @@ test('prepareMetroRuntime keeps index.bundle for non-expo kinds', async () => { dependencies: { 'react-native': '0.0.0-test' }, }), ); - writeFakeNpx(binDir); + writeFakeMetroNpx(binDir); let pid = 0; try { @@ -328,7 +173,7 @@ test('prepareMetroRuntime keeps index.bundle for non-expo kinds', async () => { `http://127.0.0.1:${metroPort}/index.bundle?platform=ios&dev=true&minify=false`, ); } finally { - await stopProcess(pid); + await stopTestMetroProcess(pid); rmSync(tempRoot, { recursive: true, force: true }); } }); @@ -339,7 +184,7 @@ test('prepareMetroRuntime detects the package manager from an ancestor lockfile const projectRoot = path.join(monorepoRoot, 'example'); const binDir = path.join(tempRoot, 'bin'); const argsFile = path.join(tempRoot, 'yarn-args.json'); - const metroPort = await findFreePort(); + const metroPort = await findFreeMetroPort(); mkdirSync(projectRoot, { recursive: true }); mkdirSync(binDir, { recursive: true }); @@ -354,7 +199,7 @@ test('prepareMetroRuntime detects the package manager from an ancestor lockfile dependencies: { 'react-native': '0.0.0-test', 'shared-lib': 'workspace:*' }, }), ); - writeFakeNpx(binDir); + writeFakeMetroNpx(binDir); writeFakePackageManager(binDir, 'yarn', argsFile); writeFakePackageManager(binDir, 'npm', path.join(tempRoot, 'npm-args.json')); @@ -377,7 +222,7 @@ test('prepareMetroRuntime detects the package manager from an ancestor lockfile assert.equal(result.dependenciesInstalled, true); assert.deepEqual(JSON.parse(readFileSync(argsFile, 'utf8')), ['install']); } finally { - await stopProcess(pid); + await stopTestMetroProcess(pid); rmSync(tempRoot, { recursive: true, force: true }); } }); @@ -757,168 +602,6 @@ test('resolveMetroReloadEndpoints keeps the bundle URL mount prefix instead of c ); }); -test('metro reload targets the dev server bound by metro prepare in the same session', async () => { - const tempRoot = mkdtempForTestSync('agent-device-metro-session'); - const projectRoot = path.join(tempRoot, 'project'); - const binDir = path.join(tempRoot, 'bin'); - const stateDir = path.join(tempRoot, 'state'); - const metroPort = await findFreePort(); - - mkdirSync(projectRoot, { recursive: true }); - mkdirSync(binDir, { recursive: true }); - writeFileSync( - path.join(projectRoot, 'package.json'), - JSON.stringify({ - name: 'metro-session-hints-test', - private: true, - dependencies: { 'react-native': '0.0.0-test' }, - }), - ); - writeFakeNpx(binDir); - - const client = createAgentDeviceClient( - { session: 'metro-session-hints', stateDir, cwd: projectRoot }, - { - // Only session close may reach the daemon; metro prepare/reload must stay local. - transport: async (req) => { - if (req.command === 'close') return { ok: true, data: {} }; - throw new Error('metro prepare/reload must stay local and never call the daemon'); - }, - }, - ); - - // The public MetroPrepareOptions doesn't expose env, so the fake npx must be on the real PATH. - const previousPath = process.env.PATH; - process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ''}`; - - let pid = 0; - try { - const prepared = await client.metro.prepare({ - projectRoot, - publicBaseUrl: `http://127.0.0.1:${metroPort}`, - port: metroPort, - reuseExisting: false, - installDependenciesIfNeeded: false, - }); - pid = prepared.pid; - - const storedHints = readMetroSessionHints({ - stateDir: resolveDaemonPaths(stateDir).baseDir, - session: 'metro-session-hints', - }); - assert.deepEqual(storedHints, { - controlBaseUrl: `http://127.0.0.1:${metroPort}/`, - deviceBaseUrls: [`http://127.0.0.1:${metroPort}/`], - }); - - // No explicit --metro-host/--metro-port/--bundle-url: reload must resolve against the - // dev server this session's `metro prepare` bound, not the Metro default (localhost:8081). - const hintedReload = await client.metro.reload(); - assert.equal(hintedReload.reloadUrl, `http://127.0.0.1:${metroPort}/reload`); - assert.equal(hintedReload.body, 'RELOADED'); - - // Regression (prepare -> close -> same-name session): close clears the binding, so a later - // flagless reload resolves to the Metro default instead of silently hitting the stale port. - await client.sessions.close(); - assert.equal( - readMetroSessionHints({ - stateDir: resolveDaemonPaths(stateDir).baseDir, - session: 'metro-session-hints', - }), - undefined, - ); - assert.equal(resolveMetroReloadEndpoints({}).reloadUrl, 'http://localhost:8081/reload'); - } finally { - process.env.PATH = previousPath; - await stopProcess(pid); - rmSync(tempRoot, { recursive: true, force: true }); - } -}); - -test('metro prepare keeps a public Expo address separate from the local reload address', async () => { - const tempRoot = mkdtempForTestSync('agent-device-metro-expo-session'); - const projectRoot = path.join(tempRoot, 'project'); - const binDir = path.join(tempRoot, 'bin'); - const stateDir = path.join(tempRoot, 'state'); - const metroPort = await findFreePort(); - const publicBasePath = '/metro/runtime-expo'; - - mkdirSync(projectRoot, { recursive: true }); - mkdirSync(binDir, { recursive: true }); - writeFileSync( - path.join(projectRoot, 'package.json'), - JSON.stringify({ - name: 'metro-expo-session-test', - private: true, - dependencies: { expo: '51.0.0', 'react-native': '0.0.0-test' }, - }), - ); - writeFakeNpx(binDir); - - const client = createAgentDeviceClient( - { session: 'metro-expo-session', stateDir, cwd: projectRoot }, - { - transport: async () => { - throw new Error('metro prepare/reload must stay local and never call the daemon'); - }, - }, - ); - - const previousPath = process.env.PATH; - process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ''}`; - - let pid = 0; - try { - const prepared = await client.metro.prepare({ - projectRoot, - kind: 'expo', - publicBaseUrl: `https://127.0.0.1:${metroPort}${publicBasePath}`, - port: metroPort, - reuseExisting: false, - installDependenciesIfNeeded: false, - }); - pid = prepared.pid; - - const nextClient = createAgentDeviceClient( - { - session: 'metro-expo-session', - stateDir, - cwd: projectRoot, - runtime: prepared.iosRuntime, - }, - { - transport: async (request) => { - assert.equal(request.command, 'open'); - assert.deepEqual(request.runtime, prepared.iosRuntime); - return { ok: true, data: {} }; - }, - }, - ); - await nextClient.apps.open({ app: 'com.example.expo' }); - const hintedReload = await nextClient.metro.reload(); - assert.equal(hintedReload.reloadUrl, `http://127.0.0.1:${metroPort}/reload`); - assert.equal(hintedReload.body, 'RELOADED'); - assert.equal(hintedReload.transport, 'http'); - assert.equal( - prepared.iosRuntime.bundleUrl, - `https://127.0.0.1:${metroPort}${publicBasePath}/.expo/.virtual-metro-entry.bundle?platform=ios&dev=true&minify=false`, - ); - - const storedHints = readMetroSessionHints({ - stateDir: resolveDaemonPaths(stateDir).baseDir, - session: 'metro-expo-session', - }); - assert.deepEqual(storedHints, { - controlBaseUrl: `http://127.0.0.1:${metroPort}/`, - deviceBaseUrls: [`https://127.0.0.1:${metroPort}${publicBasePath}`], - }); - } finally { - process.env.PATH = previousPath; - await stopProcess(pid); - rmSync(tempRoot, { recursive: true, force: true }); - } -}); - test('reloadMetro falls back to the /message websocket when the HTTP route answers with the app page', async () => { const receivedMessages: string[] = []; const upgradedSockets = new Set(); @@ -1021,72 +704,3 @@ process.exit(1) ); chmodSync(filePath, 0o755); } - -function writeFakeNpx(binDir: string, reloadPath = '/reload'): void { - const filePath = path.join(binDir, 'npx'); - 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") -// "expo start" takes a connectivity mode ("lan", "tunnel", "localhost") for --host, not a bind -// address; every other caller (react-native/rspack/webpack start) passes a real bind address. -const rawHost = hostIndex === -1 ? "0.0.0.0" : String(args[hostIndex + 1] || "0.0.0.0") -const host = rawHost === "lan" || rawHost === "tunnel" ? "0.0.0.0" : rawHost -const reloadPath = ${JSON.stringify(reloadPath)} -const server = http.createServer((req, res) => { - if (req.url === "/status") { - res.statusCode = 200 - res.end("packager-status:running") - return - } - if (req.url && (req.url.startsWith("/index.bundle") || req.url.includes(".virtual-metro-entry.bundle"))) { - res.statusCode = 200 - res.setHeader("content-type", "application/javascript") - res.end("console.log('metro-runtime-test')") - return - } - if (req.url === reloadPath) { - res.statusCode = 200 - res.end("RELOADED") - return - } - res.statusCode = 404 - res.end("not found") -}) -server.listen(port, host) -setInterval(() => {}, 1000) -`, - ); - chmodSync(filePath, 0o755); -} - -async function findFreePort(): Promise { - const server = net.createServer(); - const port = await listenOnLoopback(server); - await closeLoopbackServer(server); - return port; -} - -async function stopProcess(pid: number): Promise { - if (!pid || !isProcessAlive(pid)) return; - try { - process.kill(pid, 'SIGTERM'); - } catch { - return; - } - if (await waitForProcessExit(pid, 1_500)) return; - try { - process.kill(pid, 'SIGKILL'); - } catch { - return; - } - await waitForProcessExit(pid, 1_500); -} diff --git a/src/metro/client-metro.ts b/packages/react-native/src/metro/client-metro.ts similarity index 99% rename from src/metro/client-metro.ts rename to packages/react-native/src/metro/client-metro.ts index b6d7a1a0fa..c986753cb7 100644 --- a/src/metro/client-metro.ts +++ b/packages/react-native/src/metro/client-metro.ts @@ -36,7 +36,7 @@ import { readProjectPackageJson, type PackageJsonShape, } from '@agent-device/host-kit/project-runtime'; -import { normalizeBaseUrl } from '../client/base-url.ts'; +import { normalizeBaseUrl } from './base-url.ts'; import { buildBundleUrl } from './bundle-url.ts'; import { EXPO_VIRTUAL_ENTRY_BUNDLE_PATH, diff --git a/packages/react-native/src/metro/index.ts b/packages/react-native/src/metro/index.ts new file mode 100644 index 0000000000..22656fa684 --- /dev/null +++ b/packages/react-native/src/metro/index.ts @@ -0,0 +1,33 @@ +import type { SessionRuntimeHints } from '@agent-device/kernel/contracts'; +import { resolveRuntimeTransportHints } from '@agent-device/host-kit/runtime-transport-hints'; +import type { PrepareMetroRuntimeOptions, ReloadMetroOptions } from './client-metro.ts'; +import type { StopMetroTunnelOptions } from './metro.ts'; + +export { normalizeBaseUrl } from './base-url.ts'; +export { buildBundleUrl } from './bundle-url.ts'; +export type { + MetroBridgeDescriptor, + MetroTunnelRequestMessage, + MetroTunnelResponseMessage, +} from './metro.ts'; + +export function resolveRuntimeTransport( + runtime: SessionRuntimeHints | undefined, +): { host: string; port: number; scheme: 'http' | 'https' } | undefined { + return resolveRuntimeTransportHints(runtime); +} + +export async function prepareMetroRuntime(input: PrepareMetroRuntimeOptions = {}) { + const runtime = await import('./client-metro.ts'); + return await runtime.prepareMetroRuntime(input); +} + +export async function reloadMetro(input: ReloadMetroOptions = {}) { + const runtime = await import('./client-metro.ts'); + return await runtime.reloadMetro(input); +} + +export async function stopMetroTunnel(options: StopMetroTunnelOptions): Promise { + const runtime = await import('./metro.ts'); + await runtime.stopMetroTunnel(options); +} diff --git a/src/metro/metro-reload-endpoints.test.ts b/packages/react-native/src/metro/metro-reload-endpoints.test.ts similarity index 100% rename from src/metro/metro-reload-endpoints.test.ts rename to packages/react-native/src/metro/metro-reload-endpoints.test.ts diff --git a/src/metro/metro-reload-endpoints.ts b/packages/react-native/src/metro/metro-reload-endpoints.ts similarity index 100% rename from src/metro/metro-reload-endpoints.ts rename to packages/react-native/src/metro/metro-reload-endpoints.ts diff --git a/src/metro/metro-session-hints.test.ts b/packages/react-native/src/metro/metro-session-hints.test.ts similarity index 100% rename from src/metro/metro-session-hints.test.ts rename to packages/react-native/src/metro/metro-session-hints.test.ts diff --git a/src/metro/metro-session-hints.ts b/packages/react-native/src/metro/metro-session-hints.ts similarity index 96% rename from src/metro/metro-session-hints.ts rename to packages/react-native/src/metro/metro-session-hints.ts index b7999361ed..c6a36c00c5 100644 --- a/src/metro/metro-session-hints.ts +++ b/packages/react-native/src/metro/metro-session-hints.ts @@ -1,6 +1,5 @@ import type { PrepareMetroRuntimeResult } from '@agent-device/contracts/remote'; import type { SessionRuntimeHints } from '@agent-device/kernel/contracts'; -import { resolveSessionName } from '../client/client-normalizers.ts'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import fs from 'node:fs'; import path from 'node:path'; @@ -15,12 +14,12 @@ export type MetroSessionHints = { export type MetroSessionScope = { stateDir: string; session: string }; -type MetroSessionOptions = { stateDir?: string; session?: string }; +type MetroSessionOptions = { stateDir?: string; session: string }; function resolveMetroSessionScope(options: MetroSessionOptions): MetroSessionScope { return { stateDir: resolveDaemonPaths(options.stateDir ?? process.env.AGENT_DEVICE_STATE_DIR).baseDir, - session: resolveSessionName(options.session), + session: options.session, }; } diff --git a/src/metro/metro-types.ts b/packages/react-native/src/metro/metro-types.ts similarity index 100% rename from src/metro/metro-types.ts rename to packages/react-native/src/metro/metro-types.ts diff --git a/src/metro/metro.ts b/packages/react-native/src/metro/metro.ts similarity index 84% rename from src/metro/metro.ts rename to packages/react-native/src/metro/metro.ts index 262733b55e..49dcc7270b 100644 --- a/src/metro/metro.ts +++ b/packages/react-native/src/metro/metro.ts @@ -1,15 +1,7 @@ -import type { SessionRuntimeHints } from '@agent-device/kernel/contracts'; import { stopMetroCompanion } from './client-metro-companion.ts'; -import { resolveRuntimeTransportHints } from '@agent-device/host-kit/runtime-transport-hints'; export type { MetroBridgeDescriptor } from './metro-types.ts'; -export function resolveRuntimeTransport( - runtime: SessionRuntimeHints | undefined, -): { host: string; port: number; scheme: 'http' | 'https' } | undefined { - return resolveRuntimeTransportHints(runtime); -} - export type MetroTunnelPingMessage = { type: 'ping'; timestamp: number; diff --git a/src/cli/react-devtools/react-devtools-inspect-command.test.ts b/packages/react-native/src/react-devtools/index-inspect.test.ts similarity index 93% rename from src/cli/react-devtools/react-devtools-inspect-command.test.ts rename to packages/react-native/src/react-devtools/index-inspect.test.ts index 33ecfa3b61..0a6fe651a3 100644 --- a/src/cli/react-devtools/react-devtools-inspect-command.test.ts +++ b/packages/react-native/src/react-devtools/index-inspect.test.ts @@ -48,7 +48,6 @@ test('tree prints the mounted components with refs', async () => { ' @c4 Row [memo] key=a', ' @c5 Row [memo] key=b (1 error)', ' @c6 Footer', - '', ].join('\n'), ); assert.match( @@ -61,7 +60,7 @@ test('find lists matching components with their owners', async () => { const { run } = harness(); assert.equal( (await run('find', 'row')).text, - '2 mounted components match "row":\n @c4 Row [memo] key=a in List < App\n @c5 Row [memo] key=b (1 error) in List < App\n', + '2 mounted components match "row":\n @c4 Row [memo] key=a in List < App\n @c5 Row [memo] key=b (1 error) in List < App', ); assert.match((await run('find', 'row', '--exact')).text, /^No mounted component name is "row"\./); }); @@ -79,7 +78,6 @@ test('inspect by ref prints props, hooks, owners, and logged problems', async () ' State: true', 'logged while rendering:', ' error: Failed prop type: label (x2)', - '', ].join('\n'), ); }); @@ -97,7 +95,7 @@ test('errors lists the components that logged while rendering, with their messag const { run } = harness(); assert.equal( (await run('errors')).text, - '1 mounted component logged errors or warnings while rendering:\n @c5 Row: 1 error\n error: Failed prop type: label (x2)\n', + '1 mounted component logged errors or warnings while rendering:\n @c5 Row: 1 error\n error: Failed prop type: label (x2)', ); }); @@ -116,13 +114,12 @@ test('a runtime whose tree another DevTools frontend owns fails with a typed rea test('errors says when the messages of a component could not be read instead of listing none', async () => { const app = listApp(); app.renderer.inspectElement = () => ({ type: 'error', message: 'hooks threw' }); - const { run, options } = harness(app); + const { run } = harness(app); assert.match( (await run('errors')).text, - /\n {2}@c5 Row: 1 error\n {4}messages unavailable: hooks threw\n$/, + /\n {2}@c5 Row: 1 error\n {4}messages unavailable: hooks threw$/, ); - options.flags = { json: true }; - const parsed = JSON.parse((await run('errors')).text) as { + const parsed = (await run('errors')) as { data: { components: Array<{ messagesUnavailable?: string; errors?: unknown }> }; }; assert.deepEqual(parsed.data.components[0]?.messagesUnavailable, 'hooks threw'); @@ -132,9 +129,8 @@ test('errors says when the messages of a component could not be read instead of test('errors returns the same shape when nothing logged', async () => { const app = new FakeAppRuntime(); app.mount([{ id: 2, name: 'App' }]); - const { run, options } = harness(app); - options.flags = { json: true }; - assert.deepEqual(JSON.parse((await run('errors')).text).data, { + const { run } = harness(app); + assert.deepEqual((await run('errors')).data, { total: 0, logsTracked: true, components: [], @@ -150,7 +146,6 @@ test('errors says its list can be incomplete when the backend does not track log [ 'No mounted component logged an error or warning while rendering.', "Note: this app's React DevTools backend does not attribute every console error and warning to a component, so this list can be incomplete; check agent-device logs as well.", - '', ].join('\n'), ); }); diff --git a/src/cli/react-devtools/react-profile-command.test.ts b/packages/react-native/src/react-devtools/index-profile.test.ts similarity index 93% rename from src/cli/react-devtools/react-profile-command.test.ts rename to packages/react-native/src/react-devtools/index-profile.test.ts index 39554589d4..42ae40e966 100644 --- a/src/cli/react-devtools/react-profile-command.test.ts +++ b/packages/react-native/src/react-devtools/index-profile.test.ts @@ -46,13 +46,11 @@ function recordRowRenders(app: FakeAppRuntime) { test('profile start, an interaction, and profile stop print a compact diagnosis and save it', async () => { const { app, run, stateDir, requested } = harness(); const started = await run('start'); - assert.equal(started.code, 0); assert.match(started.text, /^Profiling React renders in com\.example\.app\./); assert.deepEqual(requested, ['http://localhost:8081/json/list']); recordRowRenders(app); const stopped = await run('stop'); - assert.equal(stopped.code, 0); assert.equal( stopped.text.split('\n')[1], '1. memo-defeated Row: memo bypassed on 36/36 updates; new onPress (function) 36x from List -> wrap those props in useCallback/useMemo in List', @@ -70,7 +68,7 @@ test('profile start, an interaction, and profile stop print a compact diagnosis ); assert.match( (await run('commit', '--limit', '1')).text, - /^Commits \(1 slowest of 3, --limit 3 for all\):\n {2}#0 \+0\.0s 40ms 13 rendered <- List\n$/, + /^Commits \(1 slowest of 3, --limit 3 for all\):\n {2}#0 \+0\.0s 40ms 13 rendered <- List$/, ); assert.match( (await run('commit', '#1')).text, @@ -78,17 +76,13 @@ test('profile start, an interaction, and profile stop print a compact diagnosis ); }); -test('profile stop --json returns the findings and where the profile was saved', async () => { - const { app, run, options, stateDir } = harness(); +test('profile stop returns the findings and where the profile was saved', async () => { + const { app, run, stateDir } = harness(); await run('start'); recordRowRenders(app); - options.flags = { json: true }; - const { text } = await run('stop', '--limit', '1'); - const parsed = JSON.parse(text) as { - success: boolean; + const parsed = (await run('stop', '--limit', '1')) as { data: { findings: Array<{ kind: string }>; savedTo: string }; }; - assert.equal(parsed.success, true); assert.deepEqual( parsed.data.findings.map((f) => f.kind), ['memo-defeated'], @@ -141,7 +135,7 @@ test('a failed prop inspection becomes a note instead of failing the stop', asyn ); const { text } = await run('stop'); assert.match(text, /changed onPress 36x from List \(value kinds not inspected\)/); - assert.match(text, /\nNote: Changed prop values could not be inspected: inspector gone\n/); + assert.match(text, /\nNote: Changed prop values could not be inspected: inspector gone/); }); test('runtime failures carry a typed reason and a hint', async () => { @@ -203,14 +197,12 @@ test('unknown profile commands and malformed options are invalid arguments', asy } }); -test('profile commit --json honors --limit like the text view', async () => { - const { app, run, options } = harness(); +test('profile commit data honors --limit like the text view', async () => { + const { app, run } = harness(); await run('start'); recordRowRenders(app); await run('stop'); - options.flags = { json: true }; - const { text } = await run('commit', '1', '--limit', '1'); - const parsed = JSON.parse(text) as { + const parsed = (await run('commit', '1', '--limit', '1')) as { data: { components: Array<{ name: string }>; rendered: number }; }; assert.deepEqual( @@ -305,7 +297,6 @@ test('a renderer that fails while another records nothing is named in the empty [ 'React profile: no commits recorded. Start profiling, then perform the interaction before stopping.', "Note: A React renderer's profile could not be read: second root gone", - '', ].join('\n'), ); }); diff --git a/packages/react-native/src/react-devtools/index.ts b/packages/react-native/src/react-devtools/index.ts new file mode 100644 index 0000000000..e730a02fdf --- /dev/null +++ b/packages/react-native/src/react-devtools/index.ts @@ -0,0 +1,26 @@ +import { AppError } from '@agent-device/kernel/errors'; +import type { ReactDevtoolsCommandOptions } from './react-devtools-connection.ts'; +import type { ReactDevtoolsOutput } from './react-devtools-output.ts'; + +export type { ReactDevtoolsCommandOptions } from './react-devtools-connection.ts'; + +export async function runReactDevtools( + args: string[], + options: ReactDevtoolsCommandOptions, +): Promise { + const [command, ...rest] = args; + let output: ReactDevtoolsOutput | undefined; + if (command === 'profile') { + const { runReactProfileCommand } = await import('./react-profile-command.ts'); + output = await runReactProfileCommand(rest, options); + } else { + const { isInspectCommand, runReactInspectCommand } = + await import('./react-devtools-inspect-command.ts'); + if (isInspectCommand(command)) output = await runReactInspectCommand(command, rest, options); + } + if (!output) + throw new AppError('INVALID_ARGS', `Unknown react-devtools command: ${command ?? '(none)'}.`, { + hint: 'Use react-devtools tree, find, inspect, errors, or profile.', + }); + return output; +} diff --git a/src/cli/react-devtools/metro-inspector.test.ts b/packages/react-native/src/react-devtools/metro-inspector.test.ts similarity index 100% rename from src/cli/react-devtools/metro-inspector.test.ts rename to packages/react-native/src/react-devtools/metro-inspector.test.ts diff --git a/src/cli/react-devtools/metro-inspector.ts b/packages/react-native/src/react-devtools/metro-inspector.ts similarity index 100% rename from src/cli/react-devtools/metro-inspector.ts rename to packages/react-native/src/react-devtools/metro-inspector.ts diff --git a/src/cli/react-devtools/react-component-inspect.test.ts b/packages/react-native/src/react-devtools/react-component-inspect.test.ts similarity index 100% rename from src/cli/react-devtools/react-component-inspect.test.ts rename to packages/react-native/src/react-devtools/react-component-inspect.test.ts diff --git a/src/cli/react-devtools/react-component-inspect.ts b/packages/react-native/src/react-devtools/react-component-inspect.ts similarity index 100% rename from src/cli/react-devtools/react-component-inspect.ts rename to packages/react-native/src/react-devtools/react-component-inspect.ts diff --git a/src/cli/react-devtools/react-component-tree.test.ts b/packages/react-native/src/react-devtools/react-component-tree.test.ts similarity index 100% rename from src/cli/react-devtools/react-component-tree.test.ts rename to packages/react-native/src/react-devtools/react-component-tree.test.ts diff --git a/src/cli/react-devtools/react-component-tree.ts b/packages/react-native/src/react-devtools/react-component-tree.ts similarity index 100% rename from src/cli/react-devtools/react-component-tree.ts rename to packages/react-native/src/react-devtools/react-component-tree.ts diff --git a/src/cli/react-devtools/react-component.test.ts b/packages/react-native/src/react-devtools/react-component.test.ts similarity index 100% rename from src/cli/react-devtools/react-component.test.ts rename to packages/react-native/src/react-devtools/react-component.test.ts diff --git a/src/cli/react-devtools/react-component.ts b/packages/react-native/src/react-devtools/react-component.ts similarity index 100% rename from src/cli/react-devtools/react-component.ts rename to packages/react-native/src/react-devtools/react-component.ts diff --git a/src/cli/react-devtools/react-devtools-command.fixtures.ts b/packages/react-native/src/react-devtools/react-devtools-command.fixtures.ts similarity index 62% rename from src/cli/react-devtools/react-devtools-command.fixtures.ts rename to packages/react-native/src/react-devtools/react-devtools-command.fixtures.ts index ebe4af259c..2789380211 100644 --- a/src/cli/react-devtools/react-devtools-command.fixtures.ts +++ b/packages/react-native/src/react-devtools/react-devtools-command.fixtures.ts @@ -1,5 +1,4 @@ -import type { CliFlags } from '@agent-device/contracts/command'; -import { runReactDevtoolsCommand } from '../commands/react-devtools.ts'; +import { runReactDevtools } from './index.ts'; import { vi } from 'vitest'; import vm from 'node:vm'; import { mkdtempForTestSync } from '@agent-device/test-kit'; @@ -16,8 +15,8 @@ export const TARGET = { }; /** - * Serves `targets` from `/json/list`, evaluates debugger calls in `app`'s realm, and captures what - * the CLI prints. Callers restore the stubbed globals and spies after each test. + * Serves `targets` from `/json/list` and evaluates debugger calls in `app`'s realm. + * Callers restore the stubbed globals and spies after each test. */ export function commandHarness( prefix: string[] = [], @@ -25,9 +24,8 @@ export function commandHarness( targets: unknown[] = [TARGET], ) { const stateDir = mkdtempForTestSync('react-profile-'); - const output: string[] = []; const requested: string[] = []; - const options: ReactDevtoolsCommandOptions & { flags?: Partial> } = { + const options: ReactDevtoolsCommandOptions = { stateDir, session: 'qa', }; @@ -42,14 +40,6 @@ export function commandHarness( close: () => {}, }) as unknown as InspectorSession, ); - vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { - output.push(String(chunk)); - return true; - }); - const run = async (...args: string[]) => { - output.length = 0; - const code = await runReactDevtoolsCommand([...prefix, ...args], options); - return { code, text: output.join('') }; - }; + const run = async (...args: string[]) => await runReactDevtools([...prefix, ...args], options); return { app, run, options, stateDir, requested }; } diff --git a/src/cli/react-devtools/react-devtools-connection.ts b/packages/react-native/src/react-devtools/react-devtools-connection.ts similarity index 98% rename from src/cli/react-devtools/react-devtools-connection.ts rename to packages/react-native/src/react-devtools/react-devtools-connection.ts index 59772b2d43..7847a1c9e7 100644 --- a/src/cli/react-devtools/react-devtools-connection.ts +++ b/packages/react-native/src/react-devtools/react-devtools-connection.ts @@ -1,6 +1,6 @@ import { parseArgs } from 'node:util'; +import { resolveSessionMetroServerUrl } from '../metro/metro-session-hints.ts'; import { AppError } from '@agent-device/kernel/errors'; -import { resolveSessionMetroServerUrl } from '../../metro/metro-session-hints.ts'; import { debuggerUrlOn, InspectorSession, diff --git a/src/cli/react-devtools/react-devtools-inspect-command.ts b/packages/react-native/src/react-devtools/react-devtools-inspect-command.ts similarity index 97% rename from src/cli/react-devtools/react-devtools-inspect-command.ts rename to packages/react-native/src/react-devtools/react-devtools-inspect-command.ts index 561e210335..98580a4de6 100644 --- a/src/cli/react-devtools/react-devtools-inspect-command.ts +++ b/packages/react-native/src/react-devtools/react-devtools-inspect-command.ts @@ -1,5 +1,5 @@ import { componentRef } from './react-component.ts'; -import type { CliOutput } from '../../commands/command-contract.ts'; +import type { ReactDevtoolsOutput } from './react-devtools-output.ts'; import { AppError } from '@agent-device/kernel/errors'; import { targetAppId, type InspectorSession } from './metro-inspector.ts'; import { @@ -89,7 +89,10 @@ function requireTarget(args: DevtoolsArgs, usage: string): string { return target; } -type Handler = (args: DevtoolsArgs, options: ReactDevtoolsCommandOptions) => Promise; +type Handler = ( + args: DevtoolsArgs, + options: ReactDevtoolsCommandOptions, +) => Promise; const HANDLERS: Record = { tree: async (args, options) => { @@ -242,7 +245,7 @@ export async function runReactInspectCommand( command: InspectCommand, args: string[], options: ReactDevtoolsCommandOptions, -): Promise { +): Promise { const parsed = parseDevtoolsArgs(args, () => FORMS[command]); return await HANDLERS[command](parsed, options); } diff --git a/packages/react-native/src/react-devtools/react-devtools-output.ts b/packages/react-native/src/react-devtools/react-devtools-output.ts new file mode 100644 index 0000000000..50cab80ccd --- /dev/null +++ b/packages/react-native/src/react-devtools/react-devtools-output.ts @@ -0,0 +1,4 @@ +export type ReactDevtoolsOutput = { + data: unknown; + text: string; +}; diff --git a/src/cli/react-devtools/react-devtools-runtime.fixtures.ts b/packages/react-native/src/react-devtools/react-devtools-runtime.fixtures.ts similarity index 100% rename from src/cli/react-devtools/react-devtools-runtime.fixtures.ts rename to packages/react-native/src/react-devtools/react-devtools-runtime.fixtures.ts diff --git a/src/cli/react-devtools/react-devtools-runtime.test.ts b/packages/react-native/src/react-devtools/react-devtools-runtime.test.ts similarity index 100% rename from src/cli/react-devtools/react-devtools-runtime.test.ts rename to packages/react-native/src/react-devtools/react-devtools-runtime.test.ts diff --git a/src/cli/react-devtools/react-devtools-runtime.ts b/packages/react-native/src/react-devtools/react-devtools-runtime.ts similarity index 100% rename from src/cli/react-devtools/react-devtools-runtime.ts rename to packages/react-native/src/react-devtools/react-devtools-runtime.ts diff --git a/src/cli/react-devtools/react-devtools-text.ts b/packages/react-native/src/react-devtools/react-devtools-text.ts similarity index 100% rename from src/cli/react-devtools/react-devtools-text.ts rename to packages/react-native/src/react-devtools/react-devtools-text.ts diff --git a/src/cli/react-devtools/react-profile-analysis.test.ts b/packages/react-native/src/react-devtools/react-profile-analysis.test.ts similarity index 100% rename from src/cli/react-devtools/react-profile-analysis.test.ts rename to packages/react-native/src/react-devtools/react-profile-analysis.test.ts diff --git a/src/cli/react-devtools/react-profile-analysis.ts b/packages/react-native/src/react-devtools/react-profile-analysis.ts similarity index 100% rename from src/cli/react-devtools/react-profile-analysis.ts rename to packages/react-native/src/react-devtools/react-profile-analysis.ts diff --git a/src/cli/react-devtools/react-profile-command.ts b/packages/react-native/src/react-devtools/react-profile-command.ts similarity index 98% rename from src/cli/react-devtools/react-profile-command.ts rename to packages/react-native/src/react-devtools/react-profile-command.ts index 5b7eb1f846..2cd56fa8b5 100644 --- a/src/cli/react-devtools/react-profile-command.ts +++ b/packages/react-native/src/react-devtools/react-profile-command.ts @@ -1,4 +1,4 @@ -import type { CliOutput } from '../../commands/command-contract.ts'; +import type { ReactDevtoolsOutput } from './react-devtools-output.ts'; import fs from 'node:fs'; import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; @@ -225,7 +225,7 @@ async function stopProfile( type SubcommandHandler = ( args: ProfileArgs, options: ReactDevtoolsCommandOptions, -) => Promise; +) => Promise; function requireComponents(analysis: ReactProfileAnalysis, name: string): ComponentProfile[] { const components = findComponents(analysis, name); @@ -319,7 +319,7 @@ const SUBCOMMAND_HANDLERS: Record = { export async function runReactProfileCommand( args: string[], options: ReactDevtoolsCommandOptions, -): Promise { +): Promise { const parsed = parseProfileArgs(args); return await SUBCOMMAND_HANDLERS[parsed.subcommand](parsed, options); } diff --git a/src/cli/react-devtools/react-profile-format.test.ts b/packages/react-native/src/react-devtools/react-profile-format.test.ts similarity index 100% rename from src/cli/react-devtools/react-profile-format.test.ts rename to packages/react-native/src/react-devtools/react-profile-format.test.ts diff --git a/src/cli/react-devtools/react-profile-format.ts b/packages/react-native/src/react-devtools/react-profile-format.ts similarity index 100% rename from src/cli/react-devtools/react-profile-format.ts rename to packages/react-native/src/react-devtools/react-profile-format.ts diff --git a/src/cli/react-devtools/react-profile-renders.fixtures.ts b/packages/react-native/src/react-devtools/react-profile-renders.fixtures.ts similarity index 100% rename from src/cli/react-devtools/react-profile-renders.fixtures.ts rename to packages/react-native/src/react-devtools/react-profile-renders.fixtures.ts diff --git a/src/cli/react-devtools/react-profile-renders.test.ts b/packages/react-native/src/react-devtools/react-profile-renders.test.ts similarity index 100% rename from src/cli/react-devtools/react-profile-renders.test.ts rename to packages/react-native/src/react-devtools/react-profile-renders.test.ts diff --git a/src/cli/react-devtools/react-profile-renders.ts b/packages/react-native/src/react-devtools/react-profile-renders.ts similarity index 100% rename from src/cli/react-devtools/react-profile-renders.ts rename to packages/react-native/src/react-devtools/react-profile-renders.ts diff --git a/packages/test-kit/src/index.ts b/packages/test-kit/src/index.ts index b38c283df3..863ff1b1a5 100644 --- a/packages/test-kit/src/index.ts +++ b/packages/test-kit/src/index.ts @@ -93,3 +93,9 @@ export { withNodeRuntime, writeFakeManagedAgentBrowserPackage, } from './web-managed-agent-browser.ts'; + +export { + writeFakeMetroNpx, + findFreeMetroPort, + stopTestMetroProcess, +} from './metro-process.fixtures.ts'; diff --git a/packages/test-kit/src/metro-process.fixtures.ts b/packages/test-kit/src/metro-process.fixtures.ts new file mode 100644 index 0000000000..375e2f46d7 --- /dev/null +++ b/packages/test-kit/src/metro-process.fixtures.ts @@ -0,0 +1,74 @@ +import { chmodSync, writeFileSync } from 'node:fs'; +import net from 'node:net'; +import path from 'node:path'; +import { isProcessAlive, waitForProcessExit } from '@agent-device/host-kit/process'; +import { closeLoopbackServer, listenOnLoopback } from './loopback.ts'; + +export function writeFakeMetroNpx(binDir: string, reloadPath = '/reload'): void { + const filePath = path.join(binDir, 'npx'); + 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") +// "expo start" takes a connectivity mode ("lan", "tunnel", "localhost") for --host, not a bind +// address; every other caller (react-native/rspack/webpack start) passes a real bind address. +const rawHost = hostIndex === -1 ? "0.0.0.0" : String(args[hostIndex + 1] || "0.0.0.0") +const host = rawHost === "lan" || rawHost === "tunnel" ? "0.0.0.0" : rawHost +const reloadPath = ${JSON.stringify(reloadPath)} +const server = http.createServer((req, res) => { + if (req.url === "/status") { + res.statusCode = 200 + res.end("packager-status:running") + return + } + if (req.url && (req.url.startsWith("/index.bundle") || req.url.includes(".virtual-metro-entry.bundle"))) { + res.statusCode = 200 + res.setHeader("content-type", "application/javascript") + res.end("console.log('metro-runtime-test')") + return + } + if (req.url === reloadPath) { + res.statusCode = 200 + res.end("RELOADED") + return + } + res.statusCode = 404 + res.end("not found") +}) +server.listen(port, host) +setInterval(() => {}, 1000) +`, + ); + chmodSync(filePath, 0o755); +} + +export async function findFreeMetroPort(): Promise { + const server = net.createServer(); + const port = await listenOnLoopback(server); + await closeLoopbackServer(server); + return port; +} + +export async function stopTestMetroProcess(pid: number): Promise { + if (!pid || !isProcessAlive(pid)) return; + try { + process.kill(pid, 'SIGTERM'); + } catch { + return; + } + if (await waitForProcessExit(pid, 1_500)) return; + try { + process.kill(pid, 'SIGKILL'); + } catch { + return; + } + await waitForProcessExit(pid, 1_500); +} diff --git a/src/__tests__/remote-connection-proxy-allocate-by-spec.test.ts b/src/__tests__/remote-connection-proxy-allocate-by-spec.test.ts index 50fd691b14..cda807f050 100644 --- a/src/__tests__/remote-connection-proxy-allocate-by-spec.test.ts +++ b/src/__tests__/remote-connection-proxy-allocate-by-spec.test.ts @@ -13,8 +13,9 @@ import { unexpectedCommandCall, } from './remote-connection.fixtures.ts'; -vi.mock('../metro/client-metro-companion.ts', () => ({ - stopMetroCompanion: vi.fn(), +vi.mock('@agent-device/react-native/metro', async (importOriginal) => ({ + ...(await importOriginal()), + stopMetroTunnel: vi.fn(), })); import { materializeRemoteConnectionForCommand } from '../cli/commands/connection-runtime.ts'; diff --git a/src/__tests__/remote-connection-proxy-lease-id.test.ts b/src/__tests__/remote-connection-proxy-lease-id.test.ts index 07053678f0..d46cc4d208 100644 --- a/src/__tests__/remote-connection-proxy-lease-id.test.ts +++ b/src/__tests__/remote-connection-proxy-lease-id.test.ts @@ -9,8 +9,9 @@ import { seedConnectionState, } from './remote-connection.fixtures.ts'; -vi.mock('../metro/client-metro-companion.ts', () => ({ - stopMetroCompanion: vi.fn(), +vi.mock('@agent-device/react-native/metro', async (importOriginal) => ({ + ...(await importOriginal()), + stopMetroTunnel: vi.fn(), })); import { materializeRemoteConnectionForCommand } from '../cli/commands/connection-runtime.ts'; diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index 313e20afcd..00e38996b5 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -16,8 +16,9 @@ import { writeReplacedProfiles, } from './remote-connection.fixtures.ts'; -vi.mock('../metro/client-metro-companion.ts', () => ({ - stopMetroCompanion: vi.fn(), +vi.mock('@agent-device/react-native/metro', async (importOriginal) => ({ + ...(await importOriginal()), + stopMetroTunnel: vi.fn(), })); import { @@ -32,7 +33,7 @@ import { CLOUD_WEBDRIVER_REMOTE_LEASE_TTL_MS, PROXY_REMOTE_LEASE_TTL_MS, } from '../cli/commands/connection-runtime.ts'; -import { stopMetroCompanion } from '../metro/client-metro-companion.ts'; +import { stopMetroTunnel } from '@agent-device/react-native/metro'; import { AppError } from '@agent-device/kernel/errors'; import { hashRemoteConfigFile, @@ -1047,7 +1048,7 @@ test('deferred materialization re-prepares runtime when explicit Metro overrides platform: 'android', bundleUrl: 'https://sandbox.example.test/index.bundle?platform=android&dev=true', }); - assert.deepEqual(vi.mocked(stopMetroCompanion).mock.calls[0]?.[0], { + assert.deepEqual(vi.mocked(stopMetroTunnel).mock.calls[0]?.[0], { projectRoot: '/tmp/project-old', profileKey: remoteConfigPath, consumerKey: 'adc-android', @@ -1540,75 +1541,6 @@ test('deferred materialization does not require a lease backend for close', asyn fs.rmSync(tempRoot, { recursive: true, force: true }); }); -test('deferred materialization stops the new Metro companion if state persistence fails', async () => { - const { tempRoot, stateDir, remoteConfigPath } = connectionWorkspace( - 'agent-device-connect-write-fail-', - ); - fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); - const release = recordedLeaseRelease(); - seedConnectionState({ - stateDir, - state: { - session: 'adc-android', - remoteConfigPath, - daemon: { baseUrl: 'https://daemon.example' }, - tenant: 'acme', - runId: 'run-123', - platform: 'android', - metro: { - projectRoot: '/tmp/old-project', - profileKey: remoteConfigPath, - consumerKey: 'adc-android', - }, - }, - }); - - const originalRenameSync = fs.renameSync.bind(fs); - const writeFailure = new Error('state write failed'); - vi.spyOn(fs, 'renameSync').mockImplementation((oldPath, newPath) => { - if (String(newPath).endsWith(path.join('remote-connections', 'adc-android.json'))) { - throw writeFailure; - } - return originalRenameSync(oldPath, newPath); - }); - - await assert.rejects( - async () => - await materializeRemoteConnectionForCommand({ - command: 'open', - flags: { - json: true, - help: false, - version: false, - stateDir, - remoteConfig: remoteConfigPath, - daemonBaseUrl: 'https://daemon.example', - tenant: 'acme', - runId: 'run-123', - session: 'adc-android', - platform: 'android', - metroPublicBaseUrl: 'https://sandbox.example.test', - metroProxyBaseUrl: 'https://proxy.example.test', - }, - client: createTestClient({ release: release.stub }), - }), - writeFailure, - ); - - assert.equal(vi.mocked(stopMetroCompanion).mock.calls.length, 1); - assert.deepEqual(vi.mocked(stopMetroCompanion).mock.calls[0]?.[0], { - projectRoot: '/tmp/project', - profileKey: remoteConfigPath, - consumerKey: 'adc-android', - }); - assert.equal(release.request?.leaseId, 'lease-1'); - assert.equal(release.request?.tenant, 'acme'); - assert.equal(release.request?.runId, 'run-123'); - assert.equal(release.request?.leaseBackend, 'android-instance'); - - fs.rmSync(tempRoot, { recursive: true, force: true }); -}); - test('connect requires force when compatible scope changes platform', async () => { const { tempRoot, stateDir, remoteConfigPath } = connectionWorkspace( 'agent-device-connect-platform-', @@ -1730,7 +1662,7 @@ test('connect --force stops replaced Metro companion after state is updated', as }); }); - assert.deepEqual(vi.mocked(stopMetroCompanion).mock.calls[0]?.[0], { + assert.deepEqual(vi.mocked(stopMetroTunnel).mock.calls[0]?.[0], { projectRoot: '/tmp/old-project', profileKey: oldRemoteConfigPath, consumerKey: 'adc-android', @@ -1788,7 +1720,7 @@ test('connect --force without a session does not replace the active generated co assert.equal(activeState?.runId, 'run-new'); assert.equal(activeState?.remoteConfigPath, newRemoteConfigPath); assert.equal(release.request, undefined); - assert.equal(vi.mocked(stopMetroCompanion).mock.calls.length, 0); + assert.equal(vi.mocked(stopMetroTunnel).mock.calls.length, 0); assert.equal(storedSessions.length, 2); assert.equal( readRemoteConnectionState({ stateDir, session: 'adc-7f3a2c' })?.remoteConfigPath, @@ -2085,7 +2017,7 @@ test('disconnect tolerates prior close and removes local connection state', asyn assert.equal(handled, true); assert.equal(readRemoteConnectionState({ stateDir, session: 'adc-android' }), null); - assert.deepEqual(vi.mocked(stopMetroCompanion).mock.calls[0]?.[0], { + assert.deepEqual(vi.mocked(stopMetroTunnel).mock.calls[0]?.[0], { projectRoot: '/tmp/project', profileKey: remoteConfigPath, consumerKey: 'adc-android', diff --git a/src/agent-device-client-metro.test.ts b/src/agent-device-client-metro.test.ts new file mode 100644 index 0000000000..8029a0f839 --- /dev/null +++ b/src/agent-device-client-metro.test.ts @@ -0,0 +1,335 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createServer } from 'node:http'; +import type { Socket } from 'node:net'; +import path from 'node:path'; +import { createAgentDeviceClient } from './agent-device-client.ts'; +import { readMetroSessionHints } from '@agent-device/react-native/session'; +import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; +import { + closeLoopbackServer, + mkdtempForTestSync, + writeFakeMetroNpx, + findFreeMetroPort, + stopTestMetroProcess, +} from '@agent-device/test-kit'; + +const TEST_TOKEN = 'agent-device-proxy-test-token'; + +test('client prepare retains local control and valid proxy runtimes when one device URL is malformed', async () => { + const tempRoot = mkdtempForTestSync('agent-device-metro'); + const projectRoot = path.join(tempRoot, 'project'); + const binDir = path.join(tempRoot, 'bin'); + const runtimeFilePath = path.join(projectRoot, '.agent-device', 'metro-runtime.json'); + const metroPort = await findFreeMetroPort(); + const proxyPort = await findFreeMetroPort(); + const requests: string[] = []; + const proxySockets = new Set(); + + mkdirSync(projectRoot, { recursive: true }); + mkdirSync(binDir, { recursive: true }); + writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ + name: 'metro-runtime-test', + private: true, + dependencies: { + 'react-native': '0.0.0-test', + }, + }), + ); + writeFakeMetroNpx(binDir); + + const proxyServer = createServer(async (req, res) => { + if (req.headers.authorization !== `Bearer ${TEST_TOKEN}`) { + res.statusCode = 401; + res.end(JSON.stringify({ ok: false, error: 'unauthorized' })); + return; + } + + requests.push(req.url || ''); + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const body = JSON.parse(Buffer.concat(chunks).toString('utf8')) as { + tenantId?: string; + runId?: string; + leaseId?: string; + ios_runtime?: { metro_bundle_url?: string }; + }; + assert.equal(body.tenantId, 'tenant-1'); + assert.equal(body.runId, 'run-1'); + assert.equal(body.leaseId, 'lease-1'); + assert.equal(body.ios_runtime, undefined); + + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + if (req.url === '/api/metro/bridge') { + res.end( + JSON.stringify({ + ok: true, + data: { + enabled: true, + base_url: 'http://127.0.0.1:8081', + status_url: 'http://127.0.0.1:8081/status', + bundle_url: 'http://127.0.0.1:8081/index.bundle?platform=ios&dev=true&minify=false', + ios_runtime: { + metro_host: 'runtime-1.metro.agent-device.dev', + metro_port: 443, + metro_bundle_url: + 'https://runtime-1.metro.agent-device.dev/index.bundle?platform=ios&dev=true&minify=false', + }, + android_runtime: { + metro_host: 'bridge.example.test', + metro_port: 443, + metro_bundle_url: 'http://[broken', + }, + upstream: { + bundle_url: `http://127.0.0.1:${metroPort}/index.bundle?platform=ios&dev=true&minify=false`, + host: '127.0.0.1', + port: metroPort, + status_url: `http://127.0.0.1:${metroPort}/status`, + }, + probe: { + reachable: true, + status_code: 200, + latency_ms: 3, + detail: 'ok', + }, + }, + }), + ); + return; + } + + res.statusCode = 404; + res.end(JSON.stringify({ ok: false, error: 'not found' })); + }); + proxyServer.on('connection', (socket) => { + proxySockets.add(socket); + socket.on('close', () => proxySockets.delete(socket)); + }); + proxyServer.listen(proxyPort, '127.0.0.1'); + proxyServer.unref(); + await once(proxyServer, 'listening'); + + let pid = 0; + const previousPath = process.env.PATH; + process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ''}`; + const client = createAgentDeviceClient({ stateDir: tempRoot, session: 'bridge' }); + + try { + const result = await client.metro.prepare({ + projectRoot, + publicBaseUrl: `http://127.0.0.1:${metroPort}`, + proxyBaseUrl: `http://127.0.0.1:${proxyPort}`, + bearerToken: TEST_TOKEN, + bridgeScope: { + tenantId: 'tenant-1', + runId: 'run-1', + leaseId: 'lease-1', + }, + port: metroPort, + reuseExisting: false, + installDependenciesIfNeeded: false, + runtimeFilePath, + }); + + pid = result.pid; + assert.equal(result.kind, 'react-native'); + assert.equal(result.started, true); + assert.equal(result.reused, false); + assert.equal(result.bridge?.enabled, true); + assert.equal(result.iosRuntime.metroHost, 'runtime-1.metro.agent-device.dev'); + assert.equal(result.iosRuntime.metroPort, 443); + assert.equal(result.iosRuntime.platform, 'ios'); + assert.equal(result.androidRuntime.metroHost, 'bridge.example.test'); + assert.equal(result.androidRuntime.platform, 'android'); + assert.deepEqual(requests, ['/api/metro/bridge']); + + const written = JSON.parse(readFileSync(runtimeFilePath, 'utf8')) as typeof result; + assert.equal(written.iosRuntime.metroHost, 'runtime-1.metro.agent-device.dev'); + assert.equal(written.iosRuntime.metroPort, 443); + assert.equal(written.iosRuntime.platform, 'ios'); + assert.equal(written.androidRuntime.metroHost, 'bridge.example.test'); + assert.equal(written.androidRuntime.platform, 'android'); + assert.equal(written.runtimeFilePath, runtimeFilePath); + assert.deepEqual(readMetroSessionHints({ stateDir: tempRoot, session: 'bridge' }), { + controlBaseUrl: `http://127.0.0.1:${metroPort}/`, + deviceBaseUrls: ['https://runtime-1.metro.agent-device.dev/'], + }); + assert.equal((await client.metro.reload()).body, 'RELOADED'); + } finally { + process.env.PATH = previousPath; + for (const socket of proxySockets) { + socket.destroy(); + } + await closeLoopbackServer(proxyServer); + await stopTestMetroProcess(pid); + rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test('metro reload targets the dev server bound by metro prepare in the same session', async () => { + const tempRoot = mkdtempForTestSync('agent-device-metro-session'); + const projectRoot = path.join(tempRoot, 'project'); + const binDir = path.join(tempRoot, 'bin'); + const stateDir = path.join(tempRoot, 'state'); + const metroPort = await findFreeMetroPort(); + + mkdirSync(projectRoot, { recursive: true }); + mkdirSync(binDir, { recursive: true }); + writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ + name: 'metro-session-hints-test', + private: true, + dependencies: { 'react-native': '0.0.0-test' }, + }), + ); + writeFakeMetroNpx(binDir); + + const client = createAgentDeviceClient( + { session: 'metro-session-hints', stateDir, cwd: projectRoot }, + { + // Only session close may reach the daemon; metro prepare/reload must stay local. + transport: async (req) => { + if (req.command === 'close') return { ok: true, data: {} }; + throw new Error('metro prepare/reload must stay local and never call the daemon'); + }, + }, + ); + + // The public MetroPrepareOptions doesn't expose env, so the fake npx must be on the real PATH. + const previousPath = process.env.PATH; + process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ''}`; + + let pid = 0; + try { + const prepared = await client.metro.prepare({ + projectRoot, + publicBaseUrl: `http://127.0.0.1:${metroPort}`, + port: metroPort, + reuseExisting: false, + installDependenciesIfNeeded: false, + }); + pid = prepared.pid; + + const storedHints = readMetroSessionHints({ + stateDir: resolveDaemonPaths(stateDir).baseDir, + session: 'metro-session-hints', + }); + assert.deepEqual(storedHints, { + controlBaseUrl: `http://127.0.0.1:${metroPort}/`, + deviceBaseUrls: [`http://127.0.0.1:${metroPort}/`], + }); + + // No explicit --metro-host/--metro-port/--bundle-url: reload must resolve against the + // dev server this session's `metro prepare` bound, not the Metro default (localhost:8081). + const hintedReload = await client.metro.reload(); + assert.equal(hintedReload.reloadUrl, `http://127.0.0.1:${metroPort}/reload`); + assert.equal(hintedReload.body, 'RELOADED'); + + // Regression (prepare -> close -> same-name session): close clears the binding, so a later + // flagless reload resolves to the Metro default instead of silently hitting the stale port. + await client.sessions.close(); + assert.equal( + readMetroSessionHints({ + stateDir: resolveDaemonPaths(stateDir).baseDir, + session: 'metro-session-hints', + }), + undefined, + ); + } finally { + process.env.PATH = previousPath; + await stopTestMetroProcess(pid); + rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test('metro prepare keeps a public Expo address separate from the local reload address', async () => { + const tempRoot = mkdtempForTestSync('agent-device-metro-expo-session'); + const projectRoot = path.join(tempRoot, 'project'); + const binDir = path.join(tempRoot, 'bin'); + const stateDir = path.join(tempRoot, 'state'); + const metroPort = await findFreeMetroPort(); + const publicBasePath = '/metro/runtime-expo'; + + mkdirSync(projectRoot, { recursive: true }); + mkdirSync(binDir, { recursive: true }); + writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ + name: 'metro-expo-session-test', + private: true, + dependencies: { expo: '51.0.0', 'react-native': '0.0.0-test' }, + }), + ); + writeFakeMetroNpx(binDir); + + const client = createAgentDeviceClient( + { session: 'metro-expo-session', stateDir, cwd: projectRoot }, + { + transport: async () => { + throw new Error('metro prepare/reload must stay local and never call the daemon'); + }, + }, + ); + + const previousPath = process.env.PATH; + process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ''}`; + + let pid = 0; + try { + const prepared = await client.metro.prepare({ + projectRoot, + kind: 'expo', + publicBaseUrl: `https://127.0.0.1:${metroPort}${publicBasePath}`, + port: metroPort, + reuseExisting: false, + installDependenciesIfNeeded: false, + }); + pid = prepared.pid; + + const nextClient = createAgentDeviceClient( + { + session: 'metro-expo-session', + stateDir, + cwd: projectRoot, + runtime: prepared.iosRuntime, + }, + { + transport: async (request) => { + assert.equal(request.command, 'open'); + assert.deepEqual(request.runtime, prepared.iosRuntime); + return { ok: true, data: {} }; + }, + }, + ); + await nextClient.apps.open({ app: 'com.example.expo' }); + const hintedReload = await nextClient.metro.reload(); + assert.equal(hintedReload.reloadUrl, `http://127.0.0.1:${metroPort}/reload`); + assert.equal(hintedReload.body, 'RELOADED'); + assert.equal(hintedReload.transport, 'http'); + assert.equal( + prepared.iosRuntime.bundleUrl, + `https://127.0.0.1:${metroPort}${publicBasePath}/.expo/.virtual-metro-entry.bundle?platform=ios&dev=true&minify=false`, + ); + + const storedHints = readMetroSessionHints({ + stateDir: resolveDaemonPaths(stateDir).baseDir, + session: 'metro-expo-session', + }); + assert.deepEqual(storedHints, { + controlBaseUrl: `http://127.0.0.1:${metroPort}/`, + deviceBaseUrls: [`https://127.0.0.1:${metroPort}${publicBasePath}`], + }); + } finally { + process.env.PATH = previousPath; + await stopTestMetroProcess(pid); + rmSync(tempRoot, { recursive: true, force: true }); + } +}); diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index 1b662d201a..efc7583e74 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -60,13 +60,13 @@ import { import type { CommandResult } from '@agent-device/command-registry/command-result'; import { sendToDaemon } from './daemon-client/daemon-client.ts'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import { prepareMetroRuntime, reloadMetro } from './metro/client-metro.ts'; +import { prepareMetroRuntime, reloadMetro } from '@agent-device/react-native/metro'; import { clearMetroSessionHintsQuietly, recordPreparedMetroSession, recordOpenedMetroSession, readMetroSessionRuntime, -} from './metro/metro-session-hints.ts'; +} from '@agent-device/react-native/session'; import { asOptionalRecord, readPresentedSurfaceFact, @@ -221,7 +221,7 @@ export function createAgentDeviceClient( }; } finally { // Close is teardown intent: drop the dev-server binding even if the daemon call fails. - clearMetroSessionHintsQuietly(mergeClientOptions(config, options)); + clearMetroSessionHintsQuietly({ ...mergeClientOptions(config, options), session }); } }, saveScript: async ( @@ -276,11 +276,14 @@ export function createAgentDeviceClient( open: async (options: AppOpenOptions) => { const session = resolveRequestSession(options); const data = await executeCommand>('open', options); - recordOpenedMetroSession(mergeClientOptions(config, options), { - explicitRuntime: options.runtime !== undefined, - runtime: mergeClientOptions(config, options).runtime, - sessionReused: data.sessionReused === true, - }); + recordOpenedMetroSession( + { ...mergeClientOptions(config, options), session }, + { + explicitRuntime: options.runtime !== undefined, + runtime: mergeClientOptions(config, options).runtime, + sessionReused: data.sessionReused === true, + }, + ); const device = normalizeOpenDevice(data); const appBundleId = readOptionalString(data, 'appBundleId'); const appId = appBundleId; @@ -361,13 +364,18 @@ export function createAgentDeviceClient( runtimeFilePath: options.runtimeFilePath, logPath: options.logPath, }); - recordPreparedMetroSession(config, result); + recordPreparedMetroSession( + { ...config, session: resolveSessionName(config.session) }, + result, + ); return result; }, reload: async (options = {}) => await reloadMetro({ ...options, - runtime: readMetroSessionRuntime(config) ?? config.runtime, + runtime: + readMetroSessionRuntime({ ...config, session: resolveSessionName(config.session) }) ?? + config.runtime, }), }, capture: { diff --git a/src/cli/commands/connection-runtime-metro-teardown.test.ts b/src/cli/commands/connection-runtime-metro-teardown.test.ts new file mode 100644 index 0000000000..154f52efa7 --- /dev/null +++ b/src/cli/commands/connection-runtime-metro-teardown.test.ts @@ -0,0 +1,92 @@ +import { afterEach, test, vi } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { + connectionWorkspace, + createTestClient, + recordedLeaseRelease, + seedConnectionState, +} from '../../__tests__/remote-connection.fixtures.ts'; + +vi.mock('@agent-device/react-native/metro', async (importOriginal) => ({ + ...(await importOriginal()), + stopMetroTunnel: vi.fn(), +})); + +import { stopMetroTunnel } from '@agent-device/react-native/metro'; +import { materializeRemoteConnectionForCommand } from './connection-runtime.ts'; + +afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); +}); + +test('deferred materialization stops the new Metro companion if state persistence fails', async () => { + const { tempRoot, stateDir, remoteConfigPath } = connectionWorkspace( + 'agent-device-connect-write-fail-', + ); + fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); + const release = recordedLeaseRelease(); + seedConnectionState({ + stateDir, + state: { + session: 'adc-android', + remoteConfigPath, + daemon: { baseUrl: 'https://daemon.example' }, + tenant: 'acme', + runId: 'run-123', + platform: 'android', + metro: { + projectRoot: '/tmp/old-project', + profileKey: remoteConfigPath, + consumerKey: 'adc-android', + }, + }, + }); + + const originalRenameSync = fs.renameSync.bind(fs); + const writeFailure = new Error('state write failed'); + vi.spyOn(fs, 'renameSync').mockImplementation((oldPath, newPath) => { + if (String(newPath).endsWith(path.join('remote-connections', 'adc-android.json'))) { + throw writeFailure; + } + return originalRenameSync(oldPath, newPath); + }); + + await assert.rejects( + async () => + await materializeRemoteConnectionForCommand({ + command: 'open', + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + tenant: 'acme', + runId: 'run-123', + session: 'adc-android', + platform: 'android', + metroPublicBaseUrl: 'https://sandbox.example.test', + metroProxyBaseUrl: 'https://proxy.example.test', + }, + client: createTestClient({ release: release.stub }), + }), + writeFailure, + ); + + assert.equal(vi.mocked(stopMetroTunnel).mock.calls.length, 1); + assert.deepEqual(vi.mocked(stopMetroTunnel).mock.calls[0]?.[0], { + projectRoot: '/tmp/project', + profileKey: remoteConfigPath, + consumerKey: 'adc-android', + }); + assert.equal(release.request?.leaseId, 'lease-1'); + assert.equal(release.request?.tenant, 'acme'); + assert.equal(release.request?.runId, 'run-123'); + assert.equal(release.request?.leaseBackend, 'android-instance'); + + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 21793ef2cd..055618cd18 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -1,7 +1,7 @@ import crypto from 'node:crypto'; import type { MetroBridgeScope } from '@agent-device/contracts/remote'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import { stopMetroTunnel } from '../../metro/metro.ts'; +import { stopMetroTunnel } from '@agent-device/react-native/metro'; import { resolveRemoteConfigProfile } from '../../remote/remote-config.ts'; // Provenance-preserving file-only read (no ambient env defaults merged in) — // see resolvePreviousOwnDaemonAuthToken below for why this must not be diff --git a/src/cli/react-devtools/react-devtools-connection.test.ts b/src/cli/commands/react-devtools-connection.test.ts similarity index 68% rename from src/cli/react-devtools/react-devtools-connection.test.ts rename to src/cli/commands/react-devtools-connection.test.ts index 9a3f5b2c83..f39603573e 100644 --- a/src/cli/react-devtools/react-devtools-connection.test.ts +++ b/src/cli/commands/react-devtools-connection.test.ts @@ -3,14 +3,28 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import type { CliFlags } from '@agent-device/contracts/command'; -import { commandHarness } from './react-devtools-command.fixtures.ts'; +import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { runReactDevtoolsCommand } from './react-devtools.ts'; afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); -const harness = () => commandHarness(['profile']); +function harness() { + const requested: string[] = []; + const options: Parameters[1] = { + stateDir: mkdtempForTestSync('react-devtools-cli-'), + session: 'qa', + }; + vi.stubGlobal('fetch', async (url: URL) => { + requested.push(String(url)); + return new Response('[]', { status: 200 }); + }); + const run = async (...args: string[]) => + await runReactDevtoolsCommand(['profile', ...args], options); + return { run, options, stateDir: options.stateDir, requested }; +} function bindSession(stateDir: string, controlBaseUrl: string) { fs.mkdirSync(path.join(stateDir, 'metro-sessions'), { recursive: true }); @@ -22,7 +36,7 @@ function bindSession(stateDir: string, controlBaseUrl: string) { test('explicit dev-server flags override the session binding', async () => { const { run, requested } = harness(); - await run('start', '--metro-port', '8082'); + await assert.rejects(run('start', '--metro-port', '8082'), /No React Native runtime/); assert.deepEqual(requested, ['http://localhost:8082/json/list']); }); @@ -33,7 +47,7 @@ test('the session Metro binding picks the dev server', async () => { path.join(stateDir, 'metro-sessions', 'qa.json'), JSON.stringify({ controlBaseUrl: 'http://127.0.0.1:19000/' }), ); - await run('start'); + await assert.rejects(run('start'), /No React Native runtime/); assert.deepEqual(requested, ['http://127.0.0.1:19000/json/list']); }); @@ -44,14 +58,14 @@ test('a remote bridge without a Metro binding stays on the local dev server, not metroPublicBaseUrl: 'https://metro.example.test/index.bundle?platform=android', }; options.flags = flags; - await run('start'); + await assert.rejects(run('start'), /No React Native runtime/); assert.deepEqual(requested, ['http://localhost:8081/json/list']); }); test('a host flag on its own keeps the port of the session binding', async () => { const { run, requested, stateDir } = harness(); bindSession(stateDir, 'http://127.0.0.1:19000/'); - await run('start', '--metro-host', '10.0.2.2'); + await assert.rejects(run('start', '--metro-host', '10.0.2.2'), /No React Native runtime/); assert.deepEqual(requested, ['http://10.0.2.2:19000/json/list']); }); diff --git a/src/cli/commands/react-devtools.test.ts b/src/cli/commands/react-devtools.test.ts new file mode 100644 index 0000000000..70241ea5f6 --- /dev/null +++ b/src/cli/commands/react-devtools.test.ts @@ -0,0 +1,57 @@ +import { afterEach, test, vi } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { runReactDevtoolsCommand } from './react-devtools.ts'; + +afterEach(() => vi.restoreAllMocks()); + +for (const json of [false, true]) { + test(`saved profile output preserves ${json ? 'JSON envelope' : 'text newline'} through the package`, async () => { + const stateDir = mkdtempForTestSync('react-devtools-cli-'); + const dir = path.join(stateDir, 'react-profiles'); + fs.mkdirSync(dir); + fs.writeFileSync( + path.join(dir, 'qa.json'), + JSON.stringify({ + version: 2, + savedAt: '2026-10-16T00:00:00.000Z', + profile: { + startedAt: 0, + stoppedAt: 1000, + naming: 'operations', + elements: {}, + keys: [], + commits: [], + }, + propKinds: {}, + notes: [], + }), + ); + const output: string[] = []; + vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + output.push(String(chunk)); + return true; + }); + const code = await runReactDevtoolsCommand(['profile', 'commit'], { + stateDir, + session: 'qa', + flags: { json }, + }); + assert.equal(code, 0); + assert.equal( + output.join(''), + json + ? JSON.stringify({ success: true, data: { commits: [] } }, null, 2) + '\n' + : 'No commits recorded.\n', + ); + }); +} + +test('unknown commands preserve the package error at the CLI adapter', async () => { + await assert.rejects( + runReactDevtoolsCommand(['unknown'], { stateDir: '/unused', session: 'qa' }), + { code: 'INVALID_ARGS', message: 'Unknown react-devtools command: unknown.' }, + ); +}); diff --git a/src/cli/commands/react-devtools.ts b/src/cli/commands/react-devtools.ts index 2979265628..f40bbc5f83 100644 --- a/src/cli/commands/react-devtools.ts +++ b/src/cli/commands/react-devtools.ts @@ -1,28 +1,15 @@ import type { CliFlags } from '@agent-device/contracts/command'; -import { writeCliOutput } from './shared.ts'; -import { AppError } from '@agent-device/kernel/errors'; -import type { ReactDevtoolsCommandOptions } from '../react-devtools/react-devtools-connection.ts'; import { - isInspectCommand, - runReactInspectCommand, -} from '../react-devtools/react-devtools-inspect-command.ts'; -import { runReactProfileCommand } from '../react-devtools/react-profile-command.ts'; + runReactDevtools, + type ReactDevtoolsCommandOptions, +} from '@agent-device/react-native/react-devtools'; +import { writeCliOutput } from './shared.ts'; export async function runReactDevtoolsCommand( args: string[], options: ReactDevtoolsCommandOptions & { flags?: Partial> }, ): Promise { - const [command, ...rest] = args; - const output = - command === 'profile' - ? await runReactProfileCommand(rest, options) - : isInspectCommand(command) - ? await runReactInspectCommand(command, rest, options) - : undefined; - if (!output) - throw new AppError('INVALID_ARGS', `Unknown react-devtools command: ${command ?? '(none)'}.`, { - hint: 'Use react-devtools tree, find, inspect, errors, or profile.', - }); + const output = await runReactDevtools(args, options); await writeCliOutput(options.flags ?? {}, output); return 0; } diff --git a/src/commands/management/app.test.ts b/src/commands/management/app.test.ts index f72538de59..665b923804 100644 --- a/src/commands/management/app.test.ts +++ b/src/commands/management/app.test.ts @@ -8,7 +8,7 @@ import type { CliFlags } from '@agent-device/contracts/command'; import { parseArgs } from '../../cli/parser/args.ts'; import { createAgentDeviceClient } from '../../agent-device-client.ts'; import type { DaemonRequest, DaemonResponse } from '@agent-device/kernel/contracts'; -import { readMetroSessionHints, writeMetroSessionHints } from '../../metro/metro-session-hints.ts'; +import { readMetroSessionHints, writeMetroSessionHints } from '@agent-device/react-native/session'; import { openCommandFacet } from './app.ts'; import { closeLoopbackServer, diff --git a/src/sdk/metro.ts b/src/sdk/metro.ts index ef6a6a8d7f..6b5c9b2e3b 100644 --- a/src/sdk/metro.ts +++ b/src/sdk/metro.ts @@ -1,9 +1,11 @@ -export { normalizeBaseUrl } from '../client/base-url.ts'; -export { buildBundleUrl } from '../metro/bundle-url.ts'; -export type { - MetroBridgeDescriptor, - MetroTunnelRequestMessage, - MetroTunnelResponseMessage, -} from '../metro/metro.ts'; -export { resolveRuntimeTransport, stopMetroTunnel } from '../metro/metro.ts'; -export { prepareMetroRuntime, reloadMetro } from '../metro/client-metro.ts'; +export { + normalizeBaseUrl, + buildBundleUrl, + resolveRuntimeTransport, + stopMetroTunnel, + prepareMetroRuntime, + reloadMetro, + type MetroBridgeDescriptor, + type MetroTunnelRequestMessage, + type MetroTunnelResponseMessage, +} from '@agent-device/react-native/metro'; From 105fdd95eaf3965cbc3bb184ef1896fbecaa0c05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 12:13:42 +0200 Subject: [PATCH 2/3] refactor(react-native): expose one lazy public interface --- .../client-companion-tunnel-worker.ts | 2 +- .../client-metro-companion.test.ts | 2 +- .../client-metro-companion.ts | 4 +- .../metro-tunnel-messages.ts} | 14 --- ...ient-metro.test.ts => index-metro.test.ts} | 87 +-------------- .../src/index-react-command-routing.test.ts | 8 +- ...ct.test.ts => index-react-inspect.test.ts} | 8 +- ...le.test.ts => index-react-profile.test.ts} | 10 +- packages/react-native/src/index.ts | 100 ++++++++++++++++++ .../metro/client-metro-auto-companion.test.ts | 4 +- .../react-native/src/metro/client-metro.ts | 2 +- packages/react-native/src/metro/index.ts | 33 ------ .../src/metro/metro-reload-endpoints.test.ts | 86 ++++++++++++++- .../src/metro/metro-session-hints.ts | 14 +-- .../react-native/src/react-devtools/index.ts | 26 ----- .../react-devtools-command.fixtures.ts | 2 +- packages/test-kit/src/index.ts | 2 + .../test-kit/src/metro-session.fixtures.ts | 22 ++++ ...-connection-proxy-allocate-by-spec.test.ts | 4 +- .../remote-connection-proxy-lease-id.test.ts | 4 +- src/__tests__/remote-connection.test.ts | 6 +- src/agent-device-client-metro.test.ts | 55 ++++++++-- src/agent-device-client.ts | 17 +-- .../connection-runtime-metro-teardown.test.ts | 6 +- src/cli/commands/connection-runtime.ts | 2 +- .../react-devtools-connection.test.ts | 28 ++--- src/cli/commands/react-devtools.ts | 5 +- src/commands/capture/runtime/snapshot.ts | 2 +- src/commands/management/app.test.ts | 79 ++++---------- src/commands/react-native/index.ts | 2 - src/commands/react-native/overlay.ts | 1 - src/sdk/metro.ts | 2 +- 32 files changed, 346 insertions(+), 293 deletions(-) rename packages/react-native/src/{metro => companion}/client-metro-companion.test.ts (99%) rename packages/react-native/src/{metro => companion}/client-metro-companion.ts (86%) rename packages/react-native/src/{metro/metro.ts => companion/metro-tunnel-messages.ts} (81%) rename packages/react-native/src/{metro/client-metro.test.ts => index-metro.test.ts} (86%) rename src/__tests__/cli-react-devtools.test.ts => packages/react-native/src/index-react-command-routing.test.ts (78%) rename packages/react-native/src/{react-devtools/index-inspect.test.ts => index-react-inspect.test.ts} (96%) rename packages/react-native/src/{react-devtools/index-profile.test.ts => index-react-profile.test.ts} (97%) create mode 100644 packages/react-native/src/index.ts delete mode 100644 packages/react-native/src/metro/index.ts delete mode 100644 packages/react-native/src/react-devtools/index.ts create mode 100644 packages/test-kit/src/metro-session.fixtures.ts delete mode 100644 src/commands/react-native/overlay.ts diff --git a/packages/react-native/src/companion/client-companion-tunnel-worker.ts b/packages/react-native/src/companion/client-companion-tunnel-worker.ts index 956092d1b6..13cbd01467 100644 --- a/packages/react-native/src/companion/client-companion-tunnel-worker.ts +++ b/packages/react-native/src/companion/client-companion-tunnel-worker.ts @@ -21,7 +21,7 @@ import { import type { MetroTunnelRequestMessage as MetroCompanionRequest, MetroTunnelResponseMessage, -} from '../metro/metro.ts'; +} from './metro-tunnel-messages.ts'; import { normalizeBaseUrl } from '../metro/base-url.ts'; const COMPANION_REGISTER_TIMEOUT_MS = 5_000; diff --git a/packages/react-native/src/metro/client-metro-companion.test.ts b/packages/react-native/src/companion/client-metro-companion.test.ts similarity index 99% rename from packages/react-native/src/metro/client-metro-companion.test.ts rename to packages/react-native/src/companion/client-metro-companion.test.ts index 5af9a5e4f5..2e2a17b988 100644 --- a/packages/react-native/src/metro/client-metro-companion.test.ts +++ b/packages/react-native/src/companion/client-metro-companion.test.ts @@ -29,7 +29,7 @@ import { } from '@agent-device/host-kit/process'; import { ensureMetroCompanion, stopMetroCompanion } from './client-metro-companion.ts'; -import { METRO_COMPANION_RUN_ARG } from '../companion/client-companion-tunnel-contract.ts'; +import { METRO_COMPANION_RUN_ARG } from './client-companion-tunnel-contract.ts'; const TEST_BRIDGE_SCOPE = { tenantId: 'tenant-1', diff --git a/packages/react-native/src/metro/client-metro-companion.ts b/packages/react-native/src/companion/client-metro-companion.ts similarity index 86% rename from packages/react-native/src/metro/client-metro-companion.ts rename to packages/react-native/src/companion/client-metro-companion.ts index eab61a2acd..9ee4f3bb81 100644 --- a/packages/react-native/src/metro/client-metro-companion.ts +++ b/packages/react-native/src/companion/client-metro-companion.ts @@ -4,8 +4,8 @@ import { type CompanionTunnelDefinition, type EnsureCompanionTunnelOptions, type StopCompanionTunnelOptions, -} from '../companion/client-companion-tunnel.ts'; -import { METRO_COMPANION_RUN_ARG } from '../companion/client-companion-tunnel-contract.ts'; +} from './client-companion-tunnel.ts'; +import { METRO_COMPANION_RUN_ARG } from './client-companion-tunnel-contract.ts'; const METRO_COMPANION_REGISTER_PATH = '/api/metro/companion/register'; diff --git a/packages/react-native/src/metro/metro.ts b/packages/react-native/src/companion/metro-tunnel-messages.ts similarity index 81% rename from packages/react-native/src/metro/metro.ts rename to packages/react-native/src/companion/metro-tunnel-messages.ts index 49dcc7270b..94503c2b22 100644 --- a/packages/react-native/src/metro/metro.ts +++ b/packages/react-native/src/companion/metro-tunnel-messages.ts @@ -1,7 +1,3 @@ -import { stopMetroCompanion } from './client-metro-companion.ts'; - -export type { MetroBridgeDescriptor } from './metro-types.ts'; - export type MetroTunnelPingMessage = { type: 'ping'; timestamp: number; @@ -78,13 +74,3 @@ export type MetroTunnelResponseMessage = | MetroTunnelWebSocketOpenResultMessage | MetroTunnelWebSocketFrameMessage | MetroTunnelWebSocketCloseMessage; - -export type StopMetroTunnelOptions = { - projectRoot: string; - profileKey?: string; - consumerKey?: string; -}; - -export async function stopMetroTunnel(options: StopMetroTunnelOptions): Promise { - await stopMetroCompanion(options); -} diff --git a/packages/react-native/src/metro/client-metro.test.ts b/packages/react-native/src/index-metro.test.ts similarity index 86% rename from packages/react-native/src/metro/client-metro.test.ts rename to packages/react-native/src/index-metro.test.ts index 349ca0f59b..724731a967 100644 --- a/packages/react-native/src/metro/client-metro.test.ts +++ b/packages/react-native/src/index-metro.test.ts @@ -5,8 +5,7 @@ import { createHash } from 'node:crypto'; import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { createServer } from 'node:http'; import path from 'node:path'; -import { prepareMetroRuntime, reloadMetro } from './client-metro.ts'; -import { resolveMetroReloadEndpoints } from './metro-reload-endpoints.ts'; +import { prepareMetroRuntime, reloadMetro } from './index.ts'; import { AppError } from '@agent-device/kernel/errors'; import { closeLoopbackServer, @@ -518,90 +517,6 @@ test('reloadMetro defaults to local Metro host and port', async () => { } }); -test('reload endpoint resolution prioritizes explicit flags, then session runtime hints, then defaults', () => { - // no-hint default: neither an explicit flag nor a runtime hint is present. - assert.equal(resolveMetroReloadEndpoints({}).reloadUrl, 'http://localhost:8081/reload'); - - // hint-only: a session runtime hint resolves the target when no flag is given. - assert.equal( - resolveMetroReloadEndpoints({ runtime: { metroHost: '127.0.0.1', metroPort: 9200 } }).reloadUrl, - 'http://127.0.0.1:9200/reload', - ); - - // flag-overrides-hint: an explicit flag wins over a conflicting session runtime hint. - assert.equal( - resolveMetroReloadEndpoints({ - metroHost: '10.0.0.5', - metroPort: 9300, - runtime: { metroHost: '127.0.0.1', metroPort: 9200 }, - }).reloadUrl, - 'http://10.0.0.5:9300/reload', - ); - - // A single explicit flag still overrides only its own field; the hint fills the rest. - assert.equal( - resolveMetroReloadEndpoints({ - metroPort: 9400, - runtime: { metroHost: '192.168.1.5', metroPort: 9200 }, - }).reloadUrl, - 'http://192.168.1.5:9400/reload', - ); -}); - -test('resolveMetroReloadEndpoints keeps the bundle URL mount prefix instead of collapsing to the host root', () => { - // Expo virtual entry at the server root: the entry-module path is not a mount prefix, so the - // endpoints live at the root (verified live: Expo serves /message at the server root). - assert.deepEqual( - resolveMetroReloadEndpoints({ - runtime: { - metroHost: '127.0.0.1', - metroPort: 8082, - bundleUrl: - 'http://127.0.0.1:8082/.expo/.virtual-metro-entry.bundle?platform=ios&dev=true&minify=false', - }, - }), - { - reloadUrl: 'http://127.0.0.1:8082/reload', - messageSocketUrl: 'ws://127.0.0.1:8082/message', - }, - ); - - // Proxy/mount prefix carrying the Expo virtual entry keeps the mount prefix only. - assert.deepEqual( - resolveMetroReloadEndpoints({ - bundleUrl: - 'http://proxy.example.test/tenant-42/.expo/.virtual-metro-entry.bundle?platform=android', - }), - { - reloadUrl: 'http://proxy.example.test/tenant-42/reload', - messageSocketUrl: 'ws://proxy.example.test/tenant-42/message', - }, - ); - - // Proxy/mount prefix carrying a plain index.bundle keeps the prefix (Re.Pack/RN over a proxy); - // https maps the message socket to wss. - assert.deepEqual( - resolveMetroReloadEndpoints({ - bundleUrl: 'https://proxy.example.test/metro/runtime-1/index.bundle?platform=ios', - }), - { - reloadUrl: 'https://proxy.example.test/metro/runtime-1/reload', - messageSocketUrl: 'wss://proxy.example.test/metro/runtime-1/message', - }, - ); - - // Root index.bundle still reloads at the host root. - assert.deepEqual( - resolveMetroReloadEndpoints({ - bundleUrl: 'http://127.0.0.1:8081/index.bundle?platform=ios', - }), - { - reloadUrl: 'http://127.0.0.1:8081/reload', - messageSocketUrl: 'ws://127.0.0.1:8081/message', - }, - ); -}); - test('reloadMetro falls back to the /message websocket when the HTTP route answers with the app page', async () => { const receivedMessages: string[] = []; const upgradedSockets = new Set(); diff --git a/src/__tests__/cli-react-devtools.test.ts b/packages/react-native/src/index-react-command-routing.test.ts similarity index 78% rename from src/__tests__/cli-react-devtools.test.ts rename to packages/react-native/src/index-react-command-routing.test.ts index d93c6cb7c1..68a88dadc7 100644 --- a/src/__tests__/cli-react-devtools.test.ts +++ b/packages/react-native/src/index-react-command-routing.test.ts @@ -1,12 +1,12 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { runReactDevtoolsCommand } from '../cli/commands/react-devtools.ts'; +import { runReactDevtools } from './index.ts'; const options = { stateDir: '/tmp/agent-device-state', session: 'default' }; test('unknown react-devtools commands are invalid arguments that list the commands', async () => { await assert.rejects( - runReactDevtoolsCommand(['get', 'tree'], options), + runReactDevtools(['get', 'tree'], options), (error: { code: string; message: string; details: { hint: string } }) => { assert.equal(error.code, 'INVALID_ARGS'); assert.equal(error.message, 'Unknown react-devtools command: get.'); @@ -18,14 +18,14 @@ test('unknown react-devtools commands are invalid arguments that list the comman test('react-devtools profile routes to the built-in profiler', async () => { await assert.rejects( - runReactDevtoolsCommand(['profile', 'export', 'out.json'], options), + runReactDevtools(['profile', 'export', 'out.json'], options), /Unknown react-devtools profile command: export/, ); }); test('react-devtools inspection commands route to the component inspector', async () => { await assert.rejects( - runReactDevtoolsCommand(['inspect'], options), + runReactDevtools(['inspect'], options), /react-devtools inspect <@ref\|Name> needs an argument/, ); }); diff --git a/packages/react-native/src/react-devtools/index-inspect.test.ts b/packages/react-native/src/index-react-inspect.test.ts similarity index 96% rename from packages/react-native/src/react-devtools/index-inspect.test.ts rename to packages/react-native/src/index-react-inspect.test.ts index 0a6fe651a3..d15cee0c9c 100644 --- a/packages/react-native/src/react-devtools/index-inspect.test.ts +++ b/packages/react-native/src/index-react-inspect.test.ts @@ -1,7 +1,11 @@ import { afterEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; -import { commandHarness } from './react-devtools-command.fixtures.ts'; -import { FakeAppRuntime, MEMO_TYPE, encodeOperations } from './react-devtools-runtime.fixtures.ts'; +import { commandHarness } from './react-devtools/react-devtools-command.fixtures.ts'; +import { + FakeAppRuntime, + MEMO_TYPE, + encodeOperations, +} from './react-devtools/react-devtools-runtime.fixtures.ts'; afterEach(() => { vi.restoreAllMocks(); diff --git a/packages/react-native/src/react-devtools/index-profile.test.ts b/packages/react-native/src/index-react-profile.test.ts similarity index 97% rename from packages/react-native/src/react-devtools/index-profile.test.ts rename to packages/react-native/src/index-react-profile.test.ts index 42ae40e966..c3f84c1502 100644 --- a/packages/react-native/src/react-devtools/index-profile.test.ts +++ b/packages/react-native/src/index-react-profile.test.ts @@ -3,9 +3,13 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import vm from 'node:vm'; -import { InspectorSession } from './metro-inspector.ts'; -import { commandHarness, TARGET } from './react-devtools-command.fixtures.ts'; -import { FakeAppRuntime, MEMO_TYPE, encodeOperations } from './react-devtools-runtime.fixtures.ts'; +import { InspectorSession } from './react-devtools/metro-inspector.ts'; +import { commandHarness, TARGET } from './react-devtools/react-devtools-command.fixtures.ts'; +import { + FakeAppRuntime, + MEMO_TYPE, + encodeOperations, +} from './react-devtools/react-devtools-runtime.fixtures.ts'; const harness = (app?: FakeAppRuntime, targets?: unknown[]) => commandHarness(['profile'], app, targets); diff --git a/packages/react-native/src/index.ts b/packages/react-native/src/index.ts new file mode 100644 index 0000000000..c3608a4ed8 --- /dev/null +++ b/packages/react-native/src/index.ts @@ -0,0 +1,100 @@ +import type { MetroSessionOptions, MetroSessionOpenInput } from './metro/metro-session-hints.ts'; +import type { SessionRuntimeHints } from '@agent-device/kernel/contracts'; +import { resolveRuntimeTransportHints } from '@agent-device/host-kit/runtime-transport-hints'; +import type { PrepareMetroRuntimeOptions, ReloadMetroOptions } from './metro/client-metro.ts'; +import type { PrepareMetroRuntimeResult, ReloadMetroResult } from '@agent-device/contracts/remote'; +import { AppError } from '@agent-device/kernel/errors'; +import type { ReactDevtoolsCommandOptions } from './react-devtools/react-devtools-connection.ts'; +import type { ReactDevtoolsOutput } from './react-devtools/react-devtools-output.ts'; + +export type { PrepareMetroRuntimeOptions, ReloadMetroOptions } from './metro/client-metro.ts'; +export type { PrepareMetroRuntimeResult, ReloadMetroResult } from '@agent-device/contracts/remote'; +export type { MetroBridgeDescriptor } from './metro/metro-types.ts'; +export type { ReactDevtoolsCommandOptions } from './react-devtools/react-devtools-connection.ts'; +export type { ReactDevtoolsOutput } from './react-devtools/react-devtools-output.ts'; +export type { MetroSessionOptions, MetroSessionOpenInput } from './metro/metro-session-hints.ts'; + +export type StopMetroTunnelOptions = { + projectRoot: string; + profileKey?: string; + consumerKey?: string; +}; + +export { normalizeBaseUrl } from './metro/base-url.ts'; +export { buildBundleUrl } from './metro/bundle-url.ts'; +export type { + MetroTunnelRequestMessage, + MetroTunnelResponseMessage, +} from './companion/metro-tunnel-messages.ts'; + +export function resolveRuntimeTransport( + runtime: SessionRuntimeHints | undefined, +): { host: string; port: number; scheme: 'http' | 'https' } | undefined { + return resolveRuntimeTransportHints(runtime); +} + +export async function prepareMetroRuntime( + input: PrepareMetroRuntimeOptions = {}, +): Promise { + const runtime = await import('./metro/client-metro.ts'); + return await runtime.prepareMetroRuntime(input); +} + +export async function reloadMetro(input: ReloadMetroOptions = {}): Promise { + const runtime = await import('./metro/client-metro.ts'); + return await runtime.reloadMetro(input); +} + +export async function stopMetroTunnel(options: StopMetroTunnelOptions): Promise { + const { stopMetroCompanion } = await import('./companion/client-metro-companion.ts'); + await stopMetroCompanion(options); +} + +export async function runReactDevtools( + args: string[], + options: ReactDevtoolsCommandOptions, +): Promise { + const [command, ...rest] = args; + let output: ReactDevtoolsOutput | undefined; + if (command === 'profile') { + const { runReactProfileCommand } = await import('./react-devtools/react-profile-command.ts'); + output = await runReactProfileCommand(rest, options); + } else { + const { isInspectCommand, runReactInspectCommand } = + await import('./react-devtools/react-devtools-inspect-command.ts'); + if (isInspectCommand(command)) output = await runReactInspectCommand(command, rest, options); + } + if (!output) + throw new AppError('INVALID_ARGS', `Unknown react-devtools command: ${command ?? '(none)'}.`, { + hint: 'Use react-devtools tree, find, inspect, errors, or profile.', + }); + return output; +} + +export async function recordPreparedMetroSession( + options: MetroSessionOptions, + result: Pick, +): Promise { + const session = await import('./metro/metro-session-hints.ts'); + session.recordPreparedMetroSession(options, result); +} + +export async function recordOpenedMetroSession( + options: MetroSessionOptions, + input: MetroSessionOpenInput, +): Promise { + const session = await import('./metro/metro-session-hints.ts'); + session.recordOpenedMetroSession(options, input); +} + +export async function readMetroSessionRuntime( + options: MetroSessionOptions, +): Promise { + const session = await import('./metro/metro-session-hints.ts'); + return session.readMetroSessionRuntime(options); +} + +export async function clearMetroSessionHintsQuietly(options: MetroSessionOptions): Promise { + const session = await import('./metro/metro-session-hints.ts'); + session.clearMetroSessionHintsQuietly(options); +} diff --git a/packages/react-native/src/metro/client-metro-auto-companion.test.ts b/packages/react-native/src/metro/client-metro-auto-companion.test.ts index 7dbb26dd3f..59b1a101aa 100644 --- a/packages/react-native/src/metro/client-metro-auto-companion.test.ts +++ b/packages/react-native/src/metro/client-metro-auto-companion.test.ts @@ -5,11 +5,11 @@ import path from 'node:path'; import { mkdtempForTestSync } from '@agent-device/test-kit'; import { AppError } from '@agent-device/kernel/errors'; -vi.mock('./client-metro-companion.ts', () => ({ +vi.mock('../companion/client-metro-companion.ts', () => ({ ensureMetroCompanion: vi.fn(), })); -import { ensureMetroCompanion } from './client-metro-companion.ts'; +import { ensureMetroCompanion } from '../companion/client-metro-companion.ts'; import { prepareMetroRuntime } from './client-metro.ts'; const TEST_BRIDGE_SCOPE = { diff --git a/packages/react-native/src/metro/client-metro.ts b/packages/react-native/src/metro/client-metro.ts index c986753cb7..c5676cadc7 100644 --- a/packages/react-native/src/metro/client-metro.ts +++ b/packages/react-native/src/metro/client-metro.ts @@ -20,7 +20,7 @@ import { type ConfirmedProcessStop, } from '@agent-device/host-kit/process'; import { sleep } from '@agent-device/host-kit/retry'; -import { ensureMetroCompanion } from './client-metro-companion.ts'; +import { ensureMetroCompanion } from '../companion/client-metro-companion.ts'; import type { MetroBridgeDescriptor, MetroBridgeResult, diff --git a/packages/react-native/src/metro/index.ts b/packages/react-native/src/metro/index.ts deleted file mode 100644 index 22656fa684..0000000000 --- a/packages/react-native/src/metro/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { SessionRuntimeHints } from '@agent-device/kernel/contracts'; -import { resolveRuntimeTransportHints } from '@agent-device/host-kit/runtime-transport-hints'; -import type { PrepareMetroRuntimeOptions, ReloadMetroOptions } from './client-metro.ts'; -import type { StopMetroTunnelOptions } from './metro.ts'; - -export { normalizeBaseUrl } from './base-url.ts'; -export { buildBundleUrl } from './bundle-url.ts'; -export type { - MetroBridgeDescriptor, - MetroTunnelRequestMessage, - MetroTunnelResponseMessage, -} from './metro.ts'; - -export function resolveRuntimeTransport( - runtime: SessionRuntimeHints | undefined, -): { host: string; port: number; scheme: 'http' | 'https' } | undefined { - return resolveRuntimeTransportHints(runtime); -} - -export async function prepareMetroRuntime(input: PrepareMetroRuntimeOptions = {}) { - const runtime = await import('./client-metro.ts'); - return await runtime.prepareMetroRuntime(input); -} - -export async function reloadMetro(input: ReloadMetroOptions = {}) { - const runtime = await import('./client-metro.ts'); - return await runtime.reloadMetro(input); -} - -export async function stopMetroTunnel(options: StopMetroTunnelOptions): Promise { - const runtime = await import('./metro.ts'); - await runtime.stopMetroTunnel(options); -} diff --git a/packages/react-native/src/metro/metro-reload-endpoints.test.ts b/packages/react-native/src/metro/metro-reload-endpoints.test.ts index 94661c3a30..a4958bc8ea 100644 --- a/packages/react-native/src/metro/metro-reload-endpoints.test.ts +++ b/packages/react-native/src/metro/metro-reload-endpoints.test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { parsePort } from './metro-reload-endpoints.ts'; +import { parsePort, resolveMetroReloadEndpoints } from './metro-reload-endpoints.ts'; test('a Metro port is a whole number from 1 to 65535', () => { assert.equal(parsePort('8082', 8081), 8082); @@ -9,3 +9,87 @@ test('a Metro port is a whole number from 1 to 65535', () => { assert.throws(() => parsePort(value, 8081), /Invalid Metro port/, value); } }); + +test('reload endpoint resolution prioritizes explicit flags, then session runtime hints, then defaults', () => { + // no-hint default: neither an explicit flag nor a runtime hint is present. + assert.equal(resolveMetroReloadEndpoints({}).reloadUrl, 'http://localhost:8081/reload'); + + // hint-only: a session runtime hint resolves the target when no flag is given. + assert.equal( + resolveMetroReloadEndpoints({ runtime: { metroHost: '127.0.0.1', metroPort: 9200 } }).reloadUrl, + 'http://127.0.0.1:9200/reload', + ); + + // flag-overrides-hint: an explicit flag wins over a conflicting session runtime hint. + assert.equal( + resolveMetroReloadEndpoints({ + metroHost: '10.0.0.5', + metroPort: 9300, + runtime: { metroHost: '127.0.0.1', metroPort: 9200 }, + }).reloadUrl, + 'http://10.0.0.5:9300/reload', + ); + + // A single explicit flag still overrides only its own field; the hint fills the rest. + assert.equal( + resolveMetroReloadEndpoints({ + metroPort: 9400, + runtime: { metroHost: '192.168.1.5', metroPort: 9200 }, + }).reloadUrl, + 'http://192.168.1.5:9400/reload', + ); +}); + +test('resolveMetroReloadEndpoints keeps the bundle URL mount prefix instead of collapsing to the host root', () => { + // Expo virtual entry at the server root: the entry-module path is not a mount prefix, so the + // endpoints live at the root (verified live: Expo serves /message at the server root). + assert.deepEqual( + resolveMetroReloadEndpoints({ + runtime: { + metroHost: '127.0.0.1', + metroPort: 8082, + bundleUrl: + 'http://127.0.0.1:8082/.expo/.virtual-metro-entry.bundle?platform=ios&dev=true&minify=false', + }, + }), + { + reloadUrl: 'http://127.0.0.1:8082/reload', + messageSocketUrl: 'ws://127.0.0.1:8082/message', + }, + ); + + // Proxy/mount prefix carrying the Expo virtual entry keeps the mount prefix only. + assert.deepEqual( + resolveMetroReloadEndpoints({ + bundleUrl: + 'http://proxy.example.test/tenant-42/.expo/.virtual-metro-entry.bundle?platform=android', + }), + { + reloadUrl: 'http://proxy.example.test/tenant-42/reload', + messageSocketUrl: 'ws://proxy.example.test/tenant-42/message', + }, + ); + + // Proxy/mount prefix carrying a plain index.bundle keeps the prefix (Re.Pack/RN over a proxy); + // https maps the message socket to wss. + assert.deepEqual( + resolveMetroReloadEndpoints({ + bundleUrl: 'https://proxy.example.test/metro/runtime-1/index.bundle?platform=ios', + }), + { + reloadUrl: 'https://proxy.example.test/metro/runtime-1/reload', + messageSocketUrl: 'wss://proxy.example.test/metro/runtime-1/message', + }, + ); + + // Root index.bundle still reloads at the host root. + assert.deepEqual( + resolveMetroReloadEndpoints({ + bundleUrl: 'http://127.0.0.1:8081/index.bundle?platform=ios', + }), + { + reloadUrl: 'http://127.0.0.1:8081/reload', + messageSocketUrl: 'ws://127.0.0.1:8081/message', + }, + ); +}); diff --git a/packages/react-native/src/metro/metro-session-hints.ts b/packages/react-native/src/metro/metro-session-hints.ts index c6a36c00c5..cd2576c44b 100644 --- a/packages/react-native/src/metro/metro-session-hints.ts +++ b/packages/react-native/src/metro/metro-session-hints.ts @@ -14,7 +14,7 @@ export type MetroSessionHints = { export type MetroSessionScope = { stateDir: string; session: string }; -type MetroSessionOptions = { stateDir?: string; session: string }; +export type MetroSessionOptions = { stateDir?: string; session: string }; function resolveMetroSessionScope(options: MetroSessionOptions): MetroSessionScope { return { @@ -142,13 +142,15 @@ export function recordPreparedMetroSession( } catch {} } +export type MetroSessionOpenInput = { + runtime: SessionRuntimeHints | undefined; + explicitRuntime: boolean; + sessionReused: boolean; +}; + export function recordOpenedMetroSession( options: MetroSessionOptions, - input: { - runtime: SessionRuntimeHints | undefined; - explicitRuntime: boolean; - sessionReused: boolean; - }, + input: MetroSessionOpenInput, ): void { try { const scope = resolveMetroSessionScope(options); diff --git a/packages/react-native/src/react-devtools/index.ts b/packages/react-native/src/react-devtools/index.ts deleted file mode 100644 index e730a02fdf..0000000000 --- a/packages/react-native/src/react-devtools/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { AppError } from '@agent-device/kernel/errors'; -import type { ReactDevtoolsCommandOptions } from './react-devtools-connection.ts'; -import type { ReactDevtoolsOutput } from './react-devtools-output.ts'; - -export type { ReactDevtoolsCommandOptions } from './react-devtools-connection.ts'; - -export async function runReactDevtools( - args: string[], - options: ReactDevtoolsCommandOptions, -): Promise { - const [command, ...rest] = args; - let output: ReactDevtoolsOutput | undefined; - if (command === 'profile') { - const { runReactProfileCommand } = await import('./react-profile-command.ts'); - output = await runReactProfileCommand(rest, options); - } else { - const { isInspectCommand, runReactInspectCommand } = - await import('./react-devtools-inspect-command.ts'); - if (isInspectCommand(command)) output = await runReactInspectCommand(command, rest, options); - } - if (!output) - throw new AppError('INVALID_ARGS', `Unknown react-devtools command: ${command ?? '(none)'}.`, { - hint: 'Use react-devtools tree, find, inspect, errors, or profile.', - }); - return output; -} diff --git a/packages/react-native/src/react-devtools/react-devtools-command.fixtures.ts b/packages/react-native/src/react-devtools/react-devtools-command.fixtures.ts index 2789380211..a1388af4b5 100644 --- a/packages/react-native/src/react-devtools/react-devtools-command.fixtures.ts +++ b/packages/react-native/src/react-devtools/react-devtools-command.fixtures.ts @@ -1,4 +1,4 @@ -import { runReactDevtools } from './index.ts'; +import { runReactDevtools } from '../index.ts'; import { vi } from 'vitest'; import vm from 'node:vm'; import { mkdtempForTestSync } from '@agent-device/test-kit'; diff --git a/packages/test-kit/src/index.ts b/packages/test-kit/src/index.ts index 863ff1b1a5..2886ff7938 100644 --- a/packages/test-kit/src/index.ts +++ b/packages/test-kit/src/index.ts @@ -99,3 +99,5 @@ export { findFreeMetroPort, stopTestMetroProcess, } from './metro-process.fixtures.ts'; + +export { readMetroSessionFixture, writeMetroSessionFixture } from './metro-session.fixtures.ts'; diff --git a/packages/test-kit/src/metro-session.fixtures.ts b/packages/test-kit/src/metro-session.fixtures.ts new file mode 100644 index 0000000000..edc7b62514 --- /dev/null +++ b/packages/test-kit/src/metro-session.fixtures.ts @@ -0,0 +1,22 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { safeSessionName } from '@agent-device/host-kit/session-paths'; + +type MetroSessionFixture = { stateDir: string; session: string }; + +function bindingPath(scope: MetroSessionFixture): string { + return path.join(scope.stateDir, 'metro-sessions', `${safeSessionName(scope.session)}.json`); +} + +export function writeMetroSessionFixture( + options: MetroSessionFixture & { hints: { controlBaseUrl: string; deviceBaseUrls?: string[] } }, +): void { + const file = bindingPath(options); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(options.hints)); +} + +export function readMetroSessionFixture(scope: MetroSessionFixture): unknown { + const file = bindingPath(scope); + return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : undefined; +} diff --git a/src/__tests__/remote-connection-proxy-allocate-by-spec.test.ts b/src/__tests__/remote-connection-proxy-allocate-by-spec.test.ts index cda807f050..22a6fbf71c 100644 --- a/src/__tests__/remote-connection-proxy-allocate-by-spec.test.ts +++ b/src/__tests__/remote-connection-proxy-allocate-by-spec.test.ts @@ -13,8 +13,8 @@ import { unexpectedCommandCall, } from './remote-connection.fixtures.ts'; -vi.mock('@agent-device/react-native/metro', async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock('@agent-device/react-native', async (importOriginal) => ({ + ...(await importOriginal()), stopMetroTunnel: vi.fn(), })); diff --git a/src/__tests__/remote-connection-proxy-lease-id.test.ts b/src/__tests__/remote-connection-proxy-lease-id.test.ts index d46cc4d208..4b3f4764cb 100644 --- a/src/__tests__/remote-connection-proxy-lease-id.test.ts +++ b/src/__tests__/remote-connection-proxy-lease-id.test.ts @@ -9,8 +9,8 @@ import { seedConnectionState, } from './remote-connection.fixtures.ts'; -vi.mock('@agent-device/react-native/metro', async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock('@agent-device/react-native', async (importOriginal) => ({ + ...(await importOriginal()), stopMetroTunnel: vi.fn(), })); diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index 00e38996b5..16a07ba398 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -16,8 +16,8 @@ import { writeReplacedProfiles, } from './remote-connection.fixtures.ts'; -vi.mock('@agent-device/react-native/metro', async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock('@agent-device/react-native', async (importOriginal) => ({ + ...(await importOriginal()), stopMetroTunnel: vi.fn(), })); @@ -33,7 +33,7 @@ import { CLOUD_WEBDRIVER_REMOTE_LEASE_TTL_MS, PROXY_REMOTE_LEASE_TTL_MS, } from '../cli/commands/connection-runtime.ts'; -import { stopMetroTunnel } from '@agent-device/react-native/metro'; +import { stopMetroTunnel } from '@agent-device/react-native'; import { AppError } from '@agent-device/kernel/errors'; import { hashRemoteConfigFile, diff --git a/src/agent-device-client-metro.test.ts b/src/agent-device-client-metro.test.ts index 8029a0f839..fc03602fed 100644 --- a/src/agent-device-client-metro.test.ts +++ b/src/agent-device-client-metro.test.ts @@ -1,4 +1,4 @@ -import { test } from 'vitest'; +import { describe, expect, test } from 'vitest'; import assert from 'node:assert/strict'; import { once } from 'node:events'; import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; @@ -6,14 +6,17 @@ import { createServer } from 'node:http'; import type { Socket } from 'node:net'; import path from 'node:path'; import { createAgentDeviceClient } from './agent-device-client.ts'; -import { readMetroSessionHints } from '@agent-device/react-native/session'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { + readMetroSessionFixture, closeLoopbackServer, mkdtempForTestSync, writeFakeMetroNpx, findFreeMetroPort, stopTestMetroProcess, + listenOnLoopback, + skipWhenLoopbackUnavailable, + writeMetroSessionFixture, } from '@agent-device/test-kit'; const TEST_TOKEN = 'agent-device-proxy-test-token'; @@ -157,7 +160,7 @@ test('client prepare retains local control and valid proxy runtimes when one dev assert.equal(written.androidRuntime.metroHost, 'bridge.example.test'); assert.equal(written.androidRuntime.platform, 'android'); assert.equal(written.runtimeFilePath, runtimeFilePath); - assert.deepEqual(readMetroSessionHints({ stateDir: tempRoot, session: 'bridge' }), { + assert.deepEqual(readMetroSessionFixture({ stateDir: tempRoot, session: 'bridge' }), { controlBaseUrl: `http://127.0.0.1:${metroPort}/`, deviceBaseUrls: ['https://runtime-1.metro.agent-device.dev/'], }); @@ -218,7 +221,7 @@ test('metro reload targets the dev server bound by metro prepare in the same ses }); pid = prepared.pid; - const storedHints = readMetroSessionHints({ + const storedHints = readMetroSessionFixture({ stateDir: resolveDaemonPaths(stateDir).baseDir, session: 'metro-session-hints', }); @@ -237,7 +240,7 @@ test('metro reload targets the dev server bound by metro prepare in the same ses // flagless reload resolves to the Metro default instead of silently hitting the stale port. await client.sessions.close(); assert.equal( - readMetroSessionHints({ + readMetroSessionFixture({ stateDir: resolveDaemonPaths(stateDir).baseDir, session: 'metro-session-hints', }), @@ -319,7 +322,7 @@ test('metro prepare keeps a public Expo address separate from the local reload a `https://127.0.0.1:${metroPort}${publicBasePath}/.expo/.virtual-metro-entry.bundle?platform=ios&dev=true&minify=false`, ); - const storedHints = readMetroSessionHints({ + const storedHints = readMetroSessionFixture({ stateDir: resolveDaemonPaths(stateDir).baseDir, session: 'metro-expo-session', }); @@ -333,3 +336,43 @@ test('metro prepare keeps a public Expo address separate from the local reload a rmSync(tempRoot, { recursive: true, force: true }); } }); + +describe('SDK open Metro session binding', () => { + test.for([false, true])( + 'config defaults replace a stale binding only on a fresh open (reused=%s)', + async (sessionReused, t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const stateDir = mkdtempForTestSync('agent-device-metro-open-'); + const previous = createServer((_req, res) => res.end('PREVIOUS')); + const current = createServer((_req, res) => res.end('CURRENT')); + try { + const previousPort = await listenOnLoopback(previous); + const currentPort = await listenOnLoopback(current); + writeMetroSessionFixture({ + stateDir, + session: 'proj-a', + hints: { + controlBaseUrl: `http://127.0.0.1:${previousPort}/`, + }, + }); + const client = createAgentDeviceClient( + { + stateDir, + session: 'proj-a', + runtime: { metroHost: '127.0.0.1', metroPort: currentPort }, + }, + { transport: async () => ({ ok: true, data: { sessionReused } }) }, + ); + await client.apps.open({ app: 'MyApp' }); + const result = await client.metro.reload(); + expect(result.reloadUrl).toBe( + `http://127.0.0.1:${sessionReused ? previousPort : currentPort}/reload`, + ); + expect(result.body).toBe(sessionReused ? 'PREVIOUS' : 'CURRENT'); + } finally { + await closeLoopbackServer(previous); + await closeLoopbackServer(current); + } + }, + ); +}); diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index efc7583e74..dbf0507add 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -60,13 +60,14 @@ import { import type { CommandResult } from '@agent-device/command-registry/command-result'; import { sendToDaemon } from './daemon-client/daemon-client.ts'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import { prepareMetroRuntime, reloadMetro } from '@agent-device/react-native/metro'; import { + prepareMetroRuntime, + reloadMetro, clearMetroSessionHintsQuietly, recordPreparedMetroSession, recordOpenedMetroSession, readMetroSessionRuntime, -} from '@agent-device/react-native/session'; +} from '@agent-device/react-native'; import { asOptionalRecord, readPresentedSurfaceFact, @@ -221,7 +222,7 @@ export function createAgentDeviceClient( }; } finally { // Close is teardown intent: drop the dev-server binding even if the daemon call fails. - clearMetroSessionHintsQuietly({ ...mergeClientOptions(config, options), session }); + await clearMetroSessionHintsQuietly({ ...mergeClientOptions(config, options), session }); } }, saveScript: async ( @@ -276,7 +277,7 @@ export function createAgentDeviceClient( open: async (options: AppOpenOptions) => { const session = resolveRequestSession(options); const data = await executeCommand>('open', options); - recordOpenedMetroSession( + await recordOpenedMetroSession( { ...mergeClientOptions(config, options), session }, { explicitRuntime: options.runtime !== undefined, @@ -364,7 +365,7 @@ export function createAgentDeviceClient( runtimeFilePath: options.runtimeFilePath, logPath: options.logPath, }); - recordPreparedMetroSession( + await recordPreparedMetroSession( { ...config, session: resolveSessionName(config.session) }, result, ); @@ -374,8 +375,10 @@ export function createAgentDeviceClient( await reloadMetro({ ...options, runtime: - readMetroSessionRuntime({ ...config, session: resolveSessionName(config.session) }) ?? - config.runtime, + (await readMetroSessionRuntime({ + ...config, + session: resolveSessionName(config.session), + })) ?? config.runtime, }), }, capture: { diff --git a/src/cli/commands/connection-runtime-metro-teardown.test.ts b/src/cli/commands/connection-runtime-metro-teardown.test.ts index 154f52efa7..cd2a366b7b 100644 --- a/src/cli/commands/connection-runtime-metro-teardown.test.ts +++ b/src/cli/commands/connection-runtime-metro-teardown.test.ts @@ -9,12 +9,12 @@ import { seedConnectionState, } from '../../__tests__/remote-connection.fixtures.ts'; -vi.mock('@agent-device/react-native/metro', async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock('@agent-device/react-native', async (importOriginal) => ({ + ...(await importOriginal()), stopMetroTunnel: vi.fn(), })); -import { stopMetroTunnel } from '@agent-device/react-native/metro'; +import { stopMetroTunnel } from '@agent-device/react-native'; import { materializeRemoteConnectionForCommand } from './connection-runtime.ts'; afterEach(() => { diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 055618cd18..b56eeb49b9 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -1,7 +1,7 @@ import crypto from 'node:crypto'; import type { MetroBridgeScope } from '@agent-device/contracts/remote'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import { stopMetroTunnel } from '@agent-device/react-native/metro'; +import { stopMetroTunnel } from '@agent-device/react-native'; import { resolveRemoteConfigProfile } from '../../remote/remote-config.ts'; // Provenance-preserving file-only read (no ambient env defaults merged in) — // see resolvePreviousOwnDaemonAuthToken below for why this must not be diff --git a/src/cli/commands/react-devtools-connection.test.ts b/src/cli/commands/react-devtools-connection.test.ts index f39603573e..8a1d982167 100644 --- a/src/cli/commands/react-devtools-connection.test.ts +++ b/src/cli/commands/react-devtools-connection.test.ts @@ -1,9 +1,7 @@ import { afterEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; import type { CliFlags } from '@agent-device/contracts/command'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, writeMetroSessionFixture } from '@agent-device/test-kit'; import { runReactDevtoolsCommand } from './react-devtools.ts'; afterEach(() => { @@ -26,14 +24,6 @@ function harness() { return { run, options, stateDir: options.stateDir, requested }; } -function bindSession(stateDir: string, controlBaseUrl: string) { - fs.mkdirSync(path.join(stateDir, 'metro-sessions'), { recursive: true }); - fs.writeFileSync( - path.join(stateDir, 'metro-sessions', 'qa.json'), - JSON.stringify({ controlBaseUrl }), - ); -} - test('explicit dev-server flags override the session binding', async () => { const { run, requested } = harness(); await assert.rejects(run('start', '--metro-port', '8082'), /No React Native runtime/); @@ -42,11 +32,11 @@ test('explicit dev-server flags override the session binding', async () => { test('the session Metro binding picks the dev server', async () => { const { run, requested, stateDir } = harness(); - fs.mkdirSync(path.join(stateDir, 'metro-sessions'), { recursive: true }); - fs.writeFileSync( - path.join(stateDir, 'metro-sessions', 'qa.json'), - JSON.stringify({ controlBaseUrl: 'http://127.0.0.1:19000/' }), - ); + writeMetroSessionFixture({ + stateDir, + session: 'qa', + hints: { controlBaseUrl: 'http://127.0.0.1:19000/' }, + }); await assert.rejects(run('start'), /No React Native runtime/); assert.deepEqual(requested, ['http://127.0.0.1:19000/json/list']); }); @@ -64,7 +54,11 @@ test('a remote bridge without a Metro binding stays on the local dev server, not test('a host flag on its own keeps the port of the session binding', async () => { const { run, requested, stateDir } = harness(); - bindSession(stateDir, 'http://127.0.0.1:19000/'); + writeMetroSessionFixture({ + stateDir, + session: 'qa', + hints: { controlBaseUrl: 'http://127.0.0.1:19000/' }, + }); await assert.rejects(run('start', '--metro-host', '10.0.2.2'), /No React Native runtime/); assert.deepEqual(requested, ['http://10.0.2.2:19000/json/list']); }); diff --git a/src/cli/commands/react-devtools.ts b/src/cli/commands/react-devtools.ts index f40bbc5f83..6fd1aa12a5 100644 --- a/src/cli/commands/react-devtools.ts +++ b/src/cli/commands/react-devtools.ts @@ -1,8 +1,5 @@ import type { CliFlags } from '@agent-device/contracts/command'; -import { - runReactDevtools, - type ReactDevtoolsCommandOptions, -} from '@agent-device/react-native/react-devtools'; +import { runReactDevtools, type ReactDevtoolsCommandOptions } from '@agent-device/react-native'; import { writeCliOutput } from './shared.ts'; export async function runReactDevtoolsCommand( diff --git a/src/commands/capture/runtime/snapshot.ts b/src/commands/capture/runtime/snapshot.ts index 2c021b7138..4e751c3753 100644 --- a/src/commands/capture/runtime/snapshot.ts +++ b/src/commands/capture/runtime/snapshot.ts @@ -35,7 +35,7 @@ import { isLikelyStaleSnapshotDrop } from '@agent-device/capture-kit/snapshot-fr import { formatPostGestureOutcomeWarning } from '@agent-device/capture-kit/post-gesture-stability'; import { buildSnapshotVisibility } from '@agent-device/capture-kit/snapshot-visibility'; import { ANDROID_SYSTEM_SURFACE_DISCLOSURE } from '@agent-device/contracts/android-system-surface-disclosure'; -import { formatReactNativeOverlayWarning } from '../../react-native/overlay.ts'; +import { formatReactNativeOverlayWarning } from '@agent-device/capture-kit/react-native-overlay'; import { now } from '../../runtime-common.ts'; import { iosSnapshotTruncationEvidence } from '@agent-device/capture-kit/ios-snapshot-acquisition'; import type { diff --git a/src/commands/management/app.test.ts b/src/commands/management/app.test.ts index 665b923804..50ec86737e 100644 --- a/src/commands/management/app.test.ts +++ b/src/commands/management/app.test.ts @@ -1,20 +1,17 @@ import { describe, expect, test } from 'vitest'; import { randomUUID } from 'node:crypto'; import { mkdirSync, rmSync } from 'node:fs'; -import { createServer } from 'node:http'; import path from 'node:path'; import type { CliFlags } from '@agent-device/contracts/command'; import { parseArgs } from '../../cli/parser/args.ts'; import { createAgentDeviceClient } from '../../agent-device-client.ts'; import type { DaemonRequest, DaemonResponse } from '@agent-device/kernel/contracts'; -import { readMetroSessionHints, writeMetroSessionHints } from '@agent-device/react-native/session'; import { openCommandFacet } from './app.ts'; import { - closeLoopbackServer, - listenOnLoopback, mkdtempForTestSync, - skipWhenLoopbackUnavailable, + readMetroSessionFixture, + writeMetroSessionFixture, } from '@agent-device/test-kit'; function flags(overrides: Partial = {}): CliFlags { @@ -71,44 +68,6 @@ describe('open startup budget', () => { }); describe('open command metro session hints', () => { - test.for([false, true])( - 'config defaults replace a stale binding only on a fresh open (reused=%s)', - async (sessionReused, t) => { - if (await skipWhenLoopbackUnavailable(t)) return; - const stateDir = tempStateDir(); - const previous = createServer((_req, res) => res.end('PREVIOUS')); - const current = createServer((_req, res) => res.end('CURRENT')); - try { - const previousPort = await listenOnLoopback(previous); - const currentPort = await listenOnLoopback(current); - writeMetroSessionHints({ - stateDir, - session: 'proj-a', - hints: { - controlBaseUrl: `http://127.0.0.1:${previousPort}/`, - }, - }); - const client = createAgentDeviceClient( - { - stateDir, - session: 'proj-a', - runtime: { metroHost: '127.0.0.1', metroPort: currentPort }, - }, - { transport: async () => ({ ok: true, data: { sessionReused } }) }, - ); - await client.apps.open({ app: 'MyApp' }); - const result = await client.metro.reload(); - expect(result.reloadUrl).toBe( - `http://127.0.0.1:${sessionReused ? previousPort : currentPort}/reload`, - ); - expect(result.body).toBe(sessionReused ? 'PREVIOUS' : 'CURRENT'); - } finally { - await closeLoopbackServer(previous); - await closeLoopbackServer(current); - } - }, - ); - test('CLI parser accepts --metro-host/--metro-port/--bundle-url/--launch-url on open', () => { const parsed = parseArgs( [ @@ -208,7 +167,7 @@ describe('open command metro session hints', () => { ); await openCommandFacet.definition.invoke(client, cliInput); - expect(readMetroSessionHints({ stateDir, session: 'proj-a' })).toEqual({ + expect(readMetroSessionFixture({ stateDir, session: 'proj-a' })).toEqual({ controlBaseUrl: 'http://127.0.0.1:8082/', }); } finally { @@ -216,10 +175,22 @@ describe('open command metro session hints', () => { } }); + test('open with only a Metro port binds reload to the local server on that port', async () => { + const stateDir = tempStateDir(); + const { client } = createOpenClient({ stateDir, session: 'proj-a' }); + await openCommandFacet.definition.invoke( + client, + openCommandFacet.cliReader(['MyApp'], flags({ metroPort: 8082 })), + ); + expect(readMetroSessionFixture({ stateDir, session: 'proj-a' })).toEqual({ + controlBaseUrl: 'http://localhost:8082/', + }); + }); + test('a hintless open that creates the session clears a leftover same-name binding', async () => { const stateDir = tempStateDir(); try { - writeMetroSessionHints({ + writeMetroSessionFixture({ stateDir, session: 'proj-a', hints: { controlBaseUrl: 'http://127.0.0.1:8083/' }, @@ -230,28 +201,16 @@ describe('open command metro session hints', () => { openCommandFacet.cliReader(['MyApp'], flags({})), ); - expect(readMetroSessionHints({ stateDir, session: 'proj-a' })).toBeUndefined(); + expect(readMetroSessionFixture({ stateDir, session: 'proj-a' })).toBeUndefined(); } finally { rmSync(stateDir, { recursive: true, force: true }); } }); - test('open with only a Metro port binds reload to the local server on that port', async () => { - const stateDir = tempStateDir(); - const { client } = createOpenClient({ stateDir, session: 'proj-a' }); - await openCommandFacet.definition.invoke( - client, - openCommandFacet.cliReader(['MyApp'], flags({ metroPort: 8082 })), - ); - expect(readMetroSessionHints({ stateDir, session: 'proj-a' })).toEqual({ - controlBaseUrl: 'http://localhost:8082/', - }); - }); - test('a hintless open on an existing session keeps the current binding', async () => { const stateDir = tempStateDir(); try { - writeMetroSessionHints({ + writeMetroSessionFixture({ stateDir, session: 'proj-a', hints: { controlBaseUrl: 'http://127.0.0.1:8083/' }, @@ -262,7 +221,7 @@ describe('open command metro session hints', () => { openCommandFacet.cliReader(['OtherApp'], flags({})), ); - expect(readMetroSessionHints({ stateDir, session: 'proj-a' })).toEqual({ + expect(readMetroSessionFixture({ stateDir, session: 'proj-a' })).toEqual({ controlBaseUrl: 'http://127.0.0.1:8083/', }); } finally { diff --git a/src/commands/react-native/index.ts b/src/commands/react-native/index.ts index b329c90db5..6f28c2351e 100644 --- a/src/commands/react-native/index.ts +++ b/src/commands/react-native/index.ts @@ -56,5 +56,3 @@ function readReactNativeAction(value: string | undefined): 'dismiss-overlay' { if (value === 'dismiss-overlay') return value; throw new AppError('INVALID_ARGS', 'react-native supports only: dismiss-overlay'); } - -export * from './overlay.ts'; diff --git a/src/commands/react-native/overlay.ts b/src/commands/react-native/overlay.ts deleted file mode 100644 index 67bd59b777..0000000000 --- a/src/commands/react-native/overlay.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '@agent-device/capture-kit/react-native-overlay'; diff --git a/src/sdk/metro.ts b/src/sdk/metro.ts index 6b5c9b2e3b..ac475fd369 100644 --- a/src/sdk/metro.ts +++ b/src/sdk/metro.ts @@ -8,4 +8,4 @@ export { type MetroBridgeDescriptor, type MetroTunnelRequestMessage, type MetroTunnelResponseMessage, -} from '@agent-device/react-native/metro'; +} from '@agent-device/react-native'; From 143c6deee769c72ce19b818ece338006ef518c00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 12:13:42 +0200 Subject: [PATCH 3/3] chore(gates): wire the React Native package and single entrypoint --- .fallowrc.json | 2 +- examples/sdk/tsconfig.json | 1 + fallow-baselines/health.json | 4 ++-- package.json | 1 + packages/react-native/package.json | 22 +++++++++++++++++++++ packages/react-native/tsconfig.json | 30 +++++++++++++++++++++++++++++ pnpm-lock.yaml | 22 +++++++++++++++++++++ scripts/layering/model.ts | 2 +- tsconfig.typecheck.json | 1 + tsdown.config.ts | 2 +- vitest.config.ts | 3 ++- 11 files changed, 84 insertions(+), 6 deletions(-) create mode 100644 packages/react-native/package.json create mode 100644 packages/react-native/tsconfig.json diff --git a/.fallowrc.json b/.fallowrc.json index edfa17fe5a..573f19888c 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -23,7 +23,7 @@ "packages/proxy/src/index.ts", "packages/proxy/tsdown.config.ts", "src/bin.ts", - "src/client/companion-tunnel.ts", + "packages/react-native/src/companion/companion-tunnel.ts", "src/daemon.ts", "packages/capture-kit/src/png-worker.ts", "scripts/patch-xcuitest-runner-icon.ts", diff --git a/examples/sdk/tsconfig.json b/examples/sdk/tsconfig.json index 483a3f32d9..683f8cd4c8 100644 --- a/examples/sdk/tsconfig.json +++ b/examples/sdk/tsconfig.json @@ -56,6 +56,7 @@ { "path": "../../packages/provider-webdriver" }, { "path": "../../packages/provision-kit" }, { "path": "../../packages/proxy" }, + { "path": "../../packages/react-native" }, { "path": "../../packages/replay-port" }, { "path": "../../packages/replay-test" }, { "path": "../../packages/selectors" }, diff --git a/fallow-baselines/health.json b/fallow-baselines/health.json index 69012dce0e..b30b6273bd 100644 --- a/fallow-baselines/health.json +++ b/fallow-baselines/health.json @@ -20,7 +20,7 @@ "count": 1 } }, - "src/client/client-companion-tunnel-worker.ts": { + "packages/react-native/src/companion/client-companion-tunnel-worker.ts": { "complexity_high": { "count": 1 } @@ -118,6 +118,6 @@ "packages/platform-android/src/ui-hierarchy.ts:high impact", "packages/platform-android/src/sdk.ts:high impact", "src/daemon-client/daemon-client-lifecycle.ts:complexity", - "src/client/client-companion-tunnel-worker.ts:complexity" + "packages/react-native/src/companion/client-companion-tunnel-worker.ts:complexity" ] } diff --git a/package.json b/package.json index c1d4ba95cc..6c8fafdf4e 100644 --- a/package.json +++ b/package.json @@ -309,6 +309,7 @@ "@agent-device/provider-webdriver": "workspace:*", "@agent-device/provision-kit": "workspace:*", "@agent-device/proxy": "workspace:*", + "@agent-device/react-native": "workspace:*", "@agent-device/replay-port": "workspace:*", "@agent-device/replay-test": "workspace:*", "@agent-device/selectors": "workspace:*", diff --git a/packages/react-native/package.json b/packages/react-native/package.json new file mode 100644 index 0000000000..e2a2bae199 --- /dev/null +++ b/packages/react-native/package.json @@ -0,0 +1,22 @@ +{ + "name": "@agent-device/react-native", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "React Native development runtime: Metro preparation, companion tunnelling, component inspection, and render profiling. Internal workspace package bundled into agent-device.", + "dependencies": { + "@agent-device/contracts": "workspace:*", + "@agent-device/daemon-contracts": "workspace:*", + "@agent-device/host-kit": "workspace:*", + "@agent-device/kernel": "workspace:*" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "devDependencies": { + "@agent-device/test-kit": "workspace:*" + } +} diff --git a/packages/react-native/tsconfig.json b/packages/react-native/tsconfig.json new file mode 100644 index 0000000000..ca7daf6de4 --- /dev/null +++ b/packages/react-native/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationDir": "./dist-types", + "rootDir": "./src", + "tsBuildInfoFile": "./dist-types/index.tsbuildinfo" + }, + "include": ["src"], + "references": [ + { + "path": "../contracts" + }, + { + "path": "../daemon-contracts" + }, + { + "path": "../host-kit" + }, + { + "path": "../kernel" + }, + { + "path": "../test-kit" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cbdaf4a136..b3788093c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,6 +82,9 @@ importers: '@agent-device/proxy': specifier: workspace:* version: link:packages/proxy + '@agent-device/react-native': + specifier: workspace:* + version: link:packages/react-native '@agent-device/replay-port': specifier: workspace:* version: link:packages/replay-port @@ -662,6 +665,25 @@ importers: specifier: workspace:* version: link:../kernel + packages/react-native: + dependencies: + '@agent-device/contracts': + specifier: workspace:* + version: link:../contracts + '@agent-device/daemon-contracts': + specifier: workspace:* + version: link:../daemon-contracts + '@agent-device/host-kit': + specifier: workspace:* + version: link:../host-kit + '@agent-device/kernel': + specifier: workspace:* + version: link:../kernel + devDependencies: + '@agent-device/test-kit': + specifier: workspace:* + version: link:../test-kit + packages/replay-port: dependencies: '@agent-device/ad-replay': diff --git a/scripts/layering/model.ts b/scripts/layering/model.ts index a1114d3352..0e7cf38c83 100644 --- a/scripts/layering/model.ts +++ b/scripts/layering/model.ts @@ -65,7 +65,6 @@ const TARGET_DAG_RANK = new Map([ ['mcp', 3], ['client', 4], ['daemon-server', 4], - ['metro', 4], ['platform-runtime', 4], ['remote', 4], ['plugins', 4], @@ -113,6 +112,7 @@ export const UNRANKED_ZONES: ReadonlySet = new Set([ 'provider-limrun', 'provider-testmu', 'proxy', + 'react-native', 'xml', ]); diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json index fb326eb4b1..d6be52177b 100644 --- a/tsconfig.typecheck.json +++ b/tsconfig.typecheck.json @@ -27,6 +27,7 @@ { "path": "packages/provider-webdriver" }, { "path": "packages/provision-kit" }, { "path": "packages/proxy" }, + { "path": "packages/react-native" }, { "path": "packages/replay-port" }, { "path": "packages/replay-test" }, { "path": "packages/selectors" }, diff --git a/tsdown.config.ts b/tsdown.config.ts index 2fb6911644..479a45576e 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -109,7 +109,7 @@ export default defineConfig({ finders: 'src/sdk/finders.ts', 'ai-sdk': 'src/ai-sdk/index.ts', 'internal/bin': 'src/bin.ts', - 'internal/companion-tunnel': 'src/client/companion-tunnel.ts', + 'internal/companion-tunnel': 'packages/react-native/src/companion/companion-tunnel.ts', 'internal/daemon': 'src/daemon.ts', 'internal/run-script-http-child': 'packages/maestro/src/daemon-port/run-script-http-child.ts', 'internal/png-worker': 'packages/capture-kit/src/png-worker.ts', diff --git a/vitest.config.ts b/vitest.config.ts index 118acf1ca3..bad895ed46 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,7 +7,8 @@ import slowTestGateReporter from './scripts/vitest-slow-test-reporter.ts'; // mutation lane excludes these tests even though the unit lane runs them normally. const MUTATION_EXCLUDED_SUBPROCESS_TESTS: readonly string[] = [ // Stubs npx plus the package managers and spawns a real Metro dev server per case. - 'src/metro/client-metro.test.ts', + 'packages/react-native/src/index-metro.test.ts', + 'src/agent-device-client-metro.test.ts', // The SUT is the subprocess watchdog: a node subprocess per case, one hangs on purpose (#1414). 'scripts/fuzz/harness.test.ts', // The daemon takeover test launches a branch-named daemon per case to prove