#!/usr/bin/env node "use strict"; const { loginViaAuthForm } = require('./lib/browser-auth-login'); 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 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 localFolderHomeUrl(root) { const url = new URL(`${BASE_URL}/`); url.searchParams.set("sourceKind", "local_folder"); url.searchParams.set("rootUri", fileUrl(root)); url.searchParams.set("treeView", "filetree"); return url.toString(); } function writeWorkspaceManifest(root, ownerId, taskId) { const metadataDir = path.join(root, ".mnote"); fs.mkdirSync(metadataDir, { recursive: true }); fs.writeFileSync( path.join(metadataDir, "workspace.json"), `${JSON.stringify({ workspaceId: `local-ws:${ownerId}:${taskId}`, ownerId, createdAt: new Date().toISOString(), capabilities: ["local_files", "markdown_edit", "asset_upload"], }, null, 2)}\n`, "utf8", ); } async function quickLogin(page) { // 7-76 P0: 标准表单登录(无测试快速登录按钮) const base = (typeof BASE_URL !== "undefined" && BASE_URL) || (typeof baseUrl !== "undefined" && baseUrl) || process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000"; const timeout = (typeof UI_TIMEOUT_MS !== "undefined" && UI_TIMEOUT_MS) || (typeof TIMEOUT !== "undefined" && TIMEOUT) || 30_000; if (!String(page.url() || "").includes("/auth")) { await page.goto(String(base).replace(/\/+$/, "") + "/auth", { waitUntil: "commit", timeout, }); } await loginViaAuthForm(page, { baseUrl: base, timeoutMs: timeout, gotoAuth: false, }); await page .waitForURL((url) => !String(url).includes("/auth"), { timeout }) .catch(() => {}); } async function activatePrimaryPageTab(page) { const pageTab = page.locator('[data-mnote-main-tab="page"][data-pane-role="primary"]').first(); if (await pageTab.count()) { await pageTab.click({ timeout: UI_TIMEOUT_MS }).catch(() => undefined); } await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); } async function uploadAttachmentViaPrimarySlash(page, fileName, markdownContent) { await activatePrimaryPageTab(page); const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first(); await editor.click({ timeout: UI_TIMEOUT_MS }); await page.keyboard.press("End").catch(() => undefined); await page.keyboard.type("/"); const item = page .locator('.document-pane[data-pane-role="primary"] [data-testid="slash-item-upload-attachment"]') .first(); await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const [fileChooser] = await Promise.all([ page.waitForEvent("filechooser", { timeout: UI_TIMEOUT_MS }), item.click({ timeout: UI_TIMEOUT_MS }), ]); await fileChooser.setFiles({ name: fileName, mimeType: "text/markdown", buffer: Buffer.from(markdownContent, "utf8"), }); await page.waitForFunction( (name) => { const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror'); return editor instanceof HTMLElement && (editor.textContent || "").includes(name); }, fileName, { timeout: UI_TIMEOUT_MS }, ); } async function waitForFileExists(filePath, timeoutMs) { const startedAt = Date.now(); while (!fs.existsSync(filePath) && Date.now() - startedAt < timeoutMs) { await new Promise((resolve) => setTimeout(resolve, 100)); } return fs.existsSync(filePath); } async function waitForUploadAsset(uploadResponses, startIndex, predicate, timeoutMs) { const startedAt = Date.now(); let seen = []; while (Date.now() - startedAt < timeoutMs) { seen = uploadResponses.slice(startIndex); for (const entry of seen) { const asset = entry?.payload?.asset; if (asset && predicate(asset, entry)) return asset; } await new Promise((resolve) => setTimeout(resolve, 100)); } throw new Error(`未捕获到符合条件的上传响应: ${JSON.stringify(seen).slice(0, 1000)}`); } async function waitForFileContent(filePath, predicate, timeoutMs) { const startedAt = Date.now(); let lastContent = ""; while (Date.now() - startedAt < timeoutMs) { if (fs.existsSync(filePath)) { lastContent = fs.readFileSync(filePath, "utf8"); if (predicate(lastContent)) return { ok: true, content: lastContent }; } await new Promise((resolve) => setTimeout(resolve, 150)); } return { ok: false, content: lastContent }; } async function assertNoPrimaryConflictPanel(page, label) { await page.waitForTimeout(1000); const conflict = await page.evaluate(() => { const pane = document.querySelector('.document-pane[data-pane-role="primary"]'); const panel = pane?.querySelector('[data-testid="mnote-editor-conflict-panel"]') || null; const status = pane?.querySelector('[data-runtime-editor-status]')?.getAttribute('data-runtime-editor-status') || document.querySelector('[data-runtime-editor-status]')?.getAttribute('data-runtime-editor-status') || ""; return { visible: panel instanceof HTMLElement && panel.offsetParent !== null, text: panel instanceof HTMLElement ? panel.textContent || "" : "", status, }; }); assert( !conflict.visible && conflict.status !== "external-change-conflict", `${label} 不应出现文件冲突: ${JSON.stringify(conflict)}`, ); } async function waitForPrimaryAttachment(page, fileName) { const link = page .locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]', { hasText: fileName, }) .first(); await link.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await page.waitForFunction( (name) => { const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror'); const link = Array.from(editor?.querySelectorAll('a[href*="/api/local-folder/files/open"]') || []) .find((node) => node instanceof HTMLAnchorElement && (node.textContent || "").includes(name)); return link instanceof HTMLAnchorElement && (link.classList.contains("mnote-uploaded-attachment-row") || getComputedStyle(link).display === "inline-flex"); }, fileName, { timeout: UI_TIMEOUT_MS }, ); return link; } async function waitForPrimaryAttachmentClass(page, fileName, className) { await page.waitForFunction( ({ name, className }) => { const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror'); const link = Array.from(editor?.querySelectorAll('a[href*="/api/local-folder/files/open"]') || []) .find((node) => node instanceof HTMLAnchorElement && (node.textContent || "").includes(name)); return link instanceof HTMLAnchorElement && link.classList.contains(className); }, { name: fileName, className }, { timeout: UI_TIMEOUT_MS }, ); return await waitForPrimaryAttachment(page, fileName) .then((link) => link.evaluate((node) => { const badgeColor = getComputedStyle(node, "::before").backgroundColor; return `${node.getAttribute("class") || ""} ${badgeColor}`.trim(); })); } async function selectPrimaryAttachmentLink(page, fileName) { await page.evaluate((name) => { const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror'); if (!(editor instanceof HTMLElement)) throw new Error("editor_missing"); const link = Array.from(editor.querySelectorAll("a")).find((node) => ( node instanceof HTMLAnchorElement && (node.textContent || "").includes(name) )); if (!(link instanceof HTMLAnchorElement)) throw new Error(`attachment_link_missing:${name}`); const range = document.createRange(); range.selectNode(link); const selection = window.getSelection(); if (!selection) throw new Error("selection_missing"); selection.removeAllRanges(); selection.addRange(range); editor.focus(); }, fileName); } async function deleteAttachmentByKeyboard(page, fileName) { await waitForPrimaryAttachment(page, fileName); await selectPrimaryAttachmentLink(page, fileName); await page.keyboard.press("Backspace"); } async function deleteAttachmentByBlockHandle(page, fileName) { const link = await waitForPrimaryAttachment(page, fileName); const box = await link.boundingBox(); assert(box, `附件 ${fileName} 缺少可点击区域`); await page.mouse.move(box.x + Math.min(12, box.width / 2), box.y + box.height / 2, { steps: 8 }); const trigger = page.locator('[data-testid="block-drag-handle-trigger"]').first(); await trigger.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await trigger.click({ timeout: UI_TIMEOUT_MS }); const menu = page.locator('[data-testid="block-drag-menu"]').first(); await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await page.locator('[data-testid="block-drag-menu-item-delete"]').first().click({ timeout: UI_TIMEOUT_MS }); } function writeTwoAttachmentPage(root, relativePath, resourceDirName, heading) { const resourceDir = path.join(root, resourceDirName); fs.mkdirSync(resourceDir, { recursive: true }); fs.writeFileSync(path.join(resourceDir, "first.md"), "# First\n\n第一个附件\n", "utf8"); fs.writeFileSync(path.join(resourceDir, "second.md"), "# Second\n\n第二个附件\n", "utf8"); fs.writeFileSync( path.join(root, relativePath), [ `# ${heading}`, "", `[first.md](${resourceDirName}/first.md)`, "", `[second.md](${resourceDirName}/second.md)`, "", ].join("\n"), "utf8", ); } // ─── main ──────────────────────────────────────────────────────────────────── async function main() { const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task479-lifecycle-")); const relativePath = "README.md"; const resourceDirName = "README"; const resourceDir = path.join(root, resourceDirName); fs.mkdirSync(resourceDir, { recursive: true }); writeWorkspaceManifest(root, "user_real", "task479"); // 主正文:README.md,引用一个已存在的附件 resource-note.md fs.writeFileSync( path.join(root, relativePath), [ "---", "title: Lifecycle Smoke", "---", "", "# Lifecycle Smoke", "", "正文内容不变。", "", "[resource-note.md](" + resourceDirName + "/resource-note.md)", "", ].join("\n"), "utf8", ); fs.writeFileSync(path.join(root, resourceDirName, "resource-note.md"), "# Resource Note\n\n已有资源正文\n", "utf8"); // 为 Check 6 准备一个带 H1 的文件(文件名不含空格,避免空格编码干扰测试) fs.writeFileSync(path.join(root, "MyNotes.md"), "# Body Heading\n\n正文内容\n", "utf8"); // 为 Check 7 准备两个删除附件引用的独立页面,避免不同删除路径互相污染。 writeTwoAttachmentPage(root, "DeleteKeyboard.md", "DeleteKeyboard", "Delete Keyboard"); writeTwoAttachmentPage(root, "DeleteHandle.md", "DeleteHandle", "Delete Handle"); // 为 Check 5 准备足够的文件树条目来产生可滚动区域 fs.mkdirSync(path.join(root, "docs"), { recursive: true }); for (let i = 0; i < 30; i++) { fs.writeFileSync(path.join(root, "docs", `doc-${String(i).padStart(2, "0")}.md`), `# Doc ${i}\n`, "utf8"); } // 为 Check 5 准备的附件 PDF fs.writeFileSync(path.join(resourceDir, "report-a.pdf"), Buffer.from("%PDF-1.4\n% task479 report-a\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 }, extraHTTPHeaders: { "x-mnote-actor-id": "user_real", "x-mnote-actor-type": "user", }, }); const page = await context.newPage(); const popups = []; page.on("popup", (popup) => popups.push(popup)); const uploadResponses = []; page.on("response", async (response) => { if (!response.url().includes("/api/local-folder/assets/upload")) return; const payload = await response.json().catch(() => null); uploadResponses.push({ status: response.status(), payload }); }); const screenshots = []; const checks = {}; let overallOk = true; try { // ── Login ── await quickLogin(page); // ────────────────────────────────────────────────────────────────────────── // Check 1: 主编辑区上传 / drop 附件后,附件应进入当前 Markdown 页面的资源目录 // (README/uploaded-one.md 不是 root 同级) // ────────────────────────────────────────────────────────────────────────── { const checkId = "editor-upload-to-page-resource-dir"; try { await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS, }); await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); const uploadStart = uploadResponses.length; // 通过主编辑区斜杠上传上传一个 MD 文件 await uploadAttachmentViaPrimarySlash(page, "uploaded-one.md", "# Uploaded One\n\n第一个上传附件\n"); const uploadAsset = await waitForUploadAsset( uploadResponses, uploadStart, (asset) => asset.file_name === "uploaded-one.md", UI_TIMEOUT_MS, ); // 等待后端 write_local_markdown_asset() 落盘 const correctPath = path.join(resourceDir, "uploaded-one.md"); const wrongPath = path.join(root, "uploaded-one.md"); const landedInResourceDir = await waitForFileExists(correctPath, UI_TIMEOUT_MS); const landedInRoot = fs.existsSync(wrongPath); assert( landedInResourceDir, `上传文件应落在 page resource directory (${correctPath})。` + ` landedInRoot=${landedInRoot} correctPath=${correctPath}`, ); assert.equal(uploadAsset.uploadIntent, "editor.markdown.attach", "主编辑区上传响应应返回 editor.markdown.attach intent"); assert.equal(uploadAsset.rootRelativePath, "README/uploaded-one.md", "主编辑区上传响应应返回 page resource rootRelativePath"); assert.equal(uploadAsset.markdownRelativePath, "README/uploaded-one.md", "主编辑区上传响应应返回 markdownRelativePath"); assert.equal(uploadAsset.ownerDocumentId, "local-md:README.md", "主编辑区上传响应应返回 ownerDocumentId"); await assertNoPrimaryConflictPanel(page, "主编辑区上传附件后"); const uploadedClassBeforeReload = await waitForPrimaryAttachmentClass( page, "uploaded-one.md", "mnote-uploaded-attachment-code", ); assert( uploadedClassBeforeReload.includes("mnote-uploaded-attachment-code"), `上传后的 md 附件应使用 code/markdown 黑色图标 class,实际 class=${uploadedClassBeforeReload}`, ); await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); const uploadedClassAfterReload = await waitForPrimaryAttachmentClass( page, "uploaded-one.md", "mnote-uploaded-attachment-code", ); assert( uploadedClassAfterReload.includes("mnote-uploaded-attachment-code"), `刷新后的 md 附件应保持 code/markdown 黑色图标 class,实际 class=${uploadedClassAfterReload}`, ); checks[checkId] = { ok: true, message: `上传文件落在资源目录 ${correctPath},刷新后仍保持 md 附件图标 class`, landedInResourceDir, landedInRoot, uploadAsset, uploadedClassBeforeReload, uploadedClassAfterReload, }; } catch (err) { overallOk = false; checks[checkId] = { ok: false, message: `主编辑区上传落盘目录检查失败: ${err.message}`, error: err.stack, }; } } // ────────────────────────────────────────────────────────────────────────── // Check 2: 文件树 folder row 外部 drop 到目标文件夹时,文件应进入该目标 folder // 而不是当前 Markdown 页面的资源目录 // ────────────────────────────────────────────────────────────────────────── { const checkId = "filetree-folder-drop-to-target"; try { await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS, }); // 等待文件树就绪 const docsFolderRow = page.locator( '#sidebar-file-tree-root .tree-row[data-row-id="local:folder:docs"]', ).first(); await docsFolderRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const droppedFileName = "drag-dropped-note.md"; const droppedContent = "# Drag Dropped\n\n从外部拖入\n"; const uploadStart = uploadResponses.length; // 使用 dispatchEvent 模拟外部文件拖入 docs 文件夹 await docsFolderRow.dispatchEvent("dragover", { dataTransfer: await page.evaluateHandle( ({ fileName, fileContent }) => { const dt = new DataTransfer(); dt.items.add(new File([fileContent], fileName, { type: "text/markdown" })); return dt; }, { fileName: droppedFileName, fileContent: droppedContent }, ), }); await docsFolderRow.dispatchEvent("drop", { dataTransfer: await page.evaluateHandle( ({ fileName, fileContent }) => { const dt = new DataTransfer(); dt.items.add(new File([fileContent], fileName, { type: "text/markdown" })); return dt; }, { fileName: droppedFileName, fileContent: droppedContent }, ), }); // 等待文件落盘 const droppedPath = path.join(root, "docs", droppedFileName); const exists = await waitForFileExists(droppedPath, UI_TIMEOUT_MS); const uploadAsset = await waitForUploadAsset( uploadResponses, uploadStart, (asset) => asset.file_name === droppedFileName, UI_TIMEOUT_MS, ); assert(exists, `拖入 docs 文件夹后文件应出现在 ${droppedPath}`); assert.equal(uploadAsset.uploadIntent, "filetree.folder.drop", "文件树 folder drop 响应应返回 filetree.folder.drop intent"); assert.equal(uploadAsset.rootRelativePath, "docs/drag-dropped-note.md", "文件树 folder drop 响应应返回目标目录 rootRelativePath"); assert.equal(uploadAsset.targetRelativePath, "docs", "文件树 folder drop 响应应返回 targetRelativePath"); assert.equal(uploadAsset.markdownRelativePath, null, "文件树 folder drop 不应返回 markdownRelativePath"); assert.equal(uploadAsset.ownerDocumentId, null, "文件树 folder drop 不应返回 ownerDocumentId"); // 确保没有出现在 root 或 README/ 下 const wrongPath = path.join(root, droppedFileName); const wrongResourcePath = path.join(resourceDir, droppedFileName); assert( !fs.existsSync(wrongPath) && !fs.existsSync(wrongResourcePath), `拖入 docs 文件夹的文件不应落在 root (${wrongPath}) 或 resourceDir (${wrongResourcePath}) 下`, ); await assertNoPrimaryConflictPanel(page, "文件树文件夹拖入文件后"); checks[checkId] = { ok: true, message: `外部拖入文件进入目标文件夹 docs/`, droppedPath, exists, uploadAsset, }; } catch (err) { overallOk = false; checks[checkId] = { ok: false, message: `文件树 folder drop 检查失败: ${err.message}`, error: err.stack, }; } } // ────────────────────────────────────────────────────────────────────────── // Check 3: 删除真实附件文件后,刷新页面,正文中的 Markdown 链接仍应可见 // 如果当前实现失败,先记录当前 failure,但断言应表达预期行为 // ────────────────────────────────────────────────────────────────────────── { const checkId = "broken-link-after-real-file-delete"; try { await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS, }); await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); // 先确认正文中有附件链接 const linkTextBefore = await page.evaluate(() => { const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror'); return editor instanceof HTMLElement ? editor.textContent || "" : ""; }); assert( linkTextBefore.includes("resource-note.md"), `初始正文应包含附件链接 resource-note.md,实际内容: ${linkTextBefore.slice(0, 500)}`, ); // 从磁盘删除真实附件文件 const assetPath = path.join(resourceDir, "resource-note.md"); assert(fs.existsSync(assetPath), "测试前置条件:附件文件应存在"); fs.unlinkSync(assetPath); await page.waitForFunction( async () => { const now = Date.now(); if ( typeof window.__mnoteRefreshEditorLocalAttachmentExistence === "function" && (!window.__mnoteTask479LastAttachmentRefreshAt || now - window.__mnoteTask479LastAttachmentRefreshAt > 500) ) { window.__mnoteTask479LastAttachmentRefreshAt = now; window.__mnoteRefreshEditorLocalAttachmentExistence(); await new Promise((resolve) => setTimeout(resolve, 250)); } const link = Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]')) .find((node) => (node.textContent || "").includes("resource-note.md")); return link instanceof HTMLAnchorElement && link.getAttribute("data-mnote-attachment-missing") === "true"; }, null, { timeout: UI_TIMEOUT_MS }, ); const missingMarkedBeforeReload = await page.evaluate(() => { const link = Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]')) .find((node) => (node.textContent || "").includes("resource-note.md")); return link instanceof HTMLAnchorElement ? { missing: link.getAttribute("data-mnote-attachment-missing"), text: link.textContent || "", } : null; }); const markdownAfterDeleteBeforeReload = fs.readFileSync(path.join(root, relativePath), "utf8"); assert(markdownAfterDeleteBeforeReload.includes("[resource-note.md](README/resource-note.md)"), "watcher 标记 missing 不应改写 Markdown 正文"); // 刷新页面 await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); // 给异步解析和渲染留时间 await page.waitForTimeout(1000); // 检查正文是否仍包含附件链接文本 const linkTextAfter = await page.evaluate(() => { const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror'); return editor instanceof HTMLElement ? editor.textContent || "" : ""; }); // 预期行为:链接应保留 const linkPreserved = linkTextAfter.includes("resource-note.md"); const popupCountBefore = popups.length; await page.evaluate(() => { const brokenLink = Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a')) .find((node) => node instanceof HTMLAnchorElement && (node.textContent || "").includes("resource-note.md")); if (!(brokenLink instanceof HTMLAnchorElement)) throw new Error("broken_link_missing"); brokenLink.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window, })); }); await page.waitForTimeout(1500); const popupCountAfter = popups.length; const resourceTabErrorVisible = await page .locator('[data-testid="mnote-resource-tab-host"]:not([hidden]) .mnote-resource-tab-error, [data-mnote-resource-panel][data-resource-kind="error"]') .first() .isVisible() .catch(() => false); const resourceTabTitle = await page .locator('[data-mnote-main-tab][data-pane-role="primary"] .mnote-main-tab-title', { hasText: "resource-note.md" }) .first() .textContent({ timeout: UI_TIMEOUT_MS }) .catch(() => ""); assert.equal(popupCountAfter, popupCountBefore, "点击缺失附件不应打开新浏览器窗口"); assert(resourceTabErrorVisible, "点击缺失附件应进入 resource tab missing/error state"); assert((resourceTabTitle || "").includes("resource-note.md"), `缺失资源 tab 标题应包含 resource-note.md,实际: ${resourceTabTitle}`); checks[checkId] = { ok: linkPreserved, message: linkPreserved ? "删除真实附件后刷新,正文链接仍保留;点击缺失链接进入 resource tab missing/error state" : "删除真实附件后刷新,正文链接消失(当前实现未保留 broken link)", assetPath, deleted: true, linkPreserved, missingMarkedBeforeReload, editorText: linkTextAfter.slice(0, 500), popupCount: popupCountAfter - popupCountBefore, resourceTabErrorVisible, resourceTabTitle, }; if (!linkPreserved) { overallOk = false; } } catch (err) { overallOk = false; const debugState = await page.evaluate(async () => { const collectLinks = () => Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a')) .map((node) => ({ text: node.textContent || "", href: node instanceof HTMLAnchorElement ? node.getAttribute("href") || "" : "", missing: node instanceof HTMLElement ? node.getAttribute("data-mnote-attachment-missing") : "", className: node instanceof HTMLElement ? node.getAttribute("class") || "" : "", })); const beforeManualRefresh = collectLinks(); if (typeof window.__mnoteRefreshEditorLocalAttachmentExistence === "function") { window.__mnoteRefreshEditorLocalAttachmentExistence(); await new Promise((resolve) => setTimeout(resolve, 1500)); } const pane = document.querySelector('.document-pane[data-pane-role="primary"]'); const editor = pane?.querySelector('.editor-surface .ProseMirror'); return { url: window.location.href, hasManualRefresh: typeof window.__mnoteRefreshEditorLocalAttachmentExistence, status: pane?.querySelector('[data-runtime-editor-status]')?.getAttribute("data-runtime-editor-status") || "", conflictVisible: Boolean(pane?.querySelector('[data-testid="mnote-editor-conflict-panel"]')), editorText: editor instanceof HTMLElement ? (editor.textContent || "").slice(0, 500) : "", beforeManualRefresh, afterManualRefresh: collectLinks(), }; }).catch((debugErr) => ({ debugError: debugErr.message })); checks[checkId] = { ok: false, message: `删除真实附件后正文链接保留检查失败: ${err.message}`, error: err.stack, debugState, }; } } // ────────────────────────────────────────────────────────────────────────── // Check 4: 刚进入 local folder、未先打开任何 Markdown 文件时, // 点击 PDF / Markdown 附件 row 应在主编辑区 tab 打开,不应产生 popup。 // 若当前 UI 无法直接进入这种状态,记录原因并把该 check 标记为 skipped // ────────────────────────────────────────────────────────────────────────── { const checkId = "direct-attach-open-without-active-md"; try { // 使用独立 asset-only 工作区,确保 local folder 首屏没有 active Markdown document。 const assetOnlyRoot = path.join(root, "asset-only-workspace"); fs.mkdirSync(path.join(assetOnlyRoot, "attachments"), { recursive: true }); writeWorkspaceManifest(assetOnlyRoot, "user_real", "task479-asset-only"); fs.writeFileSync(path.join(assetOnlyRoot, "attachments", "report-a.pdf"), Buffer.from("%PDF-1.4\n% task479 asset-only report-a\n", "utf8")); const homeUrl = localFolderHomeUrl(assetOnlyRoot); await page.goto(homeUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); // 等待文件树就绪 const fileTreeRoot = page.locator("#sidebar-file-tree-root").first(); await fileTreeRoot.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const hasResourceHost = await page.locator('[data-mnote-resource-tab-host][data-pane-role="primary"]') .first() .count() .then((count) => count > 0) .catch(() => false); const resourceFolderRow = page .locator('#sidebar-file-tree-root .tree-row[data-node-id="local:node:attachments"], #sidebar-file-tree-root .tree-row[data-row-id="local:folder:attachments"]') .first(); if (await resourceFolderRow.isVisible().catch(() => false)) { const expanded = await resourceFolderRow.getAttribute("aria-expanded").catch(() => null); const resourceFolderToggle = resourceFolderRow.locator('[data-testid="filetree-toggle"]').first(); if (expanded !== "true" && await resourceFolderToggle.isVisible().catch(() => false)) { await resourceFolderToggle.click({ timeout: UI_TIMEOUT_MS }).catch(() => undefined); } } // 找到附件 row (data-testid="filetree-asset-row" 或 data-row-kind="asset"/"attachment") const pdfAssetRow = page.locator('[data-testid="filetree-asset-row"]', { hasText: "report-a.pdf", }).first(); await pdfAssetRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const hasPdfRow = await pdfAssetRow.isVisible().catch(() => false); assert(hasResourceHost, "无 active Markdown document 时应渲染主编辑区 resource tab host"); assert(hasPdfRow, "无 active Markdown document 时应渲染 report-a.pdf 附件 row"); const popupCountBefore = popups.length; await pdfAssetRow.locator(".tree-link").click({ timeout: UI_TIMEOUT_MS }); await page.waitForTimeout(2000); const popupCountAfter = popups.length; const resourceTabVisible = await page.locator('[data-testid="mnote-resource-tab-host"]:not([hidden])') .isVisible().catch(() => false); const resourceTabTitle = await page .locator('[data-mnote-main-tab][data-pane-role="primary"] .mnote-main-tab-title', { hasText: "report-a.pdf" }) .first() .textContent({ timeout: UI_TIMEOUT_MS }) .catch(() => ""); const rowState = await pdfAssetRow.evaluate((row) => ({ rowId: row.getAttribute("data-row-id") || "", selected: row.getAttribute("data-selected") || "", active: row.getAttribute("data-active") || "", focused: row.getAttribute("data-focused") || "", })); const selectedRows = await page.locator('#sidebar-file-tree-root .tree-row[data-selected="true"]').evaluateAll((rows) => ( rows.map((row) => ({ rowId: row.getAttribute("data-row-id") || "", text: row.textContent || "", })) )); const activeRows = await page.locator('#sidebar-file-tree-root .tree-row[data-active="true"]').evaluateAll((rows) => ( rows.map((row) => ({ rowId: row.getAttribute("data-row-id") || "", text: row.textContent || "", })) )); assert( popupCountAfter === popupCountBefore, `点击附件 row 不应产生新浏览器窗口/标签页,popup 数:${popupCountBefore} → ${popupCountAfter}`, ); assert(resourceTabVisible, "点击附件 row 后应显示主编辑区 resource tab host"); assert((resourceTabTitle || "").includes("report-a.pdf"), `资源 tab 标题应包含 report-a.pdf,实际: ${resourceTabTitle}`); assert.equal(rowState.selected, "true", `点击附件 row 后应保持选中该 row,实际: ${JSON.stringify({ rowState, selectedRows })}`); assert.equal(rowState.active, "true", `点击附件 row 后应保持 active 在该 row,实际: ${JSON.stringify({ rowState, activeRows })}`); assert.deepEqual(selectedRows.map((row) => row.rowId), [rowState.rowId], `打开资源后不应跳选其他 row: ${JSON.stringify(selectedRows)}`); assert.deepEqual(activeRows.map((row) => row.rowId), [rowState.rowId], `打开资源后 active row 不应跳到其他 row: ${JSON.stringify(activeRows)}`); checks[checkId] = { ok: true, message: "无 active Markdown document 时,点击附件 row 后主编辑区 resource tab 打开,且 File Tree active/selected 保持在该附件 row", popupCount: popupCountAfter - popupCountBefore, resourceTabVisible, resourceTabTitle, rowState, selectedRows, activeRows, }; } catch (err) { overallOk = false; checks[checkId] = { ok: false, message: `直接打开附件检查异常: ${err.message}`, error: err.stack, }; } } // ────────────────────────────────────────────────────────────────────────── // Check 5: 文件树滚动到底和 resize handle 上滚轮后, // document.scrollingElement.scrollTop 应保持 0 或不产生底部空白 // ────────────────────────────────────────────────────────────────────────── { const checkId = "filetree-scroll-no-body-scroll"; try { await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS, }); await page.locator("#sidebar-file-tree-root").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); // 先让文件树出现 await page.waitForTimeout(500); // 获取滚动前的 body scrollTop const scrollBefore = await page.evaluate(() => document.scrollingElement?.scrollTop ?? 0); // 尝试让文件树滚动到底:在文件树上用鼠标滚轮 const fileTree = page.locator("#sidebar-file-tree-root").first(); await fileTree.hover({ timeout: UI_TIMEOUT_MS }); // 多次在文件树上滚轮模拟快速滚到底 for (let i = 0; i < 30; i++) { await page.mouse.wheel(0, 120); await new Promise((r) => setTimeout(r, 30)); } // 再在文件树区域一次性大量滚轮 for (let i = 0; i < 50; i++) { await page.mouse.wheel(0, 300); await new Promise((r) => setTimeout(r, 20)); } await page.waitForTimeout(500); const scrollAfter = await page.evaluate(() => document.scrollingElement?.scrollTop ?? 0); // 预期:body scrollTop 不应被文件树滚轮事件改变 const bodyNotScrolled = scrollAfter <= scrollBefore; checks[checkId] = { ok: bodyNotScrolled, message: bodyNotScrolled ? "文件树内滚轮没有导致 body 滚动" : "文件树滚轮导致 body 产生了滚动(出现底部空白)", scrollBefore, scrollAfter, delta: scrollAfter - scrollBefore, }; if (!bodyNotScrolled) { overallOk = false; } } catch (err) { overallOk = false; checks[checkId] = { ok: false, message: `文件树滚动 body 空白检查失败: ${err.message}`, error: err.stack, }; } } // ────────────────────────────────────────────────────────────────────────── // Check 6: 本地 Markdown 标题:File Name.md 且正文第一行 # Body Heading 时, // File Tree 显示 "File Name.md",正文仍显示 "Body Heading" // ────────────────────────────────────────────────────────────────────────── { const checkId = "markdown-title-from-filename-not-h1"; try { const fileName = "MyNotes.md"; const fileRelPath = fileName; // 确认文件已被写入 assert(fs.existsSync(path.join(root, fileName)), "File Name.md 应存在"); // 导航到该文件 await page.goto(documentUrl(root, fileRelPath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS, }); await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); // 等待文件树就绪 await page.locator("#sidebar-file-tree-root").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); await page.waitForTimeout(500); // 从文件树获知 MyNotes.md 的标题 const treeTitle = await page.evaluate(() => { const rows = document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-row-id^="local:markdown:"]'); for (const row of rows) { const text = row.querySelector(".tree-link-title")?.textContent?.trim() || row.textContent?.trim() || ""; if (text.includes("MyNotes")) return text; } return null; }); // 获取正文第一行 / 标题 const bodyHeading = await page.evaluate(() => { const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror'); if (!(editor instanceof HTMLElement)) return null; // 尝试获取第一个 h1 const h1 = editor.querySelector("h1"); if (h1 instanceof HTMLElement) return h1.textContent?.trim() || ""; // fallback: textContent 第一行 return (editor.textContent || "").split("\n").map((l) => l.trim()).filter(Boolean)[0] || ""; }); // 预期:文件树显示 MyNotes.md(文件名),正文显示 Body Heading(H1 内容) const treeShowsFileName = treeTitle && treeTitle.includes("MyNotes.md"); const bodyShowsHeading = bodyHeading && bodyHeading.includes("Body Heading"); // 托管工作区(我的空间)默认显示页头标题。 const titleHeaderInitiallyVisible = await page.evaluate(() => { const header = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell-header'); return header instanceof HTMLElement ? !header.hidden && header.getAttribute("data-page-title-hidden") === "false" : false; }); await page.locator('[data-testid="wolai-page-settings-trigger"]').click({ timeout: UI_TIMEOUT_MS }); const hideTitleCheckbox = page.locator('[data-page-option-checkbox="hideTitleHeader"]').first(); await hideTitleCheckbox.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const hideTitleCheckedBefore = await hideTitleCheckbox.isChecked(); assert.equal(hideTitleCheckedBefore, false, "托管工作区(我的空间)默认不隐藏文件标题"); const optionsPath = path.join(root, ".mnote", "page-options.json"); await hideTitleCheckbox.setChecked(true, { timeout: UI_TIMEOUT_MS }); await page.waitForFunction( () => { const header = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell-header'); return header instanceof HTMLElement ? header.hidden || header.getAttribute("data-page-title-hidden") === "true" : false; }, null, { timeout: UI_TIMEOUT_MS }, ); assert(!fs.existsSync(optionsPath), "隐藏标题不应继续写入 .mnote/page-options.json"); const titleHeaderHiddenAfterCheck = await page.evaluate(() => { const header = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell-header'); return header instanceof HTMLElement ? header.hidden || header.getAttribute("data-page-title-hidden") === "true" : false; }); assert(titleHeaderHiddenAfterCheck, "勾选隐藏标题后标题应隐藏"); await hideTitleCheckbox.setChecked(false, { timeout: UI_TIMEOUT_MS }); await page.waitForFunction( () => { const header = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell-header'); return header instanceof HTMLElement ? !header.hidden && header.getAttribute("data-page-title-hidden") === "false" : false; }, null, { timeout: UI_TIMEOUT_MS }, ); assert(!fs.existsSync(optionsPath), "显示标题不应继续写入 .mnote/page-options.json"); const titleHeaderVisibleAfterToggle = await page.evaluate(() => { const header = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell-header'); return header instanceof HTMLElement ? !header.hidden && header.getAttribute("data-page-title-hidden") === "false" : false; }); await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); const titleHeaderVisibleAfterReload = await page.evaluate(() => { const header = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell-header'); return header instanceof HTMLElement ? !header.hidden && header.getAttribute("data-page-title-hidden") === "false" : false; }); checks[checkId] = { ok: !!(treeShowsFileName && bodyShowsHeading && titleHeaderInitiallyVisible && titleHeaderVisibleAfterToggle && titleHeaderVisibleAfterReload), message: `treeTitle="${treeTitle}" bodyHeading="${bodyHeading}"`, treeTitle, bodyHeading, treeShowsFileName, bodyShowsHeading, titleHeaderInitiallyVisible, hideTitleCheckedBefore, titleHeaderHiddenAfterCheck, titleHeaderVisibleAfterToggle, titleHeaderVisibleAfterReload, }; } catch (err) { overallOk = false; checks[checkId] = { ok: false, message: `标题来源检查失败: ${err.message}`, error: err.stack, }; } } // ────────────────────────────────────────────────────────────────────────── // Check 7: 主编辑区删除一个附件引用时,只删除正文链接,不级联删除真实文件; // 相邻附件仍保持可点击附件块,手柄删除刷新后不回来。 // ────────────────────────────────────────────────────────────────────────── { const checkId = "editor-delete-one-attachment-preserves-adjacent"; try { const keyboardPath = "DeleteKeyboard.md"; const keyboardFile = path.join(root, keyboardPath); await page.goto(documentUrl(root, keyboardPath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS, }); await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); await waitForPrimaryAttachment(page, "first.md"); await waitForPrimaryAttachment(page, "second.md"); await deleteAttachmentByKeyboard(page, "second.md"); const keyboardPersisted = await waitForFileContent( keyboardFile, (content) => content.includes("[first.md](DeleteKeyboard/first.md)") && !content.includes("second.md"), UI_TIMEOUT_MS, ); assert( keyboardPersisted.ok, `Backspace 删除第二个附件后,Markdown 应保留 first 链接且移除 second。实际内容:\n${keyboardPersisted.content}`, ); assert(fs.existsSync(path.join(root, "DeleteKeyboard", "first.md")), "Backspace 删除引用不应删除 first.md 真实文件"); assert(fs.existsSync(path.join(root, "DeleteKeyboard", "second.md")), "Backspace 删除引用不应删除 second.md 真实文件"); await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await waitForPrimaryAttachment(page, "first.md"); const keyboardSecondVisible = await page .locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a', { hasText: "second.md" }) .first() .isVisible() .catch(() => false); assert(!keyboardSecondVisible, "Backspace 删除后刷新,second.md 引用不应回来"); const handlePath = "DeleteHandle.md"; const handleFile = path.join(root, handlePath); await page.goto(documentUrl(root, handlePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS, }); await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); await waitForPrimaryAttachment(page, "first.md"); await waitForPrimaryAttachment(page, "second.md"); await deleteAttachmentByBlockHandle(page, "second.md"); const handlePersisted = await waitForFileContent( handleFile, (content) => content.includes("[first.md](DeleteHandle/first.md)") && !content.includes("second.md"), UI_TIMEOUT_MS, ); assert( handlePersisted.ok, `手柄删除第二个附件后,Markdown 应保留 first 链接且移除 second。实际内容:\n${handlePersisted.content}`, ); assert(fs.existsSync(path.join(root, "DeleteHandle", "first.md")), "手柄删除引用不应删除 first.md 真实文件"); assert(fs.existsSync(path.join(root, "DeleteHandle", "second.md")), "手柄删除引用不应删除 second.md 真实文件"); await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await waitForPrimaryAttachment(page, "first.md"); const handleSecondVisible = await page .locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a', { hasText: "second.md" }) .first() .isVisible() .catch(() => false); assert(!handleSecondVisible, "手柄删除后刷新,second.md 引用不应回来"); checks[checkId] = { ok: true, message: "Backspace 与手柄删除单个附件引用后,相邻附件仍是可点击附件块,真实文件保留,刷新后被删引用不回来", keyboardContent: keyboardPersisted.content, handleContent: handlePersisted.content, }; } catch (err) { overallOk = false; checks[checkId] = { ok: false, message: `删除单个附件引用检查失败: ${err.message}`, error: err.stack, }; } } // ── 截图 ── { const shotTimestamp = Date.now(); const shotDir = path.join(root, `screenshots-${shotTimestamp}`); fs.mkdirSync(shotDir, { recursive: true }); await page.screenshot({ path: path.join(shotDir, "final-state.png"), fullPage: false }); screenshots.push(path.join(shotDir, "final-state.png")); } // ── 结果 ── const result = { ok: overallOk, root, checks, screenshots, popups: popups.length, }; console.log(JSON.stringify(result, null, 2)); } finally { await context.close().catch(() => undefined); await browser.close().catch(() => undefined); // 清理临时目录,保留 result JSON 被 console.log 输出即可 // 如果用户需要查看留下的文件,可设置 KEEP_TEMP=1 if (!process.env.KEEP_TEMP) { try { fs.rmSync(root, { recursive: true, force: true }); } catch (_) { /* ignore cleanup errors */ } } } } main().catch((error) => { console.error(error && error.stack ? error.stack : error); process.exit(1); });