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: '', 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 automatically saved text outside the 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("自动保存的导出文字"); await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(2); 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).not.toContainText("导出前需要提交当前修改"); await expect(dialog.getByRole("checkbox", { name: "将应用当前修改并导出" })).toHaveCount(0); await dialog.getByRole("button", { name: "取消" }).click(); await expect(dialog).toHaveCount(0); await expect(page.getByLabel("文字内容")).toHaveValue("自动保存的导出文字"); expect(backend.saves).toBe(2); expect(backend.canvas.elements[0]?.content).toBe("自动保存的导出文字"); 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("文字内容")).toHaveValue("春日计划"); 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_restored_initial_text: true, latest_exports_changed: false }); }); test("TDD-WP4-EXP-001 exports automatically saved text 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 expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(2); 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 expect(dialog.getByRole("checkbox", { name: "将应用当前修改并导出" })).toHaveCount(0); 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("已下载并保存为最新成品"); expect(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> = []; 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 }); });