desktopImages.live.test.ts 7.8 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
  1. // @vitest-environment node
  2. import {it,expect,vi} from 'vitest'
  3. import {DatabaseSync} from 'node:sqlite'
  4. import {mkdtemp,mkdir,readFile,writeFile,rm} from 'node:fs/promises'
  5. import {tmpdir} from 'node:os'
  6. import {join,resolve} from 'node:path'
  7. import {randomUUID} from 'node:crypto'
  8. import {execFile} from 'node:child_process'
  9. import {promisify} from 'node:util'
  10. import {ComputerRuntime} from '../../src/runner/worker/gateway'
  11. import {LocalVmImages} from '../../src/runner/computers/localVm'
  12. import {CUA_DRIVER,CUA_SOCKET} from '../../src/runner/computers/container'
  13. // @ts-expect-error Standalone deployment script.
  14. import {generateDesktopCompose} from '../../scripts/generate-desktop-compose.mjs'
  15. const exec=promisify(execFile)
  16. const enabled=!!process.env.YAOYAO_TEST_STANDARD_IMAGE&&!!process.env.YAOYAO_TEST_CURSOR_IMAGE
  17. const docker=(args:string[])=>exec('docker',args,{timeout:90000,maxBuffer:8*1024*1024})
  18. const evidence=resolve('test-results/desktop-image-options')
  19. it.skipIf(!enabled)('runs both images independently, preserves a selection and workspace across recreation, and shares one choice',async()=>{
  20. const root=await mkdtemp(join(tmpdir(),'yaoyao-images-live-')),db=new DatabaseSync(':memory:')
  21. const standard=(await docker(['image','inspect',process.env.YAOYAO_TEST_STANDARD_IMAGE!,'--format','{{.Id}}'])).stdout.trim()
  22. const cursor=(await docker(['image','inspect',process.env.YAOYAO_TEST_CURSOR_IMAGE!,'--format','{{.Id}}'])).stdout.trim()
  23. const runtime=new ComputerRuntime(db,{protocol:1,runnerId:randomUUID(),serverURL:'http://127.0.0.1',hermesURL:'http://127.0.0.1',token:'fixture',allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',imageId:standard,python:'/unused',hermesSource:'/unused',hermesHome:'/unused',network:'public-proxy'}},root)
  24. const manager=new LocalVmImages(runtime,db)
  25. const ids=[randomUUID(),randomUUID()],targets=ids.map(id=>({environmentId:id,agentId:id,ownerKey:'fixture'}))
  26. const ready=async(meta:typeof targets[number])=>{const spec=runtime.pool.definition('fixture',meta.environmentId)!;await vi.waitFor(async()=>{await runtime.provider.health(spec,()=>{});await runtime.provider.capture(spec,()=>{})},{timeout:65000,interval:1000});return spec}
  27. try{
  28. await manager.ready
  29. // Bind the exact two local images under test; normal preparation verification still runs.
  30. const command=manager.images.run.bind(manager.images)
  31. vi.spyOn(manager.images,'run').mockImplementation((engine,args)=>args[0]==='image'&&args[1]==='ls'?Promise.resolve(cursor):command(engine,args))
  32. await manager.prepare(randomUUID(),async()=>{},'cursor')
  33. await vi.waitFor(async()=>expect((await manager.status()).job?.state).toBe('complete'),{timeout:80000,interval:1000})
  34. vi.spyOn(runtime,'resolveWorkspace').mockResolvedValue({type:'resolved',cwd:'/home/cua/workspace',configuredCwd:'.'})
  35. for(const [index,key] of (['standard','cursor'] as const).entries()){
  36. await manager.select(targets[index]!,key,()=>{})
  37. await runtime.desktop(targets[index]!,'default','create',()=>{})
  38. }
  39. await mkdir(evidence,{recursive:true})
  40. for(const [index,meta] of targets.entries()){
  41. const spec=await ready(meta)
  42. if(index===1){
  43. await runtime.provider.execute(spec,['/usr/local/bin/yaoyao-browser','--new-window','data:text/html,<title>Yaoyao Cursor verification</title><h1>Cursor Universal</h1><p>Desktop, browser and private workspace are ready.</p>'],{authorize:()=>{}})
  44. await vi.waitFor(()=>runtime.provider.execute(spec,['xdotool','search','--name','Yaoyao Cursor verification'],{authorize:()=>{}}),{timeout:30000,interval:1000})
  45. expect((await runtime.provider.execute(spec,['ps','-eo','args='],{authorize:()=>{}})).stdout).toContain('--proxy-server=http://127.0.0.1:3128')
  46. }
  47. const result=await runtime.provider.execute(spec,['sh','-c',`printf image-${index} > proof-${index}.txt; mkdir -p .browser-profiles; printf profile-${index} > .browser-profiles/proof-${index}.txt; test ! -e proof-${1-index}.txt`],{authorize:()=>{}})
  48. expect(result.stderr).toBe('')
  49. const frame=await runtime.provider.capture(spec,()=>{})
  50. await writeFile(join(evidence,index?'cursor.png':'standard.png'),Buffer.from(frame.data,'base64'))
  51. }
  52. expect(runtime.pool.status('fixture').filter(s=>s.status==='idle')).toHaveLength(2)
  53. expect(runtime.imageFor({...targets[1]!,agentId:randomUUID()})).toBe(cursor)
  54. await expect(manager.select(targets[1]!,'standard',()=>{})).rejects.toMatchObject({code:'computer_image_in_use'})
  55. await runtime.desktop(targets[1]!,'default','remove',()=>{})
  56. await runtime.desktop(targets[1]!,'default','create',()=>{})
  57. const restored=await ready(targets[1]!)
  58. expect(restored.imageId).toBe(cursor)
  59. expect((await runtime.provider.execute(restored,['cat','proof-1.txt','.browser-profiles/proof-1.txt'],{authorize:()=>{}})).stdout).toBe('image-1profile-1')
  60. await runtime.provider.execute(restored,[CUA_DRIVER,'call','press_key',JSON.stringify({key:'escape'}),'--socket',CUA_SOCKET],{authorize:()=>{}})
  61. expect(await readFile(join(root,'computer-workspaces',ids[0]!,'proof-0.txt'),'utf8')).toBe('image-0')
  62. }finally{
  63. await manager.close()
  64. for(const meta of targets)await runtime.desktop(meta,'default','remove',()=>{})
  65. await runtime.controls.close();await runtime.pool.close();db.close();await rm(root,{recursive:true,force:true})
  66. }
  67. },240000)
  68. it.skipIf(!enabled)('runs mixed Compose desktops with isolated volumes and the same private bridge protocol',async()=>{
  69. const root=await mkdtemp(join(tmpdir(),'yaoyao-compose-images-')),project='yaoyao-image-test-'+randomUUID().slice(0,8)
  70. const entries=[{id:randomUUID(),service:'desktop-1',name:'Standard'},{id:randomUUID(),service:'desktop-2',name:'Cursor',imageKey:'cursor'}]
  71. let yaml=generateDesktopCompose(await readFile('compose.yaml','utf8'),entries)
  72. yaml=yaml.replace('${YAOYAO_DESKTOP_IMAGE:-hermes-yaoyao-desktop:0.4.0}',process.env.YAOYAO_TEST_STANDARD_IMAGE!).replace('${YAOYAO_CURSOR_DESKTOP_IMAGE:-yaoyao-desktop:cursor}',process.env.YAOYAO_TEST_CURSOR_IMAGE!)
  73. const config=join(root,'compose.yaml');await writeFile(config,yaml)
  74. const compose=['compose','--project-name',project,'--project-directory',process.cwd(),'-f',config]
  75. try{
  76. await docker([...compose,'up','-d','--no-build','desktop-1','desktop-2'])
  77. for(const [index,entry] of entries.entries()){
  78. const id=(await docker([...compose,'ps','-q',entry.service])).stdout.trim()
  79. const call=async(op:string,body={})=>{
  80. const script="import http.client,socket,sys; c=http.client.HTTPConnection('localhost'); c.sock=socket.socket(socket.AF_UNIX); c.sock.connect('/run/desktop/desktop.sock'); c.request('POST',sys.argv[1],sys.argv[2]); r=c.getresponse(); print(r.read().decode())"
  81. // Match Web's uid and read-only IPC mount, outside the desktop's private parent.
  82. return JSON.parse((await docker(['run','--rm','--network','none','--cap-drop','ALL','--user','1000:1000','--mount',`type=volume,src=${project}_${entry.service}-ipc,dst=/run/desktop,readonly`,'--entrypoint','python3',process.env.YAOYAO_TEST_CURSOR_IMAGE!,'-c',script,'/'+op,JSON.stringify({...body,desktopId:entry.id})])).stdout)
  83. }
  84. await vi.waitFor(async()=>expect((await call('health')).ready).toBe(true),{timeout:65000,interval:1000})
  85. const lease=await call('acquire',{requestId:randomUUID(),owner:'fixture'})
  86. const state=JSON.parse((await docker(['inspect',id])).stdout)[0]
  87. expect(state.HostConfig.NetworkMode).toBe('none')
  88. expect(Object.keys(state.HostConfig.PortBindings??{})).toHaveLength(0)
  89. const output=await docker(['exec','--user','1000:1000',id,'sh','-c',`printf compose-${index} > /home/cua/workspace/proof-${index}; test ! -e /home/cua/workspace/proof-${1-index}`])
  90. expect(output.stderr).toBe('')
  91. expect((await call('frame',lease)).data).toBeTruthy()
  92. await call('release',{...lease,cancel:false})
  93. }
  94. }finally{await docker([...compose,'down','--volumes']);await rm(root,{recursive:true,force:true})}
  95. },180000)