import { expect, test } from "@playwright/test"; import { readFile, readdir } from "node:fs/promises"; import path from "node:path"; import JSZip from "jszip"; // Optional, locally extracted fixtures; do not redistribute XMind's bundled samples. const fixtureDirectory = process.env.XMIND_NATIVE_FIXTURES; test("native XMind basic samples retain every topic through import and export", async ({ page, }) => { test.skip( !fixtureDirectory, "Set XMIND_NATIVE_FIXTURES to an extracted basic sample directory" ); test.setTimeout(120_000); const files = (await readdir(fixtureDirectory!)).filter((name) => name.endsWith(".xmind") ); expect(files.length).toBeGreaterThan(0); const titles = (topic: any): string[] => [ String(topic.title ?? "").trim() || "未命名主题", ...Object.values(topic.children || {}).flatMap((group) => (group as any[]).flatMap(titles) ), ]; const errors: string[] = []; page.on("pageerror", (error) => errors.push(error.message)); await page.goto("/dev/flow-board-preview", { waitUntil: "commit" }); for (const name of files) await test.step(name, async () => { const buffer = await readFile(path.join(fixtureDirectory!, name)); const input = await JSZip.loadAsync(buffer); const original = JSON.parse( await input.file("content.json")!.async("string") ); await page .locator("input[accept*=xmind]") .setInputFiles({ name, mimeType: "application/octet-stream", buffer }); await expect(page.getByLabel("白板标题", { exact: true })).toHaveValue( name.replace(/\.xmind$/, "") ); const special = ( { "org.xmind.ui.fishbone.leftHeaded": "fishbone", "org.xmind.ui.timeline.sided.horizontal": "timeline", "org.xmind.ui.timeline.vertical": "verticalTimeline", "org.xmind.ui.treetable.toptitle": "treeTable", "org.xmind.ui.treetable": "treeTableSide", "org.xmind.ui.spreadsheet": "spreadsheet", "org.xmind.ui.brace.right": "brace", } as Record )[original[0].rootTopic.structureClass]; if (["treeTable", "treeTableSide", "spreadsheet"].includes(special)) { const table = page.locator( `.drawnix [data-special-table="${special}"]` ); await expect(table).toBeVisible(); expect( await table.locator("[data-special-table-cell]").count() ).toBeGreaterThan(1); } else if (special === "verticalTimeline") { // A straight SVG path has a zero-width geometric box despite its visible stroke. const axis = page.locator( '.drawnix [data-special-axis="verticalTimeline"] path' ); await expect(axis).toHaveAttribute("d", /V/); expect(Number(await axis.getAttribute("stroke-width"))).toBeGreaterThan( 0 ); } else if (special) await expect( page .locator( `.drawnix [data-special-structure="${special}"] ${special === "timeline" ? "circle" : "path"}` ) .first() ).toBeVisible(); if (name === "mind_map.xmind") { const topic = page.locator(".drawnix").getByText("Central Topic", { exact: true }); await expect(topic).toHaveCSS("font-family", /NeverMind/); const metrics = await topic.evaluate(element => { const container = element.closest(".plait-text-container")!; const foreign = container.parentElement!; return { contentHeight: container.scrollHeight, frameHeight: Number(foreign.getAttribute("height")) }; }); expect(metrics.contentHeight).toBeLessThanOrEqual(Math.ceil(metrics.frameHeight)); } const downloadPromise = page.waitForEvent("download"); await page .getByRole("button", { name: "导出 XMind", exact: true }) .click(); const download = await downloadPromise; const stream = await download.createReadStream(); const chunks = []; for await (const chunk of stream!) chunks.push(chunk); const zip = await JSZip.loadAsync(Buffer.concat(chunks)); const output = JSON.parse( await zip.file("content.json")!.async("string") ); expect(output).toHaveLength(original.length); if (special) expect(output[0].rootTopic.structureClass).toBe( original[0].rootTopic.structureClass ); for (let i = 0; i < original.length; i++) expect(titles(output[i].rootTopic).sort(), name).toEqual( titles(original[i].rootTopic).sort() ); expect(errors, name).toEqual([]); }); });