const assert = require("node:assert"); const { spawn } = require("node:child_process"); const net = require("node:net"); const { test } = require("node:test"); const { resolveBackendExecutable, ensurePortFree, isPortFree, resolveRuntimePlan, shouldStartBackend, } = 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 作为 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", }); }); 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); });