Files
mnote/scripts/task163-local-folder-unified-tree-browser-smoke.js
T

905 lines
52 KiB
JavaScript
Raw Normal View History

2026-05-08 00:41:03 +08:00
"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);
2026-05-08 00:41:03 +08:00
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);
2026-05-08 00:41:03 +08:00
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();
2026-05-08 00:41:03 +08:00
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 });
2026-05-08 00:41:03 +08:00
}
async function expectMenuAction(page, action, options = {}) {
const item = page.locator(`[data-testid="mnote-tree-context-menu"] [data-menu-action="${action}"]`).first();
2026-05-08 00:41:03 +08:00
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 });
2026-05-08 00:41:03 +08:00
} catch {
await page.evaluate(() => {
document.querySelectorAll('[data-testid="mnote-tree-context-menu"]').forEach((element) => element.remove());
2026-05-08 00:41:03 +08:00
});
await page.locator('[data-testid="mnote-tree-context-menu"]').waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
2026-05-08 00:41:03 +08:00
}
}
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 },
);
}
2026-05-08 00:41:03 +08:00
async function dispatchRootContextMenu(page) {
await page.evaluate(() => {
const root = document.querySelector("#sidebar-file-tree-root .tree-root");
2026-05-08 00:41:03 +08:00
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",
2026-05-08 00:41:03 +08:00
"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(),
});
}
});
2026-05-08 00:41:03 +08:00
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")
) {
2026-05-08 00:41:03 +08:00
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" },
2026-05-11 13:16:34 +08:00
body: JSON.stringify({
...common,
title: "Browser Saved Title",
commandName: "page.head.updateTitle",
}),
2026-05-08 00:41:03 +08:00
});
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");
2026-05-08 00:41:03 +08:00
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 偏好开启后应显示标题自动编号");
2026-05-08 00:41:03 +08:00
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;
}
2026-05-08 00:41:03 +08:00
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);
2026-05-08 00:41:03 +08:00
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);
2026-05-08 00:41:03 +08:00
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");
2026-05-08 00:41:03 +08:00
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");
2026-05-08 00:41:03 +08:00
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;
2026-05-08 00:41:03 +08:00
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");
2026-05-08 00:41:03 +08:00
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
watcherNavigationStart = navigationEvents.length;
2026-05-08 00:41:03 +08:00
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;
2026-05-08 00:41:03 +08:00
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");
2026-05-08 00:41:03 +08:00
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);
});