import { expect, test, type Page } from "@playwright/test"; test.setTimeout(60_000); const projectId = "8d3b4bac-4539-4caf-8b49-b063f218de19"; const imageOrigin = "https://scqnycdn.jiansun.vip"; const imageNode = (id: string) => ({ id, type: "image", position: { x: 160, y: 130 }, size: { width: 300, height: 200 }, data: { status: "completed", src: `${imageOrigin}/uploads/${id}.png`, title: id } }); async function setup(page: Page, failRead = false) { const state = { canvasData: { nodes: [imageNode("source")], edges: [], selectedNodeIds: [], viewport: { x: 0, y: 0, zoom: 1 }, tool: "select" }, revision: 1, failSave: false, failRead, conflictNextSave: false, conflictSameNode: false, writes: [] as any[], metadataWrites: 0, submissions: [] as any[], appVersion: "development", holdRequest: false, finishRequest: null as (() => void) | null, }; const revision = () => new Date(Date.UTC(2026, 10, 6, 0, 0, state.revision)).toISOString(); const project = () => ({ id: projectId, title: "同步验证", canvasData: state.canvasData, updatedAt: revision() }); const errors: string[] = []; page.on("pageerror", error => errors.push(error.message)); await page.context().addInitScript(() => localStorage.setItem("theme", "light")); await page.context().route("**/api/**", async route => { const request = route.request(); const path = new URL(request.url()).pathname; let data: unknown = {}; let status = 200; if (path === `/api/canvas/${projectId}`) { if (request.method() === "GET") { status = state.failRead ? 500 : 200; data = state.failRead ? { success: false, error: "画布暂时无法读取,现有内容未被覆盖。" } : { success: true, data: project() }; } else if (request.method() === "PUT") { const body = request.postDataJSON(); state.writes.push(body); const previousCanvasRevision = revision(); if (body.canvasData) { if (state.conflictNextSave) { state.conflictNextSave = false; if (state.conflictSameNode) state.canvasData.nodes[0].position.x = 500; else state.canvasData.nodes.push(imageNode("remote-result")); state.revision++; } if (state.failSave) { status = 503; data = { success: false, error: "同步服务暂时不可用" }; } else if ((body.baseCanvasUpdatedAt || body.baseCanvasRevision) !== revision()) { status = 409; data = { success: false, code: "STALE_CANVAS_REVISION", error: "画布已更新", canvasUpdatedAt: revision() }; } else { state.canvasData = body.canvasData; state.revision++; data = { success: true, previousCanvasRevision, data: project() }; } } else { state.metadataWrites++; state.revision++; data = { success: true, previousCanvasRevision, data: project() }; } } } else if (path === "/api/canvas/agent-chat" && request.method() === "POST") { state.submissions.push(request.postDataJSON()); if (state.holdRequest) await new Promise(resolve => { state.finishRequest = resolve; }); await route.fulfill({ headers: { "content-type": "text/event-stream", "x-vercel-ai-ui-message-stream": "v1" }, body: 'data: {"type":"start","messageId":"sync-assistant"}\n\ndata: {"type":"text-start","id":"text"}\n\ndata: {"type":"text-delta","id":"text","delta":"已收到请求"}\n\ndata: {"type":"text-end","id":"text"}\n\ndata: {"type":"finish"}\n\ndata: [DONE]\n\n' }); return; } else if (path === "/api/app-version") data = { version: state.appVersion }; else if (path === "/api/files/direct-upload") { data = request.postDataJSON().action === "complete" ? { url: `${imageOrigin}/uploads/uploaded.png`, pathname: "uploads/uploaded.png" } : { mode: "completed", key: "uploads/uploaded.png" }; } else if (path === "/api/files/upload") data = { url: `${imageOrigin}/uploads/uploaded.png` }; else if (path === "/api/canvas/auto-title") data = { title: "参考图片" }; else if (path.includes("validate-session")) data = { valid: true, success: true }; else if (path === "/api/auth/session") data = { user: { id: "sync-user", name: "同步验证", type: "admin" }, expires: "2099-01-01" }; else if (path === "/api/site-settings") data = { canvasEnabled: true, agentEnabled: true, membershipEnabled: false }; else if (path === "/api/models") data = { models: [{ id: "image-test", modelId: "gpt-image-2", providerType: "openai", name: "GPT Image", type: "image", enabled: true, supportsImageGen: true }] }; else if (path === "/api/vote") data = []; else if (path.includes("messages")) data = { messages: [] }; else if (path.includes("sessions")) data = { sessions: [] }; else if (path.startsWith("/api/canvas/")) data = { success: true, runs: [] }; await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(data) }); }); await page.context().route(`${imageOrigin}/**`, route => route.fulfill({ contentType: "image/svg+xml", body: '' })); await page.goto(`/canvas/${projectId}`, { waitUntil: "domcontentloaded" }); if (!failRead) await expect(page.getByRole("heading", { name: /Hi, 同步验证/ })).toBeVisible(); return { state, errors }; } async function moveImage(page: Page) { const node = page.locator('[data-canvas-node-id="source"]'); await expect(node).toBeVisible(); const box = (await node.boundingBox())!; await page.mouse.move(box.x + 100, box.y + 80); await page.mouse.down(); await page.mouse.move(box.x + 190, box.y + 120, { steps: 5 }); await page.mouse.up(); } test("upload naming and consecutive Agent messages sync automatically without manual saves", async ({ page }) => { const { state, errors } = await setup(page); const editor = page.getByLabel("描述你的设计需求", { exact: true }); await expect(editor).toBeVisible(); await editor.evaluate(element => { const transfer = new DataTransfer(); transfer.items.add(new File([new Uint8Array([137, 80, 78, 71])], "参考图.png", { type: "image/png" })); element.dispatchEvent(new ClipboardEvent("paste", { clipboardData: transfer, bubbles: true, cancelable: true })); }); await expect.poll(() => state.metadataWrites).toBe(1); expect(state.canvasData.nodes).toHaveLength(2); for (const prompt of ["请先分析参考图", "继续调整为白色背景"]) { await editor.fill(prompt); await editor.press("Enter"); await expect.poll(() => state.submissions.length).toBe(prompt.startsWith("请") ? 1 : 2); await expect(editor).toBeEditable(); } await page.reload(); await expect(page.locator('[data-canvas-node-id]')).toHaveCount(2); expect(errors).toEqual([]); }); test("a save conflict merges a remote result with local movement before one Agent submission", async ({ page }) => { const { state, errors } = await setup(page); await moveImage(page); state.conflictNextSave = true; const editor = page.getByLabel("描述你的设计需求", { exact: true }); await editor.fill("保留我移动后的位置,再分析图片"); await editor.press("Enter"); await expect.poll(() => state.submissions.length).toBe(1); expect(state.canvasData.nodes.map(node => node.id)).toEqual(["source", "remote-result"]); expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160); expect(errors).toEqual([]); }); test("failed synchronization preserves the unsent message and canvas draft across refresh", async ({ page }) => { const { state, errors } = await setup(page); await moveImage(page); state.failSave = true; const editor = page.getByLabel("描述你的设计需求", { exact: true }); const prompt = "保持产品结构,只更换背景"; await editor.fill(prompt); await editor.press("Enter"); await expect(page.getByText(/本地草稿已保留/).first()).toBeVisible(); expect(state.submissions).toHaveLength(0); await expect(editor).toHaveText(prompt); await page.reload(); await expect(editor).toHaveText(prompt); await expect(page.locator('[data-canvas-node-id="source"]')).toBeVisible(); await expect(page.getByRole("heading", { name: /Hi, 同步验证/ })).toBeVisible(); state.failSave = false; await expect(page.getByRole("button", { name: "发送", exact: true })).toBeEnabled(); await editor.press("Enter"); await expect.poll(() => state.submissions.length).toBe(1); expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160); expect(errors).toEqual([]); }); test("conflicting edits keep both canvases and do not submit an Agent task", async ({ page }) => { const { state, errors } = await setup(page); await moveImage(page); state.conflictSameNode = true; state.conflictNextSave = true; const editor = page.getByLabel("描述你的设计需求", { exact: true }); await editor.fill("请处理这张图片"); await editor.press("Enter"); await expect(page.getByText(/画布同步发生冲突/).first()).toBeVisible(); expect(state.submissions).toHaveLength(0); expect(state.canvasData.nodes[0].position.x).toBe(500); const draft = await page.evaluate(id => JSON.parse(localStorage.getItem(`canvas-unsynced:${id}`) || "null"), projectId); expect(JSON.parse(draft.snapshot).canvasData.nodes[0].position.x).toBeGreaterThan(160); expect(JSON.parse(draft.snapshot).canvasData.nodes[0].position.x).not.toBe(500); await expect(editor).toHaveText("请处理这张图片"); expect(errors).toEqual([]); }); test("a failed canvas read cannot create or save an empty replacement", async ({ page }) => { const { state, errors } = await setup(page, true); await expect(page.getByRole("alert").filter({ hasText: "画布暂时无法读取" })).toBeVisible(); await page.reload(); await expect(page.getByRole("alert").filter({ hasText: "画布暂时无法读取" })).toBeVisible(); expect(state.writes).toHaveLength(0); state.failRead = false; await page.getByRole("button", { name: "重新加载画布" }).click(); await expect(page.locator('[data-canvas-node-id="source"]')).toBeVisible(); expect(state.canvasData.nodes).toHaveLength(1); expect(errors).toEqual([]); }); async function showUpdate(page: Page, state: { appVersion: string }) { state.appVersion = "20261106T120000Z-nextrelease1"; await page.evaluate(() => window.dispatchEvent(new Event("focus"))); await expect(page.getByRole("alertdialog")).toContainText("平台已更新"); } async function countRefreshes(page: Page, state: { appVersion: string }) { let navigations = 0; await page.route(url => url.pathname === `/canvas/${projectId}`, route => { if (route.request().isNavigationRequest()) { navigations++; state.appVersion = "development"; } return route.continue(); }); return () => navigations; } test("a new release requires confirmation and refreshes after preserving canvas and input", async ({ page }) => { const { state, errors } = await setup(page); const refreshes = await countRefreshes(page, state); await moveImage(page); const editor = page.getByLabel("描述你的设计需求", { exact: true }); await editor.fill("刷新后还要保留这条输入"); await showUpdate(page, state); await page.keyboard.press("Escape"); await expect(page.getByRole("alertdialog")).toBeVisible(); expect(refreshes()).toBe(0); await page.screenshot({ path: test.info().outputPath("update-notice-desktop.png"), animations: "disabled" }); await page.setViewportSize({ width: 390, height: 844 }); await page.screenshot({ path: test.info().outputPath("update-notice-mobile.png"), animations: "disabled" }); await page.getByRole("button", { name: "同步并刷新", exact: true }).click(); await expect.poll(refreshes).toBe(1); await expect(editor).toHaveText("刷新后还要保留这条输入"); await expect(page.getByRole("alertdialog")).toHaveCount(0); expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160); expect(state.submissions).toHaveLength(0); expect(errors).toEqual([]); }); test("an update cannot interrupt or resubmit a running Agent task", async ({ page }) => { const { state, errors } = await setup(page); const refreshes = await countRefreshes(page, state); state.holdRequest = true; const editor = page.getByLabel("描述你的设计需求", { exact: true }); await editor.fill("先分析这张图片"); await editor.press("Enter"); await expect.poll(() => state.submissions.length).toBe(1); await showUpdate(page, state); const saves = state.writes.length; await page.getByRole("button", { name: "同步并刷新", exact: true }).click(); await expect(page.getByRole("alertdialog")).toContainText("当前对话任务仍在处理中"); expect(refreshes()).toBe(0); expect(state.writes).toHaveLength(saves); state.finishRequest?.(); await expect(editor).toBeEditable(); await page.getByRole("button", { name: "同步并刷新", exact: true }).click(); await expect.poll(refreshes).toBe(1); expect(state.submissions).toHaveLength(1); expect(errors).toEqual([]); }); test("an update keeps the current page when automatic synchronization fails", async ({ page }) => { const { state, errors } = await setup(page); const refreshes = await countRefreshes(page, state); state.failSave = true; await moveImage(page); await showUpdate(page, state); await page.getByRole("button", { name: "同步并刷新", exact: true }).click(); await expect(page.getByRole("alertdialog")).toContainText("HTTP 503"); expect(refreshes()).toBe(0); expect(state.submissions).toHaveLength(0); state.failSave = false; await page.getByRole("button", { name: "同步并刷新", exact: true }).click(); await expect.poll(refreshes).toBe(1); expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160); expect(errors).toEqual([]); }); test("a release refresh never saves an empty canvas after its initial read failed", async ({ page }) => { const { state, errors } = await setup(page, true); const refreshes = await countRefreshes(page, state); await expect(page.getByRole("alert").filter({ hasText: "画布暂时无法读取" })).toBeVisible(); await showUpdate(page, state); state.failRead = false; await page.getByRole("button", { name: "同步并刷新", exact: true }).click(); await expect.poll(refreshes).toBe(1); await expect(page.locator('[data-canvas-node-id="source"]')).toBeVisible(); expect(state.canvasData.nodes).toHaveLength(1); expect(state.writes.filter(body => body.canvasData?.nodes.length === 0)).toHaveLength(0); expect(errors).toEqual([]); });