browserRuntime.test.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. // @vitest-environment node
  2. import {afterEach,describe,expect,it,vi} from 'vitest'
  3. import {EventEmitter} from 'node:events'
  4. import {mkdir,mkdtemp,readFile,readdir,realpath,writeFile,stat,rm} from 'node:fs/promises'
  5. import {hostname,tmpdir} from 'node:os'
  6. import {join} from 'node:path'
  7. import {Readable} from 'node:stream'
  8. import type {BrowserContext,Download,ElementHandle} from 'playwright'
  9. import {BrowserRuntime,type BrowserScope,type BrowserLauncher,type BrowserOperation} from '../../src/runner/browser/runtime.js'
  10. const scope:BrowserScope={ownerKey:'account-a',environmentId:'bot-a',profile:'persistent'}
  11. const deferred=<T=void>()=>{let resolve!:(value:T)=>void;const promise=new Promise<T>(yes=>{resolve=yes});return {promise,resolve}}
  12. class FakePage extends EventEmitter {
  13. closed=false;address='about:blank';visits:string[]=[];block?:Promise<void>;handles:ElementHandle<Element>[]=[]
  14. mouse={click:vi.fn(async()=>{}),wheel:vi.fn(async()=>{})}
  15. keyboard={press:vi.fn(async()=>{}),insertText:vi.fn(async()=>{})}
  16. url(){return this.address}isClosed(){return this.closed}async title(){return 'Page'}mainFrame(){return this}
  17. async goto(url:string){this.visits.push(url);if(this.block)await this.block;if(this.closed)throw new Error('Target closed');this.address=url;this.emit('framenavigated',this)}
  18. async goBack(){}async goForward(){}async reload(){}async bringToFront(){}
  19. async close(){if(!this.closed){this.closed=true;this.emit('close')}}
  20. async screenshot(){return Buffer.from('png')}
  21. async $$(){return this.handles}
  22. locator(){return {innerText:async()=> 'A page'}}
  23. }
  24. class FakeContext extends EventEmitter {
  25. tab=new FakePage();all=[this.tab];closed=false;routeCallback?:Function
  26. setDefaultTimeout(){}setDefaultNavigationTimeout(){}async clearPermissions(){}
  27. async route(_match:string,callback:Function){this.routeCallback=callback}
  28. async routeWebSocket(){}
  29. pages(){return this.all}async newPage(){const page=new FakePage();this.all.push(page);this.emit('page',page);return page}
  30. async close(){if(this.closed)return;this.closed=true;for(const page of this.all)await page.close();this.emit('close')}
  31. }
  32. function element(type='text'){
  33. return {evaluate:vi.fn(async(fn:Function)=>String(fn).includes('getComputedStyle')?{tag:'input',type,text:'',label:'',placeholder:'',disabled:false}:String(fn).includes('HTMLInputElement')?type==='file':true),dispose:vi.fn(async()=>{}),click:vi.fn(async()=>{}),fill:vi.fn(async()=>{}),setInputFiles:vi.fn(async()=>{})} as unknown as ElementHandle<Element>
  34. }
  35. const roots:string[]=[],runtimes:BrowserRuntime[]=[]
  36. async function fixture(extra:Partial<ConstructorParameters<typeof BrowserRuntime>[0]>={}){
  37. const root=await realpath(await mkdtemp(join(tmpdir(),'yaoyao-browser-test-')));roots.push(root)
  38. const contexts:FakeContext[]=[],directories:string[]=[],options:unknown[]=[]
  39. const launcher:BrowserLauncher={launchPersistentContext:vi.fn(async(directory,settings)=>{directories.push(directory);options.push(settings);const context=new FakeContext();contexts.push(context);return context as unknown as BrowserContext})}
  40. const proxyAuthorize:(()=>Promise<void>)[]=[],proxyClose=vi.fn(async()=>{}),proxyDisconnect=vi.fn()
  41. const runtime=new BrowserRuntime({root,enabled:true,launcher,proxyFactory:async authorize=>{proxyAuthorize.push(authorize);return {serverURL:'http://127.0.0.1:12345',username:'test',password:'test',close:proxyClose,disconnect:proxyDisconnect}},...extra})
  42. runtimes.push(runtime);return {runtime,launcher,contexts,directories,options,root,proxyAuthorize,proxyClose,proxyDisconnect}
  43. }
  44. function op(generation:number,operationId:string,action:BrowserOperation['action']):BrowserOperation{return {generation,operationId,action}}
  45. afterEach(async()=>{for(const runtime of runtimes.splice(0))await runtime.shutdown();for(const root of roots.splice(0))await rm(root,{recursive:true,force:true})})
  46. describe('Runner browser lifecycle and fencing',()=>{
  47. it('is disabled by default and does not start Chromium',async()=>{
  48. const f=await fixture({enabled:undefined})
  49. await expect(f.runtime.open(scope)).rejects.toMatchObject({code:'browser_disabled'})
  50. expect(f.launcher.launchPersistentContext).not.toHaveBeenCalled()
  51. })
  52. it('shares one writer per environment and retains only persistent profiles on close',async()=>{
  53. const f=await fixture()
  54. const [a,b]=await Promise.all([f.runtime.open(scope),f.runtime.open(scope)])
  55. expect(a.generation).toBe(b.generation);expect(f.contexts).toHaveLength(1)
  56. await writeFile(join(f.directories[0]!,'login'),'cookie-data')
  57. await expect(f.runtime.open({...scope,profile:'temporary'})).rejects.toMatchObject({code:'browser_profile_busy'})
  58. await f.runtime.close(scope)
  59. expect(await readFile(join(f.directories[0]!,'login'),'utf8')).toBe('cookie-data')
  60. await f.runtime.open(scope);expect(f.directories[1]).toBe(f.directories[0])
  61. const temporary={...scope,environmentId:'task-a',profile:'temporary' as const}
  62. await f.runtime.open(temporary);const temporaryDirectory=f.directories[2]!
  63. await f.runtime.close(temporary);await expect(stat(temporaryDirectory)).rejects.toMatchObject({code:'ENOENT'})
  64. expect(f.options[0]).toMatchObject({chromiumSandbox:true,serviceWorkers:'block',proxy:{bypass:'<-loopback>'}})
  65. })
  66. it('locks the same persistent profile across runtime instances',async()=>{
  67. const a=await fixture();await a.runtime.open(scope)
  68. const b=await fixture({root:a.root})
  69. await expect(b.runtime.open(scope)).rejects.toMatchObject({code:'browser_profile_busy'})
  70. await a.runtime.close(scope);await b.runtime.open(scope)
  71. expect(b.contexts).toHaveLength(1)
  72. })
  73. it('rejects a delayed old network authorization even after the parked page is resumed',async()=>{
  74. const f=await fixture(),entered=deferred(),gate=deferred();let delay=false
  75. await f.runtime.open(scope,{authorize:async()=>{if(delay){entered.resolve();await gate.promise}}})
  76. delay=true
  77. const pending=f.proxyAuthorize[0]!().catch(error=>error)
  78. await entered.promise
  79. await f.runtime.park(scope,{authorize:async()=>{}})
  80. await f.runtime.resume(scope,{authorize:async()=>{}})
  81. gate.resolve()
  82. expect(await pending).toMatchObject({code:'browser_generation_expired'})
  83. await expect(f.proxyAuthorize[0]!()).resolves.toBeUndefined()
  84. expect(f.contexts[0]!.closed).toBe(false)
  85. })
  86. it('keeps same-generation network checks valid across consecutive operations and fences handoff connections',async()=>{
  87. const f=await fixture(),entered=deferred(),gate=deferred();let delay=false
  88. const state=await f.runtime.open(scope,{authorize:async()=>{if(delay){entered.resolve();await gate.promise}}})
  89. delay=true
  90. const pending=f.proxyAuthorize[0]!()
  91. await entered.promise
  92. await f.runtime.execute(scope,op(state.generation,'same-controller',{kind:'text',text:'next'}),{authorize:async()=>{}})
  93. gate.resolve();await expect(pending).resolves.toBeUndefined()
  94. expect(f.proxyDisconnect).not.toHaveBeenCalled()
  95. await f.runtime.pause(scope,{authorize:async()=>{}})
  96. expect(f.proxyDisconnect).toHaveBeenCalledOnce()
  97. expect(f.contexts[0]!.closed).toBe(false)
  98. })
  99. it('parks the same Bot page while fencing old actions and blocking network until new control',async()=>{
  100. const f=await fixture(),task={authorize:vi.fn(async()=>{})},human={authorize:vi.fn(async()=>{})}
  101. const first=await f.runtime.open(scope,task)
  102. await f.runtime.execute(scope,op(first.generation,'page',{kind:'navigate',url:'https://example.com/form'}),task)
  103. const page=f.contexts[0]!.tab,handle=element();page.handles=[handle]
  104. await f.runtime.execute(scope,op(first.generation,'refs',{kind:'snapshot'}),task)
  105. const other={...scope,environmentId:'bot-b'}
  106. await f.runtime.open(other,human)
  107. const parked=await f.runtime.park(scope,{authorize:async()=>{}})
  108. task.authorize.mockRejectedValue(new Error('old task revoked'))
  109. expect(parked).toMatchObject({open:true,tabs:[{url:'https://example.com/form'}]})
  110. expect(f.contexts[0]!.tab).toBe(page);expect(f.contexts[0]!.closed).toBe(false)
  111. expect(handle.dispose).toHaveBeenCalled();expect(f.proxyDisconnect).toHaveBeenCalledOnce()
  112. await expect(f.proxyAuthorize[0]!()).rejects.toMatchObject({code:'browser_parked'})
  113. await expect(f.proxyAuthorize[1]!()).resolves.toBeUndefined()
  114. await expect(f.runtime.execute(scope,op(first.generation,'late',{kind:'text',text:'old'}))).rejects.toMatchObject({code:'browser_generation_expired'})
  115. await expect(f.runtime.execute(scope,op(parked.generation,'idle-mutation',{kind:'text',text:'idle'}))).rejects.toMatchObject({code:'browser_parked'})
  116. await expect(f.runtime.execute(scope,op(parked.generation,'preview',{kind:'screenshot'}))).resolves.toMatchObject({mimeType:'image/png'})
  117. const next=await f.runtime.resume(scope,human)
  118. await expect(f.proxyAuthorize[0]!()).resolves.toBeUndefined()
  119. await f.runtime.execute(scope,op(next.generation,'fresh',{kind:'text',text:'new'}),human)
  120. expect(page.keyboard.insertText).toHaveBeenCalledExactlyOnceWith('new')
  121. expect(f.contexts).toHaveLength(2);expect(next.tabs).toEqual(parked.tabs)
  122. await expect(f.runtime.execute(scope,op(next.generation,'expired-grant',{kind:'text',text:'old'}),task)).rejects.toThrow('old task revoked')
  123. })
  124. it('also locks a temporary environment across runtime instances',async()=>{
  125. const temporary={...scope,profile:'temporary' as const},a=await fixture();await a.runtime.open(temporary)
  126. const b=await fixture({root:a.root})
  127. await expect(b.runtime.open(temporary)).rejects.toMatchObject({code:'browser_profile_busy'})
  128. await a.runtime.close(temporary);await b.runtime.open(temporary)
  129. expect(b.contexts).toHaveLength(1)
  130. })
  131. it('recovers a same-host dead-process lock but refuses unknown locks',async()=>{
  132. const f=await fixture();await f.runtime.open(scope)
  133. const name=(await readdir(join(f.root,'locks')))[0]!,lock=join(f.root,'locks',name)
  134. await f.runtime.close(scope);await mkdir(lock)
  135. await expect(f.runtime.open(scope)).rejects.toMatchObject({code:'browser_profile_busy'})
  136. await writeFile(join(lock,'owner.json'),JSON.stringify({pid:2147483647,host:hostname()}))
  137. await f.runtime.open(scope);expect(f.contexts).toHaveLength(2)
  138. })
  139. it('separates owners and caps concurrent contexts',async()=>{
  140. const f=await fixture({maxSessions:2});await f.runtime.open(scope)
  141. await f.runtime.open({...scope,ownerKey:'account-b'})
  142. expect(f.directories[0]).not.toBe(f.directories[1])
  143. await f.runtime.park(scope)
  144. await expect(f.runtime.open({...scope,environmentId:'another'})).rejects.toMatchObject({code:'browser_capacity'})
  145. expect((await f.runtime.status(scope)).open).toBe(true)
  146. await f.runtime.close(scope);await f.runtime.open({...scope,environmentId:'another'})
  147. expect(f.contexts[1]!.closed).toBe(false)
  148. })
  149. it('deduplicates successful and uncertain operations, rejecting ID conflicts',async()=>{
  150. const f=await fixture(),state=await f.runtime.open(scope)
  151. const command=op(state.generation,'go-once',{kind:'navigate',url:'https://example.com/'})
  152. await Promise.all([f.runtime.execute(scope,command),f.runtime.execute(scope,command)])
  153. expect(f.contexts[0]!.tab.visits).toEqual(['https://example.com/'])
  154. await expect(f.runtime.execute(scope,{...command,action:{kind:'navigate',url:'https://other.example/'}})).rejects.toMatchObject({code:'browser_operation_conflict'})
  155. })
  156. it('does not exhaust the mutation journal during long-running screenshot polling',async()=>{
  157. const f=await fixture(),state=await f.runtime.open(scope)
  158. for(let index=0;index<1100;index++)await f.runtime.execute(scope,op(state.generation,'frame-'+index,{kind:'screenshot'}))
  159. await f.runtime.execute(scope,op(state.generation,'action-after-polling',{kind:'text',text:'still active'}))
  160. expect(f.contexts[0]!.tab.keyboard.insertText).toHaveBeenCalledWith('still active')
  161. })
  162. it('drains old actions before takeover without closing the login context',async()=>{
  163. const f=await fixture(),state=await f.runtime.open(scope),gate=deferred()
  164. f.contexts[0]!.tab.block=gate.promise
  165. const running=f.runtime.execute(scope,op(state.generation,'old',{kind:'navigate',url:'https://example.com/'})).catch(error=>error)
  166. await vi.waitFor(()=>expect(f.contexts[0]!.tab.visits).toHaveLength(1))
  167. const queued=f.runtime.execute(scope,op(state.generation,'queued',{kind:'text',text:'must not type'})).catch(error=>error)
  168. let paused=false;const takeover=f.runtime.pause(scope).then(value=>{paused=true;return value})
  169. await new Promise(resolve=>setTimeout(resolve,10));expect(paused).toBe(false)
  170. gate.resolve();const next=await takeover
  171. expect(await running).toMatchObject({code:'browser_generation_expired'})
  172. expect(await queued).toMatchObject({code:'browser_generation_expired'})
  173. expect(f.contexts[0]!.tab.keyboard.insertText).not.toHaveBeenCalled();expect(f.contexts[0]!.closed).toBe(false)
  174. expect(next.generation).toBeGreaterThan(state.generation)
  175. await f.runtime.execute(scope,op(next.generation,'human',{kind:'text',text:'login'}))
  176. expect(f.contexts[0]!.tab.keyboard.insertText).toHaveBeenCalledWith('login')
  177. })
  178. it('rechecks each caller authorization after queueing and does not replace network authority with a viewer',async()=>{
  179. const f=await fixture(),task=vi.fn(async()=>{}),viewer=vi.fn(async()=>{throw new Error('viewer expired')})
  180. const state=await f.runtime.open(scope,{authorize:task})
  181. await f.runtime.execute(scope,op(state.generation,'observe',{kind:'screenshot'}),{authorize:async()=>{}})
  182. await f.proxyAuthorize[0]!();expect(task).toHaveBeenCalled()
  183. await expect(f.runtime.execute(scope,op(state.generation,'forbidden',{kind:'text',text:'x'}),{authorize:viewer})).rejects.toThrow('viewer expired')
  184. expect(f.contexts[0]!.tab.keyboard.insertText).not.toHaveBeenCalled()
  185. })
  186. it('fences a launch that finishes after revocation and releases its lock afterward',async()=>{
  187. const ready=deferred(),finish=deferred<BrowserContext>(),context=new FakeContext()
  188. const launcher:BrowserLauncher={launchPersistentContext:async()=>{ready.resolve();return finish.promise}}
  189. const f=await fixture({launcher}),opening=f.runtime.open(scope).catch(error=>error)
  190. await ready.promise;const closing=f.runtime.revoke(scope)
  191. finish.resolve(context as unknown as BrowserContext)
  192. expect(await opening).toMatchObject({code:'browser_generation_expired'});expect((await closing).open).toBe(false);expect(context.closed).toBe(true)
  193. })
  194. })
  195. describe('Runner browser interactions and files',()=>{
  196. it('requires fresh element refs and accepts only resolved upload bytes',async()=>{
  197. const resolveUpload=vi.fn(async()=>({name:'../../report.txt',mimeType:'text/plain',buffer:Buffer.from('approved')}))
  198. const f=await fixture({resolveUpload}),state=await f.runtime.open(scope),handle=element('file');f.contexts[0]!.tab.handles=[handle]
  199. const snapshot=await f.runtime.execute(scope,op(state.generation,'snapshot',{kind:'snapshot'})) as {snapshotId:string}
  200. await expect(f.runtime.execute(scope,op(state.generation,'bad-fill',{kind:'fill',snapshotId:snapshot.snapshotId,ref:'e1',text:'/etc/passwd'}))).rejects.toMatchObject({code:'browser_upload_required'})
  201. await f.runtime.execute(scope,op(state.generation,'upload',{kind:'upload',snapshotId:snapshot.snapshotId,ref:'e1',fileId:'approved-id'}))
  202. expect(resolveUpload).toHaveBeenCalledWith(scope,'approved-id')
  203. expect(handle.setInputFiles).toHaveBeenCalledWith({name:'report.txt',mimeType:'text/plain',buffer:Buffer.from('approved')})
  204. await expect(f.runtime.execute(scope,op(state.generation,'stale',{kind:'click',snapshotId:snapshot.snapshotId,ref:'e1'}))).rejects.toMatchObject({code:'browser_snapshot_expired'})
  205. await expect(f.runtime.execute(scope,{generation:state.generation,operationId:'path-upload',action:{kind:'upload',snapshotId:snapshot.snapshotId,ref:'e1',fileId:'approved-id',path:'/etc/passwd'}} as BrowserOperation)).rejects.toThrow()
  206. })
  207. it('stores downloads by opaque ID and checks bytes and limits before exposing metadata',async()=>{
  208. const f=await fixture({maxDownloadBytes:10}),state=await f.runtime.open(scope)
  209. const download=(data:string)=>({createReadStream:async()=>Readable.from([Buffer.from(data)]),suggestedFilename:()=> '../../file.txt',url:()=> 'https://example.com/file',cancel:vi.fn(async()=>{}),delete:vi.fn(async()=>{})})
  210. const good=download('hello');f.contexts[0]!.tab.emit('download',good as unknown as Download)
  211. let metadata:{id:string;name:string;size:number}[]=[]
  212. await vi.waitFor(async()=>{metadata=(await f.runtime.status(scope)).downloads;expect(metadata).toHaveLength(1)})
  213. const file=await f.runtime.readDownload(scope,metadata[0]!.id)
  214. expect(file.name).toBe('file.txt');expect(file.buffer.toString()).toBe('hello');expect(file.metadata.sha256).toHaveLength(64)
  215. await expect(f.runtime.readDownload(scope,'../../etc/passwd')).rejects.toMatchObject({code:'browser_download_missing'})
  216. const oversized=download('much too large');f.contexts[0]!.tab.emit('download',oversized as unknown as Download)
  217. await vi.waitFor(()=>expect(oversized.cancel).toHaveBeenCalled())
  218. expect((await f.runtime.status(scope)).downloads).toHaveLength(1)
  219. await f.runtime.close(scope);await expect(f.runtime.readDownload(scope,metadata[0]!.id)).rejects.toMatchObject({code:'browser_download_missing'})
  220. })
  221. it('blocks unsupported URL schemes and raw browser protocols',async()=>{
  222. const f=await fixture(),state=await f.runtime.open(scope)
  223. await expect(f.runtime.execute(scope,op(state.generation,'file',{kind:'navigate',url:'file:///etc/passwd'}))).rejects.toThrow('HTTP(S)')
  224. await expect(f.runtime.execute(scope,op(state.generation,'port',{kind:'navigate',url:'http://example.com:9222/json'}))).rejects.toThrow('HTTP(S)')
  225. const abort=vi.fn(async()=>{}),proceed=vi.fn(async()=>{})
  226. await f.contexts[0]!.routeCallback!({request:()=>({url:()=> 'ftp://example.com/'}),abort,continue:proceed})
  227. expect(abort).toHaveBeenCalled();expect(proceed).not.toHaveBeenCalled()
  228. })
  229. })