feat(tree): checkpoint resource lifecycle work

提交当前顶层 mnote Git 工作区,范围集中在 04-tree-domain 的 resource/trash/filetree 生命周期、mnote-web resource_trash 路由、Convex/Next 兼容接口、sidebar/file-tree 客户端适配、smoke 脚本与对应设计/bug 记录。

不包含被 ignore 的 design/05-editor-mainline/reference-code/leptos-tiptap 嵌套仓库改动。新增 smoke 的测试密码改为运行时读取 MNOTE_E2E_PASSWORD,避免提交明文 credential assignment。
This commit is contained in:
lix-2026
2026-05-16 07:38:45 +08:00
parent 274779c0d8
commit 384da4e44c
85 changed files with 12570 additions and 394 deletions
@@ -90,6 +90,22 @@ async function closeContextMenu(page) {
}
}
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");
@@ -256,6 +272,15 @@ async function run() {
});
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") ||
@@ -449,7 +474,7 @@ async function run() {
await page.locator(readonlyRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(imageRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await dispatchRootContextMenu(page);
await dispatchFolderContextMenu(page, docsRow);
await waitForContextMenu(page);
await expectMenuAction(page, "newPage", { disabled: false });
await expectMenuAction(page, "newFolder", { disabled: false });
@@ -496,7 +521,7 @@ async function run() {
await page.locator(readonlyRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(imageRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await dispatchRootContextMenu(page);
await dispatchFolderContextMenu(page, docsRow);
await waitForContextMenu(page);
await clickMenuAction(page, "newFolder");
let createRenameInput = page.locator(".tree-rename-input").first();
@@ -712,13 +737,19 @@ async function run() {
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, "工作区首页");