263 lines
8.3 KiB
JavaScript
263 lines
8.3 KiB
JavaScript
const assert = require("node:assert");
|
|
const { spawn } = require("node:child_process");
|
|
const net = require("node:net");
|
|
const { test } = require("node:test");
|
|
const {
|
|
buildDefaultOpencodeCommand,
|
|
buildDefaultOpenHubCommand,
|
|
collectStaleMnoteWebCargoPids,
|
|
resolveBackendExecutable,
|
|
ensurePortFree,
|
|
isHttpHealthy,
|
|
isPortFree,
|
|
resolveOpenHubHealthPlan,
|
|
resolveRuntimePlan,
|
|
shouldStartBackend,
|
|
shouldStartOpenHub,
|
|
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_OPENCODE_BASE_URL: "http://127.0.0.1:4096",
|
|
MNOTE_OPENHUB_BASE_URL: "http://127.0.0.1:18080",
|
|
MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE: "1",
|
|
MNOTE_CONTROL_PLANE_BACKEND: "libsql-local",
|
|
MNOTE_TURSO_LOCAL_PATH: "/mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db",
|
|
});
|
|
});
|
|
|
|
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("OpenHub FastAPI 默认跳过,显式开启或命令覆盖时才启动", () => {
|
|
assert.equal(shouldStartOpenHub({}), false);
|
|
assert.equal(shouldStartOpenHub({ ENABLE_OPENHUB: "1" }), true);
|
|
assert.equal(shouldStartOpenHub({ ENABLE_OPENHUB: "true" }), true);
|
|
assert.equal(shouldStartOpenHub({ OPENHUB_CMD: "custom-openhub" }), true);
|
|
assert.equal(shouldStartOpenHub({ ENABLE_OPENHUB: "1", SKIP_OPENHUB: "1" }), false);
|
|
});
|
|
|
|
test("OpenHub health plan 包含 FastAPI、Redis、opencode 和默认关闭 Git snapshot/restore", () => {
|
|
const plan = resolveOpenHubHealthPlan({
|
|
ENABLE_OPENHUB: "1",
|
|
REQUIRE_OPENHUB_HEALTH: "1",
|
|
OPENHUB_PORT: "18081",
|
|
OPENCODE_PORT: "4097",
|
|
OPENHUB_REDIS_HEALTH_URL: "http://127.0.0.1:6379/health",
|
|
});
|
|
|
|
assert.equal(plan.enabled, true);
|
|
assert.equal(plan.openhub.url, "http://127.0.0.1:18081/api/health");
|
|
assert.equal(plan.openhub.required, true);
|
|
assert.equal(plan.redis.url, "http://127.0.0.1:6379/health");
|
|
assert.equal(plan.redis.skipped, false);
|
|
assert.equal(plan.opencode.url, "http://127.0.0.1:4097/global/health");
|
|
assert.deepEqual(plan.gitSnapshotRestore, {
|
|
env: "MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE",
|
|
value: "1",
|
|
defaultDisabled: true,
|
|
});
|
|
});
|
|
|
|
test("OpenHub 默认命令只启动 FastAPI,不包含 snapshot/restore 写操作", () => {
|
|
const command = buildDefaultOpenHubCommand(18082);
|
|
assert.match(command, /app\.main:app/);
|
|
assert.match(command, /--host "?0\.0\.0\.0"?/);
|
|
assert.match(command, /--port 18082/);
|
|
assert.match(command, /\/mnt\/Data1T\/Mnote_data\/openhub\/OpenHub\/smart-query-backend/);
|
|
assert.match(command, /MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE="1"/);
|
|
assert.doesNotMatch(command, /git\s+(snapshot|restore|revert)|kill-port/i);
|
|
});
|
|
|
|
test("opencode 默认命令使用 OpenHub 专用运行目录", () => {
|
|
const command = buildDefaultOpencodeCommand(18085);
|
|
assert.match(command, /\/mnt\/Data1T\/Mnote_data\/openhub\/opencode-runtime/);
|
|
assert.match(command, /\/mnt\/Data1T\/Mnote_data\/openhub\/opencode-home/);
|
|
assert.match(command, /HOME=/);
|
|
assert.match(command, /XDG_CONFIG_HOME=/);
|
|
assert.doesNotMatch(command, /已检测到现有 opencode/);
|
|
assert.match(command, /opencode serve --hostname=127\.0\.0\.1 --port 18085 --print-logs/);
|
|
});
|
|
|
|
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();
|
|
}
|
|
});
|