feat: cut over rust web main shell
This commit is contained in:
+207
-110
@@ -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_PORT:Rust 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,
|
||||
};
|
||||
|
||||
@@ -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 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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user