ai-chatbot/tests/e2e/canvas-autosync.test.ts

386 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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[], capabilitySubmissions: [] as any[],
appVersion: "development", holdRequest: false, finishRequest: null as (() => void) | null,
nativeResultNext: false,
};
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/${projectId}/capabilities` && request.method() === "POST") {
state.capabilitySubmissions.push(request.postDataJSON());
state.canvasData.nodes.push({ ...imageNode(`enhanced-${state.capabilitySubmissions.length}`), position: { x: 520, y: 130 } });
state.revision++;
data = { success: true, task: { status: "succeeded" } };
} else if (path === "/api/canvas/agent-chat" && request.method() === "POST") {
state.submissions.push(request.postDataJSON());
let operation = "";
if (state.nativeResultNext) {
state.nativeResultNext = false;
state.canvasData.nodes.push({ ...imageNode("agent-result"), position: { x: 520, y: 130 } });
state.revision++;
operation = `data: ${JSON.stringify({ type: "data-canvasOperation", data: { status: "applied", projectId, createdNodeIds: ["agent-result"] } })}\n\n`;
}
if (state.holdRequest) await new Promise<void>(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\n' + operation + 'data: {"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: '<svg xmlns="http://www.w3.org/2000/svg" width="300" height="200"><rect width="300" height="200" fill="#43b9bd"/></svg>' }));
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("an enhanced result can be moved and archived before the next save without blocking Agent", async ({ page }) => {
const { state, errors } = await setup(page);
await page.locator('[data-canvas-node-id="source"]').click();
await page.locator('[data-tool-key="enhance"]').click();
const result = page.locator('[data-canvas-node-id="enhanced-1"]');
await expect(result).toBeVisible();
const box = (await result.boundingBox())!;
await page.mouse.move(box.x + 100, box.y + 80);
await page.mouse.down();
await page.mouse.move(box.x + 165, box.y + 110, { steps: 5 });
await page.mouse.up();
state.canvasData.nodes.find(node => node.id === "enhanced-1")!.data.src = `${imageOrigin}/uploads/archived-result.png`;
state.revision++;
const editor = page.getByLabel("描述你的设计需求", { exact: true });
await editor.fill("保留增强结果的位置,继续分析");
await editor.press("Enter");
await expect.poll(() => state.submissions.length).toBe(1);
const saved = state.canvasData.nodes.find(node => node.id === "enhanced-1")!;
expect(saved.position.x).toBeGreaterThan(520);
expect(saved.data.src).toContain("archived-result.png");
expect(state.capabilitySubmissions).toHaveLength(1);
await page.reload();
await expect(page.locator('[data-canvas-node-id="enhanced-1"]')).toBeVisible();
expect(errors).toEqual([]);
});
test("toolbar save failures show the cause and recover without a paid submission", async ({ page }) => {
const { state, errors } = await setup(page);
await moveImage(page);
state.failSave = true;
await page.locator('[data-tool-key="enhance"]').click();
await expect(page.getByText(/画布自动同步失败(HTTP 503)/)).toBeVisible();
expect(state.capabilitySubmissions).toHaveLength(0);
state.failSave = false;
await page.locator('[data-tool-key="enhance"]').click();
await expect(page.locator('[data-canvas-node-id="enhanced-1"]')).toBeVisible();
expect(state.capabilitySubmissions).toHaveLength(1);
expect(errors).toEqual([]);
});
test("native Agent results update the save baseline before moving and archiving the result", async ({ page }) => {
const { state, errors } = await setup(page);
state.nativeResultNext = true;
const editor = page.getByLabel("描述你的设计需求", { exact: true });
await editor.fill("先分析再处理图片");
await editor.press("Enter");
const result = page.locator('[data-canvas-node-id="agent-result"]');
await expect(result).toBeVisible();
await expect(editor).toBeEditable();
const box = (await result.boundingBox())!;
await page.mouse.move(box.x + 100, box.y + 80);
await page.mouse.down();
await page.mouse.move(box.x + 165, box.y + 110, { steps: 5 });
await page.mouse.up();
state.canvasData.nodes.find(node => node.id === "agent-result")!.data.src = `${imageOrigin}/uploads/agent-archived.png`;
state.revision++;
await editor.fill("保留位置,继续分析");
await editor.press("Enter");
await expect.poll(() => state.submissions.length).toBe(2);
const saved = state.canvasData.nodes.find(node => node.id === "agent-result")!;
expect(saved.position.x).toBeGreaterThan(520);
expect(saved.data.src).toContain("agent-archived.png");
expect(state.capabilitySubmissions).toHaveLength(0);
expect(errors).toEqual([]);
});
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([]);
});
for (const choice of ["local", "remote"] as const) {
test(`an existing conflicting draft can keep the ${choice} edit and resume without losing other images`, 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.getByRole("button", { name: "保留本页修改", exact: true })).toBeVisible();
state.canvasData.nodes.push(imageNode("remote-addition"));
state.revision++;
await page.reload();
await expect(page.locator('[data-canvas-node-id="remote-addition"]')).toBeVisible();
if (choice === "local") {
await page.screenshot({ path: test.info().outputPath("conflict-recovery-desktop.png"), animations: "disabled" });
await page.setViewportSize({ width: 390, height: 844 });
await page.screenshot({ path: test.info().outputPath("conflict-recovery-mobile.png"), animations: "disabled" });
await page.setViewportSize({ width: 1440, height: 900 });
}
await page.getByRole("button", { name: choice === "local" ? "保留本页修改" : "保留云端修改", exact: true }).click();
await expect(page.getByRole("button", { name: "保留本页修改", exact: true })).toHaveCount(0);
await editor.press("Enter");
await expect.poll(() => state.submissions.length).toBe(1);
const x = state.canvasData.nodes.find(node => node.id === "source")!.position.x;
if (choice === "remote") expect(x).toBe(500);
else { expect(x).toBeGreaterThan(160); expect(x).not.toBe(500); }
expect(state.canvasData.nodes.map(node => node.id)).toEqual(["source", "remote-addition"]);
expect(state.capabilitySubmissions).toHaveLength(0);
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([]);
});