mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
e832325e87
* refactor: split generic host mechanics into @agent-device/host-kit (#2082 W1) The shared src/utils closure that blocked the platform-family moves lands on declared owners: generic host mechanics form a new private @agent-device/host-kit package between kernel and capture-kit, and capture-kit keeps capture, snapshot, and recording behavior, depending on host-kit for the mechanics it needs. tar-stream and yauzl move with the archive code. Every seam's exported subpaths are pinned in package-boundaries.test.ts, the layering model ranks the new zone, R13's allow-list names it, and each seam carries an exact eager-closure row. ADR-0019's substrate amendment describes the layout. Tests that mocked two of the moved modules separately became duplicate same-seam vi.mock factories, where the second silently replaced the first; those are merged, and the mocks that production code reaches past are pinned at their injection points instead. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * refactor(host-kit): one narrow capability port per export The four technical barrels (exec/fs/values/request) grouped by category rather than by capability, so a consumer needing one mechanic evaluated unrelated ones. Each export is now a single capability over the host machine: command, process, diagnostics, retry, archive, file, request, version. A port re-exports only what a consumer of that capability uses, and every port carries its own eager-closure row. Most of the old values barrel was never host mechanics. Pure record readers, config-source values, result text, memoization, async scoping, coordinate validation, and device-scope parsing touch no process, file, or environment, so they join kernel's other primitives instead. Closures fall accordingly: capture-kit's png-worker-client from 20 to 10, png-resize from 28 to 18, session-teardown from 79 to 68, and the CLI from 386 to 380. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * chore: drop the migration inventories and trim the touched comments Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * docs: trim the touched host-kit and mutation-lane comments Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * docs: keep tool directives only in the touched files Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * docs: keep tool directives only across the touched tree Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * fix: point the Swift parity comment at the real TS twin and test The W1 move rewrote this citation to packages/contracts/src/mobile-snapshot-semantics.ts, which does not exist: the module went to capture-kit while isTapPointInsideViewport itself went to packages/contracts/src/snapshot-visibility.ts. The TS test line was left pointing at the pre-move path. Both now resolve. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * fix: repoint comment citations at the homes this refactor moved them to The W1 move left ~20 comment citations pointing at src/utils/*.ts and src/request/*.ts paths that no longer exist. Each now names the capability port that owns the symbol, which survives further file moves: exec -> host-kit/command host-process, owner-identity -> host-kit/process diagnostics -> host-kit/diagnostics atomic-file, process-lock -> host-kit/file retry -> host-kit/retry request progress/cancel -> host-kit/request version -> host-kit/version ttl-memo, source-value, parsing, device-isolation, keyed-lock, success-text -> kernel subpaths Comment-only; no closure, budget, or behavior change. ADR citations are left as written, being dated records of the decision rather than live references. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH --------- Co-authored-by: Claude <noreply@anthropic.com>
431 lines
15 KiB
TypeScript
431 lines
15 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import crypto from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import http from 'node:http';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import type { Duplex } from 'node:stream';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { closeLoopbackServer, listenOnLoopback } from '../../src/__tests__/test-utils/loopback.ts';
|
|
import { runCmd, runCmdSync } from '@agent-device/host-kit/command';
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
const SUBPROCESS_TIMEOUT_MS = 120_000;
|
|
|
|
function destroySocket(socket: Duplex | null): void {
|
|
socket?.destroy();
|
|
}
|
|
|
|
function readJson(stdout: string): any {
|
|
return JSON.parse(stdout);
|
|
}
|
|
|
|
async function execFileText(
|
|
file: string,
|
|
args: string[],
|
|
options: { cwd: string },
|
|
): Promise<string> {
|
|
const result = await runCmd(file, args, {
|
|
cwd: options.cwd,
|
|
timeoutMs: SUBPROCESS_TIMEOUT_MS,
|
|
});
|
|
return result.stdout;
|
|
}
|
|
|
|
function packInstalledPackage(tempRoot: string): string {
|
|
const packDir = path.join(tempRoot, 'pack');
|
|
fs.mkdirSync(packDir, { recursive: true });
|
|
const result = runCmdSync('npm', ['pack', '--ignore-scripts', '--pack-destination', packDir], {
|
|
cwd: repoRoot,
|
|
timeoutMs: SUBPROCESS_TIMEOUT_MS,
|
|
});
|
|
const tarballName = result.stdout.trim();
|
|
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 });
|
|
runCmdSync('tar', ['-xzf', tarballPath, '-C', nodeModulesRoot], {
|
|
cwd: consumerRoot,
|
|
timeoutMs: SUBPROCESS_TIMEOUT_MS,
|
|
});
|
|
const extractedPackageRoot = path.join(nodeModulesRoot, 'package');
|
|
const installedPackageRoot = path.join(nodeModulesRoot, 'agent-device');
|
|
fs.renameSync(extractedPackageRoot, installedPackageRoot);
|
|
return installedPackageRoot;
|
|
}
|
|
|
|
function linkRuntimeDependencies(installedPackageRoot: string, consumerRoot: string): void {
|
|
const packageJson = JSON.parse(
|
|
fs.readFileSync(path.join(installedPackageRoot, 'package.json'), 'utf8'),
|
|
) as {
|
|
dependencies?: Record<string, string>;
|
|
};
|
|
const consumerNodeModules = path.join(consumerRoot, 'node_modules');
|
|
for (const dependencyName of Object.keys(packageJson.dependencies ?? {})) {
|
|
const sourcePath = path.join(repoRoot, 'node_modules', dependencyName);
|
|
const targetPath = path.join(consumerNodeModules, dependencyName);
|
|
if (!fs.existsSync(sourcePath) || fs.existsSync(targetPath)) continue;
|
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
fs.symlinkSync(sourcePath, targetPath, 'junction');
|
|
}
|
|
}
|
|
|
|
async function runNodeModuleJson(cwd: string, args: string[], script: string): Promise<any> {
|
|
const stdout = await execFileText(process.execPath, [...args, script], {
|
|
cwd,
|
|
});
|
|
return readJson(stdout);
|
|
}
|
|
|
|
function acceptWebSocket(socket: Duplex, key: string): void {
|
|
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'),
|
|
);
|
|
}
|
|
|
|
test('installed package exposes Node APIs and packaged companion tunnel entrypoint', async (t) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-installed-package-'));
|
|
const consumerRoot = path.join(root, 'consumer');
|
|
const projectRoot = path.join(root, 'project');
|
|
const configDir = path.join(root, 'config');
|
|
fs.mkdirSync(consumerRoot, { recursive: true });
|
|
fs.mkdirSync(projectRoot, { recursive: true });
|
|
fs.mkdirSync(configDir, { recursive: true });
|
|
fs.writeFileSync(path.join(consumerRoot, 'package.json'), '{"type":"module"}\n', 'utf8');
|
|
fs.mkdirSync(path.join(projectRoot, 'node_modules'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(projectRoot, 'package.json'),
|
|
JSON.stringify({
|
|
name: 'installed-package-metro-test',
|
|
private: true,
|
|
dependencies: {
|
|
'react-native': '0.0.0-test',
|
|
},
|
|
}),
|
|
'utf8',
|
|
);
|
|
|
|
let installedPackageRoot = '';
|
|
let remoteConfigPath = '';
|
|
const metroServer = http.createServer((req, res) => {
|
|
if (req.method === 'GET' && req.url === '/status') {
|
|
res.writeHead(200, { 'content-type': 'text/plain' });
|
|
res.end('packager-status:running');
|
|
return;
|
|
}
|
|
res.writeHead(404);
|
|
res.end('not found');
|
|
});
|
|
let bridgeSocketRef: Duplex | null = null;
|
|
const bridgeToken = 'bridge-token';
|
|
let bridgeRegistered = false;
|
|
let bridgeRequestCount = 0;
|
|
let bridgePort = 0;
|
|
const bridgeServer = http.createServer(async (req, res) => {
|
|
if (req.method === 'POST' && req.url === '/api/metro/bridge') {
|
|
bridgeRequestCount += 1;
|
|
if (!bridgeRegistered) {
|
|
res.writeHead(409, { 'content-type': 'application/json' });
|
|
res.end(JSON.stringify({ ok: false, error: 'Metro companion is not connected' }));
|
|
return;
|
|
}
|
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
res.end(
|
|
JSON.stringify({
|
|
data: {
|
|
enabled: true,
|
|
base_url: `http://127.0.0.1:${bridgePort}`,
|
|
status_url: `http://127.0.0.1:${metroPort}/status`,
|
|
bundle_url: 'https://demo.metro.agent-device.dev/index.bundle?platform=ios',
|
|
ios_runtime: {
|
|
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: 'bridge.example.test',
|
|
metro_port: 443,
|
|
metro_bundle_url:
|
|
'https://bridge.example.test/api/metro/runtimes/demo/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;
|
|
}
|
|
|
|
if (req.method === 'POST' && req.url === '/api/metro/companion/register') {
|
|
assert.equal(req.headers.authorization, `Bearer ${bridgeToken}`);
|
|
bridgeRegistered = true;
|
|
req.resume();
|
|
req.on('end', () => {
|
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
res.end(
|
|
JSON.stringify({
|
|
ok: true,
|
|
data: { ws_url: `ws://127.0.0.1:${bridgePort}/bridge` },
|
|
}),
|
|
);
|
|
});
|
|
return;
|
|
}
|
|
|
|
res.writeHead(404);
|
|
res.end('not found');
|
|
});
|
|
let metroPort = 0;
|
|
try {
|
|
ensureBuiltPackage();
|
|
const tarballPath = packInstalledPackage(root);
|
|
installedPackageRoot = extractInstalledPackage(tarballPath, consumerRoot);
|
|
linkRuntimeDependencies(installedPackageRoot, consumerRoot);
|
|
assert.equal(
|
|
fs.existsSync(
|
|
path.join(installedPackageRoot, 'dist', 'src', 'internal', 'companion-tunnel.js'),
|
|
),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
fs.existsSync(path.join(installedPackageRoot, 'dist', 'src', 'companion-tunnel.js')),
|
|
false,
|
|
);
|
|
|
|
metroPort = await listenOnLoopback(metroServer);
|
|
t.after(async () => {
|
|
await closeLoopbackServer(metroServer);
|
|
});
|
|
|
|
bridgeServer.on('upgrade', (req, socket) => {
|
|
if (req.url !== '/bridge') {
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
const key = req.headers['sec-websocket-key'];
|
|
if (typeof key !== 'string') {
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
bridgeSocketRef = socket;
|
|
acceptWebSocket(socket, key);
|
|
});
|
|
bridgePort = await listenOnLoopback(bridgeServer);
|
|
t.after(async () => {
|
|
destroySocket(bridgeSocketRef);
|
|
await closeLoopbackServer(bridgeServer);
|
|
});
|
|
|
|
remoteConfigPath = path.join(configDir, 'demo.remote.json');
|
|
fs.writeFileSync(
|
|
remoteConfigPath,
|
|
JSON.stringify({
|
|
platform: 'ios',
|
|
metroProjectRoot: projectRoot,
|
|
metroProxyBaseUrl: `http://127.0.0.1:${bridgePort}`,
|
|
metroBearerToken: bridgeToken,
|
|
tenant: 'tenant-1',
|
|
runId: 'run-1',
|
|
leaseId: 'lease-1',
|
|
metroPreparePort: metroPort,
|
|
metroStatusHost: '127.0.0.1',
|
|
}),
|
|
'utf8',
|
|
);
|
|
|
|
const imports = await runNodeModuleJson(
|
|
consumerRoot,
|
|
['--input-type=module', '-e'],
|
|
`
|
|
import { readFileSync } from 'node:fs';
|
|
import nodePath from 'node:path';
|
|
import * as agentDeviceRoot from 'agent-device';
|
|
import 'agent-device/contracts';
|
|
import { createLocalArtifactAdapter as createIoArtifactAdapter } from 'agent-device/io';
|
|
import { buildBundleUrl, normalizeBaseUrl } from 'agent-device/metro';
|
|
const client = agentDeviceRoot.createAgentDeviceClient();
|
|
const exportSubpaths = Object.keys(
|
|
JSON.parse(
|
|
readFileSync(
|
|
nodePath.join(process.cwd(), 'node_modules', 'agent-device', 'package.json'),
|
|
'utf8',
|
|
),
|
|
).exports,
|
|
).sort();
|
|
const subpathSmoke = {
|
|
'.': (mod) => mod.centerOfRect({ x: 0, y: 0, width: 10, height: 4 }),
|
|
'./ai-sdk': (mod) => typeof mod.createAgentDeviceTools,
|
|
'./android-adb': (mod) => {
|
|
const manager = mod.createAndroidPortReverseManager(async () => ({
|
|
stdout: '',
|
|
stderr: '',
|
|
exitCode: 0,
|
|
}));
|
|
return typeof manager.ensure;
|
|
},
|
|
'./artifacts': async (mod) => {
|
|
const name = await mod.resolveAndroidArchivePackageName(
|
|
nodePath.join(process.cwd(), 'package.json'),
|
|
);
|
|
return String(name);
|
|
},
|
|
'./batch': async (mod) => {
|
|
const response = await mod.runBatch(
|
|
{ command: 'batch', positionals: [], flags: { batchOnError: 'continue' } },
|
|
'smoke-session',
|
|
async () => {
|
|
throw new Error('batch invoke should not run');
|
|
},
|
|
);
|
|
return response.ok === false ? response.error.code : 'unexpected-success';
|
|
},
|
|
'./contracts': (mod) => mod.centerOfRect({ x: 2, y: 2, width: 6, height: 6 }),
|
|
'./finders': (mod) => mod.findBestMatchesByLocator([], 'text', 'anything').matches.length,
|
|
'./install-source': (mod) => typeof mod.isTrustedInstallSourceUrl('https://example.test/app.apk'),
|
|
'./io': (mod) => typeof mod.createLocalArtifactAdapter({ cwd: process.cwd() }).reserveOutput,
|
|
'./limrun': async (mod) => {
|
|
const runtime = new mod.LimrunRuntime({ apiKey: 'lim_test_key' });
|
|
await runtime.shutdown();
|
|
return runtime.provider;
|
|
},
|
|
'./metro': (mod) => mod.buildBundleUrl('https://public.example.test', 'ios'),
|
|
'./remote-config': (mod) => typeof mod,
|
|
'./selectors': (mod) =>
|
|
mod.isSelectorToken('||') && typeof mod.parseSelectorChain === 'function',
|
|
};
|
|
const subpathSmokeResults = {};
|
|
for (const subpath of Object.keys(subpathSmoke).sort()) {
|
|
const mod = await import('agent-device' + subpath.slice(1));
|
|
subpathSmokeResults[subpath] = await subpathSmoke[subpath](mod);
|
|
}
|
|
const removedSubpaths = await Promise.all([
|
|
'agent-device/backend',
|
|
'agent-device/commands',
|
|
'agent-device/testing/conformance',
|
|
'agent-device/observability',
|
|
].map(async (specifier) => {
|
|
try {
|
|
await import(specifier);
|
|
return false;
|
|
} catch {
|
|
return true;
|
|
}
|
|
}));
|
|
console.log(JSON.stringify({
|
|
bundleUrl: buildBundleUrl('https://public.example.test', 'ios'),
|
|
rootExports: Object.keys(agentDeviceRoot).sort(),
|
|
rootClientSnapshot: typeof client.capture.snapshot,
|
|
rootArtifactAdapter: typeof agentDeviceRoot.createLocalArtifactAdapter({
|
|
cwd: process.cwd(),
|
|
}).reserveOutput,
|
|
ioArtifactAdapter: typeof createIoArtifactAdapter({ cwd: process.cwd() }).reserveOutput,
|
|
removedSubpathsBlocked: removedSubpaths.every(Boolean),
|
|
normalizedBaseUrl: normalizeBaseUrl('https://public.example.test///'),
|
|
protocolBundleUrl: buildBundleUrl('https://public.example.test', 'android'),
|
|
exportSubpaths,
|
|
smokedSubpaths: Object.keys(subpathSmoke).sort(),
|
|
subpathSmokeResults,
|
|
}));
|
|
`,
|
|
);
|
|
assert.equal(
|
|
imports.bundleUrl,
|
|
'https://public.example.test/index.bundle?platform=ios&dev=true&minify=false',
|
|
);
|
|
assert.deepEqual(imports.rootExports, [
|
|
'AppError',
|
|
'centerOfRect',
|
|
'createAgentDeviceClient',
|
|
'createLocalArtifactAdapter',
|
|
'isAgentDeviceError',
|
|
'normalizeAgentDeviceError',
|
|
]);
|
|
assert.equal(imports.rootClientSnapshot, 'function');
|
|
assert.equal(imports.rootArtifactAdapter, 'function');
|
|
assert.equal(imports.ioArtifactAdapter, 'function');
|
|
assert.equal(imports.removedSubpathsBlocked, true);
|
|
assert.equal(imports.normalizedBaseUrl, 'https://public.example.test');
|
|
assert.equal(
|
|
imports.protocolBundleUrl,
|
|
'https://public.example.test/index.bundle?platform=android&dev=true&minify=false',
|
|
);
|
|
// Every subpath in the packed package's exports map must have a smoke check
|
|
// above; adding a subpath without one fails here.
|
|
assert.deepEqual(imports.smokedSubpaths, imports.exportSubpaths);
|
|
assert.deepEqual(imports.subpathSmokeResults, {
|
|
'.': { x: 5, y: 2 },
|
|
'./ai-sdk': 'function',
|
|
'./android-adb': 'function',
|
|
'./artifacts': 'undefined',
|
|
'./batch': 'INVALID_ARGS',
|
|
'./contracts': { x: 5, y: 5 },
|
|
'./finders': 0,
|
|
'./install-source': 'boolean',
|
|
'./io': 'function',
|
|
'./limrun': 'limrun',
|
|
// Type-only subpath: resolving the module from the packed exports map is
|
|
// the entire runtime check.
|
|
'./remote-config': 'object',
|
|
'./metro': 'https://public.example.test/index.bundle?platform=ios&dev=true&minify=false',
|
|
'./selectors': true,
|
|
});
|
|
const cliStdout = await execFileText(
|
|
process.execPath,
|
|
[
|
|
path.join(installedPackageRoot, 'bin', 'agent-device.mjs'),
|
|
'metro',
|
|
'prepare',
|
|
'--remote-config',
|
|
remoteConfigPath,
|
|
'--json',
|
|
],
|
|
{ cwd: consumerRoot },
|
|
);
|
|
const cliResult = readJson(cliStdout);
|
|
assert.equal(cliResult.success, true);
|
|
assert.equal(cliResult.data.reused, true);
|
|
assert.equal(cliResult.data.bridge.enabled, true);
|
|
assert.equal(bridgeRegistered, true);
|
|
assert.equal(bridgeRequestCount >= 2, true);
|
|
} finally {
|
|
destroySocket(bridgeSocketRef);
|
|
await closeLoopbackServer(bridgeServer);
|
|
await closeLoopbackServer(metroServer);
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|