managedBrowserTransport.test.ts 6.8 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586
  1. // @vitest-environment node
  2. import {afterEach,describe,expect,it,vi} from 'vitest'
  3. import {createHash,randomUUID} from 'node:crypto'
  4. import {RunnerBrowser} from '../../src/runner/managedBrowser.js'
  5. import type {BrowserRuntime,BrowserCallContext,BrowserScope} from '../../src/runner/browser/runtime.js'
  6. const scope:BrowserScope={ownerKey:'a'.repeat(64),environmentId:randomUUID(),profile:'persistent'},grantId=randomUUID()
  7. const scopes:RunnerBrowser[]=[]
  8. function fixture(config:{enabled:boolean;idleTimeoutMs?:number}={enabled:true}){
  9. const runtime={status:vi.fn(async()=>({open:false,generation:1,tabs:[],downloads:[]})),open:vi.fn(async()=>({open:true,generation:1,tabs:[],downloads:[]})),execute:vi.fn(async()=>({ok:true})),pause:vi.fn(),resume:vi.fn(),close:vi.fn(),readDownload:vi.fn(),revoke:vi.fn(async()=>{}),shutdown:vi.fn(async()=>{})}
  10. const check=vi.fn(async(_scope:BrowserScope,_grant?:string)=>{}),probe=vi.fn(async()=>true)
  11. const browser=new RunnerBrowser(config,'/unused-browser-test',check,{runtime:runtime as unknown as BrowserRuntime,probe});scopes.push(browser)
  12. const profileAllowed=vi.fn((_profile:string)=>{})
  13. const invoke=(value:Record<string,unknown>,expiresAt=Date.now()+30000)=>browser.invoke({scope,profile:'default',grantId,...value},expiresAt,profileAllowed)
  14. return {browser,runtime,check,probe,profileAllowed,invoke}
  15. }
  16. afterEach(async()=>{vi.useRealTimers();for(const browser of scopes.splice(0))await browser.shutdown()})
  17. describe('managed browser Runner transport',()=>{
  18. it('keeps default-disabled and missing-browser installations unavailable',async()=>{
  19. const f=fixture({enabled:false});await f.browser.ready
  20. expect(f.browser.available).toBe(false);expect(f.probe).not.toHaveBeenCalled()
  21. await expect(f.invoke({op:'open'})).rejects.toMatchObject({code:'browser_unavailable'})
  22. expect(f.runtime.open).not.toHaveBeenCalled()
  23. const unavailable=new RunnerBrowser({enabled:true},'/unused',async()=>{}, {runtime:f.runtime as unknown as BrowserRuntime,probe:async()=>false});scopes.push(unavailable)
  24. await unavailable.ready;expect(unavailable.available).toBe(false)
  25. })
  26. it('rejects forbidden Profiles, expired commands and foreign grants before execution',async()=>{
  27. const f=fixture();f.profileAllowed.mockImplementationOnce(()=>{throw new Error('profile forbidden')})
  28. await expect(f.invoke({op:'open'})).rejects.toThrow('profile forbidden')
  29. await expect(f.invoke({op:'open'},Date.now()-1)).rejects.toMatchObject({code:'browser_command_expired'})
  30. f.check.mockRejectedValueOnce(new Error('foreign grant'))
  31. await expect(f.invoke({op:'open'})).rejects.toThrow('foreign grant')
  32. expect(f.runtime.open).not.toHaveBeenCalled()
  33. })
  34. it('retains the explicit task grant for background activity after the command deadline',async()=>{
  35. vi.useFakeTimers();const f=fixture();let context:BrowserCallContext|undefined
  36. f.runtime.open.mockImplementationOnce(async(_scope?:unknown,call?:BrowserCallContext)=>{context=call;return {open:true,generation:1,tabs:[],downloads:[]}})
  37. await f.invoke({op:'open'},Date.now()+1000)
  38. await vi.advanceTimersByTimeAsync(5000)
  39. await context!.authorize()
  40. expect(f.check).toHaveBeenLastCalledWith(scope,grantId)
  41. expect(context!.signal!.aborted).toBe(false)
  42. f.check.mockRejectedValueOnce(new Error('grant revoked'))
  43. await expect(context!.authorize()).rejects.toThrow('grant revoked')
  44. })
  45. it('aborts in-flight execution when its transport deadline expires',async()=>{
  46. vi.useFakeTimers();const f=fixture()
  47. f.runtime.execute.mockImplementationOnce(async(_scope?:unknown,_operation?:unknown,context?:BrowserCallContext)=>new Promise((_resolve,reject)=>context!.signal!.addEventListener('abort',()=>reject(context!.signal!.reason),{once:true})))
  48. const result=f.invoke({op:'execute',operation:{generation:1,operationId:'slow',action:{kind:'text',text:'test'}}},Date.now()+1000).catch(error=>error)
  49. await vi.advanceTimersByTimeAsync(2100)
  50. expect(await result).toMatchObject({code:'browser_command_expired'})
  51. expect(f.runtime.execute).toHaveBeenCalledTimes(1)
  52. })
  53. it('disconnect aborts current calls and revokes all opened scopes',async()=>{
  54. const f=fixture()
  55. f.runtime.execute.mockImplementationOnce(async(_scope?:unknown,_operation?:unknown,context?:BrowserCallContext)=>new Promise((_resolve,reject)=>context!.signal!.addEventListener('abort',()=>reject(context!.signal!.reason),{once:true})))
  56. const result=f.invoke({op:'execute',operation:{generation:1,operationId:'inflight',action:{kind:'screenshot'}}}).catch(error=>error)
  57. await vi.waitFor(()=>expect(f.runtime.execute).toHaveBeenCalled())
  58. await f.browser.disconnect();expect(await result).toBeInstanceOf(Error)
  59. expect(f.runtime.revoke).toHaveBeenCalledWith(scope)
  60. })
  61. it('serves validated 512-KiB chunks, rechecking grants even for cached files',async()=>{
  62. const f=fixture(),id=randomUUID(),buffer=Buffer.alloc(512*1024+7,97),sha256=createHash('sha256').update(buffer).digest('hex')
  63. f.runtime.readDownload.mockResolvedValue({name:'large.txt',mimeType:'text/plain',buffer,metadata:{id,name:'large.txt',mimeType:'text/plain',size:buffer.length,sha256,url:'https://example.com/file',createdAt:Date.now()}})
  64. const first=await f.invoke({op:'download',downloadId:id,offset:0}) as {data:string;offset:number;done:boolean;sha256:string}
  65. const last=await f.invoke({op:'download',downloadId:id,offset:512*1024}) as typeof first
  66. expect(Buffer.from(first.data,'base64')).toHaveLength(512*1024);expect(first.done).toBe(false)
  67. expect(Buffer.from(last.data,'base64')).toHaveLength(7);expect(last.done).toBe(true);expect(last.sha256).toBe(sha256)
  68. expect(f.runtime.readDownload).toHaveBeenCalledTimes(1)
  69. await expect(f.invoke({op:'download',downloadId:id,offset:buffer.length+1})).rejects.toMatchObject({code:'browser_download_invalid'})
  70. f.check.mockRejectedValueOnce(new Error('download grant revoked'))
  71. await expect(f.invoke({op:'download',downloadId:id,offset:0})).rejects.toThrow('download grant revoked')
  72. await f.invoke({op:'close'});await f.invoke({op:'download',downloadId:id,offset:0})
  73. expect(f.runtime.readDownload).toHaveBeenCalledTimes(2)
  74. })
  75. it('rejects invalid upload bytes and removes idle or unauthorized browser scopes',async()=>{
  76. vi.useFakeTimers();const f=fixture({enabled:true,idleTimeoutMs:60000})
  77. await expect(f.invoke({op:'execute',upload:{id:randomUUID(),name:'file',mimeType:'text/plain',data:'a'}})).rejects.toMatchObject({code:'browser_upload_limit'})
  78. expect(f.runtime.execute).not.toHaveBeenCalled()
  79. await f.invoke({op:'open'});await vi.advanceTimersByTimeAsync(61000);await f.browser.sweep()
  80. expect(f.runtime.revoke).toHaveBeenCalledWith(scope)
  81. f.runtime.revoke.mockClear();await f.invoke({op:'open'});f.check.mockRejectedValueOnce(new Error('owner disabled'));await f.browser.sweep()
  82. expect(f.runtime.revoke).toHaveBeenCalledWith(scope)
  83. })
  84. })