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(resolve=>waiting.push(resolve)); await route.fulfill({contentType:'image/svg+xml',body:''}); }); const {errors}=await setup(page);await startStream(page); await page.evaluate(()=>(window as any).agentScrollStream.append('\n\n![生成结果](https://scqnycdn.jiansun.vip/uploads/scroll-result.png)\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(); }); });