feat: complete TASK-WP2-02 project autosave

This commit is contained in:
suyx
2026-08-02 18:28:07 +08:00
parent 4d38530361
commit da6fa25e60
17 changed files with 2268 additions and 35 deletions
+167
View File
@@ -0,0 +1,167 @@
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<void>((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 });
});