browserRuntime.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  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()=>{})
  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}},...extra})
  42. runtimes.push(runtime);return {runtime,launcher,contexts,directories,options,root,proxyAuthorize,proxyClose}
  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('also locks a temporary environment across runtime instances',async()=>{
  74. const temporary={...scope,profile:'temporary' as const},a=await fixture();await a.runtime.open(temporary)
  75. const b=await fixture({root:a.root})
  76. await expect(b.runtime.open(temporary)).rejects.toMatchObject({code:'browser_profile_busy'})
  77. await a.runtime.close(temporary);await b.runtime.open(temporary)
  78. expect(b.contexts).toHaveLength(1)
  79. })
  80. it('recovers a same-host dead-process lock but refuses unknown locks',async()=>{
  81. const f=await fixture();await f.runtime.open(scope)
  82. const name=(await readdir(join(f.root,'locks')))[0]!,lock=join(f.root,'locks',name)
  83. await f.runtime.close(scope);await mkdir(lock)
  84. await expect(f.runtime.open(scope)).rejects.toMatchObject({code:'browser_profile_busy'})
  85. await writeFile(join(lock,'owner.json'),JSON.stringify({pid:2147483647,host:hostname()}))
  86. await f.runtime.open(scope);expect(f.contexts).toHaveLength(2)
  87. })
  88. it('separates owners and caps concurrent contexts',async()=>{
  89. const f=await fixture({maxSessions:2});await f.runtime.open(scope)
  90. await f.runtime.open({...scope,ownerKey:'account-b'})
  91. expect(f.directories[0]).not.toBe(f.directories[1])
  92. await expect(f.runtime.open({...scope,environmentId:'another'})).rejects.toMatchObject({code:'browser_capacity'})
  93. })
  94. it('deduplicates successful and uncertain operations, rejecting ID conflicts',async()=>{
  95. const f=await fixture(),state=await f.runtime.open(scope)
  96. const command=op(state.generation,'go-once',{kind:'navigate',url:'https://example.com/'})
  97. await Promise.all([f.runtime.execute(scope,command),f.runtime.execute(scope,command)])
  98. expect(f.contexts[0]!.tab.visits).toEqual(['https://example.com/'])
  99. await expect(f.runtime.execute(scope,{...command,action:{kind:'navigate',url:'https://other.example/'}})).rejects.toMatchObject({code:'browser_operation_conflict'})
  100. })
  101. it('does not exhaust the mutation journal during long-running screenshot polling',async()=>{
  102. const f=await fixture(),state=await f.runtime.open(scope)
  103. for(let index=0;index<1100;index++)await f.runtime.execute(scope,op(state.generation,'frame-'+index,{kind:'screenshot'}))
  104. await f.runtime.execute(scope,op(state.generation,'action-after-polling',{kind:'text',text:'still active'}))
  105. expect(f.contexts[0]!.tab.keyboard.insertText).toHaveBeenCalledWith('still active')
  106. })
  107. it('drains old actions before takeover without closing the login context',async()=>{
  108. const f=await fixture(),state=await f.runtime.open(scope),gate=deferred()
  109. f.contexts[0]!.tab.block=gate.promise
  110. const running=f.runtime.execute(scope,op(state.generation,'old',{kind:'navigate',url:'https://example.com/'})).catch(error=>error)
  111. await vi.waitFor(()=>expect(f.contexts[0]!.tab.visits).toHaveLength(1))
  112. const queued=f.runtime.execute(scope,op(state.generation,'queued',{kind:'text',text:'must not type'})).catch(error=>error)
  113. let paused=false;const takeover=f.runtime.pause(scope).then(value=>{paused=true;return value})
  114. await new Promise(resolve=>setTimeout(resolve,10));expect(paused).toBe(false)
  115. gate.resolve();const next=await takeover
  116. expect(await running).toMatchObject({code:'browser_generation_expired'})
  117. expect(await queued).toMatchObject({code:'browser_generation_expired'})
  118. expect(f.contexts[0]!.tab.keyboard.insertText).not.toHaveBeenCalled();expect(f.contexts[0]!.closed).toBe(false)
  119. expect(next.generation).toBeGreaterThan(state.generation)
  120. await f.runtime.execute(scope,op(next.generation,'human',{kind:'text',text:'login'}))
  121. expect(f.contexts[0]!.tab.keyboard.insertText).toHaveBeenCalledWith('login')
  122. })
  123. it('rechecks each caller authorization after queueing and does not replace network authority with a viewer',async()=>{
  124. const f=await fixture(),task=vi.fn(async()=>{}),viewer=vi.fn(async()=>{throw new Error('viewer expired')})
  125. const state=await f.runtime.open(scope,{authorize:task})
  126. await f.runtime.execute(scope,op(state.generation,'observe',{kind:'screenshot'}),{authorize:async()=>{}})
  127. await f.proxyAuthorize[0]!();expect(task).toHaveBeenCalled()
  128. await expect(f.runtime.execute(scope,op(state.generation,'forbidden',{kind:'text',text:'x'}),{authorize:viewer})).rejects.toThrow('viewer expired')
  129. expect(f.contexts[0]!.tab.keyboard.insertText).not.toHaveBeenCalled()
  130. })
  131. it('fences a launch that finishes after revocation and releases its lock afterward',async()=>{
  132. const ready=deferred(),finish=deferred<BrowserContext>(),context=new FakeContext()
  133. const launcher:BrowserLauncher={launchPersistentContext:async()=>{ready.resolve();return finish.promise}}
  134. const f=await fixture({launcher}),opening=f.runtime.open(scope).catch(error=>error)
  135. await ready.promise;const closing=f.runtime.revoke(scope)
  136. finish.resolve(context as unknown as BrowserContext)
  137. expect(await opening).toMatchObject({code:'browser_generation_expired'});expect((await closing).open).toBe(false);expect(context.closed).toBe(true)
  138. })
  139. })
  140. describe('Runner browser interactions and files',()=>{
  141. it('requires fresh element refs and accepts only resolved upload bytes',async()=>{
  142. const resolveUpload=vi.fn(async()=>({name:'../../report.txt',mimeType:'text/plain',buffer:Buffer.from('approved')}))
  143. const f=await fixture({resolveUpload}),state=await f.runtime.open(scope),handle=element('file');f.contexts[0]!.tab.handles=[handle]
  144. const snapshot=await f.runtime.execute(scope,op(state.generation,'snapshot',{kind:'snapshot'})) as {snapshotId:string}
  145. 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'})
  146. await f.runtime.execute(scope,op(state.generation,'upload',{kind:'upload',snapshotId:snapshot.snapshotId,ref:'e1',fileId:'approved-id'}))
  147. expect(resolveUpload).toHaveBeenCalledWith(scope,'approved-id')
  148. expect(handle.setInputFiles).toHaveBeenCalledWith({name:'report.txt',mimeType:'text/plain',buffer:Buffer.from('approved')})
  149. await expect(f.runtime.execute(scope,op(state.generation,'stale',{kind:'click',snapshotId:snapshot.snapshotId,ref:'e1'}))).rejects.toMatchObject({code:'browser_snapshot_expired'})
  150. 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()
  151. })
  152. it('stores downloads by opaque ID and checks bytes and limits before exposing metadata',async()=>{
  153. const f=await fixture({maxDownloadBytes:10}),state=await f.runtime.open(scope)
  154. 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()=>{})})
  155. const good=download('hello');f.contexts[0]!.tab.emit('download',good as unknown as Download)
  156. let metadata:{id:string;name:string;size:number}[]=[]
  157. await vi.waitFor(async()=>{metadata=(await f.runtime.status(scope)).downloads;expect(metadata).toHaveLength(1)})
  158. const file=await f.runtime.readDownload(scope,metadata[0]!.id)
  159. expect(file.name).toBe('file.txt');expect(file.buffer.toString()).toBe('hello');expect(file.metadata.sha256).toHaveLength(64)
  160. await expect(f.runtime.readDownload(scope,'../../etc/passwd')).rejects.toMatchObject({code:'browser_download_missing'})
  161. const oversized=download('much too large');f.contexts[0]!.tab.emit('download',oversized as unknown as Download)
  162. await vi.waitFor(()=>expect(oversized.cancel).toHaveBeenCalled())
  163. expect((await f.runtime.status(scope)).downloads).toHaveLength(1)
  164. await f.runtime.close(scope);await expect(f.runtime.readDownload(scope,metadata[0]!.id)).rejects.toMatchObject({code:'browser_download_missing'})
  165. })
  166. it('blocks unsupported URL schemes and raw browser protocols',async()=>{
  167. const f=await fixture(),state=await f.runtime.open(scope)
  168. await expect(f.runtime.execute(scope,op(state.generation,'file',{kind:'navigate',url:'file:///etc/passwd'}))).rejects.toThrow('HTTP(S)')
  169. await expect(f.runtime.execute(scope,op(state.generation,'port',{kind:'navigate',url:'http://example.com:9222/json'}))).rejects.toThrow('HTTP(S)')
  170. const abort=vi.fn(async()=>{}),proceed=vi.fn(async()=>{})
  171. await f.contexts[0]!.routeCallback!({request:()=>({url:()=> 'ftp://example.com/'}),abort,continue:proceed})
  172. expect(abort).toHaveBeenCalled();expect(proceed).not.toHaveBeenCalled()
  173. })
  174. })