feat: cut over rust web main shell

This commit is contained in:
lix-2026
2026-04-29 12:24:44 +08:00
parent 7965c6c107
commit 048fe28a4d
97 changed files with 9396 additions and 1263 deletions
+207 -110
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
#!/usr/bin/env node
/**
* 同时热启动前端、FastAPI 与 Celery。
* 可使用以下环境变量调整行为:
* - FRONTEND_CMD:覆盖前端启动命令,默认为 "pnpm dev"
@@ -11,14 +11,17 @@
* - CELERY_BIN:只在 CELERY_CMD 未覆盖时,设置 Celery 可执行文件,默认 "celery"
* - REDIS_URL:仅用于探测 Redis 是否就绪,默认 "redis://localhost:6379/0"
* - SKIP_CELERY:设为 "1" or "true" 可跳过 Celery。
* - MNOTE_WEB_SKIP_GATEWAY:设为 "1" or "true" 临时恢复旧 Next 3000 入口。
* - NEXT_LEGACY_PORTRust gateway 模式下 Next legacy upstream 端口,默认 3100。
* - SKIP_NEXT_LEGACY:设为 "1" or "true" 时只启动 Rust gateway,不启动 Next legacy upstream。
*/
const { spawn, execSync } = require("child_process");
const path = require("path");
const net = require("net");
const { URL } = require("url");
const fs = require("fs");
const rootDir = path.resolve(__dirname, "..");
const frontendDir = path.join(rootDir, "wolai-frontend");
const backendDir = path.join(rootDir, "wolai-backend");
@@ -59,22 +62,85 @@ const celeryCmdFromEnv = process.env.CELERY_CMD;
const redisUrl = process.env.REDIS_URL || "redis://localhost:6379/0";
const frontendPortFromEnv = Number(process.env.FRONTEND_PORT || 3000);
const backendPortFromEnv = Number(process.env.BACKEND_PORT || 8000);
const nextLegacyPortFromEnv = Number(process.env.NEXT_LEGACY_PORT || 3100);
function isEnabledEnv(value) {
const normalized = String(value || "").toLowerCase();
return normalized === "1" || normalized === "true";
}
function resolveRuntimePlan(env = process.env) {
const frontendPort = Number(env.FRONTEND_PORT || 3000);
const nextLegacyPort = Number(env.NEXT_LEGACY_PORT || 3100);
const skipGateway = isEnabledEnv(env.MNOTE_WEB_SKIP_GATEWAY);
const skipNextLegacy = !skipGateway && isEnabledEnv(env.SKIP_NEXT_LEGACY);
const publicPort = Number.isFinite(frontendPort) ? Math.floor(frontendPort) : 3000;
const legacyPort = Number.isFinite(nextLegacyPort) ? Math.floor(nextLegacyPort) : 3100;
const legacyUrl = `http://127.0.0.1:${legacyPort}`;
const legacyCompatEnabled = skipNextLegacy ? "0" : env.MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT || "1";
return {
skipGateway,
skipNextLegacy,
publicPort,
legacyPort,
publicUrl: `http://localhost:${publicPort}`,
legacyUrl,
frontendTaskName: skipGateway ? "frontend" : skipNextLegacy ? null : "next-legacy",
frontendCommand: env.FRONTEND_CMD || `pnpm dev -p ${skipGateway ? publicPort : legacyPort}`,
mnoteWebCommand: env.MNOTE_WEB_CMD || "cargo run -p mnote-web --bin mnote-web",
mnoteWebEnv: skipGateway
? {}
: {
MNOTE_WEB_BIND: env.MNOTE_WEB_BIND || `127.0.0.1:${publicPort}`,
MNOTE_WEB_PUBLIC_BIND: env.MNOTE_WEB_PUBLIC_BIND || `127.0.0.1:${publicPort}`,
...(skipNextLegacy
? {}
: { MNOTE_WEB_LEGACY_NEXT_BASE_URL: env.MNOTE_WEB_LEGACY_NEXT_BASE_URL || legacyUrl }),
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: legacyCompatEnabled,
},
};
}
const runtimePlan = resolveRuntimePlan(process.env);
const skipMnoteWebGateway = runtimePlan.skipGateway;
const frontendTasks = runtimePlan.frontendTaskName
? [
{
name: runtimePlan.frontendTaskName,
command: runtimePlan.frontendCommand,
cwd: frontendDir,
},
]
: [];
const tasks = [
{
name: "frontend",
command: process.env.FRONTEND_CMD || "pnpm dev",
cwd: frontendDir,
},
...frontendTasks,
...(skipMnoteWebGateway
? []
: [
{
name: "mnote-web",
command:
process.env.MNOTE_WEB_CMD ||
runtimePlan.mnoteWebCommand,
cwd: path.join(rootDir, "rust"),
},
]),
{
name: "backend",
command:
process.env.BACKEND_CMD ||
`${pythonBin} -m uvicorn app.main:app --reload --port 8000`,
cwd: backendDir,
},
];
command:
process.env.BACKEND_CMD ||
`${pythonBin} -m uvicorn app.main:app --reload --port 8000`,
cwd: backendDir,
},
];
function findTask(name) {
return tasks.find((task) => task.name === name);
}
const children = [];
let shuttingDown = false;
@@ -288,16 +354,16 @@ function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return {};
const content = fs.readFileSync(filePath, "utf8");
return content
.split(/\r?\n/)
.filter((line) => line.trim() && !line.trim().startsWith("#"))
.reduce((acc, line) => {
const idx = line.indexOf("=");
if (idx === -1) return acc;
const key = line.slice(0, idx).trim();
const value = line.slice(idx + 1).trim();
acc[key] = value;
return acc;
}, {});
.split(/\r?\n/)
.filter((line) => line.trim() && !line.trim().startsWith("#"))
.reduce((acc, line) => {
const idx = line.indexOf("=");
if (idx === -1) return acc;
const key = line.slice(0, idx).trim();
const value = line.slice(idx + 1).trim();
acc[key] = value;
return acc;
}, {});
}
// 约定:全局仅使用仓库根目录的 .env.all 作为配置来源(生产优先)。
@@ -311,7 +377,7 @@ const mergedEnv = {
...process.env,
...envFromAll,
};
function logPrefix(name, message) {
console.log(`[${name}] ${message}`);
}
@@ -330,31 +396,31 @@ function buildDefaultCeleryCommand() {
function startTask(task) {
logPrefix(task.name, `启动命令:${task.command}`);
const child = spawn(task.command, {
cwd: task.cwd,
stdio: "inherit",
shell: true,
env: mergedEnv,
});
child.on("exit", (code, signal) => {
if (shuttingDown) {
return;
}
const status =
signal !== null ? `因信号 ${signal} 退出` : `退出码 ${code ?? "null"}`;
logPrefix(task.name, `进程结束(${status}),准备清理其它任务。`);
shutdown(code ?? 0);
});
child.on("error", (err) => {
logPrefix(task.name, `启动失败:${err.message}`);
shutdown(1);
});
children.push(child);
}
const child = spawn(task.command, {
cwd: task.cwd,
stdio: "inherit",
shell: true,
env: mergedEnv,
});
child.on("exit", (code, signal) => {
if (shuttingDown) {
return;
}
const status =
signal !== null ? `因信号 ${signal} 退出` : `退出码 ${code ?? "null"}`;
logPrefix(task.name, `进程结束(${status}),准备清理其它任务。`);
shutdown(code ?? 0);
});
child.on("error", (err) => {
logPrefix(task.name, `启动失败:${err.message}`);
shutdown(1);
});
children.push(child);
}
function shutdown(code) {
if (shuttingDown) {
return;
@@ -388,40 +454,40 @@ function shutdown(code) {
setTimeout(() => process.exit(code), 200);
}
process.on("SIGINT", () => shutdown(0));
process.on("SIGTERM", () => shutdown(0));
async function checkRedisReachable(urlString, timeoutMs = 2000) {
try {
const url = new URL(urlString);
const host = url.hostname || "localhost";
const port = Number(url.port) || 6379;
return await new Promise((resolve) => {
const socket = net.createConnection({ host, port });
const timer = setTimeout(() => {
socket.destroy();
resolve(false);
}, timeoutMs);
socket.once("connect", () => {
clearTimeout(timer);
socket.end();
resolve(true);
});
socket.once("error", () => {
clearTimeout(timer);
resolve(false);
});
});
} catch (error) {
logPrefix("celery", `REDIS_URL (${urlString}) 解析失败:${error.message},跳过连通性检查。`);
return true;
}
}
process.on("SIGINT", () => shutdown(0));
process.on("SIGTERM", () => shutdown(0));
async function checkRedisReachable(urlString, timeoutMs = 2000) {
try {
const url = new URL(urlString);
const host = url.hostname || "localhost";
const port = Number(url.port) || 6379;
return await new Promise((resolve) => {
const socket = net.createConnection({ host, port });
const timer = setTimeout(() => {
socket.destroy();
resolve(false);
}, timeoutMs);
socket.once("connect", () => {
clearTimeout(timer);
socket.end();
resolve(true);
});
socket.once("error", () => {
clearTimeout(timer);
resolve(false);
});
});
} catch (error) {
logPrefix("celery", `REDIS_URL (${urlString}) 解析失败:${error.message},跳过连通性检查。`);
return true;
}
}
async function main() {
// 说明:Next dev 在异常退出时可能残留 `.next/dev/lock`,会导致后续启动直接失败。
// 这里在启动前做一次“安全清理”,避免重启后仍卡住。
@@ -437,8 +503,9 @@ async function main() {
// 说明:你外网绑定了 3000 端口,这里默认强制使用 3000。
// 如果检测到 3000 被占用,则自动结束旧进程后重启,以保证始终跑在 3000。
const desiredFrontendPort = frontendPortFromEnv;
const frontendPortOk = await ensurePortFree(desiredFrontendPort, "frontend");
const desiredFrontendPort = runtimePlan.publicPort;
const frontendOwnerName = skipMnoteWebGateway ? "frontend" : "mnote-web";
const frontendPortOk = await ensurePortFree(desiredFrontendPort, frontendOwnerName);
if (!frontendPortOk) {
console.error(`前端端口 ${desiredFrontendPort} 无法释放,已中止启动。`);
process.exit(1);
@@ -446,11 +513,36 @@ async function main() {
const frontendPort = desiredFrontendPort;
const frontendUrl = `http://localhost:${frontendPort}`;
let nextLegacyPort = null;
if (!skipMnoteWebGateway) {
nextLegacyPort = runtimePlan.legacyPort;
const nextLegacyPortOk = await ensurePortFree(nextLegacyPort, "next-legacy");
if (!nextLegacyPortOk) {
console.error(`Next legacy 端口 ${nextLegacyPort} 无法释放,已中止启动。`);
process.exit(1);
}
}
// 说明:在 Windows 的 cmd.exe 下,`pnpm dev -- -p 3000` 会把 `--` 原样传给 next,导致 next 把 `-p` 误当成目录。
// 用 `pnpm dev -p 3000` 在 PowerShell/cmd.exe 下都能正确传参。
tasks[0].command = process.env.FRONTEND_CMD || `pnpm dev -p ${frontendPort}`;
logPrefix("frontend", `前端目录:${frontendDir}`);
logPrefix("frontend", `前端地址:${frontendUrl}`);
const frontendTask = skipMnoteWebGateway ? findTask("frontend") : findTask("next-legacy");
if (!frontendTask) {
throw new Error("缺少前端任务配置");
}
frontendTask.command = runtimePlan.frontendCommand;
logPrefix(frontendTask.name, `前端目录:${frontendDir}`);
if (skipMnoteWebGateway) {
logPrefix("frontend", `前端地址:${frontendUrl}`);
} else {
const legacyUrl = runtimePlan.legacyUrl;
logPrefix("mnote-web", `Rust gateway 公开入口:${frontendUrl}`);
logPrefix("next-legacy", `Next legacy upstream${legacyUrl}`);
const gatewayTask = tasks.find((task) => task.name === "mnote-web");
if (gatewayTask) {
gatewayTask.command = runtimePlan.mnoteWebCommand;
}
Object.assign(mergedEnv, runtimePlan.mnoteWebEnv);
}
const desiredBackendPort = backendPortFromEnv;
if (!process.env.BACKEND_CMD) {
@@ -459,7 +551,11 @@ async function main() {
console.error(`后端端口 ${desiredBackendPort} 无法释放,已中止启动。`);
process.exit(1);
}
tasks[1].command = `${pythonBin} -m uvicorn app.main:app --reload --port ${desiredBackendPort}`;
const backendTask = findTask("backend");
if (!backendTask) {
throw new Error("缺少后端任务配置");
}
backendTask.command = `${pythonBin} -m uvicorn app.main:app --reload --port ${desiredBackendPort}`;
}
if (!skipCelery) {
@@ -480,23 +576,23 @@ async function main() {
tasks.push(celeryTask);
} else {
// Redis 未就绪时直接跳过 Celery,避免热调试流程整体退出。
logPrefix(
"celery",
`检测到 ${redisUrl} 无法连接,自动跳过 Celery。请先启动 Redis 或设置 SKIP_CELERY=1 显式跳过。`,
);
}
}
if (tasks.length === 0) {
console.error("未配置任何可运行的任务,检查环境变量设置。");
process.exit(1);
}
for (const task of tasks) {
startTask(task);
}
}
logPrefix(
"celery",
`检测到 ${redisUrl} 无法连接,自动跳过 Celery。请先启动 Redis 或设置 SKIP_CELERY=1 显式跳过。`,
);
}
}
if (tasks.length === 0) {
console.error("未配置任何可运行的任务,检查环境变量设置。");
process.exit(1);
}
for (const task of tasks) {
startTask(task);
}
}
if (require.main === module) {
main().catch((error) => {
logPrefix("system", `启动失败:${error.message}`);
@@ -509,6 +605,7 @@ module.exports = {
getListeningPidsByPort,
getProcessNameByPid,
isPortFree,
resolveRuntimePlan,
resolveBackendExecutable,
terminatePid,
};
+69 -1
View File
@@ -2,7 +2,12 @@ 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 } = require("./desktop-hot.js");
const {
resolveBackendExecutable,
ensurePortFree,
isPortFree,
resolveRuntimePlan,
} = require("./desktop-hot.js");
function findFreePort() {
return new Promise((resolve, reject) => {
@@ -108,3 +113,66 @@ test("ensurePortFree 在非 Windows 平台能释放后端监听进程", async (t
assert.equal(await isPortFree("127.0.0.1", port), true);
await waitForExit(child);
});
test("默认热启动计划使用 mnote-web 作为 3000 ownerNext 仅作为 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",
});
});
@@ -2,12 +2,13 @@
// 说明:
// - 这份 smoke 用来覆盖 tree rust family checklist 第 9 节的通用回归要求。
// - 宿主页负责验证真实导航、上下文菜单、picker 对话框与 stream fallback
// - legacy React island 负责验证真实导航、上下文菜单、picker 对话框3000 Rust gateway 负责 API 与 stream。
// - `/api/tree/shell` 直连页已退到显式 debug/internal 边界,默认不再进入这条链路。
const { chromium } = require("playwright");
const {
BASE_URL,
DOCUMENT_UI_BASE_URL,
UI_TIMEOUT_MS,
assert,
createTempDocument,
@@ -25,6 +26,7 @@ const RUN_DIRECT_TREE_SHELL_CHECKS =
String(process.env.MNOTE_TREE_SHELL_DIRECT_SMOKE || "").trim() === "1";
const ALLOW_LEGACY_TREE_SHELL_IFRAME_HOST =
String(process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST || "").trim() === "1";
const CHROMIUM_STABLE_ARGS = ["--disable-dev-shm-usage", "--disable-gpu"];
function buildTreeShellUrl(workspaceId, params = {}) {
const search = new URLSearchParams({
@@ -302,10 +304,19 @@ async function openPageTreeContextMenuFromHost(page, driver, documentId) {
} else {
const row = driver.scope.locator(`.tree-row[data-shell-mode="page"][data-node-id="${documentId}"]`);
await row.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await row.locator('[data-testid="tree-action-menu"]').click({ timeout: UI_TIMEOUT_MS, force: true });
const menuButton = row.locator('[data-testid="tree-action-menu"]');
await menuButton.waitFor({ state: "attached", timeout: UI_TIMEOUT_MS });
await menuButton.evaluate((button) => {
if (!(button instanceof HTMLButtonElement)) {
throw new Error("页面树更多操作按钮不存在");
}
button.click();
});
}
await page.getByText("重命名", { exact: true }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.keyboard.press("Escape");
await page.evaluate(() => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
});
}
async function doubleClickFileTreeDocumentFromHost(page, driver, documentId) {
@@ -874,6 +885,11 @@ async function runFileTreeHostDoubleClickCheck(context, fixture) {
}
}
function isPlaywrightTargetCrash(error) {
const message = error instanceof Error ? error.message : String(error);
return message.includes("Target crashed");
}
async function waitForPickerEmptyState(page, dialog, pickerUsesIframe) {
if (!pickerUsesIframe) {
const emptyText = dialog.getByText("没有匹配结果");
@@ -1113,56 +1129,29 @@ async function runPickerDialogChecks(context, fixture) {
}
async function runStreamFallbackCheck(context, fixture) {
const page = await context.newPage();
let streamRequestCount = 0;
const snapshot = await requestJson(context.request, `/api/sidebar?workspaceId=${encodeURIComponent(fixture.workspaceId)}`);
const response = await context.request.fetch(
`${BASE_URL}/api/tree/events?workspaceId=${encodeURIComponent(fixture.workspaceId)}&maxPolls=0`,
{ timeout: UI_TIMEOUT_MS },
);
const body = await response.text();
assert(response.ok(), `/api/tree/events 请求失败: ${response.status()} ${body.slice(0, 200)}`);
assert(
(response.headers()["content-type"] || "").includes("text/event-stream"),
"/api/tree/events 未返回 SSE 内容类型",
);
assert(response.headers()["x-mnote-web-owner"] === "mnote-web", "/api/tree/events 缺少 mnote-web owner header");
assert(
response.headers()["x-mnote-tree-stream-owner"] === "rust-web",
"/api/tree/events 缺少 rust-web stream owner header",
);
assert(body.includes("event: snapshot") || body.includes("event:snapshot"), "/api/tree/events 未返回 snapshot event");
assert(body.includes('"kind":"snapshot"'), "/api/tree/events snapshot payload 缺少 kind=snapshot");
await page.route("**/api/mnote-web/stream**", async (route) => {
streamRequestCount += 1;
await route.fulfill({
status: 200,
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store",
},
body: buildSnapshotSseBody(snapshot, fixture.workspaceId),
});
});
try {
await openDocument(page, fixture.workspaceId, fixture.parentId);
await waitForPageTitleInput(page);
await page.waitForTimeout(600);
assert(streamRequestCount > 0, "stream fallback smoke 未命中 /api/mnote-web/stream");
const sidebarRefetchResponse = page.waitForResponse(
(response) =>
response.url().includes(`/api/sidebar?workspaceId=${encodeURIComponent(fixture.workspaceId)}`) &&
response.request().method() === "GET" &&
response.ok(),
{ timeout: UI_TIMEOUT_MS },
);
await renameThroughPageHead(page, fixture.parentId, fixture.fallbackTitle);
await sidebarRefetchResponse;
await waitForBreadcrumbTitle(page, fixture.fallbackTitle);
await page.waitForFunction(
(expectedTitle) => {
const input = document.querySelector('input[aria-label="页面标题"]');
return input instanceof HTMLInputElement && input.value.includes(expectedTitle);
},
fixture.fallbackTitle,
{ timeout: UI_TIMEOUT_MS },
);
return {
streamFallback: true,
streamRequestCount,
fallbackTitle: fixture.fallbackTitle,
};
} finally {
await page.close().catch(() => undefined);
}
return {
streamFallback: false,
rustTreeEvents: true,
streamRequestCount: 1,
};
}
async function main() {
@@ -1172,11 +1161,12 @@ async function main() {
: process.env.MNOTE_SMOKE_HEADLESS === "0"
? false
: !process.env.DISPLAY;
const browser = await chromium.launch({ headless });
const context = await browser.newContext({
const browser = await chromium.launch({ headless, args: CHROMIUM_STABLE_ARGS });
const browserContextOptions = {
viewport: { width: 1440, height: 960 },
});
const page = await context.newPage();
};
let context = await browser.newContext(browserContextOptions);
let page = await context.newPage();
let fixture = null;
let caughtError = null;
@@ -1186,13 +1176,35 @@ async function main() {
fixture = await prepareFixture(context.request);
const pageTreeHost = await runPageTreeHostChecks(page, fixture);
const storageState = await context.storageState();
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
context = await browser.newContext({
...browserContextOptions,
storageState,
});
page = null;
const pageTreeShell = RUN_DIRECT_TREE_SHELL_CHECKS
? await runPageTreeShellChecks(context, fixture)
: { skipped: true, reason: "direct_tree_shell_debug_disabled" };
const fileTreeShell = RUN_DIRECT_TREE_SHELL_CHECKS
? await runFileTreeShellChecks(context, fixture)
: { skipped: true, reason: "direct_tree_shell_debug_disabled" };
const fileTreeHost = await runFileTreeHostDoubleClickCheck(context, fixture);
let fileTreeHost;
try {
fileTreeHost = await runFileTreeHostDoubleClickCheck(context, fixture);
} catch (error) {
if (!isPlaywrightTargetCrash(error)) {
throw error;
}
await context.close().catch(() => undefined);
context = await browser.newContext({
...browserContextOptions,
storageState,
});
fileTreeHost = await runFileTreeHostDoubleClickCheck(context, fixture);
fileTreeHost.recoveredFromRendererCrash = true;
}
const picker = await runPickerDialogChecks(context, fixture);
const streamFallback = await runStreamFallbackCheck(context, fixture);
@@ -1201,6 +1213,7 @@ async function main() {
{
ok: true,
baseUrl: BASE_URL,
documentBaseUrl: DOCUMENT_UI_BASE_URL,
viewerUserId: viewer.userId,
workspaceId: fixture.workspaceId,
parentId: fixture.parentId,
@@ -1241,7 +1254,9 @@ async function main() {
}
}
await page.close().catch(() => undefined);
if (page) {
await page.close().catch(() => undefined);
}
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
@@ -0,0 +1,179 @@
#!/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/);
const legacyUpstream = auth.headers.get("x-mnote-legacy-upstream");
if (legacyUpstream === "next-app-router") {
assert.match(authText, /Wolai Clone|测试账号快速登录|加载中/);
} else {
assert.match(authText, /data-mnote-web-owner="mnote-web"/);
}
const root = await fetchWithTimeout(`${baseUrl}/`);
const rootText = await root.text();
assert.equal(root.status, 200, `/ 失败: ${root.status}`);
assert.equal(root.headers.get("x-mnote-web-owner"), "mnote-web");
assert.match(rootText, /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);
});
}
@@ -0,0 +1,168 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const {
fetchWithTimeout,
findFreePort,
startGateway,
waitForGateway,
} = require("./task114-rust-web-gateway-entry-smoke.js");
const EXTERNAL_BASE_URL = (process.env.MNOTE_UI_BASE_URL || "").replace(/\/+$/, "");
const CONFIGURED_DOCUMENT_ID = (process.env.MNOTE_DOCUMENT_ID || "").trim();
const CONFIGURED_WORKSPACE_ID = (process.env.MNOTE_WORKSPACE_ID || "").trim();
const CONFIGURED_TITLE = (process.env.MNOTE_DOCUMENT_TITLE || "").trim();
async function readJsonResponse(response, label) {
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
throw new Error(`${label} 返回了非 JSON 内容: ${text.slice(0, 1200)}`);
}
assert(response.ok, `${label} 请求失败: ${response.status}; body: ${text.slice(0, 1200)}`);
return payload;
}
async function createTempDocument(baseUrl) {
const title = `task115-rust-web-${Date.now()}`;
const requestBody = {
action: "create",
title,
};
if (CONFIGURED_WORKSPACE_ID) {
requestBody.workspaceId = CONFIGURED_WORKSPACE_ID;
}
const response = await fetchWithTimeout(`${baseUrl}/api/tree/commands`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(requestBody),
});
const payload = await readJsonResponse(response, "创建临时文档");
const result = payload && typeof payload.result === "object" ? payload.result : null;
const documentId = result && typeof result.documentId === "string" ? result.documentId : "";
const workspaceId = result && typeof result.workspaceId === "string" ? result.workspaceId : "";
assert(documentId, "创建临时文档缺少 documentId");
assert(workspaceId, "创建临时文档缺少 workspaceId");
return { documentId, workspaceId, title };
}
async function purgeTempDocument(baseUrl, target) {
const response = await fetchWithTimeout(`${baseUrl}/api/tree/commands`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
action: "purge",
workspaceId: target.workspaceId,
documentId: target.documentId,
}),
});
await readJsonResponse(response, "清理临时文档");
}
async function validateDocumentShell(baseUrl, target) {
const targetPath = `/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`;
const response = await fetchWithTimeout(`${baseUrl}${targetPath}`);
const html = await response.text();
assert.equal(response.status, 200, `文档 shell 请求失败: ${response.status}; body: ${html.slice(0, 1200)}`);
assert.equal(response.headers.get("x-mnote-web-owner"), "mnote-web");
assert.equal(response.headers.get("x-mnote-web-shell"), "document");
assert.match(response.headers.get("content-type") || "", /text\/html/);
assert.match(html, /data-mnote-shell="document"/);
assert.match(html, /data-testid="wolai-sidebar"/);
assert.match(html, /data-testid="wolai-topbar"/);
assert.match(html, /data-testid="wolai-floating-ai"/);
assert.match(html, /data-page-aggregate-snapshot="mnote\.page_aggregate\.v1"/);
assert.match(html, /id="__MNOTE_PAGE_AGGREGATE__"/);
assert.match(html, /data-editor-host="leptos_tiptap_island"/);
assert.doesNotMatch(html, /mnote-web-document-shell/);
assert.doesNotMatch(html, /convex_upstream_error|未登录/);
const aggregate = await fetchWithTimeout(
`${baseUrl}/api/page-aggregate/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`,
);
const aggregateText = await aggregate.text();
assert.equal(aggregate.status, 200, `page aggregate 请求失败: ${aggregate.status}; body: ${aggregateText.slice(0, 1200)}`);
const payload = JSON.parse(aggregateText);
assert.equal(aggregate.headers.get("x-mnote-web-owner"), "mnote-web");
assert.equal(payload.owner, "mnote-web");
assert.equal(payload.schema, "mnote.page_aggregate.v1");
assert.equal(payload.result.identity.documentId, target.documentId);
assert.equal(payload.result.identity.workspaceId, target.workspaceId);
assert.equal(payload.result.schema, "mnote.page_aggregate.v1");
assert.equal(typeof payload.result.head.title, "string");
assert(payload.result.head.title.trim(), "Page Aggregate 标题不能为空");
if (target.expectedTitle) {
assert.equal(payload.result.head.title, target.expectedTitle);
}
}
async function main() {
let gateway = null;
let baseUrl = EXTERNAL_BASE_URL;
let createdTarget = null;
if (!baseUrl) {
const port = await findFreePort();
baseUrl = `http://127.0.0.1:${port}`;
gateway = startGateway(port);
await waitForGateway(baseUrl);
}
try {
const target = CONFIGURED_DOCUMENT_ID
? {
documentId: CONFIGURED_DOCUMENT_ID,
workspaceId: CONFIGURED_WORKSPACE_ID || "ws_demo",
expectedTitle: CONFIGURED_TITLE || null,
}
: gateway
? {
documentId: "doc_1",
workspaceId: "ws_demo",
expectedTitle: CONFIGURED_TITLE || "服务端页面",
}
: await createTempDocument(baseUrl);
if (!CONFIGURED_DOCUMENT_ID && !gateway) {
createdTarget = target;
target.expectedTitle = target.title;
}
await validateDocumentShell(baseUrl, target);
console.log(
JSON.stringify(
{
ok: true,
baseUrl,
owner: "mnote-web",
shell: "document",
editorHost: "leptos_tiptap_island",
documentId: target.documentId,
workspaceId: target.workspaceId,
},
null,
2,
),
);
} finally {
if (createdTarget) {
await purgeTempDocument(baseUrl, createdTarget);
}
if (gateway && gateway.pid) {
gateway.kill("SIGTERM");
setTimeout(() => {
if (!gateway.killed) {
gateway.kill("SIGKILL");
}
}, 2000).unref();
}
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
+125
View File
@@ -0,0 +1,125 @@
"use strict";
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const REQUEST_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_TIMEOUT_MS || 20_000);
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
async function fetchWithTimeout(path, init = {}) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
return await fetch(`${BASE_URL}${path}`, {
...init,
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
}
async function readTextResponse(path, init) {
const response = await fetchWithTimeout(path, init);
const text = await response.text();
assert(response.ok, `${path} 请求失败: ${response.status} ${text.slice(0, 200)}`);
return { response, text };
}
function runtimeInputToolPlan() {
return {
kind: "tool",
context: {
deploymentId: null,
projectId: null,
workspaceId: "ws_task116",
requestId: "req_task116",
traceId: "trace_task116",
actor: {
actorType: "user",
actorId: "task116-user",
sessionId: null,
},
source: {
channel: "rust-web",
client: "task116-smoke",
},
tenantId: null,
authToken: null,
idempotencyKey: null,
validateOnly: false,
dryRun: false,
},
tool: {
tool: "docs_search",
kind: "query",
mode: "plan",
argsJson: { query: "Rust Web islands" },
target: null,
reason: "task116 owner smoke",
refs: ["task-025"],
},
data: null,
};
}
async function checkSearchShell() {
const { response, text } = await readTextResponse("/search?workspaceId=ws_task116&q=Rust");
assert(response.headers.get("x-mnote-web-owner") === "mnote-web", "Search shell 缺少 mnote-web owner header");
assert(response.headers.get("x-mnote-web-shell") === "search", "Search shell 缺少 search shell header");
assert(text.includes("mnote.search_shell.v1"), "Search shell 缺少 contract schema");
assert(text.includes("react_search_palette"), "Search shell 缺少 search palette island");
return { owner: "mnote-web", shell: "search", island: "react_search_palette" };
}
async function checkAiBridge() {
const { response, text } = await readTextResponse("/api/hermes/bridge", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(runtimeInputToolPlan()),
});
assert(response.headers.get("x-mnote-web-owner") === "mnote-web", "AI bridge 缺少 mnote-web owner header");
assert(response.headers.get("x-mnote-ai-bridge-owner") === "rust-web-hermes", "AI bridge 缺少 rust-web-hermes owner header");
const payload = JSON.parse(text);
assert(payload?.contract?.schema === "mnote.ai_bridge.v1", "AI bridge 缺少 contract schema");
assert(payload?.contract?.toolEventOwner === "rust-web-hermes", "AI bridge tool event owner 未固定到 Hermes");
return { owner: "mnote-web", bridgeOwner: "rust-web-hermes", schema: payload.contract.schema };
}
async function checkMindmapShell() {
const { response, text } = await readTextResponse("/mindmap/doc_task116/mind_task116");
assert(response.headers.get("x-mnote-web-owner") === "mnote-web", "Mindmap shell 缺少 mnote-web owner header");
assert(response.headers.get("x-mnote-web-shell") === "mindmap", "Mindmap shell 缺少 mindmap shell header");
assert(text.includes("mnote.mindmap_shell.v1"), "Mindmap shell 缺少 contract schema");
assert(text.includes('data-react-island="mindmap_runtime"'), "Mindmap shell 缺少 mindmap runtime island");
return { owner: "mnote-web", shell: "mindmap", island: "mindmap_runtime" };
}
async function main() {
const search = await checkSearchShell();
const aiBridge = await checkAiBridge();
const mindmap = await checkMindmapShell();
console.log(
JSON.stringify(
{
ok: true,
baseUrl: BASE_URL,
results: {
search,
aiBridge,
mindmap,
},
},
null,
2,
),
);
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const { spawn } = require("node:child_process");
const {
fetchWithTimeout,
findFreePort,
waitForGateway,
} = require("./task114-rust-web-gateway-entry-smoke.js");
const { resolveRuntimePlan } = require("./desktop-hot.js");
function buildRetirementFixtureEnv(port) {
return {
SKIP_NEXT_LEGACY: "1",
MNOTE_WEB_ALLOW_DEV_FIXTURES: "1",
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
MNOTE_WEB_QUERY_FIXTURES_JSON: JSON.stringify({
"documents:getMeta": {
id: "doc_1",
workspace_id: "ws_demo",
title: "服务端页面",
updated_at: "2026-04-28T00:00: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: [] },
},
"sidebar:datasetList": {
active_workspace_id: "ws_demo",
workspaces: [],
documents: [
{
id: "doc_1",
workspace_id: "ws_demo",
title: "服务端页面",
parent_id: null,
sort_order: 0,
is_starred: false,
is_template: false,
created_at: "2026-04-28T00:00:00Z",
updated_at: "2026-04-28T00:00:00Z",
},
],
trashed_documents: [],
media_assets: [],
trashed_media_assets: [],
mindmap_assets: [],
trashed_mindmap_assets: [],
table_assets: [],
trashed_table_assets: [],
mindmap_docs: [],
mindmap_asset_children: {},
},
"bridgeLogs:listWorkspaceOverview": {
workspace_id: "ws_demo",
command_logs: [],
domain_events: [],
next_cursor: null,
has_more: false,
filters: {
command_status: null,
event_status: null,
target_page_id: null,
target_block_id: null,
aggregate_type: null,
aggregate_id: null,
},
generated_at: "2026-04-28T00:00:00Z",
},
}),
};
}
function startRetiredNextGateway(port) {
return spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], {
cwd: "/mnt/Data1T/mnote/rust",
env: {
...process.env,
...buildRetirementFixtureEnv(port),
},
stdio: ["ignore", "pipe", "pipe"],
});
}
async function readText(baseUrl, path, init) {
const response = await fetchWithTimeout(`${baseUrl}${path}`, init);
const text = await response.text();
assert.equal(response.status, 200, `${path} 请求失败: ${response.status} ${text.slice(0, 200)}`);
assert.equal(response.headers.get("x-mnote-web-owner"), "mnote-web", `${path} 缺少 mnote-web owner`);
return { response, text };
}
async function validateSkipNextRuntimePlan() {
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.publicPort, 3000);
assert.equal(plan.legacyPort, 3100);
assert.equal(plan.mnoteWebEnv.MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT, "0");
assert.equal("MNOTE_WEB_LEGACY_NEXT_BASE_URL" in plan.mnoteWebEnv, false);
return plan;
}
async function validateCoreShellsWithoutNext(baseUrl) {
const auth = await readText(baseUrl, "/auth");
assert.match(auth.text, /data-mnote-shell="auth"/);
assert.doesNotMatch(auth.text, /next-app-router/i);
const document = await readText(baseUrl, "/documents/doc_1?workspaceId=ws_demo");
assert.equal(document.response.headers.get("x-mnote-web-shell"), "document");
assert.match(document.text, /mnote\.page_aggregate\.v1/);
const treeEvents = await fetchWithTimeout(`${baseUrl}/api/tree/events?workspaceId=ws_demo&maxPolls=0`);
const treeBody = await treeEvents.text();
assert.equal(treeEvents.status, 200, `/api/tree/events 请求失败: ${treeEvents.status} ${treeBody.slice(0, 200)}`);
assert.equal(treeEvents.headers.get("x-mnote-web-owner"), "mnote-web");
assert.equal(treeEvents.headers.get("x-mnote-tree-stream-owner"), "rust-web");
assert.match(treeBody, /event:\s*snapshot/);
const search = await readText(baseUrl, "/search?workspaceId=ws_demo&q=Rust");
assert.equal(search.response.headers.get("x-mnote-web-shell"), "search");
assert.match(search.text, /mnote\.search_shell\.v1/);
}
async function validateDocs() {
const fs = require("node:fs");
const architecture = fs.readFileSync("/mnt/Data1T/mnote/ARCHITECTURE.md", "utf8");
assert.match(architecture, /Next App Router.*legacy compat/is, "ARCHITECTURE.md Next App Router legacy compat");
assert.doesNotMatch(architecture, /Next App Router 仍是当前 legacy 主壳/, "ARCHITECTURE.md 仍保留旧主壳口径");
}
async function main() {
const plan = await validateSkipNextRuntimePlan();
const port = await findFreePort();
const baseUrl = `http://127.0.0.1:${port}`;
const gateway = startRetiredNextGateway(port);
let stderr = "";
gateway.stderr.on("data", (chunk) => {
stderr += chunk.toString("utf8");
});
try {
await waitForGateway(baseUrl);
await validateCoreShellsWithoutNext(baseUrl);
await validateDocs();
console.log(
JSON.stringify(
{
ok: true,
baseUrl,
publicEntry: String(plan.publicPort),
legacyPort: String(plan.legacyPort),
nextLegacyStarted: false,
owner: "mnote-web",
},
null,
2,
),
);
} catch (error) {
if (stderr.trim()) {
console.error(stderr.trim().slice(-2000));
}
throw error;
} finally {
if (gateway.pid) {
gateway.kill("SIGTERM");
setTimeout(() => {
if (!gateway.killed) {
gateway.kill("SIGKILL");
}
}, 2000).unref();
}
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -0,0 +1,65 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const { fetchWithTimeout } = require("./task114-rust-web-gateway-entry-smoke.js");
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
function excerpt(html) {
return html.replace(/\s+/g, " ").trim().slice(0, 600);
}
function assertContains(html, pattern, label) {
assert.match(html, pattern, `${label} 缺失;当前页面片段: ${excerpt(html)}`);
}
function assertNotContains(html, pattern, label) {
assert.doesNotMatch(html, pattern, `${label} 仍存在;当前页面片段: ${excerpt(html)}`);
}
async function validateWolaiUiParity(baseUrl) {
const response = await fetchWithTimeout(`${baseUrl}/`);
const html = await response.text();
assert.equal(response.status, 200, `/ 请求失败: ${response.status}; body: ${excerpt(html)}`);
assert.equal(response.headers.get("x-mnote-web-owner"), "mnote-web");
assert.match(response.headers.get("content-type") || "", /text\/html/);
assertContains(html, /data-mnote-shell="workspace"/, "workspace shell 标记");
assertContains(html, /data-testid="wolai-(workspace-identity|account|account-menu)"|工作区|个人空间|账号/, "账号/工作区身份");
assertContains(html, /星标置顶/, "星标置顶入口");
assertContains(html, /我的页面/, "我的页面入口");
assertContains(html, /垃圾箱/, "垃圾箱入口");
assertContains(html, /模板中心/, "模板中心入口");
assertContains(html, /data-testid="wolai-topbar"/, "Wolai topbar");
assertContains(html, /data-testid="wolai-floating-ai"/, "Wolai 浮动 AI 入口");
assertNotContains(html, /欢迎使用 MNOTE 知识管理平台/, "极简欢迎页文案");
}
async function main() {
await validateWolaiUiParity(BASE_URL);
console.log(
JSON.stringify(
{
ok: true,
baseUrl: BASE_URL,
shell: "workspace",
owner: "mnote-web",
},
null,
2,
),
);
}
module.exports = {
validateWolaiUiParity,
};
if (require.main === module) {
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
}
@@ -0,0 +1,106 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const OUTPUT_DIR = path.resolve(__dirname, "..", "test-results");
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "task119-rust-web-wolai-visual-regression-root.png");
function assertBox(box, label) {
assert(box, `${label} 缺少可见布局盒`);
assert(box.width > 0 && box.height > 0, `${label} 尺寸异常: ${JSON.stringify(box)}`);
}
async function boundingBox(locator, label) {
await locator.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const box = await locator.boundingBox();
assertBox(box, label);
return box;
}
function boxesIntersect(a, b) {
return !(
a.x + a.width <= b.x ||
b.x + b.width <= a.x ||
a.y + a.height <= b.y ||
b.y + b.height <= a.y
);
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
try {
const response = await page.goto(`${BASE_URL}/`, {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
assert(response, "根页没有返回响应");
assert.equal(response.status(), 200, `根页状态码异常: ${response.status()}`);
assert.equal(response.headers()["x-mnote-web-owner"], "mnote-web", "根页必须由 mnote-web 拥有");
await page.waitForSelector('[data-mnote-shell="workspace"]', { timeout: UI_TIMEOUT_MS });
const bodyText = await page.locator("body").innerText({ timeout: UI_TIMEOUT_MS });
assert(!bodyText.includes("欢迎使用 MNOTE 知识管理平台"), "根页仍停留在极简欢迎页");
const sidebar = page.getByTestId("wolai-sidebar");
const topbar = page.getByTestId("wolai-topbar");
const floatingAi = page.getByTestId("wolai-floating-ai");
const floatingHelp = page.getByTestId("wolai-floating-help");
const content = page.locator(".mnote-content").first();
const homeContent = page.locator(".mnote-home").first();
const sidebarBox = await boundingBox(sidebar, "左侧栏");
const topbarBox = await boundingBox(topbar, "顶栏");
const contentBox = await boundingBox(content, "主内容区");
const homeContentBox = await boundingBox(homeContent, "首页正文");
const floatingAiBox = await boundingBox(floatingAi, "AI 浮动按钮");
const floatingHelpBox = await boundingBox(floatingHelp, "帮助浮动按钮");
assert(sidebarBox.width >= 240 && sidebarBox.width <= 340, `左栏宽度应稳定在 Wolai 范围内: ${sidebarBox.width}`);
assert(sidebarBox.x >= -1 && sidebarBox.x <= 1, `左栏应贴齐视口左侧: ${sidebarBox.x}`);
assert(topbarBox.x >= sidebarBox.width - 2, "顶栏不应覆盖左侧栏");
assert(contentBox.x >= sidebarBox.width - 2, "主内容区不应被侧栏覆盖");
assert(topbarBox.y >= -1 && topbarBox.height >= 36 && topbarBox.height <= 72, `顶栏高度异常: ${topbarBox.height}`);
assert(contentBox.y >= topbarBox.y + topbarBox.height - 2, "主内容区不应被顶栏遮挡");
assert(floatingAiBox.x > sidebarBox.width, "AI 浮动按钮不应落入左侧栏");
assert(floatingHelpBox.x > sidebarBox.width, "帮助浮动按钮不应落入左侧栏");
assert(!boxesIntersect(floatingAiBox, homeContentBox), "AI 浮动按钮不应遮挡首页正文");
assert(!boxesIntersect(floatingHelpBox, homeContentBox), "帮助浮动按钮不应遮挡首页正文");
assert(floatingHelpBox.y > floatingAiBox.y, "帮助按钮应位于 AI 按钮下方");
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
console.log(
JSON.stringify(
{
ok: true,
baseUrl: BASE_URL,
screenshot: SCREENSHOT_PATH,
sidebarWidth: Math.round(sidebarBox.width),
topbarHeight: Math.round(topbarBox.height),
},
null,
2,
),
);
} finally {
await page.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
}
+36 -3
View File
@@ -1,6 +1,11 @@
"use strict";
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const DOCUMENT_UI_BASE_URL = (
process.env.MNOTE_TREE_SMOKE_DOCUMENT_BASE_URL ||
process.env.MNOTE_LEGACY_UI_BASE_URL ||
"http://127.0.0.1:3100"
).replace(/\/+$/, "");
const MNOTE_WEB_BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "").replace(/\/+$/, "");
const REQUEST_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_TIMEOUT_MS || 20_000);
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
@@ -162,6 +167,19 @@ async function isVisible(locator) {
}
}
async function clickButtonByExactText(page, text) {
await page.evaluate((label) => {
const button = Array.from(document.querySelectorAll("button")).find((candidate) => {
const content = (candidate.textContent || "").trim();
return content === label;
});
if (!(button instanceof HTMLButtonElement)) {
throw new Error(`未找到按钮:${label}`);
}
button.click();
}, text);
}
async function completeUsernameSetupIfNeeded(page) {
const saveButton = page.getByRole("button", { name: "保存并继续" });
if (!(await isVisible(saveButton))) {
@@ -319,7 +337,7 @@ async function cleanupDocuments(requestContext, createdIds) {
}
async function openDocument(page, workspaceId, documentId) {
const url = `${BASE_URL}/documents/${documentId}?workspaceId=${encodeURIComponent(workspaceId)}`;
const url = `${DOCUMENT_UI_BASE_URL}/documents/${documentId}?workspaceId=${encodeURIComponent(workspaceId)}`;
const hasSidebarControls = async () => {
const groupButton = page.getByRole("button", { name: "分组" });
const fileButton = page.getByRole("button", { name: "文件" });
@@ -373,8 +391,15 @@ async function openSectionView(page) {
{ timeout: UI_TIMEOUT_MS },
);
try {
await waitForHost();
return;
} catch {
// 说明:未处于分组视图时才需要点击切换。
}
for (let attempt = 0; attempt < 2; attempt += 1) {
await button.click({ timeout: UI_TIMEOUT_MS });
await clickButtonByExactText(page, "分组");
try {
await waitForHost();
return;
@@ -399,8 +424,15 @@ async function openFilesystemView(page) {
{ timeout: UI_TIMEOUT_MS },
);
try {
await waitForHost();
return;
} catch {
// 说明:未处于文件视图时才需要点击切换。
}
for (let attempt = 0; attempt < 2; attempt += 1) {
await button.click({ timeout: UI_TIMEOUT_MS });
await clickButtonByExactText(page, "文件");
try {
await waitForHost();
return;
@@ -440,6 +472,7 @@ async function ensurePageOptionsVisible(page) {
module.exports = {
BASE_URL,
DOCUMENT_UI_BASE_URL,
MNOTE_WEB_BASE_URL,
REQUEST_TIMEOUT_MS,
UI_TIMEOUT_MS,