324 lines
16 KiB
JavaScript
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);
|
|
});
|