#!/usr/bin/env node "use strict"; const assert = require("node:assert"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node: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); const OUTPUT_DIR = process.env.MNOTE_CONTEXT_MENU_DOWNLOAD_OUTPUT_DIR || "/mnt/Data1T/mnote/tmp/task476-filetree-editor-context-menu-download-smoke"; const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"] .find((candidate) => fs.existsSync(candidate)); function fileUrl(localPath) { return `file://${localPath}`; } function localMdDocumentId(relativePath) { return `local-md:${relativePath.replaceAll("/", "~2F")}`; } function documentUrl(root, relativePath) { const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`); url.searchParams.set("sourceKind", "local_folder"); url.searchParams.set("rootUri", fileUrl(root)); url.searchParams.set("treeView", "filetree"); return url.toString(); } function writeWorkspaceManifest(root, ownerId) { const metadataDir = path.join(root, ".mnote"); fs.mkdirSync(metadataDir, { recursive: true }); fs.writeFileSync( path.join(metadataDir, "workspace.json"), `${JSON.stringify({ workspaceId: `local-ws:${ownerId}:task476`, ownerId, createdAt: new Date().toISOString(), capabilities: ["local_files", "markdown_edit", "asset_upload"], }, null, 2)}\n`, "utf8", ); } async function quickLogin(page) { await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" }); if (await quickLoginButton.count()) { await quickLoginButton.click({ timeout: UI_TIMEOUT_MS }); await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS }).catch(() => undefined); } } async function menuActions(page, kind) { return await page.evaluate((targetKind) => { const menu = document.querySelector(`[data-testid="mnote-tree-context-menu"][data-kind="${targetKind}"]`); return Array.from(menu?.querySelectorAll("[data-action]") || []).map((button) => ({ action: button.getAttribute("data-action") || "", label: button.textContent?.trim() || "", })); }, kind); } async function waitForDownloadCount(downloads, expectedCount, timeoutMs) { const startedAt = Date.now(); while (downloads.length < expectedCount && Date.now() - startedAt < timeoutMs) { await new Promise((resolve) => setTimeout(resolve, 100)); } assert( downloads.length >= expectedCount, `等待下载数量达到 ${expectedCount} 失败,当前 ${downloads.length}`, ); } async function waitForFileExists(filePath, timeoutMs) { const startedAt = Date.now(); while (!fs.existsSync(filePath) && Date.now() - startedAt < timeoutMs) { await new Promise((resolve) => setTimeout(resolve, 100)); } assert(fs.existsSync(filePath), `等待文件出现失败: ${filePath}`); } async function expandFileTreeFolder(page, title) { const folderRow = page.locator('#sidebar-file-tree-root .tree-row[data-row-kind="folder"]', { hasText: title }).first(); await folderRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const expanded = await folderRow.getAttribute("aria-expanded"); if (expanded !== "true") { const toggle = folderRow.locator('[data-rust-action="toggle"]').first(); if (await toggle.count()) { await toggle.click({ timeout: UI_TIMEOUT_MS }); } else { await folderRow.click({ timeout: UI_TIMEOUT_MS }); } } } function suggestedFilenames(downloads, startIndex) { return downloads.slice(startIndex).map((download) => download.suggestedFilename()).sort(); } async function main() { fs.mkdirSync(OUTPUT_DIR, { recursive: true }); const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task476-context-download-")); const relativePath = "README.md"; const resourceDir = path.join(root, "README"); const assetNames = ["report-a.pdf", "report-b.pdf", "report-c.pdf"]; fs.mkdirSync(resourceDir, { recursive: true }); writeWorkspaceManifest(root, "user_real"); fs.writeFileSync( path.join(root, relativePath), [ "---", "title: Context Menu Download", "---", "", "# Context Menu Download", "", "[report-a.pdf](README/report-a.pdf)", "", ].join("\n"), "utf8", ); for (const fileName of assetNames) { fs.writeFileSync(path.join(resourceDir, fileName), Buffer.from(`%PDF-1.4\n% task476 ${fileName}\n`, "utf8")); } const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1", executablePath: CHROMIUM_EXECUTABLE_PATH, }); const context = await browser.newContext({ viewport: { width: 1360, height: 900 }, acceptDownloads: true, extraHTTPHeaders: { "x-mnote-actor-id": "user_real", "x-mnote-actor-type": "user", }, }); const page = await context.newPage(); const consoleErrors = []; const capturedDownloads = []; page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); }); page.on("download", (download) => { capturedDownloads.push(download); }); try { await quickLogin(page); await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); const editorSurface = page.locator(".document-pane[data-pane-role=\"primary\"] .editor-surface .ProseMirror").first(); await editorSurface.waitFor({ state: "attached", timeout: UI_TIMEOUT_MS, }).catch(() => undefined); const editorReady = await editorSurface.isVisible().catch(() => false); await expandFileTreeFolder(page, "README"); const assetRow = page.locator('[data-testid="filetree-asset-row"]', { hasText: "report-a.pdf" }).first(); await assetRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await assetRow.click({ button: "right", timeout: UI_TIMEOUT_MS }); const filetreeMenu = page.locator('[data-testid="mnote-tree-context-menu"][data-kind="filetree"]').first(); await filetreeMenu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const filetreeActions = await menuActions(page, "filetree"); assert(filetreeActions.some((item) => item.action === "download"), `文件树 asset 菜单缺少下载: ${JSON.stringify(filetreeActions)}`); await page.screenshot({ path: path.join(OUTPUT_DIR, "01-filetree-asset-menu-with-download.png"), fullPage: false }); const singleDownloadStart = capturedDownloads.length; const [filetreeDownload] = await Promise.all([ page.waitForEvent("download", { timeout: UI_TIMEOUT_MS }), filetreeMenu.locator('[data-action="download"]').click({ timeout: UI_TIMEOUT_MS }), ]); assert.equal(filetreeDownload.suggestedFilename(), "report-a.pdf"); await filetreeDownload.saveAs(path.join(OUTPUT_DIR, filetreeDownload.suggestedFilename())); await waitForDownloadCount(capturedDownloads, singleDownloadStart + 1, UI_TIMEOUT_MS); const reportCRow = page.locator('[data-testid="filetree-asset-row"]', { hasText: "report-c.pdf" }).first(); await reportCRow.click({ modifiers: ["Control"], timeout: UI_TIMEOUT_MS }); const selectedAfterCtrl = await page.evaluate(() => Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-selected="true"]')).map((row) => ({ title: row.textContent?.trim() || "", rowId: row.getAttribute("data-row-id") || "", }))); await reportCRow.click({ button: "right", timeout: UI_TIMEOUT_MS }); const ctrlMenu = page.locator('[data-testid="mnote-tree-context-menu"][data-kind="filetree"]').first(); await ctrlMenu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const ctrlActions = await menuActions(page, "filetree"); assert( ctrlActions.some((item) => item.action === "download" && item.label.includes("2")), `Ctrl 多选菜单没有显示 2 个文件下载: actions=${JSON.stringify(ctrlActions)} selected=${JSON.stringify(selectedAfterCtrl)}`, ); await page.screenshot({ path: path.join(OUTPUT_DIR, "03-filetree-ctrl-multi-download-menu.png"), fullPage: false }); const ctrlDownloadStart = capturedDownloads.length; await ctrlMenu.locator('[data-action="download"]').click({ timeout: UI_TIMEOUT_MS }); await waitForDownloadCount(capturedDownloads, ctrlDownloadStart + 2, UI_TIMEOUT_MS); const ctrlDownloadFiles = suggestedFilenames(capturedDownloads, ctrlDownloadStart); assert.deepStrictEqual(ctrlDownloadFiles, ["report-a.pdf", "report-c.pdf"]); await assetRow.click({ timeout: UI_TIMEOUT_MS }); await reportCRow.click({ modifiers: ["Shift"], timeout: UI_TIMEOUT_MS }); const selectedAfterShift = await page.evaluate(() => Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-selected="true"]')).map((row) => ({ title: row.textContent?.trim() || "", rowId: row.getAttribute("data-row-id") || "", }))); await reportCRow.click({ button: "right", timeout: UI_TIMEOUT_MS }); const shiftMenu = page.locator('[data-testid="mnote-tree-context-menu"][data-kind="filetree"]').first(); await shiftMenu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const shiftActions = await menuActions(page, "filetree"); assert( shiftActions.some((item) => item.action === "download" && item.label.includes("3")), `Shift 范围多选菜单没有显示 3 个文件下载: actions=${JSON.stringify(shiftActions)} selected=${JSON.stringify(selectedAfterShift)}`, ); await page.screenshot({ path: path.join(OUTPUT_DIR, "04-filetree-shift-multi-download-menu.png"), fullPage: false }); const shiftDownloadStart = capturedDownloads.length; await shiftMenu.locator('[data-action="download"]').click({ timeout: UI_TIMEOUT_MS }); await waitForDownloadCount(capturedDownloads, shiftDownloadStart + 3, UI_TIMEOUT_MS); const shiftDownloadFiles = suggestedFilenames(capturedDownloads, shiftDownloadStart); assert.deepStrictEqual(shiftDownloadFiles, ["report-a.pdf", "report-b.pdf", "report-c.pdf"]); const markdownRow = page.locator('#sidebar-file-tree-root .tree-row[data-row-kind="markdown"]', { hasText: "README.md" }).first(); await markdownRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await markdownRow.click({ button: "right", timeout: UI_TIMEOUT_MS }); const markdownMenu = page.locator('[data-testid="mnote-tree-context-menu"][data-kind="filetree"]').first(); await markdownMenu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const markdownActions = await menuActions(page, "filetree"); assert(markdownActions.some((item) => item.action === "download"), `Markdown 行菜单缺少下载: ${JSON.stringify(markdownActions)}`); const [markdownDownload] = await Promise.all([ page.waitForEvent("download", { timeout: UI_TIMEOUT_MS }), markdownMenu.locator('[data-action="download"]').click({ timeout: UI_TIMEOUT_MS }), ]); assert.equal(markdownDownload.suggestedFilename(), "README.md"); const folderRow = page.locator('#sidebar-file-tree-root .tree-row[data-row-kind="folder"]', { hasText: "README" }).first(); await folderRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await folderRow.click({ button: "right", timeout: UI_TIMEOUT_MS }); const folderMenu = page.locator('[data-testid="mnote-tree-context-menu"][data-kind="filetree"]').first(); await folderMenu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const folderActions = await menuActions(page, "filetree"); assert(folderActions.some((item) => item.action === "download"), `文件夹行菜单缺少下载: ${JSON.stringify(folderActions)}`); await page.screenshot({ path: path.join(OUTPUT_DIR, "05-filetree-folder-download-menu.png"), fullPage: false }); const [folderDownload] = await Promise.all([ page.waitForEvent("download", { timeout: UI_TIMEOUT_MS }), folderMenu.locator('[data-action="download"]').click({ timeout: UI_TIMEOUT_MS }), ]); assert.equal(folderDownload.suggestedFilename(), "README.tar"); await folderDownload.saveAs(path.join(OUTPUT_DIR, folderDownload.suggestedFilename())); const droppedFileName = "drag-upload-note.txt"; const droppedPath = path.join(resourceDir, droppedFileName); await folderRow.dispatchEvent("dragover", { dataTransfer: await page.evaluateHandle(({ fileName }) => { const dataTransfer = new DataTransfer(); dataTransfer.items.add(new File(["drag upload smoke"], fileName, { type: "text/plain" })); return dataTransfer; }, { fileName: droppedFileName }), }); await folderRow.dispatchEvent("drop", { dataTransfer: await page.evaluateHandle(({ fileName }) => { const dataTransfer = new DataTransfer(); dataTransfer.items.add(new File(["drag upload smoke"], fileName, { type: "text/plain" })); return dataTransfer; }, { fileName: droppedFileName }), }); await waitForFileExists(droppedPath, UI_TIMEOUT_MS); let attachmentActions = []; let attachmentDownload = null; const attachmentLink = page.locator('.editor-surface .ProseMirror a[data-mnote-attachment-link="true"]', { hasText: "report-a.pdf" }).first(); const attachmentReady = editorReady && await attachmentLink.isVisible().catch(() => false); if (attachmentReady) { await attachmentLink.click({ button: "right", timeout: UI_TIMEOUT_MS }); const attachmentMenu = page.locator('[data-testid="mnote-tree-context-menu"][data-kind="attachment"]').first(); await attachmentMenu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); attachmentActions = await menuActions(page, "attachment"); assert(attachmentActions.some((item) => item.action === "download"), `附件右键菜单缺少下载: ${JSON.stringify(attachmentActions)}`); await page.screenshot({ path: path.join(OUTPUT_DIR, "02-editor-attachment-context-menu.png"), fullPage: false }); [attachmentDownload] = await Promise.all([ page.waitForEvent("download", { timeout: UI_TIMEOUT_MS }), attachmentMenu.locator('[data-action="download"]').click({ timeout: UI_TIMEOUT_MS }), ]); assert.equal(attachmentDownload.suggestedFilename(), "report-a.pdf"); } const result = { ok: true, root, url: page.url(), filetreeActions, attachmentActions, downloads: [ { action: "filetree_asset_download", suggestedFilename: filetreeDownload.suggestedFilename() }, { action: "filetree_ctrl_multi_download", suggestedFilenames: ctrlDownloadFiles }, { action: "filetree_shift_multi_download", suggestedFilenames: shiftDownloadFiles }, { action: "filetree_markdown_download", suggestedFilename: markdownDownload.suggestedFilename() }, { action: "filetree_folder_download", suggestedFilename: folderDownload.suggestedFilename() }, { action: "filetree_folder_drop_upload", targetPath: droppedPath, exists: fs.existsSync(droppedPath) }, attachmentReady ? { action: "editor_attachment_context_menu_download", suggestedFilename: attachmentDownload.suggestedFilename() } : { action: "editor_attachment_context_menu_download", status: "blocked", reason: attachmentReady ? "" : "editor_attachment_not_visible" }, ], consoleErrors, screenshots: [ path.join(OUTPUT_DIR, "01-filetree-asset-menu-with-download.png"), path.join(OUTPUT_DIR, "03-filetree-ctrl-multi-download-menu.png"), path.join(OUTPUT_DIR, "04-filetree-shift-multi-download-menu.png"), path.join(OUTPUT_DIR, "05-filetree-folder-download-menu.png"), ...(attachmentReady ? [path.join(OUTPUT_DIR, "02-editor-attachment-context-menu.png")] : []), ], }; fs.writeFileSync(path.join(OUTPUT_DIR, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8"); console.log(JSON.stringify(result, null, 2)); } finally { await context.close().catch(() => undefined); await browser.close().catch(() => undefined); } } main().catch((error) => { console.error(error && error.stack ? error.stack : error); process.exit(1); });