computer-targets.test.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. import {test} from 'node:test'
  2. import assert from 'node:assert/strict'
  3. import {mkdtemp,writeFile,mkdir,rm} from 'node:fs/promises'
  4. import {tmpdir} from 'node:os'
  5. import {join,resolve} from 'node:path'
  6. import {pathToFileURL} from 'node:url'
  7. import {spawn} from 'node:child_process'
  8. import {_electron as electron,expect} from '@playwright/test'
  9. const root=resolve(import.meta.dirname,'..')
  10. test('selected previews reach the matching native desktop and switch only after returning the old lease',{timeout:120000},async()=>{
  11. const home=await mkdtemp(join(tmpdir(),'yaoyao-computer-targets-')),origin='http://127.0.0.1:18865'
  12. const server=spawn(process.execPath,['--import','tsx','tests/fixtures/workspace-server.ts'],{cwd:root,
  13. env:{...process.env,WORKSPACE_FIXTURE_HOME:home,WORKSPACE_FIXTURE_TEAM_TOOLS:'1',WORKSPACE_FIXTURE_PORT:'18865',WORKSPACE_FIXTURE_UPSTREAM_PORT:'19165'},stdio:'ignore'})
  14. let app
  15. try{
  16. await expect.poll(async()=>{try{return(await fetch(origin+'/healthz')).status}catch{return 0}},{timeout:30000}).toBe(200)
  17. const harness=join(home,'main.mjs')
  18. await writeFile(harness,`import {app,BrowserWindow,ipcMain} from 'electron';
  19. import {installComputerViewer} from '${pathToFileURL(join(root,'desktop/computer-viewer.mjs')).href}';
  20. ipcMain.handle('desktop:device-host',()=>({deviceHost:'client-mac'}));
  21. globalThis.grantedDeviceScopes=[];ipcMain.handle('desktop:device-authorize',(_event,scope)=>{globalThis.grantedDeviceScopes.push(scope);return {ok:true}});
  22. app.setPath('userData',${JSON.stringify(join(home,'chromium'))});
  23. app.whenReady().then(async()=>{let quitting=false;app.on('before-quit',()=>quitting=true);
  24. const owner=new BrowserWindow({width:1440,height:900,webPreferences:{preload:${JSON.stringify(join(root,'desktop/preload.cjs'))},sandbox:true,contextIsolation:true}});
  25. installComputerViewer({owner:()=>owner,origin:()=>${JSON.stringify(origin)},preload:${JSON.stringify(join(root,'desktop/preload.cjs'))},quitting:()=>quitting});
  26. await owner.loadURL(${JSON.stringify(origin+'/conversations')});});`)
  27. app=await electron.launch({args:[harness],cwd:root})
  28. const page=await app.firstWindow()
  29. await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture')
  30. await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass')
  31. await page.getByRole('button',{name:'登录',exact:true}).click()
  32. await expect(page.locator('.desktop-sidebar .sidebar-create-trigger')).toBeVisible()
  33. const seed=await(await page.request.post(origin+'/__test/computer',{data:{}})).json()
  34. const targets=[
  35. {key:'cloud',backend:'cloud',name:'Grok Bot 云端',color:'#6a405e'},
  36. {key:'managed-browser',backend:'managed-browser',name:'托管浏览器',color:'#264e60'},
  37. {key:'vm',backend:'vm',name:'独立虚拟机',color:'#65502c'},
  38. ]
  39. const images=await page.evaluate(targets=>Object.fromEntries(targets.map(target=>{
  40. const canvas=document.createElement('canvas');canvas.width=1280;canvas.height=800
  41. const ctx=canvas.getContext('2d');ctx.fillStyle=target.color;ctx.fillRect(0,0,1280,800)
  42. ctx.fillStyle='#ffffff';ctx.font='52px sans-serif';ctx.fillText(target.name,70,180)
  43. ctx.font='28px sans-serif';ctx.fillText('验证桌面 · '+target.key,70,245)
  44. return [target.key,canvas.toDataURL('image/png').split(',')[1]]
  45. })),targets)
  46. const permission={supported:true,authorized:true,screen:true,accessibility:true,ready:true,fullAuthorized:true}
  47. const hosts=[{id:'local',name:'服务器'},{id:'client-mac',name:'Mac Studio 客户端'},{id:'other-mac',name:'另一台 Mac'}].map(h=>({...h,platform:'darwin',online:true,local:{...permission,authorized:false,ready:false,fullAuthorized:false},browser:{available:false}}))
  48. const calls=[],leases=new Set()
  49. let refuseGiveback=false
  50. await app.context().route('**/api/app/desktop-environment',route=>route.fulfill({json:{hosts}}))
  51. await app.context().route('**/api/app/settings/host-tools',route=>route.fulfill({json:{scriptMachine:true,serverComputer:true,vm:true,cloud:true,managedBrowser:true}}))
  52. await app.context().route('**/api/app/agents/*/**',async route=>{
  53. const url=new URL(route.request().url()),path=url.pathname
  54. if(!path.startsWith(`/api/app/agents/${seed.agentId}/`))return route.continue()
  55. if(path.endsWith('/desktop-environment'))return route.fulfill({json:{hosts,online:true,local:permission}})
  56. if(path.endsWith('/cloud-computer')||path.endsWith('/cloud-computer/open'))return route.fulfill({json:{configured:true,running:true,connected:true}})
  57. if(path.endsWith('/local-vm'))return route.fulfill({json:{enabled:true,image:true,container:'running',ready:true,mode:'per-bot',fixedCapacity:true}})
  58. if(path.endsWith('/managed-browser'))return route.fulfill({json:{enabled:true,available:true,open:true,generation:1,profile:'temporary',tabs:[{id:'tab',title:'托管网页',url:'https://example.com/',active:true}],downloads:[]}})
  59. if(path.endsWith('/managed-browser/action')){calls.push({op:'browser-action',key:'managed-browser',body:route.request().postDataJSON()});return route.fulfill({json:{ok:true}})}
  60. if(!path.includes('/computer'))return route.continue()
  61. const backend=url.searchParams.get('backend'),host=url.searchParams.get('host')
  62. const key=backend==='desktop'?'desktop:'+host:backend
  63. const target=targets.find(t=>t.key===key)
  64. if(!target)return route.fulfill({status:409,json:{error:'Fixture requires an explicit computer target'}})
  65. if(path.endsWith('/frame'))return route.fulfill({json:{id:key,data:images[key],width:1280,height:800,generation:1}})
  66. if(route.request().method()==='POST'){
  67. const op=path.split('/').at(-1);calls.push({op,key})
  68. if(op==='giveback'&&refuseGiveback)return route.fulfill({status:409,json:{error:'Fixture giveback temporarily unavailable'}})
  69. if(op==='take')leases.add(key)
  70. if(op==='giveback')leases.delete(key)
  71. }
  72. return route.fulfill({json:{mode:leases.has(key)?'human':'idle',backend:backend==='desktop'?'local':backend==='cloud'?'grok':backend==='managed-browser'?'managed-browser':'docker',
  73. hostName:target.name,generation:1,controlId:leases.has(key)?key:undefined,token:'fixture-token'}})
  74. })
  75. await page.goto(origin+'/conversations/'+seed.conversationId)
  76. await page.getByRole('button',{name:'电脑与定时任务',exact:true}).click()
  77. const dock=page.getByRole('complementary',{name:'机器人电脑面板'})
  78. await expect(dock.getByRole('combobox',{name:'显示的桌面'})).toBeVisible()
  79. let viewer,previousKey
  80. for(const target of targets){
  81. await dock.getByRole('combobox',{name:'显示的桌面'}).selectOption(target.key)
  82. await expect(dock.locator('.preview img')).toHaveAttribute('src','data:image/png;base64,'+images[target.key])
  83. const opened=app.waitForEvent('window')
  84. await dock.locator('.preview').click()
  85. const oldViewer=viewer;viewer=await opened
  86. await expect(viewer.locator('.computer-panel.standalone')).toContainText('人工控制中')
  87. await expect(viewer.locator('.computer-screen img')).toHaveAttribute('src','data:image/png;base64,'+images[target.key])
  88. const url=new URL(viewer.url())
  89. assert.equal(url.searchParams.get('backend'),target.backend)
  90. assert.equal(url.searchParams.get('host'),target.host??null)
  91. if(oldViewer){
  92. assert.equal(oldViewer.isClosed(),true)
  93. assert.ok(calls.findIndex(c=>c.op==='giveback'&&c.key===previousKey)<calls.findIndex(c=>c.op==='take'&&c.key===target.key))
  94. }
  95. assert.deepEqual([...leases],[target.key])
  96. previousKey=target.key
  97. if(target.backend==='managed-browser'){
  98. await expect(viewer.getByRole('textbox',{name:'浏览器地址'})).toHaveValue('https://example.com/')
  99. await viewer.getByRole('textbox',{name:'浏览器地址'}).fill('https://example.org/')
  100. await viewer.getByRole('button',{name:'前往',exact:true}).click()
  101. await expect.poll(()=>calls.filter(c=>c.op==='browser-action').length).toBe(1)
  102. const navigation=calls.find(c=>c.op==='browser-action').body
  103. assert.equal(navigation.controlId,'managed-browser');assert.equal(navigation.generation,1)
  104. assert.deepEqual(navigation.action,{kind:'navigate',url:'https://example.org/'})
  105. const output=join(root,'test-results/managed-browser');await mkdir(output,{recursive:true})
  106. await viewer.screenshot({path:join(output,'desktop.png')})
  107. await page.setViewportSize({width:375,height:812});await page.screenshot({path:join(output,'mobile-preview.png')})
  108. assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>window.innerWidth),false)
  109. await page.setViewportSize({width:1440,height:900})
  110. }
  111. }
  112. // Reopening an unchanged target focuses its existing window.
  113. await page.evaluate(({id,target})=>window.yaoyaoDesktop.openComputer(id,target),{id:seed.agentId,target:{backend:'vm'}})
  114. assert.equal(viewer.isClosed(),false);assert.equal(app.windows().length,2)
  115. const rejected=await page.evaluate(async id=>{try{await window.yaoyaoDesktop.openComputer(id,{backend:'vm',host:'client-mac'});return false}catch{return true}},seed.agentId)
  116. assert.equal(rejected,true);assert.equal(viewer.isClosed(),false)
  117. const physicalRejected=await page.evaluate(async id=>{try{await window.yaoyaoDesktop.openComputer(id,{backend:'desktop',host:'local'});return false}catch{return true}},seed.agentId)
  118. assert.equal(physicalRejected,true)
  119. assert.equal(await dock.locator('option[value^="desktop:"]').count(),0)
  120. assert.equal(await dock.locator('.host-permission').count(),0)
  121. refuseGiveback=true
  122. const beforeFailedSwitch=calls.length
  123. const failedSwitch=await page.evaluate(async id=>{
  124. try{await window.yaoyaoDesktop.openComputer(id,{backend:'cloud'});return ''}
  125. catch(error){return error.message}
  126. },seed.agentId)
  127. assert.match(failedSwitch,/尚未交还/)
  128. assert.equal(viewer.isClosed(),false);assert.deepEqual([...leases],['vm'])
  129. assert.equal(calls.slice(beforeFailedSwitch).some(c=>c.op==='take'),false)
  130. refuseGiveback=false
  131. // A manual change inside the viewer must update the identity used for reuse.
  132. await viewer.getByRole('button',{name:'键盘与操作',exact:true}).click()
  133. await viewer.getByRole('button',{name:'结束控制',exact:true}).click()
  134. await viewer.getByRole('combobox',{name:'切换电脑环境'}).selectOption('cloud')
  135. await expect(viewer.locator('.computer-screen img')).toHaveAttribute('src','data:image/png;base64,'+images.cloud)
  136. const reopened=app.waitForEvent('window'),oldViewer=viewer
  137. await page.evaluate(id=>window.yaoyaoDesktop.openComputer(id,{backend:'vm'}),seed.agentId)
  138. viewer=await reopened
  139. await expect(viewer.locator('.computer-panel')).toContainText('人工控制中')
  140. assert.equal(oldViewer.isClosed(),true)
  141. await expect(viewer.locator('.computer-screen img')).toHaveAttribute('src','data:image/png;base64,'+images.vm)
  142. await viewer.getByRole('button',{name:'交还并关闭',exact:true}).click()
  143. await expect.poll(()=>viewer.isClosed()).toBe(true)
  144. assert.equal(leases.size,0)
  145. await page.locator('.desktop-sidebar').getByRole('button',{name:'工具',exact:true}).click()
  146. await page.getByRole('menuitem',{name:'Bot 设置',exact:true}).click()
  147. const settings=page.getByRole('dialog',{name:'Bot 设置',exact:true})
  148. await settings.getByRole('button',{name:'环境与授权',exact:true}).click()
  149. const devices=settings.locator('.device-authorization')
  150. await expect(devices.locator('[data-host-id="client-mac"]')).toContainText('当前设备')
  151. await expect(devices.locator('[data-host-id="local"] button')).toHaveCount(0)
  152. await expect(devices.locator('[data-host-id="other-mac"] button')).toHaveCount(0)
  153. await devices.getByRole('button',{name:'授权本机文件与命令',exact:true}).click()
  154. await expect(devices.getByRole('status')).toContainText('本机授权已保存')
  155. assert.deepEqual(await app.evaluate(()=>globalThis.grantedDeviceScopes),['full'])
  156. await expect(devices.getByRole('button',{name:'授权本机文件与命令',exact:true})).toBeEnabled()
  157. const output=join(root,'test-results/device-authorization');await mkdir(output,{recursive:true})
  158. await page.screenshot({path:join(output,'desktop-settings.png')})
  159. await page.setViewportSize({width:375,height:812})
  160. await devices.scrollIntoViewIfNeeded()
  161. assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>window.innerWidth),false)
  162. await page.screenshot({path:join(output,'mobile-settings.png')})
  163. }finally{
  164. await app?.close().catch(()=>{})
  165. server.kill('SIGTERM');if(server.exitCode===null)await new Promise(done=>server.once('exit',done))
  166. await rm(home,{recursive:true,force:true})
  167. }
  168. })