#!/usr/bin/env node "use strict"; const assert = require("node:assert/strict"); const fs = require("node:fs"); const fsp = require("node:fs/promises"); 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", "/usr/bin/chromium"] .find((candidate) => fs.existsSync(candidate)); const TASK = "task445-filetree-mindmap-switch-no-flicker-smoke"; const OUT_DIR = path.join(process.cwd(), "tmp", TASK); const RESULT_PATH = path.join(OUT_DIR, "result.json"); const ACTOR_ID = "user_real"; 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) { fs.mkdirSync(path.join(root, ".mnote"), { recursive: true }); fs.writeFileSync( path.join(root, ".mnote", "workspace.json"), `${JSON.stringify({ workspaceId: `local-ws:${ACTOR_ID}:task445`, ownerId: ACTOR_ID, createdAt: new Date().toISOString(), capabilities: ["local_files", "tree_commands", "markdown_edit", "asset_upload"], }, null, 2)}\n`, "utf8", ); } function writePage(root, relativePath, title, bodyLines) { const fullPath = path.join(root, relativePath); fs.mkdirSync(path.dirname(fullPath), { recursive: true }); fs.writeFileSync( fullPath, ["---", `title: ${title}`, "---", "", ...bodyLines, ""].join("\n"), "utf8", ); } function writeMindmapFixture(root, stamp) { const rootRelativePath = "Task445Root/Task445Root.md"; const otherRelativePath = "Task445Other.md"; const mindmapFileName = `map-${stamp}.mindmap.json`; writePage(root, rootRelativePath, `TEST-445-root-${stamp}`, [ "# TEST 445 Root", "", `[TEST-445-mind-${stamp}](${mindmapFileName})`, ]); writePage(root, otherRelativePath, `TEST-445-other-${stamp}`, [ "# TEST 445 Other", "", "用于验证从另一个页面点击资源行时仍回到资源 owner document。", ]); fs.writeFileSync( path.join(root, "Task445Root", mindmapFileName), `${JSON.stringify({ data: { uid: "root", text: `TEST-445-mind-${stamp}` }, children: [] }, null, 2)}\n`, "utf8", ); return { rootRelativePath, otherRelativePath, documentId: localMdDocumentId(rootRelativePath), otherDocumentId: localMdDocumentId(otherRelativePath), mindmapFileName, }; } async function writeResult(result) { await fsp.mkdir(OUT_DIR, { recursive: true }); await fsp.writeFile(RESULT_PATH, `${JSON.stringify({ ...result, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8"); } async function waitForMindmapRow(page, mindmapFileName) { await page.waitForFunction((fileName) => { return Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]')) .some((row) => (row.getAttribute("data-asset-id") || "").includes(fileName)); }, mindmapFileName, { timeout: UI_TIMEOUT_MS }); } async function clickMindmapRow(page, mindmapFileName) { await page.evaluate((fileName) => { const row = Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]')) .find((candidate) => (candidate.getAttribute("data-asset-id") || "").includes(fileName)); const link = row?.querySelector(".tree-link"); if (!(link instanceof HTMLElement)) { throw new Error(`找不到 mindmap 文件行: ${fileName}`); } link.click(); }, mindmapFileName); } async function clickDocumentRow(page, documentId) { await page.evaluate((docId) => { const row = Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]')) .find((candidate) => ( (candidate.getAttribute("data-document-id") || candidate.getAttribute("data-doc-id") || "") === docId )); const link = row?.querySelector(".tree-link"); if (!(link instanceof HTMLElement)) { throw new Error(`找不到文档行: ${docId}`); } link.click(); }, documentId); } async function readAllFileTreeRows(page) { return await page.evaluate(() => Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]')).map((row) => ({ rowId: row.getAttribute("data-row-id") || "", rowKind: row.getAttribute("data-row-kind") || "", nodeId: row.getAttribute("data-node-id") || "", documentId: row.getAttribute("data-document-id") || row.getAttribute("data-doc-id") || "", ownerDocumentId: row.getAttribute("data-owner-document-id") || "", assetId: row.getAttribute("data-asset-id") || "", title: (row.textContent || "").trim().slice(0, 160), selected: row.getAttribute("data-selected") || "", })), ); } async function readFileTreeState(page, documentId, mindmapFileName) { return await page.evaluate( ({ documentId: docId, mindmapFileName: fileName }) => { const fileRoot = document.getElementById("sidebar-file-tree-root"); const rows = Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]')); const pageRow = rows.find((row) => ( (row.getAttribute("data-document-id") || row.getAttribute("data-doc-id") || "") === docId )); const mindmapRow = rows.find((row) => (row.getAttribute("data-asset-id") || "").includes(fileName)); const titleNode = mindmapRow?.querySelector(":scope > .tree-link > .tree-link-title"); return { fileRootStable: Boolean(fileRoot && fileRoot === window.__task445FileRoot), bodyShell: document.body?.dataset?.mnoteShell || "", currentPath: window.location.pathname, pageSelected: pageRow instanceof HTMLElement ? pageRow.getAttribute("data-selected") || "" : "", mindmapSelected: mindmapRow instanceof HTMLElement ? mindmapRow.getAttribute("data-selected") || "" : "", mindmapTitle: titleNode instanceof HTMLElement ? (titleNode.textContent || "").trim() : "", mindmapAssetId: mindmapRow instanceof HTMLElement ? mindmapRow.getAttribute("data-asset-id") || "" : "", mindmapOwnerDocumentId: mindmapRow instanceof HTMLElement ? mindmapRow.getAttribute("data-owner-document-id") || "" : "", objectEditor: document.querySelector("[data-mnote-object-editor]")?.getAttribute("data-mnote-object-editor") || document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]')?.getAttribute("data-mnote-object-editor") || "", }; }, { documentId, mindmapFileName }, ); } async function waitForMindmapResourceTab(page, documentId, mindmapFileName) { await page.waitForFunction( ({ docId, fileName }) => { const url = new URL(window.location.href); if (!url.pathname.includes(`/documents/${encodeURIComponent(docId)}`)) return false; const rt = decodeURIComponent(url.searchParams.get("resourceTab") || ""); return rt.includes("resource:mindmap:") && rt.includes(docId) && rt.includes(fileName); }, { docId: documentId, fileName: mindmapFileName }, { timeout: UI_TIMEOUT_MS }, ); } async function readMindmapTabLayout(page) { return await page.evaluate(() => { const host = document.querySelector('[data-mnote-resource-tab-host][data-pane-role="primary"]'); const panel = document.querySelector('.mnote-resource-tab-panel[data-pane-role="primary"][data-resource-kind="mindmap"]:not([hidden])'); const shell = panel?.querySelector('.mnote-resource-tab-mindmap-shell'); const root = panel?.querySelector('[data-testid="mnote-mindmap-editor-root"]'); const rectOf = (node) => { if (!(node instanceof HTMLElement)) return null; const rect = node.getBoundingClientRect(); return { width: Math.round(rect.width), height: Math.round(rect.height), left: Math.round(rect.left), right: Math.round(rect.right), scrollWidth: node.scrollWidth, clientWidth: node.clientWidth, scrollHeight: node.scrollHeight, clientHeight: node.clientHeight, }; }; return { hostHidden: host instanceof HTMLElement ? host.hidden : true, host: rectOf(host), panel: rectOf(panel), shell: rectOf(shell), root: rectOf(root), shellOverflow: shell instanceof HTMLElement ? getComputedStyle(shell).overflow : "", rootOverflow: root instanceof HTMLElement ? getComputedStyle(root).overflow : "", }; }); } (async () => { await fsp.mkdir(OUT_DIR, { recursive: true }); const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task445-local-mindmap-")); const stamp = Date.now().toString().slice(-8); writeWorkspaceManifest(root); const fixture = writeMindmapFixture(root, stamp); const browser = await chromium.launch({ headless: true, ...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}), }); const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, extraHTTPHeaders: { "x-mnote-actor-id": ACTOR_ID, "x-mnote-actor-type": "user", }, }); const page = await context.newPage(); const navEvents = []; const consoleMessages = []; page.on("framenavigated", (frame) => { if (frame === page.mainFrame()) navEvents.push({ at: Date.now(), url: frame.url() }); }); page.on("console", (message) => { if (message.type() === "error" || /mindmap|navigation|ReferenceError/i.test(message.text())) { consoleMessages.push({ type: message.type(), text: message.text().slice(0, 2000) }); } }); page.on("pageerror", (error) => { consoleMessages.push({ type: "pageerror", text: error instanceof Error ? error.stack || error.message : String(error) }); }); const result = { baseUrl: BASE_URL, fixture: { root, ...fixture }, before: null, afterMindmap: null, afterPage: null, afterMindmapAgain: null, navEvents, consoleMessages, screenshots: [], failures: [], }; const recordFailure = (code, detail) => { result.failures.push({ code, detail }); }; try { await page.goto(documentUrl(root, fixture.rootRelativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); await waitForMindmapRow(page, fixture.mindmapFileName); await page.evaluate(() => { window.__task445FileRoot = document.getElementById("sidebar-file-tree-root"); }); result.before = await readFileTreeState(page, fixture.documentId, fixture.mindmapFileName); if (/^mindmap-mindmap[_-]/i.test(result.before.mindmapTitle)) { recordFailure("mindmap_title_double_technical_prefix", result.before.mindmapTitle); } if (result.before.mindmapTitle.length > 32) { recordFailure("mindmap_title_too_long", result.before.mindmapTitle); } await clickMindmapRow(page, fixture.mindmapFileName); await waitForMindmapResourceTab(page, fixture.documentId, fixture.mindmapFileName); await page.waitForFunction((fileName) => { return Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-selected="true"]')) .some((row) => (row.getAttribute("data-asset-id") || "").includes(fileName)); }, fixture.mindmapFileName, { timeout: UI_TIMEOUT_MS }); result.afterMindmap = await readFileTreeState(page, fixture.documentId, fixture.mindmapFileName); if (!result.afterMindmap.fileRootStable) recordFailure("mindmap_click_replaced_sidebar_root", result.afterMindmap); result.rowsBeforeOtherClick = await readAllFileTreeRows(page); await clickDocumentRow(page, fixture.otherDocumentId); await page.waitForURL((url) => url.pathname.includes(`/documents/${encodeURIComponent(fixture.otherDocumentId)}`), { timeout: UI_TIMEOUT_MS }); await page.waitForFunction((docId) => { return Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-selected="true"]')) .some((row) => (row.getAttribute("data-document-id") || row.getAttribute("data-doc-id") || "") === docId); }, fixture.otherDocumentId, { timeout: UI_TIMEOUT_MS }); result.afterPage = await readFileTreeState(page, fixture.otherDocumentId, fixture.mindmapFileName); if (!result.afterPage.fileRootStable) recordFailure("page_click_after_mindmap_replaced_sidebar_root", result.afterPage); await clickMindmapRow(page, fixture.mindmapFileName); await waitForMindmapResourceTab(page, fixture.documentId, fixture.mindmapFileName); await page.waitForFunction((fileName) => { return Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-selected="true"]')) .some((row) => (row.getAttribute("data-asset-id") || "").includes(fileName)); }, fixture.mindmapFileName, { timeout: UI_TIMEOUT_MS }); result.afterMindmapAgain = await readFileTreeState(page, fixture.documentId, fixture.mindmapFileName); result.mindmapLayout = await readMindmapTabLayout(page); if (!result.afterMindmapAgain.fileRootStable) recordFailure("mindmap_click_again_replaced_sidebar_root", result.afterMindmapAgain); if (result.mindmapLayout.hostHidden) recordFailure("mindmap_resource_host_hidden", result.mindmapLayout); if (!result.mindmapLayout.shell || !result.mindmapLayout.root) recordFailure("mindmap_resource_shell_or_root_missing", result.mindmapLayout); if ((result.mindmapLayout.shell?.width || 0) < 320 || (result.mindmapLayout.root?.width || 0) < 320) { recordFailure("mindmap_resource_width_too_small", result.mindmapLayout); } if ((result.mindmapLayout.shell?.clientWidth || 0) + 4 < (result.mindmapLayout.shell?.scrollWidth || 0)) { recordFailure("mindmap_resource_horizontal_overflow", result.mindmapLayout); } if ((result.mindmapLayout.root?.height || 0) < 600) { recordFailure("mindmap_resource_height_too_small", result.mindmapLayout); } if (!/hidden/.test(result.mindmapLayout.shellOverflow) || !/hidden/.test(result.mindmapLayout.rootOverflow)) { recordFailure("mindmap_resource_overflow_not_clipped", result.mindmapLayout); } if (!page.url().includes(`/documents/${encodeURIComponent(fixture.documentId)}`)) { recordFailure("mindmap_click_again_used_wrong_owner_document", { expectedDocumentId: fixture.documentId, actualUrl: page.url(), }); } const screenshotPath = path.join(OUT_DIR, "after-mindmap-again.png"); await page.screenshot({ path: screenshotPath, fullPage: true }); result.screenshots.push(screenshotPath); assert.equal(result.failures.length, 0, `task445 failures: ${JSON.stringify(result.failures, null, 2)}`); await writeResult({ ...result, ok: true, finalUrl: page.url() }); console.log(`ok ${TASK} ${RESULT_PATH}`); } catch (error) { await writeResult({ ...result, ok: false, currentUrl: page.url(), error: error instanceof Error ? error.stack || error.message : String(error), }); throw error; } finally { await browser.close().catch(() => null); fs.rmSync(root, { recursive: true, force: true }); } })().catch((error) => { console.error(error instanceof Error ? error.stack || error.message : error); process.exit(1); });