import { expect, test, type Page } from "@playwright/test"; import { createServer, type ViteDevServer } from "vite"; import { mkdirSync } from "node:fs"; import { resolve } from "node:path"; 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-projects-fixture-000000000000000000000000000000000000000", expires_at: "2026-08-28T08:00:00.000Z", user: { creator_name: "Project User", role: "user", social_id: "@project_user", status: "active", user_id: "00000000-0000-4000-8000-000000000201" }, }; function routeSession(page: Page) { return page.route("**/api/v1/auth/session", (route) => route.fulfill({ body: JSON.stringify(session), contentType: "application/json", status: 200, })); } function generatedImageSvg(label: string) { return `${label}`; } async function captureEvidence(page: Page, caseId: string, name: string) { const root = process.env.DADA_EVIDENCE_DIR_PROJECTS; if (!root) return; const directory = resolve(root, caseId, "screenshots"); mkdirSync(directory, { recursive: true }); await page.screenshot({ fullPage: true, path: resolve(directory, name) }); } test("TDD-WP2-PROJ-001 renders the empty WbDYT workspace without inventing a model", async ({ page }) => { await routeSession(page); await page.route("**/api/v1/projects?status=active", (route) => route.fulfill({ body: JSON.stringify({ active_count: 0, active_limit: 20, projects: [] }), contentType: "application/json", status: 200, })); await page.goto(`${webUrl}/app`); await expect(page.getByRole("heading", { name: "开始一张新作品" })).toBeVisible(); await expect(page.getByText("NEW PROJECT", { exact: true })).toBeVisible(); await expect(page.getByLabel("描述你想生成的画面")).toBeVisible(); await expect(page.getByRole("radio", { name: "3:4" })).toBeChecked(); for (const ratio of ["1:1", "4:3", "9:16"]) await expect(page.getByRole("radio", { name: ratio })).toBeVisible(); await expect(page.getByText("当前没有可用于新任务的模型")).toBeVisible(); await expect(page.getByRole("button", { name: "生成一张图片" })).toBeDisabled(); await expect(page.getByText("没有进行中的任务")).toBeVisible(); await expect(page.getByText("测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。")).toBeVisible(); await expect(page.getByText("浏览示例模板")).toHaveCount(0); await captureEvidence(page, "TDD-WP2-PROJ-001-new-versus-continue", "workspace-empty.png"); }); test("TDD-WP2-PROJ-005 limits failed-empty selection on the project list", async ({ page }) => { await page.setViewportSize({ height: 760, width: 375 }); await routeSession(page); const failedId = "00000000-0000-4000-8000-000000000211"; const successId = "00000000-0000-4000-8000-000000000212"; await page.route("**/api/v1/projects?status=active", (route) => route.fulfill({ body: JSON.stringify({ active_count: 2, active_limit: 20, projects: [ { current_image_id: null, name: "失败草稿", project_id: failedId, ratio: "3:4", state_version: 1, status: "failed_empty", successful_image_count: 0, updated_at: "2026-07-28T08:00:00.000Z" }, { current_image_id: "00000000-0000-4000-8000-000000000213", name: "城市工作室", project_id: successId, ratio: "3:4", state_version: 2, status: "active", successful_image_count: 1, updated_at: "2026-07-28T08:02:00.000Z" }, ], }), contentType: "application/json", status: 200, })); await page.route(`**/api/v1/private-assets/projects/${successId}/images/*`, (route) => route.fulfill({ body: generatedImageSvg("城市工作室"), contentType: "image/svg+xml", headers: { "Content-Disposition": "attachment; filename=\"dada-original.png\"" }, status: 200, })); let batchPayload: unknown; await page.route("**/api/v1/projects/failed-empty/trash", async (route) => { batchPayload = route.request().postDataJSON(); await route.fulfill({ body: JSON.stringify({ ignored_project_ids: [], trashed_project_ids: [failedId] }), contentType: "application/json", status: 200, }); }); await page.goto(`${webUrl}/app/projects`); await expect(page.getByRole("heading", { name: "项目" })).toBeVisible(); await expect(page.getByText("2 / 20 active")).toBeVisible(); const projectPreview = page.getByRole("img", { name: "城市工作室预览图" }); await expect(projectPreview).toHaveAttribute( "src", `/api/v1/private-assets/projects/${successId}/images/00000000-0000-4000-8000-000000000213`, ); await expect(projectPreview).toHaveCSS("object-fit", "cover"); await expect(page.getByLabel("选择失败草稿:失败草稿")).toBeVisible(); await expect(page.getByLabel("选择失败草稿:城市工作室")).toHaveCount(0); await page.getByLabel("选择失败草稿:失败草稿").check(); await page.getByRole("button", { name: "批量移入回收站" }).click(); expect(batchPayload).toEqual({ project_ids: [failedId] }); await expect(page.getByText("失败草稿已移入回收站")).toBeVisible(); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(375); await captureEvidence(page, "TDD-WP2-PROJ-005-failed-draft-retry", "projects-mobile.png"); }); test("TDD-WP2-PROJ-001 keeps ratio fixed and blocks an eleventh image in project detail", async ({ page }) => { await routeSession(page); const projectId = "00000000-0000-4000-8000-000000000221"; const currentImageId = "00000000-0000-4000-8000-000000000222"; await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({ body: JSON.stringify({ created_at: "2026-07-28T08:00:00.000Z", current_image_id: currentImageId, draft_prompt: "城市工作室", generations: [], images: Array.from({ length: 10 }, (_, index) => ({ created_at: `2026-07-28T08:${String(index).padStart(2, "0")}:00.000Z`, generation_id: `00000000-0000-4000-8000-${String(223 + index).padStart(12, "0")}`, image_id: `00000000-0000-4000-8000-${String(323 + index).padStart(12, "0")}`, })), name: "城市工作室", project_id: projectId, ratio: "3:4", state_version: 4, status: "active", successful_image_count: 10, updated_at: "2026-07-28T08:10:00.000Z", }), contentType: "application/json", status: 200, })); await page.route(`**/api/v1/private-assets/projects/${projectId}/images/*`, (route) => route.fulfill({ body: generatedImageSvg("生成结果"), contentType: "image/svg+xml", headers: { "Content-Disposition": "attachment; filename=\"dada-original.png\"" }, status: 200, })); await page.goto(`${webUrl}/app/projects/${projectId}`); await expect(page.getByRole("heading", { name: "城市工作室" })).toBeVisible(); await expect(page.getByText("固定比例 3:4")).toBeVisible(); await expect(page.getByText("10 / 10 张成功图")).toBeVisible(); const currentImage = page.getByRole("img", { name: "城市工作室当前底图" }); await expect(currentImage).toHaveAttribute( "src", `/api/v1/private-assets/projects/${projectId}/images/${currentImageId}`, ); await expect(currentImage).toHaveAttribute("loading", "eager"); await expect(currentImage).toHaveCSS("object-fit", "contain"); await expect(page.getByRole("img", { name: "生成结果 10" })).toBeVisible(); await expect(page.getByRole("button", { name: "继续生成" })).toBeDisabled(); await expect(page.getByText("请先删除一张非当前底图的历史图")).toBeVisible(); await expect(page.getByRole("radio")).toHaveCount(0); await captureEvidence(page, "TDD-WP2-PROJ-001-new-versus-continue", "project-detail-limit.png"); });