test: move packaged metro smoke to integration

This commit is contained in:
Michał Pierzchała
2026-05-16 18:27:52 +02:00
parent 20c5f0f6b3
commit 8889a17cf1
5 changed files with 16 additions and 513 deletions
+2 -8
View File
@@ -61,7 +61,6 @@
"src/__tests__/client-companion-tunnel-worker.test.ts:196-207|test/integration/installed-package-metro.test.ts:13-18",
"src/__tests__/client-companion-tunnel-worker.test.ts:205-212|test/integration/device-lab/device-lab-android-lifecycle.test.ts:901-908",
"src/__tests__/client-companion-tunnel-worker.test.ts:209-220|test/integration/installed-package-metro.test.ts:18-28",
"src/__tests__/client-companion-tunnel-worker.test.ts:397-401|test/scripts/metro-prepare-packaged-smoke.mjs:89-93",
"src/__tests__/client-companion-tunnel-worker.test.ts:403-416|src/__tests__/client-companion-tunnel-worker.test.ts:622-635|src/__tests__/client-companion-tunnel-worker.test.ts:673-686",
"src/__tests__/client-companion-tunnel-worker.test.ts:512-533|src/__tests__/client-companion-tunnel-worker.test.ts:662-683",
"src/__tests__/client-companion-tunnel-worker.test.ts:512-526|src/__tests__/client-companion-tunnel-worker.test.ts:600-625|src/__tests__/client-companion-tunnel-worker.test.ts:662-676",
@@ -502,8 +501,7 @@
"src/utils/__tests__/device.test.ts:76-83|src/utils/__tests__/device.test.ts:85-94",
"src/utils/__tests__/device.test.ts:161-170|src/utils/__tests__/device.test.ts:214-223",
"src/utils/__tests__/device.test.ts:173-189|src/utils/__tests__/device.test.ts:195-210",
"src/utils/__tests__/exec.test.ts:77-90|test/scripts/metro-prepare-packaged-smoke.mjs:124-145",
"src/utils/__tests__/exec.test.ts:81-88|src/utils/exec.ts:336-344|test/scripts/metro-prepare-packaged-smoke.mjs:130-137",
"src/utils/__tests__/exec.test.ts:81-88|src/utils/exec.ts:336-344",
"src/utils/__tests__/mobile-snapshot-semantics.test.ts:338-353|src/utils/__tests__/mobile-snapshot-semantics.test.ts:86-101",
"src/utils/__tests__/mobile-snapshot-semantics.test.ts:153-172|src/utils/__tests__/mobile-snapshot-semantics.test.ts:179-197",
"src/utils/__tests__/mobile-snapshot-semantics.test.ts:284-309|src/utils/__tests__/mobile-snapshot-semantics.test.ts:320-345",
@@ -552,13 +550,9 @@
"test/integration/device-lab/device-lab-ios-lifecycle.test.ts:69-86|test/integration/device-lab/device-lab-macos-desktop.test.ts:20-37",
"test/integration/device-lab/device-lab-ios-record-trace.test.ts:217-228|test/integration/device-lab/device-lab-ios-record-trace.test.ts:69-80",
"test/integration/device-lab/loopback.ts:6-19|test/integration/smoke-daemon-http.test.ts:184-197|test/integration/smoke-open-remote-config.test.ts:86-99",
"test/integration/device-lab/loopback.ts:10-18|test/integration/smoke-daemon-http.test.ts:188-196|test/integration/smoke-open-remote-config.test.ts:90-98|test/scripts/metro-prepare-packaged-smoke.mjs:56-62",
"test/integration/installed-package-metro.test.ts:15-22|test/integration/smoke-open-remote-config.test.ts:102-109",
"test/integration/installed-package-metro.test.ts:29-36|test/integration/smoke-open-remote-config.test.ts:115-122",
"test/integration/smoke-daemon-http.test.ts:173-197|test/integration/smoke-open-remote-config.test.ts:78-99",
"test/integration/smoke-daemon-http.test.ts:208-216|test/integration/smoke-daemon-http.test.ts:292-300",
"test/integration/smoke-open-remote-config.test.ts:103-111|test/scripts/metro-prepare-packaged-smoke.mjs:66-74",
"test/integration/smoke-open-remote-config.test.ts:146-170|test/scripts/metro-prepare-packaged-smoke.mjs:200-214",
"test/scripts/metro-prepare-packaged-smoke.mjs:217-226|test/scripts/metro-prepare-packaged-smoke.mjs:245-254"
"test/integration/smoke-daemon-http.test.ts:208-216|test/integration/smoke-daemon-http.test.ts:292-300"
]
}
+3 -11
View File
@@ -941,14 +941,6 @@
"crap_moderate": {
"count": 4
}
},
"test/scripts/metro-prepare-packaged-smoke.mjs": {
"crap_critical": {
"count": 1
},
"crap_moderate": {
"count": 1
}
}
},
"runtime_coverage_findings": [],
@@ -960,27 +952,27 @@
"src/daemon/handlers/session-replay-script.ts:complexity",
"src/commands/selector-read-utils.ts:high impact",
"src/daemon/handlers/session-replay-heal.ts:complexity",
"src/daemon/context.ts:high impact",
"src/cli/commands/connection-runtime.ts:complexity",
"src/daemon/android-snapshot-freshness.ts:high impact",
"src/daemon/context.ts:high impact",
"src/daemon/config.ts:high impact",
"src/daemon/script-utils.ts:high impact",
"src/platforms/boot-diagnostics.ts:complexity",
"src/daemon/handlers/snapshot-alert.ts:complexity",
"src/daemon/handlers/session-open-target.ts:high impact",
"src/utils/success-text.ts:high impact",
"src/cli/commands/output.ts:untested risk",
"src/daemon/handlers/install-source.ts:complexity",
"src/cli/commands/output.ts:untested risk",
"src/utils/snapshot-processing.ts:high impact",
"src/utils/process-identity.ts:high impact",
"src/utils/output.ts:high impact",
"src/utils/snapshot-lines.ts:high impact",
"src/platforms/ios/xml.ts:high impact",
"src/daemon/handlers/session-state.ts:complexity",
"src/platforms/linux/snapshot.ts:high impact",
"src/daemon/handlers/session-open.ts:complexity",
"src/utils/args.ts:high impact",
"src/daemon/request-cancel.ts:high impact",
"src/platforms/linux/snapshot.ts:high impact",
"src/cli.ts:complexity",
"src/client-metro.ts:complexity",
"src/utils/selector-build.ts:high impact",
@@ -1,28 +0,0 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const SCRIPT_PATH = path.join(REPO_ROOT, 'test', 'scripts', 'metro-prepare-packaged-smoke.mjs');
test(
'packaged metro prepare auto-starts the companion and registers before bridge success',
{ timeout: 60_000 },
() => {
const result = spawnSync(process.execPath, [SCRIPT_PATH], {
cwd: REPO_ROOT,
encoding: 'utf8',
timeout: 120_000,
});
if (result.error) {
throw result.error;
}
assert.equal(
result.status,
0,
`packaged metro smoke script failed.\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
);
},
);
@@ -67,6 +67,16 @@ function packInstalledPackage(tempRoot: string): string {
return path.join(packDir, tarballName);
}
function ensureBuiltPackage(): void {
const distMetroPath = path.join(repoRoot, 'dist', 'src', 'metro.js');
if (fs.existsSync(distMetroPath)) return;
runCmdSync('pnpm', ['build'], {
cwd: repoRoot,
timeoutMs: SUBPROCESS_TIMEOUT_MS,
});
}
function extractInstalledPackage(tarballPath: string, consumerRoot: string): string {
const nodeModulesRoot = path.join(consumerRoot, 'node_modules');
fs.mkdirSync(nodeModulesRoot, { recursive: true });
@@ -119,12 +129,6 @@ function acceptWebSocket(socket: Duplex, key: string): void {
}
test('installed package exposes Node APIs and packaged companion tunnel entrypoint', async (t) => {
const distMetroPath = path.join(repoRoot, 'dist', 'src', 'metro.js');
if (!fs.existsSync(distMetroPath)) {
t.skip('run pnpm build before executing installed-package integration tests');
return;
}
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-installed-package-'));
const consumerRoot = path.join(root, 'consumer');
const projectRoot = path.join(root, 'project');
@@ -229,6 +233,7 @@ test('installed package exposes Node APIs and packaged companion tunnel entrypoi
});
let metroPort = 0;
try {
ensureBuiltPackage();
const tarballPath = packInstalledPackage(root);
installedPackageRoot = extractInstalledPackage(tarballPath, consumerRoot);
linkRuntimeDependencies(installedPackageRoot, consumerRoot);
@@ -1,460 +0,0 @@
import assert from 'node:assert/strict';
import { spawn, spawnSync } from 'node:child_process';
import crypto from 'node:crypto';
import fs from 'node:fs';
import http from 'node:http';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
function runChecked(command, args, cwd, stdio = 'pipe') {
const result = spawnSync(command, args, {
cwd,
encoding: 'utf8',
timeout: 120_000,
stdio,
});
if (result.error) {
throw result.error;
}
assert.equal(
result.status,
0,
`Expected ${command} ${args.join(' ')} to succeed.\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
);
return result.stdout;
}
function findPackedTarball(packRoot) {
const tarballName = fs.readdirSync(packRoot).find((entry) => entry.endsWith('.tgz'));
assert.ok(tarballName, 'expected pnpm pack to produce a tarball');
return path.join(packRoot, tarballName);
}
function isProcessAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function stopProcessIfAlive(pid) {
if (!isProcessAlive(pid)) return;
try {
process.kill(pid, 'SIGKILL');
} catch {
return;
}
}
async function supportsLoopbackBind() {
return await new Promise((resolve) => {
const server = net.createServer();
server.once('error', () => resolve(false));
server.listen(0, '127.0.0.1', () => {
server.close(() => resolve(true));
});
});
}
async function listen(server) {
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => resolve());
});
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Expected TCP server address');
}
return address.port;
}
function closeServer(server) {
if (!server.listening) return;
server.closeAllConnections();
server.closeIdleConnections();
try {
server.close();
} catch {
// best effort cleanup
}
}
function readJsonBody(req, callback) {
const chunks = [];
req.on('data', (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
req.on('end', () => {
try {
const body = Buffer.concat(chunks).toString('utf8');
callback(null, body ? JSON.parse(body) : {});
} catch (error) {
callback(error instanceof Error ? error : new Error(String(error)));
}
});
req.on('error', (error) => {
callback(error instanceof Error ? error : new Error(String(error)));
});
}
function acceptWebSocket(socket, key) {
const accept = crypto
.createHash('sha1')
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
.digest('base64');
socket.write(
[
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
'Connection: Upgrade',
`Sec-WebSocket-Accept: ${accept}`,
'\r\n',
].join('\r\n'),
);
}
async function runCliWithHeartbeat(command, args, cwd) {
return await new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk) => {
stdout += chunk;
});
child.stderr.on('data', (chunk) => {
stderr += chunk;
});
const timeout = setTimeout(() => {
child.kill('SIGKILL');
reject(
new Error(
`Timed out waiting for packaged metro prepare.\nstdout:\n${stdout}\nstderr:\n${stderr}`,
),
);
}, 30_000);
child.on('error', (error) => {
clearTimeout(timeout);
reject(error);
});
child.on('close', (code) => {
clearTimeout(timeout);
resolve({
status: code,
stdout,
stderr,
});
});
});
}
async function main() {
if (!(await supportsLoopbackBind())) {
return;
}
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-packaged-metro-'));
const projectRoot = path.join(root, 'project');
const configDir = path.join(root, 'config');
const packRoot = path.join(root, 'pack');
const installRoot = path.join(root, 'install');
const bridgeScope = {
tenant: 'tenant-1',
runId: 'run-1',
leaseId: 'lease-1',
};
fs.mkdirSync(projectRoot, { recursive: true });
fs.mkdirSync(configDir, { recursive: true });
fs.mkdirSync(packRoot, { recursive: true });
fs.mkdirSync(installRoot, { recursive: true });
fs.mkdirSync(path.join(projectRoot, 'node_modules'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({
name: 'packaged-metro-smoke',
version: '1.0.0',
dependencies: {
'react-native': '0.79.0',
},
}),
'utf8',
);
let companionPid = 0;
const wsSockets = new Set();
let registerCalls = 0;
let bridgeCalls = 0;
let bridgeSucceeded = false;
let registerBody;
const bridgeBodies = [];
let hostPort = 0;
const metroServer = http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/status') {
res.writeHead(200, {
'content-type': 'text/plain',
connection: 'close',
});
res.end('packager-status:running');
return;
}
res.writeHead(404);
res.end();
});
const metroPort = await listen(metroServer);
const hostServer = http.createServer((req, res) => {
if (req.method === 'POST' && req.url === '/api/metro/companion/register') {
readJsonBody(req, (error, body) => {
if (error) {
res.writeHead(500, {
'content-type': 'application/json',
connection: 'close',
});
res.end(JSON.stringify({ error: error.message }));
return;
}
registerCalls += 1;
registerBody = body;
res.writeHead(200, {
'content-type': 'application/json',
connection: 'close',
});
res.end(
JSON.stringify({
ok: true,
data: {
ws_url: `ws://127.0.0.1:${hostPort}/companion`,
},
}),
);
});
return;
}
if (req.method === 'POST' && req.url === '/api/metro/bridge') {
readJsonBody(req, (error, body) => {
if (error) {
res.writeHead(500, {
'content-type': 'application/json',
connection: 'close',
});
res.end(JSON.stringify({ error: error.message }));
return;
}
bridgeCalls += 1;
bridgeBodies.push(body);
if (registerCalls === 0) {
res.writeHead(503, {
'content-type': 'application/json',
connection: 'close',
});
res.end(JSON.stringify({ error: 'Metro companion is not connected' }));
return;
}
bridgeSucceeded = true;
res.writeHead(200, {
'content-type': 'application/json',
connection: 'close',
});
res.end(
JSON.stringify({
data: {
enabled: true,
base_url: `http://127.0.0.1:${hostPort}`,
status_url: `http://127.0.0.1:${metroPort}/status`,
bundle_url: 'https://packaged.metro.agent-device.dev/index.bundle?platform=ios',
ios_runtime: {
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: 'bridge.example.test',
metro_port: 443,
metro_bundle_url:
'https://bridge.example.test/api/metro/runtimes/packaged/index.bundle?platform=android',
},
upstream: {
bundle_url:
'https://public.example.test/index.bundle?platform=ios&dev=true&minify=false',
host: '127.0.0.1',
port: metroPort,
status_url: `http://127.0.0.1:${metroPort}/status`,
},
probe: {
reachable: true,
status_code: 200,
latency_ms: 1,
detail: 'ok',
},
},
}),
);
});
return;
}
res.writeHead(404);
res.end();
});
hostServer.on('upgrade', (req, socket) => {
if (req.url !== '/companion') {
socket.destroy();
return;
}
const key = req.headers['sec-websocket-key'];
if (typeof key !== 'string') {
socket.destroy();
return;
}
acceptWebSocket(socket, key);
wsSockets.add(socket);
socket.on('close', () => {
wsSockets.delete(socket);
});
socket.on('error', () => {
wsSockets.delete(socket);
});
});
hostPort = await listen(hostServer);
try {
const remoteConfigPath = path.join(configDir, 'remote.json');
fs.writeFileSync(
remoteConfigPath,
JSON.stringify({
metroProjectRoot: projectRoot,
metroProxyBaseUrl: `http://127.0.0.1:${hostPort}`,
metroBearerToken: 'shared-token',
...bridgeScope,
metroPreparePort: metroPort,
metroStartupTimeoutMs: 30_000,
metroProbeTimeoutMs: 1_000,
}),
'utf8',
);
runChecked('pnpm', ['build'], REPO_ROOT, 'inherit');
runChecked(
'npm',
['pack', '--ignore-scripts', '--pack-destination', packRoot],
REPO_ROOT,
'inherit',
);
const tarballPath = findPackedTarball(packRoot);
runChecked(
'npm',
['install', '--ignore-scripts', '--prefix', installRoot, tarballPath],
REPO_ROOT,
'inherit',
);
const cliPath = path.join(
installRoot,
'node_modules',
'agent-device',
'bin',
'agent-device.mjs',
);
const companionEntryPath = path.join(
installRoot,
'node_modules',
'agent-device',
'dist',
'src',
'internal',
'companion-tunnel.js',
);
const topLevelCompanionEntryPath = path.join(
installRoot,
'node_modules',
'agent-device',
'dist',
'src',
'companion-tunnel.js',
);
const updateCheckEntryPath = path.join(
installRoot,
'node_modules',
'agent-device',
'dist',
'src',
'internal',
'update-check-entry.js',
);
assert.equal(
fs.existsSync(companionEntryPath),
true,
'expected packaged companion tunnel entry',
);
assert.equal(
fs.existsSync(topLevelCompanionEntryPath),
false,
'unexpected top-level companion tunnel entry',
);
assert.equal(fs.existsSync(updateCheckEntryPath), true, 'expected packaged update-check entry');
const cliResult = await runCliWithHeartbeat(
process.execPath,
[cliPath, 'metro', 'prepare', '--remote-config', remoteConfigPath, '--json'],
projectRoot,
);
assert.equal(
cliResult.status,
0,
`packaged CLI failed.\nstdout:\n${cliResult.stdout}\nstderr:\n${cliResult.stderr}`,
);
const payload = JSON.parse(cliResult.stdout);
assert.equal(payload.success, true);
assert.equal(payload.data.bridge.enabled, true);
assert.equal(payload.data.bridge.probe.reachable, true);
const statePath = path.join(projectRoot, '.agent-device', 'metro-companion.json');
assert.equal(fs.existsSync(statePath), true, 'expected companion state file');
const companionState = JSON.parse(fs.readFileSync(statePath, 'utf8'));
companionPid = companionState.pid;
assert.ok(companionPid > 0, 'expected companion pid to be recorded');
assert.equal(isProcessAlive(companionPid), true, 'expected companion process to be alive');
assert.equal(registerCalls > 0, true, 'expected companion registration request');
assert.equal(bridgeCalls >= 2, true, 'expected bridge retry before success');
assert.equal(bridgeSucceeded, true, 'expected bridge success after registration');
assert.equal(registerBody.tenantId, bridgeScope.tenant);
assert.equal(registerBody.runId, bridgeScope.runId);
assert.equal(registerBody.leaseId, bridgeScope.leaseId);
assert.equal(registerBody.local_base_url, `http://127.0.0.1:${metroPort}`);
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, 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, undefined);
} finally {
stopProcessIfAlive(companionPid);
for (const socket of wsSockets) {
socket.destroy();
}
closeServer(hostServer);
closeServer(metroServer);
fs.rmSync(root, { recursive: true, force: true });
}
}
main().catch((error) => {
console.error(error instanceof Error ? (error.stack ?? error.message) : String(error));
process.exitCode = 1;
});