Files
tyx_AI_xhs/tests/e2e/wp4-05-export.spec.ts
T
suyx c0a8bd6d43
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 2m34s
test: complete WP4-07 visual and performance budgets
2026-08-04 12:55:25 +08:00

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 });
});