localVm.live.test.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  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 {chromium} from '@playwright/test'
  9. import serve from 'koa-static'
  10. import request from 'supertest'
  11. import {createApplication,createNodeServer} from '../../src/server/app'
  12. import {loadServerConfig} from '../../src/server/config'
  13. 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()=>{
  14. 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')
  15. await mkdir(hermes)
  16. const upstream=createServer((req,res)=>{
  17. res.setHeader('Content-Type','application/json')
  18. const path=new URL(req.url!,'http://fixture').pathname
  19. res.end(JSON.stringify(path==='/api/profiles'?{profiles:[{name:'default'}]}:path==='/api/config'?{terminal:{cwd:'.'}}:{ok:true}))
  20. })
  21. await new Promise<void>(done=>upstream.listen(0,'127.0.0.1',done))
  22. const upstreamURL=`http://127.0.0.1:${(upstream.address() as any).port}`
  23. // Match fresh real installations: placeholder cwd and no usable model.
  24. // Desktop creation and human control must not resolve provider credentials.
  25. await writeFile(join(hermes,'config.yaml'),`model:\n provider: nonexistent-fixture-provider\n default: fixture-model\nterminal:\n cwd: .\n`,{mode:0o600})
  26. const app=createApplication({config:loadServerConfig({HERMES_YAOYAO_HOME:join(root,'web'),HERMES_YAOYAO_UPSTREAM:upstreamURL})})
  27. vi.spyOn(app.localVm as any,'installation').mockReturnValue({hermesHome:hermes,hermesSource:process.env.YAOYAO_HERMES_SOURCE,python:process.env.YAOYAO_HERMES_PYTHON})
  28. app.app.use(serve(resolve('dist')))
  29. app.app.use(ctx=>{if(!ctx.path.startsWith('/api/')){ctx.type='html';ctx.body=readFileSync(resolve('dist/index.html'))}})
  30. const node=createNodeServer(app)
  31. await new Promise<void>(done=>node.server.listen(0,'127.0.0.1',done))
  32. app.config.port=(node.server.address() as any).port
  33. const address=`http://127.0.0.1:${app.config.port}`
  34. let browser:Awaited<ReturnType<typeof chromium.launch>>|undefined
  35. try{
  36. const bootstrap=await request(address).get('/api/app/bootstrap').expect(200)
  37. 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)
  38. const owner=setup.body.user.id
  39. const agent=app.workspace.createAgent(owner,{name:'本地虚拟机验收',profile:'default'})
  40. const conversation=app.workspace.list<any>(owner,'conversation').find(c=>c.memberIds[0]===agent.id)!
  41. browser=await chromium.launch({headless:true})
  42. const context=await browser.newContext({viewport:{width:1440,height:960},locale:'zh-CN'})
  43. const page=await context.newPage()
  44. const inputs:unknown[]=[],failures:unknown[]=[]
  45. page.on('request',r=>{if(r.url().endsWith('/computer/input'))inputs.push(r.postDataJSON()?.action)})
  46. page.on('response',r=>{if(r.url().includes('/computer')&&r.status()>=400)void r.json().then(value=>failures.push(value))})
  47. await page.goto(address+'/conversations')
  48. await page.getByRole('textbox',{name:'账号',exact:true}).fill('vm-ui-fixture')
  49. await page.getByRole('textbox',{name:'密码',exact:true}).fill('vm-ui-fixture-password')
  50. await page.getByRole('button',{name:'登录',exact:true}).click()
  51. await page.getByRole('button',{name:'设置与模式',exact:true}).first().click()
  52. await page.getByRole('menuitem',{name:'进入设置',exact:true}).click()
  53. const settings=page.getByRole('dialog',{name:'设置中心'})
  54. await settings.getByRole('button',{name:'本地虚拟机',exact:true}).click()
  55. await settings.getByRole('button',{name:'准备本地虚拟机',exact:true}).click()
  56. await expect.poll(async()=>await settings.innerText(),{timeout:120000}).toContain('虚拟机环境已就绪')
  57. expect(app.runners.records()).toHaveLength(1)
  58. expect(app.runners.records()[0]?.name).toBe('本机虚拟机')
  59. await mkdir(evidence,{recursive:true})
  60. await page.screenshot({path:join(evidence,'settings.png')})
  61. await settings.getByRole('button',{name:'关闭设置中心'}).click()
  62. await page.goto(address+`/conversations/${conversation.id}`)
  63. await page.getByRole('button',{name:'电脑',exact:true}).click()
  64. const dock=page.getByRole('complementary',{name:'Agent 电脑面板'})
  65. await dock.getByRole('combobox',{name:'此 Agent 使用的电脑'}).selectOption('vm')
  66. await dock.getByRole('button',{name:/创建.*的虚拟机/}).click()
  67. await expect.poll(async()=>await dock.getByRole('img').count(),{timeout:60000}).toBe(1)
  68. await page.screenshot({path:join(evidence,'agent-panel.png')})
  69. await dock.getByRole('button',{name:'打开桌面',exact:true}).click()
  70. const viewer=page.getByRole('dialog',{name:'隔离电脑',exact:true}),screen=viewer.getByRole('img')
  71. await expect.poll(async()=>await viewer.innerText(),{timeout:30000}).toContain('人工控制中')
  72. await expect.poll(()=>viewer.locator('.computer-screen.controlling').count(),{timeout:15000}).toBe(1)
  73. await expect.poll(()=>screen.evaluate(e=>(e as HTMLImageElement).naturalWidth),{timeout:10000}).toBeGreaterThan(1000)
  74. const box=(await screen.boundingBox())!
  75. await screen.click({position:{x:box.width*568/1280,y:box.height*875/900}})
  76. await page.waitForTimeout(1500)
  77. await screen.focus()
  78. await page.keyboard.type('printf vm-ui-proof > /home/cua/workspace/proof.txt',{delay:25})
  79. await page.keyboard.press('Enter')
  80. const workspace=join(root,'web','runner-state','local-vm',app.runners.records()[0]!.id,'computer-workspaces',agent.id)
  81. try {await expect.poll(()=>readFile(join(workspace,'proof.txt'),'utf8').catch(()=>''),{timeout:30000}).toBe('vm-ui-proof')}
  82. 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()})}`)}
  83. await screen.focus()
  84. await page.keyboard.type('erase-this',{delay:20});await page.keyboard.press('Control+u')
  85. await page.keyboard.type('printf hotkey-proof > /home/cua/workspace/hotkey.txt',{delay:20});await page.keyboard.press('Enter')
  86. await expect.poll(()=>readFile(join(workspace,'hotkey.txt'),'utf8').catch(()=>''),{timeout:30000}).toBe('hotkey-proof')
  87. await page.screenshot({path:join(evidence,'interactive-desktop.png')})
  88. await viewer.getByRole('button',{name:'交还并关闭',exact:true}).click()
  89. await dock.getByRole('button',{name:'停止虚拟机',exact:true}).click()
  90. await expect.poll(async()=>await dock.innerText(),{timeout:20000}).toContain('虚拟机已停止')
  91. await dock.getByRole('button',{name:'启动虚拟机',exact:true}).click()
  92. await expect.poll(async()=>await dock.getByRole('img').count(),{timeout:60000}).toBe(1)
  93. expect(await readFile(join(workspace,'proof.txt'),'utf8')).toBe('vm-ui-proof')
  94. const second=app.workspace.createAgent(owner,{name:'第二台本地虚拟机',profile:'default',execution:'computer'})
  95. const cookies=setup.headers['set-cookie'].map((s:string)=>s.split(';')[0]).join('; ')
  96. const fresh=await request(address).get('/api/app/bootstrap').set('Cookie',cookies).expect(200)
  97. 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)
  98. await expect.poll(async()=>(await request(address).get(`/api/app/agents/${second.id}/local-vm`).set('Cookie',cookies)).body.ready,{timeout:30000}).toBe(true)
  99. await page.reload();await page.getByRole('button',{name:'电脑',exact:true}).click()
  100. await dock.getByRole('button',{name:'打开双桌面',exact:true}).click()
  101. const desktops=page.getByRole('main',{name:'双桌面工作区'})
  102. await desktops.getByRole('combobox',{name:'选择桌面 2'}).selectOption(second.id)
  103. await expect.poll(()=>desktops.getByRole('img').count(),{timeout:30000}).toBe(2)
  104. await desktops.locator('.pane').first().getByRole('button',{name:'接管电脑',exact:true}).click()
  105. await expect.poll(async()=>await desktops.locator('.pane').first().innerText(),{timeout:15000}).toContain('人工控制中')
  106. await desktops.locator('.pane').last().getByRole('button',{name:'接管电脑',exact:true}).click()
  107. await expect.poll(async()=>await desktops.locator('.pane').last().innerText(),{timeout:15000}).toContain('人工控制中')
  108. await expect.poll(async()=>await desktops.locator('.pane').first().innerText(),{timeout:15000}).toContain('仅查看')
  109. await expect.poll(()=>desktops.getByRole('img').count(),{timeout:15000}).toBe(2)
  110. await expect.poll(()=>desktops.locator('.pane').last().locator('.computer-screen.controlling').count(),{timeout:15000}).toBe(1)
  111. await page.screenshot({path:join(evidence,'two-desktops.png')})
  112. await desktops.getByRole('button',{name:'交还并关闭',exact:true}).click()
  113. }finally{
  114. await browser?.close();await node.close(true)
  115. await new Promise<void>(done=>{upstream.close(()=>done());upstream.closeAllConnections()})
  116. await rm(root,{recursive:true,force:true})
  117. }
  118. },240000)