localVm.test.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
  1. // @vitest-environment node
  2. import {it,expect,vi} from 'vitest'
  3. import {DatabaseSync} from 'node:sqlite'
  4. import {mkdtemp,rm} from 'node:fs/promises'
  5. import {tmpdir} from 'node:os'
  6. import {join} from 'node:path'
  7. import {randomUUID} from 'node:crypto'
  8. import {ComputerRuntime} from '../../src/runner/worker/gateway'
  9. import {LocalVmImages} from '../../src/runner/computers/localVm'
  10. import {UNCONFIGURED_COMPUTER_IMAGE} from '../../src/shared/runner'
  11. async function fixture(){
  12. const home=await mkdtemp(join(tmpdir(),'yaoyao-local-vm-')),db=new DatabaseSync(':memory:')
  13. 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:UNCONFIGURED_COMPUTER_IMAGE,python:'/fixture/python',hermesSource:'/fixture/source',hermesHome:'/fixture/home'}},home)
  14. await runtime.ready
  15. const running=new Set<string>()
  16. vi.spyOn(runtime.provider,'verifyRuntime').mockResolvedValue()
  17. vi.spyOn(runtime.provider,'ensure').mockImplementation(async s=>{running.add(s.id);return {id:s.id,containerId:s.id,workspace:'/fixture',running:true,isolation:'container'}})
  18. vi.spyOn(runtime.provider,'stop').mockImplementation(async s=>{running.delete(s.id)})
  19. vi.spyOn(runtime.provider,'remove').mockImplementation(async s=>{running.delete(s.id)})
  20. vi.spyOn(runtime.provider,'health').mockResolvedValue({})
  21. vi.spyOn(runtime.provider,'capture').mockResolvedValue({data:'fixture',width:1280,height:900,capturedAt:Date.now()})
  22. vi.spyOn(runtime.provider,'deleteWorkspace').mockResolvedValue()
  23. return {db,runtime,running,async close(){await runtime.controls.close();await runtime.pool.close();db.close();await rm(home,{recursive:true,force:true})}}
  24. }
  25. it('prepares one managed image, verifies the desktop, applies it and persists it without a catalog step',async()=>{
  26. const f=await fixture(),manager=new LocalVmImages(f.runtime,f.db),imageId='sha256:'+'a'.repeat(64)
  27. try{
  28. vi.spyOn(manager.images,'run').mockResolvedValue(imageId)
  29. vi.spyOn(manager.images,'inspect').mockImplementation(async id=>{if(id!==imageId)throw new Error('not ready');return {protocol:1,imageId,architecture:'arm64',driver:'0.20.0',layer:'5',createdAt:1}})
  30. await manager.ready
  31. expect((await manager.status()).image).toBe(false)
  32. const id=randomUUID(),check=vi.fn(async()=>{})
  33. await manager.prepare(id,check)
  34. await vi.waitFor(async()=>expect((await manager.status()).job?.state).toBe('complete'))
  35. expect(f.runtime.config.imageId).toBe(imageId)
  36. expect(f.runtime.provider.capture).toHaveBeenCalledOnce()
  37. expect(f.running.size).toBe(0)
  38. await manager.prepare(id,check)
  39. expect(f.runtime.provider.capture).toHaveBeenCalledOnce()
  40. await manager.close()
  41. const resumed=new LocalVmImages(f.runtime,f.db);await resumed.ready
  42. expect(f.runtime.config.imageId).toBe(imageId);await resumed.close()
  43. }finally{await manager.close();await f.close()}
  44. })
  45. it('retains completed desktop state but fences the old lease and counts idle desktops against capacity',async()=>{
  46. const f=await fixture(),spec={id:randomUUID(),ownerKey:'owner',imageId:'sha256:'+'a'.repeat(64)}
  47. try{
  48. await f.runtime.pool.desktop(spec,'create',()=>{})
  49. expect(f.running.has(spec.id)).toBe(true)
  50. expect((await f.runtime.controls.status({agentId:spec.id,environmentId:spec.id,ownerKey:'owner'})).mode).toBe('idle')
  51. const lease=await f.runtime.pool.acquire(spec,'turn-1',()=>{})
  52. await f.runtime.pool.release(lease,true)
  53. expect(()=>f.runtime.pool.use(lease,async()=>{})).toThrow()
  54. const second={...spec,id:randomUUID()};await f.runtime.pool.desktop(second,'create',()=>{})
  55. await expect(f.runtime.pool.desktop({...spec,id:randomUUID()},'create',()=>{})).rejects.toMatchObject({code:'computer_quota'})
  56. await expect(f.runtime.pool.desktop({...spec,ownerKey:'other'},'remove',()=>{})).rejects.toMatchObject({code:'computer_owner_mismatch'})
  57. await f.runtime.pool.desktop(spec,'stop',()=>{});expect(f.running.has(spec.id)).toBe(false)
  58. await f.runtime.pool.desktop(spec,'start',()=>{});expect(f.running.has(spec.id)).toBe(true)
  59. await f.runtime.pool.desktop(spec,'recreate',()=>{});expect(f.running.has(spec.id)).toBe(true)
  60. await f.runtime.pool.desktop(spec,'remove',()=>{});expect(f.runtime.pool.definition('owner',spec.id)).toBeUndefined()
  61. expect(f.runtime.provider.deleteWorkspace).not.toHaveBeenCalled()
  62. }finally{await f.close()}
  63. })
  64. it('refuses lifecycle changes while a human or Agent holds the VM',async()=>{
  65. const f=await fixture(),spec={id:randomUUID(),ownerKey:'owner',imageId:'sha256:'+'a'.repeat(64)}
  66. try{
  67. const lease=await f.runtime.pool.acquire(spec,'human:test',()=>{})
  68. for(const action of ['stop','remove','recreate'] as const)await expect(f.runtime.pool.desktop(spec,action,()=>{})).rejects.toMatchObject({code:'computer_busy'})
  69. await f.runtime.pool.release(lease)
  70. }finally{await f.close()}
  71. })
  72. it('creates without a model and can stop or remove an existing desktop after Profile resolution fails',async()=>{
  73. const f=await fixture(),meta={environmentId:randomUUID(),agentId:randomUUID(),ownerKey:'owner'}
  74. try{
  75. f.runtime.config.imageId='sha256:'+'a'.repeat(64)
  76. const model=vi.spyOn(f.runtime,'resolve').mockRejectedValue(new Error('model unavailable'))
  77. const workspace=vi.spyOn(f.runtime,'resolveWorkspace').mockResolvedValue({type:'resolved',cwd:'/home/cua/workspace',configuredCwd:'.'})
  78. await f.runtime.desktop(meta,'default','create',()=>{})
  79. expect(f.running.has(meta.environmentId)).toBe(true)
  80. expect(f.runtime.pool.definition('owner',meta.environmentId)?.cwd).toBe('/home/cua/workspace')
  81. workspace.mockRejectedValue(new Error('Profile was removed'))
  82. await f.runtime.desktop(meta,'default','stop',()=>{})
  83. expect(f.running.has(meta.environmentId)).toBe(false)
  84. await f.runtime.desktop(meta,'default','remove',()=>{})
  85. expect(f.runtime.pool.definition('owner',meta.environmentId)).toBeUndefined()
  86. expect(workspace).toHaveBeenCalledOnce()
  87. expect(model).not.toHaveBeenCalled()
  88. expect(f.runtime.provider.deleteWorkspace).not.toHaveBeenCalled()
  89. }finally{await f.close()}
  90. })
  91. it('keeps uncertain preparation cleanup fenced and retries cleanup when runtime recovers',async()=>{
  92. const f=await fixture(),manager=new LocalVmImages(f.runtime,f.db),imageId='sha256:'+'a'.repeat(64)
  93. let canStop=false
  94. try{
  95. await manager.ready
  96. vi.spyOn(manager.images,'run').mockResolvedValue(imageId)
  97. vi.spyOn(manager.images,'inspect').mockResolvedValue({protocol:1,imageId,architecture:'arm64',driver:'0.20.0',layer:'5',createdAt:1})
  98. vi.mocked(f.runtime.provider.remove).mockImplementation(async()=>{if(!canStop)throw new Error('stop uncertain')})
  99. await manager.prepare(randomUUID(),async()=>{})
  100. await vi.waitFor(async()=>expect((await manager.status()).job?.state).toBe('failed'))
  101. expect(()=>f.runtime.pool.beginMaintenance()).toThrow()
  102. expect(f.runtime.config.imageId).toBe(UNCONFIGURED_COMPUTER_IMAGE)
  103. canStop=true;expect((await manager.status()).busy).toBe(false)
  104. const release=f.runtime.pool.beginMaintenance();release()
  105. }finally{canStop=true;await manager.close();await f.close()}
  106. })