收口 Rust Web 入口与 AI 写入链
- 将 3000 主入口继续收口到 mnote-web,补齐 /favicon.ico、/api/auth、session alias、AI run 等 Rust Web 路由边界。 - 更新登录页与 Convex Auth 代理,支持测试账号快速登录写入真实 Convex Auth cookie。 - 推进页面设置、Wolai 对齐、Phase 7 AI kernel/CLI-first 设计文档与相关 smoke 脚本。 - 更新 leptos-tiptap 生成资产、mnote-cli/bridge-runtime、前端依赖和 dev/prod 启动脚本。
This commit is contained in:
+54
-35
@@ -1,19 +1,21 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 同时热启动前端、FastAPI 与 Celery。
|
||||
* 同时热启动前端、FastAPI,以及按需启用的 Celery。
|
||||
* 可使用以下环境变量调整行为:
|
||||
* - FRONTEND_CMD:覆盖前端启动命令,默认为 "pnpm dev"
|
||||
* - FRONTEND_CMD:覆盖历史 Next 启动命令,仅在显式启用 legacy compat 或跳过 Rust gateway 时生效
|
||||
* - BACKEND_CMD:覆盖 FastAPI 启动命令,默认为 "python -m uvicorn app.main:app --reload --port 8000"
|
||||
* - CELERY_CMD:覆盖 Celery 启动命令,默认为 "celery -A app.workers.celery_app worker --loglevel=info"
|
||||
* - ENABLE_CELERY:设为 "1" or "true" 时启用默认 Celery worker
|
||||
* - CELERY_CMD:覆盖 Celery 启动命令;设置后即视为显式启用 Celery
|
||||
* - CELERY_POOL:只在 CELERY_CMD 未覆盖时生效,设置 Celery worker pool;Windows 默认 "solo",其他平台默认使用 Celery 自身默认值
|
||||
* - PYTHON_BIN:只在 BACKEND_CMD 未覆盖时,设置 Python 可执行文件,默认 "python"
|
||||
* - CELERY_BIN:只在 CELERY_CMD 未覆盖时,设置 Celery 可执行文件,默认 "celery"
|
||||
* - REDIS_URL:仅用于探测 Redis 是否就绪,默认 "redis://localhost:6379/0"
|
||||
* - SKIP_CELERY:设为 "1" or "true" 可跳过 Celery。
|
||||
* - 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。
|
||||
* - MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT:设为 "1" or "true" 时才启动 Next legacy upstream。
|
||||
* - NEXT_LEGACY_PORT:显式启用 legacy compat 时的 Next upstream 端口,默认 3100。
|
||||
* - SKIP_NEXT_LEGACY:兼容旧环境变量;设为 "1" or "true" 时强制只启动 Rust gateway。
|
||||
*/
|
||||
|
||||
const { spawn, execSync } = require("child_process");
|
||||
@@ -55,29 +57,34 @@ function resolveBackendExecutable(envName, fallbackName) {
|
||||
const pythonBin = resolveBackendExecutable("PYTHON_BIN", "python");
|
||||
const celeryBin = resolveBackendExecutable("CELERY_BIN", "celery");
|
||||
const celeryPoolFromEnv = (process.env.CELERY_POOL || "").trim();
|
||||
const skipCelery =
|
||||
(process.env.SKIP_CELERY || "").toLowerCase() === "1" ||
|
||||
(process.env.SKIP_CELERY || "").toLowerCase() === "true";
|
||||
const celeryCmdFromEnv = process.env.CELERY_CMD;
|
||||
const celeryCmdFromEnv = (process.env.CELERY_CMD || "").trim();
|
||||
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 shouldStartCelery(env = process.env) {
|
||||
if (isEnabledEnv(env.SKIP_CELERY)) return false;
|
||||
if (String(env.CELERY_CMD || "").trim()) return true;
|
||||
return isEnabledEnv(env.ENABLE_CELERY);
|
||||
}
|
||||
|
||||
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 legacyCompatRequested =
|
||||
!skipGateway &&
|
||||
isEnabledEnv(env.MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT) &&
|
||||
!isEnabledEnv(env.SKIP_NEXT_LEGACY);
|
||||
const skipNextLegacy = !skipGateway && !legacyCompatRequested;
|
||||
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";
|
||||
const legacyCompatEnabled = legacyCompatRequested ? "1" : "0";
|
||||
|
||||
return {
|
||||
skipGateway,
|
||||
@@ -489,16 +496,18 @@ async function checkRedisReachable(urlString, timeoutMs = 2000) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// 说明:Next dev 在异常退出时可能残留 `.next/dev/lock`,会导致后续启动直接失败。
|
||||
// 这里在启动前做一次“安全清理”,避免重启后仍卡住。
|
||||
const nextDevLockPath = path.join(frontendDir, ".next", "dev", "lock");
|
||||
try {
|
||||
if (fs.existsSync(nextDevLockPath)) {
|
||||
fs.rmSync(nextDevLockPath, { force: true });
|
||||
logPrefix("frontend", `检测到残留的 Next dev lock,已移除:${nextDevLockPath}`);
|
||||
if (runtimePlan.frontendTaskName) {
|
||||
// 说明:Next dev 在异常退出时可能残留 `.next/dev/lock`,会导致后续启动直接失败。
|
||||
// 只有显式启动历史 Next 时才清理该 lock,避免默认热启动继续触碰旧前端目录。
|
||||
const nextDevLockPath = path.join(frontendDir, ".next", "dev", "lock");
|
||||
try {
|
||||
if (fs.existsSync(nextDevLockPath)) {
|
||||
fs.rmSync(nextDevLockPath, { force: true });
|
||||
logPrefix("frontend", `检测到残留的 Next dev lock,已移除:${nextDevLockPath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logPrefix("frontend", `尝试移除 Next dev lock 失败:${error.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logPrefix("frontend", `尝试移除 Next dev lock 失败:${error.message}`);
|
||||
}
|
||||
|
||||
// 说明:你外网绑定了 3000 端口,这里默认强制使用 3000。
|
||||
@@ -514,7 +523,7 @@ async function main() {
|
||||
const frontendUrl = `http://localhost:${frontendPort}`;
|
||||
|
||||
let nextLegacyPort = null;
|
||||
if (!skipMnoteWebGateway) {
|
||||
if (runtimePlan.frontendTaskName === "next-legacy") {
|
||||
nextLegacyPort = runtimePlan.legacyPort;
|
||||
const nextLegacyPortOk = await ensurePortFree(nextLegacyPort, "next-legacy");
|
||||
if (!nextLegacyPortOk) {
|
||||
@@ -523,20 +532,25 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// 说明:在 Windows 的 cmd.exe 下,`pnpm dev -- -p 3000` 会把 `--` 原样传给 next,导致 next 把 `-p` 误当成目录。
|
||||
// 用 `pnpm dev -p 3000` 在 PowerShell/cmd.exe 下都能正确传参。
|
||||
const frontendTask = skipMnoteWebGateway ? findTask("frontend") : findTask("next-legacy");
|
||||
if (!frontendTask) {
|
||||
throw new Error("缺少前端任务配置");
|
||||
const frontendTask = runtimePlan.frontendTaskName ? findTask(runtimePlan.frontendTaskName) : null;
|
||||
if (runtimePlan.frontendTaskName && !frontendTask) {
|
||||
throw new Error(`缺少前端任务配置:${runtimePlan.frontendTaskName}`);
|
||||
}
|
||||
if (frontendTask) {
|
||||
// 说明:在 Windows 的 cmd.exe 下,`pnpm dev -- -p 3000` 会把 `--` 原样传给 next,导致 next 把 `-p` 误当成目录。
|
||||
// 用 `pnpm dev -p 3000` 在 PowerShell/cmd.exe 下都能正确传参。
|
||||
frontendTask.command = runtimePlan.frontendCommand;
|
||||
logPrefix(frontendTask.name, `前端目录:${frontendDir}`);
|
||||
}
|
||||
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}`);
|
||||
if (runtimePlan.frontendTaskName === "next-legacy") {
|
||||
logPrefix("next-legacy", `Next legacy upstream:${runtimePlan.legacyUrl}`);
|
||||
} else {
|
||||
logPrefix("next-legacy", "默认不启动历史 Next upstream;如需临时兼容请设置 MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT=1。");
|
||||
}
|
||||
const gatewayTask = tasks.find((task) => task.name === "mnote-web");
|
||||
if (gatewayTask) {
|
||||
gatewayTask.command = runtimePlan.mnoteWebCommand;
|
||||
@@ -558,7 +572,7 @@ async function main() {
|
||||
backendTask.command = `${pythonBin} -m uvicorn app.main:app --reload --port ${desiredBackendPort}`;
|
||||
}
|
||||
|
||||
if (!skipCelery) {
|
||||
if (shouldStartCelery(process.env)) {
|
||||
const defaultCeleryCommand = buildDefaultCeleryCommand();
|
||||
const celeryTask = {
|
||||
name: "celery",
|
||||
@@ -578,9 +592,13 @@ async function main() {
|
||||
// Redis 未就绪时直接跳过 Celery,避免热调试流程整体退出。
|
||||
logPrefix(
|
||||
"celery",
|
||||
`检测到 ${redisUrl} 无法连接,自动跳过 Celery。请先启动 Redis 或设置 SKIP_CELERY=1 显式跳过。`,
|
||||
`检测到 ${redisUrl} 无法连接,自动跳过 Celery。请先启动 Redis,或继续保持当前默认关闭策略。`,
|
||||
);
|
||||
}
|
||||
} else if (isEnabledEnv(process.env.SKIP_CELERY)) {
|
||||
logPrefix("celery", "已跳过 Celery(SKIP_CELERY=1)。");
|
||||
} else {
|
||||
logPrefix("celery", "默认不启动 Celery;当前主线页面不依赖 Redis/Celery。如需启用请设置 ENABLE_CELERY=1 或 CELERY_CMD。");
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
@@ -607,5 +625,6 @@ module.exports = {
|
||||
isPortFree,
|
||||
resolveRuntimePlan,
|
||||
resolveBackendExecutable,
|
||||
shouldStartCelery,
|
||||
terminatePid,
|
||||
};
|
||||
|
||||
+29
-12
@@ -7,6 +7,7 @@ const {
|
||||
ensurePortFree,
|
||||
isPortFree,
|
||||
resolveRuntimePlan,
|
||||
shouldStartCelery,
|
||||
} = require("./desktop-hot.js");
|
||||
|
||||
function findFreePort() {
|
||||
@@ -114,15 +115,34 @@ test("ensurePortFree 在非 Windows 平台能释放后端监听进程", async (t
|
||||
await waitForExit(child);
|
||||
});
|
||||
|
||||
test("默认热启动计划使用 mnote-web 作为 3000 owner,Next 仅作为 legacy upstream", () => {
|
||||
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.skipNextLegacy, true);
|
||||
assert.equal(plan.publicPort, 3000);
|
||||
assert.equal(plan.legacyPort, 3100);
|
||||
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",
|
||||
});
|
||||
});
|
||||
|
||||
test("显式开启 legacy compat 时才启动 Next legacy upstream", () => {
|
||||
const plan = resolveRuntimePlan({
|
||||
FRONTEND_PORT: "3000",
|
||||
NEXT_LEGACY_PORT: "3100",
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "1",
|
||||
});
|
||||
|
||||
assert.equal(plan.skipGateway, false);
|
||||
assert.equal(plan.skipNextLegacy, false);
|
||||
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");
|
||||
@@ -134,17 +154,6 @@ test("默认热启动计划使用 mnote-web 作为 3000 owner,Next 仅作为 l
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
@@ -176,3 +185,11 @@ test("SKIP_NEXT_LEGACY 保持 Rust gateway 为 3000 owner,但不启动 Next le
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
||||
});
|
||||
});
|
||||
|
||||
test("默认跳过 Celery,只有显式开启时才启动", () => {
|
||||
assert.equal(shouldStartCelery({}), false);
|
||||
assert.equal(shouldStartCelery({ ENABLE_CELERY: "1" }), true);
|
||||
assert.equal(shouldStartCelery({ ENABLE_CELERY: "true" }), true);
|
||||
assert.equal(shouldStartCelery({ CELERY_CMD: "custom-celery" }), true);
|
||||
assert.equal(shouldStartCelery({ ENABLE_CELERY: "1", SKIP_CELERY: "1" }), false);
|
||||
});
|
||||
|
||||
+30
-11
@@ -13,7 +13,9 @@
|
||||
* - INGEST_PORT:默认 8779
|
||||
* - PYTHON_BIN:默认 python
|
||||
* - CELERY_BIN:默认 celery
|
||||
* - SKIP_CELERY=1:跳过 Celery worker
|
||||
* - ENABLE_CELERY=1:启用默认 Celery worker
|
||||
* - CELERY_CMD:覆盖 Celery 启动命令;设置后即视为显式启用 Celery
|
||||
* - SKIP_CELERY=1:强制跳过 Celery worker
|
||||
* - REDIS_URL:用于探测 Redis,默认 redis://127.0.0.1:6379/0
|
||||
*/
|
||||
|
||||
@@ -57,9 +59,7 @@ function resolveBackendExecutable(envName, fallbackName) {
|
||||
|
||||
const pythonBin = resolveBackendExecutable("PYTHON_BIN", "python");
|
||||
const celeryBin = resolveBackendExecutable("CELERY_BIN", "celery");
|
||||
const skipCelery =
|
||||
(process.env.SKIP_CELERY || "").toLowerCase() === "1" ||
|
||||
(process.env.SKIP_CELERY || "").toLowerCase() === "true";
|
||||
const celeryCmdFromEnv = (process.env.CELERY_CMD || "").trim();
|
||||
const redisUrl = process.env.REDIS_URL || "redis://127.0.0.1:6379/0";
|
||||
|
||||
const frontendPort = Number(process.env.FRONTEND_PORT || 3000);
|
||||
@@ -74,6 +74,17 @@ function logPrefix(name, message) {
|
||||
console.log(`[${name}] ${message}`);
|
||||
}
|
||||
|
||||
function isEnabledEnv(value) {
|
||||
const normalized = String(value || "").toLowerCase();
|
||||
return normalized === "1" || normalized === "true";
|
||||
}
|
||||
|
||||
function shouldStartCelery(env = process.env) {
|
||||
if (isEnabledEnv(env.SKIP_CELERY)) return false;
|
||||
if (String(env.CELERY_CMD || "").trim()) return true;
|
||||
return isEnabledEnv(env.ENABLE_CELERY);
|
||||
}
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return {};
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
@@ -299,20 +310,22 @@ async function main() {
|
||||
});
|
||||
|
||||
// Celery(可选)
|
||||
if (!skipCelery) {
|
||||
if (shouldStartCelery(process.env)) {
|
||||
const ok = await checkRedisReachable(envBackend.REDIS_URL || redisUrl);
|
||||
if (ok) {
|
||||
spawnTask({
|
||||
name: "celery",
|
||||
command: `${celeryBin} -A app.workers.celery_app worker --loglevel=info`,
|
||||
command: celeryCmdFromEnv || `${celeryBin} -A app.workers.celery_app worker --loglevel=info`,
|
||||
cwd: backendDir,
|
||||
env: envBackend,
|
||||
});
|
||||
} else {
|
||||
logPrefix("celery", `检测到 Redis 不可达(${envBackend.REDIS_URL || redisUrl}),跳过 Celery。`);
|
||||
}
|
||||
} else {
|
||||
} else if (isEnabledEnv(process.env.SKIP_CELERY)) {
|
||||
logPrefix("celery", "已跳过 Celery(SKIP_CELERY=1)");
|
||||
} else {
|
||||
logPrefix("celery", "默认不启动 Celery;当前主线页面不依赖 Redis/Celery。如需启用请设置 ENABLE_CELERY=1 或 CELERY_CMD。");
|
||||
}
|
||||
|
||||
// 启动 ingest_service(自动入库、OCR 触发、延迟删除清理)
|
||||
@@ -337,7 +350,13 @@ async function main() {
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[system] 启动失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error(`[system] 启动失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
shouldStartCelery,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
const assert = require("node:assert");
|
||||
const { test } = require("node:test");
|
||||
|
||||
const { shouldStartCelery } = require("./desktop-prod.js");
|
||||
|
||||
test("desktop-prod 默认跳过 Celery,只有显式开启时才启动", () => {
|
||||
assert.equal(shouldStartCelery({}), false);
|
||||
assert.equal(shouldStartCelery({ ENABLE_CELERY: "1" }), true);
|
||||
assert.equal(shouldStartCelery({ ENABLE_CELERY: "true" }), true);
|
||||
assert.equal(shouldStartCelery({ CELERY_CMD: "custom-celery" }), true);
|
||||
assert.equal(shouldStartCelery({ ENABLE_CELERY: "1", SKIP_CELERY: "1" }), false);
|
||||
});
|
||||
@@ -110,18 +110,28 @@ async function validateGateway(baseUrl) {
|
||||
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"/);
|
||||
}
|
||||
assert.equal(auth.headers.get("x-mnote-legacy-upstream"), null, "/auth 不应再代理 3100");
|
||||
assert.match(authText, /data-mnote-shell="auth"/);
|
||||
assert.match(authText, /邮箱登录/);
|
||||
assert.match(authText, /测试账号快速登录/);
|
||||
assert.doesNotMatch(authText, /隐私政策|使用\s*Google|使用\s*GitHub|第三方快捷/iu);
|
||||
|
||||
const root = await fetchWithTimeout(`${baseUrl}/`);
|
||||
const rootText = await root.text();
|
||||
assert.equal(root.status, 200, `/ 失败: ${root.status}`);
|
||||
assert.equal(root.status, 303, `/ 未登录应跳转 /auth: ${root.status} ${rootText.slice(0, 120)}`);
|
||||
assert.equal(root.headers.get("x-mnote-web-owner"), "mnote-web");
|
||||
assert.match(rootText, /data-mnote-shell="workspace"/);
|
||||
assert.equal(root.headers.get("location"), "/auth");
|
||||
|
||||
const authedRoot = await fetchWithTimeout(`${baseUrl}/`, {
|
||||
headers: {
|
||||
"x-mnote-actor-id": "user_real",
|
||||
"x-mnote-actor-type": "user",
|
||||
},
|
||||
});
|
||||
const authedRootText = await authedRoot.text();
|
||||
assert.equal(authedRoot.status, 200, `/ 已登录入口失败: ${authedRoot.status}`);
|
||||
assert.equal(authedRoot.headers.get("x-mnote-web-owner"), "mnote-web");
|
||||
assert.match(authedRootText, /data-mnote-shell="workspace"/);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
|
||||
@@ -91,6 +91,17 @@ function startRetiredNextGateway(port) {
|
||||
});
|
||||
}
|
||||
|
||||
function authedInit(init = {}) {
|
||||
return {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.headers || {}),
|
||||
"x-mnote-actor-id": "user_real",
|
||||
"x-mnote-actor-type": "user",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function readText(baseUrl, path, init) {
|
||||
const response = await fetchWithTimeout(`${baseUrl}${path}`, init);
|
||||
const text = await response.text();
|
||||
@@ -121,7 +132,7 @@ async function validateSkipNextRuntimePlan() {
|
||||
}
|
||||
|
||||
async function validateCoreShellsWithoutNext(baseUrl) {
|
||||
const root = await readText(baseUrl, "/?workspaceId=ws_demo");
|
||||
const root = await readText(baseUrl, "/?workspaceId=ws_demo", authedInit());
|
||||
assert.match(root.text, /data-mnote-shell="workspace"/);
|
||||
assert.doesNotMatch(root.text, /next-app-router/i);
|
||||
|
||||
@@ -129,22 +140,22 @@ async function validateCoreShellsWithoutNext(baseUrl) {
|
||||
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");
|
||||
const document = await readText(baseUrl, "/documents/doc_1?workspaceId=ws_demo", authedInit());
|
||||
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 treeEvents = await fetchWithTimeout(`${baseUrl}/api/tree/events?workspaceId=ws_demo&maxPolls=0`, authedInit());
|
||||
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");
|
||||
const search = await readText(baseUrl, "/search?workspaceId=ws_demo&q=Rust", authedInit());
|
||||
assert.equal(search.response.headers.get("x-mnote-web-shell"), "search");
|
||||
assert.match(search.text, /mnote\.search_shell\.v1/);
|
||||
|
||||
const mindmap = await readText(baseUrl, "/mindmap/doc_1/mind_1");
|
||||
const mindmap = await readText(baseUrl, "/mindmap/doc_1/mind_1", authedInit());
|
||||
assert.equal(mindmap.response.headers.get("x-mnote-web-shell"), "mindmap");
|
||||
assert.match(mindmap.text, /mnote\.mindmap_shell\.v1/);
|
||||
assert.doesNotMatch(mindmap.text, /next-app-router/i);
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const { chromium } = require("playwright");
|
||||
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(/\/+$/, "");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const SCREENSHOT_DIR = process.env.MNOTE_PHASE_E_SCREENSHOT_DIR || "/mnt/Data1T/mnote/tmp/wolai-editor-parity/task154-e26-anchor-local-smoke";
|
||||
const LEPTOS_SPIKE_SOURCE = "/mnt/Data1T/mnote/rust/spikes/leptos-tiptap-spike/src/lib.rs";
|
||||
const CONVERTER_SOURCE = "/mnt/Data1T/mnote/wolai-frontend/src/lib/documents/tiptap-content-converter.ts";
|
||||
const TARGET_BLOCK_ID = "e26-anchor-target";
|
||||
const SECOND_BLOCK_ID = "e26-anchor-second";
|
||||
|
||||
function assertAnchorSourceBoundary() {
|
||||
const spike = fs.readFileSync(LEPTOS_SPIKE_SOURCE, "utf8");
|
||||
assert(!spike.includes('Some(format!("top-level-{index}"))'), "E26 不能继续把 top-level 序号当块锚点 id");
|
||||
assert(spike.includes("data-block-id"), "E26 runtime DOM 必须暴露 data-block-id");
|
||||
assert(spike.includes("navigator.clipboard.writeText"), "E26 复制链接必须真实写入剪贴板");
|
||||
assert(spike.includes("scroll_mnote_block_anchor_from_hash"), "E26 必须处理 reload/hash 定位");
|
||||
const converter = fs.readFileSync(CONVERTER_SOURCE, "utf8");
|
||||
assert(converter.includes('const BLOCK_ID_ATTR = "blockId"'), "TS 转换层必须继续以 Rust blockId 作为 Tiptap attrs 真源");
|
||||
assert(converter.includes("editorBlockDocumentFromTiptapDoc"), "保存链必须继续回到 EditorBlockDocument");
|
||||
}
|
||||
|
||||
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 postTreeCommand(body, label) {
|
||||
const response = await fetchWithTimeout(`${BASE_URL}/api/tree/commands`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const payload = await readJsonResponse(response, label);
|
||||
assert(payload?.result, `${label} 缺少 result`);
|
||||
return payload.result;
|
||||
}
|
||||
|
||||
async function createTempDocument() {
|
||||
const title = `task154-e26-anchor-${Date.now().toString(36)}`;
|
||||
const result = await postTreeCommand({ action: "create", title }, "创建 E26 临时文档");
|
||||
assert(result.documentId, "创建 E26 临时文档缺少 documentId");
|
||||
assert(result.workspaceId, "创建 E26 临时文档缺少 workspaceId");
|
||||
return { documentId: result.documentId, workspaceId: result.workspaceId, title };
|
||||
}
|
||||
|
||||
async function purgeTempDocument(target) {
|
||||
if (!target?.documentId || !target?.workspaceId) return;
|
||||
await postTreeCommand({ action: "purge", workspaceId: target.workspaceId, documentId: target.documentId }, "清理 E26 临时文档");
|
||||
}
|
||||
|
||||
async function loadDocumentContent(target, label) {
|
||||
const url = `${BASE_URL}/api/documents/content?documentId=${encodeURIComponent(target.documentId)}&workspaceId=${encodeURIComponent(target.workspaceId)}`;
|
||||
const response = await fetchWithTimeout(url, { method: "GET" });
|
||||
return readJsonResponse(response, label);
|
||||
}
|
||||
|
||||
async function waitForRuntimeIsland(page) {
|
||||
const root = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first();
|
||||
await root.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const editor = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]').first();
|
||||
await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(() => {
|
||||
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
const editorNode = host?.querySelector('.editor-surface .ProseMirror[contenteditable="true"]');
|
||||
return host?.getAttribute("data-runtime-editor-status") !== "error" && editorNode instanceof HTMLElement && editorNode.isContentEditable;
|
||||
}, null, { timeout: UI_TIMEOUT_MS });
|
||||
return editor;
|
||||
}
|
||||
|
||||
async function screenshot(page, name) {
|
||||
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/${name}.png`, fullPage: true });
|
||||
}
|
||||
|
||||
function collectBlockIds(value, ids = []) {
|
||||
if (!value || typeof value !== "object") return ids;
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) collectBlockIds(item, ids);
|
||||
return ids;
|
||||
}
|
||||
if (typeof value.blockId === "string") ids.push(value.blockId);
|
||||
if (typeof value.id === "string") ids.push(value.id);
|
||||
if (value.attrs && typeof value.attrs.blockId === "string") ids.push(value.attrs.blockId);
|
||||
collectBlockIds(value.content, ids);
|
||||
collectBlockIds(value.children, ids);
|
||||
collectBlockIds(value.blocks, ids);
|
||||
collectBlockIds(value.editorDocument, ids);
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function setAnchorFixture(page) {
|
||||
await page.evaluate(({ targetBlockId, secondBlockId }) => {
|
||||
const editor = document.querySelector('.editor-surface .ProseMirror')?.editor;
|
||||
if (!editor) throw new Error('找不到 Tiptap editor');
|
||||
editor.commands.setContent({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
attrs: { blockId: targetBlockId },
|
||||
content: [{ type: 'text', text: 'E26 anchor target paragraph' }],
|
||||
},
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { blockId: secondBlockId, level: 2 },
|
||||
content: [{ type: 'text', text: 'E26 second heading' }],
|
||||
},
|
||||
],
|
||||
}, true);
|
||||
editor.commands.focus('start');
|
||||
}, { targetBlockId: TARGET_BLOCK_ID, secondBlockId: SECOND_BLOCK_ID });
|
||||
try {
|
||||
await page.waitForFunction(({ targetBlockId, secondBlockId }) => {
|
||||
return document.querySelector(`[data-block-id="${targetBlockId}"]`) instanceof HTMLElement
|
||||
&& document.querySelector(`[data-block-id="${secondBlockId}"]`) instanceof HTMLElement;
|
||||
}, { targetBlockId: TARGET_BLOCK_ID, secondBlockId: SECOND_BLOCK_ID }, { timeout: UI_TIMEOUT_MS });
|
||||
} catch (error) {
|
||||
const diagnostics = await page.evaluate(() => {
|
||||
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
const editor = document.querySelector('.editor-surface .ProseMirror');
|
||||
return {
|
||||
status: host?.getAttribute('data-runtime-editor-status') || null,
|
||||
html: editor?.innerHTML || null,
|
||||
json: editor?.editor?.getJSON?.() || null,
|
||||
};
|
||||
}).catch((evalError) => ({ evalError: String(evalError) }));
|
||||
throw new Error(`E26 fixture 未渲染 data-block-id: ${JSON.stringify(diagnostics).slice(0, 2400)}; cause=${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForSavedDocument(page, expectedBlockIds) {
|
||||
await page.waitForFunction(({ expectedBlockIds }) => {
|
||||
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
if (host?.getAttribute('data-runtime-editor-status') !== 'saved') return false;
|
||||
const editor = host?.querySelector('.editor-surface .ProseMirror');
|
||||
if (!(editor instanceof HTMLElement)) return false;
|
||||
return expectedBlockIds.every((blockId) => editor.querySelector(`[data-block-id="${blockId}"]`) instanceof HTMLElement);
|
||||
}, { expectedBlockIds }, { timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function openBlockMenuForTarget(page) {
|
||||
const target = page.locator(`[data-block-id="${TARGET_BLOCK_ID}"]`).first();
|
||||
await target.scrollIntoViewIfNeeded({ timeout: UI_TIMEOUT_MS });
|
||||
await target.hover({ timeout: UI_TIMEOUT_MS });
|
||||
const handle = page.locator('[data-testid="block-drag-handle-trigger"]').first();
|
||||
await handle.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await handle.click({ timeout: UI_TIMEOUT_MS });
|
||||
const menu = page.locator('[data-testid="block-drag-menu"]').first();
|
||||
await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
return menu;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
assertAnchorSourceBoundary();
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, permissions: ["clipboard-read", "clipboard-write"] });
|
||||
const page = await context.newPage();
|
||||
const saveRequests = [];
|
||||
page.on("request", (request) => {
|
||||
if (!request.url().includes("/api/documents/save")) return;
|
||||
const body = request.postData();
|
||||
if (!body) return;
|
||||
try {
|
||||
saveRequests.push(JSON.parse(body));
|
||||
} catch {
|
||||
saveRequests.push({ raw: body });
|
||||
}
|
||||
});
|
||||
|
||||
let target = null;
|
||||
try {
|
||||
target = await createTempDocument();
|
||||
const url = `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`;
|
||||
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
assert(response, "文档页没有返回响应");
|
||||
assert.equal(response.status(), 200, `文档页状态码异常: ${response.status()}`);
|
||||
|
||||
await waitForRuntimeIsland(page);
|
||||
await setAnchorFixture(page);
|
||||
await waitForSavedDocument(page, [TARGET_BLOCK_ID, SECOND_BLOCK_ID]);
|
||||
await screenshot(page, "01-anchor-blocks-with-data-block-id");
|
||||
|
||||
const persistedByRequest = [...saveRequests].reverse().find((request) => collectBlockIds(request).includes(TARGET_BLOCK_ID));
|
||||
assert(persistedByRequest, `保存请求必须包含 Rust blockId 派生的 EditorBlockDocument/TiptapDocument: ${JSON.stringify(saveRequests.slice(-3)).slice(0, 2000)}`);
|
||||
|
||||
const contentAfterSave = await loadDocumentContent(target, "读取 E26 保存后的正文");
|
||||
assert(collectBlockIds(contentAfterSave?.result ?? contentAfterSave).includes(TARGET_BLOCK_ID), `/api/documents/content 必须保留目标 blockId: ${JSON.stringify(contentAfterSave).slice(0, 1600)}`);
|
||||
|
||||
const menu = await openBlockMenuForTarget(page);
|
||||
await screenshot(page, "02-block-menu-copy-link-entry");
|
||||
await menu.locator('[data-testid="block-drag-menu-item-copy-link"]').first().click({ timeout: UI_TIMEOUT_MS });
|
||||
const clipboardText = await page.evaluate(() => navigator.clipboard.readText());
|
||||
assert(clipboardText.includes(`/documents/${target.documentId}`), `复制链接必须指向当前页面: ${clipboardText}`);
|
||||
assert(clipboardText.endsWith(`#${TARGET_BLOCK_ID}`), `复制链接必须使用 Rust blockId 作为 hash: ${clipboardText}`);
|
||||
assert(!clipboardText.includes("top-level-"), `复制链接不能使用前端序号 id: ${clipboardText}`);
|
||||
|
||||
const hashUrl = new URL(clipboardText);
|
||||
assert.equal(hashUrl.hash, `#${TARGET_BLOCK_ID}`, `复制链接 hash 异常: ${clipboardText}`);
|
||||
await page.goto(hashUrl.href, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await waitForRuntimeIsland(page);
|
||||
await page.waitForFunction((targetBlockId) => {
|
||||
const block = document.querySelector(`[data-block-id="${targetBlockId}"]`);
|
||||
if (!(block instanceof HTMLElement)) return false;
|
||||
const rect = block.getBoundingClientRect();
|
||||
const anchorMatched = block.id === targetBlockId && block.matches(":target");
|
||||
const highlighted = anchorMatched
|
||||
|| block.getAttribute("data-anchor-highlight") === "true"
|
||||
|| block.classList.contains("mnote-block-anchor-highlight");
|
||||
return rect.top >= 0 && rect.top < window.innerHeight * 0.75 && highlighted;
|
||||
}, TARGET_BLOCK_ID, { timeout: UI_TIMEOUT_MS });
|
||||
await screenshot(page, "03-after-hash-reload-anchor-highlight");
|
||||
|
||||
console.log(JSON.stringify({ ok: true, baseUrl: BASE_URL, documentId: target.documentId, blockId: TARGET_BLOCK_ID, clipboardText, screenshotDir: SCREENSHOT_DIR }, null, 2));
|
||||
} finally {
|
||||
if (target) await purgeTempDocument(target).catch(() => undefined);
|
||||
await page.close().catch(() => undefined);
|
||||
await context.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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const { chromium } = require("playwright");
|
||||
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(/\/+$/, "");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const SCREENSHOT_DIR = process.env.MNOTE_PHASE_E_SCREENSHOT_DIR || "/mnt/Data1T/mnote/tmp/wolai-editor-parity/task155-e27-ai-edit-local-smoke";
|
||||
const LEPTOS_SPIKE_SOURCE = "/mnt/Data1T/mnote/rust/spikes/leptos-tiptap-spike/src/lib.rs";
|
||||
const TARGET_BLOCK_ID = "e27-ai-target";
|
||||
|
||||
function assertAiSourceBoundary() {
|
||||
const spike = fs.readFileSync(LEPTOS_SPIKE_SOURCE, "utf8");
|
||||
assert(spike.includes("/api/ai-agent/run"), "E27 AI 入口必须调用 /api/ai-agent/run 在线主路径,Hermes 只能作为后端 fallback");
|
||||
assert(spike.includes('"provider": "online"'), "E27 AI 请求必须使用 provider=online 进入 openai-agents-python 主路径");
|
||||
assert(spike.includes('"stream": true'), "E27 AI 请求必须开启 stream=true,确保 /api/ai-agent/run 路由进入 agents sidecar");
|
||||
assert(spike.includes("mnote-leptos-tiptap-ai-status"), "E27 必须暴露 AI bridge 状态,供 smoke 和后续流式状态复用");
|
||||
assert(spike.includes("selectedBlockId"), "E27 AI 请求必须携带 Rust blockId 作为 selectedBlockId");
|
||||
assert(spike.includes("tiptapDocument"), "E27 AI 请求必须携带当前 Tiptap JSON 快照");
|
||||
}
|
||||
|
||||
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 postTreeCommand(body, label) {
|
||||
const response = await fetchWithTimeout(`${BASE_URL}/api/tree/commands`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const payload = await readJsonResponse(response, label);
|
||||
assert(payload?.result, `${label} 缺少 result`);
|
||||
return payload.result;
|
||||
}
|
||||
|
||||
async function createTempDocument() {
|
||||
const title = `task155-e27-ai-${Date.now().toString(36)}`;
|
||||
const result = await postTreeCommand({ action: "create", title }, "创建 E27 临时文档");
|
||||
assert(result.documentId, "创建 E27 临时文档缺少 documentId");
|
||||
assert(result.workspaceId, "创建 E27 临时文档缺少 workspaceId");
|
||||
return { documentId: result.documentId, workspaceId: result.workspaceId, title };
|
||||
}
|
||||
|
||||
async function purgeTempDocument(target) {
|
||||
if (!target?.documentId || !target?.workspaceId) return;
|
||||
await postTreeCommand({ action: "purge", workspaceId: target.workspaceId, documentId: target.documentId }, "清理 E27 临时文档");
|
||||
}
|
||||
|
||||
async function waitForRuntimeIsland(page) {
|
||||
const root = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first();
|
||||
await root.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const editor = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]').first();
|
||||
await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(() => {
|
||||
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
const editorNode = host?.querySelector('.editor-surface .ProseMirror[contenteditable="true"]');
|
||||
return host?.getAttribute("data-runtime-editor-status") !== "error" && editorNode instanceof HTMLElement && editorNode.isContentEditable;
|
||||
}, null, { timeout: UI_TIMEOUT_MS });
|
||||
return editor;
|
||||
}
|
||||
|
||||
async function screenshot(page, name) {
|
||||
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/${name}.png`, fullPage: true });
|
||||
}
|
||||
|
||||
async function setAiFixture(page) {
|
||||
await page.evaluate((targetBlockId) => {
|
||||
const editor = document.querySelector('.editor-surface .ProseMirror')?.editor;
|
||||
if (!editor) throw new Error('找不到 Tiptap editor');
|
||||
editor.commands.setContent({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
attrs: { blockId: targetBlockId },
|
||||
content: [{ type: 'text', text: 'E27 Ask AI source paragraph' }],
|
||||
},
|
||||
],
|
||||
}, true);
|
||||
editor.commands.focus('start');
|
||||
}, TARGET_BLOCK_ID);
|
||||
await page.waitForFunction((targetBlockId) => {
|
||||
return document.querySelector(`[data-block-id="${targetBlockId}"]`) instanceof HTMLElement;
|
||||
}, TARGET_BLOCK_ID, { timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function openBlockMenuForTarget(page) {
|
||||
const target = page.locator(`[data-block-id="${TARGET_BLOCK_ID}"]`).first();
|
||||
await target.scrollIntoViewIfNeeded({ timeout: UI_TIMEOUT_MS });
|
||||
await target.hover({ timeout: UI_TIMEOUT_MS });
|
||||
const handle = page.locator('[data-testid="block-drag-handle-trigger"]').first();
|
||||
await handle.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await handle.click({ timeout: UI_TIMEOUT_MS });
|
||||
const menu = page.locator('[data-testid="block-drag-menu"]').first();
|
||||
await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
return menu;
|
||||
}
|
||||
|
||||
function assertAiBridgePayload(payload, target) {
|
||||
assert.equal(payload.scope, "document", `AI 请求 scope 必须是 document: ${JSON.stringify(payload).slice(0, 1600)}`);
|
||||
assert.equal(payload.stream, true, "AI 请求必须开启 stream=true,避免退回 Hermes 非流式兼容路径");
|
||||
assert.equal(payload.options?.ai?.provider, "online", "AI 请求必须使用 provider=online,由 /api/ai-agent/run 分流到 agents sidecar");
|
||||
assert.equal(payload.context?.documentId, target.documentId, "AI 请求必须携带当前 documentId");
|
||||
assert.equal(payload.context?.workspaceId, target.workspaceId, "AI 请求必须携带当前 workspaceId");
|
||||
assert.equal(payload.context?.selectedBlockId, TARGET_BLOCK_ID, "AI 请求必须携带 Rust blockId");
|
||||
assert(Array.isArray(payload.context?.selectedUids) && payload.context.selectedUids.includes(TARGET_BLOCK_ID), "AI 请求 selectedUids 必须包含 Rust blockId");
|
||||
assert(payload.context?.selection?.currentBlockId === TARGET_BLOCK_ID, "AI 请求 selection 必须指向当前块");
|
||||
assert(payload.context?.tiptapDocument?.type === "doc", "AI 请求必须携带 tiptapDocument 快照");
|
||||
assert(JSON.stringify(payload.context.tiptapDocument).includes("E27 Ask AI source paragraph"), "AI 请求快照必须包含当前块正文");
|
||||
assert.equal(payload.context?.source, "leptos-tiptap-island", "AI 请求必须标记来源 island");
|
||||
assert.equal(payload.context?.action, "ask_ai", "块菜单 AI 入口必须使用 ask_ai action");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
assertAiSourceBoundary();
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
const page = await context.newPage();
|
||||
const aiBridgeRequests = [];
|
||||
|
||||
await page.route(/\/api\/(hermes\/bridge|ai-agent\/run)$/, async (route) => {
|
||||
const request = route.request();
|
||||
const body = request.postData() || "{}";
|
||||
let payload = null;
|
||||
try {
|
||||
payload = JSON.parse(body);
|
||||
} catch {
|
||||
payload = { raw: body };
|
||||
}
|
||||
aiBridgeRequests.push({ url: request.url(), payload });
|
||||
if (request.url().includes("/api/ai-agent/run")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-cache",
|
||||
},
|
||||
body: [
|
||||
'event: ready\n' + 'data: {"ok":true,"requestId":"task155-e27"}\n\n',
|
||||
'event: tool_result\n' + 'data: {"tool":"doc_replace_range","ok":true,"result":{"data":[{"id":"e27-ai-target","type":"paragraph","content":"E27 AI rewritten paragraph"}]}}\n\n',
|
||||
'event: completion\n' + 'data: {"ok":true,"text":"done","steps":1}\n\n',
|
||||
].join(""),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"x-mnote-ai-bridge-owner": "rust-web-hermes",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
bridge: "e27-smoke-hermes-bridge",
|
||||
canonicalRoute: "/api/hermes/bridge",
|
||||
contract: {
|
||||
schema: "mnote.ai_bridge.v1",
|
||||
structuredWriteOwner: "rust-web-hermes",
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
let target = null;
|
||||
try {
|
||||
target = await createTempDocument();
|
||||
const url = `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`;
|
||||
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
assert(response, "文档页没有返回响应");
|
||||
assert.equal(response.status(), 200, `文档页状态码异常: ${response.status()}`);
|
||||
|
||||
await waitForRuntimeIsland(page);
|
||||
await setAiFixture(page);
|
||||
const menu = await openBlockMenuForTarget(page);
|
||||
await screenshot(page, "01-block-menu-ai-entry");
|
||||
|
||||
await menu.locator('[data-testid="block-drag-menu-item-ai"]').first().click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(() => {
|
||||
const status = document.querySelector('[data-testid="mnote-leptos-tiptap-ai-status"]');
|
||||
return status instanceof HTMLElement && ["pending", "ready"].includes(status.getAttribute("data-state") || "");
|
||||
}, null, { timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await page.waitForFunction(() => window.__MNOTE_E27_AI_BRIDGE_REQUEST_COUNT__ > 0, null, { timeout: UI_TIMEOUT_MS });
|
||||
assert(aiBridgeRequests.length > 0, "点击 AI 入口后必须发起 /api/ai-agent/run 请求");
|
||||
const first = aiBridgeRequests[0];
|
||||
assert(first.url.includes("/api/ai-agent/run"), `AI 请求必须走 /api/ai-agent/run 在线主路径,实际: ${first.url}`);
|
||||
assertAiBridgePayload(first.payload, target);
|
||||
|
||||
await page.waitForFunction(() => {
|
||||
const status = document.querySelector('[data-testid="mnote-leptos-tiptap-ai-status"]');
|
||||
return status instanceof HTMLElement && status.getAttribute("data-state") === "ready";
|
||||
}, null, { timeout: UI_TIMEOUT_MS });
|
||||
await screenshot(page, "02-ai-bridge-ready-state");
|
||||
|
||||
console.log(JSON.stringify({ ok: true, baseUrl: BASE_URL, documentId: target.documentId, screenshotDir: SCREENSHOT_DIR, aiBridgeUrl: first.url }, null, 2));
|
||||
} finally {
|
||||
if (target) await purgeTempDocument(target).catch(() => undefined);
|
||||
await page.close().catch(() => undefined);
|
||||
await context.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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const { chromium } = require("playwright");
|
||||
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(/\/+$/, "");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const SCREENSHOT_DIR = process.env.MNOTE_PHASE_E_SCREENSHOT_DIR || "/mnt/Data1T/mnote/tmp/wolai-editor-parity/task156-e27-ai-writeback-local-smoke";
|
||||
const LEPTOS_SPIKE_SOURCE = "/mnt/Data1T/mnote/rust/spikes/leptos-tiptap-spike/src/lib.rs";
|
||||
const TARGET_BLOCK_ID = "e27-ai-target";
|
||||
const AI_REWRITTEN_TEXT = "E27 AI rewritten paragraph";
|
||||
|
||||
function assertAiSourceBoundary() {
|
||||
const spike = fs.readFileSync(LEPTOS_SPIKE_SOURCE, "utf8");
|
||||
assert(spike.includes("/api/ai-agent/run"), "E27 AI 入口必须调用 /api/ai-agent/run 在线主路径,Hermes 只能作为后端 fallback");
|
||||
assert(spike.includes('"provider": "online"'), "E27 AI 请求必须使用 provider=online 进入 openai-agents-python 主路径");
|
||||
assert(spike.includes('"stream": true'), "E27 AI 请求必须开启 stream=true,确保 /api/ai-agent/run 路由进入 agents sidecar");
|
||||
assert(spike.includes("mnote-leptos-tiptap-ai-status"), "E27 必须暴露 AI bridge 状态,供 smoke 和后续流式状态复用");
|
||||
assert(spike.includes("selectedBlockId"), "E27 AI 请求必须携带 Rust blockId 作为 selectedBlockId");
|
||||
assert(spike.includes("tiptapDocument"), "E27 AI 请求必须携带当前 Tiptap JSON 快照");
|
||||
}
|
||||
|
||||
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 postTreeCommand(body, label) {
|
||||
const response = await fetchWithTimeout(`${BASE_URL}/api/tree/commands`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const payload = await readJsonResponse(response, label);
|
||||
assert(payload?.result, `${label} 缺少 result`);
|
||||
return payload.result;
|
||||
}
|
||||
|
||||
async function createTempDocument() {
|
||||
const title = `task156-e27-ai-writeback-${Date.now().toString(36)}`;
|
||||
const result = await postTreeCommand({ action: "create", title }, "创建 E27 临时文档");
|
||||
assert(result.documentId, "创建 E27 临时文档缺少 documentId");
|
||||
assert(result.workspaceId, "创建 E27 临时文档缺少 workspaceId");
|
||||
return { documentId: result.documentId, workspaceId: result.workspaceId, title };
|
||||
}
|
||||
|
||||
async function purgeTempDocument(target) {
|
||||
if (!target?.documentId || !target?.workspaceId) return;
|
||||
await postTreeCommand({ action: "purge", workspaceId: target.workspaceId, documentId: target.documentId }, "清理 E27 临时文档");
|
||||
}
|
||||
|
||||
async function loadDocumentContent(target, label) {
|
||||
const url = `${BASE_URL}/api/documents/content?documentId=${encodeURIComponent(target.documentId)}&workspaceId=${encodeURIComponent(target.workspaceId)}`;
|
||||
const response = await fetchWithTimeout(url, { method: "GET" });
|
||||
return readJsonResponse(response, label);
|
||||
}
|
||||
|
||||
function rawIncludes(value, text) {
|
||||
return JSON.stringify(value ?? null).includes(text);
|
||||
}
|
||||
|
||||
async function waitForRuntimeIsland(page) {
|
||||
const root = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first();
|
||||
await root.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const editor = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]').first();
|
||||
await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(() => {
|
||||
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
const editorNode = host?.querySelector('.editor-surface .ProseMirror[contenteditable="true"]');
|
||||
return host?.getAttribute("data-runtime-editor-status") !== "error" && editorNode instanceof HTMLElement && editorNode.isContentEditable;
|
||||
}, null, { timeout: UI_TIMEOUT_MS });
|
||||
return editor;
|
||||
}
|
||||
|
||||
async function screenshot(page, name) {
|
||||
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/${name}.png`, fullPage: true });
|
||||
}
|
||||
|
||||
async function setAiFixture(page) {
|
||||
await page.evaluate((targetBlockId) => {
|
||||
const editor = document.querySelector('.editor-surface .ProseMirror')?.editor;
|
||||
if (!editor) throw new Error('找不到 Tiptap editor');
|
||||
editor.commands.setContent({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
attrs: { blockId: targetBlockId },
|
||||
content: [{ type: 'text', text: 'E27 Ask AI source paragraph' }],
|
||||
},
|
||||
],
|
||||
}, true);
|
||||
editor.commands.focus('start');
|
||||
}, TARGET_BLOCK_ID);
|
||||
await page.waitForFunction((targetBlockId) => {
|
||||
return document.querySelector(`[data-block-id="${targetBlockId}"]`) instanceof HTMLElement;
|
||||
}, TARGET_BLOCK_ID, { timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function openBlockMenuForTarget(page) {
|
||||
const target = page.locator(`[data-block-id="${TARGET_BLOCK_ID}"]`).first();
|
||||
await target.scrollIntoViewIfNeeded({ timeout: UI_TIMEOUT_MS });
|
||||
await target.hover({ timeout: UI_TIMEOUT_MS });
|
||||
const handle = page.locator('[data-testid="block-drag-handle-trigger"]').first();
|
||||
await handle.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await handle.click({ timeout: UI_TIMEOUT_MS });
|
||||
const menu = page.locator('[data-testid="block-drag-menu"]').first();
|
||||
await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
return menu;
|
||||
}
|
||||
|
||||
function assertAiBridgePayload(payload, target) {
|
||||
assert.equal(payload.scope, "document", `AI 请求 scope 必须是 document: ${JSON.stringify(payload).slice(0, 1600)}`);
|
||||
assert.equal(payload.stream, true, "AI 请求必须开启 stream=true,避免退回 Hermes 非流式兼容路径");
|
||||
assert.equal(payload.options?.ai?.provider, "online", "AI 请求必须使用 provider=online,由 /api/ai-agent/run 分流到 agents sidecar");
|
||||
assert.equal(payload.context?.documentId, target.documentId, "AI 请求必须携带当前 documentId");
|
||||
assert.equal(payload.context?.workspaceId, target.workspaceId, "AI 请求必须携带当前 workspaceId");
|
||||
assert.equal(payload.context?.selectedBlockId, TARGET_BLOCK_ID, "AI 请求必须携带 Rust blockId");
|
||||
assert(Array.isArray(payload.context?.selectedUids) && payload.context.selectedUids.includes(TARGET_BLOCK_ID), "AI 请求 selectedUids 必须包含 Rust blockId");
|
||||
assert(payload.context?.selection?.currentBlockId === TARGET_BLOCK_ID, "AI 请求 selection 必须指向当前块");
|
||||
assert(payload.context?.tiptapDocument?.type === "doc", "AI 请求必须携带 tiptapDocument 快照");
|
||||
assert(JSON.stringify(payload.context.tiptapDocument).includes("E27 Ask AI source paragraph"), "AI 请求快照必须包含当前块正文");
|
||||
assert.equal(payload.context?.source, "leptos-tiptap-island", "AI 请求必须标记来源 island");
|
||||
assert.equal(payload.context?.action, "ask_ai", "块菜单 AI 入口必须使用 ask_ai action");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
assertAiSourceBoundary();
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
const page = await context.newPage();
|
||||
const aiBridgeRequests = [];
|
||||
const saveRequests = [];
|
||||
await page.addInitScript(() => {
|
||||
window.__MNOTE_E27_SAVE_REQUESTS__ = [];
|
||||
});
|
||||
page.on("request", async (request) => {
|
||||
if (!request.url().includes("/api/documents/save")) return;
|
||||
const body = request.postData();
|
||||
if (!body) return;
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(body);
|
||||
} catch {
|
||||
payload = { raw: body };
|
||||
}
|
||||
saveRequests.push(payload);
|
||||
await page.evaluate((item) => {
|
||||
window.__MNOTE_E27_SAVE_REQUESTS__ = Array.isArray(window.__MNOTE_E27_SAVE_REQUESTS__) ? window.__MNOTE_E27_SAVE_REQUESTS__ : [];
|
||||
window.__MNOTE_E27_SAVE_REQUESTS__.push(item);
|
||||
}, payload).catch(() => undefined);
|
||||
});
|
||||
|
||||
await page.route(/\/api\/(hermes\/bridge|ai-agent\/run)$/, async (route) => {
|
||||
const request = route.request();
|
||||
const body = request.postData() || "{}";
|
||||
let payload = null;
|
||||
try {
|
||||
payload = JSON.parse(body);
|
||||
} catch {
|
||||
payload = { raw: body };
|
||||
}
|
||||
aiBridgeRequests.push({ url: request.url(), payload });
|
||||
if (request.url().includes("/api/ai-agent/run")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-cache",
|
||||
},
|
||||
body: [
|
||||
'event: ready\n' + 'data: {"ok":true,"requestId":"task155-e27"}\n\n',
|
||||
'event: tool_result\n' + `data: {"tool":"doc_replace_range","ok":true,"result":{"data":[{"id":"${TARGET_BLOCK_ID}","type":"paragraph","content":"${AI_REWRITTEN_TEXT}"}]}}\n\n`,
|
||||
'event: completion\n' + 'data: {"ok":true,"text":"done","steps":1}\n\n',
|
||||
].join(""),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"x-mnote-ai-bridge-owner": "rust-web-hermes",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
bridge: "e27-smoke-hermes-bridge",
|
||||
canonicalRoute: "/api/hermes/bridge",
|
||||
contract: {
|
||||
schema: "mnote.ai_bridge.v1",
|
||||
structuredWriteOwner: "rust-web-hermes",
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
let target = null;
|
||||
try {
|
||||
target = await createTempDocument();
|
||||
const url = `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`;
|
||||
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
assert(response, "文档页没有返回响应");
|
||||
assert.equal(response.status(), 200, `文档页状态码异常: ${response.status()}`);
|
||||
|
||||
await waitForRuntimeIsland(page);
|
||||
await setAiFixture(page);
|
||||
const menu = await openBlockMenuForTarget(page);
|
||||
await screenshot(page, "01-block-menu-ai-entry");
|
||||
|
||||
await menu.locator('[data-testid="block-drag-menu-item-ai"]').first().click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(() => {
|
||||
const status = document.querySelector('[data-testid="mnote-leptos-tiptap-ai-status"]');
|
||||
return status instanceof HTMLElement && ["pending", "ready"].includes(status.getAttribute("data-state") || "");
|
||||
}, null, { timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await page.waitForFunction(() => window.__MNOTE_E27_AI_BRIDGE_REQUEST_COUNT__ > 0, null, { timeout: UI_TIMEOUT_MS });
|
||||
assert(aiBridgeRequests.length > 0, "点击 AI 入口后必须发起 /api/ai-agent/run 请求");
|
||||
const first = aiBridgeRequests[0];
|
||||
assert(first.url.includes("/api/ai-agent/run"), `AI 请求必须走 /api/ai-agent/run 在线主路径,实际: ${first.url}`);
|
||||
assertAiBridgePayload(first.payload, target);
|
||||
|
||||
await page.waitForFunction(() => {
|
||||
const status = document.querySelector('[data-testid="mnote-leptos-tiptap-ai-status"]');
|
||||
return status instanceof HTMLElement && status.getAttribute("data-state") === "ready";
|
||||
}, null, { timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction((expectedText) => {
|
||||
const editor = document.querySelector('.editor-surface .ProseMirror');
|
||||
return editor instanceof HTMLElement && editor.innerText.includes(expectedText);
|
||||
}, AI_REWRITTEN_TEXT, { timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction((expectedText) => {
|
||||
return Array.isArray(window.__MNOTE_E27_SAVE_REQUESTS__)
|
||||
&& window.__MNOTE_E27_SAVE_REQUESTS__.some((request) => JSON.stringify(request ?? null).includes(expectedText));
|
||||
}, AI_REWRITTEN_TEXT, { timeout: UI_TIMEOUT_MS });
|
||||
await screenshot(page, "02-ai-writeback-editor-saved");
|
||||
|
||||
const saveHit = saveRequests.some((request) => rawIncludes(request, AI_REWRITTEN_TEXT));
|
||||
assert(saveHit, `AI 写入必须触发 /api/documents/save 且保存 payload 包含改写正文: ${JSON.stringify(saveRequests.slice(-4)).slice(0, 2400)}`);
|
||||
|
||||
const contentAfterWrite = await loadDocumentContent(target, "读取 E27 AI 写入后的正文");
|
||||
assert(rawIncludes(contentAfterWrite, AI_REWRITTEN_TEXT), `/api/documents/content 必须能读回 AI 写入正文: ${JSON.stringify(contentAfterWrite).slice(0, 2400)}`);
|
||||
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await waitForRuntimeIsland(page);
|
||||
await page.waitForFunction((expectedText) => {
|
||||
const editor = document.querySelector('.editor-surface .ProseMirror');
|
||||
return editor instanceof HTMLElement && editor.innerText.includes(expectedText);
|
||||
}, AI_REWRITTEN_TEXT, { timeout: UI_TIMEOUT_MS });
|
||||
await screenshot(page, "03-ai-writeback-reload-readback");
|
||||
|
||||
console.log(JSON.stringify({ ok: true, baseUrl: BASE_URL, documentId: target.documentId, screenshotDir: SCREENSHOT_DIR, aiBridgeUrl: first.url, wroteText: AI_REWRITTEN_TEXT }, null, 2));
|
||||
} finally {
|
||||
if (target) await purgeTempDocument(target).catch(() => undefined);
|
||||
await page.close().catch(() => undefined);
|
||||
await context.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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const { chromium } = require("playwright");
|
||||
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(/\/+$/, "");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const SCREENSHOT_DIR = process.env.MNOTE_PHASE_E_SCREENSHOT_DIR || "/mnt/Data1T/mnote/tmp/wolai-editor-parity/task158-e30-menu-state-local-smoke";
|
||||
const LEPTOS_SPIKE_SOURCE = "/mnt/Data1T/mnote/rust/spikes/leptos-tiptap-spike/src/lib.rs";
|
||||
const IMAGE_SRC = "/api/editor/image-placeholder.svg";
|
||||
const TARGET_BLOCK_ID = "e30-menu-state-target";
|
||||
|
||||
function assertMenuStateSourceBoundary() {
|
||||
const source = fs.readFileSync(LEPTOS_SPIKE_SOURCE, "utf8");
|
||||
assert(
|
||||
source.includes("close_editor_floating_overlays"),
|
||||
"E30 必须把菜单/浮层关闭逻辑收口到 close_editor_floating_overlays,而不是每个菜单散写 set(false)",
|
||||
);
|
||||
assert(
|
||||
source.includes("close_editor_floating_overlays_if_escape"),
|
||||
"E30 Escape 必须通过统一函数关闭 slash/block/toolbar/image/table 浮层",
|
||||
);
|
||||
assert(
|
||||
source.includes("open_slash_menu_overlay"),
|
||||
"E30 slash 打开必须走统一互斥入口,避免和其他浮层叠开",
|
||||
);
|
||||
assert(
|
||||
source.includes("open_image_toolbar_overlay"),
|
||||
"E30 image toolbar 打开必须走统一互斥入口",
|
||||
);
|
||||
assert(
|
||||
source.includes("open_block_menu_overlay"),
|
||||
"E30 block menu 打开必须走统一互斥入口",
|
||||
);
|
||||
}
|
||||
|
||||
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 postTreeCommand(body, label) {
|
||||
const response = await fetchWithTimeout(`${BASE_URL}/api/tree/commands`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const payload = await readJsonResponse(response, label);
|
||||
assert(payload?.result, `${label} 缺少 result`);
|
||||
return payload.result;
|
||||
}
|
||||
|
||||
async function createTempDocument() {
|
||||
const title = `task158-e30-menu-state-${Date.now().toString(36)}`;
|
||||
const result = await postTreeCommand({ action: "create", title }, "创建 E30 临时文档");
|
||||
assert(result.documentId, "创建 E30 临时文档缺少 documentId");
|
||||
assert(result.workspaceId, "创建 E30 临时文档缺少 workspaceId");
|
||||
return { documentId: result.documentId, workspaceId: result.workspaceId, title };
|
||||
}
|
||||
|
||||
async function purgeTempDocument(target) {
|
||||
if (!target?.documentId || !target?.workspaceId) return;
|
||||
await postTreeCommand({ action: "purge", workspaceId: target.workspaceId, documentId: target.documentId }, "清理 E30 临时文档");
|
||||
}
|
||||
|
||||
async function waitForRuntimeIsland(page) {
|
||||
const root = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first();
|
||||
await root.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const editor = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]').first();
|
||||
await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(() => {
|
||||
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
const editorNode = host?.querySelector('.editor-surface .ProseMirror[contenteditable="true"]');
|
||||
return host?.getAttribute("data-runtime-editor-status") !== "error" && editorNode instanceof HTMLElement && editorNode.isContentEditable;
|
||||
}, null, { timeout: UI_TIMEOUT_MS });
|
||||
return editor;
|
||||
}
|
||||
|
||||
async function screenshot(page, name) {
|
||||
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/${name}.png`, fullPage: true });
|
||||
}
|
||||
|
||||
async function setMenuStateFixture(page) {
|
||||
await page.evaluate(({ targetBlockId, imageSrc }) => {
|
||||
const editor = document.querySelector('.editor-surface .ProseMirror')?.editor;
|
||||
if (!editor) throw new Error('找不到 Tiptap editor');
|
||||
editor.commands.setContent({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
attrs: { blockId: targetBlockId },
|
||||
content: [{ type: 'text', text: 'E30 menu state target paragraph' }],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
attrs: { blockId: 'e30-selection-target' },
|
||||
content: [{ type: 'text', text: 'E30 selection toolbar target text' }],
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
attrs: { src: imageSrc, alt: 'E30 图片占位', title: 'E30 图片', 'data-align': 'left' },
|
||||
},
|
||||
{
|
||||
type: 'table',
|
||||
content: [
|
||||
{
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{ type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'A1' }] }] },
|
||||
{ type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'B1' }] }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{ type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'A2' }] }] },
|
||||
{ type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'B2' }] }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}, true);
|
||||
editor.commands.focus('start');
|
||||
}, { targetBlockId: TARGET_BLOCK_ID, imageSrc: IMAGE_SRC });
|
||||
await page.waitForFunction(({ targetBlockId, imageSrc }) => {
|
||||
return document.querySelector(`[data-block-id="${targetBlockId}"]`) instanceof HTMLElement
|
||||
&& document.querySelector(`.editor-surface .ProseMirror img[src="${imageSrc}"]`) instanceof HTMLImageElement
|
||||
&& document.querySelector('.editor-surface .ProseMirror table') instanceof HTMLTableElement;
|
||||
}, { targetBlockId: TARGET_BLOCK_ID, imageSrc: IMAGE_SRC }, { timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function openBlockMenuForTarget(page) {
|
||||
const target = page.locator(`[data-block-id="${TARGET_BLOCK_ID}"]`).first();
|
||||
await target.scrollIntoViewIfNeeded({ timeout: UI_TIMEOUT_MS });
|
||||
const targetBox = await target.boundingBox();
|
||||
assert(targetBox, "E30 目标块缺少可 hover 区域");
|
||||
await page.mouse.move(targetBox.x + 8, targetBox.y + Math.min(10, Math.max(4, targetBox.height / 2)));
|
||||
const handle = page.locator('[data-testid="block-drag-handle-trigger"]').first();
|
||||
await handle.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await handle.click({ timeout: UI_TIMEOUT_MS });
|
||||
const menu = page.locator('[data-testid="block-drag-menu"]').first();
|
||||
await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
return menu;
|
||||
}
|
||||
|
||||
async function openSelectionToolbar(page) {
|
||||
await page.evaluate(() => {
|
||||
const textNode = Array.from(document.querySelectorAll('.editor-surface .ProseMirror p'))
|
||||
.find((node) => (node.textContent || '').includes('E30 selection toolbar target text'))
|
||||
?.firstChild;
|
||||
if (!textNode) throw new Error('找不到 E30 选区文本节点');
|
||||
const range = document.createRange();
|
||||
range.setStart(textNode, 0);
|
||||
range.setEnd(textNode, Math.min(12, textNode.textContent.length));
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
});
|
||||
await page.mouse.up();
|
||||
const toolbar = page.locator('[data-testid="mnote-leptos-tiptap-toolbar"]').first();
|
||||
await toolbar.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
return toolbar;
|
||||
}
|
||||
|
||||
async function assertNoFloatingOverlay(page, label) {
|
||||
const state = await page.evaluate(() => ({
|
||||
slash: Boolean(document.querySelector('[data-testid="mnote-leptos-tiptap-slash-menu"]')),
|
||||
block: Boolean(document.querySelector('[data-testid="block-drag-menu"]')),
|
||||
toolbar: Boolean(document.querySelector('[data-testid="mnote-leptos-tiptap-toolbar"]')),
|
||||
turnInto: Boolean(document.querySelector('[data-testid="turn-into-panel"]')) && getComputedStyle(document.querySelector('[data-testid="turn-into-panel"]')).display !== 'none',
|
||||
color: Boolean(document.querySelector('[data-testid="toolbar-color-panel"]')) && getComputedStyle(document.querySelector('[data-testid="toolbar-color-panel"]')).display !== 'none',
|
||||
more: Boolean(document.querySelector('[data-testid="toolbar-more-panel"]')) && getComputedStyle(document.querySelector('[data-testid="toolbar-more-panel"]')).display !== 'none',
|
||||
image: Boolean(document.querySelector('[data-testid="image-floating-toolbar"]')),
|
||||
tableToolbar: Boolean(document.querySelector('[data-testid="mnote-leptos-tiptap-table-toolbar"]')),
|
||||
tableOptions: Boolean(document.querySelector('[data-testid="table-toolbar-options-menu"]')),
|
||||
}));
|
||||
assert.deepEqual(state, {
|
||||
slash: false,
|
||||
block: false,
|
||||
toolbar: false,
|
||||
turnInto: false,
|
||||
color: false,
|
||||
more: false,
|
||||
image: false,
|
||||
tableToolbar: false,
|
||||
tableOptions: false,
|
||||
}, `${label} 后仍有浮层残留: ${JSON.stringify(state)}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
assertMenuStateSourceBoundary();
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
let target = null;
|
||||
try {
|
||||
target = await createTempDocument();
|
||||
const url = `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`;
|
||||
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
assert(response, "文档页没有返回响应");
|
||||
assert.equal(response.status(), 200, `文档页状态码异常: ${response.status()}`);
|
||||
|
||||
const editor = await waitForRuntimeIsland(page);
|
||||
await setMenuStateFixture(page);
|
||||
|
||||
await editor.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.type("/");
|
||||
await page.locator('[data-testid="mnote-leptos-tiptap-slash-menu"]').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.press("ArrowDown");
|
||||
const selectedAfterArrow = await page.locator('[data-testid="mnote-leptos-tiptap-slash-menu"] .slash-item[data-active="true"]').first().innerText();
|
||||
assert(selectedAfterArrow.trim().length > 0, "slash menu 方向键后必须有 active 项");
|
||||
await screenshot(page, "01-slash-arrow-active");
|
||||
await page.keyboard.press("Escape");
|
||||
await assertNoFloatingOverlay(page, "Slash Escape");
|
||||
|
||||
await openBlockMenuForTarget(page);
|
||||
await page.locator('[data-testid="block-drag-menu-item-turn-into"]').first().hover({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="block-turn-into-menu"], [data-e30-testid="block-turn-into-menu"]').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await screenshot(page, "02-block-submenu-open");
|
||||
await page.keyboard.press("Escape");
|
||||
await assertNoFloatingOverlay(page, "Block menu Escape");
|
||||
|
||||
const toolbar = await openSelectionToolbar(page);
|
||||
await toolbar.locator('[data-testid="toolbar-color"]').first().click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="toolbar-color-panel"]').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await screenshot(page, "03-color-panel-open");
|
||||
await page.keyboard.press("Escape");
|
||||
await assertNoFloatingOverlay(page, "Selection color Escape");
|
||||
|
||||
const image = page.locator('.editor-surface .ProseMirror img[src]').first();
|
||||
await image.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="image-floating-toolbar"]').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await screenshot(page, "04-image-toolbar-open");
|
||||
await page.keyboard.press("Escape");
|
||||
await assertNoFloatingOverlay(page, "Image toolbar Escape");
|
||||
|
||||
await page.locator('.editor-surface .ProseMirror table td, .editor-surface .ProseMirror table th').first().click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-leptos-tiptap-table-toolbar"]').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="table-toolbar-options"]').first().click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="table-toolbar-options-menu"]').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await screenshot(page, "05-table-options-open");
|
||||
await page.keyboard.press("Escape");
|
||||
await assertNoFloatingOverlay(page, "Table options Escape");
|
||||
|
||||
await openSelectionToolbar(page);
|
||||
await page.locator('[data-testid="mnote-leptos-tiptap-toolbar"]').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await editor.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.type("/");
|
||||
await page.locator('[data-testid="mnote-leptos-tiptap-slash-menu"]').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
assert.equal(await page.locator('[data-testid="mnote-leptos-tiptap-toolbar"]').count(), 0, "打开 slash 时 selection toolbar 必须关闭");
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await image.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="image-floating-toolbar"]').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await openBlockMenuForTarget(page);
|
||||
assert.equal(await page.locator('[data-testid="image-floating-toolbar"]').count(), 0, "打开块菜单时 image toolbar 必须关闭");
|
||||
await screenshot(page, "06-block-menu-after-image-mutual-exclusion");
|
||||
|
||||
console.log(JSON.stringify({ ok: true, baseUrl: BASE_URL, documentId: target.documentId, screenshotDir: SCREENSHOT_DIR }, null, 2));
|
||||
} finally {
|
||||
if (target) await purgeTempDocument(target).catch(() => undefined);
|
||||
await page.close().catch(() => undefined);
|
||||
await context.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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/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");
|
||||
|
||||
function buildFixtureEnv(port) {
|
||||
return {
|
||||
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_LEGACY_NEXT_BASE_URL: "http://127.0.0.1:3100",
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "1",
|
||||
MNOTE_WEB_QUERY_FIXTURES_JSON: JSON.stringify({
|
||||
"documents:getMeta": {
|
||||
id: "doc_auth",
|
||||
workspace_id: "ws_demo",
|
||||
title: "认证验收页面",
|
||||
updated_at: "2026-05-04T00:00:00Z",
|
||||
can_edit: true,
|
||||
word_count: 3,
|
||||
character_count: 12,
|
||||
block_count: 1,
|
||||
},
|
||||
"documents:getContent": {
|
||||
content: [{ id: "block_auth_1", type: "paragraph", content: [] }],
|
||||
revision: 1,
|
||||
conflict_detection_key: "doc_auth:1",
|
||||
pageSubtree: { rootNodeId: "doc_auth", outline: [] },
|
||||
},
|
||||
}),
|
||||
MNOTE_WEB_MUTATION_FIXTURES_JSON: JSON.stringify({
|
||||
"workspaces:ensureDefaultWorkspace": {
|
||||
workspaces: [{ id: "ws_demo", name: "我的空间", type: "personal", memberCount: 1, isDefault: true }],
|
||||
activeWorkspaceId: "ws_demo",
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function startGateway(port) {
|
||||
return spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], {
|
||||
cwd: "/mnt/Data1T/mnote/rust",
|
||||
env: { ...process.env, ...buildFixtureEnv(port) },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
async function validateAuthEntry(baseUrl) {
|
||||
const root = await fetchWithTimeout(`${baseUrl}/`);
|
||||
const rootText = await root.text();
|
||||
assert.equal(root.status, 303, `/ 未登录应跳转 /auth: ${root.status} ${rootText.slice(0, 160)}`);
|
||||
assert.equal(root.headers.get("location"), "/auth");
|
||||
assert.equal(root.headers.get("x-mnote-web-owner"), "mnote-web");
|
||||
|
||||
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.equal(auth.headers.get("x-mnote-legacy-upstream"), null, "/auth 不应代理到 3100");
|
||||
assert.match(authText, /data-mnote-shell="auth"/);
|
||||
assert.match(authText, /data-testid="mnote-auth-page"/);
|
||||
assert.match(authText, /邮箱登录/);
|
||||
assert.match(authText, /name="email"[^>]*type="email"|type="email"[^>]*name="email"/);
|
||||
assert.match(authText, /name="password"[^>]*type="password"|type="password"[^>]*name="password"/);
|
||||
assert.match(authText, /data-auth-mode="convex-password"/);
|
||||
assert.match(authText, /没有账号?注册/);
|
||||
assert.match(authText, /测试账号快速登录/);
|
||||
assert.doesNotMatch(authText, /隐私政策|使用\s*Google|使用\s*GitHub|第三方快捷/iu);
|
||||
|
||||
const authedAuth = await fetchWithTimeout(`${baseUrl}/auth`, {
|
||||
headers: {
|
||||
"x-mnote-actor-id": "user_real",
|
||||
"x-mnote-actor-type": "user",
|
||||
},
|
||||
});
|
||||
assert.equal(authedAuth.status, 303, `/auth 已登录应跳转 /: ${authedAuth.status}`);
|
||||
assert.equal(authedAuth.headers.get("location"), "/");
|
||||
|
||||
const authedRoot = await fetchWithTimeout(`${baseUrl}/`, {
|
||||
headers: {
|
||||
"x-mnote-actor-id": "user_real",
|
||||
"x-mnote-actor-type": "user",
|
||||
},
|
||||
});
|
||||
const authedRootText = await authedRoot.text();
|
||||
assert.equal(authedRoot.status, 200, `/ 已登录根入口失败: ${authedRoot.status}`);
|
||||
assert.match(authedRootText, /data-mnote-shell="workspace"/);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const external = (process.env.MNOTE_UI_BASE_URL || "").replace(/\/+$/, "");
|
||||
let gateway = null;
|
||||
let baseUrl = external;
|
||||
|
||||
if (!baseUrl) {
|
||||
const port = await findFreePort();
|
||||
baseUrl = `http://127.0.0.1:${port}`;
|
||||
gateway = startGateway(port);
|
||||
await waitForGateway(baseUrl);
|
||||
}
|
||||
|
||||
let stderr = "";
|
||||
if (gateway) {
|
||||
gateway.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await validateAuthEntry(baseUrl);
|
||||
console.log(JSON.stringify({ ok: true, baseUrl, task: "task159-auth-entry" }, null, 2));
|
||||
} finally {
|
||||
if (gateway) {
|
||||
gateway.kill("SIGTERM");
|
||||
setTimeout(() => gateway.kill("SIGKILL"), 2_000).unref();
|
||||
if (stderr.trim()) {
|
||||
process.stderr.write(stderr.slice(-2000));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -10,9 +10,9 @@ 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);
|
||||
const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录";
|
||||
const TEST_USERNAME_PREFIX = "测试用户";
|
||||
const TEST_EMAIL = "test@example.com";
|
||||
const TEST_PASSWORD = "Test123456";
|
||||
const TEST_USERNAME_PREFIX = "mnote-e2e-";
|
||||
const TEST_EMAIL = "mnote.e2e@example.com";
|
||||
const TEST_PASSWORD = "MnoteE2E123!";
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
|
||||
Reference in New Issue
Block a user