diff --git a/bugs/05-editor-mainline/done/5-34-starred-scope-pagetree-and-local-edit-save-v1.md b/bugs/05-editor-mainline/done/5-34-starred-scope-pagetree-and-local-edit-save-v1.md new file mode 100644 index 00000000..89605d52 --- /dev/null +++ b/bugs/05-editor-mainline/done/5-34-starred-scope-pagetree-and-local-edit-save-v1.md @@ -0,0 +1,24 @@ +# 5-34 [done] 星标 scoped 文件夹 PageTree 错位与本地 Markdown 编辑不保存 + +## 现象 + +- 点击星标置顶的本地 `design` 文件夹后,FileTree 已切到 `design` scope,但 PageTree 仍显示旧 workspace/root 页面树或为空。 +- 在“我的空间”本地 Markdown 页面输入 `111` 后,切换页面再回来内容消失。 + +## 根因 + +- 星标文件夹打开时,`/api/tree/projections/file` 已带 `parentRelativePath=design`,但 `/api/tree/projections/sidebar` 没有带同一 scope;后端 PageTree projection 也没有按 `parentRelativePath` 扫描子目录。 +- `document-session-runtime.js` 拆分后继续直接使用 `hydrateMindmapAttrsFromDom` / `textToTiptapDocument`,但文件自身未导入这两个 conversion helper。编辑器变更能进入 dirty session,但保存计时器执行时抛出 `hydrateMindmapAttrsFromDom is not defined`,导致 `/api/page-body/write` 根本没有发出。 + +## 修复 + +- 星标 scoped folder 打开时同时给 sidebar projection 传 `parentRelativePath`,并确保 cloud/default 页面缺少 `#sidebar-tree-root` 时能在 PageTree panel 内创建 scoped PageTree host。 +- 后端新增 scoped local PageTree snapshot:按 `rootUri + parentRelativePath` 扫描并缓存,`/api/tree/projections/sidebar` 在 local folder + `parentRelativePath` 下返回 scoped PageTree。 +- `document-session-runtime.js` 显式导入 `hydrateMindmapAttrsFromDom` 与 `textToTiptapDocument`,恢复 dirty session 自动保存链路。 + +## 验证 + +- RED:`node scripts/task498-starred-page-tree-scope-and-local-edit-smoke.js` 先失败于 scoped PageTree 未显示 `Brief`,修复 PageTree scope 后再失败于保存阶段 `hydrateMindmapAttrsFromDom is not defined`。 +- GREEN:`node scripts/task498-starred-page-tree-scope-and-local-edit-smoke.js` 通过,覆盖星标 `design` 后 PageTree 只显示 design 内 Markdown、输入 `111` 后 `/api/page-body/write` 成功、磁盘 `Home.md` 包含 `111`、切到 `Other.md` 再回 `Home.md` 仍保留。 +- 回归:`node scripts/task492-sidebar-starred-shortcuts-smoke.js`、`node scripts/task494-filetree-lazy-loading-dedup-smoke.js`、`node scripts/task497-local-page-tree-filetree-open-performance-smoke.js` 通过。 +- Rust:`local_folder_page_projection_can_scope_by_parent_path`、`local_folder_page_tree_snapshot_reuses_cache_until_watch_revision_changes`、`tree_projection_routes` 通过。 diff --git a/design/05-editor-mainline/process/5-32-filetree-lazy-loading-sidex-alignment-v1.md b/design/05-editor-mainline/process/5-32-filetree-lazy-loading-sidex-alignment-v1.md index b4a4aea2..507d9c2b 100644 --- a/design/05-editor-mainline/process/5-32-filetree-lazy-loading-sidex-alignment-v1.md +++ b/design/05-editor-mainline/process/5-32-filetree-lazy-loading-sidex-alignment-v1.md @@ -67,6 +67,7 @@ - [x] 新增 `task497-local-page-tree-filetree-open-performance-smoke.js`:用真实 Chromium 从首页进入本地 workspace,点击星标 `design` 文件夹,再通过 Explorer 逐级打开 `design/05-editor-mainline/process/5-32-filetree-lazy-loading-sidex-alignment-v1.md`;记录 sidebar/filetree/page aggregate/editor ready 时序、network/resource timing、long task,并保存打开前后截图。 - [x] `task497` 不允许只用 `context.request.fetch` 或直接 `page.goto(最终文档 URL)` 作为主验收;可以用 direct request 作为对照指标,但真实验收必须来自 UI 点击路径。 - [x] `task497` 断言打开过程中旧“我的空间”页面树不会长期残留、scoped FileTree 先可交互、editor surface 可见,且目标 md 打开不会因为 page tree projection 全量重建而明显延迟。 +- [x] 新增 `task498-starred-page-tree-scope-and-local-edit-smoke.js`:覆盖星标 scoped folder 打开后 PageTree 使用同一 scope,且本地 Markdown 输入 `111` 后经 `/api/page-body/write` 保存,切页再回不丢内容。 - [x] `gateway.rs` 本地 root 分支复用已加载的 page tree snapshot,避免同一请求重复调用 `load_local_folder_page_tree_snapshot(root_uri)`。 - [x] `sidebar-tree-runtime.js` 星标文件夹打开改为并行请求 sidebar projection 与 scoped file projection;file projection 可以先应用,sidebar projection 后补页面树。 - [x] `sidebar-tree-live-apply-runtime.js` 拆出 page/file projection 独立 apply,并为二者加入 generation / stale guard。 @@ -354,6 +355,8 @@ scripts/task494-filetree-lazy-loading-dedup-smoke.js - 2026-05-26:`codegraph sync .` 已执行;`codegraph status .` 仍报告 `Pending Changes: Added: 5 files`,属于当前工作树既有未提交/新增文件状态,本轮不清理。 - 2026-05-27:`task492` RED 先失败于星标文件夹等待 sidebar projection,GREEN 后 `node scripts/task492-sidebar-starred-shortcuts-smoke.js` 通过,确认 scoped FileTree 先于延迟页面树可交互。 - 2026-05-27:`task497` RED 先失败于真实点击星标 `design` 后 scoped FileTree 未先完成;GREEN 后 `node scripts/task497-local-page-tree-filetree-open-performance-smoke.js` 通过。最终样本:`filetreeInteractiveAt=1779857492435`,`targetClickAt=1779857492621`,`pageAggregateResponseAt=1779857492667`,`editorVisibleAt=1779857492823`,`longTasks=[]`,`directDocumentMs=8`;截图保存在 `tmp/task497-local-page-tree-filetree-open-performance-smoke/`。 +- 2026-05-27:`task498` RED 先失败于星标 `design` 后 PageTree 为空 / 未按 scope 替换;补 scoped `/api/tree/projections/sidebar?parentRelativePath=design` 后进入保存段,再 RED 失败于 `hydrateMindmapAttrsFromDom is not defined`,确认 dirty session 保存计时器抛错导致 `/api/page-body/write` 未发出。GREEN 后 `node scripts/task498-starred-page-tree-scope-and-local-edit-smoke.js` 通过,覆盖 PageTree scope、保存到磁盘、切换 `Other.md` 再回 `Home.md` 仍保留 `111`。 +- 2026-05-27:复跑 `node scripts/task497-local-page-tree-filetree-open-performance-smoke.js` 通过。最终样本:`filetreeInteractiveAt=1779860840801`,`targetClickAt=1779860840985`,`pageAggregateResponseAt=1779860841032`,`editorVisibleAt=1779860841196`,`longTasks=[]`,`directDocumentMs=7`;scoped PageTree 断言改为检查 design scope 内本地页面行,而不是要求父目录名 `design` 必须作为可见行出现。 - 2026-05-27:Rust filters 通过:`root_entry_local_folder_reuses_page_tree_snapshot_for_sidebar_html`、`sidebar_runtime_applies_page_projection_with_generation_and_yield`、`sidebar_filetree_runtime`、`local_folder_file_projection`、`tree_projection_routes`、`local_folder_file_tree_children_snapshot_scans_parent_once`、`local_folder_page_tree_snapshot_reuses_cache_until_watch_revision_changes`。 - 2026-05-27:`node scripts/task494-filetree-lazy-loading-dedup-smoke.js` 通过,`targetChildrenRequests=1`,`staleScopeChildrenRequests=1`,`cachedExpandMs=33`。 diff --git a/rust/crates/mnote-web/browser/document-session-runtime.js b/rust/crates/mnote-web/browser/document-session-runtime.js index 0671c8f7..40050a4a 100644 --- a/rust/crates/mnote-web/browser/document-session-runtime.js +++ b/rust/crates/mnote-web/browser/document-session-runtime.js @@ -2,9 +2,11 @@ import { conflictDetectionKeyFromBody, editorDocumentFromTiptapDocument, flattenText, + hydrateMindmapAttrsFromDom, legacyBlocksFromEditorDocument, pageBodyTiptapDocument, revisionFromConflictKey, + textToTiptapDocument, toTiptapDocument, } from './document-tiptap-conversion-runtime.js'; diff --git a/rust/crates/mnote-web/browser/sidebar-tree-runtime.js b/rust/crates/mnote-web/browser/sidebar-tree-runtime.js index c54198ea..2523108b 100644 --- a/rust/crates/mnote-web/browser/sidebar-tree-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-tree-runtime.js @@ -687,6 +687,23 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim return root; } + function ensureStarredFolderPageTreeHost(workspaceId) { + var root = document.getElementById('sidebar-tree-root'); + if (root instanceof HTMLElement) return root; + var panel = document.getElementById('wolai-sidebar-page-tree-panel') || document.querySelector('[data-mnote-sidebar-tree-panel="page"]'); + if (!(panel instanceof HTMLElement)) return null; + var section = document.createElement('div'); + section.className = 'sidebar-tree-section'; + root = document.createElement('div'); + root.id = 'sidebar-tree-root'; + root.className = 'sidebar-tree'; + root.setAttribute('data-tree-shell-mode', 'page'); + if (workspaceId) root.setAttribute('data-workspace-id', workspaceId); + section.appendChild(root); + panel.appendChild(section); + return root; + } + function persistStarredFolderScope(workspaceId, rootUri, relativePath) { var targetUrl = new URL(window.location.href); if (workspaceId) targetUrl.searchParams.set('workspaceId', workspaceId); @@ -719,17 +736,23 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim var tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"]'); if (tab instanceof HTMLElement) switchSidebarTreeTab(tab); var root = ensureStarredFolderFileTreeHost(workspaceId); + var pageRoot = ensureStarredFolderPageTreeHost(workspaceId); if (root instanceof HTMLElement) { root.setAttribute('data-workspace-id', workspaceId); root.setAttribute('data-mnote-filetree-scope', relativePath); root.setAttribute('data-mnote-filetree-scope-title', row.textContent ? row.textContent.trim() : relativePath); } + if (pageRoot instanceof HTMLElement) { + pageRoot.setAttribute('data-workspace-id', workspaceId); + pageRoot.setAttribute('data-mnote-page-tree-scope', relativePath); + } persistStarredFolderScope(workspaceId, rootUri, relativePath); document.documentElement.setAttribute('data-mnote-filetree-scope', relativePath); var sidebarUrl = new URL('/api/tree/projections/sidebar', window.location.origin); sidebarUrl.searchParams.set('workspaceId', workspaceId); sidebarUrl.searchParams.set('sourceKind', 'local_folder'); sidebarUrl.searchParams.set('rootUri', rootUri); + sidebarUrl.searchParams.set('parentRelativePath', relativePath); var url = new URL('/api/tree/projections/file', window.location.origin); url.searchParams.set('workspaceId', workspaceId); url.searchParams.set('sourceKind', 'local_folder'); diff --git a/rust/crates/mnote-web/src/routes/kernel.rs b/rust/crates/mnote-web/src/routes/kernel.rs index ecc3d8f5..110008ca 100644 --- a/rust/crates/mnote-web/src/routes/kernel.rs +++ b/rust/crates/mnote-web/src/routes/kernel.rs @@ -4,7 +4,7 @@ use crate::error::WebError; use crate::routes::local_folder_source::{ ensure_local_workspace_read_access_with_state, load_local_folder_file_tree_children_snapshot, load_local_folder_file_tree_snapshot, load_local_folder_file_tree_snapshot_with_reveal, - load_local_folder_page_tree_snapshot, + load_local_folder_page_tree_scope_snapshot, load_local_folder_page_tree_snapshot, }; use crate::routes::query_support::resolve_effective_workspace_id; use crate::routes::snapshot_support::{ @@ -120,6 +120,8 @@ async fn project_projection( } else { load_local_folder_file_tree_snapshot(&root_uri) } + } else if let Some(parent_relative_path) = parent_relative_path.as_deref() { + load_local_folder_page_tree_scope_snapshot(&root_uri, parent_relative_path) } else { load_local_folder_page_tree_snapshot(&root_uri) } @@ -422,6 +424,52 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + #[tokio::test] + async fn local_folder_page_projection_can_scope_by_parent_path() { + let root = std::env::temp_dir().join(format!( + "mnote-local-kernel-page-scope-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("design")).expect("create design"); + std::fs::write(root.join("Home.md"), "# Home\n").expect("write home"); + std::fs::write(root.join("Other.md"), "# Other\n").expect("write other"); + std::fs::write(root.join("design").join("Brief.md"), "# Brief\n").expect("write brief"); + let root_uri = format!("file://{}", root.display()); + initialize_local_workspace_for_actor("dev-user", &root_uri).expect("init local workspace"); + + let response = app() + .oneshot( + Request::builder() + .uri(format!( + "/api/tree/projections/sidebar?workspaceId=local-ws:dev-user:my-space&sourceKind=local_folder&rootUri={root_uri}&parentRelativePath=design" + )) + .header("x-mnote-actor-id", "dev-user") + .header("x-mnote-actor-type", "user") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + let items = payload["result"]["items"].as_array().expect("items"); + let titles = items + .iter() + .filter_map(|item| item["title"].as_str()) + .collect::>(); + assert_eq!(payload["result"]["parentRelativePath"], "design"); + assert!(titles.contains(&"Brief")); + assert!(!titles.contains(&"Home")); + assert!(!titles.contains(&"Other")); + + let _ = std::fs::remove_dir_all(&root); + } + #[tokio::test] async fn local_folder_file_projection_can_load_direct_children_by_parent_path() { let root = std::env::temp_dir().join(format!( diff --git a/rust/crates/mnote-web/src/routes/local_folder_source.rs b/rust/crates/mnote-web/src/routes/local_folder_source.rs index a60240a1..73b697b5 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_source.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_source.rs @@ -2767,6 +2767,20 @@ fn load_local_folder_file_tree_scope_snapshot( pub fn load_local_folder_page_tree_snapshot( root_uri: &str, +) -> Result { + load_local_folder_page_tree_snapshot_for_scope(root_uri, None) +} + +pub fn load_local_folder_page_tree_scope_snapshot( + root_uri: &str, + parent_relative_path: &str, +) -> Result { + load_local_folder_page_tree_snapshot_for_scope(root_uri, Some(parent_relative_path)) +} + +fn load_local_folder_page_tree_snapshot_for_scope( + root_uri: &str, + parent_relative_path: Option<&str>, ) -> Result { #[cfg(test)] LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); @@ -2787,9 +2801,25 @@ pub fn load_local_folder_page_tree_snapshot( let workspace_id = local_workspace_id(&canonical_root); let root_source_uri = file_uri_for_path(&canonical_root); + let parent_relative_path = parent_relative_path + .map(str::trim) + .filter(|value| !value.is_empty() && *value != ".") + .unwrap_or(""); + let scan_root = if parent_relative_path.is_empty() { + canonical_root.clone() + } else { + resolve_metadata_relative_path(&canonical_root, parent_relative_path)? + }; + if !scan_root.is_dir() { + return Err(WebError::bad_request_code( + "local_folder_parent_not_directory", + "PageTree scope parentRelativePath 必须指向目录", + )); + } let watch_revision = local_folder_watch_revision_for_root(&canonical_root, &root_source_uri)?; + let cache_key = format!("{root_source_uri}\n{parent_relative_path}"); if let Ok(cache) = local_page_tree_snapshot_cache().lock() { - if let Some(entry) = cache.get(&root_source_uri) { + if let Some(entry) = cache.get(&cache_key) { if entry.watch_revision.revision == watch_revision.revision && entry.watch_revision.entry_count == watch_revision.entry_count && entry.watch_revision.latest_modified_ms == watch_revision.latest_modified_ms @@ -2804,7 +2834,7 @@ pub fn load_local_folder_page_tree_snapshot( LOCAL_PAGE_TREE_SNAPSHOT_SCAN_TEST_LOADS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); scan_markdown_page_tree( &canonical_root, - &canonical_root, + &scan_root, None, 0, None, @@ -2859,6 +2889,7 @@ pub fn load_local_folder_page_tree_snapshot( "projection": "page_tree", "sourceKind": "local_folder", "rootUri": root_source_uri, + "parentRelativePath": parent_relative_path, "watchRevision": watch_revision, "items": items, }), @@ -2868,7 +2899,7 @@ pub fn load_local_folder_page_tree_snapshot( cache.clear(); } cache.insert( - root_source_uri, + cache_key, LocalPageTreeSnapshotCacheEntry { watch_revision, snapshot: snapshot.clone(), diff --git a/scripts/task497-local-page-tree-filetree-open-performance-smoke.js b/scripts/task497-local-page-tree-filetree-open-performance-smoke.js index 44502148..f81ae224 100644 --- a/scripts/task497-local-page-tree-filetree-open-performance-smoke.js +++ b/scripts/task497-local-page-tree-filetree-open-performance-smoke.js @@ -324,7 +324,7 @@ async function main() { if (!(root instanceof HTMLElement)) return false; const stale = root.querySelectorAll('.tree-row[data-shell-mode="page"][data-node-id="stale-my-space-page"]').length; const localRows = root.querySelectorAll('.tree-row[data-shell-mode="page"]').length; - return stale === 0 && localRows > 0 && /design/.test(root.innerText || ""); + return stale === 0 && localRows > 0 && /05-editor-mainline|04-tree-domain|5-32 target|Reference|Other/.test(root.innerText || ""); }, { timeout: UI_TIMEOUT_MS }); const staleRowsAfterStarredOpen = await page.locator('#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="stale-my-space-page"]').count(); assert.equal(staleRowsAfterStarredOpen, 0, "星标 design 打开后旧我的空间页面树不应长期残留"); diff --git a/scripts/task498-starred-page-tree-scope-and-local-edit-smoke.js b/scripts/task498-starred-page-tree-scope-and-local-edit-smoke.js new file mode 100644 index 00000000..4602211e --- /dev/null +++ b/scripts/task498-starred-page-tree-scope-and-local-edit-smoke.js @@ -0,0 +1,278 @@ +#!/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 = "task498-starred-page-tree-scope-and-local-edit-smoke"; +const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 35_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 retry = () => { + if (Date.now() > deadline) { + reject(new Error(`server_not_ready: ${url}`)); + return; + } + setTimeout(tick, 250); + }; + 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(); + }); + }; + tick(); + }); +} + +function fileUrlToPath(value) { + const url = new URL(value); + return decodeURIComponent(url.pathname); +} + +async function signUp(context, baseUrl, actorId) { + const response = 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(response.ok(), `测试账号注册失败: ${response.status()} ${await response.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 url = new URL(page.url()); + const rootUri = url.searchParams.get("rootUri") + || await page.evaluate(() => document.body.getAttribute("data-mnote-root-uri") || ""); + assert(rootUri, "应进入 local_folder workspace"); + return { + rootUri, + workspaceId: url.searchParams.get("workspaceId") || "", + }; +} + +async function openLocalDocument(page, baseUrl, rootUri, workspaceId, documentId) { + const url = new URL(`/documents/${encodeURIComponent(documentId)}`, baseUrl); + if (workspaceId) url.searchParams.set("workspaceId", workspaceId); + url.searchParams.set("sourceKind", "local_folder"); + url.searchParams.set("rootUri", rootUri); + url.searchParams.set("treeView", "filetree"); + await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await page.locator(".document-pane[data-pane-role='primary'] .editor-surface .ProseMirror[contenteditable='true']").first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); +} + +async function readEditorText(page) { + return page.locator(".document-pane[data-pane-role='primary'] .editor-surface .ProseMirror").first().innerText({ + timeout: UI_TIMEOUT_MS, + }); +} + +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, + CONVEX_SELF_HOSTED_URL: "http://127.0.0.1:9", + NEXT_PUBLIC_CONVEX_URL: "http://127.0.0.1:9", + }, + 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: 1360, height: 920 } }); + 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); + await signUp(context, baseUrl, actorId); + const { rootUri, workspaceId } = await openDefaultLocalWorkspace(page, baseUrl); + const rootPath = fileUrlToPath(rootUri); + fs.mkdirSync(path.join(rootPath, "design"), { recursive: true }); + fs.writeFileSync(path.join(rootPath, "Home.md"), "# Home\n\noriginal\n", "utf8"); + fs.writeFileSync(path.join(rootPath, "Other.md"), "# Other\n\nother\n", "utf8"); + fs.writeFileSync(path.join(rootPath, "design", "Brief.md"), "# Brief\n\nbrief\n", "utf8"); + + const fileTreeUrl = new URL(baseUrl); + if (workspaceId) fileTreeUrl.searchParams.set("workspaceId", workspaceId); + fileTreeUrl.searchParams.set("sourceKind", "local_folder"); + fileTreeUrl.searchParams.set("rootUri", rootUri); + fileTreeUrl.searchParams.set("treeView", "filetree"); + await page.goto(fileTreeUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await page.locator('[data-mnote-sidebar-tree-tab="filetree"]').click({ timeout: UI_TIMEOUT_MS }); + const designRow = page.locator('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="design"]').first(); + await designRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await designRow.click({ button: "right", timeout: UI_TIMEOUT_MS }); + await page.getByText("加入/取消星标置顶").click({ timeout: UI_TIMEOUT_MS }); + await page.locator('.wolai-starred-section [data-mnote-shortcut-kind="folder"][data-mnote-shortcut-relative-path="design"]').first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + + const cloudUrl = new URL(baseUrl); + cloudUrl.searchParams.set("workspaceId", "default"); + await page.goto(cloudUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + const designShortcut = page.locator('.wolai-starred-section [data-mnote-shortcut-kind="folder"][data-mnote-shortcut-relative-path="design"]').first(); + await designShortcut.click({ timeout: UI_TIMEOUT_MS }); + await page.locator('#sidebar-file-tree-root[data-mnote-filetree-scope="design"] .tree-row[data-local-relative-path="design/Brief.md"]').waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + await page.waitForFunction(() => { + const root = document.querySelector("#sidebar-tree-root"); + return root instanceof HTMLElement && /Brief/.test(root.innerText || ""); + }, { timeout: UI_TIMEOUT_MS }).catch(async (error) => { + const state = await page.evaluate(() => { + const root = document.querySelector("#sidebar-tree-root"); + return { + href: location.href, + pageTreeText: root instanceof HTMLElement ? root.innerText : "", + pageTreeHtml: root instanceof HTMLElement ? root.innerHTML.slice(0, 1000) : "", + fileTreeText: document.querySelector("#sidebar-file-tree-root")?.textContent || "", + fileTreeScope: document.querySelector("#sidebar-file-tree-root")?.getAttribute("data-mnote-filetree-scope") || "", + }; + }); + throw new Error(`${error.message}; state=${JSON.stringify(state)}; diagnostics=${diagnostics.slice(-20).join(" | ")}`); + }); + const pageTreeText = await page.locator("#sidebar-tree-root").innerText({ timeout: UI_TIMEOUT_MS }); + assert.match(pageTreeText, /Brief/, `星标 design 后页面树应显示 design 内 Markdown: ${pageTreeText}`); + assert.doesNotMatch(pageTreeText, /Home|Other/, `星标 design 后页面树不应显示根目录 Markdown: ${pageTreeText}`); + + await openLocalDocument(page, baseUrl, rootUri, workspaceId, "local-md:Home.md"); + const editor = page.locator(".document-pane[data-pane-role='primary'] .editor-surface .ProseMirror[contenteditable='true']").first(); + await editor.click({ timeout: UI_TIMEOUT_MS }); + await page.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A"); + await page.keyboard.press("Backspace"); + const saveResponsePromise = page.waitForResponse(async (response) => { + if (!response.url().includes("/api/page-body/write") || response.request().method() !== "POST") return false; + const payload = response.request().postDataJSON(); + return payload?.documentId === "local-md:Home.md" && response.ok(); + }, { timeout: UI_TIMEOUT_MS }); + await page.keyboard.type("111", { delay: 10 }); + await page.waitForFunction( + (expected) => (document.querySelector(".document-pane[data-pane-role='primary'] .editor-surface .ProseMirror")?.textContent || "").includes(expected), + "111", + { timeout: UI_TIMEOUT_MS }, + ); + const saveResponse = await saveResponsePromise.catch(async (error) => { + const state = await page.evaluate(() => ({ + editorText: document.querySelector(".document-pane[data-pane-role='primary'] .editor-surface .ProseMirror")?.textContent || "", + status: document.querySelector("[data-runtime-editor-status]")?.getAttribute("data-runtime-editor-status") || "", + debugSessions: window.__mnoteDebugDocumentSessions?.snapshot?.() || null, + })); + throw new Error(`${error.message}; edit_state=${JSON.stringify(state)}; diagnostics=${diagnostics.slice(-20).join(" | ")}`); + }); + assert(saveResponse.ok(), `本地 Markdown 保存失败: ${saveResponse.status()}`); + await page.waitForFunction( + (expected) => (document.querySelector(".editor-surface .ProseMirror")?.textContent || "").includes(expected), + "111", + { timeout: UI_TIMEOUT_MS }, + ); + assert.match(fs.readFileSync(path.join(rootPath, "Home.md"), "utf8"), /111/, "保存后磁盘 Home.md 应包含 111"); + + await openLocalDocument(page, baseUrl, rootUri, workspaceId, "local-md:Other.md"); + await openLocalDocument(page, baseUrl, rootUri, workspaceId, "local-md:Home.md"); + const reloadedText = await readEditorText(page); + assert.match(reloadedText, /111/, `切换页面再回 Home.md 后编辑内容应保留: ${reloadedText}`); + + console.log(JSON.stringify({ ok: true, task: TASK, baseUrl }, null, 2)); + } finally { + await browser.close(); + server.kill("SIGINT"); + fs.rmSync(dataRoot, { recursive: true, force: true }); + if (server.exitCode == null) { + setTimeout(() => server.kill("SIGKILL"), 2000).unref(); + } + if (server.exitCode && server.exitCode !== 130 && server.exitCode !== null) { + process.stderr.write(stderr); + } + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +});