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, } = 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,Next 仅作为 legacy upstream", () => { const plan = resolveRuntimePlan({ FRONTEND_PORT: "3000", NEXT_LEGACY_PORT: "3100", }); assert.equal(plan.skipGateway, false); assert.equal(plan.publicPort, 3000); assert.equal(plan.legacyPort, 3100); assert.equal(plan.frontendTaskName, "next-legacy"); assert.equal(plan.frontendCommand, "pnpm dev -p 3100"); assert.equal(plan.mnoteWebCommand, "cargo run -p mnote-web --bin mnote-web"); assert.deepEqual(plan.mnoteWebEnv, { MNOTE_WEB_BIND: "127.0.0.1:3000", MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000", MNOTE_WEB_LEGACY_NEXT_BASE_URL: "http://127.0.0.1:3100", MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "1", }); }); test("默认任务数组顺序变化时后端命令不应覆盖 mnote-web gateway", () => { const plan = resolveRuntimePlan({ FRONTEND_PORT: "3000", NEXT_LEGACY_PORT: "3100", BACKEND_CMD: "/custom/backend", }); assert.equal(plan.mnoteWebCommand, "cargo run -p mnote-web --bin mnote-web"); assert.equal(plan.frontendCommand, "pnpm dev -p 3100"); }); test("MNOTE_WEB_SKIP_GATEWAY 可临时恢复旧 Next 3000 入口", () => { const plan = resolveRuntimePlan({ FRONTEND_PORT: "3000", NEXT_LEGACY_PORT: "3100", MNOTE_WEB_SKIP_GATEWAY: "1", }); assert.equal(plan.skipGateway, true); assert.equal(plan.frontendTaskName, "frontend"); assert.equal(plan.frontendCommand, "pnpm dev -p 3000"); assert.deepEqual(plan.mnoteWebEnv, {}); }); test("SKIP_NEXT_LEGACY 保持 Rust gateway 为 3000 owner,但不启动 Next legacy upstream", () => { const plan = resolveRuntimePlan({ FRONTEND_PORT: "3000", NEXT_LEGACY_PORT: "3100", SKIP_NEXT_LEGACY: "1", }); assert.equal(plan.skipGateway, false); assert.equal(plan.skipNextLegacy, true); assert.equal(plan.frontendTaskName, null); assert.equal(plan.mnoteWebCommand, "cargo run -p mnote-web --bin mnote-web"); assert.deepEqual(plan.mnoteWebEnv, { MNOTE_WEB_BIND: "127.0.0.1:3000", MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000", MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0", }); });