625 lines
34 KiB
TypeScript
625 lines
34 KiB
TypeScript
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,
|
||
holdUpload: false, uploadStarted: 0, finishUpload: null as (() => void) | null,
|
||
};
|
||
const revision = () => new Date(Date.UTC(2026, 10, 6, 0, 0, state.revision)).toISOString();
|
||
const project = () => ({ id: projectId, title: "同步验证", canvasData: JSON.parse(JSON.stringify(state.canvasData, (_key, value) =>
|
||
value && typeof value === "object" && !Array.isArray(value)
|
||
? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)))
|
||
: value)), 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.startsWith("/api/files/") && request.method() === "POST" && state.holdUpload) {
|
||
state.uploadStarted++;
|
||
await new Promise<void>(resolve => { state.finishUpload = resolve; });
|
||
}
|
||
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();
|
||
}
|
||
|
||
const unloadWouldBeBlocked = (page: Page) => page.evaluate(() => {
|
||
const event = new Event("beforeunload", { cancelable: true });
|
||
Object.defineProperty(event, "returnValue", { value: "", writable: true });
|
||
window.dispatchEvent(event);
|
||
return event.defaultPrevented && Boolean(event.returnValue);
|
||
});
|
||
|
||
test("background saving recovers when the network returns without a manual save", async ({ page }) => {
|
||
const { state, errors } = await setup(page);
|
||
await expect(page.getByRole("button", { name: "保存", exact: true })).toHaveCount(0);
|
||
state.failSave = true;
|
||
await moveImage(page);
|
||
await expect.poll(() => state.writes.filter(body => body.canvasData).length).toBeGreaterThan(0);
|
||
expect(state.canvasData.nodes[0].position.x).toBe(160);
|
||
state.failSave = false;
|
||
await page.evaluate(() => window.dispatchEvent(new Event("online")));
|
||
await expect.poll(() => state.canvasData.nodes[0].position.x).toBeGreaterThan(160);
|
||
await expect.poll(() => unloadWouldBeBlocked(page)).toBe(false);
|
||
expect(state.submissions).toHaveLength(0);
|
||
expect(errors).toEqual([]);
|
||
});
|
||
|
||
test("selecting a saved image does not require another save before an Agent message", async ({ page }) => {
|
||
const { state, errors } = await setup(page);
|
||
await page.locator('[data-canvas-node-id="source"]').click();
|
||
state.failSave = 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"]);
|
||
expect(errors).toEqual([]);
|
||
});
|
||
|
||
test("only unsaved content blocks closing and beforeunload does not issue duplicate saves", async ({ page }) => {
|
||
const { state, errors } = await setup(page);
|
||
expect(await unloadWouldBeBlocked(page)).toBe(false);
|
||
await page.locator('[data-canvas-node-id="source"]').click();
|
||
expect(await unloadWouldBeBlocked(page)).toBe(false);
|
||
state.failSave = true;
|
||
await moveImage(page);
|
||
const writes = state.writes.length;
|
||
expect(await unloadWouldBeBlocked(page)).toBe(true);
|
||
expect(state.writes).toHaveLength(writes);
|
||
const draft = await page.evaluate(id => localStorage.getItem(`canvas-unsynced:${id}`), projectId);
|
||
expect(JSON.parse(JSON.parse(draft!).snapshot).canvasData.nodes[0].position.x).toBeGreaterThan(160);
|
||
expect(state.submissions).toHaveLength(0);
|
||
expect(errors).toEqual([]);
|
||
});
|
||
|
||
test("pagehide and visibilitychange issue only one closing save", async ({ page }) => {
|
||
const { state, errors } = await setup(page);
|
||
state.failSave = true;
|
||
await moveImage(page);
|
||
const writes = state.writes.length;
|
||
await page.evaluate(() => {
|
||
window.dispatchEvent(new Event("pagehide"));
|
||
Object.defineProperty(document, "visibilityState", { value: "hidden", configurable: true });
|
||
document.dispatchEvent(new Event("visibilitychange"));
|
||
});
|
||
await expect.poll(() => state.writes.length).toBe(writes + 1);
|
||
expect(state.submissions).toHaveLength(0);
|
||
expect(errors).toEqual([]);
|
||
});
|
||
|
||
test("leaving with pending changes can continue editing or save before navigating", async ({ page }) => {
|
||
const { state, errors } = await setup(page);
|
||
await page.route(url => url.pathname === "/", route => route.fulfill({ contentType: "text/html; charset=utf-8", body: "<p>已返回首页</p>" }));
|
||
await moveImage(page);
|
||
await page.getByRole("button", { name: "返回首页", exact: true }).click();
|
||
const dialog = page.getByRole("dialog", { name: "离开前保存画布?" });
|
||
await expect(dialog).toBeVisible();
|
||
await page.screenshot({ path: test.info().outputPath("leave-dialog-desktop.png"), animations: "disabled" });
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await page.screenshot({ path: test.info().outputPath("leave-dialog-mobile.png"), animations: "disabled" });
|
||
await page.setViewportSize({ width: 1440, height: 900 });
|
||
await dialog.getByRole("button", { name: "继续编辑", exact: true }).click();
|
||
await expect(dialog).toHaveCount(0);
|
||
await moveImage(page);
|
||
await page.getByRole("button", { name: "返回首页", exact: true }).click();
|
||
await dialog.getByRole("button", { name: "保存并离开", exact: true }).click();
|
||
await expect(page.getByText("已返回首页")).toBeVisible();
|
||
expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160);
|
||
expect(state.submissions).toHaveLength(0);
|
||
expect(errors).toEqual([]);
|
||
});
|
||
|
||
test("failed saving keeps the leave dialog and canvas until a successful retry", async ({ page }) => {
|
||
const { state, errors } = await setup(page);
|
||
await page.route(url => url.pathname === "/", route => route.fulfill({ contentType: "text/html; charset=utf-8", body: "<p>已返回首页</p>" }));
|
||
state.failSave = true;
|
||
await moveImage(page);
|
||
await page.getByRole("button", { name: "返回首页", exact: true }).click();
|
||
const dialog = page.getByRole("dialog", { name: "离开前保存画布?" });
|
||
await dialog.getByRole("button", { name: "保存并离开", exact: true }).click();
|
||
await expect(dialog.getByRole("alert")).toContainText("HTTP 503");
|
||
await expect(page.locator('[data-canvas-node-id="source"]')).toBeVisible();
|
||
expect(state.submissions).toHaveLength(0);
|
||
state.failSave = false;
|
||
await dialog.getByRole("button", { name: "保存并离开", exact: true }).click();
|
||
await expect(page.getByText("已返回首页")).toBeVisible();
|
||
expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160);
|
||
expect(errors).toEqual([]);
|
||
});
|
||
|
||
test("discarding pending changes leaves without saving or restoring the discarded draft", async ({ page }) => {
|
||
const { state, errors } = await setup(page);
|
||
await page.route(url => url.pathname === "/", route => route.fulfill({ contentType: "text/html; charset=utf-8", body: "<p>已返回首页</p>" }));
|
||
state.failSave = true;
|
||
await moveImage(page);
|
||
expect(await unloadWouldBeBlocked(page)).toBe(true);
|
||
const writes = state.writes.length;
|
||
await page.getByRole("button", { name: "返回首页", exact: true }).click();
|
||
await page.getByRole("button", { name: "不保存并离开", exact: true }).click();
|
||
await expect(page.getByText("已返回首页")).toBeVisible();
|
||
expect(state.writes).toHaveLength(writes);
|
||
expect(state.canvasData.nodes[0].position.x).toBe(160);
|
||
expect(await page.evaluate(id => localStorage.getItem(`canvas-unsynced:${id}`), projectId)).toBeNull();
|
||
expect(errors).toEqual([]);
|
||
});
|
||
|
||
test("creating another project also offers to preserve pending canvas changes", async ({ page }) => {
|
||
const { state, errors } = await setup(page);
|
||
state.failSave = true;
|
||
await moveImage(page);
|
||
await page.getByRole("button", { name: "查看项目文件", exact: true }).click();
|
||
await page.getByRole("button", { name: "新建画布", exact: true }).first().click();
|
||
await expect(page.getByRole("dialog", { name: "离开前保存画布?" })).toBeVisible();
|
||
await page.getByRole("button", { name: "继续编辑", exact: true }).click();
|
||
await expect(page).toHaveURL(new RegExp(`/canvas/${projectId}$`));
|
||
expect(state.submissions).toHaveLength(0);
|
||
expect(errors).toEqual([]);
|
||
});
|
||
|
||
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();
|
||
await expect(page.getByText(/本次处理未提交/)).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("unfinished image uploads remain on the page even after their canvas placeholder was saved", async ({ page }) => {
|
||
const { state, errors } = await setup(page);
|
||
const refreshes = await countRefreshes(page, state);
|
||
state.holdUpload = true;
|
||
await page.mouse.click(100, 90);
|
||
await page.evaluate(() => {
|
||
const bytes = Uint8Array.from(atob("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j2d0AAAAASUVORK5CYII="), c => c.charCodeAt(0));
|
||
const transfer = new DataTransfer();
|
||
transfer.items.add(new File([bytes], "上传保护.png", { type: "image/png" }));
|
||
window.dispatchEvent(new ClipboardEvent("paste", { clipboardData: transfer, bubbles: true, cancelable: true }));
|
||
});
|
||
await expect.poll(() => state.uploadStarted).toBeGreaterThan(0);
|
||
await expect.poll(() => state.canvasData.nodes.filter(node => node.data.src === "").length).toBe(1);
|
||
expect(await unloadWouldBeBlocked(page)).toBe(true);
|
||
await page.getByRole("button", { name: "返回首页", exact: true }).click();
|
||
const dialog = page.getByRole("dialog", { name: "离开前保存画布?" });
|
||
await dialog.getByRole("button", { name: "保存并离开", exact: true }).click();
|
||
await expect(dialog.getByRole("alert")).toContainText("还有图片未上传成功");
|
||
await dialog.getByRole("button", { name: "继续编辑", exact: true }).click();
|
||
await showUpdate(page, state);
|
||
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
||
await expect(page.getByRole("alertdialog")).toContainText("还有图片未上传成功");
|
||
expect(refreshes()).toBe(0);
|
||
state.holdUpload = false;
|
||
state.finishUpload?.();
|
||
await expect.poll(() => state.canvasData.nodes.some(node => node.data.src.includes("uploaded.png"))).toBe(true);
|
||
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
||
await expect.poll(refreshes).toBe(1);
|
||
expect(state.canvasData.nodes).toHaveLength(2);
|
||
expect(state.submissions).toHaveLength(0);
|
||
expect(errors).toEqual([]);
|
||
});
|
||
|
||
test("a composer upload blocks release refresh and closing until the image and input are retained", async ({ page }) => {
|
||
const { state, errors } = await setup(page);
|
||
const refreshes = await countRefreshes(page, state);
|
||
state.holdUpload = true;
|
||
const editor = page.getByLabel("描述你的设计需求", { exact: true });
|
||
await editor.fill("上传后保留这条输入");
|
||
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.uploadStarted).toBeGreaterThan(0);
|
||
await expect(page.getByText("聊天上传保护.png", { exact: true })).toBeVisible();
|
||
expect(await unloadWouldBeBlocked(page)).toBe(true);
|
||
await showUpdate(page, state);
|
||
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
||
await expect(page.getByRole("alertdialog")).toContainText("素材仍在上传");
|
||
expect(refreshes()).toBe(0);
|
||
state.holdUpload = false;
|
||
state.finishUpload?.();
|
||
await expect.poll(() => state.canvasData.nodes.some(node => node.data.src.includes("uploaded.png"))).toBe(true);
|
||
await expect(page.getByText("聊天上传保护.png", { exact: true })).toHaveCount(1);
|
||
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
||
await expect.poll(refreshes).toBe(1);
|
||
await expect(editor).toContainText("上传后保留这条输入");
|
||
await expect(editor.getByText("聊天上传保护.png", { exact: true })).toBeVisible();
|
||
expect(state.canvasData.nodes).toHaveLength(2);
|
||
expect(state.submissions).toHaveLength(0);
|
||
expect(errors).toEqual([]);
|
||
});
|
||
|
||
test("an older browser without AbortSignal static helpers receives the release notice", async ({ page }) => {
|
||
await page.context().addInitScript(() => {
|
||
Object.defineProperty(AbortSignal, "any", { value: undefined, configurable: true });
|
||
Object.defineProperty(AbortSignal, "timeout", { value: undefined, configurable: true });
|
||
});
|
||
const { state, errors } = await setup(page);
|
||
await showUpdate(page, state);
|
||
await expect(page.getByRole("button", { name: "同步并刷新", exact: true })).toBeVisible();
|
||
expect(state.submissions).toHaveLength(0);
|
||
expect(errors).toEqual([]);
|
||
});
|
||
|
||
test("a release is detected by the minute timer without requiring a focus change", async ({ page }) => {
|
||
await page.clock.install();
|
||
const { state, errors } = await setup(page);
|
||
state.appVersion = "20261106T120000Z-nextrelease1";
|
||
await page.clock.fastForward(60_001);
|
||
await expect(page.getByRole("alertdialog")).toContainText("平台已更新");
|
||
expect(state.submissions).toHaveLength(0);
|
||
expect(errors).toEqual([]);
|
||
});
|
||
|
||
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");
|
||
await expect(page.getByRole("alertdialog")).not.toContainText("本次任务未提交");
|
||
await expect(page.getByRole("alertdialog")).not.toContainText("再次发送");
|
||
expect(refreshes()).toBe(0);
|
||
expect(state.submissions).toHaveLength(0);
|
||
await page.screenshot({ path: test.info().outputPath("update-error-desktop.png"), animations: "disabled" });
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await page.screenshot({ path: test.info().outputPath("update-error-mobile.png"), animations: "disabled" });
|
||
await page.setViewportSize({ width: 1440, height: 900 });
|
||
await page.getByRole("button", { name: "返回处理", exact: true }).click();
|
||
await expect(page.getByRole("alertdialog")).toHaveCount(0);
|
||
await expect(page.locator('[data-canvas-node-id="source"]')).toBeVisible();
|
||
state.failSave = false;
|
||
await showUpdate(page, state);
|
||
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([]);
|
||
});
|