managedBrowserTransport.test.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  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 {BrowserInstaller} from '../../src/runner/browser/installation.js'
  6. import type {BrowserRuntime,BrowserCallContext,BrowserScope} from '../../src/runner/browser/runtime.js'
  7. const scope:BrowserScope={ownerKey:'a'.repeat(64),environmentId:randomUUID(),profile:'persistent'},grantId=randomUUID()
  8. const scopes:RunnerBrowser[]=[]
  9. function fixture(config:{enabled:boolean;idleTimeoutMs?:number}={enabled:true},installer?:BrowserInstaller){
  10. 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(),park:vi.fn(async()=>({open:true,generation:2,tabs:[],downloads:[]})),close:vi.fn(),readDownload:vi.fn(),revoke:vi.fn(async()=>{}),shutdown:vi.fn(async()=>{})}
  11. const check=vi.fn(async(_scope:BrowserScope,_grant?:string)=>{}),probe=vi.fn(async()=>true)
  12. const browser=new RunnerBrowser(config,'/unused-browser-test',check,{runtime:runtime as unknown as BrowserRuntime,probe,installer});scopes.push(browser)
  13. const profileAllowed=vi.fn((_profile:string)=>{})
  14. const invoke=(value:Record<string,unknown>,expiresAt=Date.now()+30000)=>browser.invoke({scope,profile:'default',grantId,...value},expiresAt,profileAllowed)
  15. return {browser,runtime,check,probe,profileAllowed,invoke}
  16. }
  17. afterEach(async()=>{vi.useRealTimers();for(const browser of scopes.splice(0))await browser.shutdown()})
  18. describe('managed browser Runner transport',()=>{
  19. it('keeps default-disabled and missing-browser installations unavailable',async()=>{
  20. const f=fixture({enabled:false});await f.browser.ready
  21. expect(f.browser.available).toBe(false);expect(f.probe).not.toHaveBeenCalled()
  22. await expect(f.invoke({op:'open'})).rejects.toMatchObject({code:'browser_unavailable'})
  23. expect(f.runtime.open).not.toHaveBeenCalled()
  24. const unavailable=new RunnerBrowser({enabled:true},'/unused',async()=>{}, {runtime:f.runtime as unknown as BrowserRuntime,probe:async()=>false});scopes.push(unavailable)
  25. await unavailable.ready;expect(unavailable.available).toBe(false)
  26. })
  27. it('rejects forbidden Profiles, expired commands and foreign grants before execution',async()=>{
  28. const f=fixture();f.profileAllowed.mockImplementationOnce(()=>{throw new Error('profile forbidden')})
  29. await expect(f.invoke({op:'open'})).rejects.toThrow('profile forbidden')
  30. await expect(f.invoke({op:'open'},Date.now()-1)).rejects.toMatchObject({code:'browser_command_expired'})
  31. f.check.mockRejectedValueOnce(new Error('foreign grant'))
  32. await expect(f.invoke({op:'open'})).rejects.toThrow('foreign grant')
  33. expect(f.runtime.open).not.toHaveBeenCalled()
  34. })
  35. it('retains the explicit task grant for background activity after the command deadline',async()=>{
  36. vi.useFakeTimers();const f=fixture();let context:BrowserCallContext|undefined
  37. f.runtime.open.mockImplementationOnce(async(_scope?:unknown,call?:BrowserCallContext)=>{context=call;return {open:true,generation:1,tabs:[],downloads:[]}})
  38. await f.invoke({op:'open'},Date.now()+1000)
  39. await vi.advanceTimersByTimeAsync(5000)
  40. await context!.authorize()
  41. expect(f.check).toHaveBeenLastCalledWith(scope,grantId)
  42. expect(context!.signal!.aborted).toBe(false)
  43. f.check.mockRejectedValueOnce(new Error('grant revoked'))
  44. await expect(context!.authorize()).rejects.toThrow('grant revoked')
  45. })
  46. it('aborts in-flight execution when its transport deadline expires',async()=>{
  47. vi.useFakeTimers();const f=fixture()
  48. 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})))
  49. const result=f.invoke({op:'execute',operation:{generation:1,operationId:'slow',action:{kind:'text',text:'test'}}},Date.now()+1000).catch(error=>error)
  50. await vi.advanceTimersByTimeAsync(2100)
  51. expect(await result).toMatchObject({code:'browser_command_expired'})
  52. expect(f.runtime.execute).toHaveBeenCalledTimes(1)
  53. })
  54. it('disconnect aborts current calls and revokes all opened scopes',async()=>{
  55. const f=fixture()
  56. 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})))
  57. const result=f.invoke({op:'execute',operation:{generation:1,operationId:'inflight',action:{kind:'screenshot'}}}).catch(error=>error)
  58. await vi.waitFor(()=>expect(f.runtime.execute).toHaveBeenCalled())
  59. await f.browser.disconnect();expect(await result).toBeInstanceOf(Error)
  60. expect(f.runtime.revoke).toHaveBeenCalledWith(scope)
  61. })
  62. it('serves validated 512-KiB chunks, rechecking grants even for cached files',async()=>{
  63. const f=fixture(),id=randomUUID(),buffer=Buffer.alloc(512*1024+7,97),sha256=createHash('sha256').update(buffer).digest('hex')
  64. 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()}})
  65. const first=await f.invoke({op:'download',downloadId:id,offset:0}) as {data:string;offset:number;done:boolean;sha256:string}
  66. const last=await f.invoke({op:'download',downloadId:id,offset:512*1024}) as typeof first
  67. expect(Buffer.from(first.data,'base64')).toHaveLength(512*1024);expect(first.done).toBe(false)
  68. expect(Buffer.from(last.data,'base64')).toHaveLength(7);expect(last.done).toBe(true);expect(last.sha256).toBe(sha256)
  69. expect(f.runtime.readDownload).toHaveBeenCalledTimes(1)
  70. await expect(f.invoke({op:'download',downloadId:id,offset:buffer.length+1})).rejects.toMatchObject({code:'browser_download_invalid'})
  71. f.check.mockRejectedValueOnce(new Error('download grant revoked'))
  72. await expect(f.invoke({op:'download',downloadId:id,offset:0})).rejects.toThrow('download grant revoked')
  73. await f.invoke({op:'close'});await f.invoke({op:'download',downloadId:id,offset:0})
  74. expect(f.runtime.readDownload).toHaveBeenCalledTimes(2)
  75. })
  76. it('rejects invalid upload bytes and removes idle or unauthorized browser scopes',async()=>{
  77. vi.useFakeTimers();const f=fixture({enabled:true,idleTimeoutMs:60000})
  78. await expect(f.invoke({op:'execute',upload:{id:randomUUID(),name:'file',mimeType:'text/plain',data:'a'}})).rejects.toMatchObject({code:'browser_upload_limit'})
  79. expect(f.runtime.execute).not.toHaveBeenCalled()
  80. await f.invoke({op:'open'});await vi.advanceTimersByTimeAsync(61000);await f.browser.sweep()
  81. expect(f.runtime.revoke).toHaveBeenCalledWith(scope)
  82. f.runtime.revoke.mockClear();await f.invoke({op:'open'});f.check.mockRejectedValueOnce(new Error('owner disabled'));await f.browser.sweep()
  83. expect(f.runtime.revoke).toHaveBeenCalledWith(scope)
  84. })
  85. it('retains idle pages for thirty minutes without extending lifetime on preview polling',async()=>{
  86. vi.useFakeTimers();const f=fixture()
  87. await f.invoke({op:'open'});await f.invoke({op:'park'})
  88. expect(f.runtime.park).toHaveBeenCalledWith(scope,expect.objectContaining({authorize:expect.any(Function)}))
  89. await vi.advanceTimersByTimeAsync(10*60000)
  90. await f.invoke({op:'status'})
  91. await f.invoke({op:'execute',operation:{generation:2,operationId:'preview',action:{kind:'screenshot'}}})
  92. await f.browser.sweep();expect(f.runtime.revoke).not.toHaveBeenCalled()
  93. await vi.advanceTimersByTimeAsync(20*60000+1)
  94. await f.invoke({op:'status'});await f.browser.sweep()
  95. expect(f.runtime.revoke).toHaveBeenCalledExactlyOnceWith(scope)
  96. })
  97. it('refreshes idle time for real input but still revokes a parked scope when permission is removed',async()=>{
  98. vi.useFakeTimers();const f=fixture({enabled:true,idleTimeoutMs:60000})
  99. await f.invoke({op:'open'});await vi.advanceTimersByTimeAsync(55000)
  100. await f.invoke({op:'execute',operation:{generation:1,operationId:'type',action:{kind:'text',text:'active'}}})
  101. await vi.advanceTimersByTimeAsync(10000);await f.browser.sweep();expect(f.runtime.revoke).not.toHaveBeenCalled()
  102. await f.invoke({op:'park'});f.check.mockRejectedValueOnce(new Error('owner revoked'))
  103. await f.browser.sweep();expect(f.runtime.revoke).toHaveBeenCalledWith(scope)
  104. })
  105. it.each(['open','input'])('does not revoke a newly active record when %s arrives during an old idle sweep',async(action)=>{
  106. vi.useFakeTimers();const f=fixture({enabled:true,idleTimeoutMs:60000})
  107. await f.invoke({op:'open'});await vi.advanceTimersByTimeAsync(61000)
  108. let release!:()=>void,entered!:()=>void
  109. const gate=new Promise<void>(resolve=>{release=resolve}),checking=new Promise<void>(resolve=>{entered=resolve})
  110. f.check.mockImplementationOnce(async()=>{entered();await gate})
  111. const sweeping=f.browser.sweep()
  112. try{
  113. await checking
  114. await f.invoke(action==='open'?{op:'open'}:{op:'execute',operation:{generation:1,operationId:'renewed-input',action:{kind:'text',text:'still active'}}})
  115. release();await sweeping
  116. expect(f.runtime.revoke).not.toHaveBeenCalled()
  117. await f.browser.sweep();expect(f.runtime.revoke).not.toHaveBeenCalled()
  118. await vi.advanceTimersByTimeAsync(61000);await f.browser.sweep()
  119. expect(f.runtime.revoke).toHaveBeenCalledExactlyOnceWith(scope)
  120. }finally{release();await sweeping}
  121. })
  122. })
  123. function installationFixture(){
  124. let complete!:()=>void,fail!:(error:Error)=>void,signal!:AbortSignal
  125. const install=vi.fn(async(options:Parameters<BrowserInstaller['install']>[0])=>{
  126. signal=options.signal
  127. await new Promise<void>((resolve,reject)=>{complete=resolve;fail=reject;signal.addEventListener('abort',()=>reject(signal.reason),{once:true})})
  128. })
  129. const installer={probe:vi.fn(async()=>false),install},f=fixture({enabled:true},installer)
  130. f.probe.mockResolvedValue(false)
  131. return {...f,installer,complete:()=>complete(),fail:(error:Error)=>fail(error),signal:()=>signal}
  132. }
  133. describe('managed browser installation transport',()=>{
  134. it('returns authorized setup state when missing and keeps close idempotent',async()=>{
  135. const f=installationFixture()
  136. await expect(f.invoke({op:'status'})).resolves.toMatchObject({open:false,available:false,installation:{status:'missing'}})
  137. await expect(f.invoke({op:'close'})).resolves.toMatchObject({open:false,tabs:[],downloads:[]})
  138. await expect(f.invoke({op:'open'})).rejects.toMatchObject({code:'browser_unavailable'})
  139. expect(f.installer.install).not.toHaveBeenCalled()
  140. f.check.mockRejectedValueOnce(new Error('foreign grant'))
  141. await expect(f.invoke({op:'prepare'})).rejects.toThrow('foreign grant')
  142. expect(f.installer.install).not.toHaveBeenCalled()
  143. })
  144. it('prepares with the real Runtime after a view grant ends without authorizing a browser session',async()=>{
  145. vi.useFakeTimers()
  146. const viewGrant=randomUUID(),prepareGrant=randomUUID(),grants=new Map<string,'view'|'prepare'>([[viewGrant,'view']])
  147. const check=vi.fn(async(_scope:BrowserScope,grant?:string)=>{
  148. if(grant?grants.has(grant):[...grants.values()].some(role=>role!=='prepare'))return
  149. throw new Error('browser session has no active grant')
  150. })
  151. let complete!:()=>void,signal!:AbortSignal
  152. const installer:BrowserInstaller={probe:async()=>false,install:async options=>{
  153. signal=options.signal
  154. await new Promise<void>((resolve,reject)=>{complete=resolve;signal.addEventListener('abort',()=>reject(signal.reason),{once:true})})
  155. }}
  156. // Use the production BrowserRuntime and its default scope-only authorization.
  157. const browser=new RunnerBrowser({enabled:true},'/unused-no-browser-session',check,{installer});scopes.push(browser)
  158. const readState=vi.spyOn(browser.runtime,'status')
  159. const invoke=(op:string,grant:string)=>browser.invoke({scope,profile:'default',grantId:grant,op},Date.now()+30000,()=>{})
  160. await expect(invoke('status',viewGrant)).resolves.toMatchObject({available:false,installation:{status:'missing'}})
  161. grants.delete(viewGrant);grants.set(prepareGrant,'prepare')
  162. await expect(invoke('prepare',prepareGrant)).resolves.toMatchObject({open:false,tabs:[],downloads:[],available:false,installation:{status:'installing'}})
  163. expect(readState).not.toHaveBeenCalled()
  164. await vi.advanceTimersByTimeAsync(2100);expect(signal.aborted).toBe(false)
  165. // Installing a binary never grants permission to use or retain a page.
  166. await expect(browser.runtime.status(scope)).rejects.toThrow('no active grant')
  167. await expect(browser.runtime.open(scope)).rejects.toThrow('no active grant')
  168. complete();await vi.advanceTimersByTimeAsync(20);expect(browser.available).toBe(true)
  169. readState.mockClear()
  170. // Another holder can finish setup between the server's view and prepare RPC.
  171. await expect(invoke('prepare',prepareGrant)).resolves.toMatchObject({open:false,tabs:[],available:true,installation:{status:'ready'}})
  172. expect(readState).not.toHaveBeenCalled()
  173. await expect(invoke('status',prepareGrant)).rejects.toThrow('no active grant')
  174. grants.set(viewGrant,'view')
  175. await expect(invoke('status',viewGrant)).resolves.toMatchObject({open:false,available:true,installation:{status:'ready'}})
  176. grants.delete(viewGrant)
  177. await expect(browser.runtime.status(scope)).rejects.toThrow('no active grant')
  178. })
  179. it('does not refresh the idle lease of an existing session when prepare finds it ready',async()=>{
  180. vi.useFakeTimers();const f=fixture({enabled:true,idleTimeoutMs:60000})
  181. await f.invoke({op:'open'});await vi.advanceTimersByTimeAsync(55000)
  182. await expect(f.invoke({op:'prepare'})).resolves.toMatchObject({open:false,available:true,installation:{status:'ready'}})
  183. expect(f.runtime.status).not.toHaveBeenCalled()
  184. await vi.advanceTimersByTimeAsync(6000);await f.browser.sweep()
  185. expect(f.runtime.revoke).toHaveBeenCalledWith(scope)
  186. })
  187. it('returns checking immediately and queues one preparation behind a slow startup probe',async()=>{
  188. vi.useFakeTimers();const f=installationFixture();let finishProbe!:(value:boolean)=>void
  189. f.probe.mockImplementationOnce(()=>new Promise(resolve=>{finishProbe=resolve}))
  190. await expect(f.invoke({op:'status'},Date.now()+1000)).resolves.toMatchObject({available:false,installation:{status:'checking'}})
  191. await expect(f.invoke({op:'prepare'},Date.now()+1000)).resolves.toMatchObject({available:false,installation:{status:'checking'}})
  192. await f.invoke({op:'prepare'})
  193. await vi.advanceTimersByTimeAsync(31000)
  194. expect(f.installer.install).not.toHaveBeenCalled()
  195. await expect(f.invoke({op:'status'})).resolves.toMatchObject({installation:{status:'checking'}})
  196. finishProbe(false);await vi.advanceTimersByTimeAsync(20)
  197. expect(f.installer.install).toHaveBeenCalledTimes(1)
  198. expect(f.signal().aborted).toBe(false)
  199. f.complete();await vi.advanceTimersByTimeAsync(20);expect(f.browser.available).toBe(true)
  200. })
  201. it('does not install when a slow probe finds an existing browser or after disconnect',async()=>{
  202. const found=installationFixture();let finishFound!:(value:boolean)=>void
  203. found.probe.mockImplementationOnce(()=>new Promise(resolve=>{finishFound=resolve}))
  204. await expect(found.invoke({op:'prepare'})).resolves.toMatchObject({installation:{status:'checking'}})
  205. finishFound(true);await found.browser.ready
  206. await vi.waitFor(()=>expect(found.browser.available).toBe(true))
  207. expect(found.installer.install).not.toHaveBeenCalled()
  208. const disconnected=installationFixture();let finishDisconnected!:(value:boolean)=>void
  209. disconnected.probe.mockImplementationOnce(()=>new Promise(resolve=>{finishDisconnected=resolve}))
  210. await disconnected.invoke({op:'prepare'});await disconnected.browser.disconnect()
  211. expect(disconnected.browser.installation.status).toBe('failed')
  212. finishDisconnected(false);await disconnected.browser.ready
  213. expect(disconnected.browser.installation.status).toBe('failed')
  214. expect(disconnected.installer.install).not.toHaveBeenCalled()
  215. })
  216. it('cancels an open request that is still waiting for the startup check',async()=>{
  217. const f=installationFixture();let finishProbe!:(value:boolean)=>void
  218. f.probe.mockImplementationOnce(()=>new Promise(resolve=>{finishProbe=resolve}))
  219. const result=f.invoke({op:'open'}).catch(error=>error)
  220. await f.invoke({op:'status'});await f.browser.disconnect()
  221. expect(await result).toBeInstanceOf(Error)
  222. finishProbe(true);await f.browser.ready;expect(f.runtime.open).not.toHaveBeenCalled()
  223. })
  224. it('deduplicates preparation and retains the grant beyond an individual RPC deadline',async()=>{
  225. vi.useFakeTimers();const f=installationFixture()
  226. await expect(f.invoke({op:'prepare'},Date.now()+1000)).resolves.toMatchObject({available:false,installation:{status:'installing'}})
  227. await f.invoke({op:'prepare'})
  228. expect(f.installer.install).toHaveBeenCalledTimes(1)
  229. await vi.advanceTimersByTimeAsync(4000)
  230. expect(f.signal().aborted).toBe(false);expect(f.check).toHaveBeenLastCalledWith(scope,grantId)
  231. f.complete();await vi.advanceTimersByTimeAsync(20)
  232. await expect(f.invoke({op:'status'})).resolves.toMatchObject({available:true,installation:{status:'ready'}})
  233. })
  234. it('keeps other authorized holders when one grant is revoked',async()=>{
  235. vi.useFakeTimers();const f=installationFixture(),otherGrant=randomUUID()
  236. await f.invoke({op:'prepare'});await f.invoke({op:'prepare',grantId:otherGrant})
  237. f.check.mockImplementation(async(_scope,grant)=>{if(grant===grantId)throw new Error('revoked')})
  238. await vi.advanceTimersByTimeAsync(2100);expect(f.signal().aborted).toBe(false)
  239. f.complete();await vi.advanceTimersByTimeAsync(20)
  240. expect(f.browser.available).toBe(true)
  241. })
  242. it('cancels when all holders lose authorization and requires an explicit retry',async()=>{
  243. vi.useFakeTimers();const f=installationFixture();await f.invoke({op:'prepare'})
  244. f.check.mockRejectedValue(new Error('revoked'));await vi.advanceTimersByTimeAsync(2100)
  245. expect(f.signal().aborted).toBe(true);expect(f.browser.installation.status).toBe('failed')
  246. f.check.mockResolvedValue(undefined);await f.invoke({op:'prepare'})
  247. expect(f.installer.install).toHaveBeenCalledTimes(1)
  248. await f.invoke({op:'prepare',retry:true});expect(f.installer.install).toHaveBeenCalledTimes(2)
  249. f.complete();await vi.advanceTimersByTimeAsync(20);expect(f.browser.available).toBe(true)
  250. })
  251. it('closing one holder does not cancel another holder, while disconnect cancels the job',async()=>{
  252. const f=installationFixture(),otherGrant=randomUUID()
  253. await f.invoke({op:'prepare'});await f.invoke({op:'prepare',grantId:otherGrant});await f.invoke({op:'close'})
  254. expect(f.signal().aborted).toBe(false)
  255. await f.browser.disconnect();expect(f.signal().aborted).toBe(true);expect(f.browser.available).toBe(false)
  256. expect(f.browser.installation.status).toBe('failed')
  257. })
  258. it('does not start a late preparation after disconnect interrupts authorization',async()=>{
  259. const f=installationFixture();let authorize!:()=>void
  260. f.check.mockImplementationOnce(()=>new Promise(resolve=>{authorize=resolve}))
  261. const result=f.invoke({op:'prepare'}).catch(error=>error)
  262. await vi.waitFor(()=>expect(f.check).toHaveBeenCalled());await f.browser.disconnect()
  263. authorize();expect(await result).toBeInstanceOf(Error)
  264. expect(f.installer.install).not.toHaveBeenCalled()
  265. })
  266. it('sanitizes failure details and never runs installation while disabled',async()=>{
  267. const f=installationFixture();await f.invoke({op:'prepare'});await vi.waitFor(()=>expect(f.installer.install).toHaveBeenCalled());f.fail(new Error('token=secret /Users/personal'))
  268. await vi.waitFor(()=>expect(f.browser.installation.status).toBe('failed'))
  269. expect(JSON.stringify(f.browser.installation)).not.toMatch(/secret|personal/)
  270. const disabled=fixture({enabled:false},f.installer)
  271. await expect(disabled.invoke({op:'status'})).resolves.toMatchObject({enabled:false,available:false,installation:{status:'missing'}})
  272. await expect(disabled.invoke({op:'prepare'})).rejects.toMatchObject({code:'browser_unavailable'})
  273. expect(f.installer.install).toHaveBeenCalledTimes(1)
  274. })
  275. })