#!/usr/bin/env node "use strict"; const assert = require("node:assert"); const { spawn } = require("node:child_process"); const net = require("node:net"); const REQUEST_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_TIMEOUT_MS || 20_000); const EXTERNAL_BASE_URL = (process.env.MNOTE_UI_BASE_URL || "").replace(/\/+$/, ""); 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 fetchWithTimeout(url, init = {}) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(new Error(`请求超时: ${url}`)), REQUEST_TIMEOUT_MS); try { return await fetch(url, { redirect: "manual", cache: "no-store", ...init, signal: controller.signal, }); } finally { clearTimeout(timer); } } async function waitForGateway(baseUrl) { const deadline = Date.now() + REQUEST_TIMEOUT_MS; let lastError = null; while (Date.now() < deadline) { try { const response = await fetchWithTimeout(`${baseUrl}/api/gateway/health`); 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((resolve) => setTimeout(resolve, 250)); } throw lastError || new Error("gateway health 未就绪"); } function startGateway(port) { const env = { ...process.env, ...buildDocumentFixtureEnv(), MNOTE_WEB_BIND: `127.0.0.1:${port}`, 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", }; return spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], { cwd: "/mnt/Data1T/mnote/rust", env, stdio: ["ignore", "pipe", "pipe"], }); } function buildDocumentFixtureEnv() { return { MNOTE_WEB_ALLOW_DEV_FIXTURES: "1", MNOTE_WEB_QUERY_FIXTURES_JSON: JSON.stringify({ "documents:getMeta": { id: "doc_1", workspace_id: "ws_demo", title: "服务端页面", updated_at: "2026-04-18T09:30:00Z", can_edit: true, word_count: 42, character_count: 128, block_count: 3, }, "documents:getContent": { content: [{ id: "block_1", type: "paragraph", content: [] }], revision: 7, conflict_detection_key: "doc_1:7", pageSubtree: { rootNodeId: "doc_1", outline: [] }, }, }), }; } async function validateGateway(baseUrl) { const health = await fetchWithTimeout(`${baseUrl}/api/gateway/health`); const healthPayload = await health.json(); assert.equal(health.status, 200, `gateway health 失败: ${health.status}`); assert.equal(health.headers.get("x-mnote-web-owner"), "mnote-web"); assert.equal(healthPayload.owner, "mnote-web"); assert.equal(healthPayload.publicEntry, "127.0.0.1:3000"); const auth = await fetchWithTimeout(`${baseUrl}/auth`); const authText = await auth.text(); assert.equal(auth.status, 200, `/auth 失败: ${auth.status}`); assert.equal(auth.headers.get("x-mnote-web-owner"), "mnote-web"); assert.match(auth.headers.get("content-type") || "", /text\/html/); assert.equal(auth.headers.get("x-mnote-legacy-upstream"), null, "/auth 不应再代理 3100"); assert.match(authText, /data-mnote-shell="auth"/); assert.match(authText, /邮箱登录/); assert.match(authText, /测试账号快速登录/); assert.doesNotMatch(authText, /隐私政策|使用\s*Google|使用\s*GitHub|第三方快捷/iu); const root = await fetchWithTimeout(`${baseUrl}/`); const rootText = await root.text(); assert.equal(root.status, 303, `/ 未登录应跳转 /auth: ${root.status} ${rootText.slice(0, 120)}`); assert.equal(root.headers.get("x-mnote-web-owner"), "mnote-web"); assert.equal(root.headers.get("location"), "/auth"); const authedRoot = await fetchWithTimeout(`${baseUrl}/`, { headers: { "x-mnote-actor-id": "user_real", "x-mnote-actor-type": "user", }, }); const authedRootText = await authedRoot.text(); assert.equal(authedRoot.status, 200, `/ 已登录入口失败: ${authedRoot.status}`); assert.equal(authedRoot.headers.get("x-mnote-web-owner"), "mnote-web"); assert.match(authedRootText, /data-mnote-shell="workspace"/); } async function main() { let gateway = null; let baseUrl = EXTERNAL_BASE_URL; if (!baseUrl) { const port = await findFreePort(); baseUrl = `http://127.0.0.1:${port}`; gateway = startGateway(port); await waitForGateway(baseUrl); } try { await validateGateway(baseUrl); console.log( JSON.stringify( { ok: true, baseUrl, owner: "mnote-web", publicEntry: "3000", legacy3104: "not-required", }, null, 2, ), ); } finally { if (gateway && gateway.pid) { gateway.kill("SIGTERM"); setTimeout(() => { if (!gateway.killed) { gateway.kill("SIGKILL"); } }, 2000).unref(); } } } module.exports = { buildDocumentFixtureEnv, fetchWithTimeout, findFreePort, startGateway, validateGateway, waitForGateway, }; if (require.main === module) { main().catch((error) => { console.error(error instanceof Error ? error.stack || error.message : String(error)); process.exit(1); }); }