#!/usr/bin/env node "use strict"; const assert = require("node:assert/strict"); const fs = require("node:fs"); 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 ROOT_PATH = process.env.MNOTE_WOLAI_PAGE_REF_SMOKE_ROOT || "/mnt/Data1T/Mnote_data/users/liaibo/workspaces/my-space"; const PARENT_PAGE = process.env.MNOTE_WOLAI_PAGE_REF_PARENT || "liaibo的个人空间/项目/项目.md"; const CHILD_PAGE = process.env.MNOTE_WOLAI_PAGE_REF_CHILD || "liaibo的个人空间/项目/完结项目/完结项目.md"; const GRANDCHILD_PAGE = process.env.MNOTE_WOLAI_PAGE_REF_GRANDCHILD || "liaibo的个人空间/项目/完结项目/爱斯特完结项目/爱斯特完结项目.md"; const USERNAME = process.env.MNOTE_WOLAI_PAGE_REF_USER || "mnote.e2e@example.com"; const PASSWORD = process.env.MNOTE_WOLAI_PAGE_REF_PASSWORD || "MnoteE2E123!"; const OUT_DIR = path.join(process.cwd(), "tmp", "task803-local-page-reference-restore-smoke"); const RESULT_PATH = path.join(OUT_DIR, "result.json"); const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || ["/usr/bin/chromium-browser", "/usr/bin/chromium", "/usr/bin/google-chrome-stable", "/snap/bin/chromium"] .find((candidate) => fs.existsSync(candidate)); function fileUrl(localPath) { return `file://${localPath}`; } function encodeLocalIdSegment(value) { const bytes = Buffer.from(String(value || ""), "utf8"); let encoded = ""; for (const byte of bytes) { const character = String.fromCharCode(byte); if ( (byte >= 48 && byte <= 57) || (byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) || character === "." || character === "_" || character === "-" ) { encoded += character; } else { encoded += `~${byte.toString(16).toUpperCase().padStart(2, "0")}`; } } return encoded; } function localMdDocumentId(relativePath) { return `local-md:${encodeLocalIdSegment(relativePath)}`; } function documentUrl(relativePath) { const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`); url.searchParams.set("sourceKind", "local_folder"); url.searchParams.set("rootUri", fileUrl(ROOT_PATH)); url.searchParams.set("treeView", "filetree"); return url.toString(); } async function signIn(request) { const response = await request.post(`${BASE_URL}/api/auth`, { headers: { "content-type": "application/json", accept: "application/json" }, data: { action: "auth:signIn", args: { provider: "password", params: { account: USERNAME, password: PASSWORD, flow: "signIn", }, }, }, }); assert.equal(response.status(), 200, `登录失败: ${response.status()} ${await response.text()}`); } async function waitForEditor(page) { await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first() .waitFor({ state: "visible" }); await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first() .waitFor({ state: "visible" }); } async function pageReferenceTexts(page) { return page.evaluate(() => Array.from(document.querySelectorAll(".editor-surface .ProseMirror a.mnote-page-block-link")) .map((anchor) => ({ text: (anchor.textContent || "").trim(), href: anchor.getAttribute("href") || "", className: anchor.className || "", target: anchor.getAttribute("target") || "", }))); } async function pageBlockVisual(page, title) { return page.locator(".editor-surface .ProseMirror a.mnote-page-block-link", { hasText: title }).first().evaluate((anchor) => { const style = window.getComputedStyle(anchor); const before = window.getComputedStyle(anchor, "::before"); const row = anchor.closest("p"); const rowStyle = row ? window.getComputedStyle(row) : null; return { text: anchor.textContent || "", display: style.display, color: style.color, fontWeight: style.fontWeight, textDecorationLine: style.textDecorationLine, beforeWidth: before.width, beforeHeight: before.height, rowBackground: rowStyle ? rowStyle.backgroundColor : "", }; }); } function assertWolaiPageBlockVisual(visual) { assert.equal(visual.display, "inline-flex", `页面块应保持图标+标题的 inline-flex 形态: ${JSON.stringify(visual)}`); assert.notEqual(visual.color, "rgb(0, 0, 238)", `页面块不能退化为浏览器默认蓝色链接: ${JSON.stringify(visual)}`); assert.ok(visual.textDecorationLine.includes("underline"), `页面块标题应保留 Wolai 风格下划线: ${JSON.stringify(visual)}`); assert.ok(parseFloat(visual.beforeWidth) >= 14 && parseFloat(visual.beforeHeight) >= 14, `页面块必须有独立页面图标: ${JSON.stringify(visual)}`); assert.ok( ["", "rgba(0, 0, 0, 0)", "transparent"].includes(visual.rowBackground), `页面块常态不能渲染整行浅红背景: ${JSON.stringify(visual)}`, ); } function installMutationProbe(page) { return page.evaluate(() => { if (window.__mnoteTask803MutationObserver) { window.__mnoteTask803MutationObserver.disconnect(); } const countElementNodes = (node) => { if (!(node instanceof Element)) return 0; return 1 + node.querySelectorAll("*").length; }; const roots = [ ["sidebar", document.querySelector(".mnote-sidebar, #sidebar-file-tree-root, #sidebar-tree-root")], ["workspace", document.querySelector("[data-testid='mnote-document-workspace'], .document-workspace")], ].filter((entry) => entry[1] instanceof Element); window.__mnoteTask803MutationProbe = { navigationEntries: performance.getEntriesByType("navigation").length, records: [], }; const observer = new MutationObserver((records) => { for (const record of records) { const owner = roots.find((entry) => entry[1].contains(record.target)); window.__mnoteTask803MutationProbe.records.push({ owner: owner ? owner[0] : "unknown", added: Array.from(record.addedNodes).reduce((sum, node) => sum + countElementNodes(node), 0), removed: Array.from(record.removedNodes).reduce((sum, node) => sum + countElementNodes(node), 0), }); } }); roots.forEach((entry) => observer.observe(entry[1], { childList: true, subtree: true })); window.__mnoteTask803MutationObserver = observer; }); } function readMutationProbe(page) { return page.evaluate(() => { if (window.__mnoteTask803MutationObserver) { window.__mnoteTask803MutationObserver.disconnect(); } const probe = window.__mnoteTask803MutationProbe || { navigationEntries: 0, records: [] }; const summary = { navigationEntriesBefore: probe.navigationEntries, navigationEntriesAfter: performance.getEntriesByType("navigation").length, sidebarAdded: 0, sidebarRemoved: 0, workspaceAdded: 0, workspaceRemoved: 0, }; for (const record of probe.records || []) { if (record.owner === "sidebar") { summary.sidebarAdded += record.added || 0; summary.sidebarRemoved += record.removed || 0; } if (record.owner === "workspace") { summary.workspaceAdded += record.added || 0; summary.workspaceRemoved += record.removed || 0; } } return summary; }); } function assertLocalPageHref(item, expectedRelativePath) { const url = new URL(item.href, BASE_URL); assert.ok( decodeURIComponent(url.pathname).includes(localMdDocumentId(expectedRelativePath)), `页面块 href 未指向目标页面: ${JSON.stringify({ item, expectedRelativePath })}`, ); assert.equal(url.searchParams.get("sourceKind"), "local_folder", JSON.stringify(item)); assert.equal(url.searchParams.get("rootUri"), fileUrl(ROOT_PATH), JSON.stringify(item)); assert.equal(item.target, "_self", JSON.stringify(item)); } async function main() { fs.mkdirSync(OUT_DIR, { recursive: true }); const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1", ...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}), }); const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN", }); const page = await context.newPage(); page.setDefaultTimeout(Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 45_000)); const clickRequests = []; let collectClickRequests = false; page.on("request", (request) => { if (!collectClickRequests) return; clickRequests.push({ method: request.method(), resourceType: request.resourceType(), url: request.url(), }); }); try { await signIn(context.request); await page.goto(documentUrl(PARENT_PAGE), { waitUntil: "domcontentloaded" }); await waitForEditor(page); const parentRefs = await pageReferenceTexts(page); const completeProjectRef = parentRefs.find((item) => item.text === "完结项目"); assert.ok(completeProjectRef, `父页面没有把 Markdown 子页面链接还原为页面块: ${JSON.stringify(parentRefs)}`); assertLocalPageHref(completeProjectRef, CHILD_PAGE); const parentPageBlockVisual = await pageBlockVisual(page, "完结项目"); assertWolaiPageBlockVisual(parentPageBlockVisual); await page.screenshot({ path: path.join(OUT_DIR, "01-parent-page-reference.png"), fullPage: false }); await installMutationProbe(page); collectClickRequests = true; await page.locator(".editor-surface .ProseMirror a.mnote-page-block-link", { hasText: "完结项目" }).first().click(); await page.waitForURL((url) => decodeURIComponent(url.pathname).includes(localMdDocumentId(CHILD_PAGE))); await waitForEditor(page); collectClickRequests = false; const firstClickProbe = await readMutationProbe(page); const firstClickDocumentRequests = clickRequests.filter((request) => request.resourceType === "document"); const firstClickTreeProjectionRequests = clickRequests.filter((request) => request.url.includes("/api/tree/projections/")); assert.equal(firstClickDocumentRequests.length, 0, `页面块点击不能触发整页 document 导航: ${JSON.stringify(firstClickDocumentRequests)}`); assert.equal(firstClickTreeProjectionRequests.length, 0, `页面块点击不应重拉树 projection: ${JSON.stringify(firstClickTreeProjectionRequests)}`); assert.equal(firstClickProbe.navigationEntriesAfter, firstClickProbe.navigationEntriesBefore, `页面块点击不能新增浏览器 navigation entry: ${JSON.stringify(firstClickProbe)}`); assert.ok(firstClickProbe.sidebarAdded + firstClickProbe.sidebarRemoved <= 4, `页面块点击不应重建左侧 Sidebar DOM: ${JSON.stringify(firstClickProbe)}`); const childRefs = await pageReferenceTexts(page); const childTitles = childRefs.map((item) => item.text); assert.ok(childTitles.includes("爱斯特完结项目"), `子页面纯文本行没有推断为页面块: ${JSON.stringify(childRefs)}`); assert.ok(childTitles.includes("药友完结项目"), `子页面缺少其它同级页面块: ${JSON.stringify(childRefs)}`); assert.ok(childTitles.includes("倍特完结项目"), `子页面缺少其它同级页面块: ${JSON.stringify(childRefs)}`); assert.ok(!childTitles.includes("22"), `普通文本不应被误还原为页面块: ${JSON.stringify(childRefs)}`); const grandchildRef = childRefs.find((item) => item.text === "爱斯特完结项目"); assertLocalPageHref(grandchildRef, GRANDCHILD_PAGE); const childPageBlockVisual = await pageBlockVisual(page, "爱斯特完结项目"); assertWolaiPageBlockVisual(childPageBlockVisual); await page.screenshot({ path: path.join(OUT_DIR, "02-child-inferred-page-references.png"), fullPage: false }); await page.locator(".editor-surface .ProseMirror a.mnote-page-block-link", { hasText: "爱斯特完结项目" }).first().click(); await page.waitForURL((url) => decodeURIComponent(url.pathname).includes(localMdDocumentId(GRANDCHILD_PAGE))); await waitForEditor(page); await page.screenshot({ path: path.join(OUT_DIR, "03-click-opened-grandchild.png"), fullPage: false }); const result = { ok: true, parentDocumentId: localMdDocumentId(PARENT_PAGE), childDocumentId: localMdDocumentId(CHILD_PAGE), grandchildDocumentId: localMdDocumentId(GRANDCHILD_PAGE), firstClickProbe, screenshots: [ path.join(OUT_DIR, "01-parent-page-reference.png"), path.join(OUT_DIR, "02-child-inferred-page-references.png"), path.join(OUT_DIR, "03-click-opened-grandchild.png"), ], }; fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); console.log(JSON.stringify(result, null, 2)); } finally { await browser.close(); } } main().catch((error) => { console.error(error && error.stack ? error.stack : error); process.exit(1); });