Fix starred scope page tree and local edit save

This commit is contained in:
lix-2026
2026-05-27 13:50:31 +08:00
parent c01dc2dea2
commit bd7470d03e
8 changed files with 414 additions and 5 deletions
@@ -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` 通过。
@@ -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 projectionfile 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 projectionGREEN 后 `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-27Rust 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`
@@ -2,9 +2,11 @@ import {
conflictDetectionKeyFromBody,
editorDocumentFromTiptapDocument,
flattenText,
hydrateMindmapAttrsFromDom,
legacyBlocksFromEditorDocument,
pageBodyTiptapDocument,
revisionFromConflictKey,
textToTiptapDocument,
toTiptapDocument,
} from './document-tiptap-conversion-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');
+49 -1
View File
@@ -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::<Vec<_>>();
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!(
@@ -2767,6 +2767,20 @@ fn load_local_folder_file_tree_scope_snapshot(
pub fn load_local_folder_page_tree_snapshot(
root_uri: &str,
) -> Result<ProjectionSnapshot, WebError> {
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<ProjectionSnapshot, WebError> {
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<ProjectionSnapshot, WebError> {
#[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(),
@@ -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 打开后旧我的空间页面树不应长期残留");
@@ -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);
});