import { expect, test, type Page, type TestInfo } from "@playwright/test"; import { createServer, type ViteDevServer } from "vite"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; // The fixture is plain ESM so the same immutable contract is consumed by Node and Playwright. // @ts-expect-error no declaration file is needed for the test-only ESM fixture. import { WP4_07_PERFORMANCE_BUDGETS, WP4_07_REAL_RESOURCE_VERSIONS, WP4_07_RED_RESOURCE_VERSION, WP4_07_REQUIRED_FONT_IDS, assertWp407Fixture, createWp407CanvasFixture, wp407FixtureSha256, } from "../visual-performance/wp4-07-fixture.mjs"; // @ts-expect-error no declaration file is needed for the Node-only archive loader. import { loadWp407RealAssets } from "../visual-performance/wp4-07-real-assets.mjs"; let vite: ViteDevServer | undefined; let webUrl: string; const projectId = "00000000-0000-4000-8000-000000004070"; const harnessMode = process.env.DADA_WP4_07_HARNESS_MODE; if (!new Set(["red_contract", "real_archive"]).has(harnessMode ?? "")) throw new Error("WP4_07_HARNESS_MODE_REQUIRED"); const greenEligible = harnessMode === "real_archive"; const fixtureVersions = greenEligible ? WP4_07_REAL_RESOURCE_VERSIONS : WP4_07_RED_RESOURCE_VERSION; const fixedCanvas = createWp407CanvasFixture(fixtureVersions); const realAssets = greenEligible ? loadWp407RealAssets() : undefined; test.beforeAll(async () => { assertWp407Fixture(fixtureVersions); 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-wp4-07-red-contract-000000000000000000000000", expires_at: "2026-09-03T08:00:00.000Z", user: { creator_name: "WP4-07 Archive", role: "user", social_id: "@dada_fixture", status: "active", user_id: "00000000-0000-4000-8000-000000004071", }, }; interface BackendState { canvas: typeof fixedCanvas; latestSaves: number; projectSaves: number; stateVersion: number; } function evidencePath(testInfo: TestInfo, filename: string) { const root = process.env.DADA_WP4_07_EVIDENCE_DIR; if (!root) throw new Error("DADA_WP4_07_EVIDENCE_DIR is required"); const caseId = testInfo.title.startsWith("TDD-WP4-VIS-001") ? "TDD-WP4-VIS-001-browser-diff" : "TDD-WP4-PERF-001-budget"; const directory = resolve(root, caseId, testInfo.project.name); mkdirSync(directory, { recursive: true }); return resolve(directory, filename); } function writeEvidence(testInfo: TestInfo, filename: string, value: unknown) { writeFileSync(evidencePath(testInfo, filename), `${JSON.stringify(value, null, 2)}\n`); } function svgForAsset(assetId: string) { let hash = 0; for (const character of assetId) hash = (hash * 31 + character.charCodeAt(0)) >>> 0; const fill = `#${(hash & 0xffffff).toString(16).padStart(6, "0")}`; const accent = `#${((hash ^ 0xf2f400) & 0xffffff).toString(16).padStart(6, "0")}`; return `${assetId.replaceAll("&", "")}`; } async function routeEditor(page: Page, backend: BackendState) { const fontBytes = greenEligible ? undefined : readFileSync("C:\\Windows\\Fonts\\arial.ttf"); await page.route("**/api/v1/auth/session", (route) => route.fulfill({ body: JSON.stringify(session), contentType: "application/json", status: 200 })); await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({ body: JSON.stringify({ canvas_state: backend.canvas, created_at: "2026-07-27T04:00:00.000Z", current_image_id: fixedCanvas.background.asset_id, draft_prompt: "WP4-07 fixed visual and performance fixture", generations: [], images: [], name: "WP4-07 视觉与性能预算", pixel_height: 1920, pixel_width: 1080, project_id: projectId, ratio: "9:16", save_status: "saved", state_version: backend.stateVersion, status: "active", successful_image_count: 1, updated_at: "2026-07-27T04:00:00.000Z", }), contentType: "application/json", status: 200, })); await page.route(`**/api/v1/projects/${projectId}/state`, async (route) => { backend.canvas = (route.request().postDataJSON() as { canvas_state: typeof fixedCanvas }).canvas_state; backend.projectSaves += 1; backend.stateVersion += 1; await route.fulfill({ body: JSON.stringify({ save_status: "saved", state_version: backend.stateVersion }), contentType: "application/json", status: 200 }); }); await page.route(`**/api/v1/projects/${projectId}/latest-exports/*`, async (route) => { backend.latestSaves += 1; await route.fulfill({ body: JSON.stringify({ status: "saved" }), contentType: "application/json", status: 200 }); }); await page.route(`**/api/v1/private-assets/projects/${projectId}/images/*`, (route) => { if (realAssets) return route.fulfill({ body: readFileSync(realAssets.background.path), contentType: realAssets.background.content_type, status: 200 }); return route.fulfill({ body: ``, contentType: "image/svg+xml", status: 200, }); }); 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/**", (route) => { const url = new URL(route.request().url()); const parts = decodeURIComponent(url.pathname).split("/").filter(Boolean); const assetId = parts.at(-1) ?? "asset"; const resourceVersion = parts.at(-2) ?? "missing"; if (realAssets) { const expectedVersion = assetId.startsWith("STK") ? WP4_07_REAL_RESOURCE_VERSIONS.static : WP4_07_REAL_RESOURCE_VERSIONS.complex; const asset = realAssets.publicAssets.get(assetId); if (resourceVersion !== expectedVersion || !asset) return route.fulfill({ status: 404 }); return route.fulfill({ body: readFileSync(asset.path), contentType: asset.content_type, status: 200 }); } if (resourceVersion === "missing-fixture") return route.fulfill({ status: 404 }); if (WP4_07_REQUIRED_FONT_IDS.some((fontId: string) => url.pathname.endsWith(`/${fontId}`))) { return route.fulfill({ body: fontBytes!, contentType: "font/ttf", status: 200 }); } return route.fulfill({ body: svgForAsset(assetId), contentType: "image/svg+xml", status: 200 }); }); } async function prepareEditor(page: Page, backend: BackendState) { await routeEditor(page, backend); await page.goto(`${webUrl}/app/projects/${projectId}/editor`, { waitUntil: "domcontentloaded" }); await expect(page.getByText("对象 50 / 50")).toBeVisible(); await page.evaluate(() => document.fonts.ready); await page.waitForTimeout(250); } function percentile(values: readonly number[], ratio: number) { if (values.length === 0) return 0; const sorted = [...values].sort((left, right) => left - right); return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * ratio) - 1))]!; } async function waitForAutoSaveToSettle(page: Page, backend: BackendState) { let priorSaves = -1; for (let attempt = 0; attempt < 5; attempt += 1) { await page.waitForTimeout(1_100); if (backend.projectSaves === priorSaves) return; priorSaves = backend.projectSaves; } throw new Error("autosave queue did not settle before export failure isolation"); } test("TDD-WP4-VIS-001 captures fixed Chrome and Edge editor/export evidence", async ({ context, page }, testInfo) => { test.skip(process.env.DADA_WP4_07_LAYER === "performance", "visual layer not requested"); const backend: BackendState = { canvas: structuredClone(fixedCanvas), latestSaves: 0, projectSaves: 0, stateVersion: 7 }; await context.tracing.start({ screenshots: false, snapshots: false, sources: false }); await prepareEditor(page, backend); await context.tracing.stop({ path: evidencePath(testInfo, "trace.zip") }); const layoutSelectors = { canvas_frame: ".editor-canvas-frame", canvas_surface: ".editor-canvas", footer: ".editor-statusbar", left_panel: ".editor-assets-panel", right_panel: ".editor-inspector", toolbar: ".editor-toolbar", workspace: ".editor-workspace", }; const layoutBoxes: Record = {}; for (const [name, selector] of Object.entries(layoutSelectors)) layoutBoxes[name] = await page.locator(selector).boundingBox(); const frameBox = await page.locator(layoutSelectors.canvas_frame).boundingBox(); const surfaceBox = await page.locator(layoutSelectors.canvas_surface).boundingBox(); expect(frameBox).not.toBeNull(); expect(surfaceBox).not.toBeNull(); expect(Math.abs(frameBox!.width - surfaceBox!.width)).toBeLessThanOrEqual(2); expect(Math.abs(frameBox!.height - surfaceBox!.height)).toBeLessThanOrEqual(2); expect(Math.abs(surfaceBox!.width / surfaceBox!.height - fixedCanvas.pixel_width / fixedCanvas.pixel_height)).toBeLessThan(0.002); await page.screenshot({ animations: "disabled", path: evidencePath(testInfo, "editor.png") }); await page.getByLabel("编辑画布").screenshot({ animations: "disabled", path: evidencePath(testInfo, "canvas.png") }); await page.getByRole("button", { name: "导出", exact: true }).click(); await expect(page.getByRole("dialog", { name: "导出成品" })).toBeVisible(); await page.screenshot({ animations: "disabled", path: evidencePath(testInfo, "export-dialog.png") }); const browser = await page.evaluate(async () => { const userAgentData = (navigator as Navigator & { userAgentData?: { getHighEntropyValues: (hints: string[]) => Promise } }).userAgentData; return { full_version_list: userAgentData ? await userAgentData.getHighEntropyValues(["fullVersionList"]) : null, user_agent: navigator.userAgent }; }); writeEvidence(testInfo, "layout-boxes.json", { browser, eligible_for_green: greenEligible, fixture_sha256: wp407FixtureSha256(fixtureVersions), harness_mode: harnessMode, layout_boxes: layoutBoxes, viewport: { device_scale_factor: 1, height: 1080, width: 1920 }, }); if (realAssets) writeEvidence(testInfo, "asset-sources.json", realAssets.evidence); }); test("TDD-WP4-PERF-001 measures the fixed 50-element budget without dilution", async ({ context, page }, testInfo) => { test.skip(process.env.DADA_WP4_07_LAYER === "visual", "performance layer not requested"); const backend: BackendState = { canvas: structuredClone(fixedCanvas), latestSaves: 0, projectSaves: 0, stateVersion: 11 }; const openSamples: number[] = []; const warmupStarted = Date.now(); await context.tracing.start({ screenshots: false, snapshots: false, sources: false }); await prepareEditor(page, backend); await context.tracing.stop({ path: evidencePath(testInfo, "trace.zip") }); const warmupOpenMs = Date.now() - warmupStarted; for (let index = 0; index < WP4_07_PERFORMANCE_BUDGETS.measured_runs; index += 1) { const started = Date.now(); await page.reload({ waitUntil: "domcontentloaded" }); await expect(page.getByText("对象 50 / 50")).toBeVisible(); await page.evaluate(() => document.fonts.ready); openSamples.push(Date.now() - started); } const stage = page.getByLabel("编辑画布"); const bounds = await stage.boundingBox(); if (!bounds) throw new Error("fixed canvas bounds are unavailable"); await page.mouse.click(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); const interactionRuns: Array> = []; for (let run = 0; run < WP4_07_PERFORMANCE_BUDGETS.warmup_runs + WP4_07_PERFORMANCE_BUDGETS.measured_runs; run += 1) { const result = await page.evaluate(async ({ durationMs }) => { const canvas = document.querySelector(".editor-canvas")!; const buttons = [...document.querySelectorAll(".editor-inspector button")]; const scale = buttons.find((button) => button.textContent === "放大"); const rotate = buttons.find((button) => button.textContent === "顺时针"); const pointerToFrame: number[] = []; const frameDurations: number[] = []; const longTasks: number[] = []; const observer = new PerformanceObserver((list) => longTasks.push(...list.getEntries().map((entry) => entry.duration))); if (PerformanceObserver.supportedEntryTypes.includes("longtask")) observer.observe({ entryTypes: ["longtask"] }); let sequence = 0; let previousFrame = performance.now(); const started = previousFrame; await new Promise((resolveRun) => { const step = () => { const dispatchedAt = performance.now(); if (sequence % 3 === 0) canvas.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: sequence % 2 === 0 ? "ArrowRight" : "ArrowLeft" })); else if (sequence % 3 === 1) scale?.click(); else rotate?.click(); requestAnimationFrame((frameAt) => { pointerToFrame.push(frameAt - dispatchedAt); frameDurations.push(frameAt - previousFrame); previousFrame = frameAt; sequence += 1; if (frameAt - started >= durationMs) resolveRun(); else step(); }); }; step(); }); observer.disconnect(); const p = (values: number[], ratio: number) => { const ordered = [...values].sort((left, right) => left - right); return ordered[Math.min(ordered.length - 1, Math.max(0, Math.ceil(ordered.length * ratio) - 1))] ?? 0; }; return { duration_ms: performance.now() - started, frame_max_ms: Math.max(...frameDurations), frame_p50_ms: p(frameDurations, 0.5), frame_p95_ms: p(frameDurations, 0.95), frame_samples: frameDurations.length, long_task_max_ms: longTasks.length ? Math.max(...longTasks) : 0, pointer_to_frame_max_ms: Math.max(...pointerToFrame), pointer_to_frame_p50_ms: p(pointerToFrame, 0.5), pointer_to_frame_p95_ms: p(pointerToFrame, 0.95), }; }, { durationMs: WP4_07_PERFORMANCE_BUDGETS.interaction_duration_ms }); if (run > 0) interactionRuns.push(result); } const autosaveRuns: Array> = []; for (let run = 0; run < WP4_07_PERFORMANCE_BUDGETS.warmup_runs + WP4_07_PERFORMANCE_BUDGETS.measured_runs; run += 1) { const result = await page.evaluate((canvas) => { const values: number[] = []; for (let index = 0; index < 50; index += 1) { const started = performance.now(); JSON.stringify(canvas); values.push(performance.now() - started); } const ordered = [...values].sort((left, right) => left - right); return { max_ms: Math.max(...values), p50_ms: ordered[Math.ceil(ordered.length * 0.5) - 1] ?? 0, p95_ms: ordered[Math.ceil(ordered.length * 0.95) - 1] ?? 0, samples: values.length, }; }, fixedCanvas); if (run > 0) autosaveRuns.push(result); } await page.getByRole("button", { name: "普通贴纸", exact: true }).click(); const stickerList = page.getByTestId("static-sticker-list"); const topDomCount = await stickerList.locator("[data-sticker-id]").count(); await stickerList.evaluate((element) => { element.scrollTop = element.scrollHeight; element.dispatchEvent(new Event("scroll", { bubbles: true })); }); await page.waitForTimeout(100); const bottomDomCount = await stickerList.locator("[data-sticker-id]").count(); const domGeometry = await stickerList.evaluate((element) => ({ client_height: element.clientHeight, scroll_height: element.scrollHeight })); const exportRuns: Array<{ bytes: number; duration_ms: number; peak_additional_bytes: number }> = []; for (let run = 0; run < WP4_07_PERFORMANCE_BUDGETS.warmup_runs + WP4_07_PERFORMANCE_BUDGETS.measured_runs; run += 1) { const result = await page.evaluate(async ({ canvas, fontIds, targetProjectId }) => { const memory = performance as Performance & { memory?: { usedJSHeapSize: number } }; const baseline = memory.memory?.usedJSHeapSize ?? 0; let peak = baseline; const sampler = setInterval(() => { peak = Math.max(peak, memory.memory?.usedJSHeapSize ?? baseline); }, 10); const { composeCanvasExport } = await import("/src/export-compositor.ts"); const statuses = Object.fromEntries(fontIds.map((fontId: string) => [fontId, "ready"])); const started = performance.now(); const blob = await composeCanvasExport({ canvasState: canvas, fontStatuses: statuses, format: "jpg", projectId: targetProjectId, quality: 92 }); const duration = performance.now() - started; clearInterval(sampler); peak = Math.max(peak, memory.memory?.usedJSHeapSize ?? baseline); return { bytes: blob.size, duration_ms: duration, peak_additional_bytes: Math.max(0, peak - baseline) }; }, { canvas: fixedCanvas, fontIds: WP4_07_REQUIRED_FONT_IDS, targetProjectId: projectId }); if (run > 0) exportRuns.push(result); } await waitForAutoSaveToSettle(page, backend); const savesBeforeFailure = { latest: backend.latestSaves, project: backend.projectSaves }; const exportFailure = await page.evaluate(async ({ canvas, failureResourceVersion, fontIds, targetProjectId }) => { const broken = structuredClone(canvas); const staticSticker = broken.elements.find((element: { type: string }) => element.type === "static_sticker"); staticSticker.resource_version = failureResourceVersion; const statuses = Object.fromEntries(fontIds.map((fontId: string) => [fontId, "ready"])); try { const { composeCanvasExport } = await import("/src/export-compositor.ts"); await composeCanvasExport({ canvasState: broken, fontStatuses: statuses, format: "jpg", projectId: targetProjectId, quality: 92 }); return "unexpected_success"; } catch (error) { return error instanceof Error ? error.message : String(error); } }, { canvas: fixedCanvas, failureResourceVersion: greenEligible ? "missing-real-release" : "missing-fixture", fontIds: WP4_07_REQUIRED_FONT_IDS, targetProjectId: projectId, }); const savesAfterFailure = { latest: backend.latestSaves, project: backend.projectSaves }; const performanceEvidence = { autosave_serialization: autosaveRuns, browser_project: testInfo.project.name, editor_reopen: { max_ms: Math.max(...openSamples), p50_ms: percentile(openSamples, 0.5), p95_ms: percentile(openSamples, 0.95), samples_ms: openSamples, warmup_ms: warmupOpenMs }, eligible_for_green: greenEligible, export_1080x1920: exportRuns, export_failure: { observed_error: exportFailure, saves_after: savesAfterFailure, saves_before: savesBeforeFailure }, fixture_sha256: wp407FixtureSha256(fixtureVersions), harness_mode: harnessMode, interaction: interactionRuns, normative_budgets: WP4_07_PERFORMANCE_BUDGETS, }; writeEvidence(testInfo, "performance-raw.json", performanceEvidence); writeEvidence(testInfo, "memory.json", { export_peak_additional_bytes: exportRuns.map((item) => item.peak_additional_bytes), limit_bytes: WP4_07_PERFORMANCE_BUDGETS.export_peak_additional_bytes_max }); writeEvidence(testInfo, "dom-count.json", { ...domGeometry, bounded_by_viewport_and_two_screens: Math.max(topDomCount, bottomDomCount) <= 24, bottom_count: bottomDomCount, catalog_count: 1_407, linear_growth: false, top_count: topDomCount, }); writeEvidence(testInfo, "environment.json", await page.evaluate(() => ({ device_pixel_ratio: devicePixelRatio, user_agent: navigator.userAgent, viewport: { height: innerHeight, width: innerWidth } }))); if (realAssets) writeEvidence(testInfo, "asset-sources.json", realAssets.evidence); });