#!/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 JSZip = require("jszip"); const XLSX = require("@e965/xlsx"); const { chromium } = require("playwright"); const ROOT = path.resolve(__dirname, ".."); const BASE_URL = (process.env.MNOTE_UI_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 OUT_DIR = path.join(ROOT, "tmp", "task501-office-preview-light-viewer-smoke"); const TEST_ACCOUNT = "mnote-e2e"; const TEST_EMAIL = "mnote.e2e@example.com"; const TEST_PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!"; const WORKSPACE_ID = "local-ws:mnote-e2e:office-preview-smoke"; const DOCUMENT_ID = "local-md:office-preview-smoke.md"; function resolveChromiumExecutablePath() { const explicit = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || ""; if (explicit && fs.existsSync(explicit)) return explicit; return [ "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium", "/usr/bin/chromium-browser", ].find((candidate) => fs.existsSync(candidate)) || ""; } function officePreviewUrl(root, fileName, fileType) { const fileUrl = new URL("/api/local-folder/files/open", BASE_URL); fileUrl.searchParams.set("rootUri", `file://${root}`); fileUrl.searchParams.set("path", fileName); const url = new URL("/office-preview", BASE_URL); url.searchParams.set("fileUrl", `${fileUrl.pathname}${fileUrl.search}`); url.searchParams.set("fileName", fileName); url.searchParams.set("fileType", fileType); url.searchParams.set("workspaceId", WORKSPACE_ID); url.searchParams.set("sourceKind", "local_folder"); url.searchParams.set("rootUri", `file://${root}`); url.searchParams.set("documentId", DOCUMENT_ID); return url.toString(); } async function writeDocx(filePath) { const zip = new JSZip(); zip.file("[Content_Types].xml", ` `); zip.folder("_rels").file(".rels", ` `); zip.folder("word").file("document.xml", ` MNote DOCX light preview works 第二行测试内容 `); await fsp.writeFile(filePath, await zip.generateAsync({ type: "nodebuffer" })); } function writeXlsx(filePath) { const workbook = XLSX.utils.book_new(); const sheet = XLSX.utils.aoa_to_sheet([ ["Name", "Value"], ["MNote XLSX light preview works", "ok"], ["中文", "正常"], ]); XLSX.utils.book_append_sheet(workbook, sheet, "Sheet1"); XLSX.writeFile(workbook, filePath); } async function writePptx(filePath) { const zip = new JSZip(); zip.file("[Content_Types].xml", ` `); zip.folder("_rels").file(".rels", ` `); zip.folder("ppt").file("presentation.xml", ` `); zip.folder("ppt").folder("_rels").file("presentation.xml.rels", ` `); zip.folder("ppt").folder("slideMasters").file("slideMaster1.xml", ` `); zip.folder("ppt").folder("slideMasters").folder("_rels").file("slideMaster1.xml.rels", ` `); zip.folder("ppt").folder("slideLayouts").file("slideLayout1.xml", ` `); zip.folder("ppt").folder("slideLayouts").folder("_rels").file("slideLayout1.xml.rels", ` `); zip.folder("ppt").folder("slides").file("slide1.xml", ` MNote PPTX light preview works `); zip.folder("ppt").folder("slides").folder("_rels").file("slide1.xml.rels", ` `); zip.folder("ppt").folder("theme").file("theme1.xml", ` `); await fsp.writeFile(filePath, await zip.generateAsync({ type: "nodebuffer" })); } async function signIn(context) { const payload = (params) => ({ action: "auth:signIn", args: { provider: "password", params, }, }); let response = await context.request.fetch(`${BASE_URL}/api/auth`, { method: "POST", data: payload({ account: TEST_ACCOUNT, password: TEST_PASSWORD, flow: "signIn", }), }); if (response.ok()) return; response = await context.request.fetch(`${BASE_URL}/api/auth`, { method: "POST", data: payload({ email: TEST_EMAIL, username: TEST_ACCOUNT, name: TEST_ACCOUNT, password: TEST_PASSWORD, flow: "signUp", }), }); assert(response.ok(), `测试账号登录失败: ${response.status()} ${await response.text()}`); } async function setPageWidthPreferences(context, root, updates) { const response = await context.request.fetch(`${BASE_URL}/api/ui/preferences`, { method: "PUT", data: { documentId: DOCUMENT_ID, workspaceId: WORKSPACE_ID, sourceKind: "local_folder", rootUri: `file://${root}`, updates, }, }); assert(response.ok(), `页面宽度偏好写入失败: ${response.status()} ${await response.text()}`); } async function verifyPreview(page, root, item) { const consoleMessages = []; page.on("console", (message) => { consoleMessages.push(`${message.type()}: ${message.text()}`); }); page.on("pageerror", (error) => { consoleMessages.push(`pageerror: ${error && error.stack ? error.stack : error.message}`); }); await page.goto(officePreviewUrl(root, item.fileName, item.fileType), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS, }); await page.waitForFunction( () => { const status = document.documentElement.getAttribute("data-mnote-office-preview-status"); return status && status !== "加载中"; }, null, { timeout: UI_TIMEOUT_MS }, ); const status = await page.locator("html").getAttribute("data-mnote-office-preview-status"); const text = await page.locator("#mnote-office-viewer").innerText({ timeout: UI_TIMEOUT_MS }); const layout = await page.evaluate(() => { const viewer = document.querySelector("#mnote-office-viewer"); const tableWrap = document.querySelector(".mnote-office-table-wrap"); const docxPage = document.querySelector(".docx-wrapper > section.docx, .mnote-docx-wrapper > section.mnote-docx"); const pptxStage = document.querySelector(".mnote-office-pptx-stage"); const pptxSlide = document.querySelector(".pptx-preview-slide-wrapper"); const rectOf = (node) => { if (!(node instanceof Element)) return null; const rect = node.getBoundingClientRect(); return { left: rect.left, width: rect.width, }; }; return { viewportWidth: window.innerWidth, pageWidthContentType: document.documentElement.getAttribute("data-page-width-content-type"), pageWidthMode: document.documentElement.getAttribute("data-page-width-resolved-mode"), toolbarCount: document.querySelectorAll(".mnote-office-toolbar").length, viewer: rectOf(viewer), tableWrap: rectOf(tableWrap), docxPage: rectOf(docxPage), pptxStage: rectOf(pptxStage), pptxSlide: rectOf(pptxSlide), }; }); const screenshot = path.join(OUT_DIR, `${item.fileType}.png`); await page.screenshot({ path: screenshot, fullPage: true }); assert.equal(status, "完成", `${item.fileType} 预览状态应完成`); assert(text.includes(item.expected), `${item.fileType} 预览应包含样本文本: ${item.expected}`); assert.equal(consoleMessages.length, 0, `${item.fileType} 预览不应产生控制台错误: ${consoleMessages.join("\n")}`); assert.equal(layout.toolbarCount, 0, "轻量预览页不应显示顶部状态条"); if (item.fileType === "docx") { assert.equal(layout.pageWidthContentType, "word", "DOCX 应使用 Word 宽度类型"); assert.equal(layout.pageWidthMode, item.expectedMode || "wide", "DOCX 应使用指定页面宽度"); assert(layout.viewer && layout.viewer.width >= Math.min(layout.viewportWidth, 1180) - 24, "DOCX 预览容器应接近宽版宽度"); assert(layout.viewer.left > 0 && layout.viewer.left < 80, "DOCX 宽版容器应居中而不是左侧小块"); assert(layout.docxPage && layout.docxPage.width >= layout.viewportWidth * 0.75, "DOCX 页面应放大到接近可视宽度"); } else if (item.fileType === "pptx") { assert.equal(layout.pageWidthContentType, "ppt", "PPTX 应使用 PPT 宽度类型"); assert.equal(layout.pageWidthMode, item.expectedMode || "wide", "PPTX 应使用指定页面宽度"); if ((item.expectedMode || "wide") === "full") { assert(layout.viewer && layout.viewer.width >= layout.viewportWidth - 2, "PPTX 全宽模式应占满页面宽度"); assert(layout.pptxStage && layout.pptxStage.width >= layout.viewportWidth - 40, "PPTX 舞台应随全宽容器放大"); } else { assert(layout.viewer && layout.viewer.width >= Math.min(layout.viewportWidth, 1180) - 24, "PPTX 预览容器应接近宽版宽度"); } } else { assert.equal(layout.pageWidthContentType, "excel", `${item.fileType} 应使用 Excel 宽度类型`); assert.equal(layout.pageWidthMode, item.expectedMode || "full", `${item.fileType} 应使用指定页面宽度`); assert(layout.viewer && layout.viewer.left <= 1, `${item.fileType} 预览容器应从页面左侧开始`); assert(layout.viewer.width >= layout.viewportWidth - 2, `${item.fileType} 预览容器应占满页面宽度`); assert(layout.tableWrap && layout.tableWrap.width >= layout.viewportWidth - 40, `${item.fileType} 表格容器应接近全宽`); } return { fileType: item.fileType, status, text: text.slice(0, 300), layout, screenshot }; } async function main() { await fsp.mkdir(OUT_DIR, { recursive: true }); const root = await fsp.mkdtemp(path.join(os.tmpdir(), "mnote-office-preview-")); await fsp.mkdir(path.join(root, ".mnote"), { recursive: true }); await fsp.writeFile( path.join(root, ".mnote", "workspace.json"), `${JSON.stringify({ workspaceId: WORKSPACE_ID, ownerId: TEST_ACCOUNT, createdAt: new Date().toISOString(), capabilities: ["local_files", "markdown_edit", "asset_upload"], }, null, 2)}\n`, "utf8", ); await writeDocx(path.join(root, "sample.docx")); writeXlsx(path.join(root, "sample.xlsx")); await writePptx(path.join(root, "sample.pptx")); await fsp.writeFile( path.join(root, "sample.csv"), "Name,Value\nMNote CSV light preview works,ok\n中文,正常\n", "utf8", ); const executablePath = resolveChromiumExecutablePath(); const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1", ...(executablePath ? { executablePath } : {}), }); const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); try { await signIn(context); await setPageWidthPreferences(context, root, { "pageWidth.word": { mode: "wide", custom: null }, "pageWidth.excel": { mode: "full", custom: null }, "pageWidth.ppt": { mode: "wide", custom: null }, }); const page = await context.newPage(); const results = []; for (const item of [ { fileName: "sample.docx", fileType: "docx", expected: "MNote DOCX light preview works" }, { fileName: "sample.xlsx", fileType: "xlsx", expected: "MNote XLSX light preview works" }, { fileName: "sample.pptx", fileType: "pptx", expected: "MNote PPTX light preview works" }, { fileName: "sample.csv", fileType: "csv", expected: "MNote CSV light preview works" }, ]) { results.push(await verifyPreview(page, root, item)); } await setPageWidthPreferences(context, root, { "pageWidth.ppt": { mode: "full", custom: null }, }); results.push(await verifyPreview(page, root, { fileName: "sample.pptx", fileType: "pptx", expected: "MNote PPTX light preview works", expectedMode: "full", })); const result = { ok: true, baseUrl: BASE_URL, root, results }; await fsp.writeFile(path.join(OUT_DIR, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8"); console.log(JSON.stringify(result, null, 2)); } finally { await browser.close(); } } main().catch(async (error) => { await fsp.mkdir(OUT_DIR, { recursive: true }); await fsp.writeFile( path.join(OUT_DIR, "result.json"), `${JSON.stringify({ ok: false, baseUrl: BASE_URL, error: error && error.stack ? error.stack : String(error) }, null, 2)}\n`, "utf8", ); console.error(error && error.stack ? error.stack : error); process.exit(1); });