const assert = require("node:assert"); const { spawn } = require("node:child_process"); const net = require("node:net"); const { test } = require("node:test"); const { collectStaleMnoteWebCargoPids, resolveBackendExecutable, ensurePortFree, isHttpHealthy, isPortFree, resolveRuntimePlan, shouldStartBackend, stopStaleMnoteWebCargoProcesses, } = require("./desktop-hot.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); }); } function startPythonListener(port) { const pythonBin = resolveBackendExecutable("PYTHON_BIN", "python"); const child = spawn( pythonBin, [ "-c", ` import socket import time import threading server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind(("127.0.0.1", ${port})) server.listen(16) def accept_loop(): while True: conn, _addr = server.accept() conn.close() threading.Thread(target=accept_loop, daemon=True).start() print("ready", flush=True) while True: time.sleep(1) `, ], { stdio: ["ignore", "pipe", "pipe"], }, ); return new Promise((resolve, reject) => { const timeout = setTimeout(() => { child.kill("SIGKILL"); reject(new Error("测试监听进程启动超时")); }, 3000); child.stdout.on("data", (chunk) => { if (chunk.toString("utf8").includes("ready")) { clearTimeout(timeout); resolve(child); } }); child.on("error", (error) => { clearTimeout(timeout); reject(error); }); }); } function waitForExit(child, timeoutMs = 3000) { if (child.exitCode !== null || child.signalCode !== null) { return Promise.resolve(); } return new Promise((resolve, reject) => { const timeout = setTimeout(() => { child.off("exit", onExit); reject(new Error(`进程 ${child.pid} 未在 ${timeoutMs}ms 内退出`)); }, timeoutMs); const onExit = () => { clearTimeout(timeout); resolve(); }; child.once("exit", onExit); }); } test("ensurePortFree 在非 Windows 平台能释放后端监听进程", async (t) => { if (process.platform === "win32") { t.skip("该用例只覆盖 Linux/macOS 分支"); return; } const port = await findFreePort(); const child = await startPythonListener(port); t.after(() => { if (!child.killed) { child.kill("SIGKILL"); } }); assert.equal(await isPortFree("127.0.0.1", port), false); const ok = await ensurePortFree(port, "backend"); assert.equal(ok, true); assert.equal(await isPortFree("127.0.0.1", port), true); await waitForExit(child); }); test("陈旧 mnote-web cargo 清理不会匹配当前测试进程", async () => { if (process.platform === "win32") { return; } const pids = collectStaleMnoteWebCargoPids(); assert.ok(Array.isArray(pids)); assert.equal(pids.includes(process.pid), false); await stopStaleMnoteWebCargoProcesses(); }); test("默认热启动计划只使用 mnote-web 作为 3000 owner", () => { const plan = resolveRuntimePlan({ FRONTEND_PORT: "3000", }); assert.equal(plan.skipGateway, false); assert.equal(plan.publicPort, 3000); assert.equal(plan.mnoteWebCommand, "cargo run -p mnote-web --bin mnote-web"); assert.deepEqual(plan.mnoteWebEnv, { MNOTE_WEB_BIND: "0.0.0.0:3000", MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000", MNOTE_KNOWLEDGE_PROVIDER: "lightrag_legacy", MNOTE_CONTROL_PLANE_BACKEND: "libsql-local", MNOTE_TURSO_LOCAL_PATH: "/mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db", }); assert.equal(plan.mnoteWebEnv.MNOTE_OPENCODE_BASE_URL, undefined); }); test("热启动计划支持 libSQL local 控制面后端", () => { const plan = resolveRuntimePlan({ FRONTEND_PORT: "3000", MNOTE_CONTROL_PLANE_BACKEND: "libsql-local", }); assert.equal(plan.mnoteWebEnv.MNOTE_CONTROL_PLANE_BACKEND, "libsql-local"); assert.equal( plan.mnoteWebEnv.MNOTE_TURSO_LOCAL_PATH, "/mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db", ); assert.equal(plan.mnoteWebEnv.MNOTE_CONTROL_PLANE_DB_PATH, undefined); }); test("热启动计划拒绝 SQLite runtime fallback", () => { assert.throws( () => resolveRuntimePlan({ FRONTEND_PORT: "3000", MNOTE_CONTROL_PLANE_BACKEND: "sqlite", }), /不再支持 SQLite control-plane fallback/, ); }); test("显式配置知识库 provider 时透传给 MNote host", () => { const plan = resolveRuntimePlan({ FRONTEND_PORT: "3000", MNOTE_KNOWLEDGE_PROVIDER: "weknora", }); assert.equal(plan.mnoteWebEnv.MNOTE_KNOWLEDGE_PROVIDER, "weknora"); }); test("默认跳过 FastAPI 后端,只有显式开启时才启动", () => { assert.equal(shouldStartBackend({}), false); assert.equal(shouldStartBackend({ ENABLE_BACKEND: "1" }), true); assert.equal(shouldStartBackend({ ENABLE_BACKEND: "true" }), true); assert.equal(shouldStartBackend({ BACKEND_CMD: "custom-backend" }), true); assert.equal(shouldStartBackend({ ENABLE_BACKEND: "1", SKIP_BACKEND: "1" }), false); }); test("isHttpHealthy 只把 2xx HTTP health 视为可复用服务", async () => { const server = net.createServer((socket) => { socket.once("data", () => { socket.end("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK"); }); }); await new Promise((resolve, reject) => { server.listen(0, "127.0.0.1", resolve); server.once("error", reject); }); const { port } = server.address(); try { assert.equal(await isHttpHealthy(`http://127.0.0.1:${port}/health`), true); } finally { server.close(); } });