feat: complete TASK-WP2-02 project autosave
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
Reference in New Issue
Block a user