managedBrowserSmoke.test.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  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 {WebSocketServer,type WebSocket} from 'ws'
  11. import {WorkspaceStore} from '../../src/server/workspaceStore'
  12. import {LocalAuthStore,UpstreamServiceSession} from '../../src/server/localAuth'
  13. import {WorkspaceNodes,type GatewayTarget} from '../../src/server/workspaceGateway'
  14. import {loadServerConfig} from '../../src/server/config'
  15. import {saveHostTools} from '../../src/server/hostToolSettings'
  16. import {RunnerHub} from '../../src/server/runnerHub'
  17. import {RunnerAgent} from '../../src/runner/agent'
  18. import {ManagedBrowsers,type BrowserTurn} from '../../src/server/managedBrowsers'
  19. import {HttpError} from '../../src/server/errors'
  20. import {UpstreamClient} from '../../src/server/upstream'
  21. import {UploadStore} from '../../src/server/uploads'
  22. import {ExecutionSettings} from '../../src/server/executionSettings'
  23. import {WorkspaceRuntime} from '../../src/server/workspaceRuntime'
  24. import type {RunnerConfiguration} from '../../src/shared/runner'
  25. import type {WorkspaceConversation,WorkspaceMessage,WorkspaceRun} from '../../src/shared/workspace'
  26. class FixtureAuth extends LocalAuthStore {
  27. version=1
  28. allowed=true
  29. owner='owner'
  30. override isUserActive(){return true}
  31. override pushAuthorizationVersion(){return this.version}
  32. override canUseSource(){return this.allowed}
  33. override require(){return {id:this.owner,username:'fixture',role:'admin'} as ReturnType<LocalAuthStore['require']>}
  34. }
  35. 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}`)))
  36. const close=(server:Server)=>new Promise<void>(resolve=>{server.close(()=>resolve());server.closeAllConnections()})
  37. /** YAOYAO_BROWSER_SMOKE=1 npx vitest run tests/server/managedBrowserSmoke.test.ts
  38. * Real outbound Runner protocol and Chromium; about:blank avoids website access.
  39. * The Hermes HTTP fixture never opens a model session or a computer Worker. */
  40. it.skipIf(process.env.YAOYAO_BROWSER_SMOKE!=='1')('upgrades a legacy Runner without browser configuration through authenticated HTTP, UI takeover and the same task session',async()=>{
  41. const home=realpathSync(mkdtempSync(join(tmpdir(),'yaoyao-browser-http-smoke-'))),store=new WorkspaceStore(home),auth=new FixtureAuth(home)
  42. saveHostTools(home,{managedBrowser:true,vm:false,scriptMachine:false,serverComputer:false,cloud:false})
  43. let hermesRequests=0
  44. const hermes=createServer((_req,res)=>{hermesRequests++;res.setHeader('Content-Type','application/json');res.end(JSON.stringify({ok:true,auth_required:false}))})
  45. const hermesURL=await listen(hermes)
  46. const target={url:new URL(hermesURL)} as GatewayTarget
  47. const nodes=new WorkspaceNodes(store,loadServerConfig({HERMES_YAOYAO_HOME:home}),target)
  48. nodes.sourceAllowed=(owner,node,profile)=>auth.canUseSource()
  49. const hub=new RunnerHub(store,auth,target),service=new ManagedBrowsers(store,auth,nodes,hub)
  50. hub.browserAllowed=(runnerId,scope,grantId)=>service.allowed(runnerId,scope,grantId)
  51. const registered=hub.enroll('owner',{name:'浏览器专用节点',allowedProfiles:['default']})
  52. const app=new Koa(),requests:string[]=[],checks:boolean[]=[]
  53. app.use(async(ctx,next)=>{
  54. requests.push(ctx.path)
  55. 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'}}
  56. if(ctx.path.endsWith('/browser-check'))checks.push((ctx.body as any)?.allowed===true)
  57. })
  58. // RunnerHub reads its authenticated machine JSON directly; keep bodyParser
  59. // after it, matching production router order.
  60. app.use(hub.middleware());app.use(bodyParser());const router=service.router();app.use(router.routes());app.use(router.allowedMethods())
  61. const web=createServer(app.callback()),serverURL=await listen(web)
  62. const config:RunnerConfiguration={protocol:1,serverURL,runnerId:registered.runner.id,token:registered.token,hermesURL,allowedProfiles:['default'],artifactRoots:[]}
  63. const status:string[]=[],runner=new RunnerAgent(config,home,fetch,message=>status.push(message)),controller=new AbortController()
  64. const running=runner.run(controller.signal);void running.catch(()=>{})
  65. let task:BrowserTurn|undefined
  66. const ui=async(path:string,body?:unknown,status=200)=>{
  67. const response=await fetch(serverURL+path,{method:body===undefined?'GET':'POST',headers:{'Content-Type':'application/json'},...(body===undefined?{}:{body:JSON.stringify(body)})})
  68. const value=await response.json() as any
  69. expect(response.status,JSON.stringify(value)).toBe(status)
  70. return value
  71. }
  72. try{
  73. await vi.waitFor(()=>expect(hub.summary(hub.records()[0]!).features).toContain('managed-browser-v1'),{timeout:35000,interval:50})
  74. await vi.waitFor(()=>expect(status).toContain('执行节点已连接'))
  75. const features=hub.summary(hub.records()[0]!).features
  76. expect(features).toContain('managed-browser-retention-v1')
  77. expect(features).not.toContain('computer-worker-v1');expect(features).not.toContain('computer-control-v1')
  78. expect(config.computers).toBeUndefined();expect(config.browser).toBeUndefined()
  79. const agent=store.createAgent('owner',{name:'浏览器任务',profile:'default'}),base=`/api/app/agents/${agent.id}`
  80. expect(await ui(base+'/managed-browser')).toMatchObject({enabled:true,available:true,open:false})
  81. const taskController=new AbortController()
  82. 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')}})
  83. const opened=await task.call('managed_browser_open',{},'open') as any
  84. expect(opened.open).toBe(true);expect(opened.tabs).toHaveLength(1);expect(opened.tabs[0].url).toBe('about:blank')
  85. const before=await task.call('managed_browser_state',{},'state-before') as any
  86. const shot=await task.call('managed_browser_action',{action:{kind:'screenshot'}},'screenshot') as any
  87. expect(shot.content[0].mimeType).toBe('image/png')
  88. expect(Buffer.from(shot.content[0].data,'base64').subarray(1,4).toString()).toBe('PNG')
  89. const taken=await ui(base+'/computer/take?backend=managed-browser',{requestId:randomUUID()})
  90. expect(taken).toMatchObject({backend:'managed-browser',mode:'human',canResume:true})
  91. expect(taken.generation).toBeGreaterThan(before.generation)
  92. const credentials={controlId:taken.controlId,token:taken.token}
  93. const frame=await ui(base+'/computer/frame?backend=managed-browser')
  94. expect(frame).toMatchObject({generation:taken.generation,width:1280,height:800})
  95. const dragFrame={...credentials,generation:frame.generation,frameId:frame.id},gestureId=randomUUID()
  96. for(const phase of ['start','move','end'])await ui(base+'/computer/input?backend=managed-browser',{...dragFrame,requestId:randomUUID(),action:{kind:'pointer',phase,gestureId,x:120,y:180}})
  97. const nextFrame=await ui(base+'/computer/frame?backend=managed-browser')
  98. await ui(base+'/computer/input?backend=managed-browser',{...dragFrame,frameId:nextFrame.id,requestId:randomUUID(),action:{kind:'drag',fromX:10,fromY:20,toX:120,toY:180}})
  99. frame.id=(await ui(base+'/computer/frame?backend=managed-browser')).id
  100. await ui(base+'/computer/input?backend=managed-browser',{...credentials,requestId:randomUUID(),generation:frame.generation,frameId:frame.id,action:{kind:'key',key:'Tab'}})
  101. let completed=false
  102. const waiting=task.call('managed_browser_state',{},'state-after').finally(()=>{completed=true})
  103. await new Promise(resolve=>setTimeout(resolve,30));expect(completed).toBe(false)
  104. await ui(base+'/computer/giveback?backend=managed-browser',{...credentials,notes:'可以继续'})
  105. const resumed=await waiting as any
  106. expect(resumed.humanNote).toBe('可以继续')
  107. expect(resumed.result.tabs[0].id).toBe(before.tabs[0].id)
  108. expect(resumed.result.generation).toBeGreaterThan(taken.generation)
  109. await task.close({retain:true})
  110. taskController.abort()
  111. await expect(task.call('managed_browser_state',{},'finished-task')).rejects.toThrow()
  112. task=undefined
  113. const retained=await ui(base+'/managed-browser')
  114. expect(retained).toMatchObject({open:true,available:true})
  115. expect(retained.tabs).toEqual(before.tabs)
  116. expect(await ui(base+'/computer?backend=managed-browser')).toMatchObject({mode:'idle',canResume:false})
  117. const other=store.createAgent('owner',{name:'另一个浏览器 Bot',profile:'default'})
  118. expect(await ui(`/api/app/agents/${other.id}/managed-browser`)).toMatchObject({open:false,tabs:[]})
  119. auth.owner='other-owner'
  120. await ui(base+'/managed-browser',undefined,404)
  121. auth.owner='owner'
  122. const retaken=await ui(base+'/computer/take?backend=managed-browser',{requestId:randomUUID()})
  123. expect(retaken).toMatchObject({mode:'human',canResume:false})
  124. expect(retaken.generation).toBeGreaterThan(retained.generation)
  125. const newCredentials={controlId:retaken.controlId,token:retaken.token}
  126. expect((await ui(base+'/managed-browser')).tabs).toEqual(before.tabs)
  127. await ui(base+'/computer/renew?backend=managed-browser',credentials,403)
  128. const idle=await ui(base+'/computer/giveback?backend=managed-browser',newCredentials)
  129. expect(idle).toMatchObject({mode:'idle',canResume:false})
  130. expect((await ui(base+'/managed-browser')).tabs).toEqual(before.tabs)
  131. const closing=await ui(base+'/computer/take?backend=managed-browser',{requestId:randomUUID()})
  132. await ui(base+'/managed-browser/close',{controlId:closing.controlId,token:closing.token})
  133. expect(await ui(base+'/managed-browser')).toMatchObject({open:false,available:true,tabs:[]})
  134. for(const endpoint of ['poll','admit','result','browser-check'])expect(requests.some(path=>path.endsWith('/'+endpoint))).toBe(true)
  135. expect(checks.length).toBeGreaterThan(10);expect(checks.every(Boolean)).toBe(true)
  136. expect(hermesRequests).toBe(0)
  137. }finally{
  138. auth.owner='owner'
  139. await task?.close().catch(()=>{})
  140. controller.abort();await running.catch(()=>{})
  141. service.close();hub.close();nodes.close()
  142. await close(web);await close(hermes)
  143. store.close();rmSync(home,{recursive:true,force:true})
  144. }
  145. },60000)
  146. /** The model is a deterministic Hermes fixture, but its private tool bridge,
  147. * WorkspaceRuntime, Runner transport, grants and Chromium are all real.
  148. * example.com supplies a public page because production browsing rejects LAN URLs. */
  149. it.skipIf(process.env.YAOYAO_BROWSER_SMOKE!=='1')('persists live Bot browser cards through the real workspace tool bridge and human handoff',async()=>{
  150. const home=realpathSync(mkdtempSync(join(tmpdir(),'yaoyao-browser-workspace-smoke-'))),store=new WorkspaceStore(home),auth=new FixtureAuth(home),uploads=new UploadStore(home)
  151. saveHostTools(home,{managedBrowser:true,vm:false,scriptMachine:false,serverComputer:false,cloud:false})
  152. const bindings:any[]=[],rpc:{method:string;params:any}[]=[],messages:WorkspaceMessage[]=[],requests:string[]=[],checks:boolean[]=[]
  153. let socket:WebSocket|undefined,runtimeId='',sessionRunning=false
  154. const hermes=createServer(async(req,res)=>{
  155. let body='';for await(const part of req)body+=part
  156. const payload=body?JSON.parse(body):{},path=new URL(req.url!,'http://fixture').pathname
  157. if(path.endsWith('/bind'))bindings.push(payload)
  158. const result=path==='/api/auth/ws-ticket'?{ticket:'fixture'}:path.endsWith('/capabilities')?{version:1,ready:true,in_process:true,native_tools:true}:path==='/api/profiles'?{profiles:[{name:'default'}]}:{ok:true,native_tools:true,computer_runtime_version:2,terminal:{cwd:home}}
  159. res.setHeader('Content-Type','application/json');res.end(JSON.stringify(result))
  160. })
  161. const hermesURL=await listen(hermes),ws=new WebSocketServer({server:hermes})
  162. const emit=(type:string,payload:unknown)=>socket?.send(JSON.stringify({method:'event',params:{type,session_id:runtimeId,profile:'default',payload}}))
  163. ws.on('connection',connected=>{
  164. socket=connected;connected.send(JSON.stringify({method:'event',params:{type:'gateway.ready'}}))
  165. connected.on('message',raw=>{
  166. const request=JSON.parse(String(raw));rpc.push(request);let result:any={ok:true}
  167. if(request.method==='session.create'||request.method==='session.resume'){
  168. runtimeId=randomUUID();result={session_id:runtimeId,stored_session_id:randomUUID(),running:false,info:{profile_name:'default',cwd:home}}
  169. }else if(request.method==='prompt.submit')sessionRunning=true
  170. else if(request.method==='session.active_list')result={sessions:[{id:runtimeId,status:sessionRunning?'working':'idle'}]}
  171. else if(request.method==='session.usage')result={context_used:30,context_max:32000}
  172. else if(request.method==='session.interrupt'){sessionRunning=false;emit('message.complete',{text:'',status:'interrupted'})}
  173. connected.send(JSON.stringify({id:request.id,result}))
  174. })
  175. })
  176. const client=new UpstreamClient(new URL(hermesURL)),target:GatewayTarget={url:new URL(hermesURL),client,session:new UpstreamServiceSession(client,()=>undefined)}
  177. const nodes=new WorkspaceNodes(store,loadServerConfig({HERMES_YAOYAO_HOME:home}),target)
  178. nodes.sourceAllowed=()=>auth.canUseSource()
  179. const hub=new RunnerHub(store,auth,target),service=new ManagedBrowsers(store,auth,nodes,hub),runtime=new WorkspaceRuntime(store,nodes,uploads,()=>auth.isUserActive(),()=>auth.version)
  180. runtime.managedBrowsers=service
  181. runtime.executionSettings=new ExecutionSettings(store,nodes,auth)
  182. runtime.executionSettings.saveSelection({mode:'server',revision:1})
  183. hub.browserAllowed=(runnerId,scope,grantId)=>service.allowed(runnerId,scope,grantId)
  184. nodes.runnerTarget=(owner,nodeId,computer)=>hub.target(owner,nodeId,computer)
  185. const stopObserving=store.observe((_owner,event)=>{if(event.type==='message.changed')messages.push(structuredClone(event.data as WorkspaceMessage))})
  186. const registered=hub.enroll('owner',{name:'真实浏览器任务节点',allowedProfiles:['default']})
  187. const app=new Koa()
  188. app.use(async(ctx,next)=>{
  189. requests.push(ctx.path)
  190. 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'}}
  191. if(ctx.path.endsWith('/browser-check'))checks.push((ctx.body as any)?.allowed===true)
  192. })
  193. app.use(hub.middleware());app.use(bodyParser());const router=service.router();app.use(router.routes());app.use(router.allowedMethods())
  194. const web=createServer(app.callback()),serverURL=await listen(web)
  195. const config:RunnerConfiguration={protocol:1,serverURL,runnerId:registered.runner.id,token:registered.token,hermesURL,allowedProfiles:['default'],artifactRoots:[],browser:{enabled:true}}
  196. const runner=new RunnerAgent(config,home),controller=new AbortController(),running=runner.run(controller.signal);void running.catch(()=>{})
  197. const ui=async(path:string,body?:unknown,status=200)=>{
  198. const response=await fetch(serverURL+path,{method:body===undefined?'GET':'POST',headers:{'Content-Type':'application/json'},...(body===undefined?{}:{body:JSON.stringify(body)})}),value=await response.json() as any
  199. expect(response.status,JSON.stringify(value)).toBe(status);return value
  200. }
  201. const bridge=async(path:string,body:unknown,token=bindings.at(-1)?.token)=>{
  202. const binding=bindings.at(-1),response=await fetch(binding.bridge_url+path,{method:'POST',headers:{Authorization:`Bearer ${token}`,'Content-Type':'application/json'},body:JSON.stringify(body)})
  203. return {status:response.status,body:await response.json() as any}
  204. }
  205. let rootRun:WorkspaceRun|undefined
  206. try{
  207. await vi.waitFor(()=>expect(hub.summary(hub.records()[0]!).features).toContain('managed-browser-v1'),{timeout:35000,interval:50})
  208. expect(hub.summary(hub.records()[0]!).features).toContain('managed-browser-retention-v1')
  209. expect(hub.summary(hub.records()[0]!).features).not.toContain('computer-worker-v1')
  210. const agent=store.createAgent('owner',{name:'真实网页任务',profile:'default'}),base=`/api/app/agents/${agent.id}`
  211. const conversation=store.list<WorkspaceConversation>('owner','conversation').find(value=>value.memberIds[0]===agent.id)!
  212. auth.allowed=false
  213. await ui(base+'/managed-browser/prepare',{},403)
  214. auth.allowed=true
  215. const prepared=await ui(base+'/managed-browser/prepare',{})
  216. expect(prepared.installation.status).toBe('ready');expect(prepared.open).toBe(false)
  217. rootRun=runtime.send('owner',conversation.id,{requestId:randomUUID(),content:'打开网页,等待我接管,然后继续原任务。'})
  218. await vi.waitFor(()=>expect(rpc.some(call=>call.method==='prompt.submit')).toBe(true),{timeout:10000})
  219. expect(bindings).toHaveLength(1)
  220. expect(rpc.find(call=>call.method==='prompt.submit')?.params.text).toContain('默认使用 managed_browser_open')
  221. expect((await bridge('/tools/list',{},'invalid-token')).status).toBe(401)
  222. const catalog=await bridge('/tools/list',{})
  223. expect(catalog.status).toBe(200)
  224. expect(catalog.body.tools.map((tool:any)=>tool.name)).toContain('managed_browser_open')
  225. expect(catalog.body.tools.map((tool:any)=>tool.name)).not.toContain('computer_shell')
  226. const tool=async(name:string,args:unknown,callId=randomUUID())=>{
  227. const entry=catalog.body.tools.find((tool:any)=>tool.name===name);expect(entry).toBeDefined()
  228. const reply=await bridge('/tools/call',{toolId:entry.id,arguments:args,callId})
  229. expect(reply.status).toBe(200);expect(reply.body.isError,JSON.stringify(reply.body)).not.toBe(true)
  230. return reply.body.structuredContent??reply.body
  231. }
  232. const opened=await tool('managed_browser_open',{url:'https://example.com/'})
  233. const tab=opened.tabs.find((tab:any)=>tab.active)
  234. expect(tab.url).toBe('https://example.com/');expect(tab.title).toContain('Example Domain')
  235. const live=store.list<WorkspaceMessage>('owner','message').find(message=>message.browserCard?.status==='active')!
  236. expect(live.browserCard).toMatchObject({agentId:agent.id,agentName:agent.name,title:tab.title,url:tab.url,status:'active'})
  237. expect(live.status).toBe('streaming');expect(live.content).toBe('');expect(live.visible).toBe(true)
  238. expect(messages.filter(message=>message.browserCard).map(message=>message.browserCard!.status)).toEqual(expect.arrayContaining(['ready','active']))
  239. const shot=await tool('managed_browser_action',{action:{kind:'screenshot'}})
  240. expect(shot.content[0].mimeType).toBe('image/png')
  241. const taken=await ui(base+'/computer/take?backend=managed-browser',{requestId:randomUUID()}),credentials={controlId:taken.controlId,token:taken.token}
  242. expect(taken).toMatchObject({mode:'human',canResume:true})
  243. const frame=await ui(base+'/computer/frame?backend=managed-browser')
  244. await ui(base+'/computer/input?backend=managed-browser',{...credentials,requestId:randomUUID(),generation:frame.generation,frameId:frame.id,action:{kind:'key',key:'Tab'}})
  245. let settled=false
  246. const pending=tool('managed_browser_state',{}).finally(()=>{settled=true})
  247. await new Promise(resolve=>setTimeout(resolve,30));expect(settled).toBe(false)
  248. await ui(base+'/computer/giveback?backend=managed-browser',{...credentials,notes:'检查完成,请继续'})
  249. const resumed=await pending
  250. expect(resumed.humanNote).toBe('检查完成,请继续')
  251. expect(resumed.result.tabs.find((page:any)=>page.active)).toMatchObject({id:tab.id,title:tab.title,url:tab.url})
  252. expect(resumed.result.generation).toBeGreaterThan(taken.generation)
  253. // Force real network traffic after giveback, exercising the proxy's current
  254. // task grant instead of the revoked human grant that used to break reload.
  255. await tool('managed_browser_action',{action:{kind:'reload'}})
  256. expect((await tool('managed_browser_state',{})).tabs[0]).toMatchObject({id:tab.id,title:tab.title,url:tab.url})
  257. sessionRunning=false;emit('message.complete',{text:'',status:'complete'})
  258. await vi.waitFor(()=>expect(store.require<WorkspaceRun>('owner','run',rootRun!.id).status).toBe('complete'),{timeout:5000})
  259. await vi.waitFor(()=>expect(store.require<WorkspaceMessage>('owner','message',live.id).browserCard?.status).toBe('idle'))
  260. const saved=store.require<WorkspaceMessage>('owner','message',live.id)
  261. expect(saved).toMatchObject({visible:true,status:'complete',content:'',browserCard:{status:'idle',title:tab.title,url:tab.url}})
  262. const history=new WorkspaceStore(home)
  263. try{expect(history.require<WorkspaceMessage>('owner','message',live.id).browserCard).toEqual(saved.browserCard)}finally{history.close()}
  264. const retained=await ui(base+'/managed-browser')
  265. expect(retained).toMatchObject({open:true,available:true})
  266. expect(retained.tabs.find((page:any)=>page.active)).toMatchObject({id:tab.id,title:tab.title,url:tab.url})
  267. // Finishing also disposes this turn's private HTTP tool lease. Retaining
  268. // Chromium must not keep the former model's tool endpoint usable.
  269. await expect(bridge('/tools/list',{})).rejects.toThrow()
  270. const retaken=await ui(base+'/computer/take?backend=managed-browser',{requestId:randomUUID()})
  271. expect(retaken).toMatchObject({mode:'human',canResume:false})
  272. expect(retaken.generation).toBeGreaterThan(retained.generation)
  273. const retakenCredentials={controlId:retaken.controlId,token:retaken.token}
  274. const retainedFrame=await ui(base+'/computer/frame?backend=managed-browser')
  275. await ui(base+'/computer/input?backend=managed-browser',{...retakenCredentials,requestId:randomUUID(),generation:frame.generation,frameId:frame.id,action:{kind:'key',key:'Tab'}},409)
  276. await ui(base+'/computer/input?backend=managed-browser',{...retakenCredentials,requestId:randomUUID(),generation:retainedFrame.generation,frameId:retainedFrame.id,action:{kind:'key',key:'Tab'}})
  277. expect(await ui(base+'/computer/giveback?backend=managed-browser',retakenCredentials)).toMatchObject({mode:'idle',canResume:false})
  278. expect((await ui(base+'/managed-browser')).tabs.find((page:any)=>page.active)).toMatchObject({id:tab.id,title:tab.title,url:tab.url})
  279. const closing=await ui(base+'/computer/take?backend=managed-browser',{requestId:randomUUID()})
  280. expect(await ui(base+'/managed-browser/close',{controlId:closing.controlId,token:closing.token})).toMatchObject({mode:'off',canResume:false})
  281. expect(await ui(base+'/managed-browser')).toMatchObject({open:false,available:true,tabs:[]})
  282. for(const endpoint of ['poll','admit','result','browser-check'])expect(requests.some(path=>path.endsWith('/'+endpoint))).toBe(true)
  283. expect(checks.length).toBeGreaterThan(10)
  284. expect(rpc.filter(call=>call.method==='prompt.submit')).toHaveLength(1)
  285. }finally{
  286. auth.allowed=true
  287. if(rootRun)await runtime.stop('owner',rootRun.id).catch(()=>{})
  288. runtime.close();stopObserving()
  289. controller.abort();await running.catch(()=>{})
  290. service.close();hub.close();nodes.close();client.close();uploads.close()
  291. for(const connection of ws.clients)connection.terminate()
  292. await new Promise<void>(done=>ws.close(()=>done()));await close(web);await close(hermes)
  293. store.close();rmSync(home,{recursive:true,force:true})
  294. }
  295. },60000)