mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
fix: include lease scope in Metro bridge calls (#408)
This commit is contained in:
committed by
GitHub
parent
ac01393a9e
commit
e99bbb526e
@@ -126,6 +126,9 @@ test('metro prepare forwards normalized options to client.metro.prepare', async
|
||||
metroPublicBaseUrl: 'https://sandbox.example.test',
|
||||
metroProxyBaseUrl: 'https://proxy.example.test',
|
||||
metroBearerToken: 'secret',
|
||||
tenant: 'tenant-1',
|
||||
runId: 'run-1',
|
||||
leaseId: 'lease-1',
|
||||
metroPreparePort: 9090,
|
||||
metroKind: 'expo',
|
||||
metroRuntimeFile: './.agent-device/metro-runtime.json',
|
||||
@@ -143,6 +146,11 @@ test('metro prepare forwards normalized options to client.metro.prepare', async
|
||||
publicBaseUrl: 'https://sandbox.example.test',
|
||||
proxyBaseUrl: 'https://proxy.example.test',
|
||||
bearerToken: 'secret',
|
||||
bridgeScope: {
|
||||
tenantId: 'tenant-1',
|
||||
runId: 'run-1',
|
||||
leaseId: 'lease-1',
|
||||
},
|
||||
port: 9090,
|
||||
kind: 'expo',
|
||||
runtimeFilePath: './.agent-device/metro-runtime.json',
|
||||
@@ -366,6 +374,9 @@ test('metro prepare with --remote-config loads profile defaults', async () => {
|
||||
metroProjectRoot: './apps/demo',
|
||||
metroPublicBaseUrl: 'https://sandbox.example.test',
|
||||
metroProxyBaseUrl: 'https://proxy.example.test',
|
||||
tenant: 'tenant-1',
|
||||
runId: 'run-1',
|
||||
leaseId: 'lease-1',
|
||||
metroPreparePort: 9090,
|
||||
}),
|
||||
);
|
||||
@@ -421,6 +432,11 @@ test('metro prepare with --remote-config loads profile defaults', async () => {
|
||||
publicBaseUrl: 'https://sandbox.example.test',
|
||||
proxyBaseUrl: 'https://proxy.example.test',
|
||||
bearerToken: undefined,
|
||||
bridgeScope: {
|
||||
tenantId: 'tenant-1',
|
||||
runId: 'run-1',
|
||||
leaseId: 'lease-1',
|
||||
},
|
||||
port: 9090,
|
||||
listenHost: undefined,
|
||||
statusHost: undefined,
|
||||
|
||||
@@ -11,6 +11,12 @@ vi.mock('../client-metro-companion.ts', () => ({
|
||||
import { ensureMetroCompanion } from '../client-metro-companion.ts';
|
||||
import { prepareMetroRuntime } from '../client-metro.ts';
|
||||
|
||||
const TEST_BRIDGE_SCOPE = {
|
||||
tenantId: 'tenant-1',
|
||||
runId: 'run-1',
|
||||
leaseId: 'lease-1',
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
@@ -96,6 +102,7 @@ test('prepareMetroRuntime starts the local companion only after bridge setup nee
|
||||
publicBaseUrl: 'https://public.example.test',
|
||||
proxyBaseUrl: 'https://proxy.example.test',
|
||||
proxyBearerToken: 'shared-token',
|
||||
bridgeScope: TEST_BRIDGE_SCOPE,
|
||||
metroPort: 8081,
|
||||
reuseExisting: true,
|
||||
installDependenciesIfNeeded: false,
|
||||
@@ -117,6 +124,7 @@ test('prepareMetroRuntime starts the local companion only after bridge setup nee
|
||||
projectRoot,
|
||||
serverBaseUrl: 'https://proxy.example.test',
|
||||
bearerToken: 'shared-token',
|
||||
bridgeScope: TEST_BRIDGE_SCOPE,
|
||||
localBaseUrl: 'http://127.0.0.1:8081',
|
||||
launchUrl: undefined,
|
||||
profileKey: undefined,
|
||||
@@ -126,6 +134,26 @@ test('prepareMetroRuntime starts the local companion only after bridge setup nee
|
||||
assert.equal(fetchMock.mock.calls.length, 3);
|
||||
assert.equal(fetchMock.mock.calls[1]?.[0], 'https://proxy.example.test/api/metro/bridge');
|
||||
assert.equal(fetchMock.mock.calls[2]?.[0], 'https://proxy.example.test/api/metro/bridge');
|
||||
assert.deepEqual(JSON.parse(String(fetchMock.mock.calls[1]?.[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,
|
||||
});
|
||||
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 {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -165,6 +193,7 @@ test('prepareMetroRuntime preserves the initial bridge error if companion startu
|
||||
publicBaseUrl: 'https://public.example.test',
|
||||
proxyBaseUrl: 'https://proxy.example.test',
|
||||
proxyBearerToken: 'shared-token',
|
||||
bridgeScope: TEST_BRIDGE_SCOPE,
|
||||
metroPort: 8081,
|
||||
reuseExisting: true,
|
||||
installDependenciesIfNeeded: false,
|
||||
@@ -183,6 +212,57 @@ test('prepareMetroRuntime preserves the initial bridge error if companion startu
|
||||
}
|
||||
});
|
||||
|
||||
test('prepareMetroRuntime fails fast when initial bridge failure is non-retryable', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-metro-companion-401-'));
|
||||
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-initial-bridge-non-retryable-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: false,
|
||||
status: 401,
|
||||
text: async () => JSON.stringify({ ok: false, error: 'invalid scope' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
prepareMetroRuntime({
|
||||
projectRoot,
|
||||
publicBaseUrl: 'https://public.example.test',
|
||||
proxyBaseUrl: 'https://proxy.example.test',
|
||||
proxyBearerToken: 'shared-token',
|
||||
bridgeScope: TEST_BRIDGE_SCOPE,
|
||||
metroPort: 8081,
|
||||
reuseExisting: true,
|
||||
installDependenciesIfNeeded: false,
|
||||
probeTimeoutMs: 10,
|
||||
}),
|
||||
/\/api\/metro\/bridge failed \(401\)/,
|
||||
);
|
||||
assert.equal(vi.mocked(ensureMetroCompanion).mock.calls.length, 0);
|
||||
assert.equal(fetchMock.mock.calls.length, 2);
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('prepareMetroRuntime fails fast on non-retryable bridge errors after companion startup', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-metro-companion-401-'));
|
||||
const projectRoot = path.join(tempRoot, 'project');
|
||||
@@ -231,6 +311,7 @@ test('prepareMetroRuntime fails fast on non-retryable bridge errors after compan
|
||||
publicBaseUrl: 'https://public.example.test',
|
||||
proxyBaseUrl: 'https://proxy.example.test',
|
||||
proxyBearerToken: 'shared-token',
|
||||
bridgeScope: TEST_BRIDGE_SCOPE,
|
||||
metroPort: 8081,
|
||||
reuseExisting: true,
|
||||
installDependenciesIfNeeded: false,
|
||||
@@ -335,6 +416,7 @@ test('prepareMetroRuntime retries malformed retryable bridge responses after com
|
||||
publicBaseUrl: 'https://public.example.test',
|
||||
proxyBaseUrl: 'https://proxy.example.test',
|
||||
proxyBearerToken: 'shared-token',
|
||||
bridgeScope: TEST_BRIDGE_SCOPE,
|
||||
metroPort: 8081,
|
||||
reuseExisting: true,
|
||||
installDependenciesIfNeeded: false,
|
||||
|
||||
@@ -178,6 +178,7 @@ test('metro companion worker proxies websocket frames to the local upstream serv
|
||||
const upstreamMessage = createDeferred<string>();
|
||||
const bridgePong = createDeferred<void>();
|
||||
const bridgeSocketReady = createDeferred<NodeJS.WritableStream>();
|
||||
const registrationBody = createDeferred<Record<string, unknown>>();
|
||||
const bridgeOpen = createDeferred<void>();
|
||||
const bridgeFrame = createDeferred<string>();
|
||||
const bridgeClose = createDeferred<CloseFrame>();
|
||||
@@ -233,8 +234,12 @@ test('metro companion worker proxies websocket frames to the local upstream serv
|
||||
const bridgeServer = http.createServer((req, res) => {
|
||||
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
||||
if (req.method === 'POST' && url.pathname === '/api/metro/companion/register') {
|
||||
req.resume();
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
req.on('end', () => {
|
||||
registrationBody.resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
@@ -325,6 +330,9 @@ test('metro companion worker proxies websocket frames to the local upstream serv
|
||||
AGENT_DEVICE_METRO_COMPANION_SERVER_BASE_URL: `http://127.0.0.1:${bridgePort}`,
|
||||
AGENT_DEVICE_METRO_COMPANION_BEARER_TOKEN: 'test-token',
|
||||
AGENT_DEVICE_METRO_COMPANION_LOCAL_BASE_URL: `http://127.0.0.1:${upstreamPort}`,
|
||||
AGENT_DEVICE_METRO_COMPANION_SCOPE_TENANT_ID: 'tenant-1',
|
||||
AGENT_DEVICE_METRO_COMPANION_SCOPE_RUN_ID: 'run-1',
|
||||
AGENT_DEVICE_METRO_COMPANION_SCOPE_LEASE_ID: 'lease-1',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
@@ -349,6 +357,12 @@ test('metro companion worker proxies websocket frames to the local upstream serv
|
||||
waitFor(bridgeSocketReady.promise, 5_000, 'bridge websocket connection'),
|
||||
earlyExit,
|
||||
]);
|
||||
assert.deepEqual(await waitFor(registrationBody.promise, 5_000, 'companion registration'), {
|
||||
tenantId: 'tenant-1',
|
||||
runId: 'run-1',
|
||||
leaseId: 'lease-1',
|
||||
local_base_url: `http://127.0.0.1:${upstreamPort}`,
|
||||
});
|
||||
await Promise.race([waitFor(bridgePong.promise, 5_000, 'bridge pong'), earlyExit]);
|
||||
await Promise.race([waitFor(bridgeOpen.promise, 5_000, 'bridge ws-open-result'), earlyExit]);
|
||||
bridgeSocket.write(
|
||||
@@ -467,6 +481,9 @@ test('metro companion worker reconnects after the bridge closes immediately afte
|
||||
AGENT_DEVICE_METRO_COMPANION_SERVER_BASE_URL: `http://127.0.0.1:${bridgePort}`,
|
||||
AGENT_DEVICE_METRO_COMPANION_BEARER_TOKEN: 'test-token',
|
||||
AGENT_DEVICE_METRO_COMPANION_LOCAL_BASE_URL: `http://127.0.0.1:${localPort}`,
|
||||
AGENT_DEVICE_METRO_COMPANION_SCOPE_TENANT_ID: 'tenant-1',
|
||||
AGENT_DEVICE_METRO_COMPANION_SCOPE_RUN_ID: 'run-1',
|
||||
AGENT_DEVICE_METRO_COMPANION_SCOPE_LEASE_ID: 'lease-1',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
@@ -571,6 +588,9 @@ test('metro companion worker exits after its state file is removed', async () =>
|
||||
AGENT_DEVICE_METRO_COMPANION_SERVER_BASE_URL: `http://127.0.0.1:${bridgePort}`,
|
||||
AGENT_DEVICE_METRO_COMPANION_BEARER_TOKEN: 'test-token',
|
||||
AGENT_DEVICE_METRO_COMPANION_LOCAL_BASE_URL: `http://127.0.0.1:${localPort}`,
|
||||
AGENT_DEVICE_METRO_COMPANION_SCOPE_TENANT_ID: 'tenant-1',
|
||||
AGENT_DEVICE_METRO_COMPANION_SCOPE_RUN_ID: 'run-1',
|
||||
AGENT_DEVICE_METRO_COMPANION_SCOPE_LEASE_ID: 'lease-1',
|
||||
AGENT_DEVICE_METRO_COMPANION_STATE_PATH: statePath,
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
@@ -615,6 +635,9 @@ test('metro companion worker exits immediately when its state file is already mi
|
||||
AGENT_DEVICE_METRO_COMPANION_SERVER_BASE_URL: 'http://127.0.0.1:1',
|
||||
AGENT_DEVICE_METRO_COMPANION_BEARER_TOKEN: 'test-token',
|
||||
AGENT_DEVICE_METRO_COMPANION_LOCAL_BASE_URL: 'http://127.0.0.1:1',
|
||||
AGENT_DEVICE_METRO_COMPANION_SCOPE_TENANT_ID: 'tenant-1',
|
||||
AGENT_DEVICE_METRO_COMPANION_SCOPE_RUN_ID: 'run-1',
|
||||
AGENT_DEVICE_METRO_COMPANION_SCOPE_LEASE_ID: 'lease-1',
|
||||
AGENT_DEVICE_METRO_COMPANION_STATE_PATH: statePath,
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
|
||||
@@ -24,6 +24,12 @@ import {
|
||||
} from '../utils/process-identity.ts';
|
||||
import { ensureMetroCompanion, stopMetroCompanion } from '../client-metro-companion.ts';
|
||||
|
||||
const TEST_BRIDGE_SCOPE = {
|
||||
tenantId: 'tenant-1',
|
||||
runId: 'run-1',
|
||||
leaseId: 'lease-1',
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
@@ -59,6 +65,7 @@ test('companion ownership is profile-scoped and consumer-counted', async () => {
|
||||
serverBaseUrl: 'https://bridge.example.test',
|
||||
bearerToken: 'token',
|
||||
localBaseUrl: 'http://127.0.0.1:8081',
|
||||
bridgeScope: TEST_BRIDGE_SCOPE,
|
||||
launchUrl: 'myapp://staging',
|
||||
profileKey: '/tmp/staging.json',
|
||||
consumerKey: 'session-a',
|
||||
@@ -68,6 +75,7 @@ test('companion ownership is profile-scoped and consumer-counted', async () => {
|
||||
serverBaseUrl: 'https://bridge.example.test',
|
||||
bearerToken: 'token',
|
||||
localBaseUrl: 'http://127.0.0.1:8081',
|
||||
bridgeScope: TEST_BRIDGE_SCOPE,
|
||||
launchUrl: 'myapp://staging',
|
||||
profileKey: '/tmp/staging.json',
|
||||
consumerKey: 'session-b',
|
||||
@@ -77,6 +85,7 @@ test('companion ownership is profile-scoped and consumer-counted', async () => {
|
||||
serverBaseUrl: 'https://bridge.example.test',
|
||||
bearerToken: 'token',
|
||||
localBaseUrl: 'http://127.0.0.1:8081',
|
||||
bridgeScope: TEST_BRIDGE_SCOPE,
|
||||
launchUrl: 'myapp://prod',
|
||||
profileKey: '/tmp/prod.json',
|
||||
consumerKey: 'session-prod',
|
||||
@@ -155,6 +164,7 @@ test('launchUrl changes force a companion respawn for the same profile', async (
|
||||
serverBaseUrl: 'https://bridge.example.test',
|
||||
bearerToken: 'token',
|
||||
localBaseUrl: 'http://127.0.0.1:8081',
|
||||
bridgeScope: TEST_BRIDGE_SCOPE,
|
||||
launchUrl: 'myapp://first',
|
||||
profileKey: '/tmp/profile.json',
|
||||
consumerKey: 'session-a',
|
||||
@@ -164,6 +174,7 @@ test('launchUrl changes force a companion respawn for the same profile', async (
|
||||
serverBaseUrl: 'https://bridge.example.test',
|
||||
bearerToken: 'token',
|
||||
localBaseUrl: 'http://127.0.0.1:8081',
|
||||
bridgeScope: TEST_BRIDGE_SCOPE,
|
||||
launchUrl: 'myapp://second',
|
||||
profileKey: '/tmp/profile.json',
|
||||
consumerKey: 'session-a',
|
||||
@@ -184,3 +195,54 @@ test('launchUrl changes force a companion respawn for the same profile', async (
|
||||
fs.rmSync(projectRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('legacy state without bridge scope is stopped before respawn', async () => {
|
||||
const projectRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'agent-device-metro-companion-legacy-'),
|
||||
);
|
||||
const statePath = path.join(projectRoot, '.agent-device', 'metro-companion.json');
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
statePath,
|
||||
`${JSON.stringify({
|
||||
pid: 555,
|
||||
startTime: 'start-555',
|
||||
command: `${process.execPath} src/metro-companion.ts --agent-device-run-metro-companion`,
|
||||
serverBaseUrl: 'https://bridge.example.test',
|
||||
localBaseUrl: 'http://127.0.0.1:8081',
|
||||
tokenHash: 'legacy-token-hash',
|
||||
consumers: ['session-a'],
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
vi.mocked(runCmdDetached).mockReturnValueOnce(666);
|
||||
vi.mocked(isProcessAlive).mockReturnValue(true);
|
||||
vi.mocked(readProcessStartTime).mockReturnValue('start-555');
|
||||
vi.mocked(readProcessCommand).mockReturnValue(
|
||||
`${process.execPath} src/metro-companion.ts --agent-device-run-metro-companion`,
|
||||
);
|
||||
vi.mocked(waitForProcessExit).mockResolvedValue(true);
|
||||
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
|
||||
|
||||
const spawned = await ensureMetroCompanion({
|
||||
projectRoot,
|
||||
serverBaseUrl: 'https://bridge.example.test',
|
||||
bearerToken: 'token',
|
||||
localBaseUrl: 'http://127.0.0.1:8081',
|
||||
bridgeScope: TEST_BRIDGE_SCOPE,
|
||||
consumerKey: 'session-a',
|
||||
});
|
||||
|
||||
assert.equal(spawned.spawned, true);
|
||||
assert.equal(spawned.pid, 666);
|
||||
assert.equal(vi.mocked(runCmdDetached).mock.calls.length, 1);
|
||||
assert.deepEqual(killSpy.mock.calls[0], [555, 'SIGTERM']);
|
||||
const state = JSON.parse(fs.readFileSync(spawned.statePath, 'utf8')) as {
|
||||
bridgeScope?: unknown;
|
||||
};
|
||||
assert.deepEqual(state.bridgeScope, TEST_BRIDGE_SCOPE);
|
||||
} finally {
|
||||
fs.rmSync(projectRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -50,8 +50,14 @@ test('prepareMetroRuntime starts Metro, bridges through proxy, and writes runtim
|
||||
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.match(body.ios_runtime?.metro_bundle_url ?? '', /index\.bundle\?platform=ios/);
|
||||
|
||||
res.statusCode = 200;
|
||||
@@ -114,6 +120,11 @@ test('prepareMetroRuntime starts Metro, bridges through proxy, and writes runtim
|
||||
publicBaseUrl: `http://127.0.0.1:${metroPort}`,
|
||||
proxyBaseUrl: `http://127.0.0.1:${proxyPort}`,
|
||||
proxyBearerToken: TEST_TOKEN,
|
||||
bridgeScope: {
|
||||
tenantId: 'tenant-1',
|
||||
runId: 'run-1',
|
||||
leaseId: 'lease-1',
|
||||
},
|
||||
metroPort,
|
||||
reuseExisting: false,
|
||||
installDependenciesIfNeeded: false,
|
||||
@@ -172,6 +183,19 @@ test('prepareMetroRuntime rejects incomplete proxy configuration', async () => {
|
||||
error.code === 'INVALID_ARGS' &&
|
||||
error.message.includes('AGENT_DEVICE_PROXY_TOKEN'),
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
prepareMetroRuntime({
|
||||
publicBaseUrl: 'https://sandbox.example.test',
|
||||
proxyBaseUrl: 'https://proxy.example.test',
|
||||
proxyBearerToken: TEST_TOKEN,
|
||||
}),
|
||||
(error) =>
|
||||
error instanceof AppError &&
|
||||
error.code === 'INVALID_ARGS' &&
|
||||
error.message.includes('tenantId, runId, and leaseId bridge scope'),
|
||||
);
|
||||
});
|
||||
|
||||
function writeFakeNpx(binDir: string): void {
|
||||
|
||||
@@ -25,6 +25,12 @@ import {
|
||||
stopMetroTunnel,
|
||||
} from '../metro.ts';
|
||||
|
||||
const TEST_BRIDGE_SCOPE = {
|
||||
tenantId: 'tenant-1',
|
||||
runId: 'run-1',
|
||||
leaseId: 'lease-1',
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
@@ -66,6 +72,7 @@ test('public metro helpers expose stable Node-facing wrappers', async () => {
|
||||
publicBaseUrl: 'https://public.example.test',
|
||||
proxyBaseUrl: 'https://proxy.example.test',
|
||||
proxyBearerToken: 'token',
|
||||
bridgeScope: TEST_BRIDGE_SCOPE,
|
||||
profileKey: '/tmp/profile.remote.json',
|
||||
consumerKey: 'session-a',
|
||||
port: 8081,
|
||||
@@ -75,6 +82,7 @@ test('public metro helpers expose stable Node-facing wrappers', async () => {
|
||||
serverBaseUrl: 'https://proxy.example.test',
|
||||
bearerToken: 'token',
|
||||
localBaseUrl: 'http://127.0.0.1:8081',
|
||||
bridgeScope: TEST_BRIDGE_SCOPE,
|
||||
});
|
||||
await stopMetroTunnel({
|
||||
projectRoot: '/tmp/project',
|
||||
@@ -90,6 +98,7 @@ test('public metro helpers expose stable Node-facing wrappers', async () => {
|
||||
publicBaseUrl: 'https://public.example.test',
|
||||
proxyBaseUrl: 'https://proxy.example.test',
|
||||
proxyBearerToken: 'token',
|
||||
bridgeScope: TEST_BRIDGE_SCOPE,
|
||||
launchUrl: undefined,
|
||||
companionProfileKey: '/tmp/profile.remote.json',
|
||||
companionConsumerKey: 'session-a',
|
||||
|
||||
@@ -177,6 +177,11 @@ test('connect allocates a lease, prepares Metro, and writes connection state', a
|
||||
const state = readRemoteConnectionState({ stateDir, session: 'adc-android' });
|
||||
assert.equal(observedBackend, 'android-instance');
|
||||
assert.equal(observedPrepare?.companionProfileKey, remoteConfigPath);
|
||||
assert.deepEqual(observedPrepare?.bridgeScope, {
|
||||
tenantId: 'acme',
|
||||
runId: 'run-123',
|
||||
leaseId: 'lease-1',
|
||||
});
|
||||
assert.equal(state?.leaseId, 'lease-1');
|
||||
assert.equal(state?.remoteConfigHash, hashRemoteConfigFile(remoteConfigPath));
|
||||
assert.deepEqual(state?.daemon, {
|
||||
|
||||
@@ -86,7 +86,11 @@ export const connectCommand: ClientCommandHandler = async ({ flags, client }) =>
|
||||
allocatedForThisCommand = true;
|
||||
}
|
||||
|
||||
const metro = await prepareConnectedMetro(flags, client, remoteConfig.resolvedPath, session);
|
||||
const metro = await prepareConnectedMetro(flags, client, remoteConfig.resolvedPath, session, {
|
||||
tenantId: lease.tenantId,
|
||||
runId: lease.runId,
|
||||
leaseId: lease.leaseId,
|
||||
});
|
||||
metroCleanup = metro.cleanup;
|
||||
const now = new Date().toISOString();
|
||||
const state: RemoteConnectionState = {
|
||||
@@ -243,6 +247,11 @@ async function prepareConnectedMetro(
|
||||
client: AgentDeviceClient,
|
||||
remoteConfigPath: string,
|
||||
session: string,
|
||||
bridgeScope: {
|
||||
tenantId: string;
|
||||
runId: string;
|
||||
leaseId: string;
|
||||
},
|
||||
): Promise<{
|
||||
runtime?: SessionRuntimeHints;
|
||||
cleanup?: NonNullable<RemoteConnectionState['metro']>;
|
||||
@@ -265,6 +274,7 @@ async function prepareConnectedMetro(
|
||||
publicBaseUrl: flags.metroPublicBaseUrl,
|
||||
proxyBaseUrl: flags.metroProxyBaseUrl,
|
||||
bearerToken: flags.metroBearerToken,
|
||||
bridgeScope,
|
||||
launchUrl: flags.launchUrl,
|
||||
companionProfileKey: remoteConfigPath,
|
||||
companionConsumerKey: session,
|
||||
|
||||
@@ -20,6 +20,14 @@ export const metroCommand: ClientCommandHandler = async ({ positionals, flags, c
|
||||
publicBaseUrl: flags.metroPublicBaseUrl,
|
||||
proxyBaseUrl: flags.metroProxyBaseUrl,
|
||||
bearerToken: flags.metroBearerToken,
|
||||
bridgeScope:
|
||||
flags.tenant && flags.runId && flags.leaseId
|
||||
? {
|
||||
tenantId: flags.tenant,
|
||||
runId: flags.runId,
|
||||
leaseId: flags.leaseId,
|
||||
}
|
||||
: undefined,
|
||||
startupTimeoutMs: flags.metroStartupTimeoutMs,
|
||||
probeTimeoutMs: flags.metroProbeTimeoutMs,
|
||||
reuseExisting: flags.metroNoReuseExisting ? false : undefined,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { MetroBridgeScope } from './client-metro.ts';
|
||||
|
||||
export const METRO_COMPANION_RUN_ARG = '--agent-device-run-metro-companion';
|
||||
export const METRO_COMPANION_RECONNECT_DELAY_MS = 1_000;
|
||||
export const METRO_COMPANION_LEASE_CHECK_INTERVAL_MS = 250;
|
||||
@@ -8,13 +10,18 @@ export const ENV_BEARER_TOKEN = 'AGENT_DEVICE_METRO_COMPANION_BEARER_TOKEN';
|
||||
export const ENV_LOCAL_BASE_URL = 'AGENT_DEVICE_METRO_COMPANION_LOCAL_BASE_URL';
|
||||
export const ENV_LAUNCH_URL = 'AGENT_DEVICE_METRO_COMPANION_LAUNCH_URL';
|
||||
export const ENV_STATE_PATH = 'AGENT_DEVICE_METRO_COMPANION_STATE_PATH';
|
||||
export const ENV_SCOPE_TENANT_ID = 'AGENT_DEVICE_METRO_COMPANION_SCOPE_TENANT_ID';
|
||||
export const ENV_SCOPE_RUN_ID = 'AGENT_DEVICE_METRO_COMPANION_SCOPE_RUN_ID';
|
||||
export const ENV_SCOPE_LEASE_ID = 'AGENT_DEVICE_METRO_COMPANION_SCOPE_LEASE_ID';
|
||||
|
||||
export type { MetroTunnelRequestMessage as MetroCompanionRequest } from './metro.ts';
|
||||
export type { MetroBridgeScope };
|
||||
|
||||
export type CompanionOptions = {
|
||||
serverBaseUrl: string;
|
||||
bearerToken: string;
|
||||
localBaseUrl: string;
|
||||
bridgeScope: MetroBridgeScope;
|
||||
launchUrl?: string;
|
||||
statePath?: string;
|
||||
};
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
ENV_LAUNCH_URL,
|
||||
ENV_LOCAL_BASE_URL,
|
||||
ENV_SERVER_BASE_URL,
|
||||
ENV_SCOPE_LEASE_ID,
|
||||
ENV_SCOPE_RUN_ID,
|
||||
ENV_SCOPE_TENANT_ID,
|
||||
ENV_STATE_PATH,
|
||||
METRO_COMPANION_LEASE_CHECK_INTERVAL_MS,
|
||||
METRO_COMPANION_RECONNECT_DELAY_MS,
|
||||
@@ -29,6 +32,7 @@ async function registerCompanion(options: CompanionOptions): Promise<{ wsUrl: st
|
||||
method: 'POST',
|
||||
headers: createHeaders(options.serverBaseUrl, options.bearerToken),
|
||||
body: JSON.stringify({
|
||||
...options.bridgeScope,
|
||||
local_base_url: normalizeBaseUrl(options.localBaseUrl),
|
||||
...(options.launchUrl ? { launch_url: options.launchUrl } : {}),
|
||||
}),
|
||||
@@ -315,10 +319,21 @@ function readWorkerOptions(argv: string[], env: NodeJS.ProcessEnv): CompanionOpt
|
||||
if (!serverBaseUrl || !bearerToken || !localBaseUrl) {
|
||||
throw new Error('Metro companion worker is missing required environment configuration.');
|
||||
}
|
||||
const tenantId = env[ENV_SCOPE_TENANT_ID]?.trim();
|
||||
const runId = env[ENV_SCOPE_RUN_ID]?.trim();
|
||||
const leaseId = env[ENV_SCOPE_LEASE_ID]?.trim();
|
||||
if (!tenantId || !runId || !leaseId) {
|
||||
throw new Error('Metro companion worker is missing required bridge scope configuration.');
|
||||
}
|
||||
return {
|
||||
serverBaseUrl,
|
||||
bearerToken,
|
||||
localBaseUrl,
|
||||
bridgeScope: {
|
||||
tenantId,
|
||||
runId,
|
||||
leaseId,
|
||||
},
|
||||
launchUrl: env[ENV_LAUNCH_URL]?.trim() || undefined,
|
||||
statePath: env[ENV_STATE_PATH]?.trim() || undefined,
|
||||
};
|
||||
|
||||
@@ -7,9 +7,13 @@ import {
|
||||
ENV_LAUNCH_URL,
|
||||
ENV_LOCAL_BASE_URL,
|
||||
ENV_SERVER_BASE_URL,
|
||||
ENV_SCOPE_LEASE_ID,
|
||||
ENV_SCOPE_RUN_ID,
|
||||
ENV_SCOPE_TENANT_ID,
|
||||
ENV_STATE_PATH,
|
||||
METRO_COMPANION_RUN_ARG,
|
||||
} from './client-metro-companion-contract.ts';
|
||||
import type { MetroBridgeScope } from './client-metro-companion-contract.ts';
|
||||
import { normalizeBaseUrl } from './utils/url.ts';
|
||||
import { runCmdDetached } from './utils/exec.ts';
|
||||
import {
|
||||
@@ -32,6 +36,7 @@ type CompanionState = {
|
||||
serverBaseUrl: string;
|
||||
localBaseUrl: string;
|
||||
launchUrl?: string;
|
||||
bridgeScope?: MetroBridgeScope;
|
||||
tokenHash: string;
|
||||
consumers: string[];
|
||||
};
|
||||
@@ -41,6 +46,7 @@ export type EnsureMetroCompanionOptions = {
|
||||
serverBaseUrl: string;
|
||||
bearerToken: string;
|
||||
localBaseUrl: string;
|
||||
bridgeScope: MetroBridgeScope;
|
||||
launchUrl?: string;
|
||||
profileKey?: string;
|
||||
consumerKey?: string;
|
||||
@@ -68,6 +74,27 @@ function normalizeOptionalString(input: string | undefined): string | undefined
|
||||
return input?.trim() ? input.trim() : undefined;
|
||||
}
|
||||
|
||||
function readCompanionScope(input: unknown): MetroBridgeScope | undefined {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) return undefined;
|
||||
const record = input as Partial<MetroBridgeScope>;
|
||||
if (
|
||||
typeof record.tenantId !== 'string' ||
|
||||
typeof record.runId !== 'string' ||
|
||||
typeof record.leaseId !== 'string'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
tenantId: record.tenantId,
|
||||
runId: record.runId,
|
||||
leaseId: record.leaseId,
|
||||
};
|
||||
}
|
||||
|
||||
function areCompanionScopesEqual(a: MetroBridgeScope, b: MetroBridgeScope): boolean {
|
||||
return a.tenantId === b.tenantId && a.runId === b.runId && a.leaseId === b.leaseId;
|
||||
}
|
||||
|
||||
function resolveCompanionPaths(
|
||||
projectRoot: string,
|
||||
profileKey?: string,
|
||||
@@ -109,6 +136,7 @@ function readCompanionState(statePath: string): CompanionState | null {
|
||||
launchUrl: normalizeOptionalString(
|
||||
typeof parsed.launchUrl === 'string' ? parsed.launchUrl : undefined,
|
||||
),
|
||||
bridgeScope: readCompanionScope(parsed.bridgeScope),
|
||||
tokenHash: parsed.tokenHash,
|
||||
consumers,
|
||||
};
|
||||
@@ -178,10 +206,12 @@ function shouldReuseCompanion(
|
||||
}
|
||||
const command = readProcessCommand(state.pid);
|
||||
if (!command || !isMetroCompanionCommand(command)) return false;
|
||||
if (!state.bridgeScope) return false;
|
||||
return (
|
||||
state.serverBaseUrl === normalizeBaseUrl(options.serverBaseUrl) &&
|
||||
state.localBaseUrl === normalizeBaseUrl(options.localBaseUrl) &&
|
||||
state.launchUrl === normalizeOptionalString(options.launchUrl) &&
|
||||
areCompanionScopesEqual(state.bridgeScope, options.bridgeScope) &&
|
||||
state.tokenHash === hashString(options.bearerToken)
|
||||
);
|
||||
}
|
||||
@@ -250,6 +280,9 @@ function buildCompanionEnv(
|
||||
[ENV_LOCAL_BASE_URL]: normalizeBaseUrl(options.localBaseUrl),
|
||||
[ENV_STATE_PATH]: resolveCompanionPaths(options.projectRoot, options.profileKey).statePath,
|
||||
};
|
||||
nextEnv[ENV_SCOPE_TENANT_ID] = options.bridgeScope.tenantId;
|
||||
nextEnv[ENV_SCOPE_RUN_ID] = options.bridgeScope.runId;
|
||||
nextEnv[ENV_SCOPE_LEASE_ID] = options.bridgeScope.leaseId;
|
||||
if (options.launchUrl?.trim()) {
|
||||
nextEnv[ENV_LAUNCH_URL] = options.launchUrl.trim();
|
||||
} else {
|
||||
@@ -297,6 +330,7 @@ function spawnCompanionProcess(
|
||||
serverBaseUrl: normalizeBaseUrl(options.serverBaseUrl),
|
||||
localBaseUrl: normalizeBaseUrl(options.localBaseUrl),
|
||||
launchUrl: normalizeOptionalString(options.launchUrl),
|
||||
bridgeScope: options.bridgeScope,
|
||||
tokenHash: hashString(options.bearerToken),
|
||||
consumers: [],
|
||||
};
|
||||
|
||||
+30
-2
@@ -20,6 +20,12 @@ export type MetroPrepareKind = 'auto' | 'react-native' | 'expo';
|
||||
type ResolvedMetroKind = Exclude<MetroPrepareKind, 'auto'>;
|
||||
type EnvSource = NodeJS.ProcessEnv | Record<string, string | undefined>;
|
||||
|
||||
export type MetroBridgeScope = {
|
||||
tenantId: string;
|
||||
runId: string;
|
||||
leaseId: string;
|
||||
};
|
||||
|
||||
type PackageJsonShape = {
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
@@ -43,6 +49,7 @@ export type PrepareMetroRuntimeOptions = {
|
||||
publicBaseUrl?: string;
|
||||
proxyBaseUrl?: string;
|
||||
proxyBearerToken?: string;
|
||||
bridgeScope?: MetroBridgeScope;
|
||||
launchUrl?: string;
|
||||
companionProfileKey?: string;
|
||||
companionConsumerKey?: string;
|
||||
@@ -74,6 +81,7 @@ export type PrepareMetroRuntimeResult = {
|
||||
type ProxyBridgeRequestOptions = {
|
||||
baseUrl: string;
|
||||
bearerToken: string;
|
||||
scope: MetroBridgeScope;
|
||||
runtime: MetroBridgeRuntimePayload;
|
||||
timeoutMs: number;
|
||||
};
|
||||
@@ -359,6 +367,7 @@ async function configureMetroBridge(input: ProxyBridgeRequestOptions): Promise<M
|
||||
method: 'POST',
|
||||
headers: createProxyHeaders(input.baseUrl, input.bearerToken),
|
||||
body: JSON.stringify({
|
||||
...input.scope,
|
||||
ios_runtime: input.runtime,
|
||||
timeout_ms: input.timeoutMs,
|
||||
}),
|
||||
@@ -515,6 +524,16 @@ function resolveProxySettings(
|
||||
};
|
||||
}
|
||||
|
||||
function requireBridgeScope(scope: MetroBridgeScope | undefined): MetroBridgeScope {
|
||||
if (!scope?.tenantId || !scope.runId || !scope.leaseId) {
|
||||
throw new AppError(
|
||||
'INVALID_ARGS',
|
||||
'metro prepare with proxy requires tenantId, runId, and leaseId bridge scope.',
|
||||
);
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
async function waitForMetroReady(
|
||||
statusUrl: string,
|
||||
startupTimeoutMs: number,
|
||||
@@ -538,6 +557,7 @@ async function waitForMetroReady(
|
||||
async function configureMetroBridgeUntilReady(options: {
|
||||
baseUrl: string;
|
||||
bearerToken: string;
|
||||
scope: MetroBridgeScope;
|
||||
runtime: MetroBridgeRuntimePayload;
|
||||
probeTimeoutMs: number;
|
||||
startupTimeoutMs: number;
|
||||
@@ -553,6 +573,7 @@ async function configureMetroBridgeUntilReady(options: {
|
||||
const bridge = await configureMetroBridge({
|
||||
baseUrl: options.baseUrl,
|
||||
bearerToken: options.bearerToken,
|
||||
scope: options.scope,
|
||||
runtime: options.runtime,
|
||||
timeoutMs: options.probeTimeoutMs,
|
||||
});
|
||||
@@ -625,6 +646,7 @@ export async function prepareMetroRuntime(
|
||||
normalizeOptionalBaseUrl(input.proxyBaseUrl),
|
||||
normalizeOptionalString(input.proxyBearerToken) ?? '',
|
||||
);
|
||||
const bridgeScope = proxyEnabled ? requireBridgeScope(input.bridgeScope) : null;
|
||||
|
||||
const dependencyInstall = installProjectDeps
|
||||
? installDependenciesIfNeeded(projectRoot, env)
|
||||
@@ -662,28 +684,33 @@ export async function prepareMetroRuntime(
|
||||
let bridge: MetroBridgeResult | null = null;
|
||||
let initialBridgeError: string | null = null;
|
||||
|
||||
if (proxyEnabled) {
|
||||
if (bridgeScope) {
|
||||
try {
|
||||
bridge = await configureMetroBridge({
|
||||
baseUrl: proxyBaseUrl,
|
||||
bearerToken: proxyBearerToken,
|
||||
scope: bridgeScope,
|
||||
runtime: {
|
||||
metro_bundle_url: publicIosRuntime.bundleUrl,
|
||||
},
|
||||
timeoutMs: probeTimeoutMs,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isRetryableBridgeError(error)) {
|
||||
throw error;
|
||||
}
|
||||
initialBridgeError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (proxyEnabled && (!bridge || bridge.probe.reachable === false)) {
|
||||
if (bridgeScope && (!bridge || bridge.probe.reachable === false)) {
|
||||
let companionLogPath: string | undefined;
|
||||
try {
|
||||
const companion = await ensureMetroCompanion({
|
||||
projectRoot,
|
||||
serverBaseUrl: proxyBaseUrl,
|
||||
bearerToken: proxyBearerToken,
|
||||
bridgeScope,
|
||||
localBaseUrl: `http://${statusHost}:${metroPort}`,
|
||||
launchUrl: normalizeOptionalString(input.launchUrl),
|
||||
profileKey: normalizeOptionalString(input.companionProfileKey),
|
||||
@@ -705,6 +732,7 @@ export async function prepareMetroRuntime(
|
||||
bridge = await configureMetroBridgeUntilReady({
|
||||
baseUrl: proxyBaseUrl,
|
||||
bearerToken: proxyBearerToken,
|
||||
scope: bridgeScope,
|
||||
runtime: {
|
||||
metro_bundle_url: publicIosRuntime.bundleUrl,
|
||||
},
|
||||
|
||||
@@ -267,6 +267,11 @@ export type MetroPrepareOptions = {
|
||||
publicBaseUrl: string;
|
||||
proxyBaseUrl?: string;
|
||||
bearerToken?: string;
|
||||
bridgeScope?: {
|
||||
tenantId: string;
|
||||
runId: string;
|
||||
leaseId: string;
|
||||
};
|
||||
launchUrl?: string;
|
||||
companionProfileKey?: string;
|
||||
companionConsumerKey?: string;
|
||||
|
||||
@@ -264,6 +264,7 @@ export function createAgentDeviceClient(
|
||||
publicBaseUrl: options.publicBaseUrl,
|
||||
proxyBaseUrl: options.proxyBaseUrl,
|
||||
proxyBearerToken: options.bearerToken,
|
||||
bridgeScope: options.bridgeScope,
|
||||
launchUrl: options.launchUrl,
|
||||
companionProfileKey: options.companionProfileKey,
|
||||
companionConsumerKey: options.companionConsumerKey,
|
||||
|
||||
@@ -149,6 +149,11 @@ export type PrepareRemoteMetroOptions = {
|
||||
publicBaseUrl: string;
|
||||
proxyBaseUrl?: string;
|
||||
proxyBearerToken?: string;
|
||||
bridgeScope?: {
|
||||
tenantId: string;
|
||||
runId: string;
|
||||
leaseId: string;
|
||||
};
|
||||
launchUrl?: string;
|
||||
profileKey?: string;
|
||||
consumerKey?: string;
|
||||
@@ -178,6 +183,11 @@ export type EnsureMetroTunnelOptions = {
|
||||
serverBaseUrl: string;
|
||||
bearerToken: string;
|
||||
localBaseUrl: string;
|
||||
bridgeScope: {
|
||||
tenantId: string;
|
||||
runId: string;
|
||||
leaseId: string;
|
||||
};
|
||||
launchUrl?: string;
|
||||
profileKey?: string;
|
||||
consumerKey?: string;
|
||||
@@ -205,6 +215,7 @@ export async function prepareRemoteMetro(
|
||||
publicBaseUrl: options.publicBaseUrl,
|
||||
proxyBaseUrl: options.proxyBaseUrl,
|
||||
proxyBearerToken: options.proxyBearerToken,
|
||||
bridgeScope: options.bridgeScope,
|
||||
launchUrl: options.launchUrl,
|
||||
companionProfileKey: options.profileKey,
|
||||
companionConsumerKey: options.consumerKey,
|
||||
|
||||
@@ -369,6 +369,9 @@ test('connect prepares Metro and open reuses bridged runtime for remote daemon',
|
||||
capturedBridgeRequest?.body?.ios_runtime?.metro_bundle_url,
|
||||
'https://public.example.test/index.bundle?platform=ios&dev=true&minify=false',
|
||||
);
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user