import { mkdirSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import { expect, test, type BrowserContext, type Page } from "@playwright/test"; 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 projectId = "00000000-0000-4000-8000-000000000401"; const canvasState = { background: { adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 }, asset_id: null }, elements: [], pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1, }; const session = { audience: "user", authenticated: true, credits: { available_balance: 10, reserved_balance: 0 }, csrf_token: "csrf-project-state-fixture-000000000000000000000000000000000", expires_at: "2026-09-02T08:00:00.000Z", user: { creator_name: "State User", role: "user", social_id: "@state_user", status: "active", user_id: "00000000-0000-4000-8000-000000000402" }, }; function projectPayload(name: string, version: number) { return { canvas_state: canvasState, created_at: "2026-08-02T08:00:00.000Z", current_image_id: null, draft_prompt: "多标签项目", generations: [], images: [], name, pixel_height: 1440, pixel_width: 1080, project_id: projectId, ratio: "3:4", save_status: "saved", state_version: version, status: "failed_empty", successful_image_count: 0, updated_at: "2026-08-02T08:00:00.000Z", }; } async function routeSession(target: Page | BrowserContext) { await target.route("**/api/v1/auth/session", (route) => route.fulfill({ body: JSON.stringify(session), contentType: "application/json", status: 200 })); } function writeEvidence(caseId: string, file: string, value: unknown) { const root = process.env.DADA_EVIDENCE_DIR_PROJECT_STATE; if (!root) return; const directory = resolve(root, caseId); mkdirSync(directory, { recursive: true }); writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`); } async function screenshot(page: Page, caseId: string, file: string) { const root = process.env.DADA_EVIDENCE_DIR_PROJECT_STATE; if (!root) return; const directory = resolve(root, caseId, "screenshots"); mkdirSync(directory, { recursive: true }); await page.screenshot({ fullPage: true, path: resolve(directory, file) }); } test("TDD-WP2-PROJ-002 recovers a failed debounced save without browser draft persistence", async ({ context, page }) => { await routeSession(context); let backendName = "后端已保存"; let backendVersion = 4; await context.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({ body: JSON.stringify(projectPayload(backendName, backendVersion)), contentType: "application/json", status: 200 })); const timeline: Array<{ name: string; version: string | undefined }> = []; let attempts = 0; let inFlight = 0; let maxInFlight = 0; let releaseRecovery!: () => void; const recoveryGate = new Promise((resolveRecovery) => { releaseRecovery = resolveRecovery; }); await context.route(`**/api/v1/projects/${projectId}/state`, async (route) => { attempts += 1; inFlight += 1; maxInFlight = Math.max(maxInFlight, inFlight); timeline.push({ name: route.request().postDataJSON().name, version: route.request().headers()["if-match"] }); if (attempts === 1) await route.fulfill({ body: "null", contentType: "application/json", status: 503 }); else { await recoveryGate; backendName = route.request().postDataJSON().name; backendVersion += 1; await route.fulfill({ body: JSON.stringify({ save_status: "saved", state_version: backendVersion }), contentType: "application/json", status: 200 }); } inFlight -= 1; }); await page.goto(`${webUrl}/app/projects/${projectId}`); const input = page.getByRole("textbox", { name: "项目名称" }); await input.fill("第一次"); await input.fill("一秒内最终名称"); await expect(page.getByText("未保存", { exact: true })).toBeVisible({ timeout: 4_000 }); await screenshot(page, "TDD-WP2-PROJ-002-save-failure-recovery", "save-failed.png"); await page.getByRole("link", { exact: true, name: "项目" }).click(); const leaveDialog = page.getByRole("dialog", { name: "有未保存修改" }); await expect(leaveDialog).toBeVisible(); await expect(leaveDialog.getByRole("button", { name: "保存并离开" })).toBeVisible(); await expect(leaveDialog.getByRole("button", { name: "放弃修改" })).toBeVisible(); await leaveDialog.getByRole("button", { name: "取消" }).click(); await expect(leaveDialog).toBeHidden(); releaseRecovery(); await expect(page.getByText("已保存", { exact: true })).toBeVisible({ timeout: 6_000 }); expect(timeline).toEqual([ { name: "一秒内最终名称", version: "4" }, { name: "一秒内最终名称", version: "4" }, ]); expect(maxInFlight).toBe(1); await input.fill("强制关闭前的未保存修改"); await page.close(); const reopened = await context.newPage(); await reopened.goto(`${webUrl}/app/projects/${projectId}`); await expect(reopened.getByRole("textbox", { name: "项目名称" })).toHaveValue("一秒内最终名称"); await expect(reopened.getByText("state version 5")).toBeVisible(); const storage = await reopened.evaluate(async () => ({ cache_keys: "caches" in window ? await caches.keys() : [], indexed_db: "databases" in indexedDB ? (await indexedDB.databases()).map((entry) => entry.name) : [], local_storage: Object.keys(localStorage), })); expect(JSON.stringify(storage)).not.toContain("强制关闭前的未保存修改"); writeEvidence("TDD-WP2-PROJ-002-save-failure-recovery", "cache-enumeration.json", storage); writeEvidence("TDD-WP2-PROJ-002-debounce", "network-timeline.json", { max_in_flight: maxInFlight, requests: timeline }); }); test("TDD-WP2-PROJ-004 turns the stale tab read-only and consumes one local export", async ({ context }) => { await routeSession(context); let backendName = "共同版本"; let backendVersion = 1; const network: string[] = []; await context.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({ body: JSON.stringify(projectPayload(backendName, backendVersion)), contentType: "application/json", status: 200 })); await context.route(`**/api/v1/projects/${projectId}/state`, async (route) => { network.push(route.request().url()); const expected = Number(route.request().headers()["if-match"]); if (expected !== backendVersion) { await route.fulfill({ body: JSON.stringify({ latest_state_version: backendVersion, save_status: "conflicted" }), contentType: "application/json", status: 412 }); return; } backendName = route.request().postDataJSON().name; backendVersion += 1; await route.fulfill({ body: JSON.stringify({ save_status: "saved", state_version: backendVersion }), contentType: "application/json", status: 200 }); }); const pageA = await context.newPage(); const pageB = await context.newPage(); await Promise.all([pageA.goto(`${webUrl}/app/projects/${projectId}`), pageB.goto(`${webUrl}/app/projects/${projectId}`)]); await pageA.getByRole("textbox", { name: "项目名称" }).fill("标签 A 已保存"); await expect.poll(() => backendVersion, { timeout: 4_000 }).toBe(2); await expect(pageA.getByText("已保存", { exact: true })).toBeVisible({ timeout: 4_000 }); await pageB.getByRole("textbox", { name: "项目名称" }).fill("标签 B 的本地版本"); await expect(pageB.getByRole("heading", { name: "版本冲突" })).toBeVisible({ timeout: 4_000 }); await expect(pageB.getByText("本页版本 1")).toBeVisible(); await expect(pageB.getByText("最新版本 2")).toBeVisible(); await expect(pageB.getByRole("textbox", { name: "项目名称" })).toBeDisabled(); await expect(pageB.getByText("另存为")).toHaveCount(0); await expect(pageB.getByRole("link", { name: "修改并重试" })).toHaveCount(0); await screenshot(pageB, "TDD-WP2-PROJ-004-stale-tab", "conflicted.png"); const download = pageB.waitForEvent("download"); await pageB.getByRole("button", { name: "本地导出本页版本" }).click(); await download; await expect(pageB.getByRole("button", { name: "本地导出本页版本" })).toBeDisabled(); await expect(pageB.getByText("仅下载本页版本,未写入项目")).toBeVisible(); expect(network.every((url) => !url.includes("latest") && !url.includes("export"))).toBe(true); await screenshot(pageB, "TDD-WP2-PROJ-004-conflict-export-once", "conflict-export.png"); writeEvidence("TDD-WP2-PROJ-004-stale-tab", "response.json", { latest_state_version: 2, save_status: "conflicted" }); writeEvidence("TDD-WP2-PROJ-004-stale-tab", "db-diff.json", { stale_writes: 0, version: 2 }); writeEvidence("TDD-WP2-PROJ-004-conflict-export-once", "network-timeline.json", { requests: network }); writeEvidence("TDD-WP2-PROJ-004-conflict-export-once", "db-diff.json", { latest_export_delta: 0, project_delta: 0, state_delta: 0 }); });