"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}/tree`); url.searchParams.set("mode", mode); 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}/tree`); url.searchParams.set("mode", mode); 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="filetree-context-menu"]').first(); await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); return menu; } async function clickMenuAction(page, action) { await page.locator(`[data-testid="filetree-context-menu"] [data-menu-action="${action}"]`).click({ timeout: UI_TIMEOUT_MS }); } async function expectMenuAction(page, action, options = {}) { const item = page.locator(`[data-testid="filetree-context-menu"] [data-menu-action="${action}"]`).first(); 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="filetree-context-menu"]').waitFor({ state: "detached", timeout: 1_000 }); } catch { await page.evaluate(() => { document.querySelectorAll('[data-testid="filetree-context-menu"]').forEach((element) => element.remove()); }); await page.locator('[data-testid="filetree-context-menu"]').waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }); } } async function dispatchRootContextMenu(page) { await page.evaluate(() => { const root = document.querySelector(".tree-root"); 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\n\n- Bullet item\n1. Numbered item\n> Quote item\n```js\nconsole.log('hi')\n```\n---\n[Spec](assets/spec.pdf)\n", "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 = []; page.on("request", (request) => { if (request.url().includes("/api/tree/commands")) { 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.evaluate(() => window.localStorage.removeItem("mnote.global.showHeadingNumbers")); 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" }, body: JSON.stringify({ ...common, title: "Browser Saved Title" }), }); 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"); await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await waitForText(page, "Browser Saved Title"); await waitForText(page, "Browser saved body"); const globallyDisabledHeadingBefore = await page.locator("#mnote-leptos-tiptap-island-editor-root .ProseMirror h1").first().evaluate((node) => window.getComputedStyle(node, "::before").content ); assert(globallyDisabledHeadingBefore === "none", "全局关闭时,即使页面设置显式开启 showHeadingNumbers,也不应显示标题自动编号"); await page.evaluate(() => window.localStorage.setItem("mnote.global.showHeadingNumbers", "true")); await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await waitForText(page, "Browser Saved Title"); const enabledHeadingBefore = await page.locator("#mnote-leptos-tiptap-island-editor-root .ProseMirror h1").first().evaluate((node) => window.getComputedStyle(node, "::before").content ); assert(enabledHeadingBefore !== "none", "全局开启后应显示标题自动编号"); 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.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 dispatchRootContextMenu(page); 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 dispatchRootContextMenu(page); 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(".tree-root"); 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 的父目录"); 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 }); fs.writeFileSync(path.join(root, "docs", "watcher-added.md"), "# Watcher Added\n", "utf8"); await waitForText(page, "Watcher Added"); await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); fs.writeFileSync(path.join(root, "docs", "watcher-asset.txt"), "watcher asset", "utf8"); await waitForText(page, "watcher-asset.txt"); fs.rmSync(path.join(root, "docs", "watcher-asset.txt")); await page.getByText("watcher-asset.txt", { exact: true }).waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }); 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(` `); 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); });