Skip to content

Commit e99bbb5

Browse files
authored
fix: include lease scope in Metro bridge calls (#408)
1 parent ac01393 commit e99bbb5

17 files changed

+347
-4
lines changed

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,9 @@ test('metro prepare forwards normalized options to client.metro.prepare', async
126126
metroPublicBaseUrl: 'https://sandbox.example.test',
127127
metroProxyBaseUrl: 'https://proxy.example.test',
128128
metroBearerToken: 'secret',
129+
tenant: 'tenant-1',
130+
runId: 'run-1',
131+
leaseId: 'lease-1',
129132
metroPreparePort: 9090,
130133
metroKind: 'expo',
131134
metroRuntimeFile: './.agent-device/metro-runtime.json',
@@ -143,6 +146,11 @@ test('metro prepare forwards normalized options to client.metro.prepare', async
143146
publicBaseUrl: 'https://sandbox.example.test',
144147
proxyBaseUrl: 'https://proxy.example.test',
145148
bearerToken: 'secret',
149+
bridgeScope: {
150+
tenantId: 'tenant-1',
151+
runId: 'run-1',
152+
leaseId: 'lease-1',
153+
},
146154
port: 9090,
147155
kind: 'expo',
148156
runtimeFilePath: './.agent-device/metro-runtime.json',
@@ -366,6 +374,9 @@ test('metro prepare with --remote-config loads profile defaults', async () => {
366374
metroProjectRoot: './apps/demo',
367375
metroPublicBaseUrl: 'https://sandbox.example.test',
368376
metroProxyBaseUrl: 'https://proxy.example.test',
377+
tenant: 'tenant-1',
378+
runId: 'run-1',
379+
leaseId: 'lease-1',
369380
metroPreparePort: 9090,
370381
}),
371382
);
@@ -421,6 +432,11 @@ test('metro prepare with --remote-config loads profile defaults', async () => {
421432
publicBaseUrl: 'https://sandbox.example.test',
422433
proxyBaseUrl: 'https://proxy.example.test',
423434
bearerToken: undefined,
435+
bridgeScope: {
436+
tenantId: 'tenant-1',
437+
runId: 'run-1',
438+
leaseId: 'lease-1',
439+
},
424440
port: 9090,
425441
listenHost: undefined,
426442
statusHost: undefined,

src/__tests__/client-metro-auto-companion.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ vi.mock('../client-metro-companion.ts', () => ({
1111
import { ensureMetroCompanion } from '../client-metro-companion.ts';
1212
import { prepareMetroRuntime } from '../client-metro.ts';
1313

14+
const TEST_BRIDGE_SCOPE = {
15+
tenantId: 'tenant-1',
16+
runId: 'run-1',
17+
leaseId: 'lease-1',
18+
};
19+
1420
afterEach(() => {
1521
vi.useRealTimers();
1622
vi.clearAllMocks();
@@ -96,6 +102,7 @@ test('prepareMetroRuntime starts the local companion only after bridge setup nee
96102
publicBaseUrl: 'https://public.example.test',
97103
proxyBaseUrl: 'https://proxy.example.test',
98104
proxyBearerToken: 'shared-token',
105+
bridgeScope: TEST_BRIDGE_SCOPE,
99106
metroPort: 8081,
100107
reuseExisting: true,
101108
installDependenciesIfNeeded: false,
@@ -117,6 +124,7 @@ test('prepareMetroRuntime starts the local companion only after bridge setup nee
117124
projectRoot,
118125
serverBaseUrl: 'https://proxy.example.test',
119126
bearerToken: 'shared-token',
127+
bridgeScope: TEST_BRIDGE_SCOPE,
120128
localBaseUrl: 'http://127.0.0.1:8081',
121129
launchUrl: undefined,
122130
profileKey: undefined,
@@ -126,6 +134,26 @@ test('prepareMetroRuntime starts the local companion only after bridge setup nee
126134
assert.equal(fetchMock.mock.calls.length, 3);
127135
assert.equal(fetchMock.mock.calls[1]?.[0], 'https://proxy.example.test/api/metro/bridge');
128136
assert.equal(fetchMock.mock.calls[2]?.[0], 'https://proxy.example.test/api/metro/bridge');
137+
assert.deepEqual(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body)), {
138+
tenantId: 'tenant-1',
139+
runId: 'run-1',
140+
leaseId: 'lease-1',
141+
ios_runtime: {
142+
metro_bundle_url:
143+
'https://public.example.test/index.bundle?platform=ios&dev=true&minify=false',
144+
},
145+
timeout_ms: 10000,
146+
});
147+
assert.deepEqual(JSON.parse(String(fetchMock.mock.calls[2]?.[1]?.body)), {
148+
tenantId: 'tenant-1',
149+
runId: 'run-1',
150+
leaseId: 'lease-1',
151+
ios_runtime: {
152+
metro_bundle_url:
153+
'https://public.example.test/index.bundle?platform=ios&dev=true&minify=false',
154+
},
155+
timeout_ms: 10000,
156+
});
129157
} finally {
130158
fs.rmSync(tempRoot, { recursive: true, force: true });
131159
}
@@ -165,6 +193,7 @@ test('prepareMetroRuntime preserves the initial bridge error if companion startu
165193
publicBaseUrl: 'https://public.example.test',
166194
proxyBaseUrl: 'https://proxy.example.test',
167195
proxyBearerToken: 'shared-token',
196+
bridgeScope: TEST_BRIDGE_SCOPE,
168197
metroPort: 8081,
169198
reuseExisting: true,
170199
installDependenciesIfNeeded: false,
@@ -183,6 +212,57 @@ test('prepareMetroRuntime preserves the initial bridge error if companion startu
183212
}
184213
});
185214

215+
test('prepareMetroRuntime fails fast when initial bridge failure is non-retryable', async () => {
216+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-metro-companion-401-'));
217+
const projectRoot = path.join(tempRoot, 'project');
218+
fs.mkdirSync(path.join(projectRoot, 'node_modules'), { recursive: true });
219+
fs.writeFileSync(
220+
path.join(projectRoot, 'package.json'),
221+
JSON.stringify({
222+
name: 'metro-initial-bridge-non-retryable-test',
223+
private: true,
224+
dependencies: {
225+
'react-native': '0.0.0-test',
226+
},
227+
}),
228+
);
229+
230+
const fetchMock = vi.fn();
231+
fetchMock.mockResolvedValueOnce({
232+
ok: true,
233+
status: 200,
234+
text: async () => 'packager-status:running',
235+
});
236+
fetchMock.mockResolvedValueOnce({
237+
ok: false,
238+
status: 401,
239+
text: async () => JSON.stringify({ ok: false, error: 'invalid scope' }),
240+
});
241+
vi.stubGlobal('fetch', fetchMock);
242+
243+
try {
244+
await assert.rejects(
245+
() =>
246+
prepareMetroRuntime({
247+
projectRoot,
248+
publicBaseUrl: 'https://public.example.test',
249+
proxyBaseUrl: 'https://proxy.example.test',
250+
proxyBearerToken: 'shared-token',
251+
bridgeScope: TEST_BRIDGE_SCOPE,
252+
metroPort: 8081,
253+
reuseExisting: true,
254+
installDependenciesIfNeeded: false,
255+
probeTimeoutMs: 10,
256+
}),
257+
/\/api\/metro\/bridge failed \(401\)/,
258+
);
259+
assert.equal(vi.mocked(ensureMetroCompanion).mock.calls.length, 0);
260+
assert.equal(fetchMock.mock.calls.length, 2);
261+
} finally {
262+
fs.rmSync(tempRoot, { recursive: true, force: true });
263+
}
264+
});
265+
186266
test('prepareMetroRuntime fails fast on non-retryable bridge errors after companion startup', async () => {
187267
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-metro-companion-401-'));
188268
const projectRoot = path.join(tempRoot, 'project');
@@ -231,6 +311,7 @@ test('prepareMetroRuntime fails fast on non-retryable bridge errors after compan
231311
publicBaseUrl: 'https://public.example.test',
232312
proxyBaseUrl: 'https://proxy.example.test',
233313
proxyBearerToken: 'shared-token',
314+
bridgeScope: TEST_BRIDGE_SCOPE,
234315
metroPort: 8081,
235316
reuseExisting: true,
236317
installDependenciesIfNeeded: false,
@@ -335,6 +416,7 @@ test('prepareMetroRuntime retries malformed retryable bridge responses after com
335416
publicBaseUrl: 'https://public.example.test',
336417
proxyBaseUrl: 'https://proxy.example.test',
337418
proxyBearerToken: 'shared-token',
419+
bridgeScope: TEST_BRIDGE_SCOPE,
338420
metroPort: 8081,
339421
reuseExisting: true,
340422
installDependenciesIfNeeded: false,

src/__tests__/client-metro-companion-worker.test.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ test('metro companion worker proxies websocket frames to the local upstream serv
178178
const upstreamMessage = createDeferred<string>();
179179
const bridgePong = createDeferred<void>();
180180
const bridgeSocketReady = createDeferred<NodeJS.WritableStream>();
181+
const registrationBody = createDeferred<Record<string, unknown>>();
181182
const bridgeOpen = createDeferred<void>();
182183
const bridgeFrame = createDeferred<string>();
183184
const bridgeClose = createDeferred<CloseFrame>();
@@ -233,8 +234,12 @@ test('metro companion worker proxies websocket frames to the local upstream serv
233234
const bridgeServer = http.createServer((req, res) => {
234235
const url = new URL(req.url || '/', 'http://127.0.0.1');
235236
if (req.method === 'POST' && url.pathname === '/api/metro/companion/register') {
236-
req.resume();
237+
const chunks: Buffer[] = [];
238+
req.on('data', (chunk) => {
239+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
240+
});
237241
req.on('end', () => {
242+
registrationBody.resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
238243
res.writeHead(200, { 'content-type': 'application/json' });
239244
res.end(
240245
JSON.stringify({
@@ -325,6 +330,9 @@ test('metro companion worker proxies websocket frames to the local upstream serv
325330
AGENT_DEVICE_METRO_COMPANION_SERVER_BASE_URL: `http://127.0.0.1:${bridgePort}`,
326331
AGENT_DEVICE_METRO_COMPANION_BEARER_TOKEN: 'test-token',
327332
AGENT_DEVICE_METRO_COMPANION_LOCAL_BASE_URL: `http://127.0.0.1:${upstreamPort}`,
333+
AGENT_DEVICE_METRO_COMPANION_SCOPE_TENANT_ID: 'tenant-1',
334+
AGENT_DEVICE_METRO_COMPANION_SCOPE_RUN_ID: 'run-1',
335+
AGENT_DEVICE_METRO_COMPANION_SCOPE_LEASE_ID: 'lease-1',
328336
},
329337
stdio: ['ignore', 'pipe', 'pipe'],
330338
},
@@ -349,6 +357,12 @@ test('metro companion worker proxies websocket frames to the local upstream serv
349357
waitFor(bridgeSocketReady.promise, 5_000, 'bridge websocket connection'),
350358
earlyExit,
351359
]);
360+
assert.deepEqual(await waitFor(registrationBody.promise, 5_000, 'companion registration'), {
361+
tenantId: 'tenant-1',
362+
runId: 'run-1',
363+
leaseId: 'lease-1',
364+
local_base_url: `http://127.0.0.1:${upstreamPort}`,
365+
});
352366
await Promise.race([waitFor(bridgePong.promise, 5_000, 'bridge pong'), earlyExit]);
353367
await Promise.race([waitFor(bridgeOpen.promise, 5_000, 'bridge ws-open-result'), earlyExit]);
354368
bridgeSocket.write(
@@ -467,6 +481,9 @@ test('metro companion worker reconnects after the bridge closes immediately afte
467481
AGENT_DEVICE_METRO_COMPANION_SERVER_BASE_URL: `http://127.0.0.1:${bridgePort}`,
468482
AGENT_DEVICE_METRO_COMPANION_BEARER_TOKEN: 'test-token',
469483
AGENT_DEVICE_METRO_COMPANION_LOCAL_BASE_URL: `http://127.0.0.1:${localPort}`,
484+
AGENT_DEVICE_METRO_COMPANION_SCOPE_TENANT_ID: 'tenant-1',
485+
AGENT_DEVICE_METRO_COMPANION_SCOPE_RUN_ID: 'run-1',
486+
AGENT_DEVICE_METRO_COMPANION_SCOPE_LEASE_ID: 'lease-1',
470487
},
471488
stdio: ['ignore', 'pipe', 'pipe'],
472489
},
@@ -571,6 +588,9 @@ test('metro companion worker exits after its state file is removed', async () =>
571588
AGENT_DEVICE_METRO_COMPANION_SERVER_BASE_URL: `http://127.0.0.1:${bridgePort}`,
572589
AGENT_DEVICE_METRO_COMPANION_BEARER_TOKEN: 'test-token',
573590
AGENT_DEVICE_METRO_COMPANION_LOCAL_BASE_URL: `http://127.0.0.1:${localPort}`,
591+
AGENT_DEVICE_METRO_COMPANION_SCOPE_TENANT_ID: 'tenant-1',
592+
AGENT_DEVICE_METRO_COMPANION_SCOPE_RUN_ID: 'run-1',
593+
AGENT_DEVICE_METRO_COMPANION_SCOPE_LEASE_ID: 'lease-1',
574594
AGENT_DEVICE_METRO_COMPANION_STATE_PATH: statePath,
575595
},
576596
stdio: ['ignore', 'pipe', 'pipe'],
@@ -615,6 +635,9 @@ test('metro companion worker exits immediately when its state file is already mi
615635
AGENT_DEVICE_METRO_COMPANION_SERVER_BASE_URL: 'http://127.0.0.1:1',
616636
AGENT_DEVICE_METRO_COMPANION_BEARER_TOKEN: 'test-token',
617637
AGENT_DEVICE_METRO_COMPANION_LOCAL_BASE_URL: 'http://127.0.0.1:1',
638+
AGENT_DEVICE_METRO_COMPANION_SCOPE_TENANT_ID: 'tenant-1',
639+
AGENT_DEVICE_METRO_COMPANION_SCOPE_RUN_ID: 'run-1',
640+
AGENT_DEVICE_METRO_COMPANION_SCOPE_LEASE_ID: 'lease-1',
618641
AGENT_DEVICE_METRO_COMPANION_STATE_PATH: statePath,
619642
},
620643
stdio: ['ignore', 'pipe', 'pipe'],

src/__tests__/client-metro-companion.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ import {
2424
} from '../utils/process-identity.ts';
2525
import { ensureMetroCompanion, stopMetroCompanion } from '../client-metro-companion.ts';
2626

27+
const TEST_BRIDGE_SCOPE = {
28+
tenantId: 'tenant-1',
29+
runId: 'run-1',
30+
leaseId: 'lease-1',
31+
};
32+
2733
afterEach(() => {
2834
vi.clearAllMocks();
2935
vi.restoreAllMocks();
@@ -59,6 +65,7 @@ test('companion ownership is profile-scoped and consumer-counted', async () => {
5965
serverBaseUrl: 'https://bridge.example.test',
6066
bearerToken: 'token',
6167
localBaseUrl: 'http://127.0.0.1:8081',
68+
bridgeScope: TEST_BRIDGE_SCOPE,
6269
launchUrl: 'myapp://staging',
6370
profileKey: '/tmp/staging.json',
6471
consumerKey: 'session-a',
@@ -68,6 +75,7 @@ test('companion ownership is profile-scoped and consumer-counted', async () => {
6875
serverBaseUrl: 'https://bridge.example.test',
6976
bearerToken: 'token',
7077
localBaseUrl: 'http://127.0.0.1:8081',
78+
bridgeScope: TEST_BRIDGE_SCOPE,
7179
launchUrl: 'myapp://staging',
7280
profileKey: '/tmp/staging.json',
7381
consumerKey: 'session-b',
@@ -77,6 +85,7 @@ test('companion ownership is profile-scoped and consumer-counted', async () => {
7785
serverBaseUrl: 'https://bridge.example.test',
7886
bearerToken: 'token',
7987
localBaseUrl: 'http://127.0.0.1:8081',
88+
bridgeScope: TEST_BRIDGE_SCOPE,
8089
launchUrl: 'myapp://prod',
8190
profileKey: '/tmp/prod.json',
8291
consumerKey: 'session-prod',
@@ -155,6 +164,7 @@ test('launchUrl changes force a companion respawn for the same profile', async (
155164
serverBaseUrl: 'https://bridge.example.test',
156165
bearerToken: 'token',
157166
localBaseUrl: 'http://127.0.0.1:8081',
167+
bridgeScope: TEST_BRIDGE_SCOPE,
158168
launchUrl: 'myapp://first',
159169
profileKey: '/tmp/profile.json',
160170
consumerKey: 'session-a',
@@ -164,6 +174,7 @@ test('launchUrl changes force a companion respawn for the same profile', async (
164174
serverBaseUrl: 'https://bridge.example.test',
165175
bearerToken: 'token',
166176
localBaseUrl: 'http://127.0.0.1:8081',
177+
bridgeScope: TEST_BRIDGE_SCOPE,
167178
launchUrl: 'myapp://second',
168179
profileKey: '/tmp/profile.json',
169180
consumerKey: 'session-a',
@@ -184,3 +195,54 @@ test('launchUrl changes force a companion respawn for the same profile', async (
184195
fs.rmSync(projectRoot, { recursive: true, force: true });
185196
}
186197
});
198+
199+
test('legacy state without bridge scope is stopped before respawn', async () => {
200+
const projectRoot = fs.mkdtempSync(
201+
path.join(os.tmpdir(), 'agent-device-metro-companion-legacy-'),
202+
);
203+
const statePath = path.join(projectRoot, '.agent-device', 'metro-companion.json');
204+
try {
205+
fs.mkdirSync(path.dirname(statePath), { recursive: true });
206+
fs.writeFileSync(
207+
statePath,
208+
`${JSON.stringify({
209+
pid: 555,
210+
startTime: 'start-555',
211+
command: `${process.execPath} src/metro-companion.ts --agent-device-run-metro-companion`,
212+
serverBaseUrl: 'https://bridge.example.test',
213+
localBaseUrl: 'http://127.0.0.1:8081',
214+
tokenHash: 'legacy-token-hash',
215+
consumers: ['session-a'],
216+
})}\n`,
217+
);
218+
219+
vi.mocked(runCmdDetached).mockReturnValueOnce(666);
220+
vi.mocked(isProcessAlive).mockReturnValue(true);
221+
vi.mocked(readProcessStartTime).mockReturnValue('start-555');
222+
vi.mocked(readProcessCommand).mockReturnValue(
223+
`${process.execPath} src/metro-companion.ts --agent-device-run-metro-companion`,
224+
);
225+
vi.mocked(waitForProcessExit).mockResolvedValue(true);
226+
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
227+
228+
const spawned = await ensureMetroCompanion({
229+
projectRoot,
230+
serverBaseUrl: 'https://bridge.example.test',
231+
bearerToken: 'token',
232+
localBaseUrl: 'http://127.0.0.1:8081',
233+
bridgeScope: TEST_BRIDGE_SCOPE,
234+
consumerKey: 'session-a',
235+
});
236+
237+
assert.equal(spawned.spawned, true);
238+
assert.equal(spawned.pid, 666);
239+
assert.equal(vi.mocked(runCmdDetached).mock.calls.length, 1);
240+
assert.deepEqual(killSpy.mock.calls[0], [555, 'SIGTERM']);
241+
const state = JSON.parse(fs.readFileSync(spawned.statePath, 'utf8')) as {
242+
bridgeScope?: unknown;
243+
};
244+
assert.deepEqual(state.bridgeScope, TEST_BRIDGE_SCOPE);
245+
} finally {
246+
fs.rmSync(projectRoot, { recursive: true, force: true });
247+
}
248+
});

0 commit comments

Comments
 (0)