199 lines
14 KiB
TypeScript
199 lines
14 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
|
|
|
const projectId="f1827b8f-b810-44d8-9927-c2ef255eed78";
|
|
const paragraph="保持产品结构与材质一致,先分析参考图片,再安排场景与光线,逐项确认处理要求。";
|
|
const makeMessage=(id:string,role:string,text:string,offset:number)=>({id,role,parts:[{type:"text",text}],createdAt:new Date(Date.UTC(2026,10,6,0,0,offset)).toISOString()});
|
|
|
|
async function setup(page:Page) {
|
|
const state={history:Array.from({length:16},(_,i)=>makeMessage(`history-${i}`,i%2?"assistant":"user",`历史记录 ${i+1}\n\n${new Array(4).fill(paragraph).join("\n\n")}`,i)),historyReads:0,run:null as any};
|
|
const errors:string[]=[];page.on("pageerror",error=>errors.push(error.message));
|
|
await page.context().addInitScript(()=>{
|
|
localStorage.setItem("theme","light");
|
|
const originalFetch=window.fetch.bind(window);
|
|
(window as any).agentScrollStream={};
|
|
window.fetch=async(input,init)=>{
|
|
const url=typeof input==="string"?input:input instanceof URL?input.href:input.url;
|
|
if(new URL(url,location.origin).pathname!=="/api/canvas/agent-chat"||init?.method!=="POST")return originalFetch(input,init);
|
|
const request=JSON.parse(String(init.body));
|
|
const stream=(window as any).agentScrollStream;
|
|
stream.request=request;stream.text="";
|
|
const encoder=new TextEncoder();
|
|
return new Response(new ReadableStream({start(controller){
|
|
const emit=(value:unknown)=>controller.enqueue(encoder.encode(`data: ${JSON.stringify(value)}\n\n`));
|
|
emit({type:"start",messageId:"stream-assistant"});emit({type:"text-start",id:"text"});
|
|
stream.append=(text:string)=>{stream.text+=text;emit({type:"text-delta",id:"text",delta:text});};
|
|
stream.finish=()=>{emit({type:"text-end",id:"text"});emit({type:"finish"});controller.enqueue(encoder.encode("data: [DONE]\n\n"));controller.close();};
|
|
}}),{headers:{"Content-Type":"text/event-stream","x-vercel-ai-ui-message-stream":"v1"}});
|
|
};
|
|
});
|
|
await page.context().route("**/api/**",async route=>{
|
|
const request=route.request(),url=new URL(request.url()),path=url.pathname;
|
|
let data:unknown={};
|
|
if(path===`/api/canvas/${projectId}`)data={success:true,data:{id:projectId,title:"滚动验证",updatedAt:"2026-11-06T00:00:00.000Z",canvasData:{nodes:[],edges:[],viewport:{x:0,y:0,zoom:1}}}};
|
|
else if(path==="/api/auth/session")data={user:{id:"scroll-user",name:"滚动验证",type:"admin"},expires:"2099-01-01"};
|
|
else if(path.includes("validate-session"))data={success:true,valid:true};
|
|
else if(path==="/api/site-settings")data={canvasEnabled:true,agentEnabled:true,membershipEnabled:false};
|
|
else if(path==="/api/app-version")data={version:"development"};
|
|
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==="/api/canvas/agent-runs")data={run:state.run};
|
|
else if(path.includes("messages")){state.historyReads++;data={messages:url.searchParams.get("chatId")===projectId?state.history:[]};}
|
|
else if(path.includes("sessions"))data={sessions:[]};
|
|
else if(path.startsWith("/api/canvas/"))data={success:true,runs:[]};
|
|
await route.fulfill({contentType:"application/json",body:JSON.stringify(data)});
|
|
});
|
|
await page.goto(`/canvas/${projectId}`,{waitUntil:"domcontentloaded"});
|
|
await expect(page.getByTestId("canvas-chat-messages")).toContainText("历史记录 16");
|
|
await expect.poll(async()=>(await metrics(page)).bottom).toBeLessThan(4);
|
|
return {state,errors};
|
|
}
|
|
const metrics=(page:Page)=>page.getByTestId("canvas-chat-messages").evaluate(element=>({top:element.scrollTop,bottom:element.scrollHeight-element.clientHeight-element.scrollTop}));
|
|
async function startStream(page:Page){
|
|
const editor=page.getByLabel("描述你的设计需求",{exact:true});await editor.fill("先分析和规划,再处理图片。");await editor.press("Enter");
|
|
await expect.poll(()=>page.evaluate(()=>Boolean((window as any).agentScrollStream.append))).toBe(true);
|
|
await page.evaluate(text=>(window as any).agentScrollStream.append(text),`开始分析\n\n${new Array(8).fill(paragraph).join("\n\n")}\n\n首次输出已显示\n\n`);
|
|
await expect(page.getByTestId("canvas-chat-messages")).toContainText("首次输出已显示");
|
|
await expect.poll(async()=>(await metrics(page)).bottom).toBeLessThan(4);
|
|
}
|
|
async function scrollUp(page:Page){
|
|
const box=(await page.getByTestId("canvas-chat-messages").boundingBox())!;
|
|
await page.mouse.move(box.x+box.width/2,box.y+box.height/2);await page.mouse.wheel(0,-650);
|
|
await expect.poll(async()=>(await metrics(page)).bottom).toBeGreaterThan(350);
|
|
await page.waitForTimeout(100);
|
|
return (await metrics(page)).top;
|
|
}
|
|
|
|
test("streaming Agent output preserves the position after the reader scrolls up",async({page})=>{
|
|
const {errors}=await setup(page);await startStream(page);const top=await scrollUp(page);
|
|
await page.evaluate(text=>(window as any).agentScrollStream.append(text),`新增分析步骤\n\n${new Array(5).fill(paragraph).join("\n\n")}`);
|
|
await expect(page.getByTestId("canvas-chat-messages")).toContainText("新增分析步骤");
|
|
await page.waitForTimeout(400);
|
|
expect(Math.abs((await metrics(page)).top-top)).toBeLessThan(4);
|
|
expect(errors).toEqual([]);
|
|
});
|
|
|
|
test("returning to the latest message resumes following streamed output",async({page})=>{
|
|
const {errors}=await setup(page);await startStream(page);await scrollUp(page);
|
|
await expect(page.getByRole("button",{name:"回到最新消息",exact:true})).toBeVisible();
|
|
await page.screenshot({path:test.info().outputPath("reading-history-desktop.png"),animations:"disabled"});
|
|
await page.getByRole("button",{name:"回到最新消息",exact:true}).click();
|
|
await expect.poll(async()=>(await metrics(page)).bottom).toBeLessThan(4);
|
|
await page.evaluate(text=>(window as any).agentScrollStream.append(text),`继续处理\n\n${new Array(5).fill(paragraph).join("\n\n")}`);
|
|
await expect(page.getByTestId("canvas-chat-messages")).toContainText("继续处理");
|
|
await expect.poll(async()=>(await metrics(page)).bottom).toBeLessThan(4);
|
|
await scrollUp(page);
|
|
const box=(await page.getByTestId("canvas-chat-messages").boundingBox())!;
|
|
await page.mouse.move(box.x+box.width/2,box.y+box.height/2);await page.mouse.wheel(0,20000);
|
|
await expect.poll(async()=>(await metrics(page)).bottom).toBeLessThan(4);
|
|
await page.evaluate(text=>(window as any).agentScrollStream.append(text),`已回到最新消息\n\n${paragraph}`);
|
|
await expect(page.getByTestId("canvas-chat-messages")).toContainText("已回到最新消息");
|
|
await expect.poll(async()=>(await metrics(page)).bottom).toBeLessThan(4);
|
|
expect(errors).toEqual([]);
|
|
});
|
|
|
|
test("finishing a streamed request and reloading its history preserve the reader and viewport",async({page})=>{
|
|
const {state,errors}=await setup(page);await startStream(page);const top=await scrollUp(page);
|
|
await page.getByTestId("canvas-chat-messages").evaluate(element=>(window as any).readingViewport=element);
|
|
const stream=await page.evaluate(()=>({request:(window as any).agentScrollStream.request,text:(window as any).agentScrollStream.text}));
|
|
state.history.push(stream.request.message,{...makeMessage("stream-assistant","assistant",stream.text,100),createdAt:new Date(Date.now()+100).toISOString()});
|
|
const reads=state.historyReads;
|
|
await page.evaluate(()=>(window as any).agentScrollStream.finish());
|
|
await expect(page.getByLabel("描述你的设计需求",{exact:true})).toBeEditable();
|
|
await expect.poll(()=>state.historyReads).toBeGreaterThan(reads);
|
|
await page.waitForTimeout(3800);
|
|
expect(await page.getByTestId("canvas-chat-messages").evaluate(element=>element===(window as any).readingViewport)).toBe(true);
|
|
expect(Math.abs((await metrics(page)).top-top)).toBeLessThan(4);
|
|
expect(errors).toEqual([]);
|
|
});
|
|
|
|
test("background Agent progress restores text in the original chat without taking over scrolling",async({page})=>{
|
|
const {state,errors}=await setup(page);const top=await scrollUp(page);
|
|
const text=state.history.at(-1)!.parts[0].text+"\n\n后台任务继续分析参考图片。";
|
|
state.run={id:"native-scroll-run",chatId:projectId,projectId,assistantMessageId:state.history.at(-1)!.id,assistantText:text,status:"running",message:"正在分析图片",steps:2,toolCount:2,resultNodeIds:[],inputs:[],updatedAt:new Date().toISOString(),cancelRequested:false,resumable:false};
|
|
await page.evaluate(run=>window.dispatchEvent(new CustomEvent("canvas-agent-run:update",{detail:run})),state.run);
|
|
await expect(page.getByTestId("canvas-chat-messages")).toContainText("后台任务继续分析参考图片。");
|
|
await page.waitForTimeout(100);
|
|
expect(Math.abs((await metrics(page)).top-top)).toBeLessThan(4);
|
|
state.run={...state.run,status:"completed",assistantText:text+"\n\n后台任务处理结束。"};
|
|
await page.evaluate(run=>window.dispatchEvent(new CustomEvent("canvas-agent-run:update",{detail:run})),state.run);
|
|
await expect(page.getByTestId("canvas-chat-messages")).toContainText("后台任务处理结束。");
|
|
expect(Math.abs((await metrics(page)).top-top)).toBeLessThan(4);
|
|
expect(errors).toEqual([]);
|
|
});
|
|
|
|
test("background history refresh preserves the same scroll container and reading position",async({page})=>{
|
|
await page.clock.install();
|
|
const {state,errors}=await setup(page);const top=await scrollUp(page);
|
|
await page.getByTestId("canvas-chat-messages").evaluate(element=>(window as any).readingViewport=element);
|
|
state.history.at(-1)!.parts[0].text+="\n\n历史记录已从服务端更新。";
|
|
const reads=state.historyReads;
|
|
await page.clock.fastForward(11000);
|
|
await page.evaluate(()=>window.dispatchEvent(new Event("focus")));
|
|
await expect.poll(()=>state.historyReads).toBeGreaterThan(reads);
|
|
await expect(page.getByTestId("canvas-chat-messages")).toContainText("历史记录已从服务端更新。");
|
|
expect(await page.getByTestId("canvas-chat-messages").evaluate(element=>element===(window as any).readingViewport)).toBe(true);
|
|
expect(Math.abs((await metrics(page)).top-top)).toBeLessThan(4);
|
|
expect(errors).toEqual([]);
|
|
});
|
|
|
|
test("a generated preview finishing its load does not pull a reader back to the bottom",async({page})=>{
|
|
let ready=false;let requests=0;const waiting:Array<()=>void>=[];
|
|
await page.context().route('https://scqnycdn.jiansun.vip/**',async route=>{
|
|
requests++;
|
|
if(!ready)await new Promise<void>(resolve=>waiting.push(resolve));
|
|
await route.fulfill({contentType:'image/svg+xml',body:'<svg xmlns="http://www.w3.org/2000/svg" width="800" height="1200"><rect width="800" height="1200" fill="#43b9bd"/></svg>'});
|
|
});
|
|
const {errors}=await setup(page);await startStream(page);
|
|
await page.evaluate(()=>(window as any).agentScrollStream.append('\n\n\n\n'));
|
|
await expect.poll(()=>requests).toBeGreaterThan(0);
|
|
const top=await scrollUp(page);ready=true;waiting.forEach(release=>release());
|
|
await expect.poll(()=>page.getByTestId('canvas-chat-messages').locator('img').evaluateAll(images=>images.some(image=>(image as HTMLImageElement).naturalWidth>0))).toBe(true);
|
|
await page.waitForTimeout(200);
|
|
expect(Math.abs((await metrics(page)).top-top)).toBeLessThan(4);
|
|
expect(errors).toEqual([]);
|
|
});
|
|
|
|
test("keyboard scrolling pauses following and a new conversation starts at its own latest message",async({page})=>{
|
|
const {errors}=await setup(page);await startStream(page);
|
|
await page.getByTestId("canvas-chat-messages").press("PageUp");
|
|
await expect.poll(async()=>(await metrics(page)).bottom).toBeGreaterThan(100);
|
|
const top=(await metrics(page)).top;
|
|
await page.evaluate(()=>(window as any).agentScrollStream.append("\n\n键盘上翻后的新进度。"));
|
|
await expect(page.getByTestId("canvas-chat-messages")).toContainText("键盘上翻后的新进度。");
|
|
await page.waitForTimeout(200);
|
|
expect((await metrics(page)).top).toBeLessThanOrEqual(top+4);
|
|
await page.getByRole("button",{name:"新建聊天窗口",exact:true}).click();
|
|
await expect(page.getByTestId("canvas-chat-messages")).not.toContainText("历史记录 1");
|
|
await expect(page.getByLabel("描述你的设计需求",{exact:true})).toBeEditable();
|
|
await startStream(page);
|
|
await expect.poll(async()=>(await metrics(page)).bottom).toBeLessThan(4);
|
|
expect(errors).toEqual([]);
|
|
});
|
|
|
|
test.describe("mobile touch reading",()=>{
|
|
test.use({viewport:{width:390,height:844},isMobile:true,hasTouch:true});
|
|
test("touch scrolling keeps history readable while the Agent continues streaming",async({page})=>{
|
|
const {errors}=await setup(page);await startStream(page);
|
|
const box=(await page.getByTestId("canvas-chat-messages").boundingBox())!;
|
|
const cdp=await page.context().newCDPSession(page);
|
|
const x=box.x+box.width/2,y=box.y+70,delta=Math.min(260,box.height-120);
|
|
await cdp.send("Input.dispatchTouchEvent",{type:"touchStart",touchPoints:[{x,y}]});
|
|
for(let i=1;i<=8;i++){
|
|
await cdp.send("Input.dispatchTouchEvent",{type:"touchMove",touchPoints:[{x,y:y+delta*i/8}]});
|
|
await page.waitForTimeout(25);
|
|
}
|
|
await cdp.send("Input.dispatchTouchEvent",{type:"touchEnd",touchPoints:[]});
|
|
await expect.poll(async()=>(await metrics(page)).bottom).toBeGreaterThan(100);
|
|
const top=(await metrics(page)).top;
|
|
await page.evaluate(()=>(window as any).agentScrollStream.append("\n\n手机查看历史时的新进度。"));
|
|
await expect(page.getByTestId("canvas-chat-messages")).toContainText("手机查看历史时的新进度。");
|
|
await page.waitForTimeout(300);
|
|
expect((await metrics(page)).top).toBeLessThanOrEqual(top+4);
|
|
await expect(page.getByRole("button",{name:"回到最新消息",exact:true})).toBeVisible();
|
|
await page.screenshot({path:test.info().outputPath("reading-history-mobile.png"),animations:"disabled"});
|
|
await page.getByRole("button",{name:"回到最新消息",exact:true}).click();
|
|
await expect.poll(async()=>(await metrics(page)).bottom).toBeLessThan(4);
|
|
expect(errors).toEqual([]);await cdp.detach();
|
|
});
|
|
});
|