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

355 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ============================================================
// 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();