Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/__tests__/cli-client-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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,
}),
);
Expand Down Expand Up @@ -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,
Expand Down
82 changes: 82 additions & 0 deletions src/__tests__/client-metro-auto-companion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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 });
}
Expand Down Expand Up @@ -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,
Expand All @@ -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');
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
25 changes: 24 additions & 1 deletion src/__tests__/client-metro-companion-worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>();
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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'],
},
Expand All @@ -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(
Expand Down Expand Up @@ -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'],
},
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -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'],
Expand Down
62 changes: 62 additions & 0 deletions src/__tests__/client-metro-companion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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 });
}
});
Loading
Loading