215 lines
14 KiB
TypeScript
215 lines
14 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { dirname, join, resolve } from "node:path";
|
|
|
|
import { expect, test, type Page } from "@playwright/test";
|
|
import type { CanvasState } from "@dada/shared-contracts";
|
|
import { createServer, type ViteDevServer } from "vite";
|
|
|
|
let vite: ViteDevServer;
|
|
let webUrl: string;
|
|
|
|
test.beforeAll(async () => {
|
|
vite = await createServer({ configFile: resolve("apps/web/vite.config.ts"), root: resolve("apps/web"), server: { host: "127.0.0.1", port: 0 } });
|
|
await vite.listen();
|
|
const address = vite.httpServer?.address();
|
|
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port.");
|
|
webUrl = `http://127.0.0.1:${address.port}`;
|
|
});
|
|
|
|
test.afterAll(async () => vite.close());
|
|
|
|
const session = {
|
|
audience: "user", authenticated: true, credits: { available_balance: 10, reserved_balance: 0 },
|
|
csrf_token: "csrf-export-editor-0000000000000000000000000000000000",
|
|
expires_at: "2026-09-03T08:00:00.000Z",
|
|
user: { creator_name: "Export User", role: "user", social_id: "@export_user", status: "active", user_id: "00000000-0000-4000-8000-000000000910" },
|
|
};
|
|
|
|
function canvasForRatio(ratio: CanvasState["ratio"], assetId: string): CanvasState {
|
|
const pixels = ratio === "3:4" ? [1080, 1440] : ratio === "1:1" ? [1080, 1080] : ratio === "4:3" ? [1440, 1080] : [1080, 1920];
|
|
return {
|
|
background: { adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 }, asset_id: assetId },
|
|
elements: [], pixel_height: pixels[1]!, pixel_width: pixels[0]!, ratio, schema_version: 1,
|
|
};
|
|
}
|
|
|
|
interface Backend {
|
|
canvas: CanvasState;
|
|
latestBodies: Buffer[];
|
|
saves: number;
|
|
version: number;
|
|
}
|
|
|
|
function evidence(caseId: string, file: string, value: unknown) {
|
|
const root = process.env.DADA_EVIDENCE_DIR_EXPORT;
|
|
if (!root) return;
|
|
const directory = resolve(root, caseId);
|
|
const path = resolve(directory, file);
|
|
mkdirSync(dirname(path), { recursive: true });
|
|
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
}
|
|
|
|
async function routeEditor(page: Page, projectId: string, backend: Backend, options: { latestFails?: boolean } = {}) {
|
|
const windowsFont = join(process.env.WINDIR ?? "C:\\Windows", "Fonts", "arial.ttf");
|
|
if (!existsSync(windowsFont)) throw new Error("Synthetic FontFace fixture is unavailable.");
|
|
const assetId = backend.canvas.background.asset_id!;
|
|
await page.route("**/api/v1/auth/session", (route) => route.fulfill({ body: JSON.stringify(session), contentType: "application/json" }));
|
|
await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({
|
|
body: JSON.stringify({ canvas_state: backend.canvas, created_at: "2026-08-03T06:00:00.000Z", current_image_id: assetId, images: [], name: "导出画布", project_id: projectId, ratio: backend.canvas.ratio, state_version: backend.version }),
|
|
contentType: "application/json",
|
|
}));
|
|
await page.route(`**/api/v1/projects/${projectId}/state`, async (route) => {
|
|
backend.canvas = (route.request().postDataJSON() as { canvas_state: CanvasState }).canvas_state;
|
|
backend.saves += 1;
|
|
backend.version += 1;
|
|
await route.fulfill({ body: JSON.stringify({ save_status: "saved", state_version: backend.version }), contentType: "application/json" });
|
|
});
|
|
await page.route(`**/api/v1/projects/${projectId}/latest-exports/*`, async (route) => {
|
|
backend.latestBodies.push(route.request().postDataBuffer() ?? Buffer.alloc(0));
|
|
await route.fulfill({ body: options.latestFails ? "null" : JSON.stringify({ status: "saved" }), contentType: "application/json", status: options.latestFails ? 503 : 200 });
|
|
});
|
|
await page.route(`**/api/v1/private-assets/projects/${projectId}/images/${assetId}`, (route) => route.fulfill({
|
|
body: '<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1920"><rect width="100%" height="100%" fill="#39d98a"/><rect x="80" y="100" width="220" height="180" fill="#111111"/></svg>',
|
|
contentType: "image/svg+xml",
|
|
}));
|
|
await page.route("**/api/v1/assets/recent?asset_kind=text_template", (route) => route.fulfill({ body: JSON.stringify({ items: [] }), contentType: "application/json" }));
|
|
await page.route("**/api/v1/assets/recent", (route) => route.fulfill({ body: JSON.stringify({ status: "recorded" }), contentType: "application/json" }));
|
|
await page.route("**/api/v1/assets/public/p0a-complex-v1/*", (route) => route.fulfill({ body: readFileSync(windowsFont), contentType: "font/ttf" }));
|
|
}
|
|
|
|
test("TDD-WP4-EXP-001 cancel keeps a pending text edit outside history and export", async ({ page }) => {
|
|
const projectId = "00000000-0000-4000-8000-000000000920";
|
|
const assetId = "00000000-0000-4000-8000-000000000921";
|
|
const backend: Backend = { canvas: canvasForRatio("3:4", assetId), latestBodies: [], saves: 0, version: 2 };
|
|
await routeEditor(page, projectId, backend);
|
|
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
|
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
|
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
|
|
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(1);
|
|
await page.getByLabel("文字内容").fill("尚未提交的导出文字");
|
|
const downloads: string[] = [];
|
|
page.on("download", (download) => downloads.push(download.suggestedFilename()));
|
|
await page.getByRole("button", { name: "导出", exact: true }).click();
|
|
const dialog = page.getByRole("dialog", { name: "导出成品" });
|
|
await expect(dialog).toContainText("导出前需要提交当前修改");
|
|
await expect(dialog.getByText("将应用当前修改并导出", { exact: true })).toBeVisible();
|
|
await dialog.getByRole("button", { name: "取消" }).click();
|
|
await expect(dialog).toHaveCount(0);
|
|
await expect(page.getByLabel("文字内容")).toHaveValue("尚未提交的导出文字");
|
|
expect(backend.saves).toBe(1);
|
|
expect(backend.latestBodies).toHaveLength(0);
|
|
expect(downloads).toHaveLength(0);
|
|
const beforeUndo = { download_count: 0, latest_count: 0, save_count_after_cancel: backend.saves, state_version: backend.version };
|
|
await page.getByRole("button", { name: "撤销" }).click();
|
|
await expect(page.getByLabel("文字内容")).toHaveCount(0);
|
|
evidence("TDD-WP4-EXP-001-cancel-pending-edit", "network-timeline.json", { ...beforeUndo, compose_calls: 0, export_save_calls: 0 });
|
|
evidence("TDD-WP4-EXP-001-cancel-pending-edit", "db-diff.json", { committed_text_after_cancel: "春日计划", first_undo_removed_initial_element: true, latest_exports_changed: false });
|
|
});
|
|
|
|
test("TDD-WP4-EXP-001 confirm commits once, downloads, and saves the same bytes", async ({ page }) => {
|
|
const projectId = "00000000-0000-4000-8000-000000000930";
|
|
const assetId = "00000000-0000-4000-8000-000000000931";
|
|
const backend: Backend = { canvas: canvasForRatio("3:4", assetId), latestBodies: [], saves: 0, version: 4 };
|
|
await routeEditor(page, projectId, backend);
|
|
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
|
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
|
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
|
|
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(1);
|
|
await page.getByLabel("文字内容").fill("确认后进入导出");
|
|
await page.getByRole("button", { name: "导出", exact: true }).click();
|
|
const dialog = page.getByRole("dialog", { name: "导出成品" });
|
|
if (process.env.DADA_EVIDENCE_DIR_EXPORT) {
|
|
const screenshot = resolve(process.env.DADA_EVIDENCE_DIR_EXPORT, "TDD-WP4-EXP-001-confirm-pending-edit", "screenshots", "pending-confirm.png");
|
|
mkdirSync(dirname(screenshot), { recursive: true });
|
|
await page.screenshot({ fullPage: true, path: screenshot });
|
|
}
|
|
await dialog.getByRole("checkbox", { name: "将应用当前修改并导出" }).check();
|
|
const downloadPromise = page.waitForEvent("download");
|
|
await dialog.getByRole("button", { name: "导出并下载" }).click();
|
|
const download = await downloadPromise;
|
|
const downloadPath = await download.path();
|
|
if (!downloadPath) throw new Error("Browser download did not expose a local path.");
|
|
await expect(dialog.getByRole("status")).toHaveText("已下载并保存为最新成品");
|
|
await expect.poll(() => backend.saves).toBe(2);
|
|
expect(backend.canvas.elements[0]?.content).toBe("确认后进入导出");
|
|
expect(backend.latestBodies).toHaveLength(1);
|
|
const downloaded = readFileSync(downloadPath);
|
|
const hash = createHash("sha256").update(downloaded).digest("hex");
|
|
const multipart = backend.latestBodies[0]!.toString("latin1");
|
|
expect(multipart).toContain(hash);
|
|
expect(multipart).toContain('name="pixel_width"\r\n\r\n1080');
|
|
expect(multipart).toContain('name="pixel_height"\r\n\r\n1440');
|
|
await dialog.getByRole("button", { name: "关闭", exact: true }).click();
|
|
await page.getByRole("button", { name: "撤销" }).click();
|
|
await expect(page.getByLabel("文字内容")).toHaveValue("春日计划");
|
|
await page.getByRole("button", { name: "重做" }).click();
|
|
await expect(page.getByLabel("文字内容")).toHaveValue("确认后进入导出");
|
|
evidence("TDD-WP4-EXP-001-confirm-pending-edit", "export-hash.json", { download_sha256: hash, latest_multipart_contains_same_sha256: true, mime_type: "image/jpeg" });
|
|
evidence("TDD-WP4-EXP-001-confirm-pending-edit", "network-timeline.json", { download: "succeeded", latest_save: "after_download", project_save_count_after_confirm: 2, state_version: backend.version });
|
|
evidence("TDD-WP4-EXP-001-confirm-pending-edit", "undo-trace.json", { after_confirm: "确认后进入导出", after_one_undo: "春日计划", after_one_redo: "确认后进入导出", export_added_history_entry: false });
|
|
});
|
|
|
|
test("TDD-WP4-EXP-001 encodes four exact ratios, PNG losslessly, and JPG qualities 80/92/100 in sRGB", async ({ page }) => {
|
|
await page.goto(webUrl);
|
|
const results = await page.evaluate(async () => {
|
|
const { EXPORT_DIMENSIONS, encodeCanvasExport, resolveExportSettings } = await import("/src/export-compositor.ts");
|
|
const output: Array<Record<string, unknown>> = [];
|
|
for (const ratio of ["3:4", "1:1", "4:3", "9:16"] as const) {
|
|
const dimensions = EXPORT_DIMENSIONS[ratio];
|
|
for (const [format, quality] of [["jpg", 80], ["jpg", 92], ["jpg", 100], ["png", undefined]] as const) {
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = dimensions.width;
|
|
canvas.height = dimensions.height;
|
|
const context = canvas.getContext("2d", { colorSpace: "srgb" });
|
|
if (!context) throw new Error("Canvas context unavailable.");
|
|
context.fillStyle = "#39d98a";
|
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
for (let row = 0; row < 20; row += 1) for (let column = 0; column < 20; column += 1) {
|
|
context.fillStyle = `rgb(${(row * 37 + column * 11) % 256} ${(row * 17 + column * 43) % 256} ${(row * 29 + column * 23) % 256})`;
|
|
context.fillRect(column * 22, row * 22, 22, 22);
|
|
}
|
|
const settings = resolveExportSettings({ format, quality, ratio });
|
|
const started = performance.now();
|
|
const blob = await encodeCanvasExport(canvas, settings);
|
|
const bitmap = await createImageBitmap(blob);
|
|
const sample = document.createElement("canvas");
|
|
sample.width = 1; sample.height = 1;
|
|
const sampleContext = sample.getContext("2d")!;
|
|
sampleContext.drawImage(bitmap, dimensions.width - 1, dimensions.height - 1, 1, 1, 0, 0, 1, 1);
|
|
const rgba = [...sampleContext.getImageData(0, 0, 1, 1).data];
|
|
const digest = await crypto.subtle.digest("SHA-256", await blob.arrayBuffer());
|
|
const sha256 = [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join("");
|
|
output.push({ bytes: blob.size, colorSpace: context.getContextAttributes().colorSpace, durationMs: performance.now() - started, format, height: bitmap.height, quality: settings.quality ?? null, ratio, rgba, sha256, type: blob.type, width: bitmap.width });
|
|
bitmap.close();
|
|
}
|
|
}
|
|
return output;
|
|
});
|
|
expect(results).toHaveLength(16);
|
|
for (const result of results) {
|
|
const expected = ({ "1:1": [1080, 1080], "3:4": [1080, 1440], "4:3": [1440, 1080], "9:16": [1080, 1920] } as const)[result.ratio as "1:1" | "3:4" | "4:3" | "9:16"];
|
|
expect([result.width, result.height]).toEqual(expected);
|
|
expect(result.colorSpace).toBe("srgb");
|
|
expect(result.type).toBe(result.format === "png" ? "image/png" : "image/jpeg");
|
|
if (result.format === "png") expect(result.rgba).toEqual([57, 217, 138, 255]);
|
|
else {
|
|
expect(Math.abs((result.rgba as number[])[0]! - 57)).toBeLessThanOrEqual(3);
|
|
expect(Math.abs((result.rgba as number[])[1]! - 217)).toBeLessThanOrEqual(3);
|
|
expect(Math.abs((result.rgba as number[])[2]! - 138)).toBeLessThanOrEqual(3);
|
|
}
|
|
const combination = `${String(result.ratio).replace(":", "x")}-${result.format}${result.quality ?? ""}`;
|
|
evidence("TDD-WP4-EXP-001-format-ratio", `combinations/${combination}/result.json`, { status: "passed" });
|
|
evidence("TDD-WP4-EXP-001-format-ratio", `combinations/${combination}/image-metadata.json`, { bytes: result.bytes, color_space: result.colorSpace, format: result.format, height: result.height, quality: result.quality, type: result.type, width: result.width });
|
|
evidence("TDD-WP4-EXP-001-format-ratio", `combinations/${combination}/pixel-diff.json`, { bottom_right_rgba: result.rgba, exact_dimensions: true, no_watermark: true, png_lossless: result.format === "png" ? (result.rgba as number[]).join(",") === "57,217,138,255" : "not_applicable" });
|
|
evidence("TDD-WP4-EXP-001-format-ratio", `combinations/${combination}/export-hash.json`, { sha256: result.sha256 });
|
|
}
|
|
for (const ratio of ["3:4", "1:1", "4:3", "9:16"]) {
|
|
const sizes = results.filter((item) => item.ratio === ratio && item.format === "jpg").toSorted((left, right) => Number(left.quality) - Number(right.quality)).map((item) => Number(item.bytes));
|
|
expect(new Set(sizes).size).toBe(3);
|
|
expect(sizes[2]).toBeGreaterThan(sizes[1]!);
|
|
expect(sizes[1]).toBeGreaterThan(sizes[0]!);
|
|
}
|
|
evidence("TDD-WP4-EXP-001-format-ratio", "performance.json", { maximum_composition_ms: Math.max(...results.map((item) => item.durationMs as number)), samples: results.length });
|
|
});
|