#!/usr/bin/env node // Start mnote-web (mock Pi) on a free port and run S2–S7 acceptance smokes. // Does not touch unrelated workspace state. Prefer existing BASE if set. "use strict"; const { spawn } = require("node:child_process"); const fs = require("node:fs"); const net = require("node:net"); const os = require("node:os"); const path = require("node:path"); const { pathToFileURL } = require("node:url"); const REPO = path.resolve(__dirname, ".."); const RUST_DIR = path.join(REPO, "rust"); const BINARY = path.join(RUST_DIR, "target/debug/mnote-web"); const TIMEOUT_MS = Number(process.env.MNOTE_S2_S7_TIMEOUT_MS || 120_000); const EXTERNAL_BASE = (process.env.MNOTE_PI_LAB_BASE || process.env.MNOTE_S2_BASE || "").replace( /\/+$/, "", ); const SMOKES = [ "task-s2-skills-mcp-delete-no-ghost-smoke.js", "task-s3-directory-grants-allowed-roots-smoke.js", "task-s4-pi-abort-idempotent-smoke.js", "task-s5-rag-health-summary-filetree-smoke.js", "task-s6-pi-resume-history-hydrate-smoke.js", "task-s7-agent-target-dirty-guard-smoke.js", ]; function findFreePort() { return new Promise((resolve, reject) => { const server = net.createServer(); server.listen(0, "127.0.0.1", () => { const address = server.address(); if (!address || typeof address === "string") { server.close(() => reject(new Error("无法分配测试端口"))); return; } const port = address.port; server.close(() => resolve(port)); }); server.on("error", reject); }); } async function waitForGateway(baseUrl) { const deadline = Date.now() + TIMEOUT_MS; let lastError = null; while (Date.now() < deadline) { try { const response = await fetch(`${baseUrl}/api/gateway/health`, { redirect: "manual", cache: "no-store", }); if (response.ok && response.headers.get("x-mnote-web-owner") === "mnote-web") { return; } lastError = new Error(`gateway health: ${response.status}`); } catch (error) { lastError = error; } await new Promise((r) => setTimeout(r, 300)); } throw lastError || new Error("gateway health 未就绪"); } function startGateway(port, controlPlaneDb) { const env = { ...process.env, MNOTE_WEB_ALLOW_DEV_FIXTURES: "1", MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, MNOTE_CONTROL_PLANE_BACKEND: "libsql-local", MNOTE_TURSO_LOCAL_PATH: controlPlaneDb, MNOTE_PAGE_AI_PI_LAB: "1", MNOTE_PAGE_AI_PI_LAB_RUNTIME: "mock", MNOTE_PAGE_AI_PI_WARMUP: "0", MNOTE_PAGE_AI_PI_WARMUP_SEND: "0", RUST_LOG: process.env.RUST_LOG || "warn", }; const cmd = fs.existsSync(BINARY) ? [BINARY] : ["cargo", "run", "-q", "-p", "mnote-web", "--bin", "mnote-web"]; const child = spawn(cmd[0], cmd.slice(1), { cwd: RUST_DIR, env, stdio: ["ignore", "pipe", "pipe"], }); let stderr = ""; child.stderr.on("data", (chunk) => { stderr += chunk.toString(); if (stderr.length > 20_000) stderr = stderr.slice(-20_000); }); child.stdout.on("data", () => {}); child._s2s7Stderr = () => stderr; return child; } function runSmoke(script, base) { return new Promise((resolve, reject) => { const env = { ...process.env, MNOTE_PI_LAB_BASE: base, MNOTE_S2_BASE: base, MNOTE_S3_BASE: base, MNOTE_S4_BASE: base, MNOTE_S5_BASE: base, MNOTE_S6_BASE: base, MNOTE_S7_BASE: base, }; const child = spawn(process.execPath, [path.join(__dirname, script)], { cwd: REPO, env, stdio: ["ignore", "pipe", "pipe"], }); let out = ""; let err = ""; child.stdout.on("data", (c) => { out += c.toString(); process.stdout.write(c); }); child.stderr.on("data", (c) => { err += c.toString(); process.stderr.write(c); }); child.on("exit", (code) => { if (code === 0) resolve({ script, out }); else reject(new Error(`${script} exited ${code}\n${err || out}`)); }); }); } async function main() { let child = null; let base = EXTERNAL_BASE; const tmpDbDir = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-s2s7-cp-")); const controlPlaneDb = path.join(tmpDbDir, "control-plane-s2s7.db"); try { if (!base) { const port = await findFreePort(); base = `http://127.0.0.1:${port}`; console.log(`\n🚀 starting mnote-web mock on ${base}\n`); child = startGateway(port, controlPlaneDb); child.on("exit", (code, signal) => { if (code && code !== 0) { console.error(`mnote-web exited code=${code} signal=${signal}`); console.error(child._s2s7Stderr && child._s2s7Stderr()); } }); await waitForGateway(base); console.log(" gateway ready\n"); } else { console.log(`\n🔗 using external BASE ${base}\n`); await waitForGateway(base); } const results = []; for (const script of SMOKES) { console.log(`── ${script} ──`); await runSmoke(script, base); results.push(script); } console.log(`\n✅ S2–S7 all passed (${results.length}):\n ${results.join("\n ")}\n`); } finally { if (child && !child.killed) { child.kill("SIGTERM"); await new Promise((r) => setTimeout(r, 500)); try { child.kill("SIGKILL"); } catch { /* ignore */ } } } } main().catch((err) => { console.error("\n❌ S2–S7 runner failed:", err.message || err); process.exit(1); });