aps-agent/scripts/smoke-sidecar-local.mjs

355 lines
13 KiB
JavaScript
Raw Permalink Normal View History

// ============================================================
// Sidecar 本地冒烟(矩阵 99 · 本地可测部分)
// 覆盖:20 次冷启动 / 端口冲突 / 崩溃重启(父进程守护)/ 中文+空格路径
// 只读调用 server/sidecar.py(python -m server.sidecar),不改后端逻辑。
// 任一检查失败 → 非零退出码。
// 用法:node scripts/smoke-sidecar-local.mjs
// 可选:APS_SIDECAR_PYTHON=<python> 指定解释器(默认 .sidecar-venv → .venv → PATH)
// ============================================================
import { spawn, spawnSync } from 'node:child_process';
import crypto from 'node:crypto';
import fs from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const isWin = process.platform === 'win32';
const LOOPBACK_HOST = '127.0.0.1';
const NONCE_HEADER = 'x-aps-sidecar-nonce';
const HEALTH_URL = (port) => `http://${LOOPBACK_HOST}:${port}/api/health`;
const results = [];
const liveChildren = new Set();
// ---------------------------------------------------------------- helpers
function resolvePython() {
if (process.env.APS_SIDECAR_PYTHON) return process.env.APS_SIDECAR_PYTHON;
const candidates = [
path.join(repoRoot, '.sidecar-venv', 'Scripts', 'python.exe'),
path.join(repoRoot, '.venv', 'Scripts', 'python.exe'),
path.join(repoRoot, '.venv', 'bin', 'python3'),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate;
}
return isWin ? 'python' : 'python3';
}
function reservePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen({ host: LOOPBACK_HOST, port: 0, exclusive: true }, () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close((error) => error ? reject(error) : resolve(port));
});
});
}
function delay(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
function nonce() {
return crypto.randomBytes(32).toString('hex');
}
function probeHealth(port, expectedNonce, timeoutMs = 2_000) {
return new Promise((resolve) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
fetch(HEALTH_URL(port), {
headers: { [NONCE_HEADER]: expectedNonce },
signal: controller.signal,
})
.then(async (response) => {
const echoed = response.headers.get(NONCE_HEADER);
clearTimeout(timer);
resolve(response.status === 200 && echoed === expectedNonce);
})
.catch(() => {
clearTimeout(timer);
resolve(false);
});
});
}
async function waitForHealth(port, expectedNonce, timeoutMs = 60_000, child = null) {
const deadline = Date.now() + timeoutMs;
let last = null;
while (Date.now() < deadline) {
if (child && child.exitCode !== null) {
throw new Error(`sidecar exited early code=${child.exitCode}: ${child.stderrTail ?? ''}`);
}
last = await probeHealth(port, expectedNonce);
if (last) return true;
await delay(250);
}
throw new Error(`sidecar did not become healthy within ${timeoutMs}ms (last=${last})`);
}
function stopProcessTree(child) {
if (!child || !Number.isInteger(child.pid) || child.pid <= 0) return;
liveChildren.delete(child);
if (isWin) {
spawnSync('taskkill.exe', ['/pid', String(child.pid), '/T', '/F'], {
stdio: 'ignore',
windowsHide: true,
});
return;
}
try { child.kill('SIGKILL'); } catch { /* best effort */ }
}
async function waitExit(child, timeoutMs = 15_000) {
if (child.exitCode !== null) return child.exitCode;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('sidecar did not exit in time')), timeoutMs);
child.once('exit', (code) => {
clearTimeout(timer);
resolve(code ?? -1);
});
});
}
function sidecarEnv(overrides) {
return {
...process.env,
// 关键:显式隔离运行数据,绝不触碰仓库 server/data 或用户 ~/.aps
APS_MODE: 'desktop',
PYTHONNOUSERSITE: '1',
PYTHONUTF8: '1',
...overrides,
};
}
function spawnSidecar({ port, token, home, env = {}, cwd = repoRoot, python, capture = true }) {
const child = spawn(python, ['-m', 'server.sidecar'], {
cwd,
env: sidecarEnv({
APS_API_HOST: LOOPBACK_HOST,
APS_API_PORT: String(port),
APS_SIDECAR_NONCE: token,
APS_PARENT_PID: String(process.pid),
APS_HOME: home,
APS_DB_PATH: path.join(home, 'data', 'master.db'),
APS_KNOWLEDGE_PATH: path.join(home, 'knowledge'),
APS_APPROVAL_PATH: path.join(home, 'approval'),
APS_WORLD_PATH: path.join(home, 'data', 'world.json'),
APS_PROJECTS_PATH: path.join(home, 'sessions', 'workspace.json'),
APS_UI_DIR: '',
LLM_PROVIDER: '', // 离线:禁止任何 LLM 外呼
PYTHONPATH: repoRoot, // 支持 cwd 为任意目录(中文/空格路径用例)
...env,
}),
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
liveChildren.add(child);
child.nonce = token;
let stderrTail = '';
if (capture) {
child.stderr.on('data', (chunk) => {
stderrTail = (stderrTail + String(chunk)).slice(-2000);
child.stderrTail = stderrTail;
});
}
return child;
}
function check(name, ok, detail) {
results.push({ name, ok: Boolean(ok), detail: detail ?? '' });
console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`);
}
async function removeDir(dir) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
}
// ---------------------------------------------------------------- sections
async function sectionColdStarts(python, home) {
let passed = 0;
for (let index = 1; index <= 20; index += 1) {
const port = await reservePort();
const child = spawnSidecar({ port, token: nonce(), home, python });
try {
await waitForHealth(port, child.nonce, 60_000, child);
passed += 1;
console.log(` cold-start #${index} ok (port=${port} pid=${child.pid})`);
} catch (error) {
stopProcessTree(child);
check(`冷启动 #${index}/20`, false, String(error.message ?? error));
return;
} finally {
stopProcessTree(child);
await waitExit(child).catch(() => {});
}
}
check('20 次冷启动(进程起/停)', passed === 20, `${passed}/20 成功`);
}
async function sectionPortConflict(python, home) {
const port = await reservePort();
// 预占端口并保持监听,制造冲突
const blocker = net.createServer();
await new Promise((resolve, reject) => {
blocker.once('error', reject);
blocker.listen({ host: LOOPBACK_HOST, port, exclusive: true }, resolve);
});
const child = spawnSidecar({ port, token: nonce(), home, python });
try {
const code = await waitExit(child, 25_000);
const tail = (child.stderrTail ?? '').toLowerCase();
const explicitFailure = code !== 0
&& (tail.includes('10048') || tail.includes('address already in use') || tail.includes('eaddrinuse')
|| tail.includes('bind') || tail.includes('端口') || tail.includes('使用'));
check('端口冲突(预占后应换端口或显式失败)', explicitFailure,
`exit=${code}, stderr 尾部: ${(child.stderrTail ?? '').slice(0, 220)}`);
} catch (error) {
stopProcessTree(child);
check('端口冲突(预占后应换端口或显式失败)', false, `未在 25s 内显式失败:${error.message}`);
} finally {
blocker.close();
stopProcessTree(child);
}
}
async function sectionCrashRestart(python, home) {
// 崩溃重启:脚本扮演父进程/守护,kill 掉 sidecar 后以新端口+新 nonce 重启
const port1 = await reservePort();
const child1 = spawnSidecar({ port: port1, token: nonce(), home, python });
try {
await waitForHealth(port1, child1.nonce, 60_000, child1);
} catch (error) {
stopProcessTree(child1);
check('崩溃重启(父进程守护)', false, `首次启动失败:${error.message}`);
return;
}
const firstPid = child1.pid;
stopProcessTree(child1);
await waitExit(child1).catch(() => {});
const port2 = await reservePort();
const child2 = spawnSidecar({ port: port2, token: nonce(), home, python });
let restarted = false;
try {
await waitForHealth(port2, child2.nonce, 60_000, child2);
restarted = true;
} catch (error) {
check('崩溃重启(父进程守护)', false, `kill 后重启失败:${error.message}`);
stopProcessTree(child2);
return;
} finally {
stopProcessTree(child2);
}
check('崩溃重启(kill 子进程后父进程拉起新实例)', restarted,
`pid ${firstPid} → 新 pid ${child2.pid},端口 ${port1} → ${port2}`);
// 父进程看门狗:父进程退出后 sidecar 应自行退出(守护反向依赖)
const dummy = spawn(python, ['-c', 'import time; time.sleep(4)'], {
stdio: 'ignore',
windowsHide: true,
});
const port3 = await reservePort();
const child3 = spawnSidecar({
port: port3,
token: nonce(),
home,
python,
env: { APS_PARENT_PID: String(dummy.pid) },
});
try {
await waitForHealth(port3, child3.nonce, 60_000, child3);
const code = await waitExit(child3, 20_000);
check('父进程看门狗(父退出后 sidecar 自退出)', code === 0, `watchdog exit=${code}`);
} catch (error) {
stopProcessTree(child3);
check('父进程看门狗(父退出后 sidecar 自退出)', false, String(error.message ?? error));
} finally {
stopProcessTree(dummy);
stopProcessTree(child3);
}
}
async function sectionChinesePath(python, home) {
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'aps 排产 冒烟 测试-'));
const uiDir = path.join(base, 'ui 界面');
const dataDir = path.join(base, 'data 数据');
fs.mkdirSync(uiDir, { recursive: true });
fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(path.join(uiDir, 'index.html'), '<!doctype html><title>APS 冒烟</title><h1>中文路径 OK</h1>', 'utf8');
const port = await reservePort();
const token = nonce();
const child = spawnSidecar({
port,
token,
home,
python,
cwd: base, // 在含中文+空格的目录启动
env: {
APS_UI_DIR: uiDir,
APS_DB_PATH: path.join(dataDir, 'smoke.db'),
APS_WORLD_PATH: path.join(dataDir, 'world.json'),
APS_HOME: path.join(base, 'home'),
APS_PROJECTS_PATH: path.join(base, 'home', 'sessions', 'workspace.json'),
},
});
try {
await waitForHealth(port, token, 60_000, child);
const response = await fetch(`http://${LOOPBACK_HOST}:${port}/`, {
headers: { [NONCE_HEADER]: token },
signal: AbortSignal.timeout(8_000),
});
const body = await response.text();
const uiOk = response.status === 200
&& /text\/html/i.test(response.headers.get('content-type') ?? '')
&& /<html|<!doctype/i.test(body);
// APS_HOME 中文/空格路径被 ensure_aps_home 实际创建(config.json 落盘)
const homeOk = fs.existsSync(path.join(base, 'home', 'config.json'))
&& fs.existsSync(path.join(base, 'home', 'data'));
const noTraceback = !(child.stderrTail ?? '').includes('Traceback');
check('中文/空格路径(cwd + APS_HOME + APS_UI_DIR 全中文空格目录)',
uiOk && homeOk && noTraceback,
`ui=${uiOk} home=${homeOk} traceback=${!noTraceback} cwd=${base}(sqlite 懒创建,健康/UI 探针不触发)`);
} catch (error) {
check('中文/空格路径(cwd + APS_HOME + APS_UI_DIR 全中文空格目录)', false, String(error.message ?? error));
} finally {
stopProcessTree(child);
await delay(500);
await removeDir(base);
}
}
// ---------------------------------------------------------------- main
async function main() {
const python = resolvePython();
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'aps-sidecar-smoke-'));
const startedAt = Date.now();
console.log(`sidecar smoke: python=${python} platform=${process.platform} home=${home}`);
try {
await sectionColdStarts(python, home);
await sectionPortConflict(python, home);
await sectionCrashRestart(python, home);
await sectionChinesePath(python, home);
} finally {
for (const child of [...liveChildren]) stopProcessTree(child);
await delay(500);
await removeDir(home);
}
const failed = results.filter((r) => !r.ok);
console.log(`\nsidecar smoke summary: ${results.length - failed.length}/${results.length} passed in ${((Date.now() - startedAt) / 1000).toFixed(1)}s`);
for (const r of results) console.log(` [${r.ok ? 'PASS' : 'FAIL'}] ${r.name}`);
if (failed.length > 0) {
console.error(`sidecar smoke FAILED: ${failed.map((f) => f.name).join('; ')}`);
process.exitCode = 1;
}
}
await main();