import { expect, test, type Page } from "@playwright/test"; import JSZip from "jszip"; import sharp from "sharp"; import { clickXmindAction, clickXmindSheet } from "./editor-actions"; async function openMap(page: Page) { await page.goto("/dev/flow-board-preview", { waitUntil: "commit" }); const zip = new JSZip(); const image = await sharp({ create: { width: 40, height: 40, channels: 4, background: "#fa1515" } }).png().toBuffer(); zip.file("resources/red.png", image); zip.file("content.json", JSON.stringify([ { title: "完整画布", rootTopic: { id: "root", title: "图片导出验证", structureClass: "org.xmind.ui.logic.right", children: { attached: Array.from({ length: 20 }, (_, index) => ({ id: `topic-${index}`, title: `任务 ${index + 1}`, ...(index === 0 ? { image: { src: "xap:resources/red.png", width: 40 }, markers: [{ markerId: "priority-1" }] } : {}), })) } } }, { title: "第二页", rootTopic: { id: "other", title: "第二页内容" } }, ])); await page.locator("input[accept*=xmind]").setInputFiles({ name: "图片验证.xmind", mimeType: "application/octet-stream", buffer: await zip.generateAsync({ type: "nodebuffer" }) }); await expect(page.locator(".drawnix").getByText("图片导出验证", { exact: true })).toBeVisible(); await expect.poll(() => page.locator(".drawnix .xmind-topic-marker").evaluate((node: HTMLImageElement) => node.complete && node.naturalWidth > 0)).toBe(true); } async function exportPng(page: Page, scale: number, transparent: boolean) { await page.getByRole("button", { name: "更多导出选项", exact: true }).click(); await page.getByRole("menuitem", { name: "导出图片", exact: true }).click(); const dialog = page.getByRole("dialog", { name: "导出图片", exact: true }); await dialog.getByLabel("图片清晰度").selectOption(String(scale)); await dialog.getByLabel("透明背景").setChecked(transparent); const pending = page.waitForEvent("download"); await dialog.getByRole("button", { name: "导出 PNG", exact: true }).click(); const download = await pending; const stream = await download.createReadStream(); const chunks: Buffer[] = []; for await (const chunk of stream!) chunks.push(chunk); await expect(dialog).not.toBeVisible(); return { bytes: Buffer.concat(chunks), download }; } test("PNG exports full content with text, markers, images and transparent high resolution independently of zoom", async ({ page }) => { const errors: string[] = []; page.on("pageerror", error => errors.push(error.message)); await openMap(page); const standard = await exportPng(page, 1, false); expect(standard.download.suggestedFilename()).toBe("图片验证-完整画布.png"); const { data, info } = await sharp(standard.bytes).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); expect(info.height).toBeGreaterThan(900); // Includes branches outside the viewport. expect(Array.from(data.subarray(0, 4))).toEqual([255, 255, 255, 255]); let red = 0, dark = 0; for (let index = 0; index < data.length; index += 4) { if (data[index] > 210 && data[index + 1] < 60 && data[index + 2] < 60) red++; if (data[index] < 80 && data[index + 1] < 80 && data[index + 2] < 80) dark++; } expect(red).toBeGreaterThan(500); expect(dark).toBeGreaterThan(300); // Topic text is rasterized, not just node shapes. await standard.download.saveAs(test.info().outputPath("complete.png")); const high = await exportPng(page, 2, true); const highInfo = await sharp(high.bytes).metadata(); expect(highInfo.width).toBe(info.width * 2); expect(highInfo.height).toBe(info.height * 2); const corner = await sharp(high.bytes).extract({ left: 0, top: 0, width: 1, height: 1 }).ensureAlpha().raw().toBuffer(); expect(corner[3]).toBe(0); await high.download.saveAs(test.info().outputPath("transparent.png")); await clickXmindAction(page, "缩小"); await clickXmindAction(page, "缩小"); const zoomed = await exportPng(page, 2, true); expect(zoomed.bytes.equals(high.bytes)).toBe(true); await clickXmindSheet(page, "第二页"); await expect(page.locator(".drawnix").getByText("第二页内容", { exact: true })).toBeVisible(); const second = await exportPng(page, 3, false); expect(second.download.suggestedFilename()).toBe("图片验证-第二页.png"); expect((await sharp(second.bytes).metadata()).height).toBeLessThan(info.height); expect(errors).toEqual([]); }); test("image export is keyboard dismissible and failed resources show a recoverable error", async ({ page }) => { await openMap(page); await page.getByRole("button", { name: "文件与编辑", exact: true }).click(); await page.getByRole("menuitem", { name: "导出图片", exact: true }).click(); const dialog = page.getByRole("dialog", { name: "导出图片", exact: true }); await expect(dialog).toBeVisible(); await page.keyboard.press("Escape"); await expect(dialog).not.toBeVisible(); await page.route("**/fonts/xmind/NeverMind-Bold.ttf", route => route.fulfill({ status: 503, body: "unavailable" })); await page.getByRole("button", { name: "更多导出选项", exact: true }).click(); await page.getByRole("menuitem", { name: "导出图片", exact: true }).click(); await dialog.getByRole("button", { name: "导出 PNG", exact: true }).click(); await expect(page.getByText("图片资源加载失败,请稍后重试", { exact: true })).toBeVisible(); await expect(dialog.getByRole("button", { name: "导出 PNG", exact: true })).toBeEnabled(); await dialog.getByRole("button", { name: "取消", exact: true }).click(); await expect(dialog).not.toBeVisible(); }); test("PNG includes relationships, boundaries and summaries without selection outlines", async ({ page }) => { await page.goto("/dev/flow-board-preview", { waitUntil: "commit" }); const zip = new JSZip(); zip.file("content.json", JSON.stringify([{ title: "结构", rootTopic: { id: "r", title: "结构验证", structureClass: "org.xmind.ui.logic.right", children: { attached: [{ id: "a", title: "设计" }, { id: "b", title: "交付" }], summary: [{ id: "s", title: "阶段概要" }] }, summaries: [{ range: "(0,1)", topicId: "s" }], boundaries: [{ range: "(0,1)", style: { properties: { "line-color": "#00ff00" } } }], }, relationships: [{ end1Id: "a", end2Id: "b", title: "验收联系", style: { properties: { "line-color": "#ff00ff" } } }] }])); await page.locator("input[accept*=xmind]").setInputFiles({ name: "结构验证.xmind", mimeType: "application/octet-stream", buffer: await zip.generateAsync({ type: "nodebuffer" }) }); const canvas = page.locator(".drawnix"); await expect(canvas.getByText("阶段概要", { exact: true })).toBeVisible(); await expect(canvas.getByText("验收联系", { exact: true })).toBeVisible(); const plain = await exportPng(page, 2, false); const data = await sharp(plain.bytes).ensureAlpha().raw().toBuffer(); let green = 0, purple = 0; for (let index = 0; index < data.length; index += 4) { if (data[index] < 60 && data[index + 1] > 200 && data[index + 2] < 60) green++; if (data[index] > 200 && data[index + 1] < 60 && data[index + 2] > 200) purple++; } expect(green).toBeGreaterThan(40); expect(purple).toBeGreaterThan(40); await canvas.getByText("设计", { exact: true }).click(); const selected = await exportPng(page, 2, false); expect(selected.bytes.equals(plain.bytes)).toBe(true); await selected.download.saveAs(test.info().outputPath("annotations.png")); });