Files
mnote/scripts/task476-filetree-editor-context-menu-download-smoke.js
T

324 lines
16 KiB
JavaScript

#!/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);
});