managedBrowserSmoke.test.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  1. // @vitest-environment node
  2. import {expect,it,vi} from 'vitest'
  3. import Koa from 'koa'
  4. import {bodyParser} from '@koa/bodyparser'
  5. import {createServer,type Server} from 'node:http'
  6. import {mkdtempSync,realpathSync,rmSync} from 'node:fs'
  7. import {tmpdir} from 'node:os'
  8. import {join} from 'node:path'
  9. import {randomUUID} from 'node:crypto'
  10. import {WorkspaceStore} from '../../src/server/workspaceStore'
  11. import {LocalAuthStore} from '../../src/server/localAuth'
  12. import {WorkspaceNodes,type GatewayTarget} from '../../src/server/workspaceGateway'
  13. import {loadServerConfig} from '../../src/server/config'
  14. import {saveHostTools} from '../../src/server/hostToolSettings'
  15. import {RunnerHub} from '../../src/server/runnerHub'
  16. import {RunnerAgent} from '../../src/runner/agent'
  17. import {ManagedBrowsers,type BrowserTurn} from '../../src/server/managedBrowsers'
  18. import {HttpError} from '../../src/server/errors'
  19. import type {RunnerConfiguration} from '../../src/shared/runner'
  20. class FixtureAuth extends LocalAuthStore {
  21. version=1
  22. override isUserActive(){return true}
  23. override pushAuthorizationVersion(){return this.version}
  24. override canUseSource(){return true}
  25. override require(){return {id:'owner',username:'fixture',role:'admin'} as ReturnType<LocalAuthStore['require']>}
  26. }
  27. const listen=(server:Server)=>new Promise<string>(resolve=>server.listen(0,'127.0.0.1',()=>resolve(`http://127.0.0.1:${(server.address() as {port:number}).port}`)))
  28. const close=(server:Server)=>new Promise<void>(resolve=>{server.close(()=>resolve());server.closeAllConnections()})
  29. /** YAOYAO_BROWSER_SMOKE=1 npx vitest run tests/server/managedBrowserSmoke.test.ts
  30. * Real outbound Runner protocol and Chromium; about:blank avoids website access.
  31. * The Hermes HTTP fixture never opens a model session or a computer Worker. */
  32. it.skipIf(process.env.YAOYAO_BROWSER_SMOKE!=='1')('uses a browser-only Runner through authenticated HTTP, UI takeover and the same task session',async()=>{
  33. const home=realpathSync(mkdtempSync(join(tmpdir(),'yaoyao-browser-http-smoke-'))),store=new WorkspaceStore(home),auth=new FixtureAuth(home)
  34. saveHostTools(home,{managedBrowser:true,vm:false,scriptMachine:false,serverComputer:false,cloud:false})
  35. let hermesRequests=0
  36. const hermes=createServer((_req,res)=>{hermesRequests++;res.setHeader('Content-Type','application/json');res.end(JSON.stringify({ok:true,auth_required:false}))})
  37. const hermesURL=await listen(hermes)
  38. const target={url:new URL(hermesURL)} as GatewayTarget
  39. const nodes=new WorkspaceNodes(store,loadServerConfig({HERMES_YAOYAO_HOME:home}),target)
  40. nodes.sourceAllowed=(owner,node,profile)=>auth.canUseSource()
  41. const hub=new RunnerHub(store,auth,target),service=new ManagedBrowsers(store,auth,nodes,hub)
  42. hub.browserAllowed=(runnerId,scope,grantId)=>service.allowed(runnerId,scope,grantId)
  43. const registered=hub.enroll('owner',{name:'浏览器专用节点',allowedProfiles:['default']})
  44. const app=new Koa(),requests:string[]=[],checks:boolean[]=[]
  45. app.use(async(ctx,next)=>{
  46. requests.push(ctx.path)
  47. try{await next()}catch(error){ctx.status=error instanceof HttpError?error.status:500;ctx.body={code:(error as any).code,error:error instanceof Error?error.message:'Failed'}}
  48. if(ctx.path.endsWith('/browser-check'))checks.push((ctx.body as any)?.allowed===true)
  49. })
  50. // RunnerHub reads its authenticated machine JSON directly; keep bodyParser
  51. // after it, matching production router order.
  52. app.use(hub.middleware());app.use(bodyParser());const router=service.router();app.use(router.routes());app.use(router.allowedMethods())
  53. const web=createServer(app.callback()),serverURL=await listen(web)
  54. const config:RunnerConfiguration={protocol:1,serverURL,runnerId:registered.runner.id,token:registered.token,hermesURL,allowedProfiles:['default'],artifactRoots:[],browser:{enabled:true}}
  55. const status:string[]=[],runner=new RunnerAgent(config,home,fetch,message=>status.push(message)),controller=new AbortController()
  56. const running=runner.run(controller.signal);void running.catch(()=>{})
  57. let task:BrowserTurn|undefined
  58. const ui=async(path:string,body?:unknown)=>{
  59. const response=await fetch(serverURL+path,{method:body===undefined?'GET':'POST',headers:{'Content-Type':'application/json'},...(body===undefined?{}:{body:JSON.stringify(body)})})
  60. const value=await response.json() as any
  61. expect(response.status,JSON.stringify(value)).toBe(200)
  62. return value
  63. }
  64. try{
  65. await vi.waitFor(()=>expect(hub.summary(hub.records()[0]!).features).toContain('managed-browser-v1'),{timeout:35000,interval:50})
  66. await vi.waitFor(()=>expect(status).toContain('执行节点已连接'))
  67. const features=hub.summary(hub.records()[0]!).features
  68. expect(features).not.toContain('computer-worker-v1');expect(features).not.toContain('computer-control-v1')
  69. expect(config.computers).toBeUndefined()
  70. const agent=store.createAgent('owner',{name:'浏览器任务',profile:'default'}),base=`/api/app/agents/${agent.id}`
  71. expect(await ui(base+'/managed-browser')).toMatchObject({enabled:true,available:true,open:false})
  72. const taskController=new AbortController()
  73. task=service.openTurn('owner',agent,{workId:randomUUID(),signal:taskController.signal,authorize:()=>taskController.signal.throwIfAborted(),publish:async()=>{throw new Error('No download in this smoke')},upload:async()=>{throw new Error('No upload in this smoke')}})
  74. const opened=await task.call('managed_browser_open',{},'open') as any
  75. expect(opened.open).toBe(true);expect(opened.tabs).toHaveLength(1);expect(opened.tabs[0].url).toBe('about:blank')
  76. const before=await task.call('managed_browser_state',{},'state-before') as any
  77. const shot=await task.call('managed_browser_action',{action:{kind:'screenshot'}},'screenshot') as any
  78. expect(shot.content[0].mimeType).toBe('image/png')
  79. expect(Buffer.from(shot.content[0].data,'base64').subarray(1,4).toString()).toBe('PNG')
  80. const taken=await ui(base+'/computer/take?backend=managed-browser',{requestId:randomUUID()})
  81. expect(taken).toMatchObject({backend:'managed-browser',mode:'human',canResume:true})
  82. expect(taken.generation).toBeGreaterThan(before.generation)
  83. const credentials={controlId:taken.controlId,token:taken.token}
  84. const frame=await ui(base+'/computer/frame?backend=managed-browser')
  85. expect(frame).toMatchObject({generation:taken.generation,width:1280,height:800})
  86. await ui(base+'/computer/input?backend=managed-browser',{...credentials,requestId:randomUUID(),generation:frame.generation,frameId:frame.id,action:{kind:'key',key:'Tab'}})
  87. let completed=false
  88. const waiting=task.call('managed_browser_state',{},'state-after').finally(()=>{completed=true})
  89. await new Promise(resolve=>setTimeout(resolve,30));expect(completed).toBe(false)
  90. await ui(base+'/computer/giveback?backend=managed-browser',{...credentials,notes:'可以继续'})
  91. const resumed=await waiting as any
  92. expect(resumed.humanNote).toBe('可以继续')
  93. expect(resumed.result.tabs[0].id).toBe(before.tabs[0].id)
  94. expect(resumed.result.generation).toBeGreaterThan(taken.generation)
  95. await task.close();task=undefined
  96. expect(await ui(base+'/managed-browser')).toMatchObject({open:false,available:true})
  97. for(const endpoint of ['poll','admit','result','browser-check'])expect(requests.some(path=>path.endsWith('/'+endpoint))).toBe(true)
  98. expect(checks.length).toBeGreaterThan(10);expect(checks.every(Boolean)).toBe(true)
  99. expect(hermesRequests).toBe(0)
  100. }finally{
  101. await task?.close().catch(()=>{})
  102. controller.abort();await running.catch(()=>{})
  103. service.close();hub.close();nodes.close()
  104. await close(web);await close(hermes)
  105. store.close();rmSync(home,{recursive:true,force:true})
  106. }
  107. },60000)