From ee6cc7cc5b7961ba87b4a0ef3ed40db41cc2ac84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 17 Apr 2026 20:01:20 +0200 Subject: [PATCH] fix: use bridge metro runtime descriptors (#425) --- .../agent-device/references/remote-tenancy.md | 15 ++- src/__tests__/cli-client-commands.test.ts | 32 ++++- .../client-metro-auto-companion.test.ts | 117 ++++++++++++++---- src/__tests__/client-metro.test.ts | 25 ++-- src/__tests__/metro-protocol-public.test.ts | 13 +- src/cli/commands/connection-runtime.ts | 4 +- src/cli/commands/metro.ts | 7 +- src/client-metro.ts | 41 ++++-- src/client-types.ts | 2 +- src/metro.ts | 2 +- src/utils/__tests__/args.test.ts | 5 +- src/utils/command-schema.ts | 8 +- .../installed-package-metro.test.ts | 16 +-- .../smoke-open-remote-config.test.ts | 36 +++--- test/scripts/metro-prepare-packaged-smoke.mjs | 27 ++-- website/docs/docs/client-api.md | 8 +- website/docs/docs/commands.md | 15 ++- 17 files changed, 252 insertions(+), 121 deletions(-) diff --git a/skills/agent-device/references/remote-tenancy.md b/skills/agent-device/references/remote-tenancy.md index 899677193..69fb6d0e8 100644 --- a/skills/agent-device/references/remote-tenancy.md +++ b/skills/agent-device/references/remote-tenancy.md @@ -103,8 +103,8 @@ Example `remote-config.json` shape: "tenant": "acme", "runId": "run-123", "sessionIsolation": "tenant", - "platform": "android", - "metroPublicBaseUrl": "http://127.0.0.1:8081" + "platform": "ios", + "metroProxyBaseUrl": "https://bridge.example.com" } ``` @@ -112,11 +112,11 @@ Optional overrides stay available for advanced cases: ```json { - "session": "adc-android", - "leaseBackend": "android-instance", + "session": "adc-ios", + "leaseBackend": "ios-instance", "metroProjectRoot": ".", "metroKind": "expo", - "metroProxyBaseUrl": "https://bridge.example.com/metro/acme/run-123" + "metroPublicBaseUrl": "http://127.0.0.1:8081" } ``` @@ -124,7 +124,10 @@ Optional overrides stay available for advanced cases: - Omit Metro fields for non-React Native flows. - Put `tenant`, `runId`, and `sessionIsolation` in the remote profile so agents can run `agent-device connect --remote-config ./remote-config.json` without extra scope flags. Add `platform`, `leaseBackend`, `session`, or Metro overrides only when the default inference is not enough for that flow. - Explicit command-line flags override connected defaults. Use them intentionally when switching session, platform, target, tenant, run, or lease scope. -- For React Native Metro runs with `metroProxyBaseUrl`, `agent-device >= 0.11.12` can manage the local companion tunnel, but Metro itself still needs to be running locally. +- For React Native Metro runs with `metroProxyBaseUrl`, `agent-device >= 0.11.12` can manage the local companion tunnel, but Metro itself still needs to be running locally. `metroProxyBaseUrl` is the bridge origin, not a prebuilt `/api/metro/...` route. +- For cloud stock React Native iOS, use the bridge descriptor's wildcard HTTPS Metro hints directly; do not install or launch the XCTest runner just to make Metro reachable. +- Android keeps using bridge-provided `/api/metro/runtimes//...` Metro routes. +- `metroPublicBaseUrl` is only needed for direct/non-bridge bundle hints. Bridged profiles can omit it. - Use a lease backend that matches the bridge target platform, for example `android-instance`, `ios-instance`, or an explicit `--lease-backend` override. ## Transport prerequisites diff --git a/src/__tests__/cli-client-commands.test.ts b/src/__tests__/cli-client-commands.test.ts index 2e9e8004b..02d0aa5cd 100644 --- a/src/__tests__/cli-client-commands.test.ts +++ b/src/__tests__/cli-client-commands.test.ts @@ -166,6 +166,35 @@ test('metro prepare forwards normalized options to client.metro.prepare', async assert.equal(payload.runtimeFilePath, null); }); +test('metro prepare rejects when no public or proxy base URL is provided', async () => { + const client = createStubClient({ + installFromSource: async () => { + throw new Error('unexpected install call'); + }, + prepareMetro: async () => { + throw new Error('unexpected metro prepare call'); + }, + }); + + await assert.rejects( + () => + tryRunClientBackedCommand({ + command: 'metro', + positionals: ['prepare'], + flags: { + json: false, + help: false, + version: false, + }, + client, + }), + (error) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + error.message.includes('--public-base-url or --proxy-base-url '), + ); +}); + test('screenshot forwards --overlay-refs to the client capture API', async () => { let observed: | { @@ -426,7 +455,6 @@ test('metro prepare with --remote-config loads profile defaults', async () => { remoteConfigPath, JSON.stringify({ metroProjectRoot: './apps/demo', - metroPublicBaseUrl: 'https://sandbox.example.test', metroProxyBaseUrl: 'https://proxy.example.test', tenant: 'tenant-1', runId: 'run-1', @@ -483,7 +511,7 @@ test('metro prepare with --remote-config loads profile defaults', async () => { assert.deepEqual(observedPrepare, { projectRoot: path.join(configDir, 'apps/demo'), kind: undefined, - publicBaseUrl: 'https://sandbox.example.test', + publicBaseUrl: undefined, proxyBaseUrl: 'https://proxy.example.test', bearerToken: undefined, bridgeScope: { diff --git a/src/__tests__/client-metro-auto-companion.test.ts b/src/__tests__/client-metro-auto-companion.test.ts index ff5809b92..534fa211d 100644 --- a/src/__tests__/client-metro-auto-companion.test.ts +++ b/src/__tests__/client-metro-auto-companion.test.ts @@ -67,16 +67,17 @@ test('prepareMetroRuntime starts the local companion only after bridge setup nee enabled: true, base_url: 'https://proxy.example.test', status_url: 'https://proxy.example.test/status', - bundle_url: 'https://proxy.example.test/index.bundle?platform=ios', + bundle_url: 'https://runtime-1.metro.agent-device.dev/index.bundle?platform=ios', ios_runtime: { - metro_host: '127.0.0.1', - metro_port: 8081, - metro_bundle_url: 'https://proxy.example.test/index.bundle?platform=ios', + 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', }, android_runtime: { - metro_host: '10.0.2.2', - metro_port: 8081, - metro_bundle_url: 'https://proxy.example.test/index.bundle?platform=android', + metro_host: 'proxy.example.test', + metro_port: 443, + metro_bundle_url: + 'https://proxy.example.test/api/metro/runtimes/runtime-1/index.bundle?platform=android', }, upstream: { bundle_url: @@ -99,7 +100,6 @@ test('prepareMetroRuntime starts the local companion only after bridge setup nee try { const result = await prepareMetroRuntime({ projectRoot, - publicBaseUrl: 'https://public.example.test', proxyBaseUrl: 'https://proxy.example.test', proxyBearerToken: 'shared-token', bridgeScope: TEST_BRIDGE_SCOPE, @@ -113,11 +113,13 @@ test('prepareMetroRuntime starts the local companion only after bridge setup nee assert.equal(result.bridge?.enabled, true); assert.equal( result.iosRuntime.bundleUrl, - 'https://proxy.example.test/index.bundle?platform=ios', + 'https://runtime-1.metro.agent-device.dev/index.bundle?platform=ios', ); + assert.equal(result.iosRuntime.metroHost, 'runtime-1.metro.agent-device.dev'); + assert.equal(result.iosRuntime.metroPort, 443); assert.equal( result.androidRuntime.bundleUrl, - 'https://proxy.example.test/index.bundle?platform=android', + 'https://proxy.example.test/api/metro/runtimes/runtime-1/index.bundle?platform=android', ); assert.equal(vi.mocked(ensureMetroCompanion).mock.calls.length, 1); assert.deepEqual(vi.mocked(ensureMetroCompanion).mock.calls[0]?.[0], { @@ -138,20 +140,12 @@ test('prepareMetroRuntime starts the local companion only after bridge setup nee tenantId: 'tenant-1', runId: 'run-1', leaseId: 'lease-1', - ios_runtime: { - metro_bundle_url: - 'https://public.example.test/index.bundle?platform=ios&dev=true&minify=false', - }, timeout_ms: 10000, }); assert.deepEqual(JSON.parse(String(fetchMock.mock.calls[2]?.[1]?.body)), { tenantId: 'tenant-1', runId: 'run-1', leaseId: 'lease-1', - ios_runtime: { - metro_bundle_url: - 'https://public.example.test/index.bundle?platform=ios&dev=true&minify=false', - }, timeout_ms: 10000, }); } finally { @@ -159,6 +153,78 @@ test('prepareMetroRuntime starts the local companion only after bridge setup nee } }); +test('prepareMetroRuntime rejects bridged descriptors without iOS bundle URLs', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-metro-descriptor-')); + const projectRoot = path.join(tempRoot, 'project'); + fs.mkdirSync(path.join(projectRoot, 'node_modules'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ + name: 'metro-descriptor-test', + private: true, + dependencies: { + 'react-native': '0.0.0-test', + }, + }), + ); + + const fetchMock = vi.fn(); + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => 'packager-status:running', + }); + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => + JSON.stringify({ + ok: true, + data: { + enabled: true, + base_url: 'https://proxy.example.test', + status_url: 'https://proxy.example.test/status', + bundle_url: 'https://proxy.example.test/index.bundle?platform=ios', + ios_runtime: {}, + android_runtime: { + metro_bundle_url: + 'https://proxy.example.test/api/metro/runtimes/runtime-1/index.bundle?platform=android', + }, + upstream: { + bundle_url: + 'https://public.example.test/index.bundle?platform=ios&dev=true&minify=false', + }, + probe: { + reachable: true, + status_code: 200, + latency_ms: 5, + detail: 'ok', + }, + }, + }), + }); + vi.stubGlobal('fetch', fetchMock); + + try { + await assert.rejects( + () => + prepareMetroRuntime({ + projectRoot, + proxyBaseUrl: 'https://proxy.example.test', + proxyBearerToken: 'shared-token', + bridgeScope: TEST_BRIDGE_SCOPE, + metroPort: 8081, + reuseExisting: true, + installDependenciesIfNeeded: false, + }), + /bridge descriptor is missing ios_runtime\.metro_bundle_url/, + ); + assert.equal(vi.mocked(ensureMetroCompanion).mock.calls.length, 0); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + test('prepareMetroRuntime preserves the initial bridge error if companion startup fails', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-metro-companion-error-')); const projectRoot = path.join(tempRoot, 'project'); @@ -387,12 +453,17 @@ test('prepareMetroRuntime retries malformed retryable bridge responses after com enabled: true, base_url: 'https://proxy.example.test', status_url: 'https://proxy.example.test/status', - bundle_url: 'https://proxy.example.test/index.bundle?platform=ios', + bundle_url: 'https://runtime-1.metro.agent-device.dev/index.bundle?platform=ios', ios_runtime: { - metro_bundle_url: 'https://proxy.example.test/index.bundle?platform=ios', + 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', }, android_runtime: { - metro_bundle_url: 'https://proxy.example.test/index.bundle?platform=android', + metro_host: 'proxy.example.test', + metro_port: 443, + metro_bundle_url: + 'https://proxy.example.test/api/metro/runtimes/runtime-1/index.bundle?platform=android', }, upstream: { bundle_url: @@ -429,8 +500,10 @@ test('prepareMetroRuntime retries malformed retryable bridge responses after com assert.equal(result.bridge?.enabled, true); assert.equal( result.iosRuntime.bundleUrl, - 'https://proxy.example.test/index.bundle?platform=ios', + 'https://runtime-1.metro.agent-device.dev/index.bundle?platform=ios', ); + assert.equal(result.iosRuntime.metroHost, 'runtime-1.metro.agent-device.dev'); + assert.equal(result.iosRuntime.metroPort, 443); assert.equal(fetchMock.mock.calls.length, 4); } finally { fs.rmSync(tempRoot, { recursive: true, force: true }); diff --git a/src/__tests__/client-metro.test.ts b/src/__tests__/client-metro.test.ts index ec1ad820f..0d7f2ffd3 100644 --- a/src/__tests__/client-metro.test.ts +++ b/src/__tests__/client-metro.test.ts @@ -58,7 +58,7 @@ test('prepareMetroRuntime starts Metro, bridges through proxy, and writes runtim assert.equal(body.tenantId, 'tenant-1'); assert.equal(body.runId, 'run-1'); assert.equal(body.leaseId, 'lease-1'); - assert.match(body.ios_runtime?.metro_bundle_url ?? '', /index\.bundle\?platform=ios/); + assert.equal(body.ios_runtime, undefined); res.statusCode = 200; res.setHeader('content-type', 'application/json'); @@ -72,16 +72,16 @@ test('prepareMetroRuntime starts Metro, bridges through proxy, and writes runtim 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: '127.0.0.1', - metro_port: 8081, + metro_host: 'runtime-1.metro.agent-device.dev', + metro_port: 443, metro_bundle_url: - 'http://127.0.0.1:8081/index.bundle?platform=ios&dev=true&minify=false', + 'https://runtime-1.metro.agent-device.dev/index.bundle?platform=ios&dev=true&minify=false', }, android_runtime: { - metro_host: '10.0.2.2', - metro_port: 8081, + metro_host: 'bridge.example.test', + metro_port: 443, metro_bundle_url: - 'http://10.0.2.2:8081/index.bundle?platform=android&dev=true&minify=false', + 'https://bridge.example.test/api/metro/runtimes/runtime-1/index.bundle?platform=android&dev=true&minify=false', }, upstream: { bundle_url: `http://127.0.0.1:${metroPort}/index.bundle?platform=ios&dev=true&minify=false`, @@ -140,9 +140,10 @@ test('prepareMetroRuntime starts Metro, bridges through proxy, and writes runtim assert.equal(result.started, true); assert.equal(result.reused, false); assert.equal(result.bridge?.enabled, true); - assert.equal(result.iosRuntime.metroHost, '127.0.0.1'); + 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, '10.0.2.2'); + assert.equal(result.androidRuntime.metroHost, 'bridge.example.test'); assert.equal(result.androidRuntime.platform, 'android'); assert.deepEqual(requests, ['/api/metro/bridge']); @@ -151,10 +152,10 @@ test('prepareMetroRuntime starts Metro, bridges through proxy, and writes runtim androidRuntime: { metroHost?: string; metroPort?: number; platform?: string }; runtimeFilePath?: string; }; - assert.equal(written.iosRuntime.metroHost, '127.0.0.1'); - assert.equal(written.iosRuntime.metroPort, 8081); + 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, '10.0.2.2'); + assert.equal(written.androidRuntime.metroHost, 'bridge.example.test'); assert.equal(written.androidRuntime.platform, 'android'); assert.equal(written.runtimeFilePath, runtimeFilePath); } finally { diff --git a/src/__tests__/metro-protocol-public.test.ts b/src/__tests__/metro-protocol-public.test.ts index a57105cc4..b41e34ce0 100644 --- a/src/__tests__/metro-protocol-public.test.ts +++ b/src/__tests__/metro-protocol-public.test.ts @@ -15,14 +15,15 @@ import { enabled: true, base_url: 'https://bridge.example.test', ios_runtime: { - metro_host: '127.0.0.1', - metro_port: 8081, - metro_bundle_url: 'https://bridge.example.test/index.bundle?platform=ios', + 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', }, android_runtime: { - metro_host: '10.0.2.2', - metro_port: 8081, - metro_bundle_url: 'https://bridge.example.test/index.bundle?platform=android', + metro_host: 'bridge.example.test', + metro_port: 443, + metro_bundle_url: + 'https://bridge.example.test/api/metro/runtimes/runtime-1/index.bundle?platform=android', }, upstream: { bundle_url: 'http://127.0.0.1:8081/index.bundle?platform=ios' }, probe: { reachable: true, status_code: 200, latency_ms: 4, detail: 'ok' }, diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index f11c19ee6..d098a338f 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -188,10 +188,10 @@ export async function prepareConnectedMetro( 'Deferred Metro preparation requires platform "ios" or "android".', ); } - if (!flags.metroPublicBaseUrl) { + if (!flags.metroPublicBaseUrl && !flags.metroProxyBaseUrl) { throw new AppError( 'INVALID_ARGS', - 'Deferred Metro preparation requires metroPublicBaseUrl when Metro settings are provided.', + 'Deferred Metro preparation requires metroPublicBaseUrl or metroProxyBaseUrl when Metro settings are provided.', ); } const prepared = await client.metro.prepare({ diff --git a/src/cli/commands/metro.ts b/src/cli/commands/metro.ts index 9c4f07666..0900b1a7a 100644 --- a/src/cli/commands/metro.ts +++ b/src/cli/commands/metro.ts @@ -7,8 +7,11 @@ export const metroCommand: ClientCommandHandler = async ({ positionals, flags, c if (action !== 'prepare') { throw new AppError('INVALID_ARGS', 'metro only supports prepare'); } - if (!flags.metroPublicBaseUrl) { - throw new AppError('INVALID_ARGS', 'metro prepare requires --public-base-url .'); + if (!flags.metroPublicBaseUrl && !flags.metroProxyBaseUrl) { + throw new AppError( + 'INVALID_ARGS', + 'metro prepare requires --public-base-url or --proxy-base-url .', + ); } const result = await client.metro.prepare({ diff --git a/src/client-metro.ts b/src/client-metro.ts index 17936ddec..18489f19f 100644 --- a/src/client-metro.ts +++ b/src/client-metro.ts @@ -80,7 +80,7 @@ type ProxyBridgeRequestOptions = { baseUrl: string; bearerToken: string; scope: MetroBridgeScope; - runtime: MetroBridgeRuntimePayload; + runtime?: MetroBridgeRuntimePayload; timeoutMs: number; }; @@ -366,7 +366,7 @@ async function configureMetroBridge(input: ProxyBridgeRequestOptions): Promise.'); + const hasProxyBaseUrl = Boolean(normalizeOptionalBaseUrl(input.proxyBaseUrl)); + if (!hasProxyBaseUrl) { + throw new AppError('INVALID_ARGS', 'metro prepare requires --public-base-url .'); + } } const { proxyEnabled, proxyBaseUrl, proxyBearerToken } = resolveProxySettings( @@ -676,8 +691,12 @@ export async function prepareMetroRuntime( } } - const publicIosRuntime = buildMetroRuntimeHints(publicBaseUrl, 'ios'); - const publicAndroidRuntime = buildMetroRuntimeHints(publicBaseUrl, 'android'); + const publicIosRuntime = publicBaseUrl + ? buildMetroRuntimeHints(publicBaseUrl, 'ios') + : { platform: 'ios' as const }; + const publicAndroidRuntime = publicBaseUrl + ? buildMetroRuntimeHints(publicBaseUrl, 'android') + : { platform: 'android' as const }; let bridge: MetroBridgeResult | null = null; let initialBridgeError: string | null = null; @@ -688,9 +707,6 @@ export async function prepareMetroRuntime( baseUrl: proxyBaseUrl, bearerToken: proxyBearerToken, scope: bridgeScope, - runtime: { - metro_bundle_url: publicIosRuntime.bundleUrl, - }, timeoutMs: probeTimeoutMs, }); } catch (error) { @@ -731,9 +747,6 @@ export async function prepareMetroRuntime( baseUrl: proxyBaseUrl, bearerToken: proxyBearerToken, scope: bridgeScope, - runtime: { - metro_bundle_url: publicIosRuntime.bundleUrl, - }, probeTimeoutMs, startupTimeoutMs, initialBridgeError, @@ -744,6 +757,10 @@ export async function prepareMetroRuntime( } } + if (bridgeScope) { + requireBridgeRuntimeDescriptor(proxyBaseUrl, bridge); + } + const iosRuntime = bridge?.iosRuntime ?? publicIosRuntime; const androidRuntime = bridge?.androidRuntime ?? publicAndroidRuntime; const result: PrepareMetroRuntimeResult = { diff --git a/src/client-types.ts b/src/client-types.ts index b610bf191..93711d18d 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -268,7 +268,7 @@ export type LeaseScopedOptions = LeaseOptions & { export type MetroPrepareOptions = { projectRoot?: string; kind?: MetroPrepareKind; - publicBaseUrl: string; + publicBaseUrl?: string; proxyBaseUrl?: string; bearerToken?: string; bridgeScope?: MetroBridgeScope; diff --git a/src/metro.ts b/src/metro.ts index a00da3508..7c419b1cb 100644 --- a/src/metro.ts +++ b/src/metro.ts @@ -104,7 +104,7 @@ export type MetroTunnelMessage = MetroTunnelRequestMessage | MetroTunnelResponse export type PrepareRemoteMetroOptions = { projectRoot: string; kind: 'auto' | 'react-native' | 'expo'; - publicBaseUrl: string; + publicBaseUrl?: string; proxyBaseUrl?: string; proxyBearerToken?: string; bridgeScope?: MetroBridgeScope; diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index 835746ff1..ae3070cfa 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -903,7 +903,10 @@ test('usage includes swipe and press series options', () => { test('usage renders concise commands inline with descriptions', () => { const help = usage(); assert.match(help, /Commands:[\s\S]*\n boot\s{2,}Boot target device\/simulator/); - assert.match(help, / metro prepare --public-base-url \s{2,}Prepare local Metro runtime/); + assert.match( + help, + / metro prepare --public-base-url \| --proxy-base-url \s{2,}Prepare local Metro runtime/, + ); assert.match(help, / batch --steps \| --steps-file \s{2,}Run multiple commands/); assert.match(help, / test \.\.\.\s{2,}Run \.ad test suites/); assert.match(help, / session list\s{2,}List active sessions/); diff --git a/src/utils/command-schema.ts b/src/utils/command-schema.ts index e3c4f9e13..b12f53b36 100644 --- a/src/utils/command-schema.ts +++ b/src/utils/command-schema.ts @@ -393,14 +393,14 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ names: ['--public-base-url'], type: 'string', usageLabel: '--public-base-url ', - usageDescription: 'metro prepare: public base URL used to build bundle hints', + usageDescription: 'metro prepare: public base URL used for direct bundle hints', }, { key: 'metroProxyBaseUrl', names: ['--proxy-base-url'], type: 'string', usageLabel: '--proxy-base-url ', - usageDescription: 'metro prepare: optional remote host bridge base URL for Metro access', + usageDescription: 'metro prepare: optional bridge origin for remote Metro access', }, { key: 'metroBearerToken', @@ -1098,8 +1098,8 @@ const COMMAND_SCHEMAS: Record = { }, metro: { usageOverride: - 'metro prepare --public-base-url [--project-root ] [--port ] [--kind auto|react-native|expo]', - listUsageOverride: 'metro prepare --public-base-url ', + 'metro prepare (--public-base-url | --proxy-base-url ) [--project-root ] [--port ] [--kind auto|react-native|expo]', + listUsageOverride: 'metro prepare --public-base-url | --proxy-base-url ', helpDescription: 'Prepare a local Metro runtime and optionally bridge it through a remote host', summary: 'Prepare local Metro runtime', positionalArgs: ['prepare'], diff --git a/test/integration/installed-package-metro.test.ts b/test/integration/installed-package-metro.test.ts index 59e17956b..c721ae3f3 100644 --- a/test/integration/installed-package-metro.test.ts +++ b/test/integration/installed-package-metro.test.ts @@ -189,16 +189,17 @@ test('installed package exposes Node APIs and packaged metro companion entrypoin enabled: true, base_url: `http://127.0.0.1:${bridgePort}`, status_url: `http://127.0.0.1:${metroPort}/status`, - bundle_url: 'https://bridge.example.test/index.bundle?platform=ios', + bundle_url: 'https://demo.metro.agent-device.dev/index.bundle?platform=ios', ios_runtime: { - metro_host: '127.0.0.1', - metro_port: metroPort, - metro_bundle_url: 'https://bridge.example.test/index.bundle?platform=ios', + metro_host: 'demo.metro.agent-device.dev', + metro_port: 443, + metro_bundle_url: 'https://demo.metro.agent-device.dev/index.bundle?platform=ios', }, android_runtime: { - metro_host: '10.0.2.2', - metro_port: metroPort, - metro_bundle_url: 'https://bridge.example.test/index.bundle?platform=android', + metro_host: 'bridge.example.test', + metro_port: 443, + metro_bundle_url: + 'https://bridge.example.test/api/metro/runtimes/demo/index.bundle?platform=android', }, upstream: { bundle_url: @@ -278,7 +279,6 @@ test('installed package exposes Node APIs and packaged metro companion entrypoin JSON.stringify({ platform: 'ios', metroProjectRoot: projectRoot, - metroPublicBaseUrl: 'https://public.example.test', metroProxyBaseUrl: `http://127.0.0.1:${bridgePort}`, metroBearerToken: bridgeToken, tenant: 'tenant-1', diff --git a/test/integration/smoke-open-remote-config.test.ts b/test/integration/smoke-open-remote-config.test.ts index b44235179..9d1af671f 100644 --- a/test/integration/smoke-open-remote-config.test.ts +++ b/test/integration/smoke-open-remote-config.test.ts @@ -202,17 +202,19 @@ test('connect prepares Metro and open reuses bridged runtime for remote daemon', enabled: true, base_url: `http://127.0.0.1:${hostPort}`, status_url: `http://127.0.0.1:${metroPort}/status`, - bundle_url: 'https://bridge.example.test/index.bundle?platform=ios', + bundle_url: 'https://qa-android.metro.agent-device.dev/index.bundle?platform=ios', ios_runtime: { - metro_host: '127.0.0.1', - metro_port: metroPort, - metro_bundle_url: 'https://bridge.example.test/index.bundle?platform=ios', + metro_host: 'qa-android.metro.agent-device.dev', + metro_port: 443, + metro_bundle_url: + 'https://qa-android.metro.agent-device.dev/index.bundle?platform=ios', launch_url: 'myapp://ios-dev', }, android_runtime: { - metro_host: '10.0.2.2', - metro_port: metroPort, - metro_bundle_url: 'https://bridge.example.test/index.bundle?platform=android', + metro_host: 'bridge.example.test', + metro_port: 443, + metro_bundle_url: + 'https://bridge.example.test/api/metro/runtimes/qa-android/index.bundle?platform=android', launch_url: 'myapp://android-dev', }, upstream: { @@ -314,7 +316,6 @@ test('connect prepares Metro and open reuses bridged runtime for remote daemon', platform: 'android', daemonBaseUrl: `http://127.0.0.1:${hostPort}/agent-device`, metroProjectRoot: '../project', - metroPublicBaseUrl: 'https://public.example.test', metroProxyBaseUrl: `http://127.0.0.1:${hostPort}`, metroPreparePort: metroPort, }), @@ -360,23 +361,22 @@ test('connect prepares Metro and open reuses bridged runtime for remote daemon', assert.equal(capturedOpenRpcRequest?.body?.params?.meta?.leaseId, 'abc123abc123abc1'); assert.deepEqual(capturedOpenRpcRequest?.body?.params?.runtime, { platform: 'android', - metroHost: '10.0.2.2', - metroPort: metroPort, - bundleUrl: 'https://bridge.example.test/index.bundle?platform=android', + metroHost: 'bridge.example.test', + metroPort: 443, + bundleUrl: + 'https://bridge.example.test/api/metro/runtimes/qa-android/index.bundle?platform=android', launchUrl: 'myapp://android-dev', }); - assert.equal( - capturedBridgeRequest?.body?.ios_runtime?.metro_bundle_url, - 'https://public.example.test/index.bundle?platform=ios&dev=true&minify=false', - ); + assert.equal(capturedBridgeRequest?.body?.ios_runtime, undefined); assert.equal(capturedBridgeRequest?.body?.tenantId, 'acme'); assert.equal(capturedBridgeRequest?.body?.runId, 'run-123'); assert.equal(capturedBridgeRequest?.body?.leaseId, 'abc123abc123abc1'); assert.deepEqual(result.json?.data?.runtime, { platform: 'android', - metroHost: '10.0.2.2', - metroPort: metroPort, - bundleUrl: 'https://bridge.example.test/index.bundle?platform=android', + metroHost: 'bridge.example.test', + metroPort: 443, + bundleUrl: + 'https://bridge.example.test/api/metro/runtimes/qa-android/index.bundle?platform=android', launchUrl: 'myapp://android-dev', }); }); diff --git a/test/scripts/metro-prepare-packaged-smoke.mjs b/test/scripts/metro-prepare-packaged-smoke.mjs index b836da1fe..35bc94289 100644 --- a/test/scripts/metro-prepare-packaged-smoke.mjs +++ b/test/scripts/metro-prepare-packaged-smoke.mjs @@ -273,16 +273,18 @@ async function main() { enabled: true, base_url: `http://127.0.0.1:${hostPort}`, status_url: `http://127.0.0.1:${metroPort}/status`, - bundle_url: 'https://bridge.example.test/index.bundle?platform=ios', + bundle_url: 'https://packaged.metro.agent-device.dev/index.bundle?platform=ios', ios_runtime: { - metro_host: '127.0.0.1', - metro_port: metroPort, - metro_bundle_url: 'https://bridge.example.test/index.bundle?platform=ios', + metro_host: 'packaged.metro.agent-device.dev', + metro_port: 443, + metro_bundle_url: + 'https://packaged.metro.agent-device.dev/index.bundle?platform=ios', }, android_runtime: { - metro_host: '10.0.2.2', - metro_port: metroPort, - metro_bundle_url: 'https://bridge.example.test/index.bundle?platform=android', + metro_host: 'bridge.example.test', + metro_port: 443, + metro_bundle_url: + 'https://bridge.example.test/api/metro/runtimes/packaged/index.bundle?platform=android', }, upstream: { bundle_url: @@ -334,7 +336,6 @@ async function main() { remoteConfigPath, JSON.stringify({ metroProjectRoot: projectRoot, - metroPublicBaseUrl: 'https://public.example.test', metroProxyBaseUrl: `http://127.0.0.1:${hostPort}`, metroBearerToken: 'shared-token', ...bridgeScope, @@ -418,17 +419,11 @@ async function main() { assert.equal(bridgeBodies[0]?.tenantId, bridgeScope.tenant); assert.equal(bridgeBodies[0]?.runId, bridgeScope.runId); assert.equal(bridgeBodies[0]?.leaseId, bridgeScope.leaseId); - assert.equal( - bridgeBodies[0]?.ios_runtime?.metro_bundle_url, - 'https://public.example.test/index.bundle?platform=ios&dev=true&minify=false', - ); + assert.equal(bridgeBodies[0]?.ios_runtime, undefined); assert.equal(bridgeBodies.at(-1)?.tenantId, bridgeScope.tenant); assert.equal(bridgeBodies.at(-1)?.runId, bridgeScope.runId); assert.equal(bridgeBodies.at(-1)?.leaseId, bridgeScope.leaseId); - assert.equal( - bridgeBodies.at(-1)?.ios_runtime?.metro_bundle_url, - 'https://public.example.test/index.bundle?platform=ios&dev=true&minify=false', - ); + assert.equal(bridgeBodies.at(-1)?.ios_runtime, undefined); } finally { stopProcessIfAlive(companionPid); for (const socket of wsSockets) { diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 75f95af90..b0d6d4f7c 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -271,9 +271,13 @@ const remoteConfig = resolveRemoteConfigProfile({ const prepared = await prepareRemoteMetro({ projectRoot: remoteConfig.profile.metroProjectRoot!, kind: remoteConfig.profile.metroKind ?? 'auto', - publicBaseUrl: remoteConfig.profile.metroPublicBaseUrl!, proxyBaseUrl: remoteConfig.profile.metroProxyBaseUrl, proxyBearerToken: remoteConfig.profile.metroBearerToken, + bridgeScope: { + tenantId: remoteConfig.profile.tenant!, + runId: remoteConfig.profile.runId!, + leaseId: remoteConfig.profile.leaseId!, + }, profileKey: remoteConfig.resolvedPath, }); @@ -285,7 +289,7 @@ await stopMetroTunnel({ }); ``` -Use `agent-device/remote-config` for profile loading and path resolution, `agent-device/metro` for Metro preparation and tunnel lifecycle, and `agent-device/contracts` when a server consumer needs daemon request or runtime contract types. +Use `agent-device/remote-config` for profile loading and path resolution, `agent-device/metro` for Metro preparation and tunnel lifecycle, and `agent-device/contracts` when a server consumer needs daemon request or runtime contract types. For bridged remote Metro, `proxyBaseUrl` is the bridge origin and `publicBaseUrl` is optional; the bridge descriptor supplies cloud iOS wildcard HTTPS hints and Android runtime-route hints. ## Selector helpers diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 1f3d9419d..58d5329d9 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -646,13 +646,12 @@ Example `agent-device.remote.json`: "daemonTransport": "http", "tenant": "acme", "runId": "run-123", - "session": "adc-android", + "session": "adc-ios", "sessionIsolation": "tenant", - "platform": "android", - "leaseBackend": "android-instance", + "platform": "ios", + "leaseBackend": "ios-instance", "metroProjectRoot": ".", - "metroPublicBaseUrl": "http://127.0.0.1:8081", - "metroProxyBaseUrl": "https://bridge.example.com/metro/acme/run-123" + "metroProxyBaseUrl": "https://bridge.example.com" } ``` @@ -666,7 +665,7 @@ agent-device disconnect For self-contained scripts, pass the same profile to each step: ```bash -agent-device install-from-source https://example.com/builds/app.apk --remote-config ./agent-device.remote.json --platform android +agent-device install-from-source https://example.com/builds/Demo.app.zip --remote-config ./agent-device.remote.json --platform ios agent-device open com.example.myapp --remote-config ./agent-device.remote.json --relaunch agent-device snapshot --remote-config ./agent-device.remote.json -i agent-device disconnect --remote-config ./agent-device.remote.json @@ -678,6 +677,10 @@ agent-device disconnect --remote-config ./agent-device.remote.json - After `connect`, `snapshot`, `press`, `fill`, `screenshot`, and other normal commands reuse active connection state so agents do not repeat remote host/session/lease selectors inline. Passing the same `--remote-config` to a normal command is also supported for self-contained scripts; the CLI reuses matching saved state or creates it before dispatch. - Self-contained remote scripts should end with `disconnect --remote-config ` or `disconnect` to release the lease and stop the owned Metro companion. - Explicit command-line flags override connected defaults. When `open` uses explicit remote daemon or tenant flags without saved runtime hints, the CLI warns because React Native apps may launch without Metro bundle/runtime hints. +- `metroProxyBaseUrl` is the bridge origin. Do not prebuild `/api/metro/...` paths in the client profile; the CLI calls the bridge endpoints itself. +- For cloud stock React Native iOS, the bridge descriptor supplies direct wildcard HTTPS Metro hints such as `.metro.agent-device.dev:443`. The XCTest runner package is still used for runner-backed device commands, not for Metro reachability. +- Android keeps using bridge-provided runtime routes such as `/api/metro/runtimes//...`. +- `metroPublicBaseUrl` is only needed for direct/non-bridge bundle hints. Bridged profiles can omit it and rely on `metroProxyBaseUrl`. - `metro prepare --remote-config ...` remains an advanced inspection/debug path and can still write a `--runtime-file ` artifact when needed. - The local Metro companion runs on the same machine as the React Native project and Metro. `disconnect` stops the companion owned by the connection, but it does not stop the user’s Metro server.