managedBrowserCardSmoke.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. // @vitest-environment node
  2. import { expect, it } from 'vitest'
  3. import { chromium, expect as browserExpect } from '@playwright/test'
  4. import { mkdtemp, mkdir, rm } from 'node:fs/promises'
  5. import { spawn } from 'node:child_process'
  6. import { createServer } from 'node:net'
  7. import { tmpdir } from 'node:os'
  8. import { join, resolve } from 'node:path'
  9. async function freePort(){const server=createServer();await new Promise<void>(done=>server.listen(0,'127.0.0.1',done));const port=(server.address() as {port:number}).port;await new Promise<void>(done=>server.close(()=>done()));return port}
  10. // Uses a built client and isolated authentication fixture; no model, VM or
  11. // installation is started. Run after `vite build` with YAOYAO_BROWSER_UI_SMOKE=1.
  12. it.runIf(process.env.YAOYAO_BROWSER_UI_SMOKE==='1')('opens the structured chat card in the existing native/Web viewer and fits small screens',async()=>{
  13. const root=resolve(import.meta.dirname,'../..'),home=await mkdtemp(join(tmpdir(),'yaoyao-browser-ui-'))
  14. const port=await freePort(),upstream=await freePort(),origin=`http://127.0.0.1:${port}`
  15. const server=spawn(process.execPath,['--import','tsx','tests/fixtures/workspace-server.ts'],{cwd:root,env:{...process.env,WORKSPACE_FIXTURE_HOME:home,WORKSPACE_FIXTURE_TEAM_TOOLS:'1',WORKSPACE_FIXTURE_PORT:String(port),WORKSPACE_FIXTURE_UPSTREAM_PORT:String(upstream)},stdio:'ignore'})
  16. let browser:Awaited<ReturnType<typeof chromium.launch>>|undefined
  17. try{
  18. await browserExpect.poll(async()=>{try{return(await fetch(origin+'/healthz')).status}catch{return 0}},{timeout:30000}).toBe(200)
  19. browser=await chromium.launch();const page=await browser.newPage({viewport:{width:1100,height:850}})
  20. await page.goto(origin+'/conversations')
  21. await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture')
  22. await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass')
  23. await page.getByRole('button',{name:'登录',exact:true}).click()
  24. await browserExpect(page.locator('.desktop-sidebar .sidebar-create-trigger')).toBeVisible()
  25. const seed=await(await page.request.post(origin+'/__test/computer',{data:{}})).json()
  26. const calls:{op:string;body?:any}[]=[]
  27. let available=false,installing=false,human=false,online=true,browserReads=0,browserOpen=false,canResume=true
  28. let historyStatus:'idle'|'closed'|undefined
  29. const installation=()=>({status:available?'ready':installing?'installing':'missing',message:available?'浏览器已准备':installing?'正在下载 Chromium · 45%':'首次使用将自动准备 Chromium',...(installing&&!available?{progress:45}:{}),updatedAt:Date.now()})
  30. const card=()=>({id:'real-server-card',agentId:seed.agentId,agentName:'电脑面板验收 1',status:historyStatus??(available?'active':'preparing'),title:'产品文档 · 浏览器接管验证',url:'https://example.com/docs/browser/session-with-long-title-and-path',installation:installation(),updatedAt:Date.now()})
  31. const inject=(data:any)=>{if(data.conversation?.id===seed.conversationId)data.messages=[{id:'browser-message',conversationId:seed.conversationId,agentId:seed.agentId,agentName:'电脑面板验收 1',seq:1,role:'assistant',content:'',reasoning:'',status:'complete',attachments:[],tools:[],browserCard:card(),createdAt:Date.now()},{id:'service-warning',conversationId:seed.conversationId,agentId:seed.agentId,agentName:'电脑面板验收 1',seq:2,role:'assistant',content:'',reasoning:'',status:'complete',attachments:[],tools:[],serviceWarnings:[{code:'plugin_unavailable',service:'天气插件',message:'MCP 服务暂不可用,本次任务继续使用其余能力。'}],createdAt:Date.now()}];return data}
  32. await page.route('**/api/app/conversations/'+seed.conversationId+'?*',async route=>{const response=await route.fetch();await route.fulfill({response,json:inject(await response.json())})})
  33. await page.route('**/api/app/workspace/snapshot*',async route=>{const response=await route.fetch(),data=await response.json();data.details=data.details.map(inject);await route.fulfill({response,json:data})})
  34. await page.route('**/api/app/settings/host-tools',route=>route.fulfill({json:{scriptMachine:false,serverComputer:false,vm:false,cloud:false,managedBrowser:true}}))
  35. const image=await page.evaluate(()=>{const canvas=document.createElement('canvas');canvas.width=1000;canvas.height=650;const ctx=canvas.getContext('2d')!;ctx.fillStyle='#f4f6f8';ctx.fillRect(0,0,1000,650);ctx.fillStyle='#27313f';ctx.font='32px sans-serif';ctx.fillText('Browser control preview',50,95);ctx.font='20px sans-serif';ctx.fillText('The same page continues after handoff.',50,145);return canvas.toDataURL('image/png').split(',')[1]})
  36. await page.route('**/api/app/agents/'+seed.agentId+'/**',async route=>{
  37. const url=new URL(route.request().url()),path=url.pathname
  38. if(path.endsWith('/managed-browser/prepare')){calls.push({op:'prepare',body:route.request().postDataJSON()});installing=true}
  39. if(path.endsWith('/managed-browser')||path.endsWith('/managed-browser/prepare')){browserReads++;return route.fulfill({json:{enabled:true,available:online&&available,supportsRetention:true,...(online?{installation:installation()}:{reason:'执行节点离线,请恢复节点连接'}),open:browserOpen,generation:4,profile:'persistent',tabs:browserOpen?[{id:'retained-tab',title:'产品文档',url:'https://example.com/docs',active:true}]:[],downloads:[]}})}
  40. if(path.endsWith('/managed-browser/close')){
  41. expect(human).toBe(true);expect(canResume).toBe(false)
  42. calls.push({op:'close-browser',body:route.request().postDataJSON()});human=false;browserOpen=false;historyStatus='closed'
  43. return route.fulfill({json:{mode:'off',backend:'managed-browser',generation:4,canResume:false}})
  44. }
  45. if(!path.includes('/computer'))return route.continue()
  46. expect(url.searchParams.get('backend')).toBe('managed-browser')
  47. if(path.endsWith('/frame'))return route.fulfill({json:{id:'frame',generation:4,width:1000,height:650,data:image}})
  48. if(route.request().method()==='POST'){
  49. const op=path.split('/').at(-1)!;calls.push({op,body:route.request().postDataJSON()})
  50. if(op==='input'){
  51. const action=route.request().postDataJSON().action
  52. const extra=Object.keys(action).filter(key=>!['gestureId','kind','phase','x','y'].includes(key))
  53. if(extra.length)return route.fulfill({status:400,json:{error:'Unrecognized keys: '+extra.join(', ')}})
  54. }
  55. if(op==='take'){expect(available).toBe(true);human=true;browserOpen=true}
  56. if(op==='giveback')human=false
  57. }
  58. return route.fulfill({json:{mode:human?'human':browserOpen?'idle':'off',backend:'managed-browser',hostName:'托管浏览器',generation:4,canResume,...(human?{controlId:'control',token:'fixture-token'}:{})}})
  59. })
  60. await page.goto(origin+'/conversations/'+seed.conversationId)
  61. const element=page.getByRole('region',{name:'托管浏览器会话'})
  62. await browserExpect(element).toContainText('产品文档 · 浏览器接管验证')
  63. const warnings=page.getByLabel('服务状态提示')
  64. await browserExpect(warnings.getByRole('status')).toHaveText('天气插件:MCP 服务暂不可用,本次任务继续使用其余能力。')
  65. await browserExpect(warnings.getByRole('alert')).toHaveCount(0)
  66. const output=join(root,'test-results/managed-browser-cards');await mkdir(output,{recursive:true})
  67. await page.setViewportSize({width:375,height:812})
  68. await browserExpect(element.getByRole('button',{name:'接管浏览器',exact:true})).toBeVisible()
  69. expect(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth)).toBe(false)
  70. await page.screenshot({path:join(output,'chat-375.png')})
  71. // The native bridge receives exactly the trusted card's Bot and backend.
  72. await page.evaluate(()=>{(window as any).__native=[];(window as any).yaoyaoDesktop={openComputer:async(...args:unknown[])=>{(window as any).__native.push(args);return true}}})
  73. await element.getByRole('button',{name:'接管浏览器',exact:true}).click()
  74. await browserExpect.poll(()=>page.evaluate(()=>(window as any).__native)).toEqual([[seed.agentId,{backend:'managed-browser'}]])
  75. await page.evaluate(()=>delete (window as any).yaoyaoDesktop)
  76. await element.getByRole('button',{name:'接管浏览器',exact:true}).click()
  77. const panel=page.locator('dialog.computer-panel')
  78. await browserExpect(panel.getByRole('status')).toContainText('正在下载 Chromium')
  79. expect(calls.filter(call=>call.op==='prepare')).toHaveLength(1)
  80. expect(calls.some(call=>call.op==='take')).toBe(false)
  81. await page.screenshot({path:join(output,'preparing-375.png')})
  82. available=true
  83. await browserExpect(panel).toContainText('人工控制中')
  84. await browserExpect(panel.locator('.computer-screen img')).toBeVisible()
  85. expect(calls.filter(call=>call.op==='take')).toHaveLength(1)
  86. await browserExpect(panel.getByRole('button',{name:'关闭浏览器',exact:true})).toHaveCount(0)
  87. expect(await panel.evaluate(el=>el.scrollWidth>el.clientWidth)).toBe(false)
  88. await page.screenshot({path:join(output,'control-375.png')})
  89. const screen=panel.locator('.computer-screen img'),bounds=(await screen.boundingBox())!
  90. await browserExpect(screen).toHaveCSS('touch-action','none')
  91. const centerY=bounds.y+bounds.height/2
  92. await page.mouse.move(bounds.x+bounds.width*0.25,centerY);await page.mouse.down()
  93. await page.mouse.move(bounds.x+bounds.width*0.75,centerY,{steps:8})
  94. await browserExpect.poll(()=>calls.filter(call=>call.op==='input'&&call.body.action.phase==='move').length).toBeGreaterThan(0)
  95. const liveInputs=()=>calls.filter(call=>call.op==='input').map(call=>call.body.action)
  96. expect(liveInputs()[0]).toMatchObject({kind:'pointer',phase:'start'})
  97. // The real server rejects extra fields. Matching only a subset masked the
  98. // viewer's clientX/clientY/pointerId/element leaking into the start request.
  99. expect(Object.keys(liveInputs()[0]).sort()).toEqual(['gestureId','kind','phase','x','y'])
  100. expect(liveInputs().some(action=>action.phase==='end')).toBe(false)
  101. // Pointer capture keeps a release outside the image, clamped to the frame.
  102. await page.mouse.move(bounds.x+bounds.width+20,centerY);await page.mouse.up()
  103. await browserExpect.poll(()=>liveInputs().find(action=>action.phase==='end')).toMatchObject({x:999,y:325})
  104. await browserExpect(panel.getByRole('textbox',{name:'浏览器地址'})).toBeEnabled()
  105. await page.mouse.move(bounds.x+bounds.width*0.25,centerY);await page.mouse.down()
  106. await page.mouse.move(bounds.x+bounds.width*0.5,centerY,{steps:3})
  107. await browserExpect.poll(()=>liveInputs().filter(action=>action.phase==='start').length).toBe(2)
  108. await page.keyboard.press('Escape')
  109. await browserExpect.poll(()=>liveInputs().filter(action=>action.phase==='cancel').length).toBe(1)
  110. await page.mouse.up()
  111. expect(liveInputs().every(action=>action.kind==='pointer')).toBe(true)
  112. for(const action of liveInputs())expect(Object.keys(action).sort()).toEqual(['gestureId','kind','phase','x','y'])
  113. await browserExpect(panel.locator('.computer-error')).toHaveCount(0)
  114. await panel.getByRole('button',{name:'交还并收起',exact:true}).click()
  115. await browserExpect(panel).toHaveCount(0)
  116. expect(calls.filter(call=>call.op==='giveback')).toHaveLength(1)
  117. expect(browserOpen).toBe(true)
  118. expect(calls.some(call=>call.op==='close-browser')).toBe(false)
  119. expect(page.url()).toContain('/conversations/'+seed.conversationId)
  120. // Offline is a connection state, never a synthetic installation. Rechecking
  121. // must recover the same browser with read-only requests before another take.
  122. online=false
  123. const writesBeforeRecheck=calls.length
  124. await element.getByRole('button',{name:'接管浏览器',exact:true}).click()
  125. await browserExpect(panel).toContainText('执行节点离线,请恢复节点连接')
  126. await browserExpect(panel.getByRole('button',{name:'打开并控制',exact:true})).toBeDisabled()
  127. await browserExpect(panel.locator('progress')).toHaveCount(0)
  128. await page.screenshot({path:join(output,'offline-recheck-375.png')})
  129. online=true
  130. const readsBeforeRecheck=browserReads
  131. await panel.getByRole('button',{name:'重新检测',exact:true}).click()
  132. await browserExpect(panel.getByRole('button',{name:'接管电脑',exact:true})).toBeEnabled()
  133. expect(browserReads).toBeGreaterThan(readsBeforeRecheck)
  134. expect(calls).toHaveLength(writesBeforeRecheck)
  135. await panel.getByRole('button',{name:'收起窗口',exact:true}).click()
  136. await browserExpect(panel).toHaveCount(0)
  137. canResume=false;historyStatus='idle'
  138. await page.reload();await browserExpect(element).toContainText('产品文档 · 浏览器接管验证')
  139. await browserExpect(element).toContainText('页面已保留')
  140. await page.screenshot({path:join(output,'idle-375.png')})
  141. await element.getByRole('button',{name:'接管浏览器',exact:true}).click()
  142. await browserExpect(panel.getByRole('tab',{name:'产品文档',exact:true})).toBeVisible()
  143. await browserExpect(panel.getByRole('button',{name:'关闭浏览器',exact:true})).toBeVisible()
  144. await page.screenshot({path:join(output,'close-browser-375.png')})
  145. await panel.getByRole('button',{name:'关闭浏览器',exact:true}).click()
  146. await browserExpect(panel).toContainText('浏览器尚未打开')
  147. expect(calls.filter(call=>call.op==='close-browser')).toEqual([{op:'close-browser',body:{controlId:'control',token:'fixture-token'}}])
  148. expect(browserOpen).toBe(false)
  149. await panel.getByRole('button',{name:'收起窗口',exact:true}).click()
  150. await browserExpect(panel).toHaveCount(0)
  151. await page.reload()
  152. await browserExpect(element).toContainText('会话已结束')
  153. await browserExpect(element).toContainText('已关闭或回收的页面不会自动恢复')
  154. await element.getByRole('button',{name:'打开此 Bot 的浏览器',exact:true}).click()
  155. await browserExpect(panel).toContainText('人工控制中')
  156. expect(calls.filter(call=>call.op==='take')).toHaveLength(3)
  157. await panel.getByRole('button',{name:'交还并收起',exact:true}).click()
  158. await browserExpect(panel).toHaveCount(0)
  159. expect(browserOpen).toBe(true)
  160. await browserExpect(warnings).toContainText('MCP 服务暂不可用,本次任务继续使用其余能力。')
  161. await page.emulateMedia({reducedMotion:'reduce',colorScheme:'dark'})
  162. await browserExpect(page.locator('html')).toHaveClass(/dark/)
  163. await page.screenshot({path:join(output,'chat-dark-375.png')})
  164. await page.setViewportSize({width:812,height:375})
  165. await element.getByRole('button',{name:'打开此 Bot 的浏览器',exact:true}).scrollIntoViewIfNeeded()
  166. expect(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth)).toBe(false)
  167. await page.screenshot({path:join(output,'chat-landscape-dark.png')})
  168. }finally{
  169. await browser?.close();server.kill('SIGTERM')
  170. await new Promise<void>(done=>{if(server.exitCode!==null)return done();const timer=setTimeout(()=>{server.kill('SIGKILL');done()},3000);server.once('exit',()=>{clearTimeout(timer);done()})})
  171. await rm(home,{recursive:true,force:true})
  172. }
  173. },60000)