155 lines
7.6 KiB
TypeScript
155 lines
7.6 KiB
TypeScript
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(
|
|
<AgentPsdTemplateExportPreview imageUrl="https://assets.example/result.png"
|
|
enableDimensionAnnotations downloadName="dimensions.png" />
|
|
);`,
|
|
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 <img {...props} style={{ ...(fill ? { position: "absolute", inset: 0, width: "100%", height: "100%" } : {}), ...style }} />;
|
|
}`,
|
|
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: `<!doctype html><style>
|
|
#root { width: 360px; } .relative { position: relative; }
|
|
.absolute { position: absolute; } .inset-0 { inset: 0; }
|
|
.h-full { height: 100%; } .w-full { width: 100%; }
|
|
button svg { width: 16px; height: 16px; }
|
|
</style><div id="root"></div>` });
|
|
});
|
|
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" });
|
|
});
|
|
}
|