import { createRequire } from "node:module"; import { expect, test } from "@playwright/test"; import sharp from "sharp"; // tsx supplies the bundler; exercise the real component without an app server, // authentication, storage, or a paid PSD render. const { build } = createRequire(require.resolve("tsx"))("esbuild"); let bundle: string; test.beforeAll(async () => { const result = await build({ stdin: { contents: ` import { createRoot } from "react-dom/client"; import { AgentPsdTemplateExportPreview } from "./components/agents/agent-psd-template-export-preview"; createRoot(document.getElementById("root")).render( );`, resolveDir: process.cwd(), loader: "tsx", }, bundle: true, write: false, platform: "browser", jsx: "automatic", define: { "process.env.NODE_ENV": '"test"' }, plugins: [{ name: "next-image-in-browser", setup(plugin: { onResolve: (options: { filter: RegExp }, callback: () => object) => void; onLoad: (options: { filter: RegExp; namespace: string }, callback: () => object) => void; }) { plugin.onResolve({ filter: /^next\/image$/ }, () => ({ path: "image", namespace: "test" })); plugin.onLoad({ filter: /.*/, namespace: "test" }, () => ({ contents: `import React from "react"; export default function Image({ fill, unoptimized, sizes, style, ...props }) { return ; }`, loader: "jsx", resolveDir: process.cwd(), })); }, }], }); bundle = result.outputFiles[0].text; }); for (const scenario of [ { name: "a smaller square preview", preview: [800, 800], output: [1600, 1600] }, { name: "matching resolutions", preview: [800, 800], output: [800, 800] }, { name: "a portrait preview with side padding", preview: [400, 800], output: [800, 1600] }, { name: "a landscape preview with top padding", preview: [800, 400], output: [1600, 800] }, ]) { test(`export preserves dimension positions and text from ${scenario.name}`, async ({ page }, testInfo) => { const makeImage = (width: number, height: number) => sharp({ create: { width, height, channels: 3, background: "white" } }).png().toBuffer(); const preview = await makeImage(scenario.preview[0], scenario.preview[1]); const original = await makeImage(scenario.output[0], scenario.output[1]); await page.route("https://dimensions.test/**", (route) => { const url = new URL(route.request().url()); return route.fulfill(url.pathname === "/api/image-proxy" ? { contentType: "image/png", body: original } : { contentType: "text/html", body: `
` }); }); await page.route("https://assets.example/result.png", (route) => route.fulfill({ contentType: "image/png", body: preview })); await page.goto("https://dimensions.test/"); await page.addScriptTag({ content: bundle }); const overlay = page.locator("svg.absolute"); await expect(overlay).toBeVisible(); const image = page.getByAltText("PSD 模板导出结果"); const imageRect = await image.boundingBox(); expect(imageRect).not.toBeNull(); if (!imageRect) throw new Error("Missing preview image"); for (const points of [ [0.12, 0.2, 0.12, 0.75], [0.25, 0.76, 0.8, 0.9], ]) { await page.getByRole("button", { name: "新增尺寸线" }).click(); await page.mouse.move(imageRect.x + points[0] * imageRect.width, imageRect.y + points[1] * imageRect.height); await page.mouse.down(); await page.mouse.move(imageRect.x + points[2] * imageRect.width, imageRect.y + points[3] * imageRect.height, { steps: 5 }); await page.mouse.up(); await page.getByRole("textbox").first().fill(points[0] === 0.12 ? "40" : "80"); } // Read the visible SVG, independently of the component's coordinate helpers. const expected = await overlay.locator(":scope > g").evaluateAll((groups) => groups.map((group) => { const line = group.querySelector("line"); const text = group.querySelector("text"); if (!line || !text) throw new Error("Missing annotation"); const textTransform = text.getCTM(); if (!textTransform) throw new Error("Missing text transform"); const textPosition = new DOMPoint( Number(text.getAttribute("x")), Number(text.getAttribute("y")) ).matrixTransform(textTransform); return { x1: Number(line.getAttribute("x1")), y1: Number(line.getAttribute("y1")), x2: Number(line.getAttribute("x2")), y2: Number(line.getAttribute("y2")), textX: textPosition.x, textY: textPosition.y, fontSize: Number(text.getAttribute("font-size")), label: text.textContent, }; })); expect(expected).toHaveLength(2); const overlayRect = await overlay.boundingBox(); if (!overlayRect) throw new Error("Missing annotation overlay"); await page.evaluate(() => { const recorded: { x: number; y: number; size: number; label: string }[] = []; Object.assign(window, { exportedDimensionText: recorded }); const fillText = CanvasRenderingContext2D.prototype.fillText; CanvasRenderingContext2D.prototype.fillText = function (label, x, y) { const transform = this.getTransform(); const point = transform.transformPoint({ x, y }); recorded.push({ x: point.x, y: point.y, size: Number.parseFloat(this.font.split(" ")[1]) * Math.hypot(transform.a, transform.b), label }); fillText.call(this, label, x, y); }; }); const downloadReady = page.waitForEvent("download"); await page.getByRole("button", { name: "下载结果图" }).click(); const download = await downloadReady; const path = testInfo.outputPath("dimensions.png"); await download.saveAs(path); const { data, info } = await sharp(path).removeAlpha().raw().toBuffer({ resolveWithObject: true }); expect([info.width, info.height]).toEqual(scenario.output); const scale = info.width / imageRect.width; const left = imageRect.x - overlayRect.x; const top = imageRect.y - overlayRect.y; const texts = await page.evaluate(() => (window as unknown as { exportedDimensionText: { x: number; y: number; size: number; label: string }[]; }).exportedDimensionText); expect(texts).toHaveLength(2); for (const [index, annotation] of expected.entries()) { expect(texts[index].label).toBe(annotation.label); expect(texts[index].x).toBeCloseTo((annotation.textX - left) * scale, 3); expect(texts[index].y).toBeCloseTo((annotation.textY - top) * scale, 3); expect(texts[index].size).toBeCloseTo(annotation.fontSize * scale, 4); for (const fraction of [0.1, 0.5, 0.9]) { const x = Math.round((annotation.x1 + (annotation.x2 - annotation.x1) * fraction - left) * scale); const y = Math.round((annotation.y1 + (annotation.y2 - annotation.y1) * fraction - top) * scale); const offset = (y * info.width + x) * info.channels; expect(data[offset], `dimension ${index + 1} at ${fraction}`).toBeLessThan(180); } } await testInfo.attach("exported dimensions", { path, contentType: "image/png" }); }); }