Files
mnote/scripts/task501-office-preview-light-viewer-smoke.js
T

362 lines
20 KiB
JavaScript

#!/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", `<?xml version="1.0" encoding="UTF-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
</Types>`);
zip.folder("_rels").file(".rels", `<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>`);
zip.folder("word").file("document.xml", `<?xml version="1.0" encoding="UTF-8"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>
<w:p><w:r><w:t>MNote DOCX light preview works</w:t></w:r></w:p>
<w:p><w:r><w:t>第二行测试内容</w:t></w:r></w:p>
<w:sectPr/>
</w:body>
</w:document>`);
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", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>
<Override PartName="/ppt/slides/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>
<Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/>
<Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>
<Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>
</Types>`);
zip.folder("_rels").file(".rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/>
</Relationships>`);
zip.folder("ppt").file("presentation.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:presentation xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst>
<p:sldIdLst><p:sldId id="256" r:id="rId2"/></p:sldIdLst>
<p:sldSz cx="12192000" cy="6858000" type="wide"/>
<p:notesSz cx="6858000" cy="9144000"/>
<p:defaultTextStyle>
<a:defPPr><a:defRPr lang="zh-CN"/></a:defPPr>
<a:lvl1pPr marL="0" algn="l"><a:defRPr sz="1800" lang="zh-CN"/></a:lvl1pPr>
</p:defaultTextStyle>
</p:presentation>`);
zip.folder("ppt").folder("_rels").file("presentation.xml.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/>
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="theme/theme1.xml"/>
</Relationships>`);
zip.folder("ppt").folder("slideMasters").file("slideMaster1.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sldMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr></p:spTree></p:cSld>
<p:sldLayoutIdLst><p:sldLayoutId id="2147483649" r:id="rId1"/></p:sldLayoutIdLst>
</p:sldMaster>`);
zip.folder("ppt").folder("slideMasters").folder("_rels").file("slideMaster1.xml.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
</Relationships>`);
zip.folder("ppt").folder("slideLayouts").file("slideLayout1.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sldLayout xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" type="blank">
<p:cSld name="Blank"><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr></p:spTree></p:cSld>
</p:sldLayout>`);
zip.folder("ppt").folder("slideLayouts").folder("_rels").file("slideLayout1.xml.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="../slideMasters/slideMaster1.xml"/>
</Relationships>`);
zip.folder("ppt").folder("slides").file("slide1.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:spTree>
<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>
<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>
<p:sp>
<p:nvSpPr><p:cNvPr id="2" name="Title"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="914400" y="914400"/><a:ext cx="10363200" cy="1371600"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr>
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:rPr lang="zh-CN" sz="4400"/><a:t>MNote PPTX light preview works</a:t></a:r></a:p></p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
</p:sld>`);
zip.folder("ppt").folder("slides").folder("_rels").file("slide1.xml.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
</Relationships>`);
zip.folder("ppt").folder("theme").file("theme1.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="MNote">
<a:themeElements>
<a:clrScheme name="MNote"><a:dk1><a:srgbClr val="000000"/></a:dk1><a:lt1><a:srgbClr val="FFFFFF"/></a:lt1><a:dk2><a:srgbClr val="1F2937"/></a:dk2><a:lt2><a:srgbClr val="F8FAFC"/></a:lt2><a:accent1><a:srgbClr val="2563EB"/></a:accent1><a:accent2><a:srgbClr val="22A559"/></a:accent2><a:accent3><a:srgbClr val="E0525B"/></a:accent3><a:accent4><a:srgbClr val="F59E0B"/></a:accent4><a:accent5><a:srgbClr val="8B5CF6"/></a:accent5><a:accent6><a:srgbClr val="14B8A6"/></a:accent6><a:hlink><a:srgbClr val="2563EB"/></a:hlink><a:folHlink><a:srgbClr val="7C3AED"/></a:folHlink></a:clrScheme>
<a:fontScheme name="MNote"><a:majorFont><a:latin typeface="Arial"/></a:majorFont><a:minorFont><a:latin typeface="Arial"/></a:minorFont></a:fontScheme>
<a:fmtScheme name="MNote"><a:fillStyleLst/><a:lnStyleLst/><a:effectStyleLst/><a:bgFillStyleLst/></a:fmtScheme>
</a:themeElements>
</a:theme>`);
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);
});