localVm.live.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. // @vitest-environment node
  2. import {it,expect,vi} from 'vitest'
  3. import {mkdtemp,mkdir,writeFile,readFile,rm} from 'node:fs/promises'
  4. import {readFileSync} from 'node:fs'
  5. import {tmpdir} from 'node:os'
  6. import {join,resolve} from 'node:path'
  7. import {createServer} from 'node:http'
  8. import {execFile} from 'node:child_process'
  9. import {promisify} from 'node:util'
  10. import {chromium} from '@playwright/test'
  11. import serve from 'koa-static'
  12. import request from 'supertest'
  13. import {createApplication,createNodeServer} from '../../src/server/app'
  14. import {loadServerConfig} from '../../src/server/config'
  15. it.skipIf(!process.env.YAOYAO_HERMES_PYTHON||!process.env.YAOYAO_COMPUTER_IMAGE)('prepares the local runner automatically and operates a real desktop from the Agent chat panel',async()=>{
  16. const root=await mkdtemp(join(tmpdir(),'yaoyao-local-vm-ui-')),hermes=join(root,'hermes'),evidence=resolve(process.env.YAOYAO_VM_EVIDENCE??'test-results/local-vm-live')
  17. await mkdir(hermes)
  18. const upstream=createServer((req,res)=>{
  19. res.setHeader('Content-Type','application/json')
  20. const path=new URL(req.url!,'http://fixture').pathname
  21. res.end(JSON.stringify(path==='/api/profiles'?{profiles:[{name:'default'}]}:path==='/api/config'?{terminal:{cwd:'.'}}:{ok:true}))
  22. })
  23. await new Promise<void>(done=>upstream.listen(0,'127.0.0.1',done))
  24. const upstreamURL=`http://127.0.0.1:${(upstream.address() as any).port}`
  25. // Match fresh real installations: placeholder cwd and no usable model.
  26. // Desktop creation and human control must not resolve provider credentials.
  27. await writeFile(join(hermes,'config.yaml'),`model:\n provider: nonexistent-fixture-provider\n default: fixture-model\nterminal:\n cwd: .\n`,{mode:0o600})
  28. const app=createApplication({config:loadServerConfig({HERMES_YAOYAO_HOME:join(root,'web'),HERMES_YAOYAO_UPSTREAM:upstreamURL})})
  29. vi.spyOn(app.localVm as any,'installation').mockReturnValue({hermesHome:hermes,hermesSource:process.env.YAOYAO_HERMES_SOURCE,python:process.env.YAOYAO_HERMES_PYTHON})
  30. app.app.use(serve(resolve('dist')))
  31. app.app.use(ctx=>{if(!ctx.path.startsWith('/api/')){ctx.type='html';ctx.body=readFileSync(resolve('dist/index.html'))}})
  32. const node=createNodeServer(app)
  33. await new Promise<void>(done=>node.server.listen(0,'127.0.0.1',done))
  34. app.config.port=(node.server.address() as any).port
  35. const address=`http://127.0.0.1:${app.config.port}`
  36. let browser:Awaited<ReturnType<typeof chromium.launch>>|undefined
  37. try{
  38. const bootstrap=await request(address).get('/api/app/bootstrap').expect(200)
  39. const setup=await request(address).post('/api/app/setup').set('Origin',address).set('Cookie',bootstrap.headers['set-cookie'].map((s:string)=>s.split(';')[0]).join('; ')).set('X-CSRF-Token',bootstrap.body.csrfToken).send({username:'vm-ui-fixture',password:'vm-ui-fixture-password'}).expect(200)
  40. const owner=setup.body.user.id
  41. const agent=app.workspace.createAgent(owner,{name:'本地虚拟机验收',profile:'default'})
  42. const conversation=app.workspace.list<any>(owner,'conversation').find(c=>c.memberIds[0]===agent.id)!
  43. browser=await chromium.launch({headless:true})
  44. const context=await browser.newContext({viewport:{width:1440,height:960},locale:'zh-CN'})
  45. const page=await context.newPage()
  46. const inputs:unknown[]=[],failures:unknown[]=[]
  47. page.on('request',r=>{if(r.url().endsWith('/computer/input'))inputs.push(r.postDataJSON()?.action)})
  48. page.on('response',r=>{if((r.url().includes('/computer')||r.url().includes('/local-vm'))&&r.status()>=400)void r.json().then(value=>failures.push(value))})
  49. await page.goto(address+'/conversations')
  50. await page.getByRole('textbox',{name:'账号',exact:true}).fill('vm-ui-fixture')
  51. await page.getByRole('textbox',{name:'密码',exact:true}).fill('vm-ui-fixture-password')
  52. await page.getByRole('button',{name:'登录',exact:true}).click()
  53. await page.locator('.desktop-sidebar .sidebar-account-switcher__main').click()
  54. await page.getByRole('menuitem',{name:'进入设置',exact:true}).click()
  55. const settings=page.getByRole('dialog',{name:'设置中心'})
  56. await settings.getByRole('button',{name:'本地虚拟机',exact:true}).click()
  57. await settings.getByRole('button',{name:'准备所选镜像',exact:true}).click()
  58. await expect.poll(async()=>await settings.innerText(),{timeout:120000}).toContain('虚拟机环境已就绪')
  59. expect(app.runners.records()).toHaveLength(1)
  60. expect(app.runners.records()[0]?.name).toBe('本机虚拟机')
  61. await mkdir(evidence,{recursive:true})
  62. await page.screenshot({path:join(evidence,'settings.png')})
  63. await settings.getByRole('button',{name:'关闭设置中心'}).click()
  64. await page.goto(address+`/conversations/${conversation.id}`)
  65. await page.getByRole('button',{name:'电脑',exact:true}).click()
  66. const dock=page.getByRole('complementary',{name:'Agent 电脑面板'})
  67. await dock.getByRole('combobox',{name:'此 Agent 使用的电脑'}).selectOption('vm')
  68. await dock.getByRole('button',{name:/创建.*的虚拟机/}).click()
  69. await expect.poll(async()=>await dock.getByRole('img').count(),{timeout:60000}).toBe(1)
  70. await page.screenshot({path:join(evidence,'agent-panel.png')})
  71. await dock.getByRole('button',{name:'打开桌面',exact:true}).click()
  72. const viewer=page.getByRole('dialog',{name:'隔离电脑',exact:true}),screen=viewer.getByRole('img')
  73. await expect.poll(async()=>await viewer.innerText(),{timeout:30000}).toContain('人工控制中')
  74. await expect.poll(()=>viewer.locator('.computer-screen.controlling').count(),{timeout:15000}).toBe(1)
  75. await expect.poll(()=>screen.evaluate(e=>(e as HTMLImageElement).naturalWidth),{timeout:10000}).toBeGreaterThan(1000)
  76. const box=(await screen.boundingBox())!
  77. await screen.click({position:{x:box.width*568/1280,y:box.height*875/900}})
  78. await page.waitForTimeout(1500)
  79. await screen.focus()
  80. await page.keyboard.type('printf vm-ui-proof > /home/cua/workspace/proof.txt',{delay:25})
  81. await page.keyboard.press('Enter')
  82. const workspace=join(root,'web','runner-state','local-vm',app.runners.records()[0]!.id,'computer-workspaces',agent.id)
  83. try {await expect.poll(()=>readFile(join(workspace,'proof.txt'),'utf8').catch(()=>''),{timeout:30000}).toBe('vm-ui-proof')}
  84. catch(error){await page.screenshot({path:join(evidence,'input-failed.png')});throw new Error(`${String(error)}\n${JSON.stringify({inputs,failures,text:await viewer.innerText()})}`)}
  85. await screen.focus()
  86. await page.keyboard.type('erase-this',{delay:20});await page.keyboard.press('Control+u')
  87. await page.keyboard.type('printf hotkey-proof > /home/cua/workspace/hotkey.txt',{delay:20});await page.keyboard.press('Enter')
  88. await expect.poll(()=>readFile(join(workspace,'hotkey.txt'),'utf8').catch(()=>''),{timeout:30000}).toBe('hotkey-proof')
  89. await page.screenshot({path:join(evidence,'interactive-desktop.png')})
  90. await viewer.getByRole('button',{name:'交还并关闭',exact:true}).click()
  91. await dock.getByRole('button',{name:'停止虚拟机',exact:true}).click()
  92. await expect.poll(async()=>await dock.innerText(),{timeout:20000}).toContain('虚拟机已停止')
  93. await dock.getByRole('button',{name:'启动虚拟机',exact:true}).click()
  94. await expect.poll(async()=>await dock.getByRole('img').count(),{timeout:60000}).toBe(1)
  95. expect(await readFile(join(workspace,'proof.txt'),'utf8')).toBe('vm-ui-proof')
  96. const second=app.workspace.createAgent(owner,{name:'第二台本地虚拟机',profile:'default',execution:'computer'})
  97. const cookies=setup.headers['set-cookie'].map((s:string)=>s.split(';')[0]).join('; ')
  98. const fresh=await request(address).get('/api/app/bootstrap').set('Cookie',cookies).expect(200)
  99. await request(address).post(`/api/app/agents/${second.id}/local-vm/create`).set('Origin',address).set('Cookie',cookies).set('X-CSRF-Token',fresh.body.csrfToken).send({}).expect(200)
  100. await expect.poll(async()=>(await request(address).get(`/api/app/agents/${second.id}/local-vm`).set('Cookie',cookies)).body.ready,{timeout:30000}).toBe(true)
  101. await page.reload();await page.getByRole('button',{name:'电脑',exact:true}).click()
  102. await dock.getByRole('button',{name:'打开双桌面',exact:true}).click()
  103. const desktops=page.getByRole('main',{name:'双桌面工作区'})
  104. await desktops.getByRole('combobox',{name:'选择桌面 2'}).selectOption(second.id)
  105. await expect.poll(()=>desktops.getByRole('img').count(),{timeout:30000}).toBe(2)
  106. await desktops.locator('.pane').first().getByRole('button',{name:'接管电脑',exact:true}).click()
  107. await expect.poll(async()=>await desktops.locator('.pane').first().innerText(),{timeout:15000}).toContain('人工控制中')
  108. await desktops.locator('.pane').last().getByRole('button',{name:'接管电脑',exact:true}).click()
  109. await expect.poll(async()=>await desktops.locator('.pane').last().innerText(),{timeout:15000}).toContain('人工控制中')
  110. await expect.poll(async()=>await desktops.locator('.pane').first().innerText(),{timeout:15000}).toContain('仅查看')
  111. await expect.poll(()=>desktops.getByRole('img').count(),{timeout:15000}).toBe(2)
  112. await expect.poll(()=>desktops.locator('.pane').last().locator('.computer-screen.controlling').count(),{timeout:15000}).toBe(1)
  113. await page.screenshot({path:join(evidence,'two-desktops.png')})
  114. await desktops.getByRole('button',{name:'交还并关闭',exact:true}).click()
  115. await expect.poll(()=>desktops.count(),{timeout:15000}).toBe(0)
  116. expect(app.workspace.list<any>('_system','computer-control').some(g=>g.expiresAt>Date.now())).toBe(false)
  117. // A Profile chat must not prevent switching idle VMs between shared and
  118. // per-Agent mode. Keep this synthetic busy marker inside the isolated DB.
  119. const chatting=app.workspace.createAgent(owner,{name:'普通聊天进行中',profile:'default'})
  120. const ordinaryWorkId='sharing-policy-ordinary-chat'
  121. app.workspace.put(owner,'turn',ordinaryWorkId,{agentId:chatting.id,status:'running'})
  122. try{
  123. await page.locator('.desktop-sidebar .sidebar-account-switcher__main').click()
  124. await page.getByRole('menuitem',{name:'进入设置',exact:true}).click()
  125. await settings.getByRole('button',{name:'本地虚拟机',exact:true}).click()
  126. page.once('dialog',dialog=>{void dialog.accept()})
  127. await settings.getByRole('button',{name:'共享虚拟机',exact:true}).click()
  128. try{await expect.poll(()=>settings.getByRole('button',{name:'共享虚拟机',exact:true}).getAttribute('aria-pressed'),{timeout:15000}).toBe('true')}
  129. catch(error){await page.screenshot({path:join(evidence,'sharing-failed.png')});throw new Error(`${String(error)}\n${JSON.stringify({failures,settings:await settings.innerText(),vm:await app.localVm.status(owner)})}`)}
  130. const group=app.workspace.require<any>(owner,'agent',agent.id).computerEnvironmentId
  131. expect(group).toBeTruthy()
  132. expect(app.workspace.require<any>(owner,'agent',second.id).computerEnvironmentId).toBe(group)
  133. expect(app.workspace.require(owner,'agent',chatting.id)).toEqual(chatting)
  134. await settings.getByRole('button',{name:'每个 Agent 独立',exact:true}).click()
  135. await expect.poll(()=>settings.getByRole('button',{name:'每个 Agent 独立',exact:true}).getAttribute('aria-pressed'),{timeout:15000}).toBe('true')
  136. expect(app.workspace.require<any>(owner,'agent',agent.id).computerEnvironmentId).toBeUndefined()
  137. expect(app.workspace.require(owner,'agent',chatting.id)).toEqual(chatting)
  138. expect(await readFile(join(workspace,'proof.txt'),'utf8')).toBe('vm-ui-proof')
  139. await page.screenshot({path:join(evidence,'sharing-policy.png')})
  140. }finally{app.workspace.remove(owner,'turn',ordinaryWorkId)}
  141. }finally{
  142. const runnerId=app.runners.records()[0]?.id
  143. // Shut down while the browser is still attached. All owned desktops must
  144. // actually stop before the server reports completion.
  145. await node.close(true)
  146. if(runnerId){const result=await promisify(execFile)('docker',['ps','-q','--filter',`label=cn.samien.yaoyao.runner=${runnerId}`]);expect(result.stdout.trim()).toBe('')}
  147. await browser?.close()
  148. await new Promise<void>(done=>{upstream.close(()=>done());upstream.closeAllConnections()})
  149. await rm(root,{recursive:true,force:true})
  150. }
  151. },240000)