Fix starred scope page tree and local edit save
This commit is contained in:
@@ -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 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`。
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user