feat: consolidate local-first mnote web runtime

This commit is contained in:
lix-2026
2026-05-28 22:01:44 +08:00
parent 7354807ee9
commit 39b9a0183a
154 changed files with 13591 additions and 12728 deletions
+6 -23
View File
@@ -8,8 +8,8 @@
-`mnote` 来说,最有价值的不是把所有测试都换成“浏览器插件式控制”,而是把已有 smoke 的结果变得更可看、更易复核。
- `/doko` 适合作为 smoke 之后的“真实页面直观复核层”,不是用来替代 smoke。
- 2026-05-20 起,默认主入口固定按 `http://127.0.0.1:3000` 理解;历史记录里的 `3001` 只代表当时临时 mnote-web 实例,不再作为默认验收入口。
- 历史 smoke 分三类维护:`current` 可直接作为当前主链证据,`compat` 只验证 Convex/control-plane/兼容链,`retired/debug` 不应进入默认回归,除非脚本自身要求显式环境变量。
- 2026-05-26 起,默认 smoke 基线只覆盖 `3000 Rust SSR + leptos-tiptap + local-first` 主路径;Convex export、Next、3104、BlockNote 默认路径脚本不再放在默认候选里。
- 历史 smoke 分`current` `retired/debug` 两类维护;退役脚本不应进入默认回归,除非脚本自身要求显式环境变量。
- 2026-05-27 起,默认 smoke 基线只覆盖 `3000 Rust SSR + leptos-tiptap + local-first` 主路径;Convex export、Convex 兼容、Next、3104、BlockNote 默认路径脚本不再放在默认候选里。
## 0. 当前 smoke 状态清单
@@ -36,37 +36,21 @@ node scripts/task490-runtime-surfaces-smoke.js
- Rust SSR 文档页 / Page Aggregatelocal-first 默认优先 `task167-local-markdown-title-body-options-no-convex-smoke.js`cloud/control-plane 文档可补跑 `task110-page-title-single-truth-smoke.js``task-page-aggregate-body-sync-smoke.js``task-page-aggregate-options-sync-smoke.js``task-page-aggregate-refresh-persistence-smoke.js`
- leptos-tiptap runtime surface`task490-runtime-surfaces-smoke.js`;需要验证保存回读可补跑 `task121-rust-web-editor-island-hydration-smoke.js`,但它仍使用 `/api/tree/commands create` 准备文档,不作为无 Convex 默认基线;需要验证浮层互斥和更多菜单状态时再跑 `task158-e30-menu-state-smoke.js`
- local-first 本地工作区:`task164-desktop-hot-local-folder-main-entry-smoke.js``task166-local-first-managed-workspace-no-convex-smoke.js``task167-local-markdown-title-body-options-no-convex-smoke.js``task436-local-markdown-open-document-external-change-smoke.js``task443-local-markdown-asset-upload-smoke.js``task451-local-markdown-conflict-resolution-ui-smoke.js``task452-local-search-index-browser-smoke.js``task453-local-folder-page-ai-changed-files-smoke.js`
- local Markdown conflict regression`task510-local-markdown-conflict-regression-group.js` 串联真实冲突、连续上传、新建页面、外部恢复、多 tab CAS 与上传 409 孤儿策略;可用 `MNOTE_CONFLICT_REGRESSION_TASKS=a.js,b.js` 做定点子集。
- tree realtime / live cache`task446-tree-rename-dual-browser-live-smoke.js``task447-tree-move-order-dual-browser-live-smoke.js``task448-tree-resync-recovery-dual-browser-smoke.js``task449-tree-sse-reconnect-snapshot-recovery-smoke.js`
- 资源对象与 mindmap`task455-local-folder-mindmap-clean-smoke.js``task456-resource-object-shell-sync-smoke.js``task166-mindmap-phase6-block-smoke.js``task167-mindmap-kmind-parity-smoke.js``task168-mindmap-put-validator-smoke.js`
- local resource lifecycle API`task437-local-folder-asset-trash-lifecycle-smoke.js`,现在只验证 `local-file:*` delete / restore / purge,不再依赖普通 `.txt` 是否出现在 UI 文件树。
### 0.2 兼容/control-plane 脚本
这些脚本仍可用于 Convex/control-plane 或 cloud-source 兼容链,但不能单独证明 local-first 主链正确:
- `task427-trash-empty-isolated-workspace-smoke.js`
- `task428-filetree-bulk-delete-selection-smoke.js`
- `task429-trash-restore-location-reveal-smoke.js`
- `task430-vscode-explorer-stage7-smoke.js`
- `task431-vscode-explorer-dnd-readonly-conflict-smoke.js`
- `task432-filetree-trash-page-dual-browser-no-refresh-smoke.js`
- `task433-filetree-trash-file-asset-dual-browser-no-refresh-smoke.js`
- `task434-filetree-trash-mindmap-table-dual-browser-no-refresh-smoke.js`
- `task174-rust-onlyoffice-attachment-open-smoke.js`
- `task175-rust-upload-entry-smoke.js`
注意:上述 427 / 428 / 429 / 430 / 431 / 432 / 433 / 434 脚本默认入口已改回 `3000`;如需对临时端口验证,显式设置 `MNOTE_UI_BASE_URL``task174` / `task175` 仍验证 Convex media / OnlyOffice cloud upload 兼容入口,不属于 local-first 默认上传链。
### 0.3 需要退役或拆分的历史脚本
### 0.2 需要退役或拆分的历史脚本
- `task444-convex-workspace-export-local-fixture-smoke.js``task455-convex-export-plan-rollback-smoke.js``export-convex-workspace-to-local.js`Convex export 迁移验证已退出默认 smoke,已归档到 `recycle/scripts/retired-convex-export-smokes-20260526/`
- 注意编号复用:`scripts/task444-filetree-create-mindmap-title-cache-smoke.js``scripts/task455-local-folder-mindmap-clean-smoke.js` 仍是当前本地 filetree / mindmap 脚本;退役的是带 `convex-export` 名称的 task444 / task455。
- 旧 Convex 兼容 smoke 已软删除到 `recycle/20260527-legacy-cleanup/convex/scripts/`,包括 `task163``task174``task175``task427``task428``task429``task430``task431``task432``task433``task434`
- `task179-tree-create-delete-no-reload-smoke.js`:覆盖 `/tree` debug shell。`/tree` 已是显式 debug/internal 边界,脚本默认阻断;只有设置 `MNOTE_ALLOW_DEBUG_TREE_SMOKE=1` 时才可执行。
- `task163-local-folder-unified-tree-browser-smoke.js`:历史“大一统”脚本,同时混合 local folder、Convex、拖拽、复制粘贴、watcher 和文件树行为。后续不要作为默认回归入口;优先用 164 / 166 / 436 / 441 / 443 / 451 / 452 / 455 这些聚焦脚本替代。
- `task123-rust-web-tree-live-stream-consumer-smoke.js`:只证明 `/api/tree/events` SSE fallback 仍可用。当前 realtime 主链是 WebSocket push + SSE fallback;验证主链时优先使用 446 / 447 / 448,只有排查 fallback 时再跑 123。
-`task019``task021``task022` 这类早期 UI regression 脚本已软删除到 `recycle/scripts/retired-ui-regressions/`,只作为历史对照;后续默认不要用于当前 Rust SSR / local-first 主路径验收。
### 0.4 其他 current 候选脚本
### 0.3 其他 current 候选脚本
这些脚本不是最小默认基线,但仍属于当前 Rust SSR / leptos-tiptap / local-first 维护面,按改动范围选择性运行:
@@ -93,7 +77,6 @@ node scripts/task490-runtime-surfaces-smoke.js
- `task097-homepage-entry-smoke.js`
- `task114-rust-web-gateway-entry-smoke.js`
- `task117-next-retirement-guard.js`
- `task159-auth-entry-smoke.js`
适用场景:
-449
View File
@@ -1,449 +0,0 @@
#!/usr/bin/env node
"use strict";
const { execFileSync } = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");
const WAIVER = "local-first-allow-convex-main-storage";
const MIGRATION_PERIOD_WAIVER = "sqlite-migration-waiver";
const RUNTIME_FORBIDDEN = [
{
name: "未标注的 Convex documents 直连",
pattern: /documents:(?:createWithParentReference|updateTitle|move|updateContent|getMeta|getContent|purge)\b/,
},
{
name: "未标注的 Convex media 直连",
pattern: /mediaAssets:(?:generateUploadUrl|createWithStorage|getById|refreshUrl|patchById|emptyTrashByWorkspace|purgeById|listByIds)\b/,
},
{
name: "未标注的 Convex AI session 直连",
pattern:
/aiSessions:(?:upsertRuntimeRun|appendRuntimeEvent|getRuntimeRun|listRuntimeRuns|listRuntimeEvents|renameRuntimeSession|autoTitleRuntimeSession|deleteRuntimeSession|searchRuntimeSessions)\b/,
},
{
name: "Convex media URL 主路径",
pattern: /\/api\/media\/(?:upload|sign|batch)|assetId=/,
},
];
const DESIGN_DEFAULT_CONVEX = /(?:Convex|convex).*(?:默认|default).*(?:主存储|主数据层|数据真相|页面正文真相|文件树真相|附件|AI 会话|session)/;
const DESIGN_NEGATION =
/(?:不应|不得|不再|禁止|只作为|降级|控制面|检查|若把|应要求改成|不是|不作为|不依赖|可选|sync replica|cloud source|compat|依赖证据|代码盘点|未完成|部分完成|当前状态)/;
const ACTIVE_CONVEX_FORBIDDEN = [
{
name: "未标注的 Convex documents 表",
pattern: /\bdocuments\s*:\s*defineTable\b/,
},
{
name: "未标注的 Convex media_assets 表",
pattern: /\bmedia_assets\s*:\s*defineTable\b/,
},
{
name: "未标注的 Convex documents 函数",
pattern: /export const (?:createWithParentReference|updateTitle|move|updateContent|getMeta|getContent|purge|listByWorkspace)\b/,
},
{
name: "未标注的 Convex mediaAssets 函数",
pattern: /export const (?:generateUploadUrl|createWithStorage|getById|refreshUrl|patchById|emptyTrashByWorkspace|purgeById|listByIds)\b/,
},
];
const RETIRED_ROOT_CONVEX_PATHS = [
{
name: "根 convex schema",
pattern: /^convex\/schema\.ts$/,
},
{
name: "根 Convex Auth 函数",
pattern: /^convex\/auth(?:\.config)?\.ts$/,
},
{
name: "根 Convex 用户函数",
pattern: /^convex\/users\.ts$/,
},
{
name: "根 Convex AI session 函数",
pattern: /^convex\/aiSessions\.ts$/,
},
];
const RETIRED_CONVEX_INFRA_PATHS = [
{
name: "Convex 自托管 infra",
pattern: /^infra\/convex(?:\/|$)/,
},
{
name: "根 storage-convex-bridge 占位文件",
pattern: /^storage-convex-bridge$/,
},
];
// ─── SQLite 迁移期 forbid ───────────────────────────────────
// 在 SQLite 控制面迁移阶段(Phase 1-5),新增 Convex 控制面代码
// users/workspaces/sessions/policies/audit/outbox/share 等)
// 应标记 sqlite-migration-waiver,否则提示应迁移到 SQLite。
const MIGRATION_PERIOD_FORBIDDEN = [
{
name: "未标注迁移例外的 Convex 控制面 users 代码",
pattern: /(?:[Uu]sers?\s*[:=.]|[a-z]+[A-Z][a-z]*[Uu]ser\b|auth_identities|auth_sessions).*(?:defineTable|mutation|query|schema)/,
scope: "convex",
},
{
name: "未标注迁移例外的 Convex 控制面 workspace 代码",
pattern: /(?:[Ww]orkspaces?\s*[:=]|[Ww]orkspace[Mm]embers\b|[Ww]orkspace[Mm]ember\b).*(?:defineTable|mutation|query|schema)/,
scope: "convex",
},
{
name: "未标注迁移例外的 Convex 控制面 grants/access 代码",
pattern: /\b(?:directory_grants|directoryGrants|access_policy|accessPolicy)\b/,
scope: "convex",
},
{
name: "未标注迁移例外的 Convex 控制面 share 代码",
pattern: /\b(?:share_links|shareLinks)\b/,
scope: "convex",
},
{
name: "未标注迁移例外的 Convex 控制面 sync/audit/outbox 代码",
pattern: /\b(?:sync_state|syncState|audit_log|auditLog|outbox_events|outboxEvents)\b/,
scope: "convex",
},
{
name: "未标注迁移例外的 Convex 控制面 ai_policies 代码",
pattern: /\b(?:ai_policies|aiPolicies|ai_policy|aiPolicy)\b/,
scope: "convex",
},
{
name: "未标注迁移例外的 Convex 控制面 legacy_id_map 代码",
pattern: /\b(?:legacy_id_map|legacyIdMap)\b/,
scope: "convex",
},
];
function usage() {
return [
"用法:",
" node scripts/check-local-first-convex-guard.js",
" node scripts/check-local-first-convex-guard.js --base origin/main",
" node scripts/check-local-first-convex-guard.js --staged",
" node scripts/check-local-first-convex-guard.js --files <path...>",
" node scripts/check-local-first-convex-guard.js --self-test",
"",
`如需保留明确的 cloud/compat 直连,请在同一新增行加入 ${WAIVER}`,
"根 convex/ functions 源码已退役;如确需新增显式 cloud/compat Convex functions,应先放到明确的辅助目录并更新 guard。",
"infra/convex 自托管基础设施已退役到 recycle;不要重新引回 active 路径。",
`SQLite 迁移期中新增 Convex 控制面代码(users/workspaces/grants/share 等)需加 ${MIGRATION_PERIOD_WAIVER} 标注迁移例外。`,
].join("\n");
}
function parseArgs(argv) {
const result = {
base: "HEAD",
staged: false,
files: [],
selfTest: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--help" || arg === "-h") {
console.log(usage());
process.exit(0);
}
if (arg === "--staged") {
result.staged = true;
continue;
}
if (arg === "--self-test") {
result.selfTest = true;
continue;
}
if (arg === "--base") {
const value = argv[index + 1];
if (!value) throw new Error("--base 需要一个 git ref");
result.base = value;
index += 1;
continue;
}
if (arg === "--files") {
result.files = argv.slice(index + 1);
break;
}
throw new Error(`未知参数:${arg}\n${usage()}`);
}
return result;
}
function isDesignPath(filePath) {
return filePath.startsWith("design/") && !filePath.startsWith("design/old/") && filePath.endsWith(".md");
}
function isRuntimePath(filePath) {
return filePath.startsWith("rust/crates/mnote-web/src/") && filePath.endsWith(".rs");
}
function isActiveConvexPath(filePath) {
return filePath.startsWith("convex/") && filePath.endsWith(".ts") && !filePath.startsWith("convex/_generated/");
}
function isRetiredRootConvexPath(filePath) {
return RETIRED_ROOT_CONVEX_PATHS.some((entry) => entry.pattern.test(filePath));
}
function isRetiredConvexInfraPath(filePath) {
return RETIRED_CONVEX_INFRA_PATHS.some((entry) => entry.pattern.test(filePath));
}
function isAllowedRuntimeAdapter(filePath) {
return (
filePath === "rust/crates/mnote-web/src/transport/convex.rs" ||
filePath === "rust/crates/mnote-web/src/routes/compat.rs"
);
}
function normalizePath(filePath) {
return filePath.split(path.sep).join("/");
}
function readDiffLines(options) {
const args = ["diff", "--unified=0", "--no-ext-diff"];
if (options.staged) {
args.push("--cached");
} else {
args.push(options.base);
}
args.push("--");
const output = execFileSync("git", args, { encoding: "utf8" });
return output.split(/\r?\n/);
}
function collectAddedLinesFromDiff(lines) {
const added = [];
let currentFile = null;
let newLine = 0;
for (const line of lines) {
const fileMatch = line.match(/^\+\+\+ b\/(.+)$/);
if (fileMatch) {
currentFile = fileMatch[1];
continue;
}
if (line.startsWith("+++ /dev/null")) {
currentFile = null;
continue;
}
const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
if (hunkMatch) {
newLine = Number(hunkMatch[1]);
continue;
}
if (!currentFile || line.startsWith("diff --git") || line.startsWith("index ")) {
continue;
}
if (line.startsWith("+") && !line.startsWith("+++")) {
added.push({ filePath: currentFile, lineNumber: newLine, text: line.slice(1) });
newLine += 1;
continue;
}
if (!line.startsWith("-")) {
newLine += 1;
}
}
return added;
}
function collectLinesFromFiles(files) {
const rows = [];
for (const rawPath of files) {
const filePath = normalizePath(rawPath);
const text = fs.readFileSync(filePath, "utf8");
text.split(/\r?\n/).forEach((line, index) => {
rows.push({ filePath, lineNumber: index + 1, text: line });
});
}
return rows;
}
function isMigrationWaived(row) {
return row.text.includes(WAIVER) || row.text.includes(MIGRATION_PERIOD_WAIVER);
}
function checkLine(row) {
if (isMigrationWaived(row)) {
return [];
}
if (isDesignPath(row.filePath)) {
if (DESIGN_DEFAULT_CONVEX.test(row.text) && !DESIGN_NEGATION.test(row.text)) {
return [
{
...row,
reason: "设计稿新增口径把 Convex 描述成默认主存储;请改成 LocalFS/WorkspaceSource 默认,或明确标注 cloud/control-plane/compat。",
},
];
}
return [];
}
if (isRetiredRootConvexPath(row.filePath)) {
return [
{
...row,
reason: `${RETIRED_ROOT_CONVEX_PATHS.find((entry) => entry.pattern.test(row.filePath)).name} 已随根 convex/ 退役;不要重新新增为默认控制面,请迁到 Rust SQLite control-plane 或明确的 cloud/compat 辅助目录。`,
},
];
}
if (isRetiredConvexInfraPath(row.filePath)) {
return [
{
...row,
reason: `${RETIRED_CONVEX_INFRA_PATHS.find((entry) => entry.pattern.test(row.filePath)).name} 已软删除到 recycle;不要重新引回 active 路径或默认启动链路。`,
},
];
}
if (!isRuntimePath(row.filePath) || isAllowedRuntimeAdapter(row.filePath)) {
if (!isActiveConvexPath(row.filePath)) {
return [];
}
const violations = ACTIVE_CONVEX_FORBIDDEN.filter((entry) => entry.pattern.test(row.text)).map((entry) => ({
...row,
reason: `${entry.name} 出现在 active convex 目录;新增 Convex 代码只能是 auth、membership、share grants、sync state、AI policy、cloud source、compat 或 sync replica,并需用 ${WAIVER} 标注例外。`,
}));
// 在迁移期内,新增控制面 Convex 代码也应标记例外
if (violations.length === 0) {
const migrationHit = MIGRATION_PERIOD_FORBIDDEN.filter((entry) => entry.pattern.test(row.text)).map((entry) => ({
...row,
reason: `${entry.name} — SQLite 迁移期内新增 Convex 控制面代码应标记 ${MIGRATION_PERIOD_WAIVER} 迁移例外,或将逻辑迁移到 Rust control-plane SQLite 实现。`,
}));
violations.push(...migrationHit);
}
return violations;
}
return RUNTIME_FORBIDDEN.filter((entry) => entry.pattern.test(row.text)).map((entry) => ({
...row,
reason: `${entry.name} 出现在非 Convex adapter 路径;请先走 WorkspaceSource / Rust kernel / LocalFS executor,或加 ${WAIVER} 并说明原因。`,
}));
}
function runSelfTest() {
const rows = [
{
filePath: "convex/schema.ts",
lineNumber: 1,
text: " documents: defineTable({",
},
{
filePath: "convex/aiSessions.ts",
lineNumber: 1,
text: "export const upsertRuntimeRun = mutation({",
},
{
filePath: "convex/mediaAssets.ts",
lineNumber: 1,
text: "export const generateUploadUrl = mutation({",
},
{
filePath: "convex/shareGrants.ts",
lineNumber: 1,
text: "export const upsertShareGrant = mutation({",
},
{
filePath: "rust/crates/mnote-web/src/routes/example.rs",
lineNumber: 1,
text: '"documents:updateContent"',
},
{
filePath: "rust/crates/mnote-web/src/transport/convex.rs",
lineNumber: 1,
text: '"documents:updateContent"',
},
{
filePath: "convex/users.ts",
lineNumber: 1,
text: " users: defineTable({",
},
{
filePath: "convex/workspaces_migration.ts",
lineNumber: 1,
text: " workspaces: defineTable({",
},
{
filePath: "infra/convex/docker-compose.yml",
lineNumber: 1,
text: "services:",
},
{
filePath: "storage-convex-bridge",
lineNumber: 1,
text: "",
},
];
const violations = rows.flatMap(checkLine);
const labels = violations.map((item) => item.filePath);
if (!labels.includes("convex/schema.ts")) {
throw new Error("self-test 失败:未拦截 active convex documents 表");
}
if (!labels.includes("convex/aiSessions.ts")) {
throw new Error("self-test 失败:未拦截退役根 convex AI session 函数");
}
if (!labels.includes("convex/mediaAssets.ts")) {
throw new Error("self-test 失败:未拦截 active convex media 函数");
}
if (!labels.includes("rust/crates/mnote-web/src/routes/example.rs")) {
throw new Error("self-test 失败:未拦截非 adapter Rust Convex 直连");
}
if (labels.includes("convex/shareGrants.ts")) {
throw new Error("self-test 失败:误拦截控制面 share grants");
}
if (labels.includes("rust/crates/mnote-web/src/transport/convex.rs")) {
throw new Error("self-test 失败:误拦截 Convex adapter");
}
if (!labels.includes("convex/users.ts")) {
throw new Error("self-test 失败:未拦截迁移期新增 Convex 控制面 users 代码");
}
if (!labels.includes("convex/workspaces_migration.ts")) {
throw new Error("self-test 失败:未拦截迁移期新增 Convex 控制面 workspaces 代码");
}
if (!labels.includes("infra/convex/docker-compose.yml")) {
throw new Error("self-test 失败:未拦截退役的 Convex infra 回流");
}
if (!labels.includes("storage-convex-bridge")) {
throw new Error("self-test 失败:未拦截根 storage-convex-bridge 占位文件回流");
}
console.log(JSON.stringify({ ok: true, guard: "local-first-convex", selfTest: true }, null, 2));
}
function main() {
const options = parseArgs(process.argv.slice(2));
if (options.selfTest) {
runSelfTest();
return;
}
const rows = options.files.length > 0 ? collectLinesFromFiles(options.files) : collectAddedLinesFromDiff(readDiffLines(options));
const violations = rows.flatMap(checkLine);
if (violations.length > 0) {
console.error("local-first Convex guard 发现新增主存储绑定:");
for (const violation of violations) {
console.error(`- ${violation.filePath}:${violation.lineNumber} ${violation.reason}`);
console.error(` ${violation.text.trim()}`);
}
process.exit(1);
}
console.log(JSON.stringify({ ok: true, checkedLines: rows.length, guard: "local-first-convex" }, null, 2));
}
if (require.main === module) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
+4 -8
View File
@@ -7,8 +7,8 @@
#
# 注意:
# - hostname 需要先在 Cloudflare DNS 里绑定到该 tunnel
# - 本示例把 app/supabase/lightrag/mineru 分别用子域名暴露
# - 生产环境建议对 lightrag/mineru 增加额外保护(Cloudflare Access / IP 白名单)
# - 本示例把 app/supabase/mineru 分别用子域名暴露
# - 生产环境建议对 mineru 增加额外保护(Cloudflare Access / IP 白名单)
tunnel: <YOUR_TUNNEL_NAME_OR_ID>
credentials-file: ./<YOUR_TUNNEL_ID>.json
@@ -26,15 +26,11 @@ ingress:
- hostname: backend.mnote.example.com
service: http://127.0.0.1:8000
# 4) LightRAG(你当前规划为 7777
- hostname: lightrag.mnote.example.com
service: http://127.0.0.1:7777
# 5) MinerU(你当前 .env.local 为 18888
# 4) MinerU(你当前 .env.local 为 18888
- hostname: mineru.mnote.example.com
service: http://127.0.0.1:18888
# 6) OnlyOffice Document Serverdocker: onlyoffice-dev 暴露到 8081
# 5) OnlyOffice Document Serverdocker: onlyoffice-dev 暴露到 8081
- hostname: onlyoffice.mnote.example.com
service: http://127.0.0.1:8081
+31 -167
View File
@@ -1,33 +1,20 @@
#!/usr/bin/env node
/**
* 热启动 mnote-web 单入口,以及按需启用的 FastAPI / Celery
* 热启动 mnote-web 单入口,以及按需启用的 FastAPI。
* 可使用以下环境变量调整行为:
* - FRONTEND_CMD:覆盖历史 Next 启动命令,仅在显式启用 legacy compat 或跳过 Rust gateway 时生效
* - ENABLE_BACKEND:设为 "1" or "true" 时启用默认 FastAPI 后端
* - BACKEND_CMD:覆盖 FastAPI 启动命令;设置后即视为显式启用后端
* - SKIP_BACKEND:设为 "1" or "true" 可强制跳过 FastAPI 后端
* - ENABLE_CELERY:设为 "1" or "true" 时启用默认 Celery worker
* - CELERY_CMD:覆盖 Celery 启动命令;设置后即视为显式启用 Celery
* - CELERY_POOL:只在 CELERY_CMD 未覆盖时生效,设置 Celery worker poolWindows 默认 "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。
* - MNOTE_WEB_SKIP_GATEWAY:设为 "1" or "true" 临时恢复旧 Next 3000 入口。
* - MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT:已废弃;desktop:hot 默认不再启动 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");
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");
function resolveBackendExecutable(envName, fallbackName) {
@@ -57,23 +44,35 @@ function resolveBackendExecutable(envName, fallbackName) {
}
const pythonBin = resolveBackendExecutable("PYTHON_BIN", "python");
const celeryBin = resolveBackendExecutable("CELERY_BIN", "celery");
const celeryPoolFromEnv = (process.env.CELERY_POOL || "").trim();
const celeryCmdFromEnv = (process.env.CELERY_CMD || "").trim();
const redisUrl = process.env.REDIS_URL || "redis://localhost:6379/0";
const backendPortFromEnv = Number(process.env.BACKEND_PORT || 8000);
function hasCommand(command) {
try {
execSync(`command -v ${command}`, { stdio: "ignore", shell: "/bin/sh" });
return true;
} catch {
return false;
}
}
function shouldUseUvBackendRuntime() {
if (String(process.env.PYTHON_BIN || "").trim()) return false;
if (!fs.existsSync(path.join(backendDir, "requirements.txt"))) return false;
return hasCommand("uv");
}
function buildDefaultBackendCommand(port) {
if (shouldUseUvBackendRuntime()) {
return `uv run --with-requirements requirements.txt python -m uvicorn app.main:app --reload --port ${port}`;
}
return `${pythonBin} -m uvicorn app.main:app --reload --port ${port}`;
}
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 shouldStartBackend(env = process.env) {
if (isEnabledEnv(env.SKIP_BACKEND)) return false;
if (String(env.BACKEND_CMD || "").trim()) return true;
@@ -82,49 +81,25 @@ function shouldStartBackend(env = process.env) {
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;
const skipGateway = false;
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 = "0";
return {
skipGateway,
skipNextLegacy,
publicPort,
legacyPort,
publicUrl: `http://localhost:${publicPort}`,
legacyUrl,
frontendTaskName: skipGateway ? "frontend" : null,
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 || `0.0.0.0:${publicPort}`,
MNOTE_WEB_PUBLIC_BIND: env.MNOTE_WEB_PUBLIC_BIND || `127.0.0.1:${publicPort}`,
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: legacyCompatEnabled,
},
mnoteWebEnv: {
MNOTE_WEB_BIND: env.MNOTE_WEB_BIND || `0.0.0.0:${publicPort}`,
MNOTE_WEB_PUBLIC_BIND: env.MNOTE_WEB_PUBLIC_BIND || `127.0.0.1:${publicPort}`,
},
};
}
const runtimePlan = resolveRuntimePlan(process.env);
const skipMnoteWebGateway = runtimePlan.skipGateway;
const frontendTasks = runtimePlan.frontendTaskName
? [
{
name: runtimePlan.frontendTaskName,
command: runtimePlan.frontendCommand,
cwd: frontendDir,
},
]
: [];
const tasks = [
...frontendTasks,
...(skipMnoteWebGateway
? []
: [
@@ -142,7 +117,7 @@ const tasks = [
name: "backend",
command:
process.env.BACKEND_CMD ||
`${pythonBin} -m uvicorn app.main:app --reload --port 8000`,
buildDefaultBackendCommand(8000),
cwd: backendDir,
},
]
@@ -394,18 +369,6 @@ function logPrefix(name, message) {
console.log(`[${name}] ${message}`);
}
function getDefaultCeleryPool() {
if (celeryPoolFromEnv) return celeryPoolFromEnv;
if (process.platform === "win32") return "solo";
return "";
}
function buildDefaultCeleryCommand() {
const pool = getDefaultCeleryPool();
const poolArg = pool ? ` --pool=${pool}` : "";
return `${celeryBin} -A app.workers.celery_app worker --loglevel=info${poolArg}`;
}
function startTask(task) {
logPrefix(task.name, `启动命令:${task.command}`);
const child = spawn(task.command, {
@@ -470,55 +433,11 @@ function shutdown(code) {
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() {
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}`);
}
}
// 说明:你外网绑定了 3000 端口,这里默认强制使用 3000。
// 如果检测到 3000 被占用,则自动结束旧进程后重启,以保证始终跑在 3000。
const desiredFrontendPort = runtimePlan.publicPort;
const frontendOwnerName = skipMnoteWebGateway ? "frontend" : "mnote-web";
const frontendOwnerName = "mnote-web";
const frontendPortOk = await ensurePortFree(desiredFrontendPort, frontendOwnerName);
if (!frontendPortOk) {
console.error(`前端端口 ${desiredFrontendPort} 无法释放,已中止启动。`);
@@ -527,35 +446,10 @@ async function main() {
const frontendPort = desiredFrontendPort;
const frontendUrl = `http://localhost:${frontendPort}`;
let nextLegacyPort = null;
if (runtimePlan.frontendTaskName === "next-legacy") {
nextLegacyPort = runtimePlan.legacyPort;
const nextLegacyPortOk = await ensurePortFree(nextLegacyPort, "next-legacy");
if (!nextLegacyPortOk) {
console.error(`Next legacy 端口 ${nextLegacyPort} 无法释放,已中止启动。`);
process.exit(1);
}
}
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}`);
}
if (skipMnoteWebGateway) {
logPrefix("frontend", `前端地址:${frontendUrl}`);
} else {
logPrefix("mnote-web", `Rust gateway 公开入口:${frontendUrl}`);
if (runtimePlan.frontendTaskName === "next-legacy") {
logPrefix("next-legacy", `Next legacy upstream${runtimePlan.legacyUrl}`);
} else {
}
const gatewayTask = tasks.find((task) => task.name === "mnote-web");
if (gatewayTask) {
gatewayTask.command = runtimePlan.mnoteWebCommand;
@@ -574,42 +468,13 @@ async function main() {
if (!backendTask) {
throw new Error("缺少后端任务配置");
}
backendTask.command = `${pythonBin} -m uvicorn app.main:app --reload --port ${desiredBackendPort}`;
backendTask.command = buildDefaultBackendCommand(desiredBackendPort);
} else if (isEnabledEnv(process.env.SKIP_BACKEND)) {
logPrefix("backend", "已跳过 FastAPI 后端(SKIP_BACKEND=1)。");
} else if (!shouldStartBackend(process.env)) {
}
if (shouldStartCelery(process.env)) {
const defaultCeleryCommand = buildDefaultCeleryCommand();
const celeryTask = {
name: "celery",
command: celeryCmdFromEnv || defaultCeleryCommand,
cwd: backendDir,
};
if (celeryCmdFromEnv) {
tasks.push(celeryTask);
} else if (await checkRedisReachable(redisUrl)) {
const defaultPool = getDefaultCeleryPool();
if (defaultPool) {
logPrefix("celery", `未显式设置 CELERY_CMD,当前平台默认使用 worker pool${defaultPool}`);
}
tasks.push(celeryTask);
} else {
// Redis 未就绪时直接跳过 Celery,避免热调试流程整体退出。
logPrefix(
"celery",
`检测到 ${redisUrl} 无法连接,自动跳过 Celery。请先启动 Redis,或继续保持当前默认关闭策略。`,
);
}
} else if (isEnabledEnv(process.env.SKIP_CELERY)) {
logPrefix("celery", "已跳过 CelerySKIP_CELERY=1)。");
} else {
}
if (tasks.length === 0) {
console.error("未配置任何可运行的任务,检查环境变量设置。");
process.exit(1);
@@ -635,6 +500,5 @@ module.exports = {
resolveRuntimePlan,
resolveBackendExecutable,
shouldStartBackend,
shouldStartCelery,
terminatePid,
};
+1 -65
View File
@@ -8,7 +8,6 @@ const {
isPortFree,
resolveRuntimePlan,
shouldStartBackend,
shouldStartCelery,
} = require("./desktop-hot.js");
function findFreePort() {
@@ -116,83 +115,20 @@ test("ensurePortFree 在非 Windows 平台能释放后端监听进程", async (t
await waitForExit(child);
});
test("默认热启动计划只使用 mnote-web 作为 3000 owner,不启动 Next legacy upstream", () => {
test("默认热启动计划只使用 mnote-web 作为 3000 owner", () => {
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: "0.0.0.0: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, 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: "0.0.0.0:3000",
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
});
});
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: "0.0.0.0:3000",
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
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);
});
test("默认跳过 FastAPI 后端,只有显式开启时才启动", () => {
assert.equal(shouldStartBackend({}), false);
assert.equal(shouldStartBackend({ ENABLE_BACKEND: "1" }), true);
-189
View File
@@ -1,189 +0,0 @@
#!/usr/bin/env node
/**
* 一键启动:wolai-backend + 桌面端(Electron + 内置 Next standalone
*
* 目的:
* - 本地联调无需反复安装 NSIS 安装包
* - Electron 走内置 Next(更接近安装包运行方式)
*
* 可选环境变量:
* - SKIP_DESKTOP_NEXT_BUILD=1:跳过 build:desktop:next(仅启动)
* - PYTHON_BIN:默认 python
* - BACKEND_CMD:覆盖后端启动命令
* - ELECTRON_CMD:覆盖 Electron 启动命令
* - MNOTE_DATA_DIR:覆盖桌面端 data 目录(默认 <repo>/data-desktop-test
*/
const { spawnSync, spawn } = require("child_process");
const fs = require("fs");
const net = require("net");
const path = require("path");
const rootDir = path.resolve(__dirname, "..");
const backendDir = path.join(rootDir, "wolai-backend");
function resolveBackendPython() {
const fromEnv = (process.env.PYTHON_BIN || "").trim();
if (fromEnv) return fromEnv;
const isWin = process.platform === "win32";
const candidates = isWin
? [
path.join(backendDir, ".venv", "Scripts", "python.exe"),
path.join(backendDir, ".venv312", "Scripts", "python.exe"),
path.join(backendDir, "venv", "Scripts", "python.exe"),
]
: [
path.join(backendDir, ".venv-linux", "bin", "python"),
path.join(backendDir, ".venv", "bin", "python"),
path.join(backendDir, "venv", "bin", "python"),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return "python";
}
const pythonBin = resolveBackendPython();
const dataDir = process.env.MNOTE_DATA_DIR || path.join(rootDir, "data-desktop-test");
const backendCmd =
process.env.BACKEND_CMD || `${pythonBin} -m uvicorn app.main:app --reload --host 127.0.0.1 --port 8000`;
const electronCmd = process.env.ELECTRON_CMD || "electron desktop-electron/main.js";
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;
}, {});
}
function runOrExit(command, cwd, env) {
const r = spawnSync(command, {
cwd,
env,
stdio: "inherit",
shell: true,
});
if (r.status !== 0) process.exit(r.status ?? 1);
}
async function waitForPort(host, port, timeoutMs = 20_000) {
const start = Date.now();
// eslint-disable-next-line no-constant-condition
while (true) {
const ok = await new Promise((resolve) => {
const socket = net.createConnection({ host, port });
const timer = setTimeout(() => {
socket.destroy();
resolve(false);
}, 500);
socket.once("connect", () => {
clearTimeout(timer);
socket.end();
resolve(true);
});
socket.once("error", () => {
clearTimeout(timer);
resolve(false);
});
});
if (ok) return;
if (Date.now() - start > timeoutMs) {
throw new Error(`等待端口超时:${host}:${port}`);
}
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => setTimeout(r, 120));
}
}
async function main() {
const envAllPath = path.join(rootDir, ".env.all");
if (!fs.existsSync(envAllPath)) {
console.error("[desktop-local] 缺少 .env.all:请在仓库根目录创建全局唯一 env 文件后重试。");
process.exit(1);
}
const envMerged = {
...process.env,
// 约定:全局仅使用仓库根目录的 .env.all 作为配置来源(生产优先)。
...loadEnvFile(envAllPath),
PYTHONUTF8: "1",
MNOTE_DATA_DIR: dataDir,
};
const skipBuild =
(process.env.SKIP_DESKTOP_NEXT_BUILD || "").toLowerCase() === "1" ||
(process.env.SKIP_DESKTOP_NEXT_BUILD || "").toLowerCase() === "true";
if (!skipBuild) {
console.log("[desktop-local] 构建桌面端内置 Nextstandalone)…");
runOrExit("pnpm run build:desktop:next", rootDir, envMerged);
} else {
console.log("[desktop-local] 已跳过 build:desktop:nextSKIP_DESKTOP_NEXT_BUILD=1");
}
console.log(`[desktop-local] data 目录:${dataDir}`);
console.log(`[desktop-local] 后端命令:${backendCmd}`);
console.log(`[desktop-local] Electron 命令:${electronCmd}`);
const children = [];
let shuttingDown = false;
const shutdown = () => {
if (shuttingDown) return;
shuttingDown = true;
console.log("[desktop-local] 正在关闭子进程…");
for (const child of children) {
try {
if (!child.killed) child.kill("SIGINT");
} catch {}
}
setTimeout(() => process.exit(0), 200);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
const backend = spawn(backendCmd, {
cwd: backendDir,
env: envMerged,
stdio: "inherit",
shell: true,
});
children.push(backend);
backend.on("exit", () => shutdown());
await waitForPort("127.0.0.1", 8000).catch(() => {
console.log("[desktop-local] 提示:未检测到后端 8000 端口就绪(可能仍在启动或启动失败)。");
});
const electron = spawn(electronCmd, {
cwd: rootDir,
env: envMerged,
stdio: "inherit",
shell: true,
});
children.push(electron);
electron.on("exit", () => shutdown());
}
main().catch((err) => {
console.error(err instanceof Error ? err.stack : String(err));
process.exit(1);
});
-362
View File
@@ -1,362 +0,0 @@
#!/usr/bin/env node
/**
* 一键启动“生产模式”的前后端与相关服务(适用于 Cloudflare Tunnel / 公网访问)。
*
* 目标:
* - 前后端与内部服务统一读取仓库根目录 `.env.all`(生产优先,全局唯一 env 文件)
*
* 可选环境变量:
* - SKIP_FRONTEND_BUILD=1:跳过前端 build(仅 start
* - FRONTEND_PORT:默认 3000
* - BACKEND_PORT:默认 8000
* - RAG_GATEWAY_PORT:默认 8778
* - INGEST_PORT:默认 8779
* - PYTHON_BIN:默认 python
* - CELERY_BIN:默认 celery
* - ENABLE_CELERY=1:启用默认 Celery worker
* - CELERY_CMD:覆盖 Celery 启动命令;设置后即视为显式启用 Celery
* - SKIP_CELERY=1:强制跳过 Celery worker
* - REDIS_URL:用于探测 Redis,默认 redis://127.0.0.1:6379/0
*/
const { spawn } = require("child_process");
const fs = require("fs");
const net = require("net");
const path = require("path");
const { URL } = require("url");
const rootDir = path.resolve(__dirname, "..");
const frontendDir = path.join(rootDir, "wolai-frontend");
const backendDir = path.join(rootDir, "wolai-backend");
const ingestDir = path.join(rootDir, "services", "ingest_service");
const ragGatewayDir = path.join(rootDir, "services", "rag_gateway");
function resolveBackendExecutable(envName, fallbackName) {
const fromEnv = (process.env[envName] || "").trim();
if (fromEnv) return fromEnv;
const isWin = process.platform === "win32";
const candidates = isWin
? [
path.join(backendDir, ".venv", "Scripts", `${fallbackName}.exe`),
path.join(backendDir, ".venv312", "Scripts", `${fallbackName}.exe`),
path.join(backendDir, "venv", "Scripts", `${fallbackName}.exe`),
]
: [
path.join(backendDir, ".venv-linux", "bin", fallbackName),
path.join(backendDir, ".venv", "bin", fallbackName),
path.join(backendDir, "venv", "bin", fallbackName),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return fallbackName;
}
const pythonBin = resolveBackendExecutable("PYTHON_BIN", "python");
const celeryBin = resolveBackendExecutable("CELERY_BIN", "celery");
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);
const backendPort = Number(process.env.BACKEND_PORT || 8000);
const ragGatewayPort = Number(process.env.RAG_GATEWAY_PORT || 8778);
const ingestPort = Number(process.env.INGEST_PORT || 8779);
const children = [];
let shuttingDown = false;
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");
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;
}, {});
}
async function waitForExit(child, name) {
return await new Promise((resolve) => {
child.on("exit", (code, signal) => {
const status = signal ? `信号 ${signal}` : `退出码 ${code ?? "null"}`;
logPrefix(name, `进程结束(${status}`);
resolve(code ?? 0);
});
child.on("error", () => resolve(1));
});
}
function spawnTask({ name, command, cwd, env }) {
logPrefix(name, `启动命令:${command}`);
const child = spawn(command, {
cwd,
stdio: "inherit",
shell: true,
env,
});
child.on("exit", (code, signal) => {
if (shuttingDown) return;
const status = signal !== null ? `因信号 ${signal} 退出` : `退出码 ${code ?? "null"}`;
logPrefix(name, `进程结束(${status}),准备清理其它任务。`);
shutdown(code ?? 0);
});
child.on("error", (err) => {
logPrefix(name, `启动失败:${err.message}`);
shutdown(1);
});
children.push(child);
return child;
}
function shutdown(code) {
if (shuttingDown) return;
shuttingDown = true;
logPrefix("system", "收到终止信号,正在关闭所有子进程…");
for (const child of children) {
if (!child.killed) {
child.kill("SIGINT");
setTimeout(() => {
if (!child.killed) {
child.kill("SIGTERM");
}
}, 5000);
}
}
setTimeout(() => process.exit(code), 200);
}
process.on("SIGINT", () => shutdown(0));
process.on("SIGTERM", () => shutdown(0));
async function checkPortOpen(host, port, timeoutMs = 1200) {
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);
});
});
}
async function checkRedisReachable(urlString, timeoutMs = 2000) {
try {
const url = new URL(urlString);
const host = url.hostname || "127.0.0.1";
const port = Number(url.port) || 6379;
return await checkPortOpen(host, port, timeoutMs);
} catch (error) {
logPrefix("celery", `REDIS_URL (${urlString}) 解析失败:${error.message},跳过连通性检查。`);
return true;
}
}
function requireFile(filePath, hint) {
if (!fs.existsSync(filePath)) {
console.error(`[system] 缺少文件:${filePath}`);
if (hint) console.error(`[system] 提示:${hint}`);
process.exit(1);
}
}
function ensureStandaloneStatic(frontendDir) {
// 说明:当 next.config.ts 配置了 output: "standalone" 时,推荐使用
// `node .next/standalone/server.js` 启动。
// 但 Next 不会自动把 `.next/static` 放入 `.next/standalone/.next/static`
// 若缺失会导致 `/_next/static/*` 404(远程访问尤其明显)。
const standaloneServer = path.join(frontendDir, ".next", "standalone", "server.js");
if (!fs.existsSync(standaloneServer)) return false;
const src = path.join(frontendDir, ".next", "static");
const dest = path.join(frontendDir, ".next", "standalone", ".next", "static");
if (!fs.existsSync(src)) return true;
try {
fs.mkdirSync(path.dirname(dest), { recursive: true });
// Node 16+fs.cpSync
fs.cpSync(src, dest, { recursive: true });
return true;
} catch (err) {
logPrefix(
"frontend",
`复制 .next/static 到 standalone 失败:${err instanceof Error ? err.message : String(err)}`,
);
// 失败时不强制退出,仍尝试启动(方便你自行排查)
return true;
}
}
async function main() {
// 约定:全局仅使用仓库根目录的 .env.all 作为配置来源(生产优先)。
const envAllPath = path.join(rootDir, ".env.all");
requireFile(envAllPath, "请先在仓库根目录创建 .env.all(全局唯一 env 文件)");
const envAll = loadEnvFile(envAllPath);
// 生产模式:前端必须使用 production env,避免本地开发配置污染公网用户
const envFrontend = {
...process.env,
...envAll,
NODE_ENV: "production",
NEXT_TELEMETRY_DISABLED: "1",
};
// 后端/内部服务:尽量使用本机/内网配置(不会暴露给浏览器)
const envBackend = {
...process.env,
...envAll,
PYTHONUTF8: "1",
};
const envInternalServices = {
...process.env,
...envAll,
PYTHONUTF8: "1",
};
const shouldSkipBuild =
(process.env.SKIP_FRONTEND_BUILD || "").toLowerCase() === "1" ||
(process.env.SKIP_FRONTEND_BUILD || "").toLowerCase() === "true";
if (!shouldSkipBuild) {
logPrefix("frontend", "开始 production build…");
const build = spawn("pnpm -C wolai-frontend build", {
cwd: rootDir,
stdio: "inherit",
shell: true,
env: envFrontend,
});
const code = await waitForExit(build, "frontend");
if (code !== 0) {
process.exit(code);
}
} else {
logPrefix("frontend", "已跳过 buildSKIP_FRONTEND_BUILD=1");
}
const useStandalone = ensureStandaloneStatic(frontendDir);
// 预检查:提醒必要端口是否已就绪(不强制退出,避免误伤已有进程)
const portChecks = [
{ name: "frontend", port: frontendPort },
{ name: "backend", port: backendPort },
{ name: "rag_gateway", port: ragGatewayPort },
{ name: "ingest_service", port: ingestPort },
];
for (const item of portChecks) {
// 仅检查本机 127.0.0.1 是否已有服务在监听,方便你快速判断“是否重复启动”
// 监听着不代表冲突:可能是你已经手动启动了。
// eslint-disable-next-line no-await-in-loop
const open = await checkPortOpen("127.0.0.1", item.port);
if (open) {
logPrefix("system", `检测到端口 ${item.port} 已有服务监听(${item.name}),若你准备全量启动,请确认不会端口冲突。`);
}
}
// 启动前端(production
const envFrontendServer = {
...envFrontend,
PORT: String(frontendPort),
HOSTNAME: "0.0.0.0",
};
spawnTask({
name: "frontend",
// 说明:next.config.ts 配置了 output: "standalone" 时,应使用 standalone server 启动,避免 next start 行为不一致。
command: useStandalone
? "node .next/standalone/server.js"
: `pnpm -C wolai-frontend exec next start -p ${frontendPort} -H 0.0.0.0`,
cwd: useStandalone ? frontendDir : rootDir,
env: envFrontendServer,
});
// 启动 wolai-backendFastAPI
spawnTask({
name: "backend",
command: `${pythonBin} -m uvicorn app.main:app --host 0.0.0.0 --port ${backendPort}`,
cwd: backendDir,
env: envBackend,
});
// Celery(可选)
if (shouldStartCelery(process.env)) {
const ok = await checkRedisReachable(envBackend.REDIS_URL || redisUrl);
if (ok) {
spawnTask({
name: "celery",
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 if (isEnabledEnv(process.env.SKIP_CELERY)) {
logPrefix("celery", "已跳过 CelerySKIP_CELERY=1");
} else {
logPrefix("celery", "默认不启动 Celery;当前主线页面不依赖 Redis/Celery。如需启用请设置 ENABLE_CELERY=1 或 CELERY_CMD。");
}
// 启动 ingest_service(自动入库、OCR 触发、延迟删除清理)
spawnTask({
name: "ingest",
command: `${pythonBin} -m uvicorn app.main:app --host 0.0.0.0 --port ${ingestPort}`,
cwd: ingestDir,
env: envInternalServices,
});
// 启动 rag_gateway/rag 查询网关)
spawnTask({
name: "rag_gateway",
command: `${pythonBin} -m uvicorn app.main:app --host 0.0.0.0 --port ${ragGatewayPort}`,
cwd: ragGatewayDir,
env: envInternalServices,
});
logPrefix(
"system",
`已启动:frontend:${frontendPort} backend:${backendPort} ingest:${ingestPort} rag_gateway:${ragGatewayPort}(如需公网访问,请确保 cloudflared tunnel 正在运行)`,
);
}
if (require.main === module) {
main().catch((error) => {
console.error(`[system] 启动失败:${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});
}
module.exports = {
shouldStartCelery,
};
-12
View File
@@ -1,12 +0,0 @@
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);
});
-9
View File
@@ -111,12 +111,6 @@ function Get-AppliedEnvFiles {
}
}
# Vite(开发模式)默认读取:仅对仓库内已存在的 webui 目录做显式识别
$viteDev = Join-Path $Root "LightRAG\lightrag_webui\.env.development"
if (Test-Path -LiteralPath $viteDev) {
$applied.Add((Resolve-Path -LiteralPath $viteDev).Path) | Out-Null
}
return $applied | Select-Object -Unique
}
@@ -273,10 +267,7 @@ function Get-MergeOrderScore {
# 目录组优先级:越大越[后覆盖](优先级更高)
$group = 0
if ($rel -match "^(?i)wolai-backend[/\\\\]") { $group = 60 }
elseif ($rel -match "^(?i)wolai-frontend[/\\\\]") { $group = 50 }
elseif ($rel -match "^(?i)infra[/\\\\]convex[/\\\\]") { $group = 40 }
elseif ($rel -match "^(?i)services[/\\\\]") { $group = 30 }
elseif ($rel -match "^(?i)LightRAG[/\\\\]") { $group = 20 }
elseif ($rel -match "^(?i)cankao[/\\\\]") { $group = -50 }
elseif ($rel -match "(?i)[/\\\\]test[/\\\\]") { $group = -40 }
else { $group = 0 }
-121
View File
@@ -1,121 +0,0 @@
#!/usr/bin/env node
/**
* 生成桌面端内置的 Next standalone 产物到 `desktop-electron/desktop-next`。
*
* 目标:
* - 打包时不依赖用户机器上的 pnpm / node 环境即可运行(Electron 自身携带 Node)。
* - 运行期文件读写落到安装目录的 data/(由 Electron 注入 MNOTE_DATA_DIR)。
*
* 注意:需要 wolai-frontend 的 next.config.ts 开启 output: "standalone"。
*/
const { spawnSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const rootDir = path.resolve(__dirname, "..");
const frontendDir = path.join(rootDir, "wolai-frontend");
const outDir = path.join(rootDir, "desktop-electron", "desktop-next");
function parseDotenv(filePath) {
if (!fs.existsSync(filePath)) return {};
const raw = fs.readFileSync(filePath, { encoding: "utf8" });
const out = {};
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const idx = trimmed.indexOf("=");
if (idx === -1) continue;
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim();
if (!key) continue;
out[key] = value;
}
return out;
}
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
function rmDir(dir) {
fs.rmSync(dir, { recursive: true, force: true });
}
function run(command, cwd, env) {
const r = spawnSync(command, {
cwd,
env: { ...process.env, ...env },
stdio: "inherit",
shell: true,
});
if (r.status !== 0) {
process.exit(r.status ?? 1);
}
}
function assertExists(filePath, hint) {
if (!fs.existsSync(filePath)) {
console.error(`\n[prepare-desktop-next] 缺少:${filePath}`);
if (hint) console.error(`[prepare-desktop-next] 提示:${hint}`);
process.exit(1);
}
}
function copyDir(from, to) {
// 关键:Next standalone 在 pnpm 环境下可能包含大量 symlink/junction。
// electron-builder 在复制 extraResources 时对链接目录处理不稳定,
// 因此这里强制“解引用复制”,把真实文件复制出来,确保运行期能 require('next')。
fs.cpSync(from, to, { recursive: true, force: true, dereference: true });
}
function main() {
console.log("[prepare-desktop-next] 开始构建 wolai-frontendstandalone)…");
// 约定:全局仅使用仓库根目录的 .env.all 作为配置来源(生产优先)。
// 桌面端如需覆盖,可通过系统环境变量注入(例如在启动命令前临时设置)。
const envAllPath = path.join(rootDir, ".env.all");
assertExists(envAllPath, "请先在仓库根目录创建 .env.all(全局唯一 env 文件)。");
const desktopEnv = {
...parseDotenv(envAllPath),
};
console.log("[prepare-desktop-next] 桌面端 env 摘要:", {
NEXT_PUBLIC_BACKEND_URL: desktopEnv.NEXT_PUBLIC_BACKEND_URL,
NEXT_PUBLIC_ONLYOFFICE_BASE_URL: desktopEnv.NEXT_PUBLIC_ONLYOFFICE_BASE_URL,
});
run("pnpm -C wolai-frontend build", rootDir, {
NODE_ENV: "production",
NEXT_TELEMETRY_DISABLED: "1",
...desktopEnv,
});
const standaloneDir = path.join(frontendDir, ".next", "standalone");
const staticDir = path.join(frontendDir, ".next", "static");
const publicDir = path.join(frontendDir, "public");
assertExists(
path.join(standaloneDir, "server.js"),
'请在 wolai-frontend/next.config.ts 增加 `output: "standalone"` 后重试。',
);
assertExists(staticDir, "Next build 未生成 .next/static(可能构建失败或目录异常)。");
assertExists(publicDir, "wolai-frontend/public 不存在。");
console.log(`[prepare-desktop-next] 清理旧产物:${outDir}`);
rmDir(outDir);
ensureDir(outDir);
console.log("[prepare-desktop-next] 复制 standalone …");
copyDir(standaloneDir, outDir);
console.log("[prepare-desktop-next] 补齐 .next/static …");
ensureDir(path.join(outDir, ".next"));
copyDir(staticDir, path.join(outDir, ".next", "static"));
console.log("[prepare-desktop-next] 补齐 public …");
copyDir(publicDir, path.join(outDir, "public"));
console.log("[prepare-desktop-next] 完成。");
}
main();
+2 -2
View File
@@ -169,7 +169,7 @@ async function main() {
details: `收到 ${receivedDeltas.length} 条 delta 事件`,
});
// ── 6. 验证编辑器内容已更新(通过回读 Convex ──
// ── 6. 验证编辑器内容已更新(通过 runtime 回读) ──
const readbackRes = await callTool(request, {
toolName: "mnote.doc.fetch",
workspaceId: target.workspaceId,
@@ -185,7 +185,7 @@ async function main() {
const finalBlocks = readbackRes.body?.blockDocument?.blocks || [];
const hasReplaced = finalBlocks.some((b) => b.text?.includes("段落B已替换"));
report.checks.push({
name: "Convex 回读确认替换成功",
name: "runtime 回读确认替换成功",
passed: hasReplaced,
details: finalBlocks.map((b) => b.text).join(" | "),
});
+4 -4
View File
@@ -6,7 +6,7 @@
*
* 验证 EditorRuntimeActor Phase A
* - 块写工具(replace / insert_after)在 actor 启用时返回时间 < 800ms
* - 多次写入循环不依赖 Convex RTT
* - 多次写入循环不依赖远端 RTT
* - 写后回读内容正确
*
* 依赖:运行中的 mnote-web (localhost:3000),已登录状态,测试 Helpers
@@ -154,7 +154,7 @@ async function main() {
console.log(`2/3 insert_after: ${insertMs}ms — ok`);
// 3. doc.fetch — 回读验证
await sleep(500); // 等 Convex 写入完成
await sleep(500); // 等待 runtime 写入完成
const t4 = Date.now();
const readbackRes = await callTool(request, {
toolName: "mnote.doc.fetch",
@@ -184,11 +184,11 @@ async function main() {
assert.ok(texts.some((t) => t.includes("第三段")), "应包含第三段");
assert.ok(texts.some((t) => t.includes("插入段")), "应包含插入段");
// ── 延迟断言:三次写循环总时间 < 800ms(不含 Convex wait ──
// ── 延迟断言:三次写循环总时间 < 800ms(不含回读等待 ──
const writeTotal = replaceMs + insertMs;
const allWithReadback = writeTotal + readbackMs;
console.log(`\n=== 延迟报告 ===`);
console.log(`三次操作总延迟(不含 await Convex: ${writeTotal}ms`);
console.log(`三次操作总延迟(不含回读等待: ${writeTotal}ms`);
console.log(`含回读总延迟: ${allWithReadback}ms`);
// 写操作若 > 800ms 打印 warning 但不 fail(因为首跑可能较慢)
@@ -121,7 +121,7 @@ async function validateGateway(baseUrl) {
const rootText = await root.text();
assert.equal(root.status, 303, `/ 未登录应跳转 /auth: ${root.status} ${rootText.slice(0, 120)}`);
assert.equal(root.headers.get("x-mnote-web-owner"), "mnote-web");
assert.equal(root.headers.get("location"), "/auth");
assert.equal(root.headers.get("location"), "/auth?next=%2F");
const authedRoot = await fetchWithTimeout(`${baseUrl}/`, {
headers: {
-219
View File
@@ -1,219 +0,0 @@
#!/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"],
});
}
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();
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`);
assert.equal(
response.headers.get("x-mnote-legacy-upstream"),
null,
`${path} 默认主路径不应出现 Next fallback header`,
);
return { response, text };
}
async function validateSkipNextRuntimePlan() {
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, 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 root = await readText(baseUrl, "/?workspaceId=ws_demo", authedInit());
assert.match(root.text, /data-mnote-shell="workspace"/);
assert.doesNotMatch(root.text, /next-app-router/i);
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", 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`, 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", 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", 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);
}
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);
});
-241
View File
@@ -1,241 +0,0 @@
#!/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);
});
}
@@ -1,904 +0,0 @@
"use strict";
const fs = require("fs");
const os = require("os");
const path = require("path");
const { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function fileUrl(localPath) {
return `file://${localPath}`;
}
function treeUrl(root, mode) {
const url = new URL(`${BASE_URL}/`);
url.searchParams.set("treeView", mode);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
return url.toString();
}
function documentUrl(root, documentId) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(documentId)}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
return url.toString();
}
function convexTreeUrl(mode = "filetree") {
const url = new URL(`${BASE_URL}/`);
url.searchParams.set("treeView", mode);
url.searchParams.set("workspaceId", "ws_demo");
url.searchParams.set("sourceKind", "convex_workspace");
return url.toString();
}
async function waitForText(page, text) {
await page.waitForFunction(
(expectedText) => Array.from(document.querySelectorAll("body *")).some((element) => {
const textContent = element.textContent ? element.textContent.trim() : "";
if (textContent !== expectedText) return false;
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
}),
text,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForContextMenu(page) {
const menu = page.locator('[data-testid="mnote-tree-context-menu"]').first();
await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
return menu;
}
async function clickMenuAction(page, action) {
await page.locator(`[data-testid="mnote-tree-context-menu"] [data-menu-action="${action}"]`).click({ timeout: UI_TIMEOUT_MS });
}
async function expectMenuAction(page, action, options = {}) {
const item = page.locator(`[data-testid="mnote-tree-context-menu"] [data-menu-action="${action}"]`).first();
await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const disabled = await item.isDisabled();
if (options.disabled !== undefined) {
assert(disabled === options.disabled, `${action} 菜单项 disabled=${disabled} 不符合预期`);
}
if (options.reasonIncludes) {
const reason = await item.getAttribute("data-disabled-reason");
assert((reason || "").includes(options.reasonIncludes), `${action} 菜单项应展示禁用原因`);
}
}
async function closeContextMenu(page) {
await page.keyboard.press("Escape");
try {
await page.locator('[data-testid="mnote-tree-context-menu"]').waitFor({ state: "detached", timeout: 1_000 });
} catch {
await page.evaluate(() => {
document.querySelectorAll('[data-testid="mnote-tree-context-menu"]').forEach((element) => element.remove());
});
await page.locator('[data-testid="mnote-tree-context-menu"]').waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
}
}
async function dispatchFolderContextMenu(page, rowSelector) {
await page.evaluate(
({ rowSelector }) => {
const row = document.querySelector(rowSelector);
if (!(row instanceof HTMLElement)) throw new Error("filetree folder row 不存在");
row.dispatchEvent(new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
clientX: 24,
clientY: 64,
}));
},
{ rowSelector },
);
}
async function dispatchRootContextMenu(page) {
await page.evaluate(() => {
const root = document.querySelector("#sidebar-file-tree-root .tree-root");
if (!(root instanceof HTMLElement)) throw new Error("filetree root 不存在");
root.dispatchEvent(new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
clientX: 24,
clientY: 64,
}));
});
}
async function waitForPreflight(page, titleText) {
const preflight = page.locator('[data-testid="tree-preflight"]').first();
await preflight.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
if (titleText) {
await preflight.getByText(titleText, { exact: false }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
}
return preflight;
}
async function cancelPreflight(page) {
await page.locator('[data-testid="tree-preflight"] [data-role="cancel"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="tree-preflight"]').waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
}
async function confirmPreflight(page) {
await page.locator('[data-testid="tree-preflight"] [data-role="confirm"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="tree-preflight"]').waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
}
async function dispatchInternalDrop(page, sourceRowSelector, targetRowSelector, options = {}) {
await page.evaluate(
({ sourceRowSelector, targetRowSelector, altKey }) => {
const source = document.querySelector(sourceRowSelector);
const target = document.querySelector(targetRowSelector);
if (!(source instanceof HTMLElement) || !(target instanceof HTMLElement)) {
throw new Error("拖拽目标不存在");
}
const dataTransfer = new DataTransfer();
source.dispatchEvent(new DragEvent("dragstart", { bubbles: true, cancelable: true, dataTransfer }));
target.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer, altKey }));
target.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer, altKey }));
source.dispatchEvent(new DragEvent("dragend", { bubbles: true, cancelable: true, dataTransfer }));
},
{ sourceRowSelector, targetRowSelector, altKey: Boolean(options.altKey) },
);
}
async function dispatchInternalDragOver(page, sourceRowSelector, targetRowSelector, ratio) {
return await page.evaluate(
({ sourceRowSelector, targetRowSelector, ratio }) => {
const source = document.querySelector(sourceRowSelector);
const target = document.querySelector(targetRowSelector);
if (!(source instanceof HTMLElement) || !(target instanceof HTMLElement)) {
throw new Error("拖拽目标不存在");
}
const rect = target.getBoundingClientRect();
const dataTransfer = new DataTransfer();
source.dispatchEvent(new DragEvent("dragstart", { bubbles: true, cancelable: true, dataTransfer }));
target.dispatchEvent(new DragEvent("dragover", {
bubbles: true,
cancelable: true,
dataTransfer,
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height * ratio,
}));
return {
dropTarget: target.dataset.dropTarget || "",
dropPosition: target.dataset.dropPosition || "",
};
},
{ sourceRowSelector, targetRowSelector, ratio },
);
}
async function dispatchExternalFileDrop(page, targetRowSelector, fileName, text) {
await page.evaluate(
({ targetRowSelector, fileName, text }) => {
const target = document.querySelector(targetRowSelector);
if (!(target instanceof HTMLElement)) {
throw new Error("外部拖入目标不存在");
}
const dataTransfer = new DataTransfer();
dataTransfer.items.add(new File([text], fileName, { type: "text/plain" }));
target.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer }));
target.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer }));
},
{ targetRowSelector, fileName, text },
);
}
async function waitForHostMessage(page, type, predicate = () => true) {
return await page.waitForFunction(
({ type }) => {
const messages = Array.isArray(window.__mnoteHostMessages) ? window.__mnoteHostMessages : [];
return messages.find((message) => message && message.type === type) || null;
},
{ type },
{ timeout: UI_TIMEOUT_MS },
).then(async (handle) => {
const message = await handle.evaluate((value) => {
const files = Array.from(value?.files || []).map((file) => ({
name: file?.name || "",
size: Number.isFinite(file?.size) ? file.size : 0,
type: file?.type || "",
}));
return {
...value,
files,
};
});
assert(predicate(message), `${type} host message 不符合预期: ${JSON.stringify(message)}`);
return message;
});
}
async function dispatchForeignInternalDrop(page, targetRowSelector) {
await page.evaluate(
({ targetRowSelector }) => {
const target = document.querySelector(targetRowSelector);
if (!(target instanceof HTMLElement)) {
throw new Error("跨 workspace 拖拽目标不存在");
}
const dataTransfer = new DataTransfer();
const payload = JSON.stringify({ type: "mnote-file-tree-dnd", version: 1, rowIds: ["foreign:workspace-row"] });
dataTransfer.setData("application/x-mnote-filetree-row-ids", payload);
target.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer }));
target.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer }));
},
{ targetRowSelector },
);
}
async function run() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-local-folder-smoke-"));
fs.mkdirSync(path.join(root, "docs"));
fs.mkdirSync(path.join(root, "docs", "nested"));
fs.mkdirSync(path.join(root, "readonly-dir"));
fs.mkdirSync(path.join(root, ".git"));
fs.mkdirSync(path.join(root, "node_modules"));
fs.mkdirSync(path.join(root, ".mnote"));
fs.writeFileSync(path.join(root, "README.md"), "---\ntitle: Frontmatter Title\n---\n# Ignored\n正文\n", "utf8");
fs.writeFileSync(path.join(root, "docs", "child.md"), "# Child H1\n", "utf8");
fs.writeFileSync(path.join(root, "docs", "nested", "deep.md"), "# Deep Page\n", "utf8");
fs.writeFileSync(
path.join(root, "docs", "blocks.md"),
"---\ntitle: Complex Title\n---\n# Complex H1\n\nparagraph with `inline code`, **bold**, *italic*, ~~strike~~, and [Link](https://example.com).\n\n- Bullet item\n1. Numbered item\n- [ ] Unchecked item\n- [x] Checked item\n> Quote item\n```js\nconsole.log('hi')\n```\n---\n[Spec](assets/spec.pdf)\n\n| Name | Value |\n| --- | --- |\n| Mark | `cell` |\n",
"utf8",
);
fs.writeFileSync(path.join(root, "image.png"), "png", "utf8");
fs.writeFileSync(path.join(root, ".git", "hidden.md"), "# hidden\n", "utf8");
fs.writeFileSync(path.join(root, "node_modules", "hidden.md"), "# hidden\n", "utf8");
fs.chmodSync(path.join(root, "readonly-dir"), 0o555);
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1280, height: 860 },
extraHTTPHeaders: {
"x-mnote-actor-id": "user_real",
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
const requests = [];
const navigationEvents = [];
page.on("framenavigated", (frame) => {
if (frame === page.mainFrame()) {
navigationEvents.push({
url: frame.url(),
timestamp: Date.now(),
});
}
});
page.on("request", (request) => {
if (
request.url().includes("/api/tree/commands") ||
request.url().includes("/api/documents/title") ||
request.url().includes("/api/documents/save") ||
request.url().includes("/api/documents/options") ||
request.url().includes("/api/ui/preferences")
) {
requests.push({
url: request.url(),
method: request.method(),
body: request.postData() || "",
});
}
});
try {
await page.goto(documentUrl(root, "local-md:README.md"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForText(page, "Frontmatter Title");
const dialogs = [];
page.on("dialog", async (dialog) => {
dialogs.push(dialog.type());
await dialog.dismiss().catch(() => {});
});
await page.locator('[data-mnote-action="open-local-folder"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-local-folder-dialog"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert(dialogs.length === 0, "打开本地文件夹入口不能再依赖 prompt/alert/confirm 原生弹窗");
await page.locator('[data-testid="mnote-local-folder-path-input"]').fill(root);
await page.locator('[data-testid="mnote-local-folder-open-confirm"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL(
(url) =>
url.pathname === "/" &&
url.searchParams.get("treeView") === "filetree" &&
url.searchParams.get("sourceKind") === "local_folder" &&
url.searchParams.get("rootUri") === fileUrl(root),
{ timeout: UI_TIMEOUT_MS },
);
await waitForText(page, "README.md");
const recentLocalRoots = await page.evaluate(() => {
const raw = window.localStorage.getItem("mnote.localFolder.recentRoots") || "[]";
return JSON.parse(raw);
});
assert(recentLocalRoots[0] === fileUrl(root), "打开本地文件夹入口应记录最近 rootUri");
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForText(page, "README.md");
await waitForText(page, "docs");
await waitForText(page, "image.png");
assert(!(await page.getByText(".git", { exact: true }).isVisible().catch(() => false)), ".git 不应出现在 Explorer");
assert(!(await page.getByText("node_modules", { exact: true }).isVisible().catch(() => false)), "node_modules 不应出现在 Explorer");
assert(await page.locator('[data-row-kind="folder"][data-row-id="local:folder:docs"]').count() === 1, "目录 rowKind 应保持 folder");
assert(await page.locator('[data-row-kind="markdown"][data-row-id="local:markdown:README.md"]').count() === 1, "Markdown rowKind 应保持 markdown");
await page.goto(treeUrl(root, "page"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForText(page, "Frontmatter Title");
await waitForText(page, "Child H1");
assert(!(await page.getByText("image.png", { exact: true }).isVisible().catch(() => false)), "page_tree 不应显示普通文件");
await page.goto(documentUrl(root, "local-md:README.md"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForText(page, "Frontmatter Title");
await waitForText(page, "正文");
const defaultHeadingBefore = await page.locator("#mnote-leptos-tiptap-island-editor-root .ProseMirror h1").first().evaluate((node) =>
window.getComputedStyle(node, "::before").content
);
assert(defaultHeadingBefore === "none", `本地 Markdown 默认不应开启标题自动编号,实际 ::before=${defaultHeadingBefore}`);
await page.evaluate(async ({ rootUri }) => {
const common = {
documentId: "local-md:README.md",
sourceKind: "local_folder",
rootUri,
};
const titleResponse = await fetch("/api/documents/title", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
...common,
title: "Browser Saved Title",
commandName: "page.head.updateTitle",
}),
});
if (!titleResponse.ok) throw new Error(`title_failed_${titleResponse.status}`);
const saveResponse = await fetch("/api/documents/save", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
...common,
content: [
{
id: "h1",
type: "heading",
props: { level: 1 },
content: [{ type: "text", text: "Browser Saved Heading" }],
},
{
id: "p1",
type: "paragraph",
content: [{ type: "text", text: "Browser saved body" }],
},
],
blockCount: 2,
}),
});
if (!saveResponse.ok) throw new Error(`save_failed_${saveResponse.status}`);
const optionsResponse = await fetch("/api/documents/options", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...common, options: { wideLayout: true, showToc: false, showHeadingNumbers: true } }),
});
if (!optionsResponse.ok) throw new Error(`options_failed_${optionsResponse.status}`);
}, { rootUri: fileUrl(root) });
assert(fs.readFileSync(path.join(root, "README.md"), "utf8").includes("Browser saved body"), "浏览器保存应写回 Markdown 正文");
assert(!fs.existsSync(path.join(root, ".mnote", "page-options.json")), "浏览器页面设置保存不应继续写入 .mnote/page-options.json");
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForText(page, "Browser Saved Title");
await waitForText(page, "Browser saved body");
const enabledHeadingBefore = await page.locator("#mnote-leptos-tiptap-island-editor-root .ProseMirror h1").first().evaluate((node) =>
window.getComputedStyle(node, "::before").content
);
assert(enabledHeadingBefore !== "none", "SQLite 偏好开启后应显示标题自动编号");
await page.goto(documentUrl(root, "local-md:docs~2Fblocks.md"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForText(page, "Complex Title");
await waitForText(page, "Bullet item");
await waitForText(page, "Numbered item");
await waitForText(page, "Quote item");
await waitForText(page, "console.log('hi')");
await waitForText(page, "Spec");
await page.locator(".ProseMirror").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const checkboxStates = await page.locator(".ProseMirror input[type='checkbox']").evaluateAll((nodes) =>
nodes.map((node) => Boolean(node.checked))
);
assert(checkboxStates.length === 2, `本地 Markdown 应渲染两个 checkbox,实际 ${checkboxStates.length}`);
assert(checkboxStates.includes(true), "本地 Markdown 应渲染 checked checkbox");
assert(checkboxStates.includes(false), "本地 Markdown 应渲染 unchecked checkbox");
assert(await page.locator(".ProseMirror p code", { hasText: "inline code" }).count() >= 1, "inline code 应渲染为 code mark");
assert(await page.locator(".ProseMirror strong", { hasText: "bold" }).count() >= 1, "strong 应渲染为 strong 标签");
assert(await page.locator(".ProseMirror em", { hasText: "italic" }).count() >= 1, "em 应渲染为 em 标签");
assert(await page.locator(".ProseMirror s, .ProseMirror del", { hasText: "strike" }).count() >= 1, "strike 应渲染为删除线标签");
const linkTexts = await page.locator(".ProseMirror a").evaluateAll((nodes) => nodes.map((node) => node.textContent?.trim() || ""));
assert(linkTexts.includes("Link"), `普通链接应渲染为 a 标签,实际 ${JSON.stringify(linkTexts)}`);
assert(linkTexts.includes("Spec"), `附件链接应渲染为 a 标签,实际 ${JSON.stringify(linkTexts)}`);
assert(await page.locator(".ProseMirror table td code", { hasText: "cell" }).count() >= 1, "table cell inline code 应保留 code mark");
if (process.env.MNOTE_TASK163_RUN_EXTENDED_TREE_OPS !== "1") {
const filetreeRows = await page.locator('#sidebar-file-tree-root [data-testid="filetree-asset-row"]').evaluateAll((nodes) =>
nodes.map((node) => ({
rowId: node.getAttribute("data-row-id") || "",
rowKind: node.getAttribute("data-row-kind") || "",
}))
);
assert(filetreeRows.some((row) => row.rowId === "local:folder:docs" && row.rowKind === "folder"), "当前主入口下的 filetree 容器应继续渲染 docs folder 行");
assert(filetreeRows.some((row) => row.rowId === "local:markdown:README.md" && row.rowKind === "markdown"), "当前主入口下的 filetree 容器应继续渲染 README markdown 行");
const pageAggregateResult = await page.evaluate(async ({ rootUri }) => {
const response = await fetch(`/api/page-aggregate/${encodeURIComponent("local-md:docs~2Fblocks.md")}?sourceKind=local_folder&rootUri=${encodeURIComponent(rootUri)}`, {
headers: { accept: "application/json" },
});
const payload = await response.json().catch(() => null);
return {
ok: response.ok,
status: response.status,
payload,
};
}, { rootUri: fileUrl(root) });
assert(pageAggregateResult.ok, `page aggregate 路由应返回 200,实际 ${pageAggregateResult.status}`);
assert(pageAggregateResult.payload?.schema === "mnote.page_aggregate.v1", `page aggregate schema 应保持稳定,实际 ${JSON.stringify(pageAggregateResult.payload)}`);
assert(pageAggregateResult.payload?.result?.title === "Complex Title", "page aggregate 应继续返回本地 Markdown 标题");
assert(requests.some((request) => request.body.includes('"sourceKind":"local_folder"')), "本地 Markdown 保存写操作必须透传 sourceKind=local_folder");
await browser.close();
fs.chmodSync(path.join(root, "readonly-dir"), 0o755);
fs.rmSync(root, { recursive: true, force: true });
console.log("task163 local markdown parser browser smoke passed");
return;
}
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const readmeRow = '.tree-row[data-row-id="local:markdown:README.md"]';
const docsRow = '.tree-row[data-row-id="local:folder:docs"]';
const nestedRow = '.tree-row[data-row-id="local:folder:docs/nested"]';
const readonlyRow = '.tree-row[data-row-id="local:folder:readonly-dir"]';
let imageRow = '.tree-row[data-row-id="local:asset:image.png"]';
await page.locator(readmeRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(docsRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(nestedRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(readonlyRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(imageRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await dispatchFolderContextMenu(page, docsRow);
await waitForContextMenu(page);
await expectMenuAction(page, "newPage", { disabled: false });
await expectMenuAction(page, "newFolder", { disabled: false });
await expectMenuAction(page, "upload", { disabled: true });
await expectMenuAction(page, "refresh", { disabled: false });
await closeContextMenu(page);
await page.locator(readmeRow).click({ button: "right", timeout: UI_TIMEOUT_MS });
await waitForContextMenu(page);
await expectMenuAction(page, "open", { disabled: false });
await expectMenuAction(page, "rename", { disabled: false });
await expectMenuAction(page, "copy", { disabled: false });
await expectMenuAction(page, "cut", { disabled: false });
await expectMenuAction(page, "paste", { disabled: true });
await expectMenuAction(page, "delete", { disabled: false });
await closeContextMenu(page);
await page.locator(docsRow).click({ button: "right", timeout: UI_TIMEOUT_MS });
await waitForContextMenu(page);
await expectMenuAction(page, "newPage", { disabled: false });
await expectMenuAction(page, "newFolder", { disabled: false });
await expectMenuAction(page, "paste", { disabled: true });
await expectMenuAction(page, "rename", { disabled: false });
await expectMenuAction(page, "delete", { disabled: true, reasonIncludes: "目录删除" });
await closeContextMenu(page);
await page.locator(imageRow).click({ button: "right", timeout: UI_TIMEOUT_MS });
await waitForContextMenu(page);
await expectMenuAction(page, "open", { disabled: false });
await expectMenuAction(page, "rename", { disabled: false });
await expectMenuAction(page, "copy", { disabled: true });
await expectMenuAction(page, "cut", { disabled: true });
await expectMenuAction(page, "delete", { disabled: true });
await expectMenuAction(page, "download", { disabled: true });
await clickMenuAction(page, "rename");
let assetRenameInput = page.locator(".tree-rename-input").first();
await assetRenameInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await assetRenameInput.fill("renamed-image");
await page.keyboard.press("Enter");
await page.waitForTimeout(800);
assert(fs.existsSync(path.join(root, "renamed-image.png")), "asset rename 应通过同一 inline rename 写入本地文件系统");
imageRow = '.tree-row[data-row-id="local:asset:renamed-image.png"]';
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator(readmeRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(docsRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(nestedRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(readonlyRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(imageRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await dispatchFolderContextMenu(page, docsRow);
await waitForContextMenu(page);
await clickMenuAction(page, "newFolder");
let createRenameInput = page.locator(".tree-rename-input").first();
await createRenameInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await createRenameInput.fill("新建资料");
await page.keyboard.press("Enter");
await page.waitForTimeout(800);
assert(fs.existsSync(path.join(root, "新建资料")), "根菜单新建文件夹后应进入 inline rename 并写入本地目录");
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator(docsRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(docsRow).click({ button: "right", timeout: UI_TIMEOUT_MS });
await waitForContextMenu(page);
await clickMenuAction(page, "newPage");
createRenameInput = page.locator(".tree-rename-input").first();
await createRenameInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await createRenameInput.fill("菜单新页面");
await page.keyboard.press("Enter");
await page.waitForTimeout(800);
assert(fs.existsSync(path.join(root, "docs", "菜单新页面.md")), "文件夹菜单新建页面后应进入 inline rename 并写入本地 Markdown");
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator(readmeRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(docsRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(nestedRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(readonlyRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(imageRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const createdPageRow = '.tree-row[data-row-id="local:markdown:docs/菜单新页面.md"]';
await page.locator(createdPageRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(createdPageRow).click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press("Delete");
await waitForPreflight(page, "删除预检");
await confirmPreflight(page);
await page.waitForTimeout(800);
assert(!fs.existsSync(path.join(root, "docs", "菜单新页面.md")), "确认 Delete preflight 后本地 Markdown 原路径应消失");
assert(fs.existsSync(path.join(root, ".mnote", "trash", "菜单新页面.md")), "确认 Delete preflight 后本地 Markdown 应进入 .mnote/trash");
const trashIndex = JSON.parse(fs.readFileSync(path.join(root, ".mnote", "trash-index.json"), "utf8"));
const createdTrashEntry = Object.values(trashIndex.entries || {}).find((entry) => entry.originalRelativePath === "docs/菜单新页面.md");
assert(createdTrashEntry && createdTrashEntry.documentId, "trash-index 应记录被删除页面的 documentId");
await page.evaluate(async ({ rootUri, documentId }) => {
const response = await fetch("/api/tree/commands", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
action: "restore",
sourceKind: "local_folder",
rootUri,
documentId,
}),
});
if (!response.ok) throw new Error(`restore_failed_${response.status}`);
}, { rootUri: fileUrl(root), documentId: createdTrashEntry.documentId });
await page.waitForTimeout(300);
assert(fs.existsSync(path.join(root, "docs", "菜单新页面.md")), "local restore 应从 .mnote/trash 恢复 Markdown");
await page.evaluate(async ({ rootUri, documentId }) => {
const response = await fetch("/api/tree/commands", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
action: "purge",
sourceKind: "local_folder",
rootUri,
documentId,
}),
});
if (!response.ok) throw new Error(`purge_failed_${response.status}`);
}, { rootUri: fileUrl(root), documentId: createdTrashEntry.documentId });
await page.waitForTimeout(300);
assert(!fs.existsSync(path.join(root, "docs", "菜单新页面.md")), "local purge 应永久删除已恢复的 Markdown");
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator(readmeRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(docsRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(nestedRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert((await dispatchInternalDragOver(page, readmeRow, docsRow, 0.05)).dropPosition === "before", "内部拖拽应显示 before indicator");
assert((await dispatchInternalDragOver(page, readmeRow, docsRow, 0.5)).dropPosition === "inside", "内部拖拽应显示 inside indicator");
assert((await dispatchInternalDragOver(page, readmeRow, docsRow, 0.95)).dropPosition === "after", "内部拖拽应显示 after indicator");
await dispatchInternalDragOver(page, readmeRow, nestedRow, 0.5);
await page.locator('.tree-row[data-row-id="local:markdown:docs/nested/deep.md"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const illegalDropRequestCount = requests.length;
await dispatchInternalDrop(page, readmeRow, readmeRow);
await dispatchInternalDrop(page, docsRow, nestedRow);
await dispatchForeignInternalDrop(page, docsRow);
await page.waitForTimeout(500);
assert(requests.length === illegalDropRequestCount, "自拖自身、拖到后代、跨 workspace 不应发 execute 请求");
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.locator(docsRow).click({ timeout: UI_TIMEOUT_MS });
await page.locator(nestedRow).click({ modifiers: [modifier], timeout: UI_TIMEOUT_MS });
const redundantMoveRequestCount = requests.length;
await dispatchInternalDrop(page, docsRow, readmeRow);
await waitForPreflight(page, "移动预检");
await confirmPreflight(page);
await page.waitForTimeout(800);
const redundantMoveRequests = requests.slice(redundantMoveRequestCount).filter((request) => request.body.includes('"action":"move"'));
assert(redundantMoveRequests.length === 1, "多选父子节点 move 时应过滤子节点重复执行");
assert(redundantMoveRequests[0].body.includes("local:node:docs"), "多选父子节点 move 应保留父节点作为唯一执行项");
assert(!redundantMoveRequests[0].body.includes("local:node:docs/nested"), "多选父子节点 move 不应重复执行子节点");
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator(readmeRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(docsRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(nestedRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(readonlyRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(imageRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(readmeRow).click({ timeout: UI_TIMEOUT_MS });
await page.locator(docsRow).click({ modifiers: [modifier], timeout: UI_TIMEOUT_MS });
assert(await page.locator(`${readmeRow}[data-selected="true"]`).count() === 1, "Ctrl/Cmd+Click 后原选区应保留");
assert(await page.locator(`${docsRow}[data-selected="true"]`).count() === 1, "Ctrl/Cmd+Click 后目标行应加入选区");
await page.locator(docsRow).click({ button: "right", timeout: UI_TIMEOUT_MS });
assert(await page.locator(`${readmeRow}[data-selected="true"]`).count() === 1, "已选区内右键不应清空多选");
await waitForContextMenu(page);
await expectMenuAction(page, "copy", { disabled: false });
await expectMenuAction(page, "cut", { disabled: false });
await expectMenuAction(page, "delete", { disabled: false });
await expectMenuAction(page, "moveTo", { disabled: true });
await closeContextMenu(page);
await page.locator(readmeRow).click({ timeout: UI_TIMEOUT_MS });
await page.locator(nestedRow).click({ modifiers: ["Shift"], timeout: UI_TIMEOUT_MS });
assert(await page.locator('.tree-row[data-shell-mode="filetree"][data-selected="true"]').count() >= 2, "Shift+Click 应形成范围选区");
await page.locator(readmeRow).click({ timeout: UI_TIMEOUT_MS });
await page.locator(imageRow).click({ button: "right", timeout: UI_TIMEOUT_MS });
assert(await page.locator(`${imageRow}[data-selected="true"]`).count() === 1, "未选项右键应切换到该行");
assert(await page.locator(`${readmeRow}[data-selected="true"]`).count() === 0, "未选项右键应清理旧选区");
await closeContextMenu(page);
await page.evaluate(() => {
const root = document.querySelector("#sidebar-file-tree-root .tree-root");
if (!(root instanceof HTMLElement)) throw new Error("filetree root 不存在");
root.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
});
assert(await page.locator('.tree-row[data-shell-mode="filetree"][data-selected="true"]').count() === 0, "空白区点击应清空 selection/focus");
await page.locator(readmeRow).click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A");
const visibleFileTreeRows = await page.locator('.tree-row[data-shell-mode="filetree"]').count();
assert(await page.locator('.tree-row[data-shell-mode="filetree"][data-selected="true"]').count() === visibleFileTreeRows, "Ctrl/Cmd+A 应选择全部可见 filetree 行");
await page.locator(readmeRow).click({ timeout: UI_TIMEOUT_MS });
const deletePreflightRequestCount = requests.length;
await page.keyboard.press("Delete");
const deletePreflight = await waitForPreflight(page, "删除预检");
assert(await deletePreflight.getByText("影响:", { exact: false }).isVisible(), "Delete preflight 应说明影响集合");
await cancelPreflight(page);
await page.waitForTimeout(300);
assert(requests.length === deletePreflightRequestCount, "取消 Delete preflight 后不应发 execute 请求");
assert(fs.existsSync(path.join(root, "README.md")), "取消 Delete preflight 后文件不应被移动到 trash");
await page.locator(readmeRow).click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press("F2");
const renameInput = page.locator(".tree-rename-input").first();
await renameInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await renameInput.fill("README Renamed");
await page.keyboard.press("Enter");
await page.waitForTimeout(800);
assert(fs.existsSync(path.join(root, "README Renamed.md")), "F2 inline rename 应真实重命名 Markdown 文件");
const renamedRow = '.tree-row[data-row-id="local:markdown:README Renamed.md"]';
await page.locator(renamedRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(renamedRow).click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press(process.platform === "darwin" ? "Meta+X" : "Control+X");
assert(await page.locator(`${renamedRow}[data-cut="true"]`).count() === 1, "Ctrl/Cmd+X 后应出现 cut decoration");
await page.locator(docsRow).click({ timeout: UI_TIMEOUT_MS });
const pasteCancelRequestCount = requests.length;
await page.keyboard.press(process.platform === "darwin" ? "Meta+V" : "Control+V");
const pastePreflight = await waitForPreflight(page, "粘贴移动预检");
assert(await pastePreflight.getByText("命名冲突策略", { exact: false }).isVisible(), "Paste preflight 应展示命名冲突策略");
await cancelPreflight(page);
await page.waitForTimeout(300);
assert(requests.length === pasteCancelRequestCount, "取消 Paste preflight 后不应发 execute 请求");
assert(fs.existsSync(path.join(root, "README Renamed.md")), "取消 Paste preflight 后文件应留在原目录");
await page.locator(docsRow).click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press(process.platform === "darwin" ? "Meta+V" : "Control+V");
await waitForPreflight(page, "粘贴移动预检");
await confirmPreflight(page);
await page.waitForTimeout(800);
assert(fs.existsSync(path.join(root, "docs", "README Renamed.md")), "剪切粘贴应真实移动 Markdown 文件");
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await dispatchInternalDrop(page, '.tree-row[data-row-id="local:markdown:docs/README Renamed.md"]', '.tree-row[data-row-id="local:folder:docs"]', { altKey: true });
const copyPreflight = await waitForPreflight(page, "复制预检");
assert(await copyPreflight.getByText("命名冲突策略", { exact: false }).isVisible(), "内部复制 drop preflight 应展示命名冲突策略");
await confirmPreflight(page);
await page.waitForTimeout(800);
assert(fs.existsSync(path.join(root, "docs", "README Renamed 2.md")), "Alt 内部拖拽应真实复制 Markdown 文件");
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const externalCancelRequestCount = requests.length;
await dispatchExternalFileDrop(page, '.tree-row[data-row-id="local:folder:docs"]', "drop-cancel.txt", "取消外部拖入");
await waitForPreflight(page, "外部拖入预检");
await cancelPreflight(page);
await page.waitForTimeout(300);
assert(requests.length === externalCancelRequestCount, "取消外部 File drop preflight 后不应发 execute 请求");
assert(!fs.existsSync(path.join(root, "docs", "drop-cancel.txt")), "取消外部 File drop preflight 后不应写入文件");
await dispatchExternalFileDrop(page, '.tree-row[data-row-id="local:folder:docs"]', "drop-real.txt", "真实外部拖入");
const externalPreflight = await waitForPreflight(page, "外部拖入预检");
assert(await externalPreflight.getByText("drop-real.txt", { exact: false }).isVisible(), "外部 drop preflight 应展示文件名");
await confirmPreflight(page);
await page.waitForTimeout(800);
assert(fs.existsSync(path.join(root, "docs", "drop-real.txt")), "外部 File drop 应真实写入本地目录");
await dispatchExternalFileDrop(page, '.tree-row[data-row-id="local:markdown:docs/README Renamed.md"]', "drop-on-md.txt", "拖到 Markdown 行");
const externalMdPreflight = await waitForPreflight(page, "外部拖入预检");
assert(await externalMdPreflight.getByText("README Renamed.md 的父目录", { exact: false }).isVisible(), "drop 到 md 文件时 preflight 应说明目标归属为父目录");
await confirmPreflight(page);
await page.waitForTimeout(800);
assert(fs.existsSync(path.join(root, "docs", "drop-on-md.txt")), "drop 到 md 文件时应写入该 md 的父目录");
assert(!fs.existsSync(path.join(root, "drop-on-md.txt")), "drop 到 Markdown 行时不应回退写入 root");
const readonlyDropRequestCount = requests.length;
await dispatchExternalFileDrop(page, readonlyRow, "readonly-drop.txt", "readonly");
await waitForPreflight(page, "操作预检失败");
assert(await page.getByText("只读", { exact: false }).first().isVisible(), "readonly 目标应展示统一预检失败原因");
await confirmPreflight(page);
await page.waitForTimeout(300);
assert(requests.length === readonlyDropRequestCount, "readonly 目标预检失败后不应发 execute 请求");
assert(!fs.existsSync(path.join(root, "readonly-dir", "readonly-drop.txt")), "readonly 目标预检失败后不应写入文件");
await page.goto(treeUrl(root, "page"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
let watcherNavigationStart = navigationEvents.length;
fs.writeFileSync(path.join(root, "docs", "watcher-added.md"), "# Watcher Added\n", "utf8");
await waitForText(page, "Watcher Added");
assert(navigationEvents.length === watcherNavigationStart, "外部新增 Markdown 后 page tree 不应发生浏览器导航或 reload");
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
watcherNavigationStart = navigationEvents.length;
fs.writeFileSync(path.join(root, "docs", "watcher-asset.txt"), "watcher asset", "utf8");
await waitForText(page, "watcher-asset.txt");
assert(navigationEvents.length === watcherNavigationStart, "外部新增非 md 资源后 filetree 不应发生浏览器导航或 reload");
watcherNavigationStart = navigationEvents.length;
fs.rmSync(path.join(root, "docs", "watcher-asset.txt"));
await page.getByText("watcher-asset.txt", { exact: true }).waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
assert(navigationEvents.length === watcherNavigationStart, "外部删除非 md 资源后 filetree 不应发生浏览器导航或 reload");
await page.goto(convexTreeUrl("filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForText(page, "工作区首页");
await waitForText(page, "封面.png");
assert(!(await page.getByText("Watcher Added", { exact: true }).isVisible().catch(() => false)), "切回 Convex 后不应显示旧 local watcher 内容");
assert(await page.locator('.tree-row[data-shell-mode="filetree"][data-selected="true"]').count() === 0, "切回 Convex 后旧 local selection 不应污染新 source");
await dispatchRootContextMenu(page);
await waitForContextMenu(page);
await expectMenuAction(page, "newPage", { disabled: false });
await expectMenuAction(page, "newFolder", { disabled: true, reasonIncludes: "Convex workspace" });
await closeContextMenu(page);
await page.getByText("工作区首页", { exact: true }).click({ button: "right", timeout: UI_TIMEOUT_MS });
await waitForContextMenu(page);
await expectMenuAction(page, "open", { disabled: false });
await expectMenuAction(page, "rename", { disabled: false });
await expectMenuAction(page, "copy", { disabled: false });
await expectMenuAction(page, "paste", { disabled: true });
await closeContextMenu(page);
const convexRootRow = '.tree-row[data-row-id="doc:page_root"]';
const convexChildRow = '.tree-row[data-row-id="doc:page_child"]';
const convexAssetFolderRow = '.tree-row[data-row-id="asset-folder:mind_1"]';
await page.locator(convexRootRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(convexChildRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(convexAssetFolderRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert((await dispatchInternalDragOver(page, convexChildRow, convexRootRow, 0.05)).dropPosition === "before", "Convex document 拖拽应显示 before indicator");
assert((await dispatchInternalDragOver(page, convexChildRow, convexRootRow, 0.5)).dropPosition === "inside", "Convex document 拖拽应显示 inside indicator");
assert((await dispatchInternalDragOver(page, convexChildRow, convexRootRow, 0.95)).dropPosition === "after", "Convex document 拖拽应显示 after indicator");
assert((await dispatchInternalDragOver(page, convexChildRow, convexAssetFolderRow, 0.5)).dropPosition === "inside", "Convex asset-folder 拖拽应显示 inside indicator");
await page.locator(convexChildRow).click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press(process.platform === "darwin" ? "Meta+C" : "Control+C");
await page.locator(convexRootRow).click({ timeout: UI_TIMEOUT_MS });
const convexCopyRequestCount = requests.length;
await page.keyboard.press(process.platform === "darwin" ? "Meta+V" : "Control+V");
await waitForPreflight(page, "粘贴复制预检");
await confirmPreflight(page);
await page.waitForTimeout(800);
const convexCopyRequests = requests.slice(convexCopyRequestCount).filter((request) => request.body.includes('"sourceKind":"convex_workspace"'));
assert(convexCopyRequests.some((request) => request.body.includes('"action":"copy"')), "Convex copy paste 应通过同一 command endpoint 发送 copy action");
await page.goto(convexTreeUrl("filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator(convexRootRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(convexChildRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(convexChildRow).click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press(process.platform === "darwin" ? "Meta+X" : "Control+X");
await page.locator(convexRootRow).click({ timeout: UI_TIMEOUT_MS });
const convexMoveRequestCount = requests.length;
await page.keyboard.press(process.platform === "darwin" ? "Meta+V" : "Control+V");
await waitForPreflight(page, "粘贴移动预检");
await confirmPreflight(page);
await page.waitForTimeout(800);
const convexMoveRequests = requests.slice(convexMoveRequestCount).filter((request) => request.body.includes('"sourceKind":"convex_workspace"'));
assert(convexMoveRequests.some((request) => request.body.includes('"action":"move"')), "Convex cut paste 应通过同一 command endpoint 发送 move action");
await page.goto(convexTreeUrl("filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator(convexChildRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(convexChildRow).click({ timeout: UI_TIMEOUT_MS });
const convexDeleteRequestCount = requests.length;
await page.keyboard.press("Delete");
await waitForPreflight(page, "删除预检");
await confirmPreflight(page);
await page.waitForTimeout(800);
const convexDeleteRequests = requests.slice(convexDeleteRequestCount).filter((request) => request.body.includes('"sourceKind":"convex_workspace"'));
assert(convexDeleteRequests.some((request) => request.body.includes('"action":"delete"')), "Convex Delete 应通过同一 command endpoint 发送 delete action");
const convexRestoreRequestCount = requests.length;
await page.evaluate(async () => {
const response = await fetch("/api/tree/commands", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
action: "restore",
sourceKind: "convex_workspace",
workspaceId: "ws_demo",
documentId: "page_child",
}),
});
if (!response.ok) throw new Error(`convex_restore_failed_${response.status}`);
});
await page.evaluate(async () => {
const response = await fetch("/api/tree/commands", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
action: "purge",
sourceKind: "convex_workspace",
workspaceId: "ws_demo",
documentId: "page_child",
}),
});
if (!response.ok) throw new Error(`convex_purge_failed_${response.status}`);
});
const convexRestorePurgeRequests = requests.slice(convexRestoreRequestCount).filter((request) => request.body.includes('"sourceKind":"convex_workspace"'));
assert(convexRestorePurgeRequests.some((request) => request.body.includes('"action":"restore"')), "Convex restore 应继续走 tree.node.restore command");
assert(convexRestorePurgeRequests.some((request) => request.body.includes('"action":"purge"')), "Convex purge 应继续走 tree.node.purge command");
await page.setContent(`
<!doctype html>
<meta charset="utf-8">
<script>
window.__mnoteHostMessages = [];
window.addEventListener("message", (event) => {
window.__mnoteHostMessages.push(event.data);
});
</script>
<iframe id="tree-host" src="${convexTreeUrl("filetree")}"></iframe>
`);
const treeFrameLocator = page.frameLocator("#tree-host");
await treeFrameLocator.locator(convexRootRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const treeFrameElement = await page.locator("#tree-host").elementHandle({ timeout: UI_TIMEOUT_MS });
const treeFrame = treeFrameElement ? await treeFrameElement.contentFrame() : null;
assert(treeFrame, "Convex iframe tree shell 应加载成功");
await treeFrame.locator(convexRootRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await treeFrame.locator(convexAssetFolderRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await dispatchExternalFileDrop(treeFrame, convexRootRow, "convex-doc-drop.txt", "上传到 Convex document");
await treeFrame.locator('[data-testid="tree-preflight"]').getByText("外部拖入预检", { exact: false }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await treeFrame.locator('[data-testid="tree-preflight"]').getByText("上传到对象存储", { exact: false }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await treeFrame.locator('[data-testid="tree-preflight"] [data-role="confirm"]').click({ timeout: UI_TIMEOUT_MS });
const convexDocDropMessage = await waitForHostMessage(page, "tree.filetree.external-drop", (message) =>
message.documentId === "page_root" &&
message.rowKind === "document" &&
Array.isArray(message.files) &&
message.files.length === 1 &&
message.files[0].name === "convex-doc-drop.txt"
);
assert(convexDocDropMessage.fileCount === undefined || convexDocDropMessage.fileCount === 1, "Convex document 外部 drop host message 应保留文件数量");
await page.evaluate(() => {
window.__mnoteHostMessages = [];
});
await dispatchExternalFileDrop(treeFrame, convexAssetFolderRow, "convex-asset-folder-drop.txt", "上传到 Convex asset folder");
await treeFrame.locator('[data-testid="tree-preflight"]').getByText("外部拖入预检", { exact: false }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await treeFrame.locator('[data-testid="tree-preflight"] [data-role="confirm"]').click({ timeout: UI_TIMEOUT_MS });
await waitForHostMessage(page, "tree.filetree.external-drop", (message) =>
message.assetId === "mind_1" &&
message.rowKind === "asset_folder" &&
Array.isArray(message.files) &&
message.files.some((file) => file.name === "convex-asset-folder-drop.txt")
);
assert(requests.some((request) => request.body.includes('"sourceKind":"local_folder"')), "写操作必须透传 sourceKind=local_folder");
assert(requests.some((request) => request.body.includes('"action":"move"')), "剪切粘贴应走统一 move action");
assert(requests.some((request) => request.body.includes('"action":"copy"')), "内部 copy drop 应走统一 copy action");
assert(requests.some((request) => request.body.includes('"action":"dropFiles"')), "外部文件 drop 应走统一 dropFiles action");
await browser.close();
fs.chmodSync(path.join(root, "readonly-dir"), 0o755);
fs.rmSync(root, { recursive: true, force: true });
console.log("task163 local folder unified tree browser smoke passed");
} catch (error) {
await browser.close().catch(() => undefined);
console.error(`local root preserved for debugging: ${root}`);
throw error;
}
}
run().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -84,8 +84,6 @@ async function main() {
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
CONVEX_SELF_HOSTED_URL: "http://127.0.0.1:9",
NEXT_PUBLIC_CONVEX_URL: "http://127.0.0.1:9",
},
stdio: ["ignore", "pipe", "pipe"],
});
@@ -101,8 +101,6 @@ async function main() {
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
CONVEX_SELF_HOSTED_URL: "http://127.0.0.1:9",
NEXT_PUBLIC_CONVEX_URL: "http://127.0.0.1:9",
},
stdio: ["ignore", "pipe", "pipe"],
});
@@ -124,6 +122,8 @@ async function main() {
const firstAggregate = await loadAggregate(baseUrl, actorId, documentId, rootUri);
assert.equal(firstAggregate.head.title, "README");
assert.equal(firstAggregate.body.projectionSource, "local_markdown.content", "local-first Page Aggregate 应声明 local markdown projection source");
assert(firstAggregate.body.blockDocument, "local-first Page Aggregate 应输出 blockDocumentruntime 不应只依赖 legacy content");
const titlePayload = await requestJson(baseUrl, actorId, "/api/documents/title", {
method: "POST",
@@ -178,6 +178,8 @@ async function main() {
const finalAggregate = await loadAggregate(baseUrl, actorId, documentId, rootUri);
assert.equal(finalAggregate.head.title, "P3 标题", "frontmatter title 应优先于正文 H1");
assert.equal(finalAggregate.body.projectionSource, "local_markdown.content", "保存后 Page Aggregate source 不应退回 legacy documents.content");
assert(finalAggregate.body.blockDocument, "保存后 Page Aggregate 应保留 blockDocument");
assert.equal(finalAggregate.layout.pageOptions.wideLayout, true);
assert.equal(finalAggregate.layout.pageOptions.showToc, true);
assert.equal(finalAggregate.layout.pageOptions.showHeadingNumbers, true);
@@ -1,287 +0,0 @@
"use strict";
// 说明:
// - 验证 Rust 3000 主入口的 Convex 附件行打开 OnlyOffice 链路。
// - 脚本会创建临时页面、上传临时 docx、点击文件树附件行并断言新窗口打开 /onlyoffice。
// - 结束后清理临时页面,避免污染长期测试空间。
const fs = require("node:fs");
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
assert,
createTempDocument,
ensureAuthenticated,
openDocument,
openFilesystemView,
purgeDocument,
} = require("./tree-shell-smoke-helpers");
const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
const ONLYOFFICE_READY_TIMEOUT_MS = Number(process.env.MNOTE_ONLYOFFICE_READY_TIMEOUT_MS || 120_000);
const PROBE_DOCX_PATH =
process.env.MNOTE_ONLYOFFICE_PROBE_DOCX ||
"/tmp/mnote-onlyoffice-manual/mnote-onlyoffice-upload-test.docx";
const TEST_EMAIL = "mnote.e2e@example.com";
const TEST_PASSWORD = "MnoteE2E123!";
let authCookieHeader = "";
const popupConsoleMessages = [];
const popupNetworkFailures = [];
function parseSetCookie(setCookie, origin) {
const [nameValue] = String(setCookie || "").split(";");
const separator = nameValue.indexOf("=");
if (separator <= 0) return null;
return {
name: nameValue.slice(0, separator).trim(),
value: nameValue.slice(separator + 1).trim(),
domain: new URL(origin).hostname,
path: "/",
httpOnly: /;\s*httponly\b/i.test(setCookie),
sameSite: "Lax",
};
}
async function forceTestAccountLogin(context) {
const response = await context.request.fetch(`${BASE_URL}/api/auth`, {
method: "POST",
data: {
action: "auth:signIn",
args: {
provider: "password",
params: {
email: TEST_EMAIL,
password: TEST_PASSWORD,
flow: "signIn",
},
},
},
headers: { "content-type": "application/json" },
timeout: UI_TIMEOUT_MS,
});
const payload = await response.json().catch(async () => ({ raw: await response.text() }));
assert(response.ok(), `测试账号登录失败:${response.status()} ${JSON.stringify(payload)}`);
const cookies = response
.headersArray()
.filter((header) => header.name.toLowerCase() === "set-cookie")
.map((header) => parseSetCookie(header.value, BASE_URL))
.filter(Boolean);
assert(cookies.length > 0, "测试账号登录响应缺少 set-cookie");
await context.addCookies(cookies);
authCookieHeader = cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; ");
}
async function uploadProbeDocx(requestContext, target) {
assert(fs.existsSync(PROBE_DOCX_PATH), `缺少探测文件:${PROBE_DOCX_PATH}`);
const buffer = fs.readFileSync(PROBE_DOCX_PATH);
const response = await requestContext.fetch(`${BASE_URL}/api/media/upload`, {
method: "POST",
headers: authCookieHeader ? { cookie: authCookieHeader } : undefined,
multipart: {
file: {
name: "task174-onlyoffice-attachment.docx",
mimeType: DOCX_MIME,
buffer,
},
workspaceId: target.workspaceId,
documentId: target.documentId,
},
timeout: UI_TIMEOUT_MS,
});
const payload = await response.json().catch(async () => ({ raw: await response.text() }));
assert(
response.ok(),
`/api/media/upload 请求失败:${response.status()} ${JSON.stringify(payload)}`,
);
assert(
payload && payload.asset && typeof payload.asset.id === "string",
`上传结果缺少 asset.id${JSON.stringify(payload)}`,
);
return payload.asset;
}
async function waitForAssetRow(page, assetId) {
const selector = `[data-testid="filetree-asset-row"][data-asset-id="${assetId}"]`;
const row = page.locator(selector).first();
await row.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
return row;
}
async function waitForOnlyOfficeReady(page) {
page.on("console", (message) => {
popupConsoleMessages.push({
type: message.type(),
text: message.text().slice(0, 1000),
});
});
page.on("pageerror", (error) => {
popupConsoleMessages.push({
type: "pageerror",
text: error.message.slice(0, 1000),
});
});
page.on("response", (response) => {
if (response.status() >= 400) {
popupNetworkFailures.push({
status: response.status(),
url: response.url().slice(0, 1000),
});
}
});
try {
await page.waitForFunction(
() => window.__MNOTE_ONLYOFFICE_READY__ === true,
undefined,
{ timeout: ONLYOFFICE_READY_TIMEOUT_MS },
);
await page.waitForFunction(
() => {
const root = document.getElementById("onlyoffice-frame");
if (root && root.querySelector("iframe,canvas")) return true;
return Boolean(document.querySelector("iframe,canvas"));
},
undefined,
{ timeout: ONLYOFFICE_READY_TIMEOUT_MS },
);
} catch (error) {
const debug = await page.evaluate(() => ({
url: window.location.href,
title: document.title,
readyState: document.readyState,
ready: window.__MNOTE_ONLYOFFICE_READY__ === true,
debug: window.__MNOTE_ONLYOFFICE_DEBUG__ || null,
errors: window.__MNOTE_ONLYOFFICE_ERRLOG__ || [],
hasFrameRoot: Boolean(document.getElementById("onlyoffice-frame")),
bodyText: document.body ? document.body.innerText.slice(0, 800) : "",
html: document.body ? document.body.innerHTML.slice(0, 1600) : "",
scriptCount: document.scripts.length,
frameCount: document.querySelectorAll("iframe,canvas").length,
hasFrameRoot: Boolean(document.getElementById("onlyoffice-frame")),
})).catch((debugError) => ({
evaluateError: debugError instanceof Error ? debugError.message : String(debugError),
isClosed: page.isClosed(),
}));
debug.consoleMessages = popupConsoleMessages.slice(-20);
debug.networkFailures = popupNetworkFailures.slice(-30);
throw new Error(
`OnlyOffice ready 超时:${error instanceof Error ? error.message : String(error)} ${JSON.stringify(debug)}`,
);
}
}
async function readOnlyOfficeDebug(page) {
return await page.evaluate(() => {
const debug = window.__MNOTE_ONLYOFFICE_DEBUG__ || null;
return {
debug,
docKey: debug && typeof debug.docKey === "string" ? debug.docKey : "",
ready: window.__MNOTE_ONLYOFFICE_READY__ === true,
frameCount: document.querySelectorAll("iframe,canvas").length,
};
});
}
async function assertOnlyOfficeDocumentUrlReachableFromContainer(debug) {
const resolvedFileUrl = String(debug && debug.resolvedFileUrl || "");
assert(resolvedFileUrl.includes("/api/onlyoffice/proxy"), `OnlyOffice resolvedFileUrl 未走 proxy${JSON.stringify(debug)}`);
assert(
/^(http:\/\/host\.docker\.internal:3000|http:\/\/172\.17\.0\.1:3000)/.test(resolvedFileUrl),
`OnlyOffice resolvedFileUrl 应使用 DocumentServer 可回源的 mnote-web 地址:${resolvedFileUrl}`,
);
const { execFile } = require("node:child_process");
const { promisify } = require("node:util");
const execFileAsync = promisify(execFile);
const response = await execFileAsync(
"docker",
["exec", "mnote-onlyoffice-documentserver", "curl", "-fsSI", "--max-time", "10", resolvedFileUrl],
{ timeout: 15_000, maxBuffer: 1024 * 1024 },
);
assert(/HTTP\/[0-9.]+ 200/.test(response.stdout), `DocumentServer 容器无法回源读取文档:${response.stdout}\n${response.stderr}`);
}
async function main() {
const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" });
const context = await browser.newContext();
const page = await context.newPage();
let target = null;
try {
await forceTestAccountLogin(context);
await ensureAuthenticated(page, context.request);
target = await createTempDocument(context.request, null);
const asset = await uploadProbeDocx(context.request, target);
await openDocument(page, target.workspaceId, target.documentId);
await openFilesystemView(page);
const row = await waitForAssetRow(page, asset.id);
const [popup] = await Promise.all([
page.waitForEvent("popup", { timeout: UI_TIMEOUT_MS }),
row.locator('[data-rust-action="open"]').first().click({ timeout: UI_TIMEOUT_MS }),
]);
await popup.waitForLoadState("domcontentloaded", { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
const opened = new URL(popup.url());
assert(opened.pathname === "/onlyoffice", `附件应在新窗口打开 /onlyoffice,实际为:${popup.url()}`);
assert(opened.searchParams.get("assetId") === asset.id, "OnlyOffice URL 缺少正确 assetId");
assert(
opened.searchParams.get("documentId") === target.documentId,
"OnlyOffice URL 缺少正确 documentId",
);
assert(opened.searchParams.get("fileType") === "docx", "OnlyOffice URL fileType 应为 docx");
assert(opened.searchParams.get("mode") === "edit", "OnlyOffice URL mode 应为 edit");
await waitForOnlyOfficeReady(popup);
const firstDebug = await readOnlyOfficeDebug(popup);
assert(firstDebug.ready, "OnlyOffice ready flag 应为 true");
assert(firstDebug.frameCount > 0, "OnlyOffice 应创建 iframe/canvas");
assert(firstDebug.debug && firstDebug.debug.documentUrlBase, `OnlyOffice debug 应暴露 documentUrlBase${JSON.stringify(firstDebug)}`);
await assertOnlyOfficeDocumentUrlReachableFromContainer(firstDebug.debug);
assert(
/^[0-9A-Za-z_.=-]{1,128}$/.test(firstDebug.docKey),
`OnlyOffice document.key 不符合安全字符集或长度:${firstDebug.docKey}`,
);
assert(
firstDebug.docKey === asset.id || firstDebug.docKey.startsWith(`${asset.id}_`),
`OnlyOffice document.key 应绑定 assetId,实际为:${firstDebug.docKey}`,
);
const reopened = await context.newPage();
await reopened.goto(popup.url(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForOnlyOfficeReady(reopened);
const secondDebug = await readOnlyOfficeDebug(reopened);
assert(
secondDebug.docKey === firstDebug.docKey,
`同一附件重复打开 document.key 应稳定:first=${firstDebug.docKey} second=${secondDebug.docKey}`,
);
await reopened.close().catch(() => undefined);
console.log(
JSON.stringify(
{
ok: true,
documentId: target.documentId,
workspaceId: target.workspaceId,
assetId: asset.id,
openedUrl: popup.url(),
docKey: firstDebug.docKey,
},
null,
2,
),
);
} finally {
if (target && target.documentId) {
await purgeDocument(context.request, target.documentId).catch((error) => {
console.warn(`清理临时页面失败:${error instanceof Error ? error.message : String(error)}`);
});
}
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
-368
View File
@@ -1,368 +0,0 @@
#!/usr/bin/env node
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
assert,
createTempDocument,
ensureAuthenticated,
openDocument,
openFilesystemView,
purgeDocument,
} = require("./tree-shell-smoke-helpers");
const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
const PNG_MIME = "image/png";
const TEST_EMAIL = "mnote.e2e@example.com";
const TEST_PASSWORD = "MnoteE2E123!";
// 说明:该 smoke 仍在验证 Convex media / OnlyOffice 的 cloud upload 兼容入口,不是 local-first 默认上传路径。
const PROBE_DOCX_PATH =
process.env.MNOTE_ONLYOFFICE_PROBE_DOCX ||
"/tmp/mnote-onlyoffice-manual/mnote-onlyoffice-upload-test.docx";
const SCREENSHOT_DIR =
process.env.MNOTE_UPLOAD_ENTRY_SCREENSHOT_DIR ||
"/mnt/Data1T/mnote/tmp/wolai-editor-parity/task175-rust-upload-entry";
function ensureProbeDocx() {
assert(fs.existsSync(PROBE_DOCX_PATH), `缺少 Office 探测文件:${PROBE_DOCX_PATH}`);
return {
name: "task175-slash-attachment.docx",
mimeType: DOCX_MIME,
buffer: fs.readFileSync(PROBE_DOCX_PATH),
};
}
function tinyPngBuffer() {
return Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=",
"base64",
);
}
function parseSetCookie(setCookie, origin) {
const [nameValue] = String(setCookie || "").split(";");
const separator = nameValue.indexOf("=");
if (separator <= 0) return null;
return {
name: nameValue.slice(0, separator).trim(),
value: nameValue.slice(separator + 1).trim(),
domain: new URL(origin).hostname,
path: "/",
httpOnly: /;\s*httponly\b/i.test(setCookie),
sameSite: "Lax",
};
}
async function forceTestAccountLogin(context) {
const response = await context.request.fetch(`${BASE_URL}/api/auth`, {
method: "POST",
data: {
action: "auth:signIn",
args: {
provider: "password",
params: {
email: TEST_EMAIL,
password: TEST_PASSWORD,
flow: "signIn",
},
},
},
headers: { "content-type": "application/json" },
timeout: UI_TIMEOUT_MS,
});
const payload = await response.json().catch(async () => ({ raw: await response.text() }));
assert(response.ok(), `测试账号登录失败:${response.status()} ${JSON.stringify(payload)}`);
const cookies = response
.headersArray()
.filter((header) => header.name.toLowerCase() === "set-cookie")
.map((header) => parseSetCookie(header.value, BASE_URL))
.filter(Boolean);
assert(cookies.length > 0, "测试账号登录响应缺少 set-cookie");
await context.addCookies(cookies);
}
async function waitForRuntimeEditor(page) {
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 });
return editor;
}
async function waitForUploadResponse(page, fileName, action) {
const response = await page.waitForResponse(
async (candidate) => {
if (!candidate.url().includes("/api/media/upload") || candidate.request().method() !== "POST") {
return false;
}
const payload = await candidate.json().catch(() => null);
return Boolean(payload?.asset?.id && (!fileName || payload.asset.file_name === fileName));
},
{ timeout: UI_TIMEOUT_MS },
);
const payload = await response.json();
assert(response.ok(), `${action} 上传失败:${response.status()} ${JSON.stringify(payload)}`);
assert(payload?.asset?.id, `${action} 上传响应缺少 asset.id${JSON.stringify(payload)}`);
return payload.asset;
}
async function waitForAssetRow(page, assetId, action) {
try {
await page.waitForFunction(
(targetAssetId) =>
Array.from(document.querySelectorAll(`[data-testid="filetree-asset-row"][data-asset-id="${targetAssetId}"]`)).some(
(row) =>
row instanceof HTMLElement &&
window.getComputedStyle(row).display !== "none" &&
window.getComputedStyle(row).visibility !== "hidden" &&
row.getClientRects().length > 0,
),
assetId,
{ timeout: UI_TIMEOUT_MS },
);
} catch (error) {
const debug = await page.evaluate((targetAssetId) => ({
mode: document.documentElement.getAttribute("data-mnote-sidebar-tree-mode"),
lastUpload: document.documentElement.getAttribute("data-mnote-last-upload-asset-id"),
targetCount: document.querySelectorAll(`[data-testid="filetree-asset-row"][data-asset-id="${targetAssetId}"]`).length,
assets: Array.from(document.querySelectorAll('[data-testid="filetree-asset-row"]')).slice(-12).map((row) => ({
id: row.getAttribute("data-asset-id"),
visible: row instanceof HTMLElement && window.getComputedStyle(row).display !== "none" && window.getComputedStyle(row).visibility !== "hidden" && row.getClientRects().length > 0,
text: row.textContent,
})),
body: document.body.innerText.slice(0, 1200),
}), assetId).catch((debugError) => ({ debugError: String(debugError) }));
throw new Error(`${action} 上传后的文件树附件行不可见:${assetId} ${JSON.stringify(debug)}`);
}
const title = await page.evaluate((targetAssetId) => {
const row = Array.from(document.querySelectorAll(`[data-testid="filetree-asset-row"][data-asset-id="${targetAssetId}"]`)).find(
(candidate) =>
candidate instanceof HTMLElement &&
window.getComputedStyle(candidate).display !== "none" &&
window.getComputedStyle(candidate).visibility !== "hidden" &&
candidate.getClientRects().length > 0,
);
return row?.querySelector(".tree-link-title")?.textContent?.trim() || "";
}, assetId);
assert(title.length > 0, `${action} 上传后的文件树附件行缺少标题`);
}
async function waitForEditorOfficeAttachment(page, assetId, fileName, action) {
const attachment = page
.locator(`.editor-surface .ProseMirror a[data-mnote-attachment-link="true"][href*="/onlyoffice"][href*="${assetId}"]`)
.first();
await attachment.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const text = (await attachment.innerText()).trim();
assert(text.includes(fileName), `${action} 正文附件标题不正确:${text}`);
const href = await attachment.getAttribute("href");
assert(href && href.includes("/onlyoffice?"), `${action} 正文 Office 附件 href 应指向 /onlyoffice,实际:${href}`);
assert(
href && href.startsWith("/onlyoffice?"),
`${action} 正文 Office 附件 href 应保存为相对 /onlyoffice 链接,实际:${href}`,
);
assert(href.includes(`assetId=${encodeURIComponent(assetId)}`), `${action} 正文 Office 附件 href 缺少 assetId${href}`);
return attachment;
}
async function assertAttachmentActions(page, attachment, action) {
await attachment.hover({ timeout: UI_TIMEOUT_MS });
const actions = page.locator('[data-testid="mnote-attachment-actions"]').first();
await actions.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await actions.locator('[data-testid="mnote-attachment-action-menu"]').click({ timeout: UI_TIMEOUT_MS });
const menu = page.locator('[data-testid="mnote-tree-context-menu"][data-kind="attachment"]').first();
await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const text = await menu.innerText();
for (const label of ["拷贝副本", "删除", "复制链接", "弹窗预览", "右侧预览", "下载", "更换文件", "重命名", "添加说明文字"]) {
assert(text.includes(label), `${action} 附件三点菜单缺少“${label}”:${text}`);
}
await page.keyboard.press("Escape").catch(() => undefined);
}
async function screenshot(page, name) {
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
await page.screenshot({
path: path.join(SCREENSHOT_DIR, `${name}.png`),
fullPage: false,
timeout: UI_TIMEOUT_MS,
});
}
async function uploadViaSlash(page, itemTestId, filePayload, action) {
const editor = await waitForRuntimeEditor(page);
await editor.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.type("/");
const slash = page.locator('[data-testid="mnote-leptos-tiptap-slash-menu"]').first();
await slash.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const slashText = await slash.innerText();
assert(slashText.includes("媒体与附件"), `slash 菜单缺少媒体与附件分组:${slashText}`);
const item = page.locator(`[data-testid="${itemTestId}"]`).first();
await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await item.scrollIntoViewIfNeeded({ timeout: UI_TIMEOUT_MS });
await screenshot(page, `slash-${itemTestId}`);
const [fileChooser] = await Promise.all([
page.waitForEvent("filechooser", { timeout: UI_TIMEOUT_MS }),
item.click({ timeout: UI_TIMEOUT_MS }),
]);
const uploadResponse = waitForUploadResponse(page, filePayload.name, action);
await fileChooser.setFiles(filePayload);
const asset = await uploadResponse;
await waitForAssetRow(page, asset.id, action);
return asset;
}
async function dispatchFileDrop(page, selector, filePayload, action) {
const uploadPromise = waitForUploadResponse(page, filePayload.name, action);
await page.evaluate(
({ selector, fileName, mimeType, bytes }) => {
const target = Array.from(document.querySelectorAll(selector)).find(
(candidate) => candidate instanceof HTMLElement && candidate.getClientRects().length > 0,
) || document.querySelector(selector);
if (!(target instanceof HTMLElement)) {
throw new Error(`拖放目标不存在:${selector}`);
}
const dataTransfer = new DataTransfer();
dataTransfer.items.add(new File([new Uint8Array(bytes)], fileName, { type: mimeType }));
target.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer }));
target.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer }));
},
{
selector,
fileName: filePayload.name,
mimeType: filePayload.mimeType,
bytes: Array.from(filePayload.buffer),
},
);
const asset = await uploadPromise;
await waitForAssetRow(page, asset.id, action);
return asset;
}
async function main() {
const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
const networkNotes = [];
page.on("response", async (response) => {
if (!/\/api\/(media\/upload|tree\/filetree\/upload-target-preflight)/.test(response.url())) return;
const text = await response.text().catch(() => "");
networkNotes.push({
status: response.status(),
url: response.url(),
body: text.slice(0, 1000),
});
});
page.on("pageerror", (error) => {
networkNotes.push({ type: "pageerror", message: error.message });
});
page.on("console", (message) => {
if (["error", "warning"].includes(message.type())) {
networkNotes.push({ type: message.type(), message: message.text().slice(0, 1000) });
}
});
page.on("dialog", async (dialog) => {
networkNotes.push({ type: "dialog", message: dialog.message() });
await dialog.dismiss().catch(() => undefined);
});
let target = null;
try {
await forceTestAccountLogin(context);
await ensureAuthenticated(page, context.request);
target = await createTempDocument(context.request, null);
await openDocument(page, target.workspaceId, target.documentId);
await openFilesystemView(page);
const slashAttachment = ensureProbeDocx();
const slashImage = {
name: "task175-slash-image.png",
mimeType: PNG_MIME,
buffer: tinyPngBuffer(),
};
const treeDropAttachment = {
name: "task175-filetree-drop.docx",
mimeType: DOCX_MIME,
buffer: slashAttachment.buffer,
};
const editorDropImage = {
name: "task175-editor-drop.png",
mimeType: PNG_MIME,
buffer: tinyPngBuffer(),
};
const attachmentAsset = await uploadViaSlash(
page,
"slash-item-upload-attachment",
slashAttachment,
"slash 上传附件",
);
const editorAttachment = await waitForEditorOfficeAttachment(
page,
attachmentAsset.id,
slashAttachment.name,
"slash 上传附件",
);
await assertAttachmentActions(page, editorAttachment, "slash 上传附件");
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForRuntimeEditor(page);
await waitForEditorOfficeAttachment(page, attachmentAsset.id, slashAttachment.name, "刷新后正文附件");
await openFilesystemView(page);
const imageAsset = await uploadViaSlash(page, "slash-item-image", slashImage, "slash 上传图片");
const docRowSelector = `[data-testid="filetree-doc-row"][data-document-id="${target.documentId}"]`;
const droppedAttachment = await dispatchFileDrop(page, docRowSelector, treeDropAttachment, "文件树拖入附件");
const editorStageSelector = '[data-testid="mnote-leptos-tiptap-editor-stage"]';
const droppedImage = await dispatchFileDrop(page, editorStageSelector, editorDropImage, "主编辑区拖入图片");
await page.waitForFunction(
(fileName) => {
const images = Array.from(document.querySelectorAll(".editor-surface .ProseMirror img[src]"));
return images.some((image) => image.getAttribute("alt") === fileName || image.getAttribute("title") === fileName);
},
editorDropImage.name,
{ timeout: UI_TIMEOUT_MS },
);
console.log(
JSON.stringify(
{
ok: true,
workspaceId: target.workspaceId,
documentId: target.documentId,
uploadedAssetIds: [
attachmentAsset.id,
imageAsset.id,
droppedAttachment.id,
droppedImage.id,
],
screenshotDir: SCREENSHOT_DIR,
},
null,
2,
),
);
} catch (error) {
if (networkNotes.length) {
console.error(`上传入口调试信息:${JSON.stringify(networkNotes.slice(-20), null, 2)}`);
}
throw error;
} finally {
if (target?.documentId) {
await purgeDocument(context.request, target.documentId).catch((error) => {
console.warn(`清理临时页面失败:${error instanceof Error ? error.message : String(error)}`);
});
}
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -1,206 +0,0 @@
"use strict";
const assert = require("node:assert/strict");
const { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const CONVEX_URL = (process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_SELF_HOSTED_URL || "http://127.0.0.1:3210").replace(/\/+$/, "");
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
// 说明:该 smoke 覆盖 convex-source trash 兼容路径,不代表 local-first 默认文件回收策略。
async function requestJson(request, path, init = {}) {
const response = await request.fetch(`${BASE_URL}${path}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 20_000,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(`${path} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
return payload;
}
async function convexCall(context, kind, path, args) {
const cookies = await context.cookies(BASE_URL);
const jwt = cookies.find((cookie) => cookie.name === "__convexAuthJWT")?.value;
assert(jwt, "缺少 __convexAuthJWT cookie");
const response = await fetch(`${CONVEX_URL}/api/${kind}`, {
method: "POST",
headers: {
authorization: `Bearer ${jwt}`,
"content-type": "application/json",
"Convex-Client": "mnote-trash-empty-smoke",
},
body: JSON.stringify({ path, format: "convex_encoded_json", args: [args] }),
});
const body = await response.json();
if (!response.ok || body.status !== "success") {
throw new Error(`Convex ${kind} ${path} 失败: ${response.status} ${JSON.stringify(body)}`);
}
return body.value;
}
async function main() {
const stamp = Date.now();
const email = `mnote.trash.${stamp}@example.com`;
const username = `trash-${stamp}`;
const assetId = `asset_trash_${stamp}`;
const mindmapId = `mind_trash_${stamp}`;
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
const request = context.request;
try {
await requestJson(request, "/api/auth", {
method: "POST",
data: {
action: "auth:signIn",
args: {
provider: "password",
params: { email, password: e2ePassword(), flow: "signUp", name: username },
},
},
});
await page.goto(`${BASE_URL}/`, { waitUntil: "domcontentloaded", timeout: 20_000 });
const currentUser = await convexCall(context, "query", "users:currentUser", {});
const userId = currentUser?._id;
assert(userId, "缺少 Convex currentUser._id");
const workspaces = await convexCall(context, "query", "workspaces:fetchWorkspaceSummaries", {});
const workspaceId = workspaces.activeWorkspaceId;
assert(workspaceId, "缺少隔离 workspaceId");
const root = await requestJson(request, "/api/tree/commands", {
method: "POST",
data: { action: "create", workspaceId, title: `TEST-10REVIEW-07-P3-root-${stamp}` },
});
const rootId = root.result.documentId;
const child = await requestJson(request, "/api/tree/commands", {
method: "POST",
data: { action: "create", workspaceId, parentId: rootId, title: `TEST-10REVIEW-07-P3-child-${stamp}` },
});
const childId = child.result.documentId;
const resourceDoc = await requestJson(request, "/api/tree/commands", {
method: "POST",
data: { action: "create", workspaceId, title: `TEST-10REVIEW-07-P3-resources-${stamp}` },
});
const resourceDocId = resourceDoc.result.documentId;
await convexCall(context, "mutation", "mediaAssets:create", {
userId,
asset: {
id: assetId,
workspace_id: workspaceId,
document_id: resourceDocId,
asset_type: "file",
file_url: null,
thumbnail_url: null,
storage_id: null,
bucket: null,
storage_path: null,
file_name: `TEST-10REVIEW-07-P3-file-${stamp}.txt`,
file_size: 12,
mime_type: "text/plain",
},
});
await convexCall(context, "mutation", "mindmaps:put", {
docId: resourceDocId,
mindmapId,
data: { root: { data: { text: `TEST-10REVIEW-07-P3-mind-${stamp}` }, children: [] } },
createOnly: true,
});
const table = await convexCall(context, "mutation", "tables:create", {
userId,
workspaceId,
documentId: resourceDocId,
title: `TEST-10REVIEW-07-P3-table-${stamp}`,
schema: {},
snapshot: null,
});
const tableId = table.id;
await convexCall(context, "mutation", "tables:update", {
userId,
tableId,
rows: [{ cells: ["p3-row"] }],
});
await requestJson(request, "/api/tree/commands", {
method: "POST",
data: { action: "archive", workspaceId, documentId: rootId },
});
await requestJson(request, "/api/media/batch", {
method: "POST",
data: { action: "delete", assetIds: [assetId] },
});
await requestJson(request, `/api/mindmap/${encodeURIComponent(resourceDocId)}/${encodeURIComponent(mindmapId)}`, {
method: "DELETE",
});
await requestJson(request, `/api/tables/${encodeURIComponent(tableId)}`, { method: "DELETE" });
const trashedDocs = await convexCall(context, "query", "documents:listTrashedByWorkspace", { workspaceId });
assert(trashedDocs.some((doc) => doc.id === rootId), "根页面未进入垃圾箱");
assert(trashedDocs.some((doc) => doc.id === childId), "子页面未级联进入垃圾箱");
const mediaBefore = await convexCall(context, "query", "mediaAssets:getById", { userId, id: assetId });
assert(mediaBefore?.deleted_at, "附件未进入垃圾箱");
const mindmapsBefore = await convexCall(context, "query", "mindmaps:listByWorkspace", { workspaceId, includeDeleted: true });
assert(mindmapsBefore.some((row) => row.mindmap_id === mindmapId && row.deleted_at), "mindmap 未进入垃圾箱");
const tablesBefore = await convexCall(context, "query", "tables:listByWorkspaceForSearch", {
userId,
workspaceId,
includeArchived: true,
limit: 100,
});
assert(tablesBefore.some((row) => row.id === tableId && row.deleted_at), "table 未进入垃圾箱");
const pageEmpty = await requestJson(request, "/api/documents/empty-trash", { method: "POST", data: { workspaceId } });
const mediaEmpty = await requestJson(request, "/api/media/empty-trash", { method: "POST", data: { workspaceId } });
const mindmapEmpty = await requestJson(request, "/api/mindmap-trash/empty", { method: "POST", data: { workspaceId } });
const tableEmpty = await requestJson(request, "/api/tables/empty-trash", { method: "POST", data: { workspaceId } });
assert.equal(await convexCall(context, "query", "documents:getMeta", { id: rootId }), null, "根页面 purge 后仍可查询");
assert.equal(await convexCall(context, "query", "documents:getMeta", { id: childId }), null, "子页面 purge 后仍可查询");
assert.equal(await convexCall(context, "query", "mediaAssets:getById", { userId, id: assetId }), null, "附件 purge 后仍可查询");
const mindmapsAfter = await convexCall(context, "query", "mindmaps:listByWorkspace", { workspaceId, includeDeleted: true });
assert(!mindmapsAfter.some((row) => row.mindmap_id === mindmapId), "mindmap purge 后仍存在");
assert.equal(await convexCall(context, "query", "tables:get", { userId, tableId }), null, "table purge 后仍可查询");
assert.equal((await convexCall(context, "query", "tables:getRows", { tableId })).length, 0, "table rows purge 后仍存在");
await requestJson(request, "/api/documents/purge", { method: "POST", data: { documentId: resourceDocId } }).catch(() => null);
console.log(JSON.stringify({
ok: true,
email,
userId,
workspaceId,
rootId,
childId,
resourceDocId,
assetId,
mindmapId,
tableId,
pageEmpty: pageEmpty.result,
mediaEmpty: mediaEmpty.result,
mindmapEmpty: mindmapEmpty.result,
tableEmpty: tableEmpty.result,
}, null, 2));
} finally {
await browser.close();
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -1,535 +0,0 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { chromium } = require("playwright");
const TASK = "task428-filetree-bulk-delete-selection-smoke";
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const CONVEX_URL = (process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_SELF_HOSTED_URL || "http://127.0.0.1:3210").replace(/\/+$/, "");
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
// 说明:该 smoke 覆盖 convex-source 批量删除兼容路径,不代表 local-first 默认 File Tree 主链。
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
async function writeResult(payload) {
await fs.mkdir(OUTPUT_DIR, { recursive: true });
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
}
async function requestJson(request, requestPath, init = {}) {
const response = await request.fetch(`${BASE_URL}${requestPath}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 20_000,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
return payload;
}
async function convexCall(context, kind, convexPath, args) {
const cookies = await context.cookies(BASE_URL);
const jwt = cookies.find((cookie) => cookie.name === "__convexAuthJWT")?.value;
assert(jwt, "缺少 __convexAuthJWT cookie");
const response = await fetch(`${CONVEX_URL}/api/${kind}`, {
method: "POST",
headers: {
authorization: `Bearer ${jwt}`,
"content-type": "application/json",
"Convex-Client": TASK,
},
body: JSON.stringify({ path: convexPath, format: "convex_encoded_json", args: [args] }),
});
const body = await response.json();
if (!response.ok || body.status !== "success") {
throw new Error(`Convex ${kind} ${convexPath} 失败: ${response.status} ${JSON.stringify(body)}`);
}
return body.value;
}
function docRowSelector(documentId) {
return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="doc:${cssEscape(documentId)}"]`;
}
function assetRowSelector(assetId) {
return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-asset-id="${cssEscape(assetId)}"]`;
}
function cssEscape(value) {
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
async function openDocumentFileTree(page, workspaceId, documentId) {
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
waitUntil: "commit",
timeout: UI_TIMEOUT_MS,
});
await page.waitForFunction(
() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
const fileTab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
return Boolean(fileRoot || fileTab);
},
undefined,
{ timeout: UI_TIMEOUT_MS },
);
await page.evaluate(() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
const visible = (node) =>
node instanceof HTMLElement &&
!node.hidden &&
getComputedStyle(node).display !== "none" &&
getComputedStyle(node).visibility !== "hidden" &&
node.getClientRects().length > 0;
if (visible(fileRoot)) return;
const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
if (tab instanceof HTMLElement) tab.click();
});
await page.waitForFunction(
() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
return fileRoot instanceof HTMLElement && fileRoot.getClientRects().length > 0;
},
undefined,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForRow(page, selector, label) {
await page.locator(selector).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }).catch(async (error) => {
const text = await page.locator("#sidebar-file-tree-root").innerText({ timeout: 3_000 }).catch(() => "");
throw new Error(`${label} 未出现: ${error.message}; filetree=${text.slice(0, 2000)}`);
});
}
async function expandDocIfNeeded(page, documentId) {
const selector = docRowSelector(documentId);
await waitForRow(page, selector, `页面 ${documentId}`);
const expanded = await page.locator(selector).first().getAttribute("aria-expanded").catch(() => null);
if (expanded === "true") return;
const clicked = await page.locator(`${selector} [data-testid="filetree-toggle"]`).first().click({ timeout: 2_000 }).then(() => true).catch(() => false);
if (!clicked) {
await page.locator(selector).first().dblclick({ timeout: 2_000 }).catch(() => undefined);
}
}
async function selectedRows(page) {
return page.locator('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-selected="true"]').evaluateAll((rows) =>
rows.map((row) => ({
rowId: row instanceof HTMLElement ? row.dataset.rowId || "" : "",
assetId: row instanceof HTMLElement ? row.dataset.assetId || "" : "",
documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "",
text: row.textContent || "",
})),
);
}
async function clickRow(page, selector, options = {}) {
await page.locator(selector).first().click({ timeout: UI_TIMEOUT_MS, ...options });
}
async function rightClickRow(page, selector) {
await page.locator(selector).first().click({ button: "right", timeout: UI_TIMEOUT_MS });
}
async function closeContextMenu(page) {
await page.keyboard.press("Escape").catch(() => undefined);
await page.waitForTimeout(150);
}
async function deleteSelectionAndCaptureConfirm(page, key, focusSelector) {
const target = focusSelector ? page.locator(focusSelector).first() : null;
if (focusSelector) {
await target.focus({ timeout: UI_TIMEOUT_MS });
}
const dialogPromise = page.waitForEvent("dialog", { timeout: UI_TIMEOUT_MS }).then(async (dialog) => {
const message = dialog.message();
await dialog.accept();
return message;
});
if (target) {
await target.press(key, { timeout: UI_TIMEOUT_MS });
} else {
await page.keyboard.press(key);
}
return await dialogPromise.catch((error) => {
throw new Error(`${key} 未触发删除确认弹窗: ${error instanceof Error ? error.message : String(error)}`);
});
}
async function deleteSelectionAndCaptureConfirmAndAlert(page, key, focusSelector) {
const confirmPromise = page.waitForEvent("dialog", { timeout: UI_TIMEOUT_MS }).then(async (dialog) => {
const message = dialog.message();
await dialog.accept();
return message;
});
await page.locator(focusSelector).first().press(key, { timeout: UI_TIMEOUT_MS });
const confirm = await confirmPromise.catch((error) => {
throw new Error(`${key} 未触发删除确认弹窗: ${error instanceof Error ? error.message : String(error)}`);
});
const alert = await page.waitForEvent("dialog", { timeout: UI_TIMEOUT_MS }).then(async (dialog) => {
const message = dialog.message();
await dialog.accept();
return message;
}).catch((error) => {
throw new Error(`${key} 未触发失败摘要弹窗: ${error instanceof Error ? error.message : String(error)}`);
});
return { confirm, alert };
}
async function expectDetached(page, selector, label) {
await page.locator(selector).first().waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }).catch(async (error) => {
const state = await selectedRows(page).catch(() => []);
throw new Error(`${label} 未从 filetree 消失: ${error.message}; selection=${JSON.stringify(state)}`);
});
}
async function createPage(request, workspaceId, title, parentId = null) {
const payload = await requestJson(request, "/api/tree/commands", {
method: "POST",
data: {
action: "create",
workspaceId,
parentId,
title,
},
});
const result = payload.result || payload;
assert(result.documentId, `创建页面失败: ${JSON.stringify(payload)}`);
return result.documentId;
}
async function seedFixture(context, request, stamp) {
const currentUser = await convexCall(context, "query", "users:currentUser", {});
const userId = currentUser?._id;
assert(userId, "缺少 Convex currentUser._id");
const workspaces = await convexCall(context, "query", "workspaces:fetchWorkspaceSummaries", {});
const workspaceId = workspaces.activeWorkspaceId;
assert(workspaceId, "缺少隔离 workspaceId");
const rootId = await createPage(request, workspaceId, `TEST-10REVIEW-07-P4-root-${stamp}`);
const childId = await createPage(request, workspaceId, `TEST-10REVIEW-07-P4-child-${stamp}`, rootId);
const siblingId = await createPage(request, workspaceId, `TEST-10REVIEW-07-P4-sibling-${stamp}`);
const failureDocId = await createPage(request, workspaceId, `TEST-10REVIEW-07-P4-failure-doc-${stamp}`);
const resourceDocId = await createPage(request, workspaceId, `TEST-10REVIEW-07-P4-resources-${stamp}`);
const fileAssetA = `asset_p4_a_${stamp}`;
const fileAssetB = `asset_p4_b_${stamp}`;
const failureAssetId = `asset_p4_failure_${stamp}`;
const mindmapId = `mind_p4_${stamp}`;
for (const [assetId, fileName] of [
[fileAssetA, `TEST-10REVIEW-07-P4-file-a-${stamp}.txt`],
[fileAssetB, `TEST-10REVIEW-07-P4-file-b-${stamp}.txt`],
[failureAssetId, `TEST-10REVIEW-07-P4-failure-file-${stamp}.txt`],
]) {
await convexCall(context, "mutation", "mediaAssets:create", {
userId,
asset: {
id: assetId,
workspace_id: workspaceId,
document_id: resourceDocId,
asset_type: "file",
file_url: null,
thumbnail_url: null,
storage_id: null,
bucket: null,
storage_path: null,
file_name: fileName,
file_size: 12,
mime_type: "text/plain",
},
});
}
await convexCall(context, "mutation", "mindmaps:put", {
docId: resourceDocId,
mindmapId,
data: { root: { data: { text: `TEST-10REVIEW-07-P4-mind-${stamp}` }, children: [] } },
createOnly: true,
});
const table = await convexCall(context, "mutation", "tables:create", {
userId,
workspaceId,
documentId: resourceDocId,
title: `TEST-10REVIEW-07-P4-table-${stamp}`,
schema: {},
snapshot: null,
});
await convexCall(context, "mutation", "tables:update", {
userId,
tableId: table.id,
rows: [{ cells: ["p4-row"] }],
});
return {
userId,
workspaceId,
rootId,
childId,
siblingId,
failureDocId,
resourceDocId,
fileAssetA,
fileAssetB,
failureAssetId,
mindmapId,
tableId: table.id,
};
}
async function assertTrashState(context, fixture, stage) {
const trashedDocs = await convexCall(context, "query", "documents:listTrashedByWorkspace", {
workspaceId: fixture.workspaceId,
});
for (const documentId of stage.documentIds || []) {
assert(trashedDocs.some((doc) => doc.id === documentId), `${stage.name}: 页面 ${documentId} 未进入垃圾箱`);
}
for (const assetId of stage.fileAssetIds || []) {
const row = await convexCall(context, "query", "mediaAssets:getById", { userId: fixture.userId, id: assetId });
assert(row?.deleted_at, `${stage.name}: 附件 ${assetId} 未进入垃圾箱`);
}
if (stage.mindmapId) {
const mindmaps = await convexCall(context, "query", "mindmaps:listByWorkspace", {
workspaceId: fixture.workspaceId,
includeDeleted: true,
});
assert(
mindmaps.some((row) => row.mindmap_id === stage.mindmapId && row.deleted_at),
`${stage.name}: mindmap ${stage.mindmapId} 未进入垃圾箱`,
);
}
if (stage.tableId) {
const tables = await convexCall(context, "query", "tables:listByWorkspaceForSearch", {
userId: fixture.userId,
workspaceId: fixture.workspaceId,
includeArchived: true,
limit: 100,
});
assert(tables.some((row) => row.id === stage.tableId && row.deleted_at), `${stage.name}: table ${stage.tableId} 未进入垃圾箱`);
}
}
async function cleanup(context, request, fixture) {
if (!fixture?.workspaceId) return;
await requestJson(request, "/api/documents/empty-trash", { method: "POST", data: { workspaceId: fixture.workspaceId } }).catch(() => null);
await requestJson(request, "/api/media/empty-trash", { method: "POST", data: { workspaceId: fixture.workspaceId } }).catch(() => null);
await requestJson(request, "/api/mindmap-trash/empty", { method: "POST", data: { workspaceId: fixture.workspaceId } }).catch(() => null);
await requestJson(request, "/api/tables/empty-trash", { method: "POST", data: { workspaceId: fixture.workspaceId } }).catch(() => null);
if (fixture.resourceDocId) {
if (fixture.failureAssetId) {
await requestJson(request, "/api/media/batch", {
method: "POST",
data: { action: "delete", assetIds: [fixture.failureAssetId] },
}).catch(() => null);
await requestJson(request, "/api/media/empty-trash", { method: "POST", data: { workspaceId: fixture.workspaceId } }).catch(() => null);
}
await requestJson(request, "/api/documents/purge", { method: "POST", data: { documentId: fixture.resourceDocId } }).catch(() => null);
}
await convexCall(context, "query", "users:currentUser", {}).catch(() => null);
}
async function main() {
const stamp = Date.now();
const result = {
ok: false,
task: TASK,
baseUrl: BASE_URL,
convexUrl: CONVEX_URL,
email: `mnote.filetree.${stamp}@example.com`,
requests: [],
confirms: [],
fixture: null,
};
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
const request = context.request;
let fixture = null;
page.on("request", (req) => {
const url = req.url();
if (
url.includes("/api/tree/filetree/delete-preflight") ||
url.includes("/api/tree/commands") ||
url.includes("/api/media/batch") ||
url.includes("/api/mindmap/") ||
url.includes("/api/tables/")
) {
result.requests.push({
method: req.method(),
url,
body: req.postData() || null,
});
}
});
try {
await requestJson(request, "/api/auth", {
method: "POST",
data: {
action: "auth:signIn",
args: {
provider: "password",
params: { email: result.email, password: e2ePassword(), flow: "signUp", name: `filetree-${stamp}` },
},
},
});
await page.goto(`${BASE_URL}/`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
fixture = await seedFixture(context, request, stamp);
result.fixture = fixture;
await openDocumentFileTree(page, fixture.workspaceId, fixture.resourceDocId);
for (const documentId of [fixture.rootId, fixture.siblingId, fixture.failureDocId, fixture.resourceDocId]) {
await waitForRow(page, docRowSelector(documentId), `页面 ${documentId}`);
}
await expandDocIfNeeded(page, fixture.rootId);
await waitForRow(page, docRowSelector(fixture.childId), `子页面 ${fixture.childId}`);
await expandDocIfNeeded(page, fixture.resourceDocId);
for (const assetId of [fixture.fileAssetA, fixture.fileAssetB, fixture.failureAssetId, fixture.mindmapId, fixture.tableId]) {
await waitForRow(page, assetRowSelector(assetId), `资源 ${assetId}`);
}
const accel = process.platform === "darwin" ? "Meta" : "Control";
await clickRow(page, docRowSelector(fixture.rootId));
await clickRow(page, assetRowSelector(fixture.fileAssetA), { modifiers: [accel] });
let selected = await selectedRows(page);
assert(selected.length >= 2, `Ctrl/Cmd 多选后选区数量异常: ${JSON.stringify(selected)}`);
await clickRow(page, docRowSelector(fixture.rootId));
await clickRow(page, docRowSelector(fixture.childId), { modifiers: ["Shift"] });
selected = await selectedRows(page);
assert(selected.length >= 2, `Shift 范围选择后选区数量异常: ${JSON.stringify(selected)}`);
await clickRow(page, docRowSelector(fixture.rootId));
await clickRow(page, assetRowSelector(fixture.fileAssetA), { modifiers: [accel] });
const beforeContextSelected = await selectedRows(page);
await rightClickRow(page, assetRowSelector(fixture.fileAssetA));
const afterContextSelected = await selectedRows(page);
assert(
afterContextSelected.length === beforeContextSelected.length,
`右键已选项不应清空多选: before=${JSON.stringify(beforeContextSelected)} after=${JSON.stringify(afterContextSelected)}`,
);
await closeContextMenu(page);
await rightClickRow(page, assetRowSelector(fixture.fileAssetB));
selected = await selectedRows(page);
assert(
selected.length === 1 && selected[0].assetId === fixture.fileAssetB,
`右键未选项应切换 action target: ${JSON.stringify(selected)}`,
);
await closeContextMenu(page);
await clickRow(page, docRowSelector(fixture.rootId));
await clickRow(page, docRowSelector(fixture.childId), { modifiers: [accel] });
await clickRow(page, assetRowSelector(fixture.fileAssetA), { modifiers: [accel] });
await clickRow(page, assetRowSelector(fixture.mindmapId), { modifiers: [accel] });
await clickRow(page, assetRowSelector(fixture.tableId), { modifiers: [accel] });
selected = await selectedRows(page);
assert(selected.length >= 5, `Delete 前混合选区数量异常: ${JSON.stringify(selected)}`);
const deleteConfirm = await deleteSelectionAndCaptureConfirm(page, "Delete", assetRowSelector(fixture.tableId));
result.confirms.push({ key: "Delete", message: deleteConfirm });
assert(deleteConfirm.includes("1 个页面"), `Delete 确认文案缺少页面计数: ${deleteConfirm}`);
assert(deleteConfirm.includes("1 个附件"), `Delete 确认文案缺少附件计数: ${deleteConfirm}`);
assert(deleteConfirm.includes("1 个思维导图"), `Delete 确认文案缺少思维导图计数: ${deleteConfirm}`);
assert(deleteConfirm.includes("1 个在线表格"), `Delete 确认文案缺少在线表格计数: ${deleteConfirm}`);
await expectDetached(page, docRowSelector(fixture.rootId), "root 页面");
await expectDetached(page, docRowSelector(fixture.childId), "child 页面");
await expectDetached(page, assetRowSelector(fixture.fileAssetA), "普通附件 A");
await expectDetached(page, assetRowSelector(fixture.mindmapId), "mindmap");
await expectDetached(page, assetRowSelector(fixture.tableId), "table");
await assertTrashState(context, fixture, {
name: "Delete",
documentIds: [fixture.rootId, fixture.childId],
fileAssetIds: [fixture.fileAssetA],
mindmapId: fixture.mindmapId,
tableId: fixture.tableId,
});
await clickRow(page, docRowSelector(fixture.siblingId));
await clickRow(page, assetRowSelector(fixture.fileAssetB), { modifiers: [accel] });
selected = await selectedRows(page);
assert(selected.length >= 2, `Backspace 前混合选区数量异常: ${JSON.stringify(selected)}`);
const backspaceConfirm = await deleteSelectionAndCaptureConfirm(page, "Backspace", assetRowSelector(fixture.fileAssetB));
result.confirms.push({ key: "Backspace", message: backspaceConfirm });
assert(backspaceConfirm.includes("1 个页面"), `Backspace 确认文案缺少页面计数: ${backspaceConfirm}`);
assert(backspaceConfirm.includes("1 个附件"), `Backspace 确认文案缺少附件计数: ${backspaceConfirm}`);
await expectDetached(page, docRowSelector(fixture.siblingId), "sibling 页面");
await expectDetached(page, assetRowSelector(fixture.fileAssetB), "普通附件 B");
await assertTrashState(context, fixture, {
name: "Backspace",
documentIds: [fixture.siblingId],
fileAssetIds: [fixture.fileAssetB],
});
await clickRow(page, docRowSelector(fixture.failureDocId));
await clickRow(page, assetRowSelector(fixture.failureAssetId), { modifiers: [accel] });
let mediaBatchFailed = false;
await page.route("**/api/media/batch", async (route) => {
const postData = route.request().postData() || "";
if (postData.includes(fixture.failureAssetId)) {
mediaBatchFailed = true;
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ error: "task428 forced media failure" }),
});
return;
}
await route.continue();
});
const failureDialogs = await deleteSelectionAndCaptureConfirmAndAlert(page, "Delete", assetRowSelector(fixture.failureAssetId));
await page.unroute("**/api/media/batch").catch(() => undefined);
result.confirms.push({ key: "Delete-partial", message: failureDialogs.confirm });
result.partialFailureAlert = failureDialogs.alert;
assert(mediaBatchFailed, "部分失败场景未拦截到 media batch 请求");
assert(failureDialogs.alert.includes("部分对象删除失败"), `失败摘要文案不正确: ${failureDialogs.alert}`);
await expectDetached(page, docRowSelector(fixture.failureDocId), "partial failure 页面");
await waitForRow(page, assetRowSelector(fixture.failureAssetId), "partial failure 附件应保留");
await assertTrashState(context, fixture, {
name: "PartialFailure",
documentIds: [fixture.failureDocId],
});
const failureAsset = await convexCall(context, "query", "mediaAssets:getById", {
userId: fixture.userId,
id: fixture.failureAssetId,
});
assert(failureAsset && !failureAsset.deleted_at, "部分失败后附件不应被错误移入垃圾箱");
result.ok = true;
await writeResult(result);
} catch (error) {
result.error = error instanceof Error ? error.stack || error.message : String(error);
result.selectedRows = await selectedRows(page).catch(() => []);
result.filetreeText = await page.locator("#sidebar-file-tree-root").innerText({ timeout: 3_000 }).catch(() => "");
await writeResult(result);
throw error;
} finally {
await cleanup(context, request, fixture).catch((error) => {
console.warn(`清理 task428 临时数据失败: ${error instanceof Error ? error.message : String(error)}`);
});
await browser.close();
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -1,323 +0,0 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { chromium } = require("playwright");
const TASK = "task429-trash-restore-location-reveal-smoke";
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const CONVEX_URL = (process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_SELF_HOSTED_URL || "http://127.0.0.1:3210").replace(/\/+$/, "");
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
async function writeResult(payload) {
await fs.mkdir(OUTPUT_DIR, { recursive: true });
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
}
async function requestJson(request, requestPath, init = {}) {
const response = await request.fetch(`${BASE_URL}${requestPath}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 20_000,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
return payload;
}
async function convexCall(context, kind, convexPath, args) {
const cookies = await context.cookies(BASE_URL);
const jwt = cookies.find((cookie) => cookie.name === "__convexAuthJWT")?.value;
assert(jwt, "缺少 __convexAuthJWT cookie");
const response = await fetch(`${CONVEX_URL}/api/${kind}`, {
method: "POST",
headers: {
authorization: `Bearer ${jwt}`,
"content-type": "application/json",
"Convex-Client": TASK,
},
body: JSON.stringify({ path: convexPath, format: "convex_encoded_json", args: [args] }),
});
const body = await response.json();
if (!response.ok || body.status !== "success") {
throw new Error(`Convex ${kind} ${convexPath} 失败: ${response.status} ${JSON.stringify(body)}`);
}
return body.value;
}
async function createPage(request, workspaceId, title, parentId = null) {
const payload = await requestJson(request, "/api/tree/commands", {
method: "POST",
data: {
action: "create",
workspaceId,
parentId,
title,
},
});
const result = payload.result || payload;
assert(result.documentId, `创建页面失败: ${JSON.stringify(payload)}`);
return result.documentId;
}
async function postTreeCommand(request, data) {
return await requestJson(request, "/api/tree/commands", {
method: "POST",
data,
});
}
function pageTreeRowSelector(documentId) {
const escaped = String(documentId).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
return `[data-testid="page-tree-row"][data-node-id="${escaped}"], [data-testid="wolai-sidebar-row"][data-node-id="${escaped}"], #sidebar-file-tree-root .tree-row[data-document-id="${escaped}"], #sidebar-file-tree-root .tree-row[data-doc-id="${escaped}"]`;
}
async function waitForPageTreeRow(page, documentId, label) {
await page.locator(pageTreeRowSelector(documentId)).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }).catch(async (error) => {
const text = await page.locator('[data-testid="sidebar-page-tree-shell"], #sidebar-file-tree-root, [data-testid="wolai-sidebar"]').innerText({ timeout: 3_000 }).catch(() => "");
throw new Error(`${label} 未出现在侧边栏树: ${error.message}; tree=${text.slice(0, 1600)}`);
});
}
async function openDocumentFileTree(page, workspaceId, documentId) {
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
waitUntil: "commit",
timeout: UI_TIMEOUT_MS,
});
await page.waitForFunction(
() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
const fileTab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
return Boolean(fileRoot || fileTab);
},
undefined,
{ timeout: UI_TIMEOUT_MS },
);
await page.evaluate(() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
const visible = (node) =>
node instanceof HTMLElement &&
!node.hidden &&
getComputedStyle(node).display !== "none" &&
getComputedStyle(node).visibility !== "hidden" &&
node.getClientRects().length > 0;
if (visible(fileRoot)) return;
const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
if (tab instanceof HTMLElement) tab.click();
});
await page.waitForFunction(
() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
return fileRoot instanceof HTMLElement && fileRoot.getClientRects().length > 0;
},
undefined,
{ timeout: UI_TIMEOUT_MS },
);
}
async function clickTrashButton(page) {
if (await page.getByRole("heading", { name: "垃圾桶" }).isVisible().catch(() => false)) {
return;
}
await page.getByRole("button", { name: /^垃圾桶\b/ }).first().click({ timeout: UI_TIMEOUT_MS });
await page.getByRole("heading", { name: "垃圾桶" }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
}
async function restoreTrashItemWithDialogs(page, title, expectFallbackAlert = false) {
const confirmPromise = page.waitForEvent("dialog", { timeout: UI_TIMEOUT_MS }).then(async (dialog) => {
const message = dialog.message();
await dialog.accept();
return message;
});
const clicked = await page.evaluate((targetTitle) => {
const rows = Array.from(document.querySelectorAll("div"));
for (const row of rows) {
if (!row.textContent?.includes(targetTitle)) continue;
const button = Array.from(row.querySelectorAll("button")).find((candidate) => candidate.textContent?.trim() === "恢复");
if (button instanceof HTMLButtonElement) {
button.click();
return true;
}
}
return false;
}, title);
assert(clicked, `未找到垃圾桶恢复按钮: ${title}`);
const dialogs = [await confirmPromise];
if (expectFallbackAlert) {
const alertMessage = await page.waitForEvent("dialog", { timeout: UI_TIMEOUT_MS }).then(async (dialog) => {
const message = dialog.message();
await dialog.accept();
return message;
});
dialogs.push(alertMessage);
assert(dialogs.some((message) => message.includes("原父页面已不存在")), `缺少 fallback 提示: ${JSON.stringify(dialogs)}`);
}
return dialogs;
}
async function listDocuments(context, workspaceId) {
return await convexCall(context, "query", "documents:listByWorkspace", { workspaceId });
}
async function cleanup(request, workspaceId, rootId, orphanId) {
if (!workspaceId) return;
if (orphanId) {
await postTreeCommand(request, { action: "archive", workspaceId, documentId: orphanId }).catch(() => null);
}
if (rootId) {
await postTreeCommand(request, { action: "archive", workspaceId, documentId: rootId }).catch(() => null);
}
await requestJson(request, "/api/documents/empty-trash", { method: "POST", data: { workspaceId } }).catch(() => null);
}
async function main() {
const stamp = Date.now();
const email = `mnote.restore.${stamp}@example.com`;
const username = `restore-${stamp}`;
const parentTitle = `TEST-10REVIEW-07-P6-parent-${stamp}`;
const childTitle = `TEST-10REVIEW-07-P6-child-${stamp}`;
const siblingTitle = `TEST-10REVIEW-07-P6-sibling-${stamp}`;
const orphanTitle = `TEST-10REVIEW-07-P6-orphan-${stamp}`;
const orphanId = `tree_restore_orphan_${stamp}`;
const missingParentId = `tree_missing_parent_${stamp}`;
const result = {
ok: false,
task: TASK,
baseUrl: BASE_URL,
convexUrl: CONVEX_URL,
email,
dialogs: [],
};
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
const request = context.request;
let workspaceId = null;
let parentId = null;
try {
await requestJson(request, "/api/auth", {
method: "POST",
data: {
action: "auth:signIn",
args: {
provider: "password",
params: { email, password: e2ePassword(), flow: "signUp", name: username },
},
},
});
await page.goto(`${BASE_URL}/`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
const currentUser = await convexCall(context, "query", "users:currentUser", {});
const userId = currentUser?._id;
assert(userId, "缺少 Convex currentUser._id");
const workspaces = await convexCall(context, "query", "workspaces:fetchWorkspaceSummaries", {});
workspaceId = workspaces.activeWorkspaceId;
assert(workspaceId, "缺少隔离 workspaceId");
parentId = await createPage(request, workspaceId, parentTitle);
const childId = await createPage(request, workspaceId, childTitle, parentId);
const siblingId = await createPage(request, workspaceId, siblingTitle, parentId);
await convexCall(context, "mutation", "documents:create", {
id: orphanId,
workspaceId,
parentId: missingParentId,
title: orphanTitle,
accessScope: "private",
content: [],
});
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(parentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
waitUntil: "commit",
timeout: UI_TIMEOUT_MS,
});
await waitForPageTreeRow(page, parentId, "父页面");
await waitForPageTreeRow(page, childId, "子页面");
await waitForPageTreeRow(page, siblingId, "兄弟页面");
await postTreeCommand(request, { action: "archive", workspaceId, documentId: childId });
await page.locator(pageTreeRowSelector(childId)).first().waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
await postTreeCommand(request, { action: "archive", workspaceId, documentId: orphanId });
const restorePayload = await postTreeCommand(request, { action: "restore", workspaceId, documentId: childId });
result.restorePayload = restorePayload.result || restorePayload;
await waitForPageTreeRow(page, childId, "恢复后的子页面");
await page.waitForFunction(
(documentId) => {
const row = document.querySelector(`[data-testid="page-tree-row"][data-node-id="${documentId}"], [data-testid="wolai-sidebar-row"][data-node-id="${documentId}"], #sidebar-file-tree-root .tree-row[data-document-id="${documentId}"], #sidebar-file-tree-root .tree-row[data-doc-id="${documentId}"]`);
return row instanceof HTMLElement && row.getClientRects().length > 0;
},
childId,
{ timeout: UI_TIMEOUT_MS },
);
let docs = await listDocuments(context, workspaceId);
const childDoc = docs.find((doc) => doc.id === childId);
const siblingDoc = docs.find((doc) => doc.id === siblingId);
assert.equal(childDoc?.parent_id, parentId, "恢复后的子页面应回到原父页面");
assert.equal(childDoc?.sort_order, 0, "恢复后的子页面应回到原排序位");
assert.equal(siblingDoc?.sort_order, 1, "原排序位被恢复页面占用时,兄弟页面应后移");
const fallbackPayload = await postTreeCommand(request, { action: "restore", workspaceId, documentId: orphanId });
result.fallbackPayload = fallbackPayload.result || fallbackPayload;
assert.equal(
result.fallbackPayload?.execution?.restore_location?.fallback_reason,
"parent_missing_or_deleted",
`fallback restore 应返回 parent_missing_or_deleted: ${JSON.stringify(result.fallbackPayload)}`,
);
await waitForPageTreeRow(page, orphanId, "fallback 恢复后的孤儿页面");
docs = await listDocuments(context, workspaceId);
const orphanDoc = docs.find((doc) => doc.id === orphanId);
assert.equal(orphanDoc?.parent_id ?? null, null, "原父页面不存在时应恢复到根目录");
const order = await page.evaluate(
({ child, sibling }) =>
Array.from(document.querySelectorAll('[data-testid="page-tree-row"], [data-testid="wolai-sidebar-row"], #sidebar-file-tree-root .tree-row'))
.map((row) => row instanceof HTMLElement ? row.dataset.nodeId : "")
.filter((id) => id === child || id === sibling)
.filter((id, index, list) => list.indexOf(id) === index),
{ child: childId, sibling: siblingId },
);
assert.deepEqual(order, [childId, siblingId], `页面树顺序应为 child -> sibling: ${JSON.stringify(order)}`);
result.ok = true;
result.workspaceId = workspaceId;
result.parentId = parentId;
result.childId = childId;
result.siblingId = siblingId;
result.orphanId = orphanId;
await writeResult(result);
} catch (error) {
result.error = error instanceof Error ? error.stack || error.message : String(error);
result.pageTreeText = await page.locator('[data-testid="sidebar-page-tree-shell"], #sidebar-file-tree-root, [data-testid="wolai-sidebar"]').innerText({ timeout: 3_000 }).catch(() => "");
await writeResult(result);
throw error;
} finally {
await cleanup(request, workspaceId, parentId, orphanId).catch((error) => {
console.warn(`清理 task429 临时数据失败: ${error instanceof Error ? error.message : String(error)}`);
});
await browser.close();
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -1,597 +0,0 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { chromium } = require("playwright");
const TASK = "task430-vscode-explorer-stage7-smoke";
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const CONVEX_URL = (process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_SELF_HOSTED_URL || "http://127.0.0.1:3210").replace(/\/+$/, "");
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
// 说明:该 smoke 覆盖 convex-source Explorer 兼容路径;local-first 默认主链另有 local workspace smoke。
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
async function writeResult(payload) {
await fs.mkdir(OUTPUT_DIR, { recursive: true });
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
}
async function requestJson(request, requestPath, init = {}) {
const response = await request.fetch(`${BASE_URL}${requestPath}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 20_000,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
return payload;
}
async function requestJsonAllowError(request, requestPath, init = {}) {
let response;
try {
response = await request.fetch(`${BASE_URL}${requestPath}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 20_000,
});
} catch (error) {
return {
ok: false,
status: 0,
payload: {
error: error instanceof Error ? error.message : String(error),
},
};
}
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
return { ok: response.ok(), status: response.status(), payload };
}
async function convexCall(context, kind, convexPath, args) {
const cookies = await context.cookies(BASE_URL);
const jwt = cookies.find((cookie) => cookie.name === "__convexAuthJWT")?.value;
assert(jwt, "缺少 __convexAuthJWT cookie");
const response = await fetch(`${CONVEX_URL}/api/${kind}`, {
method: "POST",
headers: {
authorization: `Bearer ${jwt}`,
"content-type": "application/json",
"Convex-Client": TASK,
},
body: JSON.stringify({ path: convexPath, format: "convex_encoded_json", args: [args] }),
});
const body = await response.json();
if (!response.ok || body.status !== "success") {
throw new Error(`Convex ${kind} ${convexPath} 失败: ${response.status} ${JSON.stringify(body)}`);
}
return body.value;
}
function cssEscape(value) {
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function docRowSelector(documentId) {
return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="doc:${cssEscape(documentId)}"]`;
}
function assetRowSelector(assetId) {
return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-asset-id="${cssEscape(assetId)}"]`;
}
async function createPage(request, workspaceId, title, parentId = null) {
const payload = await requestJson(request, "/api/tree/commands", {
method: "POST",
data: {
action: "create",
workspaceId,
parentId,
title,
},
});
const result = payload.result || payload;
assert(result.documentId, `创建页面失败: ${JSON.stringify(payload)}`);
return result.documentId;
}
async function openDocumentFileTree(page, workspaceId, documentId) {
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
waitUntil: "commit",
timeout: UI_TIMEOUT_MS,
});
await page.waitForFunction(
() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
const fileTab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
return Boolean(fileRoot || fileTab);
},
undefined,
{ timeout: UI_TIMEOUT_MS },
);
await page.evaluate(() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
const visible = (node) =>
node instanceof HTMLElement &&
!node.hidden &&
getComputedStyle(node).display !== "none" &&
getComputedStyle(node).visibility !== "hidden" &&
node.getClientRects().length > 0;
if (visible(fileRoot)) return;
const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
if (tab instanceof HTMLElement) tab.click();
});
await page.waitForFunction(
() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
return fileRoot instanceof HTMLElement && fileRoot.getClientRects().length > 0;
},
undefined,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForRow(page, selector, label) {
await page.locator(selector).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }).catch(async (error) => {
const text = await page.locator("#sidebar-file-tree-root").innerText({ timeout: 3_000 }).catch(() => "");
throw new Error(`${label} 未出现: ${error.message}; filetree=${text.slice(0, 2000)}`);
});
}
async function expandDocIfNeeded(page, documentId) {
const selector = docRowSelector(documentId);
await waitForRow(page, selector, `页面 ${documentId}`);
const expanded = await page.locator(selector).first().getAttribute("aria-expanded").catch(() => null);
if (expanded === "true") return;
const clicked = await page.locator(`${selector} [data-testid="filetree-toggle"]`).first().click({ timeout: 2_000 }).then(() => true).catch(() => false);
if (!clicked) {
await page.locator(selector).first().dblclick({ timeout: 2_000 }).catch(() => undefined);
}
}
async function readDocument(context, workspaceId, documentId) {
const docs = await convexCall(context, "query", "documents:listByWorkspace", { workspaceId });
return docs.find((doc) => doc.id === documentId) || null;
}
async function waitForAssetFileName(context, userId, assetId, fileName) {
const deadline = Date.now() + UI_TIMEOUT_MS;
let lastAsset = null;
while (Date.now() < deadline) {
lastAsset = await convexCall(context, "query", "mediaAssets:getById", { userId, id: assetId });
if (lastAsset?.file_name === fileName) {
return lastAsset;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
assert.equal(lastAsset?.file_name, fileName, "F2 inline rename 附件后 Convex 文件名未更新");
return lastAsset;
}
async function renameRowWithF2(page, selector, nextTitle) {
await page.locator(selector).first().click({ timeout: UI_TIMEOUT_MS });
await page.locator(selector).first().press("F2", { timeout: UI_TIMEOUT_MS });
const input = page.locator(`${selector} .tree-rename-input`).first();
await input.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await input.fill(nextTitle, { timeout: UI_TIMEOUT_MS });
await input.press("Enter", { timeout: UI_TIMEOUT_MS });
await page.locator(selector).first().getByText(nextTitle, { exact: false }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
}
async function visibleContextMenuLabels(page, selector) {
await page.locator(selector).first().click({ button: "right", timeout: UI_TIMEOUT_MS });
await page.waitForTimeout(250);
const labels = ["New File", "New Folder", "Paste Into", "Refresh", "Collapse All", "Copy Path", "Reveal"];
const visible = [];
for (const label of labels) {
if (await page.getByText(label, { exact: true }).first().isVisible().catch(() => false)) {
visible.push(label);
}
}
const disabledTitles = await page.evaluate(() =>
Array.from(document.querySelectorAll("button[disabled][title]"))
.map((button) => button instanceof HTMLButtonElement ? button.title : "")
.filter(Boolean),
);
await page.keyboard.press("Escape").catch(() => undefined);
return { visible, disabledTitles };
}
async function runDropPreflight(request, payload) {
return await requestJsonAllowError(request, "/api/tree/filetree/drop-preflight", {
method: "POST",
data: payload,
});
}
function preflightRow({ rowId, rowKind, documentId, assetId = null, assetDocumentId = null, assetType = null, storagePath = null }) {
return { rowId, rowKind, documentId, assetId, assetDocumentId, assetType, storagePath };
}
async function cleanup(request, workspaceId, ids) {
if (!workspaceId) return;
for (const documentId of ids.filter(Boolean)) {
await requestJson(request, "/api/tree/commands", {
method: "POST",
data: { action: "archive", workspaceId, documentId },
}).catch(() => null);
}
await requestJson(request, "/api/documents/empty-trash", { method: "POST", data: { workspaceId } }).catch(() => null);
await requestJson(request, "/api/media/empty-trash", { method: "POST", data: { workspaceId } }).catch(() => null);
}
async function runOptional(result, area, fn) {
try {
const details = await fn();
result.checks.push({ area, ok: true, details });
return details;
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
result.skipped.push({ area, reason });
result.checks.push({ area, ok: false, skipped: true, reason });
return null;
}
}
async function main() {
const stamp = Date.now();
const prefix = `TEST-10REVIEW-07-P7-${stamp}`;
const result = {
ok: false,
task: TASK,
baseUrl: BASE_URL,
convexUrl: CONVEX_URL,
email: `mnote.stage7.${stamp}@example.com`,
prefix,
requests: [],
checks: [],
skipped: [],
phases: [],
};
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
const request = context.request;
let workspaceId = null;
const cleanupDocIds = [];
page.on("request", (req) => {
const url = req.url();
if (
url.includes("/api/tree/commands") ||
url.includes("/api/tree/filetree/drop-preflight") ||
url.includes("/api/media/batch")
) {
result.requests.push({
method: req.method(),
url,
body: req.postData() || null,
});
}
});
try {
// 阶段:auth - signIn
result.phases.push({ name: "auth-signin", ok: false });
await requestJson(request, "/api/auth", {
method: "POST",
data: {
action: "auth:signIn",
args: {
provider: "password",
params: { email: result.email, password: e2ePassword(), flow: "signUp", name: `stage7-${stamp}` },
},
},
});
result.phases[result.phases.length - 1].ok = true;
// 阶段:auth - cookie 初始化(导航到首页以设置 cookie)
result.phases.push({ name: "auth-navigate", ok: false });
await page.goto(`${BASE_URL}/`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
result.phases[result.phases.length - 1].ok = true;
// 阶段:workspace bootstrap
result.phases.push({ name: "workspace-bootstrap", ok: false });
const currentUser = await convexCall(context, "query", "users:currentUser", {});
const userId = currentUser?._id;
assert(userId, "[env] Convex 不可用: users:currentUser 未返回用户 ID");
let workspaces;
try {
workspaces = await convexCall(context, "query", "workspaces:fetchWorkspaceSummaries", {});
} catch (workspaceQueryError) {
result.skipped.push({
area: "workspace-bootstrap",
reason: `workspaces:fetchWorkspaceSummaries 查询失败(可能是函数未部署或 Convex 不可用),尝试通过 /api/tree/commands create 自举: ${workspaceQueryError instanceof Error ? workspaceQueryError.message : String(workspaceQueryError)}`,
});
}
workspaceId = workspaces?.activeWorkspaceId;
if (!workspaceId) {
// Fallback: 通过 /api/tree/commands create(不带 workspaceId)触发 Rust 端 workspace bootstrap
const bootstrapResult = await requestJson(request, "/api/tree/commands", {
method: "POST",
data: { action: "create", title: `${prefix}-bootstrap` },
});
workspaceId = bootstrapResult.workspaceId || bootstrapResult.result?.workspaceId;
}
assert(workspaceId, "[env] workspace bootstrap 失败:fetchWorkspaceSummaries 无返回 + /api/tree/commands create 自举也未产生 workspaceId");
result.phases[result.phases.length - 1].ok = true;
const parentA = await createPage(request, workspaceId, `${prefix}-parent-a`);
const parentB = await createPage(request, workspaceId, `${prefix}-parent-b`);
const child = await createPage(request, workspaceId, `${prefix}-child`, parentA);
const renameDoc = await createPage(request, workspaceId, `${prefix}-rename-doc`, parentA);
const resourceDoc = await createPage(request, workspaceId, `${prefix}-resource-doc`);
cleanupDocIds.push(parentA, parentB, child, renameDoc, resourceDoc);
const assetId = `asset_p7_${stamp}`;
await convexCall(context, "mutation", "mediaAssets:create", {
userId,
asset: {
id: assetId,
workspace_id: workspaceId,
document_id: resourceDoc,
asset_type: "file",
file_url: null,
thumbnail_url: null,
storage_id: null,
bucket: null,
storage_path: null,
file_name: `${prefix}-file.txt`,
file_size: 12,
mime_type: "text/plain",
},
});
await openDocumentFileTree(page, workspaceId, resourceDoc);
for (const documentId of [parentA, parentB, resourceDoc]) {
await waitForRow(page, docRowSelector(documentId), `页面 ${documentId}`);
}
await expandDocIfNeeded(page, parentA);
await waitForRow(page, docRowSelector(child), `子页面 ${child}`);
await waitForRow(page, docRowSelector(renameDoc), `重命名页面 ${renameDoc}`);
await expandDocIfNeeded(page, resourceDoc);
await waitForRow(page, assetRowSelector(assetId), `附件 ${assetId}`);
// 阶段:FileTree active row 与 Open Editors 状态
await runOptional(result, "filetree-active-row", async () => {
const activeRowIds = await page.evaluate(() => {
const rows = document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-active="true"]');
return Array.from(rows).map(row => row.getAttribute('data-row-id') || '');
});
const expectedDocRow = `doc:${resourceDoc}`;
if (activeRowIds.length === 0) {
// 当前打开的资源行可能没有 data-active 标记——记录但不报错,属于已知缺口
return { activeRowIds, note: `当前页 ${expectedDocRow} 的 FileTree row 无 data-active="true" 标记(UI 尚未同步 active 状态)` };
}
assert(activeRowIds.includes(expectedDocRow), `业务: FileTree active row 应为 ${expectedDocRow},实际为 [${activeRowIds.join(", ")}]`);
return { activeRowIds, expectedDocRow };
});
await runOptional(result, "filetree-selected-row", async () => {
const selectedRowIds = await page.evaluate(() => {
const rows = document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-selected="true"]');
return Array.from(rows).map(row => row.getAttribute('data-row-id') || '');
});
// 当前只记录选中行状态,不做强制断言——选中语义可能随点击位置变化
return { selectedRowIds };
});
await runOptional(result, "open-editors-section", async () => {
const hasOpenEditors = await page.evaluate(() => {
return !!(document.querySelector('[data-testid="open-editors"]') ||
document.querySelector('[data-mnote-section="open-editors"]') ||
document.getElementById('sidebar-open-editors') ||
document.querySelector('[data-mnote-editors-section]'));
});
// Open Editors section 当前尚未在 MNote sidebar 中实现(已知缺口 vs VSCode Open Editors
// 这里只记录状态不报错,让将来实装时可以切换为断言
return { implemented: hasOpenEditors, note: hasOpenEditors ? "Open Editors 区域已出现" : "Open Editors 区域尚未实现(当前为已知缺口)" };
});
await runOptional(result, "f2-inline-rename-doc", async () => {
const renamedDocTitle = `${prefix}-renamed-doc`;
await renameRowWithF2(page, docRowSelector(renameDoc), renamedDocTitle);
const renamedDoc = await readDocument(context, workspaceId, renameDoc);
assert.equal(renamedDoc?.title, renamedDocTitle, "F2 inline rename 页面后 Convex 标题未更新");
return { documentId: renameDoc, title: renamedDocTitle };
});
await runOptional(result, "f2-inline-rename-asset", async () => {
const renamedAssetTitle = `${prefix}-renamed-file.txt`;
await renameRowWithF2(page, assetRowSelector(assetId), renamedAssetTitle);
await waitForAssetFileName(context, userId, assetId, renamedAssetTitle);
return { assetId, fileName: renamedAssetTitle };
});
const menu = await visibleContextMenuLabels(page, docRowSelector(parentA));
result.contextMenu = menu;
const expectedMenuLabels = ["New File", "New Folder", "Paste Into", "Refresh", "Collapse All", "Copy Path", "Reveal"];
const missingMenuLabels = expectedMenuLabels.filter((label) => !menu.visible.includes(label));
if (missingMenuLabels.length > 0) {
result.skipped.push({
area: "context-menu-minimum",
reason: `当前真实 filetree 右键菜单未暴露这些 React Sidebar 菜单项: ${missingMenuLabels.join(", ")}`,
});
result.checks.push({
area: "context-menu-minimum",
ok: false,
skipped: true,
reason: `当前真实 filetree 右键菜单未暴露这些 React Sidebar 菜单项: ${missingMenuLabels.join(", ")}`,
});
}
assert(
menu.visible.length === 0 || menu.disabledTitles.some((title) => title.includes("右键 Paste Into") || title.includes("文件夹")),
`右键菜单禁用态缺少可解释原因: ${JSON.stringify(menu)}`,
);
if (missingMenuLabels.length === 0) {
result.checks.push({
area: "context-menu-minimum",
ok: true,
details: menu,
});
}
const accel = process.platform === "darwin" ? "Meta" : "Control";
await runOptional(result, "cut-paste-move", async () => {
await page.locator(docRowSelector(child)).first().click({ timeout: UI_TIMEOUT_MS });
await page.locator(docRowSelector(child)).first().press(`${accel}+X`, { timeout: UI_TIMEOUT_MS });
await page.locator(docRowSelector(parentB)).first().click({ timeout: UI_TIMEOUT_MS });
const moveRequestCount = result.requests.length;
await page.locator(docRowSelector(parentB)).first().press(`${accel}+V`, { timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
({ selector, expectedParent }) => {
const row = document.querySelector(selector);
return row instanceof HTMLElement && row.textContent?.includes(expectedParent);
},
{ selector: docRowSelector(parentB), expectedParent: `${prefix}-parent-b` },
{ timeout: UI_TIMEOUT_MS },
).catch(() => undefined);
await page.waitForTimeout(800);
const movedChild = await readDocument(context, workspaceId, child);
assert.equal(movedChild?.parent_id, parentB, "Ctrl/Cmd+X 后 Ctrl/Cmd+V 应移动页面到目标父页面");
const moveRequests = result.requests.slice(moveRequestCount).filter((entry) => entry.body?.includes('"action":"move"'));
assert(moveRequests.length > 0, "Cut/Paste move 未捕获到 tree move 请求");
return { documentId: child, parentId: parentB, moveRequestCount: moveRequests.length };
});
const childAfterCutPaste = await readDocument(context, workspaceId, child);
const childParentId = childAfterCutPaste?.parent_id ?? parentA;
const rows = [
preflightRow({ rowId: `doc:${parentA}`, rowKind: "doc", documentId: parentA }),
preflightRow({ rowId: `doc:${parentB}`, rowKind: "doc", documentId: parentB }),
preflightRow({ rowId: `doc:${child}`, rowKind: "doc", documentId: child }),
preflightRow({ rowId: `doc:${renameDoc}`, rowKind: "doc", documentId: renameDoc }),
preflightRow({
rowId: `asset:${assetId}`,
rowKind: "asset",
documentId: resourceDoc,
assetId,
assetDocumentId: resourceDoc,
assetType: "file",
}),
];
const documentParents = [
{ documentId: parentA, parentId: null },
{ documentId: parentB, parentId: null },
{ documentId: child, parentId: childParentId },
{ documentId: renameDoc, parentId: parentA },
{ documentId: resourceDoc, parentId: null },
];
const selfDrop = await runDropPreflight(request, {
workspaceId,
copy: false,
targetDocumentId: child,
targetRowId: `doc:${child}`,
focusedRowId: `doc:${child}`,
activeDocumentId: resourceDoc,
rowIds: [`doc:${child}`],
rows,
documentParents,
});
const parentToChildDrop = await runDropPreflight(request, {
workspaceId,
copy: false,
targetDocumentId: renameDoc,
targetRowId: `doc:${renameDoc}`,
focusedRowId: `doc:${renameDoc}`,
activeDocumentId: resourceDoc,
rowIds: [`doc:${parentA}`],
rows,
documentParents,
});
const copyDrop = await runDropPreflight(request, {
workspaceId,
copy: true,
targetDocumentId: parentB,
targetRowId: `doc:${parentB}`,
focusedRowId: `doc:${parentB}`,
activeDocumentId: resourceDoc,
rowIds: [`doc:${renameDoc}`],
rows,
documentParents,
});
if ([selfDrop, parentToChildDrop, copyDrop].some((entry) => entry.status === 0 || entry.status === 404)) {
result.skipped.push({
area: "dnd-preflight-guard",
reason: "当前 3000 入口未暴露或已断开 /api/tree/filetree/drop-preflight,无法稳定自动化 DnD preflight guard;未把入口失败伪造成通过。",
});
result.checks.push({
area: "dnd-preflight-guard",
ok: false,
skipped: true,
reason: "当前 3000 入口未暴露或已断开 /api/tree/filetree/drop-preflight",
});
} else {
assert(!selfDrop.ok, `拖到自身应被 preflight 拒绝: ${JSON.stringify(selfDrop)}`);
assert(!parentToChildDrop.ok, `父拖子应被 preflight 拒绝: ${JSON.stringify(parentToChildDrop)}`);
assert(copyDrop.ok && copyDrop.payload?.plan?.copy === true, `copy modifier preflight 应保留 copy=true: ${JSON.stringify(copyDrop)}`);
result.checks.push({
area: "dnd-preflight-guard",
ok: true,
details: {
selfDropStatus: selfDrop.status,
parentToChildDropStatus: parentToChildDrop.status,
copyDropPlan: copyDrop.payload?.plan ?? null,
},
});
}
result.dndPreflight = {
selfDropStatus: selfDrop.status,
parentToChildDropStatus: parentToChildDrop.status,
copyDropPlan: copyDrop.payload?.plan ?? null,
};
result.skipped.push({
area: "dnd-readonly-conflict",
reason: "主 Sidebar Convex filetree 当前 smoke 未构造 readonly source 与真实重名冲突确认弹窗;已有 local-folder smoke 和 bridge preflight 单测覆盖,仍需后续端到端矩阵补齐。",
});
result.ok = true;
result.workspaceId = workspaceId;
result.fixture = { parentA, parentB, child, renameDoc, resourceDoc, assetId };
await writeResult(result);
} catch (error) {
result.error = error instanceof Error ? error.stack || error.message : String(error);
result.filetreeText = await page.locator("#sidebar-file-tree-root").innerText({ timeout: 3_000 }).catch(() => "");
await writeResult(result);
throw error;
} finally {
await cleanup(request, workspaceId, cleanupDocIds).catch((error) => {
console.warn(`清理 task430 临时数据失败: ${error instanceof Error ? error.message : String(error)}`);
});
await browser.close();
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -1,295 +0,0 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { chromium } = require("playwright");
const TASK = "task431-vscode-explorer-dnd-readonly-conflict-smoke";
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, "");
const CONVEX_URL = (process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_SELF_HOSTED_URL || "http://127.0.0.1:3210").replace(/\/+$/, "");
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
async function writeResult(payload) {
await fs.mkdir(OUTPUT_DIR, { recursive: true });
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
}
async function requestJson(request, requestPath, init = {}) {
const response = await request.fetch(`${BASE_URL}${requestPath}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 20_000,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
return payload;
}
async function requestAuthJson(request, requestPath, init = {}) {
const response = await request.fetch(`${AUTH_BASE_URL}${requestPath}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 20_000,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
return payload;
}
async function requestJsonAllowError(request, requestPath, init = {}) {
let response;
try {
response = await request.fetch(`${BASE_URL}${requestPath}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 20_000,
});
} catch (error) {
return {
ok: false,
status: 0,
payload: { error: error instanceof Error ? error.message : String(error) },
};
}
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
return { ok: response.ok(), status: response.status(), payload };
}
async function convexCall(context, kind, convexPath, args) {
const cookies = await context.cookies(BASE_URL);
const jwt = cookies.find((cookie) => cookie.name === "__convexAuthJWT")?.value;
assert(jwt, "缺少 __convexAuthJWT cookie");
const response = await fetch(`${CONVEX_URL}/api/${kind}`, {
method: "POST",
headers: {
authorization: `Bearer ${jwt}`,
"content-type": "application/json",
"Convex-Client": TASK,
},
body: JSON.stringify({ path: convexPath, format: "convex_encoded_json", args: [args] }),
});
const body = await response.json();
if (!response.ok || body.status !== "success") {
throw new Error(`Convex ${kind} ${convexPath} 失败: ${response.status} ${JSON.stringify(body)}`);
}
return body.value;
}
async function createPage(request, workspaceId, title, parentId = null) {
const data = {
action: "create",
parentId,
title,
};
if (workspaceId) {
data.workspaceId = workspaceId;
}
const payload = await requestJson(request, "/api/tree/commands", {
method: "POST",
data,
});
const result = payload.result || payload;
assert(result.documentId, `创建页面失败: ${JSON.stringify(payload)}`);
return result;
}
function docRow(documentId, title) {
return {
rowId: `doc:${documentId}`,
rowKind: "doc",
documentId,
assetId: null,
assetDocumentId: null,
assetType: null,
storagePath: null,
title,
};
}
async function runDropPreflight(request, payload) {
return await requestJsonAllowError(request, "/api/tree/filetree/drop-preflight", {
method: "POST",
data: payload,
});
}
async function cleanup(request, workspaceId, ids) {
if (!workspaceId) return;
for (const documentId of ids.filter(Boolean)) {
await requestJson(request, "/api/tree/commands", {
method: "POST",
data: { action: "archive", workspaceId, documentId },
}).catch(() => null);
}
await requestJson(request, "/api/documents/empty-trash", {
method: "POST",
data: { workspaceId },
}).catch(() => null);
}
(async () => {
const stamp = Date.now();
const prefix = `TEST-10REVIEW-07-P7-DND-${stamp}`;
const result = {
ok: false,
task: TASK,
baseUrl: BASE_URL,
authBaseUrl: AUTH_BASE_URL,
convexUrl: CONVEX_URL,
email: `mnote.stage7.dnd.${stamp}@example.com`,
prefix,
checks: [],
};
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const request = context.request;
let workspaceId = null;
const cleanupDocIds = [];
try {
await requestAuthJson(request, "/api/auth", {
method: "POST",
data: {
action: "auth:signIn",
args: {
provider: "password",
params: { email: result.email, password: e2ePassword(), flow: "signUp", name: `stage7-dnd-${stamp}` },
},
},
});
const currentUser = await convexCall(context, "query", "users:currentUser", {});
assert(currentUser?._id, "缺少 Convex currentUser._id");
const workspaces = await convexCall(context, "query", "workspaces:fetchWorkspaceSummaries", {});
workspaceId = workspaces.activeWorkspaceId;
const targetResult = await createPage(request, workspaceId, `${prefix}-target`);
workspaceId = workspaceId || targetResult.workspaceId;
assert(workspaceId, "缺少隔离 workspaceId");
const target = targetResult.documentId;
const source = (await createPage(request, workspaceId, `${prefix}-same-name`)).documentId;
const existing = (await createPage(request, workspaceId, `${prefix}-same-name`, target)).documentId;
cleanupDocIds.push(target, source, existing);
const rows = [
docRow(target, `${prefix}-target`),
docRow(source, `${prefix}-same-name`),
docRow(existing, `${prefix}-same-name`),
];
const documentParents = [
{ documentId: target, parentId: null },
{ documentId: source, parentId: null },
{ documentId: existing, parentId: target },
];
const basePayload = {
workspaceId,
copy: false,
sourceCapabilities: ["read", "write", "move"],
targetCapabilities: ["read", "write", "drop"],
targetDocumentId: target,
targetRowId: `doc:${target}`,
focusedRowId: `doc:${target}`,
activeDocumentId: target,
rowIds: [`doc:${source}`],
rows,
targetChildren: [
{
rowKind: "doc",
documentId: existing,
assetId: null,
title: `${prefix}-same-name`,
},
],
documentParents,
conflictPolicy: "prompt",
};
const readonlyTarget = await runDropPreflight(request, {
...basePayload,
targetCapabilities: ["read"],
targetChildren: [],
});
assert.equal(readonlyTarget.ok, false, `readonly target 应被拒绝: ${JSON.stringify(readonlyTarget)}`);
assert.match(JSON.stringify(readonlyTarget.payload), /只读|readonly|目标位置/);
const readonlySource = await runDropPreflight(request, {
...basePayload,
sourceCapabilities: ["read"],
targetChildren: [],
});
assert.equal(readonlySource.ok, false, `readonly source 应被拒绝: ${JSON.stringify(readonlySource)}`);
assert.match(JSON.stringify(readonlySource.payload), /只读|readonly|来源/);
const conflict = await runDropPreflight(request, basePayload);
assert.equal(conflict.ok, true, `同名冲突应返回确认计划: ${JSON.stringify(conflict)}`);
assert.equal(conflict.payload?.plan?.requiresConfirmation, true);
assert.equal(conflict.payload?.plan?.conflicts?.[0]?.title, `${prefix}-same-name`);
assert.equal(conflict.payload?.plan?.conflicts?.[0]?.existingDocumentId, existing);
const normalCopy = await runDropPreflight(request, {
...basePayload,
copy: true,
sourceCapabilities: ["read"],
targetChildren: [],
});
assert.equal(normalCopy.ok, true, `copy modifier 不应被 readonly source move 规则误拒绝: ${JSON.stringify(normalCopy)}`);
assert.equal(normalCopy.payload?.plan?.copy, true);
assert.equal(normalCopy.payload?.plan?.requiresConfirmation ?? false, false);
result.checks.push(
{ area: "readonly-target", ok: true, status: readonlyTarget.status },
{ area: "readonly-source", ok: true, status: readonlySource.status },
{ area: "conflict-confirmation-plan", ok: true, conflict: conflict.payload.plan.conflicts[0] },
{ area: "copy-modifier-no-false-positive", ok: true, plan: normalCopy.payload.plan },
);
result.workspaceId = workspaceId;
result.fixture = { target, source, existing };
result.ok = true;
} catch (error) {
result.error = error instanceof Error ? error.stack || error.message : String(error);
process.exitCode = 1;
} finally {
await cleanup(request, workspaceId, cleanupDocIds).catch((error) => {
result.cleanupError = error instanceof Error ? error.message : String(error);
});
await browser.close().catch(() => undefined);
await writeResult(result);
}
})();
@@ -1,389 +0,0 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { chromium } = require("playwright");
const TASK = "task432-filetree-trash-page-dual-browser-no-refresh-smoke";
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, "");
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 35_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
async function writeResult(payload) {
await fs.mkdir(OUTPUT_DIR, { recursive: true });
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
}
async function requestJson(request, requestPath, init = {}) {
const response = await request.fetch(`${BASE_URL}${requestPath}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 20_000,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
return payload;
}
async function requestAuthJson(request, requestPath, init = {}) {
const response = await request.fetch(`${AUTH_BASE_URL}${requestPath}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 20_000,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
return payload;
}
function cssEscape(value) {
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function docRowSelector(documentId) {
return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="doc:${cssEscape(documentId)}"]`;
}
function trashDocRowSelector(documentId) {
return `[data-testid="mnote-trash-workbench"] [data-trash-row="document"][data-document-id="${cssEscape(documentId)}"]`;
}
async function createPage(request, workspaceId, title, parentId = null) {
const payload = await requestJson(request, "/api/tree/commands", {
method: "POST",
data: {
action: "create",
workspaceId,
parentId,
title,
},
});
const result = payload.result || payload;
const documentId = result.documentId || payload.documentId || result.id || "";
const resolvedWorkspaceId = result.workspaceId || payload.workspaceId || payload.workspace_id || workspaceId || "";
assert(documentId, `创建页面失败: ${JSON.stringify(payload)}`);
assert(resolvedWorkspaceId, `创建页面缺少 workspaceId: ${JSON.stringify(payload)}`);
return { documentId, workspaceId: resolvedWorkspaceId, payload };
}
async function treeCommand(request, workspaceId, action, documentId) {
return await requestJson(request, "/api/tree/commands", {
method: "POST",
data: { action, workspaceId, documentId },
});
}
async function emptyDocumentTrash(request, workspaceId) {
return await requestJson(request, "/api/documents/empty-trash", {
method: "POST",
data: { workspaceId },
});
}
async function authenticate(request, email, name) {
await requestAuthJson(request, "/api/auth", {
method: "POST",
data: {
action: "auth:signIn",
args: {
provider: "password",
params: { email, password: e2ePassword(), flow: "signUp", name },
},
},
});
}
async function openDocumentFileTree(page, workspaceId, documentId) {
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
waitUntil: "commit",
timeout: UI_TIMEOUT_MS,
});
await page.waitForFunction(
() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
const fileTab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
return Boolean(fileRoot || fileTab);
},
undefined,
{ timeout: UI_TIMEOUT_MS },
);
await page.evaluate(() => {
const visible = (node) =>
node instanceof HTMLElement &&
!node.hidden &&
getComputedStyle(node).display !== "none" &&
getComputedStyle(node).visibility !== "hidden" &&
node.getClientRects().length > 0;
const fileRoot = document.getElementById("sidebar-file-tree-root");
if (visible(fileRoot)) return;
const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
if (tab instanceof HTMLElement) tab.click();
});
await page.waitForFunction(
() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
return fileRoot instanceof HTMLElement && fileRoot.getClientRects().length > 0;
},
undefined,
{ timeout: UI_TIMEOUT_MS },
);
}
async function openTrash(page, workspaceId) {
await page.goto(`${BASE_URL}/trash?workspaceId=${encodeURIComponent(workspaceId)}`, {
waitUntil: "commit",
timeout: UI_TIMEOUT_MS,
});
await page.locator('[data-testid="mnote-trash-workbench"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
}
async function waitForVisible(page, selector, label) {
await page.locator(selector).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }).catch(async (error) => {
throw new Error(`${label} 未出现: ${error.message}; state=${JSON.stringify(await readPageState(page).catch(() => null))}`);
});
}
async function waitForDetached(page, selector, label) {
await page.locator(selector).first().waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }).catch(async (error) => {
throw new Error(`${label} 未消失: ${error.message}; state=${JSON.stringify(await readPageState(page).catch(() => null))}`);
});
}
async function installTreeEventRecorder(page, label, records) {
await page.addInitScript(() => {
window.__MNOTE_TASK432_TREE_EVENTS__ = [];
const record = (name, event) => {
const detail = event && event.detail ? event.detail : {};
const payload = detail.payload || detail || {};
window.__MNOTE_TASK432_TREE_EVENTS__.push({
name,
at: Date.now(),
revision: detail.revision || payload.revision || payload.cursor || "",
op: payload && payload.data && payload.data.op ? payload.data.op : "",
});
};
window.addEventListener("tree:snapshot", (event) => record("tree:snapshot", event));
window.addEventListener("tree:delta", (event) => record("tree:delta", event));
window.addEventListener("tree:resync", (event) => record("tree:resync", event));
});
page.on("request", (request) => {
const url = request.url();
if (url.includes("/api/tree/events")) {
records.push({ label, type: "request", method: request.method(), url, at: Date.now() });
}
});
page.on("requestfailed", (request) => {
const url = request.url();
if (url.includes("/api/tree/events")) {
records.push({
label,
type: "requestfailed",
method: request.method(),
url,
failure: request.failure()?.errorText || "",
at: Date.now(),
});
}
});
page.on("console", (message) => {
const text = message.text();
if (/tree live|EventSource|trash|error|failed/i.test(text)) {
records.push({ label, type: "console", level: message.type(), text: text.slice(0, 2000), at: Date.now() });
}
});
}
function recordNavigation(page, label, records) {
page.on("framenavigated", (frame) => {
if (frame === page.mainFrame()) {
records.push({ label, url: frame.url(), at: Date.now() });
}
});
}
async function readPageState(page) {
return await page.evaluate(() => ({
url: window.location.href,
liveStatus: document.documentElement.getAttribute("data-mnote-tree-live-status") || "",
liveApplied: document.documentElement.getAttribute("data-mnote-tree-live-applied") || "",
liveRevision: document.documentElement.getAttribute("data-mnote-tree-live-revision") || "",
liveError: document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "",
trashLiveReason:
document.querySelector('[data-testid="mnote-trash-workbench"]')?.getAttribute("data-live-refresh-reason") || "",
trashLiveAt:
document.querySelector('[data-testid="mnote-trash-workbench"]')?.getAttribute("data-live-refresh-at") || "",
filetreeRows: Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode='filetree']")).map((row) => ({
rowId: row instanceof HTMLElement ? row.dataset.rowId || "" : "",
documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "",
text: row.textContent || "",
})),
trashDocumentRows: Array.from(document.querySelectorAll('[data-testid="mnote-trash-workbench"] [data-trash-row="document"]')).map((row) => ({
documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "",
text: row.textContent || "",
})),
treeEvents: window.__MNOTE_TASK432_TREE_EVENTS__ || [],
}));
}
async function snapshotStep(result, name, fileTreePage, trashPage, navigationStart) {
result.steps.push({
name,
fileTree: await readPageState(fileTreePage),
trash: await readPageState(trashPage),
navigationEvents: result.navigationEvents.slice(navigationStart),
});
}
async function cleanup(request, workspaceId, documentIds) {
if (!workspaceId) return;
for (const documentId of documentIds.filter(Boolean)) {
await treeCommand(request, workspaceId, "archive", documentId).catch(() => null);
}
await emptyDocumentTrash(request, workspaceId).catch(() => null);
}
(async () => {
const stamp = Date.now();
const email = `mnote.stage8.page.${stamp}@example.com`;
const prefix = `TEST-10REVIEW-07-P8-PAGE-${stamp}`;
const result = {
ok: false,
task: TASK,
baseUrl: BASE_URL,
authBaseUrl: AUTH_BASE_URL,
email,
prefix,
fixture: {},
requests: [],
navigationEvents: [],
treeEventRequests: [],
steps: [],
};
const browser = await chromium.launch({ headless: true });
const contextA = await browser.newContext();
const contextB = await browser.newContext();
const pageA = await contextA.newPage();
const fileTreeB = await contextB.newPage();
const trashB = await contextB.newPage();
const requestA = contextA.request;
let workspaceId = "";
const cleanupIds = [];
pageA.on("request", (request) => {
const url = request.url();
if (url.includes("/api/tree/commands") || url.includes("/api/documents/empty-trash")) {
result.requests.push({ side: "A-page", method: request.method(), url, body: request.postData() || null, at: Date.now() });
}
});
for (const requestContext of [requestA, contextB.request]) {
await authenticate(requestContext, email, `stage8-page-${stamp}`);
}
await installTreeEventRecorder(fileTreeB, "B-filetree", result.treeEventRequests);
await installTreeEventRecorder(trashB, "B-trash", result.treeEventRequests);
recordNavigation(fileTreeB, "B-filetree", result.navigationEvents);
recordNavigation(trashB, "B-trash", result.navigationEvents);
try {
const root = await createPage(requestA, null, `${prefix}-root`);
workspaceId = root.workspaceId;
cleanupIds.push(root.documentId);
result.fixture.rootId = root.documentId;
result.fixture.workspaceId = workspaceId;
await pageA.goto(`${BASE_URL}/documents/${encodeURIComponent(root.documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
waitUntil: "commit",
timeout: UI_TIMEOUT_MS,
});
await openDocumentFileTree(fileTreeB, workspaceId, root.documentId);
await openTrash(trashB, workspaceId);
await waitForVisible(fileTreeB, docRowSelector(root.documentId), "B 文件树 root 页面");
const navigationStart = result.navigationEvents.length;
await snapshotStep(result, "initial", fileTreeB, trashB, navigationStart);
const lifecycle = await createPage(requestA, workspaceId, `${prefix}-lifecycle`, root.documentId);
cleanupIds.push(lifecycle.documentId);
result.fixture.lifecycleId = lifecycle.documentId;
await waitForVisible(fileTreeB, docRowSelector(lifecycle.documentId), "B 文件树 lifecycle 新建页面");
await snapshotStep(result, "create-visible-on-b", fileTreeB, trashB, navigationStart);
await treeCommand(requestA, workspaceId, "archive", lifecycle.documentId);
await waitForDetached(fileTreeB, docRowSelector(lifecycle.documentId), "B 文件树 lifecycle 删除后");
await waitForVisible(trashB, trashDocRowSelector(lifecycle.documentId), "B 垃圾箱 lifecycle 删除后");
await snapshotStep(result, "archive-visible-on-b", fileTreeB, trashB, navigationStart);
await treeCommand(requestA, workspaceId, "restore", lifecycle.documentId);
await waitForVisible(fileTreeB, docRowSelector(lifecycle.documentId), "B 文件树 lifecycle 恢复后");
await waitForDetached(trashB, trashDocRowSelector(lifecycle.documentId), "B 垃圾箱 lifecycle 恢复后");
await snapshotStep(result, "restore-visible-on-b", fileTreeB, trashB, navigationStart);
await treeCommand(requestA, workspaceId, "archive", lifecycle.documentId);
await waitForVisible(trashB, trashDocRowSelector(lifecycle.documentId), "B 垃圾箱 lifecycle 再次删除后");
await treeCommand(requestA, workspaceId, "purge", lifecycle.documentId);
await waitForDetached(trashB, trashDocRowSelector(lifecycle.documentId), "B 垃圾箱 lifecycle 彻底删除后");
await waitForDetached(fileTreeB, docRowSelector(lifecycle.documentId), "B 文件树 lifecycle 彻底删除后");
await snapshotStep(result, "purge-visible-on-b", fileTreeB, trashB, navigationStart);
const emptyA = await createPage(requestA, workspaceId, `${prefix}-empty-a`, root.documentId);
const emptyB = await createPage(requestA, workspaceId, `${prefix}-empty-b`, root.documentId);
cleanupIds.push(emptyA.documentId, emptyB.documentId);
result.fixture.emptyIds = [emptyA.documentId, emptyB.documentId];
await waitForVisible(fileTreeB, docRowSelector(emptyA.documentId), "B 文件树 empty-a 新建后");
await waitForVisible(fileTreeB, docRowSelector(emptyB.documentId), "B 文件树 empty-b 新建后");
await treeCommand(requestA, workspaceId, "archive", emptyA.documentId);
await treeCommand(requestA, workspaceId, "archive", emptyB.documentId);
await waitForVisible(trashB, trashDocRowSelector(emptyA.documentId), "B 垃圾箱 empty-a 删除后");
await waitForVisible(trashB, trashDocRowSelector(emptyB.documentId), "B 垃圾箱 empty-b 删除后");
await emptyDocumentTrash(requestA, workspaceId);
await waitForDetached(trashB, trashDocRowSelector(emptyA.documentId), "B 垃圾箱 empty-a 清空后");
await waitForDetached(trashB, trashDocRowSelector(emptyB.documentId), "B 垃圾箱 empty-b 清空后");
await waitForDetached(fileTreeB, docRowSelector(emptyA.documentId), "B 文件树 empty-a 清空后");
await waitForDetached(fileTreeB, docRowSelector(emptyB.documentId), "B 文件树 empty-b 清空后");
await snapshotStep(result, "empty-trash-visible-on-b", fileTreeB, trashB, navigationStart);
const unexpectedNavigations = result.navigationEvents.slice(navigationStart);
assert.equal(unexpectedNavigations.length, 0, `B 浏览器发生了刷新/导航: ${JSON.stringify(unexpectedNavigations)}`);
result.ok = true;
} catch (error) {
result.error = error instanceof Error ? error.stack || error.message : String(error);
result.failure = {
fileTree: await readPageState(fileTreeB).catch(() => null),
trash: await readPageState(trashB).catch(() => null),
};
process.exitCode = 1;
} finally {
await cleanup(requestA, workspaceId, cleanupIds).catch((error) => {
result.cleanupError = error instanceof Error ? error.message : String(error);
});
await browser.close().catch(() => undefined);
await writeResult(result);
}
})();
@@ -1,426 +0,0 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { chromium } = require("playwright");
const TASK = "task433-filetree-trash-file-asset-dual-browser-no-refresh-smoke";
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, "");
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
// 说明:该 smoke 仍在验证 convex-source 的 file asset trash 兼容链路,不是 local-first 默认资源流。
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 35_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
async function writeResult(payload) {
await fs.mkdir(OUTPUT_DIR, { recursive: true });
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
}
async function requestJson(request, requestPath, init = {}) {
const response = await request.fetch(`${BASE_URL}${requestPath}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 30_000,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
return payload;
}
async function requestAuthJson(request, requestPath, init = {}) {
const response = await request.fetch(`${AUTH_BASE_URL}${requestPath}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 20_000,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
return payload;
}
function cssEscape(value) {
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function docRowSelector(documentId) {
return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="doc:${cssEscape(documentId)}"]`;
}
function assetRowSelector(assetId) {
return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-asset-id="${cssEscape(assetId)}"]`;
}
function trashAssetRowSelector(assetId) {
return `[data-testid="mnote-trash-workbench"] [data-trash-row="resource"][data-resource-kind="media"][data-resource-id="${cssEscape(assetId)}"]`;
}
async function authenticate(request, email, name) {
await requestAuthJson(request, "/api/auth", {
method: "POST",
data: {
action: "auth:signIn",
args: {
provider: "password",
params: { email, password: e2ePassword(), flow: "signUp", name },
},
},
});
}
async function createPage(request, workspaceId, title, parentId = null) {
const payload = await requestJson(request, "/api/tree/commands", {
method: "POST",
data: { action: "create", workspaceId, parentId, title },
});
const result = payload.result || payload;
const documentId = result.documentId || payload.documentId || result.id || "";
const resolvedWorkspaceId = result.workspaceId || payload.workspaceId || payload.workspace_id || workspaceId || "";
assert(documentId, `创建页面失败: ${JSON.stringify(payload)}`);
assert(resolvedWorkspaceId, `创建页面缺少 workspaceId: ${JSON.stringify(payload)}`);
return { documentId, workspaceId: resolvedWorkspaceId, payload };
}
async function treeCommand(request, workspaceId, action, documentId) {
return await requestJson(request, "/api/tree/commands", {
method: "POST",
data: { action, workspaceId, documentId },
});
}
async function uploadFileAsset(request, workspaceId, documentId, fileName, text) {
const response = await request.fetch(`${BASE_URL}/api/media/upload`, {
method: "POST",
multipart: {
workspaceId,
documentId,
file: {
name: fileName,
mimeType: "text/plain",
buffer: Buffer.from(text, "utf8"),
},
},
timeout: 30_000,
});
const payload = await response.json().catch(async () => await response.text());
if (!response.ok()) {
throw new Error(`/api/media/upload 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
const assetId = payload?.asset?.id || payload?.assetId || "";
assert(assetId, `上传附件缺少 asset id: ${JSON.stringify(payload)}`);
return { assetId, payload };
}
async function archiveAsset(request, assetId) {
return await requestJson(request, "/api/media/batch", {
method: "POST",
data: { action: "delete", assetIds: [assetId] },
});
}
async function restoreAsset(request, assetId) {
return await requestJson(request, "/api/media/batch", {
method: "POST",
data: { action: "restore", assetIds: [assetId] },
});
}
async function purgeAsset(request, assetId) {
return await requestJson(request, "/api/media/purge", {
method: "POST",
data: { assetId },
});
}
async function emptyResourceTrash(request, workspaceId) {
return await requestJson(request, "/api/media/empty-trash", {
method: "POST",
data: { workspaceId },
});
}
async function openDocumentFileTree(page, workspaceId, documentId) {
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
waitUntil: "commit",
timeout: UI_TIMEOUT_MS,
});
await page.waitForFunction(
() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
const fileTab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
return Boolean(fileRoot || fileTab);
},
undefined,
{ timeout: UI_TIMEOUT_MS },
);
await page.evaluate(() => {
const visible = (node) =>
node instanceof HTMLElement &&
!node.hidden &&
getComputedStyle(node).display !== "none" &&
getComputedStyle(node).visibility !== "hidden" &&
node.getClientRects().length > 0;
const fileRoot = document.getElementById("sidebar-file-tree-root");
if (visible(fileRoot)) return;
const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
if (tab instanceof HTMLElement) tab.click();
});
await page.waitForFunction(
() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
return fileRoot instanceof HTMLElement && fileRoot.getClientRects().length > 0;
},
undefined,
{ timeout: UI_TIMEOUT_MS },
);
}
async function openTrash(page, workspaceId) {
await page.goto(`${BASE_URL}/trash?workspaceId=${encodeURIComponent(workspaceId)}`, {
waitUntil: "commit",
timeout: UI_TIMEOUT_MS,
});
await page.locator('[data-testid="mnote-trash-workbench"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
}
async function waitForVisible(page, selector, label) {
await page.locator(selector).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }).catch(async (error) => {
throw new Error(`${label} 未出现: ${error.message}; state=${JSON.stringify(await readPageState(page).catch(() => null))}`);
});
}
async function waitForDetached(page, selector, label) {
await page.locator(selector).first().waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }).catch(async (error) => {
throw new Error(`${label} 未消失: ${error.message}; state=${JSON.stringify(await readPageState(page).catch(() => null))}`);
});
}
async function installTreeEventRecorder(page, label, records) {
await page.addInitScript(() => {
window.__MNOTE_TASK433_TREE_EVENTS__ = [];
const record = (name, event) => {
const detail = event && event.detail ? event.detail : {};
const payload = detail.payload || detail || {};
window.__MNOTE_TASK433_TREE_EVENTS__.push({
name,
at: Date.now(),
revision: detail.revision || payload.revision || payload.cursor || "",
op: payload && payload.data && payload.data.op ? payload.data.op : "",
});
};
window.addEventListener("tree:snapshot", (event) => record("tree:snapshot", event));
window.addEventListener("tree:delta", (event) => record("tree:delta", event));
window.addEventListener("tree:resync", (event) => record("tree:resync", event));
});
page.on("request", (request) => {
const url = request.url();
if (url.includes("/api/tree/events")) {
records.push({ label, type: "request", method: request.method(), url, at: Date.now() });
}
});
page.on("console", (message) => {
const text = message.text();
if (/tree live|EventSource|trash|error|failed/i.test(text)) {
records.push({ label, type: "console", level: message.type(), text: text.slice(0, 2000), at: Date.now() });
}
});
}
function recordNavigation(page, label, records) {
page.on("framenavigated", (frame) => {
if (frame === page.mainFrame()) {
records.push({ label, url: frame.url(), at: Date.now() });
}
});
}
async function readPageState(page) {
return await page.evaluate(() => ({
url: window.location.href,
liveStatus: document.documentElement.getAttribute("data-mnote-tree-live-status") || "",
liveApplied: document.documentElement.getAttribute("data-mnote-tree-live-applied") || "",
liveRevision: document.documentElement.getAttribute("data-mnote-tree-live-revision") || "",
liveError: document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "",
trashLiveReason:
document.querySelector('[data-testid="mnote-trash-workbench"]')?.getAttribute("data-live-refresh-reason") || "",
trashLiveAt:
document.querySelector('[data-testid="mnote-trash-workbench"]')?.getAttribute("data-live-refresh-at") || "",
filetreeRows: Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode='filetree']")).map((row) => ({
rowId: row instanceof HTMLElement ? row.dataset.rowId || "" : "",
assetId: row instanceof HTMLElement ? row.dataset.assetId || "" : "",
documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "",
objectKind: row instanceof HTMLElement ? row.dataset.objectKind || "" : "",
text: row.textContent || "",
})),
trashResourceRows: Array.from(document.querySelectorAll('[data-testid="mnote-trash-workbench"] [data-trash-row="resource"]')).map((row) => ({
resourceKind: row instanceof HTMLElement ? row.dataset.resourceKind || "" : "",
resourceId: row instanceof HTMLElement ? row.dataset.resourceId || "" : "",
documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "",
text: row.textContent || "",
})),
treeEvents: window.__MNOTE_TASK433_TREE_EVENTS__ || [],
}));
}
async function snapshotStep(result, name, fileTreePage, trashPage, navigationStart) {
result.steps.push({
name,
fileTree: await readPageState(fileTreePage),
trash: await readPageState(trashPage),
navigationEvents: result.navigationEvents.slice(navigationStart),
});
}
async function cleanup(request, workspaceId, documentIds, assetIds) {
for (const assetId of assetIds.filter(Boolean)) {
await archiveAsset(request, assetId).catch(() => null);
}
if (workspaceId) {
await emptyResourceTrash(request, workspaceId).catch(() => null);
}
for (const documentId of documentIds.filter(Boolean)) {
await treeCommand(request, workspaceId, "archive", documentId).catch(() => null);
}
if (workspaceId) {
await requestJson(request, "/api/documents/empty-trash", { method: "POST", data: { workspaceId } }).catch(() => null);
}
}
(async () => {
const stamp = Date.now();
const email = `mnote.stage8.file.${stamp}@example.com`;
const prefix = `TEST-10REVIEW-07-P8-FILE-${stamp}`;
const result = {
ok: false,
task: TASK,
baseUrl: BASE_URL,
authBaseUrl: AUTH_BASE_URL,
email,
prefix,
fixture: {},
navigationEvents: [],
treeEventRequests: [],
steps: [],
};
const browser = await chromium.launch({ headless: true });
const contextA = await browser.newContext();
const contextB = await browser.newContext();
const requestA = contextA.request;
const fileTreeB = await contextB.newPage();
const trashB = await contextB.newPage();
let workspaceId = "";
const cleanupDocumentIds = [];
const cleanupAssetIds = [];
await installTreeEventRecorder(fileTreeB, "B-filetree", result.treeEventRequests);
await installTreeEventRecorder(trashB, "B-trash", result.treeEventRequests);
recordNavigation(fileTreeB, "B-filetree", result.navigationEvents);
recordNavigation(trashB, "B-trash", result.navigationEvents);
try {
for (const requestContext of [requestA, contextB.request]) {
await authenticate(requestContext, email, `stage8-file-${stamp}`);
}
const root = await createPage(requestA, null, `${prefix}-root`);
workspaceId = root.workspaceId;
cleanupDocumentIds.push(root.documentId);
result.fixture.rootId = root.documentId;
result.fixture.workspaceId = workspaceId;
await openDocumentFileTree(fileTreeB, workspaceId, root.documentId);
await openTrash(trashB, workspaceId);
await waitForVisible(fileTreeB, docRowSelector(root.documentId), "B 文件树 root 页面");
const navigationStart = result.navigationEvents.length;
await snapshotStep(result, "initial", fileTreeB, trashB, navigationStart);
const uploaded = await uploadFileAsset(
requestA,
workspaceId,
root.documentId,
`${prefix}-lifecycle.txt`,
`${prefix} lifecycle asset`,
);
cleanupAssetIds.push(uploaded.assetId);
result.fixture.lifecycleAssetId = uploaded.assetId;
await waitForVisible(fileTreeB, assetRowSelector(uploaded.assetId), "B 文件树上传附件");
await snapshotStep(result, "upload-visible-on-b", fileTreeB, trashB, navigationStart);
await archiveAsset(requestA, uploaded.assetId);
await waitForDetached(fileTreeB, assetRowSelector(uploaded.assetId), "B 文件树附件删除后");
await waitForVisible(trashB, trashAssetRowSelector(uploaded.assetId), "B 垃圾箱附件删除后");
await snapshotStep(result, "archive-visible-on-b", fileTreeB, trashB, navigationStart);
await restoreAsset(requestA, uploaded.assetId);
await waitForVisible(fileTreeB, assetRowSelector(uploaded.assetId), "B 文件树附件恢复后");
await waitForDetached(trashB, trashAssetRowSelector(uploaded.assetId), "B 垃圾箱附件恢复后");
await snapshotStep(result, "restore-visible-on-b", fileTreeB, trashB, navigationStart);
await archiveAsset(requestA, uploaded.assetId);
await waitForVisible(trashB, trashAssetRowSelector(uploaded.assetId), "B 垃圾箱附件再次删除后");
await purgeAsset(requestA, uploaded.assetId);
await waitForDetached(trashB, trashAssetRowSelector(uploaded.assetId), "B 垃圾箱附件彻底删除后");
await waitForDetached(fileTreeB, assetRowSelector(uploaded.assetId), "B 文件树附件彻底删除后");
await snapshotStep(result, "purge-visible-on-b", fileTreeB, trashB, navigationStart);
const emptyAssetA = await uploadFileAsset(requestA, workspaceId, root.documentId, `${prefix}-empty-a.txt`, "empty a");
const emptyAssetB = await uploadFileAsset(requestA, workspaceId, root.documentId, `${prefix}-empty-b.txt`, "empty b");
cleanupAssetIds.push(emptyAssetA.assetId, emptyAssetB.assetId);
result.fixture.emptyAssetIds = [emptyAssetA.assetId, emptyAssetB.assetId];
await waitForVisible(fileTreeB, assetRowSelector(emptyAssetA.assetId), "B 文件树 empty-a 上传后");
await waitForVisible(fileTreeB, assetRowSelector(emptyAssetB.assetId), "B 文件树 empty-b 上传后");
await archiveAsset(requestA, emptyAssetA.assetId);
await archiveAsset(requestA, emptyAssetB.assetId);
await waitForVisible(trashB, trashAssetRowSelector(emptyAssetA.assetId), "B 垃圾箱 empty-a 删除后");
await waitForVisible(trashB, trashAssetRowSelector(emptyAssetB.assetId), "B 垃圾箱 empty-b 删除后");
await emptyResourceTrash(requestA, workspaceId);
await waitForDetached(trashB, trashAssetRowSelector(emptyAssetA.assetId), "B 垃圾箱 empty-a 清空后");
await waitForDetached(trashB, trashAssetRowSelector(emptyAssetB.assetId), "B 垃圾箱 empty-b 清空后");
await waitForDetached(fileTreeB, assetRowSelector(emptyAssetA.assetId), "B 文件树 empty-a 清空后");
await waitForDetached(fileTreeB, assetRowSelector(emptyAssetB.assetId), "B 文件树 empty-b 清空后");
await snapshotStep(result, "empty-trash-visible-on-b", fileTreeB, trashB, navigationStart);
const unexpectedNavigations = result.navigationEvents.slice(navigationStart);
assert.equal(unexpectedNavigations.length, 0, `B 浏览器发生了刷新/导航: ${JSON.stringify(unexpectedNavigations)}`);
result.ok = true;
} catch (error) {
result.error = error instanceof Error ? error.stack || error.message : String(error);
result.failure = {
fileTree: await readPageState(fileTreeB).catch(() => null),
trash: await readPageState(trashB).catch(() => null),
};
process.exitCode = 1;
} finally {
await cleanup(requestA, workspaceId, cleanupDocumentIds, cleanupAssetIds).catch((error) => {
result.cleanupError = error instanceof Error ? error.message : String(error);
});
await browser.close().catch(() => undefined);
await writeResult(result);
}
})();
@@ -1,559 +0,0 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { chromium } = require("playwright");
const TASK = "task434-filetree-trash-mindmap-table-dual-browser-no-refresh-smoke";
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, "");
const CONVEX_URL = (process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_SELF_HOSTED_URL || "http://127.0.0.1:3210").replace(/\/+$/, "");
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
// 说明:该 smoke 覆盖 convex-source mindmap/table trash 兼容链路,不代表 local-first 默认资源真相。
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 35_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
async function writeResult(payload) {
await fs.mkdir(OUTPUT_DIR, { recursive: true });
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
}
async function requestJson(request, requestPath, init = {}) {
const response = await request.fetch(`${BASE_URL}${requestPath}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 30_000,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
return payload;
}
async function requestAuthJson(request, requestPath, init = {}) {
const response = await request.fetch(`${AUTH_BASE_URL}${requestPath}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
},
timeout: 20_000,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
return payload;
}
async function convexCall(context, kind, convexPath, args) {
const cookies = await context.cookies(BASE_URL);
const jwt = cookies.find((cookie) => cookie.name === "__convexAuthJWT")?.value;
assert(jwt, "缺少 __convexAuthJWT cookie");
const response = await fetch(`${CONVEX_URL}/api/${kind}`, {
method: "POST",
headers: {
authorization: `Bearer ${jwt}`,
"content-type": "application/json",
"Convex-Client": TASK,
},
body: JSON.stringify({ path: convexPath, format: "convex_encoded_json", args: [args] }),
});
const body = await response.json();
if (!response.ok || body.status !== "success") {
throw new Error(`Convex ${kind} ${convexPath} 失败: ${response.status} ${JSON.stringify(body)}`);
}
return body.value;
}
function cssEscape(value) {
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function docRowSelector(documentId) {
return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="doc:${cssEscape(documentId)}"]`;
}
function assetRowSelector(assetId) {
return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-asset-id="${cssEscape(assetId)}"]`;
}
function trashAssetRowSelector(assetId) {
return `[data-testid="mnote-trash-workbench"] [data-trash-row="resource"][data-resource-kind="media"][data-resource-id="${cssEscape(assetId)}"]`;
}
function trashResourceRowSelector(kind, resourceId) {
return `[data-testid="mnote-trash-workbench"] [data-trash-row="resource"][data-resource-kind="${cssEscape(kind)}"][data-resource-id="${cssEscape(resourceId)}"]`;
}
async function authenticate(request, email, name) {
await requestAuthJson(request, "/api/auth", {
method: "POST",
data: {
action: "auth:signIn",
args: {
provider: "password",
params: { email, password: e2ePassword(), flow: "signUp", name },
},
},
});
}
async function createPage(request, workspaceId, title, parentId = null) {
const payload = await requestJson(request, "/api/tree/commands", {
method: "POST",
data: { action: "create", workspaceId, parentId, title },
});
const result = payload.result || payload;
const documentId = result.documentId || payload.documentId || result.id || "";
const resolvedWorkspaceId = result.workspaceId || payload.workspaceId || payload.workspace_id || workspaceId || "";
assert(documentId, `创建页面失败: ${JSON.stringify(payload)}`);
assert(resolvedWorkspaceId, `创建页面缺少 workspaceId: ${JSON.stringify(payload)}`);
return { documentId, workspaceId: resolvedWorkspaceId, payload };
}
async function treeCommand(request, workspaceId, action, documentId) {
return await requestJson(request, "/api/tree/commands", {
method: "POST",
data: { action, workspaceId, documentId },
});
}
async function uploadFileAsset(request, workspaceId, documentId, fileName, text) {
const response = await request.fetch(`${BASE_URL}/api/media/upload`, {
method: "POST",
multipart: {
workspaceId,
documentId,
file: {
name: fileName,
mimeType: "text/plain",
buffer: Buffer.from(text, "utf8"),
},
},
timeout: 30_000,
});
const payload = await response.json().catch(async () => await response.text());
if (!response.ok()) {
throw new Error(`/api/media/upload 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
const assetId = payload?.asset?.id || payload?.assetId || "";
assert(assetId, `上传附件缺少 asset id: ${JSON.stringify(payload)}`);
return { assetId, payload };
}
async function createMindmap(request, workspaceId, documentId, mindmapId, title) {
return await requestJson(request, `/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, {
method: "POST",
data: {
commandName: "mindmaps.put",
workspaceId,
createOnly: true,
data: { root: { data: { text: title }, children: [] } },
},
});
}
async function archiveMindmap(request, documentId, mindmapId) {
return await requestJson(request, `/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, {
method: "DELETE",
});
}
async function restoreMindmap(request, documentId, mindmapId) {
return await requestJson(request, `/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, {
method: "PATCH",
data: { action: "restore" },
});
}
async function purgeMindmap(request, documentId, mindmapId) {
return await requestJson(request, `/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, {
method: "PATCH",
data: { action: "purge" },
});
}
async function createTable(request, documentId, title) {
const payload = await requestJson(request, "/api/tables/create", {
method: "POST",
data: {
documentId,
title,
schema: {},
snapshot: null,
},
});
const tableId = payload?.result?.id || payload?.id || "";
assert(tableId, `创建 table 缺少 id: ${JSON.stringify(payload)}`);
return tableId;
}
async function archiveTable(request, tableId) {
return await requestJson(request, `/api/tables/${encodeURIComponent(tableId)}`, { method: "DELETE" });
}
async function restoreTable(request, tableId) {
return await requestJson(request, "/api/tables/restore", {
method: "POST",
data: { tableId },
});
}
async function purgeTable(request, tableId) {
return await requestJson(request, "/api/tables/purge", {
method: "POST",
data: { tableId },
});
}
async function archiveAsset(request, assetId) {
return await requestJson(request, "/api/media/batch", {
method: "POST",
data: { action: "delete", assetIds: [assetId] },
});
}
async function restoreAsset(request, assetId) {
return await requestJson(request, "/api/media/batch", {
method: "POST",
data: { action: "restore", assetIds: [assetId] },
});
}
async function purgeAsset(request, assetId) {
return await requestJson(request, "/api/media/purge", {
method: "POST",
data: { assetId },
});
}
async function emptyResourceTrash(request, workspaceId) {
return await requestJson(request, "/api/media/empty-trash", {
method: "POST",
data: { workspaceId },
});
}
async function emptyMindmapTrash(request, workspaceId) {
return await requestJson(request, "/api/mindmap-trash/empty", {
method: "POST",
data: { workspaceId },
});
}
async function emptyTableTrash(request, workspaceId) {
return await requestJson(request, "/api/tables/empty-trash", {
method: "POST",
data: { workspaceId },
});
}
async function openDocumentFileTree(page, workspaceId, documentId) {
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
waitUntil: "commit",
timeout: UI_TIMEOUT_MS,
});
await page.waitForFunction(
() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
const fileTab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
return Boolean(fileRoot || fileTab);
},
undefined,
{ timeout: UI_TIMEOUT_MS },
);
await page.evaluate(() => {
const visible = (node) =>
node instanceof HTMLElement &&
!node.hidden &&
getComputedStyle(node).display !== "none" &&
getComputedStyle(node).visibility !== "hidden" &&
node.getClientRects().length > 0;
const fileRoot = document.getElementById("sidebar-file-tree-root");
if (visible(fileRoot)) return;
const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
if (tab instanceof HTMLElement) tab.click();
});
await page.waitForFunction(
() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
return fileRoot instanceof HTMLElement && fileRoot.getClientRects().length > 0;
},
undefined,
{ timeout: UI_TIMEOUT_MS },
);
}
async function openTrash(page, workspaceId) {
await page.goto(`${BASE_URL}/trash?workspaceId=${encodeURIComponent(workspaceId)}`, {
waitUntil: "commit",
timeout: UI_TIMEOUT_MS,
});
await page.locator('[data-testid="mnote-trash-workbench"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
}
async function waitForVisible(page, selector, label) {
await page.locator(selector).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }).catch(async (error) => {
throw new Error(`${label} 未出现: ${error.message}; state=${JSON.stringify(await readPageState(page).catch(() => null))}`);
});
}
async function waitForDetached(page, selector, label) {
await page.locator(selector).first().waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }).catch(async (error) => {
throw new Error(`${label} 未消失: ${error.message}; state=${JSON.stringify(await readPageState(page).catch(() => null))}`);
});
}
async function installTreeEventRecorder(page, label, records) {
await page.addInitScript(() => {
window.__MNOTE_TASK433_TREE_EVENTS__ = [];
const record = (name, event) => {
const detail = event && event.detail ? event.detail : {};
const payload = detail.payload || detail || {};
window.__MNOTE_TASK433_TREE_EVENTS__.push({
name,
at: Date.now(),
revision: detail.revision || payload.revision || payload.cursor || "",
op: payload && payload.data && payload.data.op ? payload.data.op : "",
});
};
window.addEventListener("tree:snapshot", (event) => record("tree:snapshot", event));
window.addEventListener("tree:delta", (event) => record("tree:delta", event));
window.addEventListener("tree:resync", (event) => record("tree:resync", event));
});
page.on("request", (request) => {
const url = request.url();
if (url.includes("/api/tree/events")) {
records.push({ label, type: "request", method: request.method(), url, at: Date.now() });
}
});
page.on("console", (message) => {
const text = message.text();
if (/tree live|EventSource|trash|error|failed/i.test(text)) {
records.push({ label, type: "console", level: message.type(), text: text.slice(0, 2000), at: Date.now() });
}
});
}
function recordNavigation(page, label, records) {
page.on("framenavigated", (frame) => {
if (frame === page.mainFrame()) {
records.push({ label, url: frame.url(), at: Date.now() });
}
});
}
async function readPageState(page) {
return await page.evaluate(() => ({
url: window.location.href,
liveStatus: document.documentElement.getAttribute("data-mnote-tree-live-status") || "",
liveApplied: document.documentElement.getAttribute("data-mnote-tree-live-applied") || "",
liveRevision: document.documentElement.getAttribute("data-mnote-tree-live-revision") || "",
liveError: document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "",
trashLiveReason:
document.querySelector('[data-testid="mnote-trash-workbench"]')?.getAttribute("data-live-refresh-reason") || "",
trashLiveAt:
document.querySelector('[data-testid="mnote-trash-workbench"]')?.getAttribute("data-live-refresh-at") || "",
filetreeRows: Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode='filetree']")).map((row) => ({
rowId: row instanceof HTMLElement ? row.dataset.rowId || "" : "",
assetId: row instanceof HTMLElement ? row.dataset.assetId || "" : "",
documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "",
objectKind: row instanceof HTMLElement ? row.dataset.objectKind || "" : "",
text: row.textContent || "",
})),
trashResourceRows: Array.from(document.querySelectorAll('[data-testid="mnote-trash-workbench"] [data-trash-row="resource"]')).map((row) => ({
resourceKind: row instanceof HTMLElement ? row.dataset.resourceKind || "" : "",
resourceId: row instanceof HTMLElement ? row.dataset.resourceId || "" : "",
documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "",
text: row.textContent || "",
})),
treeEvents: window.__MNOTE_TASK433_TREE_EVENTS__ || [],
}));
}
async function snapshotStep(result, name, fileTreePage, trashPage, navigationStart) {
result.steps.push({
name,
fileTree: await readPageState(fileTreePage),
trash: await readPageState(trashPage),
navigationEvents: result.navigationEvents.slice(navigationStart),
});
}
async function cleanup(request, workspaceId, documentIds, mindmapIds, tableIds) {
for (const mindmapId of mindmapIds.filter(Boolean)) {
for (const documentId of documentIds.filter(Boolean)) {
await archiveMindmap(request, documentId, mindmapId).catch(() => null);
}
}
for (const tableId of tableIds.filter(Boolean)) {
await archiveTable(request, tableId).catch(() => null);
}
if (workspaceId) {
await emptyMindmapTrash(request, workspaceId).catch(() => null);
await emptyTableTrash(request, workspaceId).catch(() => null);
}
for (const documentId of documentIds.filter(Boolean)) {
await treeCommand(request, workspaceId, "archive", documentId).catch(() => null);
}
if (workspaceId) {
await requestJson(request, "/api/documents/empty-trash", { method: "POST", data: { workspaceId } }).catch(() => null);
}
}
(async () => {
const stamp = Date.now();
const email = `mnote.stage8.mindtable.${stamp}@example.com`;
const prefix = `TEST-10REVIEW-07-P8-MT-${stamp}`;
const result = {
ok: false,
task: TASK,
baseUrl: BASE_URL,
authBaseUrl: AUTH_BASE_URL,
email,
prefix,
fixture: {},
navigationEvents: [],
treeEventRequests: [],
steps: [],
};
const browser = await chromium.launch({ headless: true });
const contextA = await browser.newContext();
const contextB = await browser.newContext();
const requestA = contextA.request;
const fileTreeB = await contextB.newPage();
const trashB = await contextB.newPage();
let workspaceId = "";
const cleanupDocumentIds = [];
const cleanupMindmapIds = [];
const cleanupTableIds = [];
await installTreeEventRecorder(fileTreeB, "B-filetree", result.treeEventRequests);
await installTreeEventRecorder(trashB, "B-trash", result.treeEventRequests);
recordNavigation(fileTreeB, "B-filetree", result.navigationEvents);
recordNavigation(trashB, "B-trash", result.navigationEvents);
try {
for (const requestContext of [requestA, contextB.request]) {
await authenticate(requestContext, email, `stage8-mindtable-${stamp}`);
}
const root = await createPage(requestA, null, `${prefix}-root`);
workspaceId = root.workspaceId;
cleanupDocumentIds.push(root.documentId);
result.fixture.rootId = root.documentId;
result.fixture.workspaceId = workspaceId;
await openDocumentFileTree(fileTreeB, workspaceId, root.documentId);
await openTrash(trashB, workspaceId);
await waitForVisible(fileTreeB, docRowSelector(root.documentId), "B 文件树 root 页面");
const navigationStart = result.navigationEvents.length;
await snapshotStep(result, "initial", fileTreeB, trashB, navigationStart);
const mindmapId = `mind_stage8_${stamp}_lifecycle`;
await createMindmap(requestA, workspaceId, root.documentId, mindmapId, `${prefix}-mind-lifecycle`);
cleanupMindmapIds.push(mindmapId);
result.fixture.mindmapId = mindmapId;
await waitForVisible(fileTreeB, assetRowSelector(mindmapId), "B 文件树 mindmap 创建后");
await snapshotStep(result, "mindmap-create-visible-on-b", fileTreeB, trashB, navigationStart);
await archiveMindmap(requestA, root.documentId, mindmapId);
await waitForDetached(fileTreeB, assetRowSelector(mindmapId), "B 文件树 mindmap 删除后");
await waitForVisible(trashB, trashResourceRowSelector("mindmap", mindmapId), "B 垃圾箱 mindmap 删除后");
await snapshotStep(result, "mindmap-archive-visible-on-b", fileTreeB, trashB, navigationStart);
await restoreMindmap(requestA, root.documentId, mindmapId);
await waitForVisible(fileTreeB, assetRowSelector(mindmapId), "B 文件树 mindmap 恢复后");
await waitForDetached(trashB, trashResourceRowSelector("mindmap", mindmapId), "B 垃圾箱 mindmap 恢复后");
await snapshotStep(result, "mindmap-restore-visible-on-b", fileTreeB, trashB, navigationStart);
await archiveMindmap(requestA, root.documentId, mindmapId);
await waitForVisible(trashB, trashResourceRowSelector("mindmap", mindmapId), "B 垃圾箱 mindmap 再次删除后");
await purgeMindmap(requestA, root.documentId, mindmapId);
await waitForDetached(trashB, trashResourceRowSelector("mindmap", mindmapId), "B 垃圾箱 mindmap 彻底删除后");
await waitForDetached(fileTreeB, assetRowSelector(mindmapId), "B 文件树 mindmap 彻底删除后");
await snapshotStep(result, "mindmap-purge-visible-on-b", fileTreeB, trashB, navigationStart);
const tableId = await createTable(requestA, root.documentId, `${prefix}-table-lifecycle`);
cleanupTableIds.push(tableId);
result.fixture.tableId = tableId;
await waitForVisible(fileTreeB, assetRowSelector(tableId), "B 文件树 table 创建后");
await snapshotStep(result, "table-create-visible-on-b", fileTreeB, trashB, navigationStart);
await archiveTable(requestA, tableId);
await waitForDetached(fileTreeB, assetRowSelector(tableId), "B 文件树 table 删除后");
await waitForVisible(trashB, trashResourceRowSelector("table", tableId), "B 垃圾箱 table 删除后");
await snapshotStep(result, "table-archive-visible-on-b", fileTreeB, trashB, navigationStart);
await restoreTable(requestA, tableId);
await waitForVisible(fileTreeB, assetRowSelector(tableId), "B 文件树 table 恢复后");
await waitForDetached(trashB, trashResourceRowSelector("table", tableId), "B 垃圾箱 table 恢复后");
await snapshotStep(result, "table-restore-visible-on-b", fileTreeB, trashB, navigationStart);
await archiveTable(requestA, tableId);
await waitForVisible(trashB, trashResourceRowSelector("table", tableId), "B 垃圾箱 table 再次删除后");
await purgeTable(requestA, tableId);
await waitForDetached(trashB, trashResourceRowSelector("table", tableId), "B 垃圾箱 table 彻底删除后");
await waitForDetached(fileTreeB, assetRowSelector(tableId), "B 文件树 table 彻底删除后");
await snapshotStep(result, "table-purge-visible-on-b", fileTreeB, trashB, navigationStart);
const emptyMindmapId = `mind_stage8_${stamp}_empty`;
await createMindmap(requestA, workspaceId, root.documentId, emptyMindmapId, `${prefix}-mind-empty`);
const emptyTableId = await createTable(requestA, root.documentId, `${prefix}-table-empty`);
cleanupMindmapIds.push(emptyMindmapId);
cleanupTableIds.push(emptyTableId);
result.fixture.emptyMindmapId = emptyMindmapId;
result.fixture.emptyTableId = emptyTableId;
await waitForVisible(fileTreeB, assetRowSelector(emptyMindmapId), "B 文件树 empty mindmap 创建后");
await waitForVisible(fileTreeB, assetRowSelector(emptyTableId), "B 文件树 empty table 创建后");
await archiveMindmap(requestA, root.documentId, emptyMindmapId);
await archiveTable(requestA, emptyTableId);
await waitForVisible(trashB, trashResourceRowSelector("mindmap", emptyMindmapId), "B 垃圾箱 empty mindmap 删除后");
await waitForVisible(trashB, trashResourceRowSelector("table", emptyTableId), "B 垃圾箱 empty table 删除后");
await emptyMindmapTrash(requestA, workspaceId);
await emptyTableTrash(requestA, workspaceId);
await waitForDetached(trashB, trashResourceRowSelector("mindmap", emptyMindmapId), "B 垃圾箱 empty mindmap 清空后");
await waitForDetached(trashB, trashResourceRowSelector("table", emptyTableId), "B 垃圾箱 empty table 清空后");
await waitForDetached(fileTreeB, assetRowSelector(emptyMindmapId), "B 文件树 empty mindmap 清空后");
await waitForDetached(fileTreeB, assetRowSelector(emptyTableId), "B 文件树 empty table 清空后");
await snapshotStep(result, "mindmap-table-empty-trash-visible-on-b", fileTreeB, trashB, navigationStart);
const unexpectedNavigations = result.navigationEvents.slice(navigationStart);
assert.equal(unexpectedNavigations.length, 0, `B 浏览器发生了刷新/导航: ${JSON.stringify(unexpectedNavigations)}`);
result.ok = true;
} catch (error) {
result.error = error instanceof Error ? error.stack || error.message : String(error);
result.failure = {
fileTree: await readPageState(fileTreeB).catch(() => null),
trash: await readPageState(trashB).catch(() => null),
};
process.exitCode = 1;
} finally {
await cleanup(requestA, workspaceId, cleanupDocumentIds, cleanupMindmapIds, cleanupTableIds).catch((error) => {
result.cleanupError = error instanceof Error ? error.message : String(error);
});
await browser.close().catch(() => undefined);
await writeResult(result);
}
})();
@@ -165,6 +165,7 @@ async function run() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-local-open-doc-external-"));
const files = {
clean: "clean-sync.md",
aiClean: "clean-ai-sync.md",
dirty: "dirty-conflict.md",
aiDirty: "dirty-ai-conflict.md",
rename: "rename-open.md",
@@ -172,6 +173,7 @@ async function run() {
};
writeWorkspaceManifest(root, "user_real");
fs.writeFileSync(path.join(root, files.clean), markdown("Clean Sync", ["initial clean"]), "utf8");
fs.writeFileSync(path.join(root, files.aiClean), markdown("Clean AI Sync", ["initial ai clean"]), "utf8");
fs.writeFileSync(path.join(root, files.dirty), markdown("Dirty Conflict", ["initial dirty"]), "utf8");
fs.writeFileSync(path.join(root, files.aiDirty), markdown("Dirty AI Conflict", ["initial ai dirty"]), "utf8");
fs.writeFileSync(path.join(root, files.rename), markdown("Rename Open", ["initial rename"]), "utf8");
@@ -211,6 +213,17 @@ async function run() {
await waitForEditorStatus(page, "synced-external-change");
}));
await openDocument(page, root, files.aiClean);
await waitForEditorText(page, "initial ai clean");
await page.waitForTimeout(300);
navigationEvents.length = 0;
steps.push(await runStep("clean 文档 AI 后台写入后自动同步内容", navigationEvents, async () => {
const aiToken = `ai-clean-${Date.now()}`;
await callMarkdownEdit(root, files.aiClean, "initial ai clean", `initial ai clean ${aiToken}`);
await waitForEditorText(page, aiToken);
await waitForEditorStatus(page, "synced-external-change");
}));
await openDocument(page, root, files.dirty);
await waitForEditorText(page, "initial dirty");
await page.waitForTimeout(300);
@@ -262,7 +275,7 @@ async function run() {
fs.rmSync(path.join(root, files.delete));
await waitForEditorStatus(page, "external-change-conflict");
const runtime = await readEditorRuntime(page);
assert(runtime.error.includes("本地 Markdown 文件已在外部更新"), `删除提示不正确: ${JSON.stringify(runtime)}`);
assert(runtime.error.includes("已被删除"), `删除提示不正确: ${JSON.stringify(runtime)}`);
}));
const result = {
@@ -256,6 +256,9 @@ async function main() {
output: scenario.message,
agentAudit: {
eventId: `audit_local_changed_${currentScenario}_${suffix}`,
actorId,
actorType: "user",
agentKind: "reasonix",
rootUri,
diffSummary: "1 changed file(s)",
changedFiles: [
@@ -263,6 +266,10 @@ async function main() {
path: scenario.relativePath,
changeType: "modified",
summary: `追加 ${scenario.marker}`,
hashBefore: "111",
hashAfter: "222",
modifiedBeforeMs: 10,
modifiedAfterMs: 20,
},
],
},
@@ -308,6 +315,8 @@ async function main() {
&& card.text.includes("agent.changed_files")
&& card.text.includes("README.md")
&& card.text.includes(marker)
&& card.text.includes("hash 111→222")
&& card.text.includes("reasonix/user/user_real")
),
`本地 AI changed files 工具卡未显示 README.md 与 diff 摘要: ${JSON.stringify(cards)}`,
);
@@ -337,8 +346,70 @@ async function main() {
assert.equal(runBody.documentId, documentId, `Hermes run 应携带 local documentId: ${JSON.stringify(runBody)}`);
assert.equal(runBody.sourceKind, "local_folder", `Hermes run 应携带 local_folder sourceKind: ${JSON.stringify(runBody)}`);
assert.equal(runBody.rootUri, rootUri, `Hermes run 应携带 rootUri: ${JSON.stringify(runBody)}`);
assert.equal(runBody.editorTarget?.schema, "mnote.ai_editor_target.v1", `Hermes run 应携带 AI editor target: ${JSON.stringify(runBody)}`);
assert.equal(runBody.editorTarget?.source, "open_editors_snapshot", `AI editor target 应来自 OpenEditorsSnapshot: ${JSON.stringify(runBody.editorTarget)}`);
assert.equal(runBody.editorTarget?.documentId, documentId, `AI editor target 应指向当前文档: ${JSON.stringify(runBody.editorTarget)}`);
assert.equal(runBody.runTargetSnapshot?.schema, "mnote.page_ai_run_target_snapshot.v1", `Hermes run 应携带冻结 target snapshot: ${JSON.stringify(runBody.runTargetSnapshot)}`);
assert.equal(runBody.runTargetSnapshot?.editorTarget?.documentId, documentId, `冻结 target snapshot 应指向发起 run 时的文档: ${JSON.stringify(runBody.runTargetSnapshot)}`);
assert.equal(runBody.runTargetSnapshot?.editorTarget?.workspacePath?.rootUri, rootUri, `冻结 target snapshot 应保留发起 run 时的 rootUri: ${JSON.stringify(runBody.runTargetSnapshot)}`);
assert.equal(runBody.pageContext?.aiContext?.runTargetSnapshot?.editorTarget?.documentId, documentId, `pageContext.aiContext 应保留冻结 target snapshot: ${JSON.stringify(runBody.pageContext?.aiContext)}`);
assert.equal(runBody.pageContext?.aiContext?.activeEditorTarget?.source, "open_editors_snapshot", `pageContext.aiContext 应包含 activeEditorTarget: ${JSON.stringify(runBody.pageContext?.aiContext)}`);
assert.equal(runBody.pageContext?.aiContext?.openEditorsSnapshot?.activeEditor?.documentId, documentId, `aiContext.openEditorsSnapshot 应包含 active editor: ${JSON.stringify(runBody.pageContext?.aiContext?.openEditorsSnapshot)}`);
assert(runBody.pageContext?.aiContext?.openEditorsSnapshot?.groups?.primary, `aiContext.openEditorsSnapshot 应保留 primary group: ${JSON.stringify(runBody.pageContext?.aiContext?.openEditorsSnapshot)}`);
assert(runBody.pageContext?.aiContext?.openEditorsSnapshot?.groups?.secondary, `aiContext.openEditorsSnapshot 应保留 secondary group: ${JSON.stringify(runBody.pageContext?.aiContext?.openEditorsSnapshot)}`);
const cleanCapturedKinds = captured.map((entry) => entry.kind);
captured.length = 0;
await page.evaluate(({ otherRootUri, otherWorkspaceId }) => {
const runtime = window.__mnoteDocumentPaneRuntime;
if (!runtime || typeof runtime.getOpenEditorsSnapshot !== "function") {
throw new Error("missing_open_editors_snapshot_runtime");
}
window.__task453OriginalOpenEditorsSnapshot = runtime.getOpenEditorsSnapshot.bind(runtime);
runtime.getOpenEditorsSnapshot = () => {
const snapshot = window.__task453OriginalOpenEditorsSnapshot();
const cloned = JSON.parse(JSON.stringify(snapshot || {}));
const poisonEditor = (entry) => {
if (!entry || typeof entry !== "object") return;
entry.workspaceId = otherWorkspaceId;
entry.workspacePath = Object.assign({}, entry.workspacePath || {}, {
workspaceId: otherWorkspaceId,
rootUri: otherRootUri,
sourceKind: "local_folder",
});
};
poisonEditor(cloned.activeEditor);
(cloned.editors || []).forEach((entry) => {
if (entry && entry.active) poisonEditor(entry);
});
Object.values(cloned.groups || {}).forEach((group) => {
(group.editors || []).forEach((entry) => {
if (entry && entry.active) poisonEditor(entry);
});
});
return cloned;
};
}, { otherRootUri: "file:///tmp/mnote-task453-other-root", otherWorkspaceId: "local-ws:user_real:other" });
await page.locator("[data-page-ai-input]").fill(`请错误写入其他 workspace ${marker}`, { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => {
const drawerText = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "";
return drawerText.includes("AI target 与当前本地工作区 rootUri 不一致")
|| drawerText.includes("AI target 与当前 workspaceId 不一致");
},
null,
{ timeout: UI_TIMEOUT_MS },
);
assert(!captured.some((entry) => entry.kind === "run"), `跨 workspace target 不应发起 Hermes run: ${JSON.stringify(captured)}`);
await page.evaluate(() => {
const runtime = window.__mnoteDocumentPaneRuntime;
if (runtime && window.__task453OriginalOpenEditorsSnapshot) {
runtime.getOpenEditorsSnapshot = window.__task453OriginalOpenEditorsSnapshot;
}
delete window.__task453OriginalOpenEditorsSnapshot;
});
currentScenario = "dirty";
captured.length = 0;
const dirtyUrl = new URL(`${BASE_URL}/documents/${encodeURIComponent(dirtyDocumentId)}`);
@@ -353,49 +424,31 @@ async function main() {
await page.waitForFunction(
() => {
const drawerText = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "";
return drawerText.includes("agent.changed_files") && drawerText.includes("Dirty.md");
return drawerText.includes("目标文档存在未保存或外部变更状态");
},
null,
{ timeout: UI_TIMEOUT_MS },
);
await waitForEditorStatus(page, "external-change-conflict");
await page.locator('[data-testid="mnote-editor-conflict-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
(expected) => {
const panel = document.querySelector('[data-testid="mnote-editor-conflict-panel"]');
return (panel?.textContent || "").includes(expected);
},
`agent run ${dirtyRunId}`,
{ timeout: UI_TIMEOUT_MS },
);
const envelope = await conflictEnvelope(page, dirtyDocumentId);
assert(envelope, "dirty AI 写入应生成冲突信封");
assert("externalActor" in envelope, `冲突信封应包含 externalActor: ${JSON.stringify(envelope)}`);
assert("dirtyState" in envelope, `冲突信封应包含 dirtyState: ${JSON.stringify(envelope)}`);
assert("bufferFileVersion" in envelope, `冲突信封应包含 bufferFileVersion: ${JSON.stringify(envelope)}`);
await page.locator('[data-testid="mnote-conflict-open-diff"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-conflict-diff-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
({ localToken, aiToken }) => {
const panel = document.querySelector('[data-testid="mnote-conflict-diff-panel"]');
const text = panel?.textContent || "";
return text.includes(localToken) && text.includes(aiToken);
},
{ localToken: dirtyLocalToken, aiToken: dirtyMarker },
{ timeout: UI_TIMEOUT_MS },
);
const diffText = await page.locator('[data-testid="mnote-conflict-diff-panel"]').innerText({ timeout: UI_TIMEOUT_MS });
assert(diffText.includes(dirtyLocalToken), `dirty diff 应包含本地未保存内容: ${diffText}`);
assert(diffText.includes(dirtyMarker), `dirty diff 应包含 AI 写盘内容: ${diffText}`);
assert(!captured.some((entry) => entry.kind === "run"), `dirty buffer 下 Page AI 不应发起 Hermes run: ${JSON.stringify(captured)}`);
const dirtyDiskText = fs.readFileSync(dirtyPath, "utf8");
assert(!dirtyDiskText.includes(dirtyMarker), "dirty buffer 阻断后磁盘不应出现 AI 写入标记");
const dirtyEditorState = await page.evaluate(() => {
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
const conflictPanel = document.querySelector('[data-testid="mnote-editor-conflict-panel"]');
return {
status: root?.getAttribute("data-runtime-editor-status") || "",
text: editor?.textContent || "",
conflictVisible: Boolean(conflictPanel && conflictPanel.getClientRects().length > 0),
};
});
assert(dirtyEditorState.text.includes(dirtyLocalToken), `dirty buffer 阻断后本地未保存内容应仍在编辑器中: ${JSON.stringify(dirtyEditorState)}`);
assert.equal(dirtyEditorState.conflictVisible, false, `dirty buffer 阻断不应生成冲突面板: ${JSON.stringify(dirtyEditorState)}`);
const dirtyAggregate = await fetchPageAggregate(page, dirtyDocumentId, rootUri);
assert(
JSON.stringify(dirtyAggregate.payload || {}).includes(dirtyMarker),
`dirty Page Aggregate 应读回 AI 写入标记: ${JSON.stringify(dirtyAggregate)}`,
!JSON.stringify(dirtyAggregate.payload || {}).includes(dirtyMarker),
`dirty buffer 阻断后 Page Aggregate 应读回 AI 写入标记: ${JSON.stringify(dirtyAggregate)}`,
);
const dirtyRunBody = JSON.parse(captured.find((entry) => entry.kind === "run")?.body || "{}");
assert.equal(dirtyRunBody.documentId, dirtyDocumentId, `dirty Hermes run 应携带 local documentId: ${JSON.stringify(dirtyRunBody)}`);
assert.equal(dirtyRunBody.sourceKind, "local_folder", `dirty Hermes run 应携带 local_folder sourceKind: ${JSON.stringify(dirtyRunBody)}`);
assert.equal(dirtyRunBody.rootUri, rootUri, `dirty Hermes run 应携带 rootUri: ${JSON.stringify(dirtyRunBody)}`);
const result = {
ok: true,
@@ -409,8 +462,8 @@ async function main() {
dirtyDocumentId,
dirtyRunId,
dirtyMarker,
dirtyConflictStatus: "external-change-conflict",
dirtyEnvelope: envelope,
dirtyBlocked: true,
dirtyEditorStatus: dirtyEditorState.status,
capturedKinds: cleanCapturedKinds,
dirtyCapturedKinds: captured.map((entry) => entry.kind),
resultPath: RESULT_PATH,
@@ -64,6 +64,7 @@ async function uploadLocalAsset(page, root, documentId, fileName, mimeType, byte
const form = new FormData();
form.append("rootUri", rootUri);
form.append("documentId", documentId);
form.append("uploadIntent", "editor.markdown.attach");
form.append("kind", kind);
form.append("file", new File([new Uint8Array(bytes)], fileName, { type: mimeType }));
const response = await fetch("/api/local-folder/assets/upload", { method: "POST", body: form });
@@ -169,6 +170,16 @@ async function main() {
assert(!/\b(description|article|notes)\b/i.test(markdownTabState.resourceText), `Markdown tab 不应显示 Material Symbols 文本: ${JSON.stringify(markdownTabState)}`);
assert.equal(markdownTabState.badgeKind, "code", `Markdown tab 应使用黑色 code 色块: ${JSON.stringify(markdownTabState)}`);
assert(markdownTabState.badgeColor === "rgb(17, 17, 17)", `Markdown tab 色块应为黑色: ${JSON.stringify(markdownTabState)}`);
const openEditorsSnapshot = await page.evaluate(() => window.__mnoteOpenEditorsSnapshot || null);
assert.equal(openEditorsSnapshot?.schema, "mnote.open_editors_snapshot.v1", `应输出 open editors snapshot: ${JSON.stringify(openEditorsSnapshot)}`);
assert(openEditorsSnapshot.groups?.primary, `snapshot 应包含 primary group: ${JSON.stringify(openEditorsSnapshot)}`);
assert(openEditorsSnapshot.groups?.secondary, `snapshot 应包含 secondary group: ${JSON.stringify(openEditorsSnapshot)}`);
assert(openEditorsSnapshot.activeObjectIdentity.includes("resource-note.md"), `全局 active 应优先 primary active resource,而不是 secondary page: ${JSON.stringify(openEditorsSnapshot)}`);
assert(openEditorsSnapshot.groups.primary.activeObjectIdentity.includes("resource-note.md"), `primary group active 应指向 markdown resource: ${JSON.stringify(openEditorsSnapshot.groups.primary)}`);
assert(openEditorsSnapshot.groups.primary.resourceEditors.some((entry) => entry.paneRole === "primary" && entry.kind === "markdown" && entry.preview === false && entry.pinned === false), `primary resource editor 应包含轻量 editor 字段: ${JSON.stringify(openEditorsSnapshot.groups.primary)}`);
assert(openEditorsSnapshot.groups.primary.editors.some((entry) => entry.kind === "page" && entry.pinned === true), `primary group 应包含 pinned page editor: ${JSON.stringify(openEditorsSnapshot.groups.primary)}`);
assert(openEditorsSnapshot.groups.secondary.editors.some((entry) => entry.kind === "page" && entry.paneRole === "secondary"), `secondary group 应包含 secondary page editor: ${JSON.stringify(openEditorsSnapshot.groups.secondary)}`);
assert(openEditorsSnapshot.groups.secondary.editors.some((entry) => entry.kind === "page" && entry.documentId === localMdDocumentId(sideRelativePath) && entry.workspacePath?.relativePath === sideRelativePath), `secondary page editor 应保留自己的 documentId/workspacePath: ${JSON.stringify(openEditorsSnapshot.groups.secondary)}`);
const editor = page.locator('.mnote-resource-tab-panel:not([hidden]) .editor-surface .ProseMirror[contenteditable="true"]').first();
await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await editor.click({ timeout: UI_TIMEOUT_MS });
@@ -213,14 +224,24 @@ async function main() {
assert(!/\b(description|article|notes)\b/i.test(officeTabState.resourceText), `Office tab 不应显示 Material Symbols 文本: ${JSON.stringify(officeTabState)}`);
assert.equal(officeTabState.badgeKind, "word", `docx tab 应使用 word 色块: ${JSON.stringify(officeTabState)}`);
assert(officeTabState.badgeColor === "rgb(79, 125, 243)", `docx tab 色块应与附件 word 蓝色一致: ${JSON.stringify(officeTabState)}`);
const openEditorsSnapshotAfterOffice = await page.evaluate(() => window.__mnoteOpenEditorsSnapshot || null);
const activeOfficeEditor = openEditorsSnapshotAfterOffice?.groups?.primary?.resourceEditors?.find((entry) => entry.active && entry.kind === "office");
assert(activeOfficeEditor, `打开 Office 后 snapshot 应把 office resource 标为 active: ${JSON.stringify(openEditorsSnapshotAfterOffice?.groups?.primary)}`);
assert.equal(activeOfficeEditor.documentId, documentId, `Office snapshot 应携带 documentId: ${JSON.stringify(activeOfficeEditor)}`);
assert(activeOfficeEditor.workspaceId, `Office snapshot 应携带 workspaceId: ${JSON.stringify(activeOfficeEditor)}`);
assert.equal(activeOfficeEditor.editorKind, "office", `Office snapshot 应携带 editorKind: ${JSON.stringify(activeOfficeEditor)}`);
assert(activeOfficeEditor.lastActiveAt > 0, `Office passive resource 激活后 lastActiveAt 应大于 0: ${JSON.stringify(activeOfficeEditor)}`);
const officeFrameSrc = await page.locator('.mnote-resource-tab-panel:not([hidden]) iframe.mnote-resource-tab-frame').first().getAttribute("src", {
timeout: UI_TIMEOUT_MS,
});
assert(officeFrameSrc, "Office resource tab 应创建 iframe");
const officeFrameUrl = new URL(officeFrameSrc, BASE_URL);
assert(officeFrameUrl.pathname === "/onlyoffice", `Office resource tab iframe 应指向 /onlyoffice,实际为 ${officeFrameSrc}`);
assert(officeFrameUrl.searchParams.get("assetId") === officeAsset.id, "Office iframe URL 应携带当前资源 assetId");
assert(officeFrameUrl.searchParams.get("mode") === "view", "Office resource tab iframe 应使用 view 模式");
assert(officeFrameUrl.pathname === "/office-preview", `Office resource tab iframe 应指向 /office-preview 轻量预览,实际为 ${officeFrameSrc}`);
assert(officeFrameUrl.searchParams.get("assetId") === officeAsset.id, "Office preview URL 应携带当前资源 assetId");
assert(officeFrameUrl.searchParams.get("documentId") === documentId, "Office preview URL 应携带当前 documentId");
assert(officeFrameUrl.searchParams.get("fileName") === "resource-office.docx", "Office preview URL 应携带资源文件名");
assert(officeFrameUrl.searchParams.get("fileType") === "docx", "Office preview URL 应携带 docx 类型");
assert(officeFrameUrl.searchParams.get("fileUrl")?.includes("/api/local-folder/files/open"), "Office preview URL 应通过 local-folder file open 回源");
const [officePopup] = await Promise.all([
page.waitForEvent("popup", { timeout: UI_TIMEOUT_MS }),
@@ -271,6 +292,31 @@ async function main() {
if (markdownActiveState.resourceRows.includes(asset.id)) {
assert.equal(markdownActiveState.activeRowAssetId, asset.id, `已渲染对应 filetree row 时应标记 data-active: ${JSON.stringify(markdownActiveState)}`);
}
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator('.mnote-main-tab.is-active[data-mnote-tab-kind="markdown"]').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.locator('.mnote-resource-tab-panel:not([hidden]) .editor-surface .ProseMirror[contenteditable="true"]').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
const restoredMarkdownState = await page.evaluate(() => {
const snapshot = window.__mnoteOpenEditorsSnapshot || null;
const active = snapshot?.groups?.primary?.resourceEditors?.find((entry) => entry.active && entry.kind === "markdown");
return {
activeResource: new URL(window.location.href).searchParams.get("resourceTab") || "",
activeObjectIdentity: active?.objectIdentity || "",
path: active?.path || "",
workspacePath: active?.workspacePath || null,
editorText: document.querySelector('.mnote-resource-tab-panel:not([hidden]) .editor-surface .ProseMirror')?.textContent || "",
};
});
assert(restoredMarkdownState.activeResource.includes("resource-note.md"), `刷新后应保留 canonical resourceTab URL 状态: ${JSON.stringify(restoredMarkdownState)}`);
assert(restoredMarkdownState.activeObjectIdentity.includes("resource-note.md"), `刷新后应从 OpenEditorsSnapshot 恢复 active markdown resource: ${JSON.stringify(restoredMarkdownState)}`);
assert.equal(restoredMarkdownState.path, "README/resource-note.md", `刷新后 active resource snapshot 应保留 path: ${JSON.stringify(restoredMarkdownState)}`);
assert.equal(restoredMarkdownState.workspacePath?.relativePath, "README/resource-note.md", `刷新后 active resource 应带 workspacePath.relativePath: ${JSON.stringify(restoredMarkdownState)}`);
assert(restoredMarkdownState.editorText.includes("新增资源正文"), `刷新后 resource editor 应读回资源正文: ${JSON.stringify(restoredMarkdownState)}`);
const mdCloseBtn = page.locator('.mnote-main-tab.is-active[data-mnote-tab-kind="markdown"] .mnote-main-tab-close');
await mdCloseBtn.click({ timeout: UI_TIMEOUT_MS });
@@ -59,6 +59,7 @@ async function uploadLocalAsset(page, root, documentId, fileName, mimeType, byte
const form = new FormData();
form.append("rootUri", rootUri);
form.append("documentId", documentId);
form.append("uploadIntent", "editor.markdown.attach");
form.append("kind", kind);
form.append("file", new File([new Uint8Array(bytes)], fileName, { type: mimeType }));
const response = await fetch("/api/local-folder/assets/upload", { method: "POST", body: form });
@@ -173,7 +174,7 @@ async function main() {
await page.waitForTimeout(100);
// 尝试关闭 dirty 的 resource tab — 应被阻止
const closeBtn = page.locator('.mnote-main-tab.is-active .mnote-main-tab-close');
const closeBtn = page.locator('.mnote-main-tab.is-active[data-pane-role="primary"] .mnote-main-tab-close:not([data-mnote-pane-close])');
await closeBtn.click({ timeout: UI_TIMEOUT_MS });
await page.waitForTimeout(300);
@@ -84,6 +84,20 @@ async function waitForFileExists(filePath, timeoutMs) {
assert(fs.existsSync(filePath), `等待文件出现失败: ${filePath}`);
}
async function expandFileTreeFolder(page, title) {
const folderRow = page.locator('#sidebar-file-tree-root .tree-row[data-row-kind="folder"]', { hasText: title }).first();
await folderRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const expanded = await folderRow.getAttribute("aria-expanded");
if (expanded !== "true") {
const toggle = folderRow.locator('[data-rust-action="toggle"]').first();
if (await toggle.count()) {
await toggle.click({ timeout: UI_TIMEOUT_MS });
} else {
await folderRow.click({ timeout: UI_TIMEOUT_MS });
}
}
}
function suggestedFilenames(downloads, startIndex) {
return downloads.slice(startIndex).map((download) => download.suggestedFilename()).sort();
}
@@ -145,6 +159,7 @@ async function main() {
}).catch(() => undefined);
const editorReady = await editorSurface.isVisible().catch(() => false);
await expandFileTreeFolder(page, "README");
const assetRow = page.locator('[data-testid="filetree-asset-row"]', { hasText: "report-a.pdf" }).first();
await assetRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await assetRow.click({ button: "right", timeout: UI_TIMEOUT_MS });
@@ -521,7 +521,16 @@ async function main() {
assert(fs.existsSync(assetPath), "测试前置条件:附件文件应存在");
fs.unlinkSync(assetPath);
await page.waitForFunction(
() => {
async () => {
const now = Date.now();
if (
typeof window.__mnoteRefreshEditorLocalAttachmentExistence === "function"
&& (!window.__mnoteTask479LastAttachmentRefreshAt || now - window.__mnoteTask479LastAttachmentRefreshAt > 500)
) {
window.__mnoteTask479LastAttachmentRefreshAt = now;
window.__mnoteRefreshEditorLocalAttachmentExistence();
await new Promise((resolve) => setTimeout(resolve, 250));
}
const link = Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]'))
.find((node) => (node.textContent || "").includes("resource-note.md"));
return link instanceof HTMLAnchorElement && link.getAttribute("data-mnote-attachment-missing") === "true";
@@ -560,12 +569,16 @@ async function main() {
// 预期行为:链接应保留
const linkPreserved = linkTextAfter.includes("resource-note.md");
const popupCountBefore = popups.length;
const brokenLink = page
.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a', {
hasText: "resource-note.md",
})
.first();
await brokenLink.click({ timeout: UI_TIMEOUT_MS });
await page.evaluate(() => {
const brokenLink = Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a'))
.find((node) => node instanceof HTMLAnchorElement && (node.textContent || "").includes("resource-note.md"));
if (!(brokenLink instanceof HTMLAnchorElement)) throw new Error("broken_link_missing");
brokenLink.dispatchEvent(new MouseEvent("click", {
bubbles: true,
cancelable: true,
view: window,
}));
});
await page.waitForTimeout(1500);
const popupCountAfter = popups.length;
const resourceTabErrorVisible = await page
@@ -602,10 +615,36 @@ async function main() {
}
} catch (err) {
overallOk = false;
const debugState = await page.evaluate(async () => {
const collectLinks = () => Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a'))
.map((node) => ({
text: node.textContent || "",
href: node instanceof HTMLAnchorElement ? node.getAttribute("href") || "" : "",
missing: node instanceof HTMLElement ? node.getAttribute("data-mnote-attachment-missing") : "",
className: node instanceof HTMLElement ? node.getAttribute("class") || "" : "",
}));
const beforeManualRefresh = collectLinks();
if (typeof window.__mnoteRefreshEditorLocalAttachmentExistence === "function") {
window.__mnoteRefreshEditorLocalAttachmentExistence();
await new Promise((resolve) => setTimeout(resolve, 1500));
}
const pane = document.querySelector('.document-pane[data-pane-role="primary"]');
const editor = pane?.querySelector('.editor-surface .ProseMirror');
return {
url: window.location.href,
hasManualRefresh: typeof window.__mnoteRefreshEditorLocalAttachmentExistence,
status: pane?.querySelector('[data-runtime-editor-status]')?.getAttribute("data-runtime-editor-status") || "",
conflictVisible: Boolean(pane?.querySelector('[data-testid="mnote-editor-conflict-panel"]')),
editorText: editor instanceof HTMLElement ? (editor.textContent || "").slice(0, 500) : "",
beforeManualRefresh,
afterManualRefresh: collectLinks(),
};
}).catch((debugErr) => ({ debugError: debugErr.message }));
checks[checkId] = {
ok: false,
message: `删除真实附件后正文链接保留检查失败: ${err.message}`,
error: err.stack,
debugState,
};
}
}
@@ -87,8 +87,6 @@ async function main() {
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
MNOTE_CONTROL_PLANE_DB_PATH: dbPath,
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
CONVEX_SELF_HOSTED_URL: "http://127.0.0.1:9",
NEXT_PUBLIC_CONVEX_URL: "http://127.0.0.1:9",
},
stdio: ["ignore", "pipe", "pipe"],
});
@@ -122,8 +122,6 @@ async function main() {
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
MNOTE_LOCAL_ACCESS_POLICY_FILE: policyFile,
CONVEX_SELF_HOSTED_URL: "http://127.0.0.1:9",
NEXT_PUBLIC_CONVEX_URL: "http://127.0.0.1:9",
},
stdio: ["ignore", "pipe", "pipe"],
});
@@ -173,6 +171,9 @@ async function main() {
showHeadingNumbers: true,
wideLayout: true,
pageFont: "song",
"pageWidth.markdown": { mode: "wide", custom: null },
"pageWidth.word": { mode: "wide", custom: null },
"pageWidth.excel": { mode: "full", custom: null },
},
},
});
@@ -187,10 +188,35 @@ async function main() {
assert.equal(sameUserEffective.pageOptions.showHeadingNumbers, true, "同用户第二浏览器应读到标题编号偏好");
assert.equal(sameUserEffective.pageOptions.wideLayout, true, "同用户第二浏览器应读到页面宽度偏好");
assert.equal(sameUserEffective.pageOptions.pageFont, "song", "同用户第二浏览器应读到页面字体偏好");
assert.equal(sameUserEffective.pageWidthPreferences.markdown.mode, "wide", "同用户第二浏览器应读到 Markdown 宽度偏好");
assert.equal(sameUserEffective.pageWidthPreferences.markdown.source, "global", "页面宽度偏好应存为全局 scope");
assert.equal(sameUserEffective.pageWidthPreferences.excel.mode, "full", "同用户第二浏览器应读到 Excel 宽度偏好");
const documentPage = await secondContext.newPage();
const documentUrl = new URL(`/documents/${encodeURIComponent(documentId)}`, baseUrl);
documentUrl.searchParams.set("sourceKind", "local_folder");
documentUrl.searchParams.set("rootUri", rootUri);
await documentPage.goto(documentUrl.toString(), { waitUntil: "domcontentloaded", timeout: TIMEOUT_MS });
await documentPage.waitForSelector('.document-shell[data-page-width-resolved-mode="wide"]', { timeout: TIMEOUT_MS });
const shellWidthState = await documentPage.evaluate(() => {
const shell = document.querySelector(".document-shell");
if (!(shell instanceof HTMLElement)) return null;
return {
mode: shell.getAttribute("data-page-width-resolved-mode"),
source: shell.getAttribute("data-page-width-source"),
maxWidth: getComputedStyle(shell).maxWidth,
};
});
assert(shellWidthState, "文档页应渲染 document-shell");
assert.equal(shellWidthState.mode, "wide", "Markdown 文档页应消费全局 Markdown 宽度偏好");
assert.equal(shellWidthState.source, "global", "Markdown 文档页宽度来源应为 global");
assert.equal(shellWidthState.maxWidth, "1180px", "Markdown 文档页宽版 max-width 应为 1180px");
await documentPage.close();
const otherUserEffective = await effectivePreferences(otherContext.request, baseUrl, documentId, rootUri);
assert.equal(otherUserEffective.pageOptions.hideTitleHeader, true, "不同用户不应串读 source family 偏好");
assert.equal(otherUserEffective.pageOptions.showHeadingNumbers, false, "不同用户不应串读 global 偏好");
assert.equal(otherUserEffective.pageWidthPreferences.markdown.mode, "readable", "不同用户不应串读 Markdown 宽度偏好");
console.log("task493 page settings sqlite preferences smoke passed");
} finally {
@@ -92,8 +92,6 @@ async function main() {
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
MNOTE_CONTROL_PLANE_DB_PATH: dbPath,
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
CONVEX_SELF_HOSTED_URL: "http://127.0.0.1:9",
NEXT_PUBLIC_CONVEX_URL: "http://127.0.0.1:9",
},
stdio: ["ignore", "pipe", "pipe"],
});
@@ -9,6 +9,14 @@ const RUNTIME_PATH = path.join(
REPO_ROOT,
"rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js",
);
const FILETREE_RUNTIME_PATH = path.join(
REPO_ROOT,
"rust/crates/mnote-web/browser/filetree-runtime.js",
);
const FILETREE_CONTEXT_MENU_RUNTIME_PATH = path.join(
REPO_ROOT,
"rust/crates/mnote-web/browser/filetree-context-menu-runtime.js",
);
function loadRuntimeFactory() {
const source = fs
@@ -64,6 +72,9 @@ function loadRuntimeFactory() {
};
context.globalThis = context;
vm.createContext(context);
vm.runInContext(fs.readFileSync(FILETREE_CONTEXT_MENU_RUNTIME_PATH, "utf8"), context, {
filename: FILETREE_CONTEXT_MENU_RUNTIME_PATH,
});
vm.runInContext(
`${source}\nglobalThis.__factory = createSidebarFileTreeCommandRuntime;`,
context,
@@ -72,6 +83,23 @@ function loadRuntimeFactory() {
return context;
}
function loadFileTreeRuntime() {
const context = {
console,
HTMLElement: class HTMLElement {},
document: {},
window: {},
URL,
};
context.globalThis = context;
context.window = context;
vm.createContext(context);
vm.runInContext(fs.readFileSync(FILETREE_RUNTIME_PATH, "utf8"), context, {
filename: FILETREE_RUNTIME_PATH,
});
return context;
}
async function runCopyId(runtime, context, detail) {
context.__clipboardText = "";
runtime.handleTreeContextMenuAction("copy-id", detail, null);
@@ -80,10 +108,151 @@ async function runCopyId(runtime, context, detail) {
}
async function main() {
const fileTreeContext = loadFileTreeRuntime();
class FakeRow extends fileTreeContext.HTMLElement {
constructor(attrs, textContent = "") {
super();
this.attrs = attrs;
this.textContent = textContent;
}
getAttribute(name) {
return this.attrs[name] || "";
}
querySelector() {
return null;
}
}
const readWorkspacePath = (attrs, textContent = "") =>
fileTreeContext.window.__mnoteFileTreeRuntime.readWorkspacePathFromRow(
new FakeRow(attrs, textContent),
{
currentSourceKind: () => attrs.__sourceKind || "local_folder",
currentRootUri: () => attrs.__rootUri || "file:///mnt/Data1T/mnote",
resolveWorkspaceId: () => "local-folder",
rowTitle: (row) => row.textContent,
},
);
const workspacePath = readWorkspacePath({
"data-row-id": "local:asset:assets/report.pdf",
"data-row-kind": "asset",
"data-document-id": "local-md:README.md",
"data-asset-id": "local-file:assets/report.pdf",
"data-local-relative-path": "assets/report.pdf",
"data-object-identity": JSON.stringify({
objectKind: "attachment",
documentId: "local-md:README.md",
assetId: "local-file:assets/report.pdf",
}),
}, "report.pdf");
assert.equal(workspacePath.schema, "mnote.workspace_path.v1");
assert.equal(workspacePath.workspaceId, "local-folder");
assert.equal(workspacePath.sourceKind, "local_folder");
assert.equal(workspacePath.rootUri, "file:///mnt/Data1T/mnote");
assert.equal(workspacePath.relativePath, "assets/report.pdf");
assert.equal(workspacePath.documentId, "local-md:README.md");
assert.equal(workspacePath.assetId, "local-file:assets/report.pdf");
assert.equal(workspacePath.objectKind, "attachment");
assert.equal(workspacePath.resourceKind, "attachment");
assert.equal(workspacePath.objectIdentity.assetId, "local-file:assets/report.pdf");
const markdownWorkspacePath = readWorkspacePath({
"data-row-id": "local:markdown:docs/Page.md",
"data-row-kind": "markdown",
"data-document-id": "local-md:docs~2FPage.md",
"data-local-relative-path": "docs/Page.md",
"data-object-identity": JSON.stringify({
objectKind: "page",
documentId: "local-md:docs~2FPage.md",
assetId: null,
}),
}, "Page.md");
assert.equal(markdownWorkspacePath.relativePath, "docs/Page.md");
assert.equal(markdownWorkspacePath.objectKind, "page");
assert.equal(markdownWorkspacePath.isLocalFolder, true);
const folderWorkspacePath = readWorkspacePath({
"data-row-id": "local:folder:docs",
"data-row-kind": "folder",
"data-document-id": "local-dir:docs",
"data-local-relative-path": "docs",
"data-object-identity": JSON.stringify({
objectKind: "index",
documentId: "local-dir:docs",
assetId: null,
}),
}, "docs");
assert.equal(folderWorkspacePath.relativePath, "docs");
assert.equal(folderWorkspacePath.objectKind, "index");
const mindmapWorkspacePath = readWorkspacePath({
"data-row-id": "local:asset:Page/思维导图.json",
"data-row-kind": "asset",
"data-asset-id": "local-file:Page/思维导图.json",
"data-local-relative-path": "Page/思维导图.json",
"data-object-identity": JSON.stringify({
objectKind: "mindmap",
documentId: "local-md:Page~2FPage.md",
assetId: "local-file:Page/思维导图.json",
}),
}, "思维导图.json");
assert.equal(mindmapWorkspacePath.objectKind, "mindmap");
assert.equal(mindmapWorkspacePath.objectIdentity.assetId, "local-file:Page/思维导图.json");
const officeWorkspacePath = readWorkspacePath({
"data-row-id": "local:asset:Page/report.docx",
"data-row-kind": "asset",
"data-asset-id": "local-file:Page/report.docx",
"data-local-relative-path": "Page/report.docx",
"data-object-identity": JSON.stringify({
objectKind: "only_office",
documentId: "local-md:Page~2FPage.md",
assetId: "local-file:Page/report.docx",
}),
}, "report.docx");
assert.equal(officeWorkspacePath.objectKind, "only_office");
assert.equal(officeWorkspacePath.objectIdentity.assetId, "local-file:Page/report.docx");
const nonLocalWorkspacePath = readWorkspacePath({
__sourceKind: "cloud",
__rootUri: "",
"data-row-id": "doc:cloud-page",
"data-row-kind": "document",
"data-document-id": "local-cloud-compatible-id",
"data-object-identity": JSON.stringify({
objectKind: "page",
documentId: "local-cloud-compatible-id",
assetId: null,
}),
}, "cloud");
assert.equal(nonLocalWorkspacePath.isLocalFolder, false, "sourceKind 非 local_folder 时不应仅因 local-* documentId 误判成本地文件夹");
const context = loadRuntimeFactory();
class FakeCommandBadge extends context.HTMLElement {
constructor(kind) {
super();
this.kind = kind;
}
getAttribute(name) {
return name === "data-kind" ? this.kind : "";
}
}
class FakeCommandRow extends context.HTMLElement {
constructor(attrs, textContent = "", badgeKind = "") {
super();
this.attrs = attrs;
this.textContent = textContent;
this.badge = badgeKind ? new FakeCommandBadge(badgeKind) : null;
}
getAttribute(name) {
return this.attrs[name] || "";
}
querySelector(selector) {
return selector === ".tree-kind-badge" ? this.badge : null;
}
}
const runtime = context.__factory({
currentRootUri: () => "file:///mnt/Data1T/mnote",
currentSourceKind: () => "local_folder",
currentRootUri: () => "file:///mnt/Data1T/mnote",
cssEscape: (value) => String(value).replace(/"/g, '\\"'),
fileTreeRuntimeFunction: () => null,
localFilePathFromAssetId: (assetId) =>
@@ -92,6 +261,139 @@ async function main() {
runtimeState: { activeTreeContextMenu: null },
selectedSidebarFileTreeSelection: { selectedRowIds: new Set(), focusedRowId: null },
});
assert.equal(
runtime.classifySidebarFileTreeAsset(new FakeCommandRow({}, "legacy.luckysheet", "luckysheet")),
"table",
"legacy luckysheet 行仍应归为 table 资源",
);
const dirtyCommandRow = new FakeCommandRow({
"data-row-id": "local:markdown:docs/Page.md",
"data-row-kind": "markdown",
"data-document-id": "local-md:docs~2FPage.md",
"data-local-relative-path": "docs/Page.md",
}, "Page.md");
const commandContextRuntime = context.window.__mnoteFileTreeContextMenuRuntime;
const dirtyContext = commandContextRuntime.buildFileTreeCommandContext(
dirtyCommandRow,
{
schema: "mnote.open_editors_snapshot.v1",
editors: [
{
documentId: "local-md:docs~2FPage.md",
dirtyState: "dirty",
workspacePath: { relativePath: "docs/Page.md" },
},
],
},
null,
{
currentSourceKind: () => "local_folder",
workspaceReadonly: () => false,
selection: { selectedRowIds: new Set(["local:markdown:docs/Page.md"]) },
queryRowById: () => dirtyCommandRow,
},
);
assert.equal(dirtyContext["workspace.sourceKind"], "local_folder");
assert.equal(dirtyContext["tree.selectionCount"], 1);
assert.equal(dirtyContext["tree.selectionResourceKind"], "page");
assert.equal(dirtyContext["tree.targetResourceKind"], "page");
assert.equal(dirtyContext["editor.dirty"], true);
assert.equal(dirtyContext["editor.dirtyState"], "dirty");
assert.equal(dirtyContext["ai.canWrite"], false);
assert.equal(
commandContextRuntime.evaluateSidebarFileTreeWhen(dirtyContext, "!workspace.readonly && !editor.dirty"),
false,
"dirty 目标下菜单与快捷键写操作应被统一 command context 禁用",
);
const readonlyContext = commandContextRuntime.buildFileTreeCommandContext(
dirtyCommandRow,
{ editors: [] },
null,
{
currentSourceKind: () => "local_folder",
workspaceReadonly: () => true,
selection: { selectedRowIds: new Set(["local:markdown:docs/Page.md"]) },
queryRowById: () => dirtyCommandRow,
},
);
assert.equal(readonlyContext["workspace.readonly"], true);
assert.equal(
commandContextRuntime.evaluateSidebarFileTreeWhen(readonlyContext, "!workspace.readonly"),
false,
"readonly 目标下菜单与快捷键写操作应被统一 command context 禁用",
);
const cleanTargetRow = new FakeCommandRow({
"data-row-id": "local:markdown:docs/Clean.md",
"data-row-kind": "markdown",
"data-document-id": "local-md:docs~2FClean.md",
"data-local-relative-path": "docs/Clean.md",
}, "Clean.md");
const selectedRows = new Map([
["local:markdown:docs/Clean.md", cleanTargetRow],
["local:markdown:docs/Page.md", dirtyCommandRow],
]);
const multiSelectDirtyContext = commandContextRuntime.buildFileTreeCommandContext(
cleanTargetRow,
{
editors: [
{
documentId: "local-md:docs~2FPage.md",
dirtyState: "Dirty",
workspacePath: { relativePath: "docs/Page.md" },
},
],
},
null,
{
currentSourceKind: () => "local_folder",
workspaceReadonly: () => false,
selection: { selectedRowIds: new Set(["local:markdown:docs/Clean.md", "local:markdown:docs/Page.md"]) },
queryRowById: (rowId) => selectedRows.get(rowId),
},
);
assert.equal(multiSelectDirtyContext["tree.selectionCount"], 2);
assert.equal(multiSelectDirtyContext["tree.selectionResourceKind"], "page");
assert.equal(multiSelectDirtyContext["editor.dirty"], true);
assert.equal(multiSelectDirtyContext["editor.dirtyState"], "dirty");
assert.equal(
commandContextRuntime.evaluateSidebarFileTreeWhen(multiSelectDirtyContext, "!workspace.readonly && !editor.dirty"),
false,
"多选中任一非 target 行 dirty 时,菜单与快捷键写操作也必须禁用",
);
const dirtyAssetRow = new FakeCommandRow({
"data-row-id": "local:asset:assets/report.pdf",
"data-row-kind": "asset",
"data-asset-id": "local-file:assets/report.pdf",
"data-local-relative-path": "assets/report.pdf",
}, "report.pdf");
const dirtyAssetContext = commandContextRuntime.buildFileTreeCommandContext(
dirtyAssetRow,
{
resourceEditors: [
{
assetId: "local-file:assets/report.pdf",
dirtyState: "ExternalModified",
workspacePath: { relativePath: "assets/report.pdf", assetId: "local-file:assets/report.pdf" },
},
],
},
null,
{
currentSourceKind: () => "local_folder",
workspaceReadonly: () => false,
selection: { selectedRowIds: new Set(["local:asset:assets/report.pdf"]) },
queryRowById: () => dirtyAssetRow,
},
);
assert.equal(dirtyAssetContext["tree.selectionResourceKind"], "asset");
assert.equal(dirtyAssetContext["tree.targetIsAsset"], true);
assert.equal(dirtyAssetContext["editor.dirty"], true);
assert.equal(dirtyAssetContext["editor.dirtyState"], "external_modified");
assert.equal(
commandContextRuntime.evaluateSidebarFileTreeWhen(dirtyAssetContext, "!workspace.readonly && !editor.dirty"),
false,
"dirty resource tab 对应 asset 的 rename/delete 也必须禁用",
);
assert.equal(
await runCopyId(runtime, context, {
@@ -99,6 +401,7 @@ async function main() {
rowKind: "folder",
documentId: "local-dir:docs",
rowId: "local:folder:docs",
workspacePath: folderWorkspacePath,
title: "docs",
}),
"/mnt/Data1T/mnote/docs",
@@ -111,6 +414,7 @@ async function main() {
rowKind: "markdown",
documentId: "local-md:docs~2FPage.md",
rowId: "local:markdown:docs~2FPage.md",
workspacePath: markdownWorkspacePath,
title: "Page",
}),
"/mnt/Data1T/mnote/docs/Page.md",
@@ -121,8 +425,9 @@ async function main() {
await runCopyId(runtime, context, {
contextKind: "filetree",
rowKind: "asset",
assetId: "local-file:assets~2Freport.pdf",
rowId: "local:asset:assets~2Freport.pdf",
assetId: "local-file:assets/report.pdf",
rowId: "local:asset:assets/report.pdf",
workspacePath,
title: "report.pdf",
}),
"/mnt/Data1T/mnote/assets/report.pdf",
@@ -87,8 +87,6 @@ async function main() {
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
MNOTE_CONTROL_PLANE_DB_PATH: dbPath,
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
CONVEX_SELF_HOSTED_URL: "http://127.0.0.1:9",
NEXT_PUBLIC_CONVEX_URL: "http://127.0.0.1:9",
},
stdio: ["ignore", "pipe", "pipe"],
});
@@ -163,8 +163,6 @@ async function main() {
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
MNOTE_CONTROL_PLANE_DB_PATH: dbPath,
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
CONVEX_SELF_HOSTED_URL: "http://127.0.0.1:9",
NEXT_PUBLIC_CONVEX_URL: "http://127.0.0.1:9",
},
stdio: ["ignore", "pipe", "pipe"],
});
@@ -166,8 +166,6 @@ async function main() {
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
MNOTE_CONTROL_PLANE_DB_PATH: dbPath,
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
CONVEX_SELF_HOSTED_URL: "http://127.0.0.1:9",
NEXT_PUBLIC_CONVEX_URL: "http://127.0.0.1:9",
},
stdio: ["ignore", "pipe", "pipe"],
});
@@ -190,8 +190,6 @@ async function main() {
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
MNOTE_CONTROL_PLANE_DB_PATH: dbPath,
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
CONVEX_SELF_HOSTED_URL: "http://127.0.0.1:9",
NEXT_PUBLIC_CONVEX_URL: "http://127.0.0.1:9",
},
stdio: ["ignore", "pipe", "pipe"],
});
@@ -0,0 +1,274 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const http = require("node:http");
const net = require("node:net");
const os = require("node:os");
const path = require("node:path");
const { spawn } = require("node:child_process");
const { chromium } = require("playwright");
const TASK = "task500-navigation-page-route-guard-smoke";
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 45_000);
const TEST_PASSWORD = process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
function resolveChromiumExecutablePath() {
const explicit = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || "";
if (explicit && fs.existsSync(explicit)) return explicit;
return [
"/usr/bin/google-chrome-stable",
"/usr/bin/google-chrome",
"/snap/bin/chromium",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
].find((candidate) => fs.existsSync(candidate)) || "";
}
function pickPort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port = address && typeof address === "object" ? address.port : 0;
server.close(() => resolve(port));
});
server.on("error", reject);
});
}
function waitForHttpOk(url, timeoutMs) {
const deadline = Date.now() + timeoutMs;
return new Promise((resolve, reject) => {
const tick = () => {
const request = http.get(url, (response) => {
response.resume();
if (response.statusCode >= 200 && response.statusCode < 500) {
resolve();
return;
}
retry();
});
request.on("error", retry);
request.setTimeout(1000, () => {
request.destroy();
retry();
});
};
const retry = () => {
if (Date.now() > deadline) {
reject(new Error(`server_not_ready: ${url}`));
return;
}
setTimeout(tick, 250);
};
tick();
});
}
function fileUrlToPath(value) {
const url = new URL(value);
return decodeURIComponent(url.pathname);
}
async function signUp(context, baseUrl, actorId) {
const authResponse = await context.request.fetch(`${baseUrl}/api/auth`, {
method: "POST",
data: {
action: "auth:signIn",
args: {
provider: "password",
params: {
email: `${actorId}@example.com`,
username: actorId,
name: actorId,
password: TEST_PASSWORD,
flow: "signUp",
},
},
},
});
assert(authResponse.ok(), `测试账号注册失败: ${authResponse.status()} ${await authResponse.text()}`);
}
async function openDefaultLocalWorkspace(page, baseUrl) {
await page.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const createButton = page.locator('[data-testid="mnote-create-default-local-workspace"]').first();
if (await createButton.isVisible({ timeout: 3000 }).catch(() => false)) {
await createButton.click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => url.searchParams.get("sourceKind") === "local_folder", {
timeout: UI_TIMEOUT_MS,
});
}
const current = new URL(page.url());
const rootUri = current.searchParams.get("rootUri")
|| await page.evaluate(() => document.body.getAttribute("data-mnote-root-uri") || "");
assert(rootUri, "应进入 local_folder workspace");
return {
rootUri,
workspaceId: current.searchParams.get("workspaceId") || "",
};
}
async function assertNavigationPage(page) {
await page.locator('[data-testid="mnote-navigation-page"]').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
assert.equal(await page.locator("#__MNOTE_PAGE_AGGREGATE__").count(), 0, "导航页不应注入 Page Aggregate");
assert.equal(await page.locator("#__MNOTE_EDITOR_BOOTSTRAP__").count(), 0, "导航页不应注入 editor bootstrap");
assert.equal(await page.locator(".mnote-leptos-tiptap-spike-island").count(), 0, "导航页不应启动 tiptap island");
}
async function main() {
const port = await pickPort();
const baseUrl = `http://127.0.0.1:${port}`;
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), `${TASK}-`));
const dbPath = path.join(dataRoot, "control-plane.sqlite3");
const actorId = `${TASK}-${process.pid}-${Date.now()}`;
const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], {
cwd: path.join(__dirname, "..", "rust"),
env: {
...process.env,
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
MNOTE_CONTROL_PLANE_DB_PATH: dbPath,
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
},
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
server.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
const executablePath = resolveChromiumExecutablePath();
const browser = await chromium.launch({
headless: true,
...(executablePath ? { executablePath } : {}),
});
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
const diagnostics = [];
page.on("console", (message) => diagnostics.push(`console:${message.type()}:${message.text()}`));
page.on("pageerror", (error) => diagnostics.push(`pageerror:${error.message}`));
try {
await waitForHttpOk(`${baseUrl}/health`, 60_000);
const unauthContext = await browser.newContext();
const unauthPage = await unauthContext.newPage();
await unauthPage.goto(`${baseUrl}/documents/local-md%3Adocs%7E2FPlan.md?sourceKind=local_folder&rootUri=file:///tmp/missing`, {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
assert.equal(new URL(unauthPage.url()).pathname, "/auth", "未登录文档访问应跳转 auth");
assert(new URL(unauthPage.url()).searchParams.get("next") || "", "auth 跳转应保留 next");
await unauthContext.close();
await signUp(context, baseUrl, actorId);
const { rootUri, workspaceId } = await openDefaultLocalWorkspace(page, baseUrl);
const rootPath = fileUrlToPath(rootUri);
fs.mkdirSync(path.join(rootPath, "docs"), { recursive: true });
fs.mkdirSync(path.join(rootPath, "other"), { recursive: true });
fs.writeFileSync(path.join(rootPath, "Home.md"), "# Home\n", "utf8");
fs.writeFileSync(path.join(rootPath, "docs", "Plan.md"), "# Plan\n正文\n", "utf8");
fs.writeFileSync(path.join(rootPath, "other", "Other.md"), "# Other\n", "utf8");
fs.writeFileSync(path.join(rootPath, "Report.pdf"), "%PDF-1.4\n", "utf8");
const rootUrl = new URL(baseUrl);
if (workspaceId) rootUrl.searchParams.set("workspaceId", workspaceId);
rootUrl.searchParams.set("sourceKind", "local_folder");
rootUrl.searchParams.set("rootUri", rootUri);
rootUrl.searchParams.set("treeView", "filetree");
await page.goto(rootUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await assertNavigationPage(page);
assert.equal(await page.locator('[data-root-active-page-id="local-md:Home.md"]').count(), 0, "打开文件夹 root 不应自动打开 Home.md");
await page.waitForFunction(() => Boolean(window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab), null, {
timeout: UI_TIMEOUT_MS,
});
const popupPromise = page.waitForEvent("popup", { timeout: 3000 }).then((popup) => popup.url()).catch(() => null);
const reportRow = page.locator('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="Report.pdf"]').first();
await reportRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await reportRow.locator('[data-rust-action="open"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-mnote-tab-kind="pdf"] .mnote-main-tab-title', { hasText: "Report.pdf" }).waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.locator('[data-mnote-resource-tab-panel][data-resource-kind="pdf"] iframe.mnote-resource-tab-frame').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
assert.equal(await popupPromise, null, "导航页打开 PDF 应进入 mnoteTab,不应新开浏览器 Tab");
assert.equal(new URL(page.url()).pathname, "/", "导航页打开 PDF 不应离开主页");
await page.locator('[data-mnote-main-tab="page"][data-pane-role="primary"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-navigation-page"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const docsRow = page.locator('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="docs"]').first();
await docsRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const rootNavigationUrl = page.url();
await docsRow.locator('[data-rust-action="open"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(() => {
const row = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="docs"]');
return row && row.getAttribute("aria-expanded") === "true";
}, null, { timeout: UI_TIMEOUT_MS });
assert.equal(new URL(page.url()).searchParams.get("fileTreeScope"), null, "文件树文件夹点击只应展开,不应进入 scoped 导航页");
assert.equal(page.url(), rootNavigationUrl, "文件树文件夹展开不应改 URL");
await page.locator('[data-testid="mnote-navigation-folders"] [data-navigation-item-kind="folder"][data-local-relative-path="docs"]').first().click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => url.searchParams.get("fileTreeScope") === "docs", {
timeout: UI_TIMEOUT_MS,
});
await assertNavigationPage(page);
assert(await page.locator('[data-testid="mnote-navigation-pages"] >> text=Plan.md').isVisible({ timeout: UI_TIMEOUT_MS }), "scope 导航页应展示当前层 Markdown");
assert.equal(await page.locator('text=Other.md').count(), 0, "scope 导航页不应渲染 root 兄弟目录页面");
const planRow = page.locator('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-document-id="local-md:docs~2FPlan.md"]').first();
await planRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await planRow.locator('[data-rust-action="open"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => url.pathname.includes("/documents/local-md%3Adocs~2FPlan.md"), {
timeout: UI_TIMEOUT_MS,
});
await page.locator("#__MNOTE_PAGE_AGGREGATE__").waitFor({ state: "attached", timeout: UI_TIMEOUT_MS });
const scopedUrl = new URL(baseUrl);
if (workspaceId) scopedUrl.searchParams.set("workspaceId", workspaceId);
scopedUrl.searchParams.set("sourceKind", "local_folder");
scopedUrl.searchParams.set("rootUri", rootUri);
scopedUrl.searchParams.set("treeView", "filetree");
scopedUrl.searchParams.set("fileTreeScope", "docs");
await page.goto(scopedUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await assertNavigationPage(page);
assert(await page.locator('[data-testid="mnote-navigation-recent-folders"] >> text=docs').isVisible({ timeout: UI_TIMEOUT_MS }), "recent folders 应展示 docs");
assert(await page.locator('[data-testid="mnote-navigation-recent-pages"] >> text=Plan').isVisible({ timeout: UI_TIMEOUT_MS }), "recent pages 应展示 Plan");
const documentUrl = `${baseUrl}/documents/local-md%3Adocs%7E2FPlan.md?sourceKind=local_folder&rootUri=${encodeURIComponent(rootUri)}&treeView=filetree&fileTreeScope=docs`;
await page.goto(documentUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
fs.unlinkSync(path.join(rootPath, "docs", "Plan.md"));
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => url.pathname === "/" && url.searchParams.get("missingPage") === "local-md:docs~2FPlan.md", {
timeout: UI_TIMEOUT_MS,
});
await assertNavigationPage(page);
console.log(JSON.stringify({
ok: true,
task: TASK,
rootUri,
finalUrl: page.url(),
}, null, 2));
} catch (error) {
error.message += `\nserver stderr:\n${stderr.slice(-4000)}\nbrowser diagnostics:\n${diagnostics.slice(-20).join("\n")}`;
throw error;
} finally {
await browser.close().catch(() => {});
server.kill("SIGTERM");
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -0,0 +1,361 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const fsp = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const JSZip = require("jszip");
const XLSX = require("@e965/xlsx");
const { chromium } = require("playwright");
const ROOT = path.resolve(__dirname, "..");
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const OUT_DIR = path.join(ROOT, "tmp", "task501-office-preview-light-viewer-smoke");
const TEST_ACCOUNT = "mnote-e2e";
const TEST_EMAIL = "mnote.e2e@example.com";
const TEST_PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
const WORKSPACE_ID = "local-ws:mnote-e2e:office-preview-smoke";
const DOCUMENT_ID = "local-md:office-preview-smoke.md";
function resolveChromiumExecutablePath() {
const explicit = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || "";
if (explicit && fs.existsSync(explicit)) return explicit;
return [
"/usr/bin/google-chrome-stable",
"/usr/bin/google-chrome",
"/snap/bin/chromium",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
].find((candidate) => fs.existsSync(candidate)) || "";
}
function officePreviewUrl(root, fileName, fileType) {
const fileUrl = new URL("/api/local-folder/files/open", BASE_URL);
fileUrl.searchParams.set("rootUri", `file://${root}`);
fileUrl.searchParams.set("path", fileName);
const url = new URL("/office-preview", BASE_URL);
url.searchParams.set("fileUrl", `${fileUrl.pathname}${fileUrl.search}`);
url.searchParams.set("fileName", fileName);
url.searchParams.set("fileType", fileType);
url.searchParams.set("workspaceId", WORKSPACE_ID);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", `file://${root}`);
url.searchParams.set("documentId", DOCUMENT_ID);
return url.toString();
}
async function writeDocx(filePath) {
const zip = new JSZip();
zip.file("[Content_Types].xml", `<?xml version="1.0" encoding="UTF-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
</Types>`);
zip.folder("_rels").file(".rels", `<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>`);
zip.folder("word").file("document.xml", `<?xml version="1.0" encoding="UTF-8"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>
<w:p><w:r><w:t>MNote DOCX light preview works</w:t></w:r></w:p>
<w:p><w:r><w:t>第二行测试内容</w:t></w:r></w:p>
<w:sectPr/>
</w:body>
</w:document>`);
await fsp.writeFile(filePath, await zip.generateAsync({ type: "nodebuffer" }));
}
function writeXlsx(filePath) {
const workbook = XLSX.utils.book_new();
const sheet = XLSX.utils.aoa_to_sheet([
["Name", "Value"],
["MNote XLSX light preview works", "ok"],
["中文", "正常"],
]);
XLSX.utils.book_append_sheet(workbook, sheet, "Sheet1");
XLSX.writeFile(workbook, filePath);
}
async function writePptx(filePath) {
const zip = new JSZip();
zip.file("[Content_Types].xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>
<Override PartName="/ppt/slides/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>
<Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/>
<Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>
<Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>
</Types>`);
zip.folder("_rels").file(".rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/>
</Relationships>`);
zip.folder("ppt").file("presentation.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:presentation xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst>
<p:sldIdLst><p:sldId id="256" r:id="rId2"/></p:sldIdLst>
<p:sldSz cx="12192000" cy="6858000" type="wide"/>
<p:notesSz cx="6858000" cy="9144000"/>
<p:defaultTextStyle>
<a:defPPr><a:defRPr lang="zh-CN"/></a:defPPr>
<a:lvl1pPr marL="0" algn="l"><a:defRPr sz="1800" lang="zh-CN"/></a:lvl1pPr>
</p:defaultTextStyle>
</p:presentation>`);
zip.folder("ppt").folder("_rels").file("presentation.xml.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/>
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="theme/theme1.xml"/>
</Relationships>`);
zip.folder("ppt").folder("slideMasters").file("slideMaster1.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sldMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr></p:spTree></p:cSld>
<p:sldLayoutIdLst><p:sldLayoutId id="2147483649" r:id="rId1"/></p:sldLayoutIdLst>
</p:sldMaster>`);
zip.folder("ppt").folder("slideMasters").folder("_rels").file("slideMaster1.xml.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
</Relationships>`);
zip.folder("ppt").folder("slideLayouts").file("slideLayout1.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sldLayout xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" type="blank">
<p:cSld name="Blank"><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr></p:spTree></p:cSld>
</p:sldLayout>`);
zip.folder("ppt").folder("slideLayouts").folder("_rels").file("slideLayout1.xml.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="../slideMasters/slideMaster1.xml"/>
</Relationships>`);
zip.folder("ppt").folder("slides").file("slide1.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:spTree>
<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>
<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>
<p:sp>
<p:nvSpPr><p:cNvPr id="2" name="Title"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="914400" y="914400"/><a:ext cx="10363200" cy="1371600"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr>
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:rPr lang="zh-CN" sz="4400"/><a:t>MNote PPTX light preview works</a:t></a:r></a:p></p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
</p:sld>`);
zip.folder("ppt").folder("slides").folder("_rels").file("slide1.xml.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
</Relationships>`);
zip.folder("ppt").folder("theme").file("theme1.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="MNote">
<a:themeElements>
<a:clrScheme name="MNote"><a:dk1><a:srgbClr val="000000"/></a:dk1><a:lt1><a:srgbClr val="FFFFFF"/></a:lt1><a:dk2><a:srgbClr val="1F2937"/></a:dk2><a:lt2><a:srgbClr val="F8FAFC"/></a:lt2><a:accent1><a:srgbClr val="2563EB"/></a:accent1><a:accent2><a:srgbClr val="22A559"/></a:accent2><a:accent3><a:srgbClr val="E0525B"/></a:accent3><a:accent4><a:srgbClr val="F59E0B"/></a:accent4><a:accent5><a:srgbClr val="8B5CF6"/></a:accent5><a:accent6><a:srgbClr val="14B8A6"/></a:accent6><a:hlink><a:srgbClr val="2563EB"/></a:hlink><a:folHlink><a:srgbClr val="7C3AED"/></a:folHlink></a:clrScheme>
<a:fontScheme name="MNote"><a:majorFont><a:latin typeface="Arial"/></a:majorFont><a:minorFont><a:latin typeface="Arial"/></a:minorFont></a:fontScheme>
<a:fmtScheme name="MNote"><a:fillStyleLst/><a:lnStyleLst/><a:effectStyleLst/><a:bgFillStyleLst/></a:fmtScheme>
</a:themeElements>
</a:theme>`);
await fsp.writeFile(filePath, await zip.generateAsync({ type: "nodebuffer" }));
}
async function signIn(context) {
const payload = (params) => ({
action: "auth:signIn",
args: {
provider: "password",
params,
},
});
let response = await context.request.fetch(`${BASE_URL}/api/auth`, {
method: "POST",
data: payload({
account: TEST_ACCOUNT,
password: TEST_PASSWORD,
flow: "signIn",
}),
});
if (response.ok()) return;
response = await context.request.fetch(`${BASE_URL}/api/auth`, {
method: "POST",
data: payload({
email: TEST_EMAIL,
username: TEST_ACCOUNT,
name: TEST_ACCOUNT,
password: TEST_PASSWORD,
flow: "signUp",
}),
});
assert(response.ok(), `测试账号登录失败: ${response.status()} ${await response.text()}`);
}
async function setPageWidthPreferences(context, root, updates) {
const response = await context.request.fetch(`${BASE_URL}/api/ui/preferences`, {
method: "PUT",
data: {
documentId: DOCUMENT_ID,
workspaceId: WORKSPACE_ID,
sourceKind: "local_folder",
rootUri: `file://${root}`,
updates,
},
});
assert(response.ok(), `页面宽度偏好写入失败: ${response.status()} ${await response.text()}`);
}
async function verifyPreview(page, root, item) {
const consoleMessages = [];
page.on("console", (message) => {
consoleMessages.push(`${message.type()}: ${message.text()}`);
});
page.on("pageerror", (error) => {
consoleMessages.push(`pageerror: ${error && error.stack ? error.stack : error.message}`);
});
await page.goto(officePreviewUrl(root, item.fileName, item.fileType), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
await page.waitForFunction(
() => {
const status = document.documentElement.getAttribute("data-mnote-office-preview-status");
return status && status !== "加载中";
},
null,
{ timeout: UI_TIMEOUT_MS },
);
const status = await page.locator("html").getAttribute("data-mnote-office-preview-status");
const text = await page.locator("#mnote-office-viewer").innerText({ timeout: UI_TIMEOUT_MS });
const layout = await page.evaluate(() => {
const viewer = document.querySelector("#mnote-office-viewer");
const tableWrap = document.querySelector(".mnote-office-table-wrap");
const docxPage = document.querySelector(".docx-wrapper > section.docx, .mnote-docx-wrapper > section.mnote-docx");
const pptxStage = document.querySelector(".mnote-office-pptx-stage");
const pptxSlide = document.querySelector(".pptx-preview-slide-wrapper");
const rectOf = (node) => {
if (!(node instanceof Element)) return null;
const rect = node.getBoundingClientRect();
return {
left: rect.left,
width: rect.width,
};
};
return {
viewportWidth: window.innerWidth,
pageWidthContentType: document.documentElement.getAttribute("data-page-width-content-type"),
pageWidthMode: document.documentElement.getAttribute("data-page-width-resolved-mode"),
toolbarCount: document.querySelectorAll(".mnote-office-toolbar").length,
viewer: rectOf(viewer),
tableWrap: rectOf(tableWrap),
docxPage: rectOf(docxPage),
pptxStage: rectOf(pptxStage),
pptxSlide: rectOf(pptxSlide),
};
});
const screenshot = path.join(OUT_DIR, `${item.fileType}.png`);
await page.screenshot({ path: screenshot, fullPage: true });
assert.equal(status, "完成", `${item.fileType} 预览状态应完成`);
assert(text.includes(item.expected), `${item.fileType} 预览应包含样本文本: ${item.expected}`);
assert.equal(consoleMessages.length, 0, `${item.fileType} 预览不应产生控制台错误: ${consoleMessages.join("\n")}`);
assert.equal(layout.toolbarCount, 0, "轻量预览页不应显示顶部状态条");
if (item.fileType === "docx") {
assert.equal(layout.pageWidthContentType, "word", "DOCX 应使用 Word 宽度类型");
assert.equal(layout.pageWidthMode, item.expectedMode || "wide", "DOCX 应使用指定页面宽度");
assert(layout.viewer && layout.viewer.width >= Math.min(layout.viewportWidth, 1180) - 24, "DOCX 预览容器应接近宽版宽度");
assert(layout.viewer.left > 0 && layout.viewer.left < 80, "DOCX 宽版容器应居中而不是左侧小块");
assert(layout.docxPage && layout.docxPage.width >= layout.viewportWidth * 0.75, "DOCX 页面应放大到接近可视宽度");
} else if (item.fileType === "pptx") {
assert.equal(layout.pageWidthContentType, "ppt", "PPTX 应使用 PPT 宽度类型");
assert.equal(layout.pageWidthMode, item.expectedMode || "wide", "PPTX 应使用指定页面宽度");
if ((item.expectedMode || "wide") === "full") {
assert(layout.viewer && layout.viewer.width >= layout.viewportWidth - 2, "PPTX 全宽模式应占满页面宽度");
assert(layout.pptxStage && layout.pptxStage.width >= layout.viewportWidth - 40, "PPTX 舞台应随全宽容器放大");
} else {
assert(layout.viewer && layout.viewer.width >= Math.min(layout.viewportWidth, 1180) - 24, "PPTX 预览容器应接近宽版宽度");
}
} else {
assert.equal(layout.pageWidthContentType, "excel", `${item.fileType} 应使用 Excel 宽度类型`);
assert.equal(layout.pageWidthMode, item.expectedMode || "full", `${item.fileType} 应使用指定页面宽度`);
assert(layout.viewer && layout.viewer.left <= 1, `${item.fileType} 预览容器应从页面左侧开始`);
assert(layout.viewer.width >= layout.viewportWidth - 2, `${item.fileType} 预览容器应占满页面宽度`);
assert(layout.tableWrap && layout.tableWrap.width >= layout.viewportWidth - 40, `${item.fileType} 表格容器应接近全宽`);
}
return { fileType: item.fileType, status, text: text.slice(0, 300), layout, screenshot };
}
async function main() {
await fsp.mkdir(OUT_DIR, { recursive: true });
const root = await fsp.mkdtemp(path.join(os.tmpdir(), "mnote-office-preview-"));
await fsp.mkdir(path.join(root, ".mnote"), { recursive: true });
await fsp.writeFile(
path.join(root, ".mnote", "workspace.json"),
`${JSON.stringify({
workspaceId: WORKSPACE_ID,
ownerId: TEST_ACCOUNT,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "markdown_edit", "asset_upload"],
}, null, 2)}\n`,
"utf8",
);
await writeDocx(path.join(root, "sample.docx"));
writeXlsx(path.join(root, "sample.xlsx"));
await writePptx(path.join(root, "sample.pptx"));
await fsp.writeFile(
path.join(root, "sample.csv"),
"Name,Value\nMNote CSV light preview works,ok\n中文,正常\n",
"utf8",
);
const executablePath = resolveChromiumExecutablePath();
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
...(executablePath ? { executablePath } : {}),
});
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
try {
await signIn(context);
await setPageWidthPreferences(context, root, {
"pageWidth.word": { mode: "wide", custom: null },
"pageWidth.excel": { mode: "full", custom: null },
"pageWidth.ppt": { mode: "wide", custom: null },
});
const page = await context.newPage();
const results = [];
for (const item of [
{ fileName: "sample.docx", fileType: "docx", expected: "MNote DOCX light preview works" },
{ fileName: "sample.xlsx", fileType: "xlsx", expected: "MNote XLSX light preview works" },
{ fileName: "sample.pptx", fileType: "pptx", expected: "MNote PPTX light preview works" },
{ fileName: "sample.csv", fileType: "csv", expected: "MNote CSV light preview works" },
]) {
results.push(await verifyPreview(page, root, item));
}
await setPageWidthPreferences(context, root, {
"pageWidth.ppt": { mode: "full", custom: null },
});
results.push(await verifyPreview(page, root, {
fileName: "sample.pptx",
fileType: "pptx",
expected: "MNote PPTX light preview works",
expectedMode: "full",
}));
const result = { ok: true, baseUrl: BASE_URL, root, results };
await fsp.writeFile(path.join(OUT_DIR, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} finally {
await browser.close();
}
}
main().catch(async (error) => {
await fsp.mkdir(OUT_DIR, { recursive: true });
await fsp.writeFile(
path.join(OUT_DIR, "result.json"),
`${JSON.stringify({ ok: false, baseUrl: BASE_URL, error: error && error.stack ? error.stack : String(error) }, null, 2)}\n`,
"utf8",
);
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,339 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
ensureAuthenticated,
} = require("./tree-shell-smoke-helpers");
const TASK = "task502-page-ai-agent-selector-context-smoke";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function writeWorkspaceManifest(root, ownerId, workspaceId) {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(
path.join(root, ".mnote", "workspace.json"),
`${JSON.stringify({
workspaceId,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "ai_sessions", "markdown_edit"],
}, null, 2)}\n`,
"utf8",
);
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
async function saveScreenshot(page, name) {
const target = path.join(OUTPUT_DIR, `${name}.png`);
await page.screenshot({ path: target, fullPage: false });
return target;
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const suffix = Date.now().toString(36);
const actorId = "mnote-e2e";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task502-agent-context-"));
const rootUri = fileUrl(root);
const workspaceId = `local-ws:${actorId}:task502`;
const relativePath = "AgentContext.md";
const documentId = localMdDocumentId(relativePath);
const captured = [];
let caughtError = null;
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(
path.join(root, relativePath),
["# Agent Context", "", `Task502 ${suffix}`, ""].join("\n"),
"utf8",
);
const browser = await chromium.launch({
headless: true,
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
locale: "zh-CN",
extraHTTPHeaders: {
"x-mnote-actor-id": actorId,
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
try {
await page.route("**/api/user/access-policy**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
controlPlane: "sqlite",
grants: [{
id: `grant_task502_${suffix}`,
userId: actorId,
workspaceId,
rootUri,
rootPath: root,
permission: "write",
recursive: true,
capabilities: ["ai", "markdown_edit"],
source: "user",
status: "active",
}],
}),
});
});
await page.route("**/api/ui/preferences**", async (route) => {
captured.push({
kind: "ui-preferences",
method: route.request().method(),
body: route.request().postData() || "",
});
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, owner: "mnote-web", result: {} }),
});
});
await page.route("**/api/hermes/client/gateway/health**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
gateway: { ok: true, status: "mocked" },
profile: { name: "reasonix", modelConfigured: true, apiKeyConfigured: true },
suggestions: [],
}),
});
});
await page.route("**/api/hermes/client/tools**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, tools: [] }),
});
});
await page.route("**/api/hermes/client/profiles", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
active: "reasonix",
profiles: [{ name: "reasonix", label: "Reasonix", modelConfigured: true, apiKeyConfigured: true }],
}),
});
});
await page.route("**/api/hermes/client/skills**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/sessions", async (route) => {
captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
sessionId: `mnote_task502_${suffix}`,
title: "task502",
traceId: `trace_task502_session_${suffix}`,
persistence: "local_ai_session_jsonl",
sessionStorage: "local_private",
}),
});
});
await page.route("**/api/hermes/client/runs", async (route) => {
captured.push({ kind: "run", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
sessionId: `mnote_task502_${suffix}`,
runId: `run_task502_${suffix}`,
events: [],
traceId: `trace_task502_run_${suffix}`,
}),
});
});
await page.route("**/api/hermes/client/events/*", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "text/event-stream; charset=utf-8" },
body:
`data: ${JSON.stringify({ event: "message.delta", run_id: `run_task502_${suffix}`, delta: "Task502 response" })}\n\n` +
`data: ${JSON.stringify({ event: "run.completed", run_id: `run_task502_${suffix}`, output: "Task502 response" })}\n\n`,
});
});
await ensureAuthenticated(page, context.request);
const response = await page.goto(documentUrl(root, relativePath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
assert(response && response.status() === 200, `文档页状态码异常: ${response && response.status()}`);
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="wolai-page-ai-drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const drawer = page.locator('[data-testid="wolai-page-ai-drawer"]');
const defaultChatText = await drawer.innerText({ timeout: UI_TIMEOUT_MS });
assert(!defaultChatText.includes("问 Hermes"), "默认输入区不应继续固定写“问 Hermes”");
assert(!defaultChatText.includes("model.default"), "默认聊天面不应显示 model.default");
assert(!defaultChatText.includes("gateway:"), "默认聊天面不应显示 gateway 技术详情");
assert(!defaultChatText.includes("Hermes profile"), "默认聊天面不应显示 Hermes profile 技术项");
await page.locator("[data-page-ai-agent-selector]").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const agentIds = await page.$$eval("[data-page-ai-agent-id]", (nodes) =>
nodes.map((node) => node.getAttribute("data-page-ai-agent-id")).filter(Boolean),
);
assert.deepStrictEqual(agentIds.sort(), ["chat_only", "hermes", "reasonix"]);
await page.locator('[data-page-ai-agent-id="reasonix"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-agent-id") === "reasonix",
null,
{ timeout: UI_TIMEOUT_MS },
);
const contextRefs = page.locator("[data-page-ai-context-refs]");
await contextRefs.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert(
await page.locator('[data-page-ai-context-ref="current_page"][aria-pressed="true"]').isVisible(),
"当前页 contextRef 应默认勾选",
);
assert(
await page.locator("[data-page-ai-allowed-root]").first().isVisible(),
"SQLite 授权区域应作为 allowedRoot chip 显示",
);
await page.locator('.wolai-page-ai-header-actions [data-page-ai-tab="agent"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="agent"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const commonSettingsText = await page.locator('[data-page-ai-panel="agent"]').innerText({ timeout: UI_TIMEOUT_MS });
assert(commonSettingsText.includes("授权区域"), "Common 设置页应展示授权区域");
assert(commonSettingsText.includes("默认上下文"), "Common 设置页应展示默认 contextRefs");
assert(commonSettingsText.includes("审计"), "Common 设置页应展示审计/changed files 共性设置");
assert(!commonSettingsText.includes("Hermes profile"), "Common 设置页不应包含 Hermes profile");
assert(!commonSettingsText.includes("ACP runtime"), "Common 设置页不应包含 agent runtime 差异项");
await page.locator('[data-page-ai-panel="agent"] [data-page-ai-tab="hermes-settings"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="hermes-settings"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const hermesSettingsText = await page.locator('[data-page-ai-panel="hermes-settings"]').innerText({ timeout: UI_TIMEOUT_MS });
assert(hermesSettingsText.includes("Hermes profile"), "Hermes 设置页应只承接 Hermes profile");
assert(!hermesSettingsText.includes("Reasonix 专属设置"), "Hermes 设置页不应混入 Reasonix 设置");
await page.locator('[data-page-ai-panel="hermes-settings"] [data-page-ai-tab="reasonix-settings"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="reasonix-settings"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const reasonixSettingsText = await page.locator('[data-page-ai-panel="reasonix-settings"]').innerText({ timeout: UI_TIMEOUT_MS });
assert(reasonixSettingsText.includes("ACP runtime"), "Reasonix 设置页应展示 ACP runtime");
assert(reasonixSettingsText.includes("Reasonix 专属设置"), "Reasonix 设置页应展示 Reasonix 专属设置");
assert(!reasonixSettingsText.includes("Hermes profile"), "Reasonix 设置页不应混入 Hermes profile");
await page.locator('[data-page-ai-panel="reasonix-settings"] [data-page-ai-tab="chat-only-settings"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="chat-only-settings"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const chatOnlySettingsText = await page.locator('[data-page-ai-panel="chat-only-settings"]').innerText({ timeout: UI_TIMEOUT_MS });
assert(chatOnlySettingsText.includes("默认不申请文件写权限"), "Chat-only 设置页应说明默认不申请写权限");
assert(!chatOnlySettingsText.includes("ACP runtime"), "Chat-only 设置页不应混入 ACP runtime");
await page.locator('[data-page-ai-panel="chat-only-settings"] [data-page-ai-tab="chat"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="chat"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-context-ref="current_page"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-context-ref="folder"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-context-ref="changed_files"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-input]").fill(`Task502 agent/context ${suffix}`, { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Task502 response"),
null,
{ timeout: UI_TIMEOUT_MS },
);
const runs = captured.filter((item) => item.kind === "run");
assert(runs.length >= 1, "未捕获 Page AI run payload");
const runBody = JSON.parse(runs[runs.length - 1].body);
assert.strictEqual(runBody.agentId, "reasonix");
assert(Array.isArray(runBody.contextRefs), "run payload 必须包含 contextRefs 数组");
assert(!runBody.contextRefs.some((item) => item.kind === "current_page"), "取消当前页后不应发送 current_page contextRef");
assert.strictEqual(runBody.pageContext?.aiContext?.pageText, undefined, "取消当前页后不应发送 pageText");
assert.strictEqual(runBody.pageContext?.aiContext?.pageXml, undefined, "取消当前页后不应发送 pageXml");
assert.strictEqual(runBody.pageContext?.aiContext?.contextBlocks, undefined, "取消当前页后不应发送 contextBlocks");
assert(runBody.contextRefs.some((item) => item.kind === "active_editor" && item.documentId === documentId));
assert(runBody.contextRefs.some((item) => item.kind === "folder" && item.rootUri === rootUri));
assert(runBody.contextRefs.some((item) => item.kind === "changed_files"));
assert(Array.isArray(runBody.allowedRoots), "run payload 必须包含 allowedRoots 数组");
assert(runBody.allowedRoots.some((item) =>
item.rootUri === rootUri
&& item.permission === "write"
&& item.source === "sqlite_directory_grant"
));
assert.strictEqual(runBody.runTargetSnapshot?.source, "open_editors_snapshot");
const screenshot = await saveScreenshot(page, "01-agent-selector-context");
const result = {
ok: true,
task: TASK,
baseUrl: BASE_URL,
root,
rootUri,
documentId,
screenshot,
captured,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} catch (error) {
caughtError = error;
await saveScreenshot(page, "failure").catch(() => undefined);
} finally {
fs.rmSync(root, { recursive: true, force: true });
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error);
process.exit(1);
});
}
@@ -0,0 +1,496 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const PPTX_PATH = process.env.MNOTE_TASK503_PPTX_PATH
|| "/home/lix/Downloads/1768096586803-672be7a7-5cb3-454d-9b1b-6230f86ba392.pptx";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task503-local-pptx-upload-filetree-open-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function workspaceId(ownerId) {
return `local-ws:${ownerId}:task503`;
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
function writeWorkspaceManifest(root, ownerId) {
const metadataDir = path.join(root, ".mnote");
fs.mkdirSync(metadataDir, { recursive: true });
fs.writeFileSync(
path.join(metadataDir, "workspace.json"),
`${JSON.stringify({
workspaceId: workspaceId(ownerId),
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "markdown_edit", "asset_upload"],
}, null, 2)}\n`,
"utf8",
);
}
async function quickLogin(page) {
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
if (await quickLoginButton.count()) {
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
}
}
async function openDocument(page, root, relativePath) {
await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function uploadAttachmentViaSlash(page, filePath) {
const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first();
await editor.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press("End").catch(() => undefined);
await page.keyboard.type("/");
const item = page.locator('.document-pane[data-pane-role="primary"] [data-testid="slash-item-upload-attachment"]').first();
await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const [fileChooser] = await Promise.all([
page.waitForEvent("filechooser", { timeout: UI_TIMEOUT_MS }),
item.click({ timeout: UI_TIMEOUT_MS }),
]);
await fileChooser.setFiles(filePath);
}
async function waitForEditorLinks(page, expectedCount) {
await page.waitForFunction(
({ count }) => {
const links = Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a'));
return links.filter((link) => /\.pptx(?:\?|$)/i.test(link.textContent || "")).length >= count;
},
{ count: expectedCount },
{ timeout: UI_TIMEOUT_MS },
);
return await page.evaluate(() => Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a'))
.filter((link) => /\.pptx(?:\?|$)/i.test(link.textContent || ""))
.map((link) => ({
text: link.textContent || "",
href: link.getAttribute("href") || "",
assetId: link.getAttribute("data-asset-id") || "",
className: link.getAttribute("class") || "",
})));
}
async function assertNoConflict(page, label) {
await page.waitForTimeout(1500);
const state = await page.evaluate(() => {
const pane = document.querySelector('.document-pane[data-pane-role="primary"]');
const runtimeRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
return {
status: runtimeRoot?.getAttribute("data-runtime-editor-status") || "",
statusError: runtimeRoot?.getAttribute("data-runtime-editor-error") || "",
conflictVisible: Boolean(pane?.querySelector('[data-testid="mnote-editor-conflict-panel"]')),
lastSaveError: document.documentElement.getAttribute("data-mnote-last-upload-save-error") || "",
};
});
assert.notEqual(state.status, "external-change-conflict", `${label}: 不应进入文件冲突态: ${JSON.stringify(state)}`);
assert.equal(state.conflictVisible, false, `${label}: 不应显示文件冲突面板: ${JSON.stringify(state)}`);
assert.equal(state.lastSaveError, "", `${label}: 上传保存不应失败: ${JSON.stringify(state)}`);
return state;
}
async function setPptPageWidth(context, root, relativePath, mode) {
const response = await context.request.fetch(`${BASE_URL}/api/ui/preferences`, {
method: "PUT",
data: {
documentId: localMdDocumentId(relativePath),
workspaceId: workspaceId("mnote-e2e"),
sourceKind: "local_folder",
rootUri: fileUrl(root),
updates: {
"pageWidth.ppt": { mode, custom: null },
},
},
});
assert(response.ok(), `PPT 页面宽度偏好写入失败: ${response.status()} ${await response.text()}`);
}
async function waitForActivePptxResourceTabLayout(page, expectedMode) {
await page.waitForFunction(
({ mode }) => {
const shell = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell');
const panel = Array.from(document.querySelectorAll(".mnote-resource-tab-panel"))
.find((node) => node instanceof HTMLElement && !node.hidden && node.getAttribute("data-resource-kind") === "office");
const frame = panel?.querySelector("iframe.mnote-resource-tab-frame");
const frameDocument = frame instanceof HTMLIFrameElement ? frame.contentDocument : null;
return shell instanceof HTMLElement
&& shell.getAttribute("data-page-width-content-type") === "ppt"
&& shell.getAttribute("data-page-width-resolved-mode") === mode
&& frameDocument?.documentElement?.getAttribute("data-mnote-office-preview-status") === "完成"
&& frameDocument.documentElement.getAttribute("data-page-width-resolved-mode") === mode
&& frameDocument.querySelector(".mnote-office-pptx-stage");
},
{ mode: expectedMode },
{ timeout: UI_TIMEOUT_MS },
);
return await page.evaluate((mode) => {
const rectOf = (node) => {
if (!(node instanceof Element)) return null;
const rect = node.getBoundingClientRect();
return { left: rect.left, width: rect.width };
};
const shell = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell');
const panel = Array.from(document.querySelectorAll(".mnote-resource-tab-panel"))
.find((node) => node instanceof HTMLElement && !node.hidden && node.getAttribute("data-resource-kind") === "office");
const frame = panel?.querySelector("iframe.mnote-resource-tab-frame");
const frameDocument = frame instanceof HTMLIFrameElement ? frame.contentDocument : null;
const viewer = frameDocument?.querySelector("#mnote-office-viewer");
const stage = frameDocument?.querySelector(".mnote-office-pptx-stage");
const viewerRect = rectOf(viewer);
const stageRect = rectOf(stage);
return {
expectedMode: mode,
viewportWidth: window.innerWidth,
shellMode: shell?.getAttribute("data-page-width-resolved-mode") || "",
shellContentType: shell?.getAttribute("data-page-width-content-type") || "",
documentMode: document.documentElement.getAttribute("data-page-width-resolved-mode") || "",
activeResourceWidthType: document.documentElement.getAttribute("data-mnote-active-resource-width-type") || "",
iframeMode: frameDocument?.documentElement?.getAttribute("data-page-width-resolved-mode") || "",
shell: rectOf(shell),
panel: rectOf(panel),
frame: rectOf(frame),
viewer: viewerRect,
stage: stageRect,
};
}, expectedMode);
}
async function clickEditorPptxLink(page, context, titleContains) {
await page.waitForFunction(
(needle) => {
return Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a'))
.some((node) => {
const rect = node.getBoundingClientRect();
return (node.textContent || "").includes(needle) && rect.width > 0 && rect.height > 0;
});
},
titleContains,
{ timeout: UI_TIMEOUT_MS },
);
const target = await page.evaluate((needle) => {
const candidates = Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a'));
for (let index = candidates.length - 1; index >= 0; index -= 1) {
const node = candidates[index];
const rect = node.getBoundingClientRect();
if (!(node.textContent || "").includes(needle) || rect.width <= 0 || rect.height <= 0) continue;
return {
href: node instanceof HTMLAnchorElement ? node.href : "",
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
};
}
return null;
}, titleContains);
assert(target?.href, `编辑器附件缺少 href: ${titleContains}`);
assert(target?.rect, `编辑器附件链接不可见或无坐标: ${titleContains}`);
await page.mouse.click(target.rect.x + target.rect.width / 2, target.rect.y + target.rect.height / 2);
await page.waitForFunction(
(needle) => {
const activePanel = document.querySelector('.mnote-resource-tab-panel:not([hidden])');
const activeFrame = activePanel?.querySelector?.('iframe.mnote-resource-tab-frame');
const frameSrc = activeFrame instanceof HTMLIFrameElement ? activeFrame.getAttribute('src') || '' : '';
return decodeURIComponent(frameSrc.replace(/\+/g, ' ')).includes(needle)
|| decodeURIComponent(location.href.replace(/\+/g, ' ')).includes(needle);
},
titleContains,
{ timeout: UI_TIMEOUT_MS },
);
await page.locator('.mnote-resource-tab-panel:not([hidden]) iframe.mnote-resource-tab-frame').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function activatePrimaryPageTab(page) {
const pageTab = page.locator('[data-mnote-main-tab="page"][data-pane-role="primary"]').first();
await pageTab.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await pageTab.click({ timeout: UI_TIMEOUT_MS });
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
function assertLocalUploadSaveVersions(result, expectedCount) {
const saves = result.documentSaveRequests.filter((item) => item.editorSource === "local-upload-runtime");
assert(
saves.length >= expectedCount,
`local-upload-runtime 保存次数不足: expected>=${expectedCount}, actual=${saves.length}`,
);
saves.slice(0, expectedCount).forEach((item, index) => {
assert(
item.expectedFileVersion,
`${index + 1} 次附件正文保存缺少 expectedFileVersion: ${JSON.stringify(item)}`,
);
assert(
item.writeIntentId,
`${index + 1} 次附件正文保存缺少 writeIntentId: ${JSON.stringify(item)}`,
);
assert(
item.saveOperationId,
`${index + 1} 次附件正文保存缺少 saveOperationId: ${JSON.stringify(item)}`,
);
});
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function typeLineAfterUpload(page, text) {
const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first();
await editor.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press("End").catch(() => undefined);
await page.keyboard.press("Enter");
await page.keyboard.type(text);
}
async function waitForFileTreeAssetRows(page, expectedCount) {
await page.waitForFunction(
({ count }) => {
return Array.from(document.querySelectorAll('#sidebar-file-tree-root [data-testid="filetree-asset-row"]'))
.filter((row) => /\.pptx/i.test(row.textContent || ""))
.length >= count;
},
{ count: expectedCount },
{ timeout: UI_TIMEOUT_MS },
);
return await page.evaluate(() => Array.from(document.querySelectorAll('#sidebar-file-tree-root [data-testid="filetree-asset-row"]'))
.filter((row) => /\.pptx/i.test(row.textContent || ""))
.map((row) => ({
title: row.textContent?.trim() || "",
rowId: row.getAttribute("data-row-id") || "",
assetId: row.getAttribute("data-asset-id") || "",
localRelativePath: row.getAttribute("data-local-relative-path") || "",
objectIdentity: row.getAttribute("data-object-identity") || "",
})));
}
async function clickPptxFileTreeRow(page, titleContains) {
const row = page.locator('#sidebar-file-tree-root [data-testid="filetree-asset-row"]', { hasText: titleContains }).last();
await row.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await row.click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
(needle) => {
const active = document.querySelector('[data-testid="document-resource-tab"][data-active="true"], [data-mnote-resource-tab][data-active="true"]');
if (active instanceof HTMLElement && (active.textContent || "").includes(needle)) return true;
return decodeURIComponent(location.href.replace(/\+/g, " ")).includes(needle);
},
titleContains,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForLocalFile(filePath) {
const startedAt = Date.now();
while (Date.now() - startedAt < UI_TIMEOUT_MS) {
if (fs.existsSync(filePath)) return;
await new Promise((resolve) => setTimeout(resolve, 100));
}
assert(fs.existsSync(filePath), `文件应存在: ${filePath}`);
}
async function dispatchPptxDrop(page, filePath, targetText) {
const buffer = fs.readFileSync(filePath);
const base64 = buffer.toString("base64");
const fileName = path.basename(filePath);
const target = page.locator('#sidebar-file-tree-root .tree-row[data-row-kind="folder"]', { hasText: targetText }).first();
await target.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await target.dispatchEvent("dragover", {
dataTransfer: await page.evaluateHandle(({ name, payload }) => {
const bytes = Uint8Array.from(atob(payload), (char) => char.charCodeAt(0));
const transfer = new DataTransfer();
transfer.items.add(new File([bytes], name, {
type: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
}));
return transfer;
}, { name: fileName, payload: base64 }),
});
await target.dispatchEvent("drop", {
dataTransfer: await page.evaluateHandle(({ name, payload }) => {
const bytes = Uint8Array.from(atob(payload), (char) => char.charCodeAt(0));
const transfer = new DataTransfer();
transfer.items.add(new File([bytes], name, {
type: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
}));
return transfer;
}, { name: fileName, payload: base64 }),
});
}
async function main() {
assert(fs.existsSync(PPTX_PATH), `测试 pptx 不存在: ${PPTX_PATH}`);
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task503-pptx-"));
const relativePath = "README/README.md";
const resourceDir = path.join(root, "README");
const fileName = path.basename(PPTX_PATH);
writeWorkspaceManifest(root, "mnote-e2e");
fs.mkdirSync(resourceDir, { recursive: true });
fs.writeFileSync(path.join(root, relativePath), "# PPTX Upload\n\n正文\n", "utf8");
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
executablePath: CHROMIUM_EXECUTABLE_PATH,
});
const context = await browser.newContext({
viewport: { width: 1366, height: 900 },
extraHTTPHeaders: {
"x-mnote-actor-id": "mnote-e2e",
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
const result = { root, fileName, states: [], console: [], pageErrors: [], documentSaveRequests: [] };
page.on("console", (message) => result.console.push({ type: message.type(), text: message.text() }));
page.on("pageerror", (error) => result.pageErrors.push(String(error && error.stack || error)));
page.on("request", (request) => {
try {
const url = new URL(request.url());
if (url.pathname !== "/api/documents/save" || request.method() !== "POST") return;
const payload = JSON.parse(request.postData() || "{}");
result.documentSaveRequests.push({
editorSource: String(payload.editorSource || ""),
documentId: String(payload.documentId || ""),
expectedFileVersion: String(payload.expectedFileVersion || ""),
writeIntentId: String(payload.writeIntentId || ""),
saveOperationId: String(payload.saveOperationId || ""),
blockCount: Number(payload.blockCount || 0),
});
} catch (_error) {
// ignore diagnostics-only capture failures
}
});
try {
await quickLogin(page);
await setPptPageWidth(context, root, relativePath, "full");
await openDocument(page, root, relativePath);
await uploadAttachmentViaSlash(page, PPTX_PATH);
const firstLinks = await waitForEditorLinks(page, 1);
const firstConflictState = await assertNoConflict(page, "first-editor-upload");
assertLocalUploadSaveVersions(result, 1);
result.states.push({ step: "first-editor-upload", links: firstLinks, conflictState: firstConflictState });
await typeLineAfterUpload(page, "第一份附件后的正文");
const afterFirstEditConflictState = await assertNoConflict(page, "after-first-upload-edit");
result.states.push({ step: "after-first-upload-edit", conflictState: afterFirstEditConflictState });
await uploadAttachmentViaSlash(page, PPTX_PATH);
const secondLinks = await waitForEditorLinks(page, 2);
const secondConflictState = await assertNoConflict(page, "second-editor-upload");
assertLocalUploadSaveVersions(result, 2);
await delay(1800);
const editorRows = await waitForFileTreeAssetRows(page, 2);
result.states.push({ step: "second-editor-upload", links: secondLinks, conflictState: secondConflictState, fileTreeRows: editorRows });
assert(editorRows.every((row) => row.localRelativePath), `文件树上传行缺少本地路径: ${JSON.stringify(editorRows)}`);
await openDocument(page, root, relativePath);
await waitForEditorLinks(page, 2);
await clickEditorPptxLink(page, context, fileName);
await activatePrimaryPageTab(page);
await clickEditorPptxLink(page, context, fileName.replace(/\.pptx$/i, "-1.pptx"));
await clickPptxFileTreeRow(page, fileName.replace(/\.pptx$/i, "-1.pptx"));
const fullWidthLayout = await waitForActivePptxResourceTabLayout(page, "full");
assert(fullWidthLayout.panel?.width >= fullWidthLayout.viewportWidth - 320, `PPT 资源 tab 面板应使用主编辑区宽度: ${JSON.stringify(fullWidthLayout)}`);
assert(fullWidthLayout.frame?.width >= fullWidthLayout.viewportWidth - 320, `PPT 资源 tab iframe 应使用主编辑区宽度: ${JSON.stringify(fullWidthLayout)}`);
result.states.push({ step: "pptx-resource-tab-full-width", layout: fullWidthLayout });
await dispatchPptxDrop(page, PPTX_PATH, "README");
const droppedFileName = fileName.replace(/\.pptx$/i, " 2.pptx");
const droppedPath = path.join(resourceDir, droppedFileName);
await page.waitForFunction(
(targetPath) => document.documentElement.getAttribute("data-mnote-assets-local-applied") === "true" && targetPath,
droppedPath,
{ timeout: UI_TIMEOUT_MS },
).catch(() => undefined);
await waitForLocalFile(droppedPath);
const finalRows = await waitForFileTreeAssetRows(page, 3);
result.states.push({ step: "filetree-drop-upload", droppedPath, fileTreeRows: finalRows });
await clickPptxFileTreeRow(page, droppedFileName);
await page.screenshot({ path: path.join(OUTPUT_DIR, "success.png"), fullPage: false });
fs.writeFileSync(RESULT_PATH, `${JSON.stringify({ ok: true, ...result }, null, 2)}\n`, "utf8");
console.log(JSON.stringify({ ok: true, resultPath: RESULT_PATH, root, fileName }, null, 2));
} catch (error) {
const diagnostics = await page.evaluate(() => ({
url: location.href,
lastUploadAssetId: document.documentElement.getAttribute("data-mnote-last-upload-asset-id") || "",
lastUploadInserted: document.documentElement.getAttribute("data-mnote-last-upload-inserted") || "",
assetsLocalApplied: document.documentElement.getAttribute("data-mnote-assets-local-applied") || "",
filetreeRows: Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]')).map((row) => ({
text: row.textContent?.trim() || "",
rowKind: row.getAttribute("data-row-kind") || "",
rowId: row.getAttribute("data-row-id") || "",
assetId: row.getAttribute("data-asset-id") || "",
localRelativePath: row.getAttribute("data-local-relative-path") || "",
})).slice(0, 80),
editorLinks: Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a')).map((link) => ({
text: link.textContent || "",
href: link.getAttribute("href") || "",
assetId: link.getAttribute("data-asset-id") || "",
className: link.getAttribute("class") || "",
})),
documentSessions: typeof window.__mnoteDebugDocumentSessions?.snapshot === "function"
? window.__mnoteDebugDocumentSessions.snapshot()
: null,
aggregate: (() => {
try {
const script = document.getElementById("__MNOTE_PAGE_AGGREGATE__");
return script ? JSON.parse(script.textContent || "null") : null;
} catch (_error) {
return null;
}
})(),
})).catch((err) => ({ diagnosticsError: String(err) }));
await page.screenshot({ path: path.join(OUTPUT_DIR, "failure.png"), fullPage: true }).catch(() => undefined);
fs.writeFileSync(
RESULT_PATH,
`${JSON.stringify({ ok: false, ...result, diagnostics, error: String(error && error.stack || error) }, null, 2)}\n`,
"utf8",
);
console.error(JSON.stringify({ ok: false, resultPath: RESULT_PATH, root, diagnostics, error: String(error && error.stack || error) }, null, 2));
process.exitCode = 1;
} finally {
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,410 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_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.join(process.cwd(), "tmp", "task504-local-md-external-conflict-recovery-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function workspaceId(ownerId) {
return `local-ws:${ownerId}:task504`;
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
function writeWorkspaceManifest(root, ownerId) {
const metadataDir = path.join(root, ".mnote");
fs.mkdirSync(metadataDir, { recursive: true });
fs.writeFileSync(
path.join(metadataDir, "workspace.json"),
`${JSON.stringify({
workspaceId: workspaceId(ownerId),
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "markdown_edit", "asset_upload"],
}, null, 2)}\n`,
"utf8",
);
}
async function quickLogin(page) {
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
if (await quickLoginButton.count()) {
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
}
}
async function openDocument(page, root, relativePath) {
await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function typeUnsavedEditorText(page, text) {
const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first();
await editor.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press("End").catch(() => undefined);
await page.keyboard.press("Enter");
await page.keyboard.type(text, { delay: 2 });
}
async function waitForFileContent(filePath, predicate, timeoutMs) {
const startedAt = Date.now();
let lastContent = "";
while (Date.now() - startedAt < timeoutMs) {
if (fs.existsSync(filePath)) {
lastContent = fs.readFileSync(filePath, "utf8");
if (predicate(lastContent)) return lastContent;
}
await new Promise((resolve) => setTimeout(resolve, 120));
}
throw new Error(`文件内容未达到预期: ${filePath}; lastContent=${JSON.stringify(lastContent)}`);
}
function withTimeout(promise, timeoutMs, makeError) {
let timer = 0;
const timeout = new Promise((_, reject) => {
timer = setTimeout(async () => {
try {
const error = typeof makeError === "function"
? await makeError()
: new Error(String(makeError || "operation_timeout"));
reject(error instanceof Error ? error : new Error(String(error)));
} catch (err) {
reject(err);
}
}, timeoutMs);
});
return Promise.race([promise, timeout]).finally(() => {
if (timer) clearTimeout(timer);
});
}
async function captureEditorDiagnostics(page, extra = {}) {
return await page.evaluate((extraData) => ({
...extraData,
url: location.href,
status: document.querySelector('[data-runtime-editor-status]')?.getAttribute('data-runtime-editor-status') || "",
statusError: document.querySelector('[data-runtime-editor-error]')?.getAttribute('data-runtime-editor-error') || "",
conflictVisible: Boolean(document.querySelector('[data-testid="mnote-editor-conflict-panel"]')),
editorText: document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "",
panelText: document.querySelector('[data-testid="mnote-editor-conflict-panel"]')?.textContent || "",
sessions: typeof window.__mnoteDebugDocumentSessions?.snapshot === "function"
? window.__mnoteDebugDocumentSessions.snapshot()
: null,
}), extra).catch((err) => ({ ...extra, diagnosticsError: String(err) }));
}
async function waitForConflictState(page, expectedEditorText, expectedDiskText) {
await page.locator('[data-testid="mnote-editor-conflict-panel"]').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.waitForFunction(
({ editorNeedle, diskNeedle }) => {
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
const panel = document.querySelector('[data-testid="mnote-editor-conflict-panel"]');
return root?.getAttribute("data-runtime-editor-status") === "external-change-conflict"
&& (editor?.textContent || "").includes(editorNeedle)
&& (panel?.textContent || "").includes("文件冲突")
&& diskNeedle;
},
{ editorNeedle: expectedEditorText, diskNeedle: expectedDiskText },
{ timeout: UI_TIMEOUT_MS },
);
return await page.evaluate(() => {
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
const panel = document.querySelector('[data-testid="mnote-editor-conflict-panel"]');
const sessions = typeof window.__mnoteDebugDocumentSessions?.snapshot === "function"
? window.__mnoteDebugDocumentSessions.snapshot()
: null;
return {
status: root?.getAttribute("data-runtime-editor-status") || "",
statusError: root?.getAttribute("data-runtime-editor-error") || "",
editorText: editor?.textContent || "",
panelText: panel?.textContent || "",
sessions,
};
});
}
async function triggerExternalConflict(page, markdownPath, editorToken, diskToken) {
await typeUnsavedEditorText(page, editorToken);
fs.writeFileSync(markdownPath, `# Conflict Base\n\n${diskToken}\n`, "utf8");
const conflictState = await waitForConflictState(page, editorToken, diskToken);
assert(conflictState.editorText.includes(editorToken), `冲突后应保留编辑器未保存内容: ${JSON.stringify(conflictState)}`);
assert(conflictState.status === "external-change-conflict", `应进入外部冲突态: ${JSON.stringify(conflictState)}`);
return conflictState;
}
async function openConflictDiff(page, editorToken, diskToken) {
await page.getByTestId("mnote-conflict-open-diff").click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-conflict-diff-panel"]').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.waitForFunction(
({ editorNeedle, diskNeedle }) => {
const current = document.querySelector('[data-testid="mnote-conflict-current-text"]')?.textContent || "";
const disk = document.querySelector('[data-testid="mnote-conflict-disk-text"]')?.textContent || "";
return current.includes(editorNeedle) && disk.includes(diskNeedle);
},
{ editorNeedle: editorToken, diskNeedle: diskToken },
{ timeout: UI_TIMEOUT_MS },
);
return await page.evaluate(() => ({
currentText: document.querySelector('[data-testid="mnote-conflict-current-text"]')?.textContent || "",
diskText: document.querySelector('[data-testid="mnote-conflict-disk-text"]')?.textContent || "",
mergeText: document.querySelector('[data-testid="mnote-conflict-merge-text"]')?.value || "",
}));
}
async function acceptDiskVersion(page, diskToken) {
await page.getByTestId("mnote-conflict-accept-disk").click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
(needle) => {
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editorText = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "";
const conflictVisible = Boolean(document.querySelector('[data-testid="mnote-editor-conflict-panel"]'));
return !conflictVisible
&& root?.getAttribute("data-runtime-editor-status") !== "external-change-conflict"
&& editorText.includes(needle);
},
diskToken,
{ timeout: UI_TIMEOUT_MS },
);
return await page.evaluate(() => ({
status: document.querySelector('[data-runtime-editor-status]')?.getAttribute('data-runtime-editor-status') || "",
editorText: document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "",
conflictVisible: Boolean(document.querySelector('[data-testid="mnote-editor-conflict-panel"]')),
}));
}
async function keepCurrentVersion(page, markdownPath, editorToken) {
await page.getByTestId("mnote-conflict-keep-current").click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
(needle) => {
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editorText = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "";
const conflictVisible = Boolean(document.querySelector('[data-testid="mnote-editor-conflict-panel"]'));
return !conflictVisible
&& root?.getAttribute("data-runtime-editor-status") !== "external-change-conflict"
&& editorText.includes(needle);
},
editorToken,
{ timeout: UI_TIMEOUT_MS },
);
const saved = await waitForFileContent(markdownPath, (content) => content.includes(editorToken), UI_TIMEOUT_MS);
return await page.evaluate((savedContent) => ({
status: document.querySelector('[data-runtime-editor-status]')?.getAttribute('data-runtime-editor-status') || "",
editorText: document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "",
conflictVisible: Boolean(document.querySelector('[data-testid="mnote-editor-conflict-panel"]')),
savedContent,
}), saved);
}
async function installDelayedFirstSave(page) {
const gate = {
enabled: false,
intercepted: false,
saveRequestCount: 0,
release: null,
interceptedPromise: null,
releasePromise: null,
};
gate.interceptedPromise = new Promise((resolve) => {
gate.markIntercepted = resolve;
});
gate.releasePromise = new Promise((resolve) => {
gate.release = resolve;
});
const interceptSave = async (route) => {
gate.saveRequestCount += 1;
if (gate.enabled && !gate.intercepted) {
gate.intercepted = true;
gate.markIntercepted();
await gate.releasePromise;
}
await route.continue();
};
await page.route("**/api/documents/save", interceptSave);
await page.route("**/api/page-body/write", interceptSave);
return gate;
}
async function assertSaveWhileTypingKeepsDirty(page, markdownPath, firstToken, secondToken, gate) {
const saveRequestCountBeforeRace = gate.saveRequestCount;
gate.enabled = true;
await typeUnsavedEditorText(page, firstToken);
await withTimeout(
gate.interceptedPromise,
UI_TIMEOUT_MS,
async () => new Error(`保存请求未被拦截: ${JSON.stringify(await captureEditorDiagnostics(page, {
saveRequestCount: gate.saveRequestCount,
firstToken,
secondToken,
}))}`),
);
await typeUnsavedEditorText(page, secondToken);
gate.release();
await page.waitForFunction(
({ firstNeedle, secondNeedle }) => {
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editorText = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "";
const conflictVisible = Boolean(document.querySelector('[data-testid="mnote-editor-conflict-panel"]'));
return !conflictVisible
&& root?.getAttribute("data-runtime-editor-status") !== "external-change-conflict"
&& editorText.includes(firstNeedle)
&& editorText.includes(secondNeedle);
},
{ firstNeedle: firstToken, secondNeedle: secondToken },
{ timeout: UI_TIMEOUT_MS },
);
const saved = await waitForFileContent(
markdownPath,
(content) => content.includes(firstToken) && content.includes(secondToken),
UI_TIMEOUT_MS,
);
assert(
gate.saveRequestCount >= saveRequestCountBeforeRace + 2,
`保存中继续输入应触发第二轮保存: before=${saveRequestCountBeforeRace}; after=${gate.saveRequestCount}`,
);
return await page.evaluate(({ savedContent, saveRequestCountBeforeRace, saveRequestCountAfterRace }) => ({
status: document.querySelector('[data-runtime-editor-status]')?.getAttribute('data-runtime-editor-status') || "",
editorText: document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "",
conflictVisible: Boolean(document.querySelector('[data-testid="mnote-editor-conflict-panel"]')),
savedContent,
saveRequestCountBeforeRace,
saveRequestCountAfterRace,
}), {
savedContent: saved,
saveRequestCountBeforeRace,
saveRequestCountAfterRace: gate.saveRequestCount,
});
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task504-conflict-"));
const relativePath = "README.md";
const keepRelativePath = "Keep.md";
const raceRelativePath = "SaveRace.md";
const markdownPath = path.join(root, relativePath);
const keepMarkdownPath = path.join(root, keepRelativePath);
const raceMarkdownPath = path.join(root, raceRelativePath);
writeWorkspaceManifest(root, "mnote-e2e");
fs.writeFileSync(markdownPath, "# Conflict Base\n\n原始正文\n", "utf8");
fs.writeFileSync(keepMarkdownPath, "# Conflict Base\n\n保留当前基线\n", "utf8");
fs.writeFileSync(raceMarkdownPath, "# Save Race\n\n保存中继续输入基线\n", "utf8");
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
executablePath: CHROMIUM_EXECUTABLE_PATH,
});
const context = await browser.newContext({
viewport: { width: 1366, height: 900 },
extraHTTPHeaders: {
"x-mnote-actor-id": "mnote-e2e",
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
const delayedSaveGate = await installDelayedFirstSave(page);
const result = { root, relativePath, states: [], console: [], pageErrors: [] };
page.on("console", (message) => result.console.push({ type: message.type(), text: message.text() }));
page.on("pageerror", (error) => result.pageErrors.push(String(error && error.stack || error)));
try {
await quickLogin(page);
await openDocument(page, root, relativePath);
const editorToken = `编辑器未保存 ${Date.now()}`;
const diskToken = `磁盘外部修改 ${Date.now()}`;
const conflictState = await triggerExternalConflict(page, markdownPath, editorToken, diskToken);
result.states.push({ step: "external-edit-conflict", conflictState, diskText: fs.readFileSync(markdownPath, "utf8") });
const diffState = await openConflictDiff(page, editorToken, diskToken);
result.states.push({ step: "open-diff", diffState });
const acceptState = await acceptDiskVersion(page, diskToken);
result.states.push({ step: "accept-disk", acceptState });
await openDocument(page, root, keepRelativePath);
const keepEditorToken = `保留当前编辑器 ${Date.now()}`;
const keepDiskToken = `保留当前磁盘外部修改 ${Date.now()}`;
const keepConflictState = await triggerExternalConflict(page, keepMarkdownPath, keepEditorToken, keepDiskToken);
result.states.push({ step: "keep-current-conflict", conflictState: keepConflictState, diskText: fs.readFileSync(keepMarkdownPath, "utf8") });
const keepState = await keepCurrentVersion(page, keepMarkdownPath, keepEditorToken);
result.states.push({ step: "keep-current", keepState });
await openDocument(page, root, raceRelativePath);
const firstRaceToken = `保存中第一段 ${Date.now()}`;
const secondRaceToken = `保存中第二段 ${Date.now()}`;
const raceState = await assertSaveWhileTypingKeepsDirty(
page,
raceMarkdownPath,
firstRaceToken,
secondRaceToken,
delayedSaveGate,
);
result.states.push({ step: "save-while-typing", raceState });
await page.screenshot({ path: path.join(OUTPUT_DIR, "conflict.png"), fullPage: false });
fs.writeFileSync(RESULT_PATH, `${JSON.stringify({ ok: true, ...result }, null, 2)}\n`, "utf8");
console.log(JSON.stringify({ ok: true, resultPath: RESULT_PATH, root, conflictState }, null, 2));
} catch (error) {
const diagnostics = await captureEditorDiagnostics(page, {
delayedSaveGate: {
enabled: delayedSaveGate.enabled,
intercepted: delayedSaveGate.intercepted,
saveRequestCount: delayedSaveGate.saveRequestCount,
},
});
await page.screenshot({ path: path.join(OUTPUT_DIR, "failure.png"), fullPage: true }).catch(() => undefined);
fs.writeFileSync(
RESULT_PATH,
`${JSON.stringify({ ok: false, ...result, diagnostics, diskText: fs.readFileSync(markdownPath, "utf8"), error: String(error && error.stack || error) }, null, 2)}\n`,
"utf8",
);
console.error(JSON.stringify({ ok: false, resultPath: RESULT_PATH, root, diagnostics, error: String(error && error.stack || error) }, null, 2));
process.exitCode = 1;
} finally {
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,273 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
function writeWorkspaceManifest(root, ownerId) {
const metadataDir = path.join(root, ".mnote");
fs.mkdirSync(metadataDir, { recursive: true });
fs.writeFileSync(
path.join(metadataDir, "workspace.json"),
`${JSON.stringify({
workspaceId: `local-ws:${ownerId}:task505`,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "markdown_edit", "asset_upload"],
}, null, 2)}\n`,
"utf8",
);
}
async function quickLogin(page) {
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
if (await quickLoginButton.count()) {
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
}
}
async function uploadLocalAsset(page, root, documentId, fileName, mimeType, bytes, kind) {
return await page.evaluate(
async ({ rootUri, documentId, fileName, mimeType, bytes, kind }) => {
const form = new FormData();
form.append("rootUri", rootUri);
form.append("documentId", documentId);
form.append("uploadIntent", "editor.markdown.attach");
form.append("kind", kind);
form.append("file", new File([new Uint8Array(bytes)], fileName, { type: mimeType }));
const response = await fetch("/api/local-folder/assets/upload", { method: "POST", body: form });
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(`upload_failed_${response.status}:${JSON.stringify(payload)}`);
}
return payload.asset;
},
{ rootUri: fileUrl(root), documentId, fileName, mimeType, bytes: Array.from(bytes), kind },
);
}
async function openResourceTab(page, asset, documentId) {
await page.evaluate(({ asset, documentId }) => {
window.dispatchEvent(new CustomEvent("tree.asset.open", {
detail: {
assetId: asset.id,
documentId,
title: asset.file_name || "resource-note.md",
assetType: asset.asset_type || "attachment",
},
}));
}, { asset, documentId });
await page.locator('[data-testid="mnote-resource-tab-host"]:not([hidden])').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.locator('.mnote-resource-tab-panel:not([hidden]) .editor-surface .ProseMirror[contenteditable="true"]').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function main() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task505-resource-watch-"));
const relativePath = "README.md";
const documentId = localMdDocumentId(relativePath);
const resourceRelativePath = "README/resource-note.md";
const resourcePath = path.join(root, resourceRelativePath);
writeWorkspaceManifest(root, "mnote-e2e");
fs.writeFileSync(path.join(root, relativePath), "# Page\n\n页面正文保持不变\n", "utf8");
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
executablePath: CHROMIUM_EXECUTABLE_PATH,
});
const context = await browser.newContext({
viewport: { width: 1366, height: 900 },
extraHTTPHeaders: {
"x-mnote-actor-id": "mnote-e2e",
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
const result = { root, resourceRelativePath, states: [] };
try {
await quickLogin(page);
await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
const asset = await uploadLocalAsset(
page,
root,
documentId,
"resource-note.md",
"text/markdown",
Buffer.from("# Resource Note\n\n旧资源正文\n", "utf8"),
"attachment",
);
await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await openResourceTab(page, asset, documentId);
await page.waitForFunction(
(resourceNeedle) => {
const snapshot = typeof window.__mnoteDebugDocumentSessions?.snapshot === "function"
? window.__mnoteDebugDocumentSessions.snapshot()
: null;
return Array.isArray(snapshot?.localFolderRoots)
&& snapshot.localFolderRoots.some((item) => String(item || "").includes(`resource:${resourceNeedle}`));
},
resourceRelativePath,
{ timeout: UI_TIMEOUT_MS },
);
const externalCleanToken = `外部资源更新 ${Date.now()}`;
fs.writeFileSync(resourcePath, `# Resource Note\n\n${externalCleanToken}\n`, "utf8");
await page.waitForFunction(
(needle) => {
const resourceText = document.querySelector('.mnote-resource-tab-panel:not([hidden]) .editor-surface .ProseMirror')?.textContent || "";
const pageConflict = Boolean(document.querySelector('.document-pane[data-pane-role="primary"] > [data-testid="mnote-editor-conflict-panel"]'));
return resourceText.includes(needle) && !pageConflict;
},
externalCleanToken,
{ timeout: UI_TIMEOUT_MS },
);
result.states.push({ step: "clean-resource-external-update", externalCleanToken });
const editor = page.locator('.mnote-resource-tab-panel:not([hidden]) .editor-surface .ProseMirror[contenteditable="true"]').first();
const dirtyToken = `资源未保存 ${Date.now()}`;
const externalDirtyToken = `资源外部二次更新 ${Date.now()}`;
await editor.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press(process.platform === "darwin" ? "Meta+End" : "Control+End").catch(() => undefined);
await page.keyboard.type(`\n${dirtyToken}`, { delay: 2 });
fs.writeFileSync(resourcePath, `# Resource Note\n\n${externalDirtyToken}\n`, "utf8");
await page.waitForFunction(
({ dirtyNeedle }) => {
const resourceText = document.querySelector('.mnote-resource-tab-panel:not([hidden]) .editor-surface .ProseMirror')?.textContent || "";
const resourceConflict = document.querySelector('.mnote-resource-tab-panel:not([hidden]) [data-testid="mnote-editor-conflict-panel"]');
const pageConflict = Boolean(document.querySelector('.document-pane[data-pane-role="primary"] > [data-testid="mnote-editor-conflict-panel"]'));
return resourceText.includes(dirtyNeedle)
&& Boolean(resourceConflict)
&& /资源文件已在外部更新|文件冲突/.test(resourceConflict.textContent || "")
&& !pageConflict;
},
{ dirtyNeedle: dirtyToken },
{ timeout: UI_TIMEOUT_MS },
);
result.states.push({ step: "dirty-resource-external-conflict", dirtyToken, externalDirtyToken });
const finalState = await page.evaluate(() => ({
resourceStatus: document.querySelector('.mnote-resource-tab-panel:not([hidden]) [data-runtime-editor-status]')?.getAttribute('data-runtime-editor-status') || "",
resourceText: document.querySelector('.mnote-resource-tab-panel:not([hidden]) .editor-surface .ProseMirror')?.textContent || "",
resourceConflictText: document.querySelector('.mnote-resource-tab-panel:not([hidden]) [data-testid="mnote-editor-conflict-panel"]')?.textContent || "",
pageText: document.querySelector('.document-pane[data-pane-role="primary"] > .document-shell .editor-surface .ProseMirror')?.textContent || "",
pageConflictVisible: Boolean(document.querySelector('.document-pane[data-pane-role="primary"] > [data-testid="mnote-editor-conflict-panel"]')),
sessions: typeof window.__mnoteDebugDocumentSessions?.snapshot === "function"
? window.__mnoteDebugDocumentSessions.snapshot()
: null,
}));
assert.equal(finalState.pageConflictVisible, false, `资源冲突不应污染正文页面: ${JSON.stringify(finalState)}`);
assert(finalState.resourceText.includes(dirtyToken), `资源冲突后应保留未保存内容: ${JSON.stringify(finalState)}`);
result.states.push({ step: "final", finalState });
const officeAsset = await uploadLocalAsset(
page,
root,
documentId,
"resource-office.docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
Buffer.from("task505 office initial", "utf8"),
"attachment",
);
const officePath = path.join(root, "README", "resource-office.docx");
await page.evaluate(({ asset, documentId }) => {
window.dispatchEvent(new CustomEvent("tree.asset.open", {
detail: {
assetId: asset.id,
documentId,
title: asset.file_name || "resource-office.docx",
assetType: asset.asset_type || "attachment",
},
}));
}, { asset: officeAsset, documentId });
await page.locator('.mnote-resource-tab-panel:not([hidden]) iframe.mnote-resource-tab-frame').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.locator('.mnote-resource-tab-panel:not([hidden])[data-mnote-resource-watch-ready="true"]').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
const officeFrameBefore = await page.locator('.mnote-resource-tab-panel:not([hidden]) iframe.mnote-resource-tab-frame').first().getAttribute("src");
fs.writeFileSync(officePath, "task505 office externally updated", "utf8");
await page.waitForFunction(
(previousSrc) => {
const frame = document.querySelector('.mnote-resource-tab-panel:not([hidden]) iframe.mnote-resource-tab-frame');
const src = frame instanceof HTMLIFrameElement ? String(frame.getAttribute('src') || frame.src || '') : '';
return src && src !== previousSrc && src.includes('mnoteResourceReload=');
},
officeFrameBefore,
{ timeout: UI_TIMEOUT_MS },
);
result.states.push({ step: "passive-office-external-update", officeFrameBefore });
fs.unlinkSync(officePath);
await page.locator('.mnote-resource-tab-panel:not([hidden]) [data-mnote-resource-missing="true"]').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
const officeMissingState = await page.evaluate(() => ({
activeKind: document.querySelector('.mnote-main-tab.is-active')?.getAttribute('data-mnote-tab-kind') || '',
missingText: document.querySelector('.mnote-resource-tab-panel:not([hidden]) [data-mnote-resource-missing="true"]')?.textContent || '',
pageConflictVisible: Boolean(document.querySelector('.document-pane[data-pane-role="primary"] > [data-testid="mnote-editor-conflict-panel"]')),
}));
assert.equal(officeMissingState.pageConflictVisible, false, `Office 删除态不应污染正文页面: ${JSON.stringify(officeMissingState)}`);
result.states.push({ step: "passive-office-delete-missing", officeMissingState });
console.log(JSON.stringify({ ok: true, ...result }, null, 2));
} catch (error) {
const diagnostics = await page.evaluate(() => ({
url: location.href,
resourceText: document.querySelector('.mnote-resource-tab-panel:not([hidden]) .editor-surface .ProseMirror')?.textContent || "",
resourceStatus: document.querySelector('.mnote-resource-tab-panel:not([hidden]) [data-runtime-editor-status]')?.getAttribute('data-runtime-editor-status') || "",
resourceConflictText: document.querySelector('.mnote-resource-tab-panel:not([hidden]) [data-testid="mnote-editor-conflict-panel"]')?.textContent || "",
pageText: document.querySelector('.document-pane[data-pane-role="primary"] > .document-shell .editor-surface .ProseMirror')?.textContent || "",
pageConflictVisible: Boolean(document.querySelector('.document-pane[data-pane-role="primary"] > [data-testid="mnote-editor-conflict-panel"]')),
sessions: typeof window.__mnoteDebugDocumentSessions?.snapshot === "function"
? window.__mnoteDebugDocumentSessions.snapshot()
: null,
})).catch((diagnosticsError) => ({ diagnosticsError: String(diagnosticsError) }));
console.error(JSON.stringify({ ok: false, ...result, diagnostics, error: String(error && error.stack || error) }, null, 2));
process.exitCode = 1;
} finally {
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,179 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
function writeWorkspaceManifest(root, ownerId) {
const metadataDir = path.join(root, ".mnote");
fs.mkdirSync(metadataDir, { recursive: true });
fs.writeFileSync(
path.join(metadataDir, "workspace.json"),
`${JSON.stringify({
workspaceId: `local-ws:${ownerId}:task506`,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "markdown_edit", "asset_upload"],
}, null, 2)}\n`,
"utf8",
);
}
async function quickLogin(page) {
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
if (await quickLoginButton.count()) {
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
}
}
async function openDocument(page, root, relativePath) {
await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function waitForDeletedConflict(page, retainedText) {
await page.waitForFunction(
(needle) => {
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editorText = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "";
const panelText = document.querySelector('[data-testid="mnote-editor-conflict-panel"]')?.textContent || "";
const snapshot = typeof window.__mnoteDebugDocumentSessions?.snapshot === "function"
? window.__mnoteDebugDocumentSessions.snapshot()
: null;
const envelopeDirtyState = snapshot?.sessions?.[0]?.lastExternalConflictEnvelope?.dirtyState || "";
return root?.getAttribute("data-runtime-editor-status") === "external-change-conflict"
&& editorText.includes(needle)
&& panelText.includes("已被删除")
&& envelopeDirtyState === "Deleted";
},
retainedText,
{ timeout: UI_TIMEOUT_MS },
);
return await page.evaluate(() => ({
status: document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]')?.getAttribute("data-runtime-editor-status") || "",
editorText: document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "",
panelText: document.querySelector('[data-testid="mnote-editor-conflict-panel"]')?.textContent || "",
sessions: typeof window.__mnoteDebugDocumentSessions?.snapshot === "function"
? window.__mnoteDebugDocumentSessions.snapshot()
: null,
}));
}
async function waitForExternalMoveConflict(page, retainedText) {
await page.waitForFunction(
(needle) => {
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editorText = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "";
const panelText = document.querySelector('[data-testid="mnote-editor-conflict-panel"]')?.textContent || "";
return root?.getAttribute("data-runtime-editor-status") === "external-change-conflict"
&& editorText.includes(needle)
&& panelText.includes("文件冲突");
},
retainedText,
{ timeout: UI_TIMEOUT_MS },
);
return await page.evaluate(() => ({
status: document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]')?.getAttribute("data-runtime-editor-status") || "",
editorText: document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "",
panelText: document.querySelector('[data-testid="mnote-editor-conflict-panel"]')?.textContent || "",
sessions: typeof window.__mnoteDebugDocumentSessions?.snapshot === "function"
? window.__mnoteDebugDocumentSessions.snapshot()
: null,
}));
}
async function main() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task506-lifecycle-"));
const deleteRelativePath = "DeleteMe.md";
const moveRelativePath = "MoveMe.md";
writeWorkspaceManifest(root, "mnote-e2e");
fs.writeFileSync(path.join(root, deleteRelativePath), "# Delete Me\n\n保留删除 buffer\n", "utf8");
fs.writeFileSync(path.join(root, moveRelativePath), "# Move Me\n\n保留移动 buffer\n", "utf8");
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
executablePath: CHROMIUM_EXECUTABLE_PATH,
});
const context = await browser.newContext({
viewport: { width: 1366, height: 900 },
extraHTTPHeaders: {
"x-mnote-actor-id": "mnote-e2e",
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
try {
await quickLogin(page);
await openDocument(page, root, deleteRelativePath);
fs.rmSync(path.join(root, deleteRelativePath));
const deletedState = await waitForDeletedConflict(page, "保留删除 buffer");
await openDocument(page, root, moveRelativePath);
fs.renameSync(path.join(root, moveRelativePath), path.join(root, "Moved.md"));
const movedState = await waitForExternalMoveConflict(page, "保留移动 buffer");
console.log(JSON.stringify({
ok: true,
root,
states: [
{ step: "external-delete", deletedState },
{ step: "external-rename-as-deleted", movedState },
],
}, null, 2));
} catch (error) {
const diagnostics = await page.evaluate(() => ({
url: location.href,
status: document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]')?.getAttribute("data-runtime-editor-status") || "",
statusError: document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]')?.getAttribute("data-runtime-editor-error") || "",
editorText: document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "",
panelText: document.querySelector('[data-testid="mnote-editor-conflict-panel"]')?.textContent || "",
sessions: typeof window.__mnoteDebugDocumentSessions?.snapshot === "function"
? window.__mnoteDebugDocumentSessions.snapshot()
: null,
})).catch((diagnosticsError) => ({ diagnosticsError: String(diagnosticsError) }));
console.error(JSON.stringify({ ok: false, root, diagnostics, error: String(error && error.stack || error) }, null, 2));
process.exitCode = 1;
} finally {
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,252 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
function writeWorkspaceManifest(root, ownerId) {
const metadataDir = path.join(root, ".mnote");
fs.mkdirSync(metadataDir, { recursive: true });
fs.writeFileSync(
path.join(metadataDir, "workspace.json"),
`${JSON.stringify({
workspaceId: `local-ws:${ownerId}:task508`,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "markdown_edit", "asset_upload"],
}, null, 2)}\n`,
"utf8",
);
}
async function openDocument(page, root, relativePath) {
await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function typeEditorText(page, text) {
const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first();
await editor.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press(process.platform === "darwin" ? "Meta+End" : "Control+End").catch(() => undefined);
await page.keyboard.type(`\n${text}`, { delay: 2 });
}
async function waitForEditorText(page, text) {
await page.waitForFunction(
(needle) => {
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
return (editor?.textContent || "").includes(needle);
},
text,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForFileText(filePath, text) {
const deadline = Date.now() + UI_TIMEOUT_MS;
while (Date.now() < deadline) {
const content = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
if (content.includes(text)) return content;
await new Promise((resolve) => setTimeout(resolve, 120));
}
throw new Error(`文件未出现期望内容: ${text}`);
}
async function withTimeout(promise, timeoutMs, diagnostics) {
let timer = 0;
try {
return await Promise.race([
promise,
new Promise((_, reject) => {
timer = setTimeout(async () => {
let detail = null;
if (typeof diagnostics === "function") {
try {
detail = await diagnostics();
} catch (error) {
detail = { diagnosticsError: error && error.message ? error.message : String(error) };
}
}
reject(new Error(`等待超时: ${JSON.stringify(detail)}`));
}, timeoutMs);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
async function installDelayedSave(page) {
let release = null;
const gate = {
intercepted: false,
status: 0,
body: "",
release: () => release && release(),
interceptedPromise: null,
};
gate.interceptedPromise = new Promise((resolve) => {
gate.markIntercepted = resolve;
});
const releasePromise = new Promise((resolve) => {
release = resolve;
});
await page.route("**/api/page-body/write", async (route) => {
if (!gate.intercepted) {
gate.intercepted = true;
gate.markIntercepted();
await releasePromise;
const response = await route.fetch();
gate.status = response.status();
gate.body = await response.text();
await route.fulfill({
status: gate.status,
headers: response.headers(),
body: gate.body,
});
return;
}
await route.continue();
});
return gate;
}
async function main() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task508-multitab-"));
const relativePath = "Concurrent.md";
const markdownPath = path.join(root, relativePath);
writeWorkspaceManifest(root, "mnote-e2e");
fs.writeFileSync(markdownPath, "# Concurrent\n\n初始正文\n", "utf8");
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
executablePath: CHROMIUM_EXECUTABLE_PATH,
});
const contextA = await browser.newContext({
viewport: { width: 1280, height: 860 },
extraHTTPHeaders: { "x-mnote-actor-id": "mnote-e2e", "x-mnote-actor-type": "user" },
});
const contextB = await browser.newContext({
viewport: { width: 1280, height: 860 },
extraHTTPHeaders: { "x-mnote-actor-id": "mnote-e2e", "x-mnote-actor-type": "user" },
});
const pageA = await contextA.newPage();
const pageB = await contextB.newPage();
const pageBEvents = [];
pageB.on("request", (request) => {
const url = request.url();
if (url.includes("/api/page-body/write") || url.includes("/api/documents/save") || url.includes("/api/documents/buffer-state") || url.includes("/api/page-aggregate")) {
pageBEvents.push({ type: "request", method: request.method(), url });
}
});
pageB.on("response", (response) => {
const url = response.url();
if (url.includes("/api/page-body/write") || url.includes("/api/documents/save") || url.includes("/api/documents/buffer-state") || url.includes("/api/page-aggregate")) {
pageBEvents.push({ type: "response", status: response.status(), url });
}
});
pageB.on("console", (message) => {
const text = message.text();
if (text.includes("mnote") || text.includes("conflict")) {
pageBEvents.push({ type: "console", level: message.type(), text });
}
});
try {
await Promise.all([
openDocument(pageA, root, relativePath),
openDocument(pageB, root, relativePath),
]);
const cleanToken = `A-clean-sync-${Date.now()}`;
await typeEditorText(pageA, cleanToken);
await waitForFileText(markdownPath, cleanToken);
await waitForEditorText(pageB, cleanToken);
const gate = await installDelayedSave(pageB);
const dirtyToken = `B-dirty-pending-${Date.now()}`;
await typeEditorText(pageB, dirtyToken);
await withTimeout(gate.interceptedPromise, UI_TIMEOUT_MS, async () => {
const state = await pageB.evaluate(() => ({
status: document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]')?.getAttribute("data-runtime-editor-status") || "",
text: document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "",
sessions: typeof window.__mnoteDebugDocumentSessions?.snapshot === "function"
? window.__mnoteDebugDocumentSessions.snapshot()
: null,
}));
return { ...state, pageBEvents };
});
const winnerToken = `A-winner-${Date.now()}`;
await typeEditorText(pageA, winnerToken);
await waitForFileText(markdownPath, winnerToken);
gate.release();
await pageB.waitForFunction(
(needle) => {
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
return root?.getAttribute("data-runtime-editor-status") === "external-change-conflict"
&& (editor?.textContent || "").includes(needle);
},
dirtyToken,
{ timeout: UI_TIMEOUT_MS },
);
assert.equal(gate.status, 409, `B tab stale save 应返回 409,实际 ${gate.status}: ${gate.body}`);
const finalB = await pageB.evaluate(() => ({
status: document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]')?.getAttribute("data-runtime-editor-status") || "",
text: document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "",
conflictText: document.querySelector('[data-testid="mnote-editor-conflict-panel"]')?.textContent || "",
sessions: typeof window.__mnoteDebugDocumentSessions?.snapshot === "function"
? window.__mnoteDebugDocumentSessions.snapshot()
: null,
}));
console.log(JSON.stringify({
ok: true,
root,
states: [
{ step: "clean-tab-auto-sync", cleanToken },
{ step: "dirty-tab-save-409", dirtyToken, winnerToken, gateStatus: gate.status, finalB },
],
}, null, 2));
} finally {
await contextA.close().catch(() => undefined);
await contextB.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,206 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const PPTX_PATH = process.env.MNOTE_UPLOAD_TEST_FILE || "/home/lix/Downloads/1768096586803-672be7a7-5cb3-454d-9b1b-6230f86ba392.pptx";
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
function writeWorkspaceManifest(root, ownerId) {
const metadataDir = path.join(root, ".mnote");
fs.mkdirSync(metadataDir, { recursive: true });
fs.writeFileSync(
path.join(metadataDir, "workspace.json"),
`${JSON.stringify({
workspaceId: `local-ws:${ownerId}:task509`,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "markdown_edit", "asset_upload"],
}, null, 2)}\n`,
"utf8",
);
}
async function openDocument(page, root, relativePath) {
await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function uploadAttachmentViaSlash(page, filePath) {
const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first();
await editor.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press("End").catch(() => undefined);
await page.keyboard.type("/");
const item = page.locator('.document-pane[data-pane-role="primary"] [data-testid="slash-item-upload-attachment"]').first();
await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const [fileChooser] = await Promise.all([
page.waitForEvent("filechooser", { timeout: UI_TIMEOUT_MS }),
item.click({ timeout: UI_TIMEOUT_MS }),
]);
await fileChooser.setFiles(filePath);
}
async function typeEditorText(page, text) {
const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first();
await editor.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press(process.platform === "darwin" ? "Meta+End" : "Control+End").catch(() => undefined);
await page.keyboard.type(`\n${text}`, { delay: 2 });
}
async function waitForEditorStatus(page, status) {
await page.waitForFunction(
(expected) => {
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
return root?.getAttribute("data-runtime-editor-status") === expected;
},
status,
{ timeout: UI_TIMEOUT_MS },
);
}
async function main() {
assert(fs.existsSync(PPTX_PATH), `测试 pptx 不存在: ${PPTX_PATH}`);
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task509-upload-409-"));
const relativePath = "README/README.md";
writeWorkspaceManifest(root, "mnote-e2e");
fs.mkdirSync(path.join(root, "README"), { recursive: true });
fs.writeFileSync(path.join(root, relativePath), "# Upload 409\n\n正文\n", "utf8");
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
executablePath: CHROMIUM_EXECUTABLE_PATH,
});
const context = await browser.newContext({
viewport: { width: 1366, height: 900 },
extraHTTPHeaders: { "x-mnote-actor-id": "mnote-e2e", "x-mnote-actor-type": "user" },
});
const page = await context.newPage();
let interceptedSave = null;
await page.route("**/api/documents/save", async (route) => {
const payload = JSON.parse(route.request().postData() || "{}");
if (payload.editorSource === "local-upload-runtime") {
interceptedSave = {
expectedFileVersion: String(payload.expectedFileVersion || ""),
writeIntentId: String(payload.writeIntentId || ""),
saveOperationId: String(payload.saveOperationId || ""),
};
await route.fulfill({
status: 409,
contentType: "application/json",
body: JSON.stringify({
ok: false,
error: {
message: "本地 Markdown 文件已在外部更新,请刷新或保存前先处理冲突",
code: "local_markdown_external_change",
},
conflict: {
code: "local_markdown_external_change",
currentDiskVersion: "simulated-disk-version",
editorBaseVersion: payload.expectedFileVersion || "",
},
}),
});
return;
}
await route.continue();
});
try {
await openDocument(page, root, relativePath);
await uploadAttachmentViaSlash(page, PPTX_PATH);
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-last-upload-orphaned-policy") === "asset-kept-reference-unsaved-retry-required",
null,
{ timeout: UI_TIMEOUT_MS },
);
const state = await page.evaluate(() => ({
saveError: document.documentElement.getAttribute("data-mnote-last-upload-save-error") || "",
orphanedAssetId: document.documentElement.getAttribute("data-mnote-last-upload-orphaned-asset-id") || "",
orphanedPolicy: document.documentElement.getAttribute("data-mnote-last-upload-orphaned-policy") || "",
inserted: document.documentElement.getAttribute("data-mnote-last-upload-inserted") || "",
links: Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a')).map((link) => ({
text: link.textContent || "",
href: link.getAttribute("href") || "",
})),
}));
assert(interceptedSave?.expectedFileVersion, `local-upload save 应携带 expectedFileVersion: ${JSON.stringify(interceptedSave)}`);
assert(interceptedSave?.writeIntentId && interceptedSave?.saveOperationId, `local-upload save 应携带写入意图: ${JSON.stringify(interceptedSave)}`);
assert(state.saveError.includes("本地 Markdown 文件已在外部更新"), `应显示保存冲突错误: ${JSON.stringify(state)}`);
assert(state.orphanedAssetId, `409 后应标记已落盘孤儿附件: ${JSON.stringify(state)}`);
assert(state.links.some((link) => /\.pptx/i.test(link.text)), `编辑器中应保留未落盘引用以便用户重试/处理: ${JSON.stringify(state)}`);
const assetPath = path.join(root, "README", path.basename(PPTX_PATH));
assert(fs.existsSync(assetPath), `附件文件应已落盘并由 orphan policy 显式保留: ${assetPath}`);
const blockedRelativePath = "Blocked.md";
const blockedPath = path.join(root, blockedRelativePath);
const blockedToken = `blocked-conflict-${Date.now()}`;
fs.writeFileSync(blockedPath, "# Blocked\n\n初始正文\n", "utf8");
await openDocument(page, root, blockedRelativePath);
await typeEditorText(page, blockedToken);
fs.writeFileSync(blockedPath, `# Blocked\n\n外部版本 ${blockedToken}\n`, "utf8");
await waitForEditorStatus(page, "external-change-conflict");
await page.evaluate(() => {
document.documentElement.removeAttribute("data-mnote-last-upload-blocked-conflict");
});
const beforeBlockedFiles = new Set(fs.readdirSync(root));
await uploadAttachmentViaSlash(page, PPTX_PATH);
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-last-upload-blocked-conflict") === "true",
null,
{ timeout: UI_TIMEOUT_MS },
);
const afterBlockedFiles = new Set(fs.readdirSync(root));
assert.deepEqual(
[...afterBlockedFiles].sort(),
[...beforeBlockedFiles].sort(),
"session 已冲突时不应继续写入新的附件文件",
);
const blockedState = await page.evaluate(() => ({
blockedConflict: document.documentElement.getAttribute("data-mnote-last-upload-blocked-conflict") || "",
saveError: document.documentElement.getAttribute("data-mnote-last-upload-save-error") || "",
}));
assert.equal(blockedState.blockedConflict, "true", `冲突 session 应阻断上传: ${JSON.stringify(blockedState)}`);
assert(blockedState.saveError.includes("冲突"), `冲突 session 阻断应给出提示: ${JSON.stringify(blockedState)}`);
console.log(JSON.stringify({ ok: true, root, assetPath, interceptedSave, state, blockedState }, null, 2));
} finally {
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,44 @@
#!/usr/bin/env node
"use strict";
const { spawnSync } = require("node:child_process");
const path = require("node:path");
const DEFAULT_TASKS = [
"scripts/task451-local-markdown-conflict-resolution-ui-smoke.js",
"scripts/task503-local-pptx-upload-filetree-open-smoke.js",
"scripts/task458-local-create-page-no-conflict-smoke.js",
"scripts/task504-local-md-external-conflict-recovery-smoke.js",
"scripts/task508-local-md-multitab-conflict-smoke.js",
"scripts/task509-local-upload-save-409-orphan-smoke.js",
];
function requestedTasks() {
const raw = String(process.env.MNOTE_CONFLICT_REGRESSION_TASKS || "").trim();
if (!raw) return DEFAULT_TASKS;
return raw.split(",").map((item) => item.trim()).filter(Boolean);
}
function runTask(scriptPath) {
const absolute = path.resolve(process.cwd(), scriptPath);
const result = spawnSync(process.execPath, [absolute], {
cwd: process.cwd(),
env: process.env,
stdio: "inherit",
});
if (result.error) throw result.error;
if (result.status !== 0) {
throw new Error(`${scriptPath} failed with status ${result.status}`);
}
}
function main() {
const tasks = requestedTasks();
for (const task of tasks) {
console.log(`[task510] running ${task}`);
runTask(task);
}
console.log(JSON.stringify({ ok: true, tasks }, null, 2));
}
main();