chore: align mvp design governance
- 统一 local-first MVP 后阶段架构口径,补充 process 执行总序和 Reasonix 协作记录 - 归档已完成的 design checklist,标注参考型 process,更新 AGENTS/REASONIX/架构文档 - 补充文件树/主编辑器下载与上下文菜单相关实现、bug 记录和 smoke 脚本 验证:git diff --check;codegraph sync .;cargo test -p mnote-web;node --check scripts/task476-filetree-editor-context-menu-download-smoke.js
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
#!/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}`);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user