|
|
@@ -31,17 +31,18 @@ function fixture(enabled=true){
|
|
|
const auth={require:(ctx:any)=>({id:ctx.get('x-fixture-owner')||'owner'}),pushAuthorizationVersion:(owner:string)=>versions.get(owner)??1,isUserActive:(owner:string)=>!inactiveOwners.has(owner)} as unknown as LocalAuthStore
|
|
|
const nodes=new WorkspaceNodes(store,loadServerConfig({HERMES_YAOYAO_HOME:home}),{} as any)
|
|
|
nodes.sourceAllowed=(owner,node,profile)=>!blockedSources.has(`${owner}:${node}:${profile}`)
|
|
|
- let installation:BrowserInstallation|undefined
|
|
|
+ let installation:BrowserInstallation|undefined,retention=true
|
|
|
const states=new Map<string,BrowserState>(),calls:any[]=[],turns:BrowserTurn[]=[]
|
|
|
- let runnerId=randomUUID(),downloadReply:(payload:any)=>unknown=()=>{throw new Error('Unexpected download')}
|
|
|
+ let runnerId=randomUUID(),runnerEpoch=randomUUID(),downloadReply:(payload:any)=>unknown=()=>{throw new Error('Unexpected download')}
|
|
|
let beforeReply:((payload:any)=>Promise<void>)|undefined
|
|
|
+ let afterReply:((payload:any)=>Promise<void>)|undefined
|
|
|
const stateFor=(scope:BrowserScope)=>{
|
|
|
- const key=runnerId+':'+JSON.stringify(scope);let state=states.get(key)
|
|
|
+ const key=runnerId+':'+runnerEpoch+':'+JSON.stringify(scope);let state=states.get(key)
|
|
|
if(!state){state={open:false,generation:1,profile:scope.profile,tabs:[],downloads:[]};states.set(key,state)}
|
|
|
return state
|
|
|
}
|
|
|
let service:ManagedBrowsers
|
|
|
- const hub={browserRunner:()=>({id:runnerId}),browser:vi.fn(async(owner:string,agent:any,payload:any,authorize:()=>void)=>{
|
|
|
+ const hub={browserRunner:()=>({id:runnerId,browserEpoch:runnerEpoch}),supportsBrowserRetention:()=>retention,browser:vi.fn(async(owner:string,agent:any,payload:any,authorize:()=>void)=>{
|
|
|
authorize()
|
|
|
expect(service.allowed(runnerId,payload.scope,payload.grantId)).toBe(true)
|
|
|
calls.push({owner,agentId:agent.id,runnerId,...structuredClone(payload)})
|
|
|
@@ -52,7 +53,7 @@ function fixture(enabled=true){
|
|
|
if(installation?.status!=='failed'||payload.retry)installation={status:'installing',message:'正在下载浏览器',updatedAt:Date.now()}
|
|
|
}else if(payload.op==='open'){
|
|
|
if(!state.open){state.open=true;state.generation++;state.tabs=[{id:'tab',title:'Example',url:'about:blank',active:true}]}
|
|
|
- }else if(payload.op==='pause'||payload.op==='resume')state.generation++
|
|
|
+ }else if(payload.op==='pause'||payload.op==='resume'||payload.op==='park')state.generation++
|
|
|
else if(payload.op==='close'){state.open=false;state.tabs=[];state.generation++}
|
|
|
else if(payload.op==='download')result=downloadReply(payload)
|
|
|
else if(payload.op==='execute'){
|
|
|
@@ -63,8 +64,10 @@ function fixture(enabled=true){
|
|
|
result={ok:true,action:payload.operation.action}
|
|
|
}
|
|
|
}
|
|
|
+ const reply=result??structuredClone({...state,...(installation?{available:installation.status==='ready',installation}:{})})
|
|
|
+ if(afterReply)await afterReply(payload)
|
|
|
authorize()
|
|
|
- return result??structuredClone({...state,...(installation?{available:installation.status==='ready',installation}:{})})
|
|
|
+ return reply
|
|
|
})} as unknown as RunnerHub
|
|
|
service=new ManagedBrowsers(store,auth,nodes,hub)
|
|
|
const app=new Koa()
|
|
|
@@ -79,9 +82,13 @@ function fixture(enabled=true){
|
|
|
}
|
|
|
return {home,store,nodes,service,agent,base,app,hub,calls,versions,blockedSources,inactiveOwners,turn,
|
|
|
setInstallation:(status:BrowserInstallation['status'],message='浏览器环境',error?:string)=>{installation={status,message,error,updatedAt:Date.now()}},
|
|
|
+ setRetention:(value:boolean)=>{retention=value},
|
|
|
+ runnerClosed:(scope:BrowserScope)=>{const state=stateFor(scope);state.open=false;state.tabs=[];state.generation++},
|
|
|
setRunner:(id:string)=>{runnerId=id},runner:()=>runnerId,
|
|
|
+ setEpoch:(id:string)=>{runnerEpoch=id},
|
|
|
setDownload:(reply:(payload:any)=>unknown)=>{downloadReply=reply},
|
|
|
setBeforeReply:(value:typeof beforeReply)=>{beforeReply=value},
|
|
|
+ setAfterReply:(value:typeof afterReply)=>{afterReply=value},
|
|
|
async close(){for(const turn of turns)await turn.close();service.close();nodes.close();store.close();rmSync(home,{recursive:true,force:true})}}
|
|
|
}
|
|
|
const controlURL=(base:string,action='')=>`${base}/computer${action}?backend=managed-browser`
|
|
|
@@ -205,11 +212,11 @@ it('waits for one confirmed pause before returning duplicate takeover requests',
|
|
|
}finally{gate.resolve();await f.close()}
|
|
|
})
|
|
|
|
|
|
-it('joins duplicate takeovers that arrived while the previous task was closing',async()=>{
|
|
|
+it('joins duplicate takeovers that arrived while the previous task was parking',async()=>{
|
|
|
const f=fixture(),gate=defer(),entered=defer(),requestId=randomUUID()
|
|
|
try{
|
|
|
const task=f.turn();await task.value.call('managed_browser_open',{},'open')
|
|
|
- f.setBeforeReply(async payload=>{if(payload.op==='close'){entered.resolve();await gate.promise}})
|
|
|
+ f.setBeforeReply(async payload=>{if(payload.op==='park'){entered.resolve();await gate.promise}})
|
|
|
const closing=task.value.close();await entered.promise
|
|
|
const first=request(f.app.callback()).post(controlURL(f.base,'/take')).send({requestId}).then(value=>value)
|
|
|
const second=request(f.app.callback()).post(controlURL(f.base,'/take')).send({requestId}).then(value=>value)
|
|
|
@@ -453,10 +460,11 @@ it('publishes preparation and same-session takeover cards, preserving safe page
|
|
|
expect(resume.grantId).toBe(taskGrant.grantId)
|
|
|
expect(f.service.allowed(f.runner(),resume.scope,resume.grantId)).toBe(true)
|
|
|
await task.value.close()
|
|
|
- expect(cards.at(-1)).toMatchObject({id:active.id,status:'closed',title:'Example',url:'https://example.com/page'})
|
|
|
- const count=cards.length
|
|
|
- await request(f.app.callback()).post(controlURL(f.base,'/take')).send({requestId:randomUUID()}).expect(200)
|
|
|
- expect(cards).toHaveLength(count)
|
|
|
+ expect(cards.at(-1)).toMatchObject({id:active.id,status:'idle',title:'Example',url:'https://example.com/page'})
|
|
|
+ const takeAgain=await request(f.app.callback()).post(controlURL(f.base,'/take')).send({requestId:randomUUID()}).expect(200)
|
|
|
+ expect(cards.at(-1)).toMatchObject({status:'active',message:'人工控制中,交还后保留页面'})
|
|
|
+ await request(f.app.callback()).post(f.base+'/managed-browser/close').send({controlId:takeAgain.body.controlId,token:takeAgain.body.token}).expect(200)
|
|
|
+ expect(cards.at(-1)).toMatchObject({status:'closed',url:'https://example.com/page'})
|
|
|
}finally{await f.close()}
|
|
|
})
|
|
|
|
|
|
@@ -490,16 +498,16 @@ it('revokes an installing task immediately on cancellation, without late opening
|
|
|
}finally{await f.close()}
|
|
|
})
|
|
|
|
|
|
-it('retains the card through a task ending under human control and closes it only after giveback',async()=>{
|
|
|
+it('retains the card through a task ending under human control and parks it after giveback',async()=>{
|
|
|
const f=fixture(),cards:ManagedBrowserCard[]=[]
|
|
|
try{
|
|
|
const task=f.turn({onCard:card=>{cards.push(card)}})
|
|
|
await task.value.call('managed_browser_open',{url:'https://example.com/page'},'open')
|
|
|
const take=await request(f.app.callback()).post(controlURL(f.base,'/take')).send({requestId:randomUUID()}).expect(200)
|
|
|
await task.value.close()
|
|
|
- expect(cards.at(-1)).toMatchObject({status:'active',message:'人工控制中,交还后结束本轮会话'})
|
|
|
+ expect(cards.at(-1)).toMatchObject({status:'active',message:'人工控制中,交还后保留页面'})
|
|
|
await request(f.app.callback()).post(controlURL(f.base,'/giveback')).send({controlId:take.body.controlId,token:take.body.token}).expect(200)
|
|
|
- expect(cards.at(-1)).toMatchObject({status:'closed',url:'https://example.com/page'})
|
|
|
+ expect(cards.at(-1)).toMatchObject({status:'idle',url:'https://example.com/page'})
|
|
|
}finally{await f.close()}
|
|
|
})
|
|
|
|
|
|
@@ -514,8 +522,8 @@ it('keeps browser handoff and authorization cleanup working when the card observ
|
|
|
expect(f.service.allowed(f.runner(),taskGrant.scope,taskGrant.grantId)).toBe(false)
|
|
|
await expect((f.service as any).sweep()).resolves.toBeUndefined()
|
|
|
await request(f.app.callback()).post(controlURL(f.base,'/giveback')).send({controlId:take.body.controlId,token:take.body.token}).expect(200)
|
|
|
- expect(f.service.allowed(f.runner(),taskGrant.scope)).toBe(false)
|
|
|
- expect(f.calls.at(-1).op).toBe('close')
|
|
|
+ expect(f.service.allowed(f.runner(),taskGrant.scope)).toBe(true)
|
|
|
+ expect(f.calls.at(-1).op).toBe('park')
|
|
|
expect(observe).toHaveBeenCalled()
|
|
|
}finally{await f.close()}
|
|
|
})
|
|
|
@@ -535,3 +543,226 @@ it('retries an unchanged card after its persistence observer reports a failed sa
|
|
|
expect(observe).toHaveBeenCalledTimes(saved)
|
|
|
}finally{await f.close()}
|
|
|
})
|
|
|
+
|
|
|
+it('retains tabs across task boundaries without retaining old task authority or waking on status',async()=>{
|
|
|
+ const f=fixture(),cards:ManagedBrowserCard[]=[]
|
|
|
+ try{
|
|
|
+ const first=f.turn({onCard:card=>{cards.push(card)}})
|
|
|
+ const opened=await first.value.call('managed_browser_open',{url:'https://example.com/session'},'first') as BrowserState
|
|
|
+ const original=f.calls.find(call=>call.op==='open')
|
|
|
+ await first.value.close()
|
|
|
+ const park=f.calls.at(-1)
|
|
|
+ expect(park.op).toBe('park');expect(park.grantId).not.toBe(original.grantId)
|
|
|
+ expect(f.service.allowed(f.runner(),original.scope,original.grantId)).toBe(false)
|
|
|
+ expect(f.service.allowed(f.runner(),park.scope,park.grantId)).toBe(false)
|
|
|
+ expect(f.service.allowed(f.runner(),park.scope)).toBe(true)
|
|
|
+ expect(cards.at(-1)).toMatchObject({status:'idle',url:'https://example.com/session'})
|
|
|
+ const statusTurn=f.turn(),start=f.calls.length
|
|
|
+ expect(await statusTurn.value.call('managed_browser_state',{},'read')).toMatchObject({open:true,tabs:opened.tabs})
|
|
|
+ await statusTurn.value.close()
|
|
|
+ expect(f.calls.slice(start).map(call=>call.op)).toEqual(['status'])
|
|
|
+ const next=f.turn(),before=f.calls.length
|
|
|
+ await next.value.call('managed_browser_snapshot',{},'snapshot')
|
|
|
+ expect(f.calls.slice(before).map(call=>call.op)).toEqual(['resume','execute'])
|
|
|
+ expect(f.calls.at(-1).operation.generation).toBeGreaterThan(opened.generation)
|
|
|
+ expect(f.calls.at(-1).grantId).not.toBe(park.grantId)
|
|
|
+ await expect(first.value.call('managed_browser_state',{},'old')).rejects.toMatchObject({code:'browser_authorization_revoked'})
|
|
|
+ expect((await f.service.state('owner',f.agent.id)).tabs).toEqual(opened.tabs)
|
|
|
+ }finally{await f.close()}
|
|
|
+})
|
|
|
+
|
|
|
+it.each(['temporary','legacy','cancel'] as const)('recycles %s sessions instead of claiming they were retained',async kind=>{
|
|
|
+ const f=fixture(),cards:ManagedBrowserCard[]=[]
|
|
|
+ try{
|
|
|
+ if(kind==='legacy')f.setRetention(false)
|
|
|
+ const agent=kind==='temporary'?{...f.agent,temporaryGoalId:randomUUID()}:f.agent
|
|
|
+ if(kind==='temporary')f.store.put('owner','agent',agent.id,agent)
|
|
|
+ const task=f.turn({onCard:card=>{cards.push(card)}},'owner',agent)
|
|
|
+ await task.value.call('managed_browser_open',{},'open')
|
|
|
+ const opening=f.calls.find(call=>call.op==='open')
|
|
|
+ await task.value.close({retain:kind!=='cancel'})
|
|
|
+ expect(f.calls.at(-1).op).toBe('close')
|
|
|
+ expect(f.calls.some(call=>call.op==='park')).toBe(false)
|
|
|
+ expect(f.service.allowed(f.runner(),opening.scope)).toBe(false)
|
|
|
+ expect(cards.at(-1)?.status).toBe('closed')
|
|
|
+ if(kind==='legacy')expect((await f.service.state('owner',agent.id)).supportsRetention).toBe(false)
|
|
|
+ }finally{await f.close()}
|
|
|
+})
|
|
|
+
|
|
|
+it('keeps an independently controlled browser on task cancellation, then closes on giveback',async()=>{
|
|
|
+ const f=fixture()
|
|
|
+ try{
|
|
|
+ const task=f.turn();await task.value.call('managed_browser_open',{},'open')
|
|
|
+ const take=await request(f.app.callback()).post(controlURL(f.base,'/take')).send({requestId:randomUUID()}).expect(200)
|
|
|
+ const body={controlId:take.body.controlId,token:take.body.token},scope=f.calls[0].scope
|
|
|
+ task.controller.abort();await task.value.close()
|
|
|
+ expect(f.service.allowed(f.runner(),scope,take.body.controlId)).toBe(true)
|
|
|
+ await request(f.app.callback()).post(controlURL(f.base,'/giveback')).send(body).expect(200)
|
|
|
+ expect(f.calls.at(-1).op).toBe('close');expect(f.service.allowed(f.runner(),scope)).toBe(false)
|
|
|
+ }finally{await f.close()}
|
|
|
+})
|
|
|
+
|
|
|
+it('allows explicit close only for the current human holder after the task has ended',async()=>{
|
|
|
+ const f=fixture()
|
|
|
+ try{
|
|
|
+ const task=f.turn();await task.value.call('managed_browser_open',{},'open')
|
|
|
+ const take=await request(f.app.callback()).post(controlURL(f.base,'/take')).send({requestId:randomUUID()}).expect(200)
|
|
|
+ const body={controlId:take.body.controlId,token:take.body.token},scope=f.calls[0].scope
|
|
|
+ await request(f.app.callback()).post(f.base+'/managed-browser/close').send(body).expect(409)
|
|
|
+ await task.value.close()
|
|
|
+ await request(f.app.callback()).post(f.base+'/managed-browser/close').send({...body,token:'x'.repeat(43)}).expect(403)
|
|
|
+ await request(f.app.callback()).post(f.base+'/managed-browser/close').set('x-fixture-owner','other').send(body).expect(403)
|
|
|
+ const other=f.store.createAgent('owner',{name:'另一个 Bot',profile:'default'})
|
|
|
+ await request(f.app.callback()).post(`/api/app/agents/${other.id}/managed-browser/close`).send(body).expect(403)
|
|
|
+ const result=await request(f.app.callback()).post(f.base+'/managed-browser/close').send(body).expect(200)
|
|
|
+ expect(result.body).toMatchObject({mode:'off',canResume:false})
|
|
|
+ expect(result.body).not.toHaveProperty('controlId')
|
|
|
+ expect(f.service.allowed(f.runner(),scope)).toBe(false)
|
|
|
+ await request(f.app.callback()).post(controlURL(f.base,'/giveback')).send(body).expect(403)
|
|
|
+ }finally{await f.close()}
|
|
|
+})
|
|
|
+
|
|
|
+it.each([false,true])('handles expired human control with task present=%s without reviving its authority',async active=>{
|
|
|
+ const f=fixture()
|
|
|
+ try{
|
|
|
+ const task=active?f.turn():undefined
|
|
|
+ if(task)await task.value.call('managed_browser_open',{},'open')
|
|
|
+ const take=await request(f.app.callback()).post(controlURL(f.base,'/take')).send({requestId:randomUUID()}).expect(200),scope=f.calls[0].scope
|
|
|
+ vi.spyOn(Date,'now').mockReturnValue(take.body.expiresAt+1)
|
|
|
+ // The retained scope bridges expiry and the next server sweep; it grants no input.
|
|
|
+ expect(f.service.allowed(f.runner(),scope)).toBe(true)
|
|
|
+ expect(f.service.allowed(f.runner(),scope,take.body.controlId)).toBe(false)
|
|
|
+ await (f.service as any).sweep()
|
|
|
+ const status=await request(f.app.callback()).get(controlURL(f.base)).expect(200)
|
|
|
+ expect(status.body.mode).toBe(active?'pausing':'idle')
|
|
|
+ expect(f.calls.some(call=>call.op==='park')).toBe(!active)
|
|
|
+ expect(f.calls.some(call=>call.op==='resume')).toBe(false)
|
|
|
+ expect((await f.service.state('owner',f.agent.id)).open).toBe(true)
|
|
|
+ }finally{await f.close()}
|
|
|
+})
|
|
|
+
|
|
|
+it.each(['owner','logout','source','global'] as const)('revokes retained sessions and human authority when %s access is removed',async reason=>{
|
|
|
+ const f=fixture(),cards:ManagedBrowserCard[]=[]
|
|
|
+ try{
|
|
|
+ const task=f.turn({onCard:card=>{cards.push(card)}});await task.value.call('managed_browser_open',{},'open');await task.value.close()
|
|
|
+ const scope=f.calls[0].scope
|
|
|
+ if(reason==='owner')f.inactiveOwners.add('owner')
|
|
|
+ else if(reason==='logout')f.versions.set('owner',2)
|
|
|
+ else if(reason==='source')f.blockedSources.add('owner:local:default')
|
|
|
+ else saveHostTools(f.home,{managedBrowser:false})
|
|
|
+ expect(f.service.allowed(f.runner(),scope)).toBe(false)
|
|
|
+ await (f.service as any).sweep()
|
|
|
+ expect(cards.at(-1)?.status).toBe('closed')
|
|
|
+ expect(f.calls.filter(call=>call.op==='park')).toHaveLength(1)
|
|
|
+ }finally{await f.close()}
|
|
|
+})
|
|
|
+
|
|
|
+it('falls back to confirmed close when parking fails',async()=>{
|
|
|
+ const f=fixture(),cards:ManagedBrowserCard[]=[]
|
|
|
+ try{
|
|
|
+ const task=f.turn({onCard:card=>{cards.push(card)}});await task.value.call('managed_browser_open',{},'open')
|
|
|
+ f.setBeforeReply(async payload=>{if(payload.op==='park')throw new Error('park failed')})
|
|
|
+ await task.value.close()
|
|
|
+ expect(f.calls.slice(-2).map(call=>call.op)).toEqual(['park','close'])
|
|
|
+ expect(f.service.allowed(f.runner(),f.calls[0].scope)).toBe(false)
|
|
|
+ expect(cards.at(-1)?.status).toBe('closed')
|
|
|
+ }finally{await f.close()}
|
|
|
+})
|
|
|
+
|
|
|
+it('updates the retained card and drops its grant after Runner idle reclamation',async()=>{
|
|
|
+ const f=fixture(),cards:ManagedBrowserCard[]=[]
|
|
|
+ try{
|
|
|
+ const task=f.turn({onCard:card=>{cards.push(card)}});await task.value.call('managed_browser_open',{},'open');await task.value.close()
|
|
|
+ const scope=f.calls[0].scope,start=f.calls.length
|
|
|
+ f.runnerClosed(scope)
|
|
|
+ vi.spyOn(Date,'now').mockReturnValue(Date.now()+31000)
|
|
|
+ await (f.service as any).sweep()
|
|
|
+ await vi.waitFor(()=>expect(cards.at(-1)?.status).toBe('closed'))
|
|
|
+ expect(f.calls.slice(start).map(call=>call.op)).toEqual(['status'])
|
|
|
+ expect(f.service.allowed(f.runner(),scope)).toBe(false)
|
|
|
+ }finally{await f.close()}
|
|
|
+})
|
|
|
+
|
|
|
+it('parks a status-only turn when its human holder expired before normal completion',async()=>{
|
|
|
+ const f=fixture()
|
|
|
+ try{
|
|
|
+ const first=f.turn();await first.value.call('managed_browser_open',{},'open');await first.value.close()
|
|
|
+ const read=f.turn();await read.value.call('managed_browser_state',{},'read')
|
|
|
+ const take=await request(f.app.callback()).post(controlURL(f.base,'/take')).send({requestId:randomUUID()}).expect(200)
|
|
|
+ vi.spyOn(Date,'now').mockReturnValue(take.body.expiresAt+1)
|
|
|
+ await read.value.close()
|
|
|
+ expect(f.calls.filter(call=>call.op==='park')).toHaveLength(2)
|
|
|
+ expect(f.calls.at(-1).op).toBe('park')
|
|
|
+ expect((await request(f.app.callback()).get(controlURL(f.base)).expect(200)).body.mode).toBe('idle')
|
|
|
+ }finally{await f.close()}
|
|
|
+})
|
|
|
+
|
|
|
+it.each(['close','giveback'] as const)('serializes %s against the competing release action and a new takeover',async firstAction=>{
|
|
|
+ const f=fixture(),entered=defer(),gate=defer()
|
|
|
+ try{
|
|
|
+ const take=await request(f.app.callback()).post(controlURL(f.base,'/take')).send({requestId:randomUUID()}).expect(200)
|
|
|
+ const credentials={controlId:take.body.controlId,token:take.body.token}
|
|
|
+ const url=(action:'close'|'giveback')=>action==='close'?f.base+'/managed-browser/close':controlURL(f.base,'/giveback')
|
|
|
+ f.setBeforeReply(async payload=>{if(payload.op===(firstAction==='close'?'close':'park')){entered.resolve();await gate.promise}})
|
|
|
+ const first=request(f.app.callback()).post(url(firstAction)).send(credentials).then(value=>value)
|
|
|
+ await entered.promise
|
|
|
+ const other=request(f.app.callback()).post(url(firstAction==='close'?'giveback':'close')).send(credentials).then(value=>value)
|
|
|
+ let taken=false
|
|
|
+ const next=request(f.app.callback()).post(controlURL(f.base,'/take')).send({requestId:randomUUID()}).then(value=>{taken=true;return value})
|
|
|
+ await new Promise(resolve=>setTimeout(resolve,20));expect(taken).toBe(false)
|
|
|
+ gate.resolve()
|
|
|
+ expect((await first).status).toBe(200);expect((await other).status).toBe(403)
|
|
|
+ const current=await next
|
|
|
+ expect(current.status).toBe(200);expect(current.body.controlId).not.toBe(credentials.controlId)
|
|
|
+ expect(f.calls.at(-1).op).toBe('open')
|
|
|
+ }finally{gate.resolve();await f.close()}
|
|
|
+})
|
|
|
+
|
|
|
+it('waits for a background retained-state check before admitting the next task',async()=>{
|
|
|
+ const f=fixture(),entered=defer(),gate=defer()
|
|
|
+ try{
|
|
|
+ const first=f.turn();await first.value.call('managed_browser_open',{},'open');await first.value.close()
|
|
|
+ f.setBeforeReply(async payload=>{if(payload.op==='status'){entered.resolve();await gate.promise}})
|
|
|
+ vi.spyOn(Date,'now').mockReturnValue(Date.now()+31000)
|
|
|
+ await (f.service as any).sweep();await entered.promise
|
|
|
+ const next=f.turn(),pending=next.value.call('managed_browser_snapshot',{},'next')
|
|
|
+ gate.resolve()
|
|
|
+ await expect(pending).resolves.toMatchObject({ok:true})
|
|
|
+ expect(f.calls.slice(-2).map(call=>call.op)).toEqual(['resume','execute'])
|
|
|
+ }finally{gate.resolve();await f.close()}
|
|
|
+})
|
|
|
+
|
|
|
+it('ignores a late closed-state reply instead of deleting the newly opened retained session',async()=>{
|
|
|
+ const f=fixture(),entered=defer(),gate=defer();let held=false
|
|
|
+ try{
|
|
|
+ f.setAfterReply(async payload=>{if(payload.op==='status'&&!held){held=true;entered.resolve();await gate.promise}})
|
|
|
+ const oldStatus=f.service.state('owner',f.agent.id);await entered.promise
|
|
|
+ const take=await request(f.app.callback()).post(controlURL(f.base,'/take')).send({requestId:randomUUID()}).expect(200)
|
|
|
+ gate.resolve();await oldStatus
|
|
|
+ const current=await f.service.state('owner',f.agent.id)
|
|
|
+ expect(current).toMatchObject({open:true,generation:take.body.generation})
|
|
|
+ await request(f.app.callback()).post(controlURL(f.base,'/giveback')).send({controlId:take.body.controlId,token:take.body.token}).expect(200)
|
|
|
+ expect(f.calls.at(-1).op).toBe('park')
|
|
|
+ expect(f.service.allowed(f.runner(),f.calls[0].scope)).toBe(true)
|
|
|
+ }finally{gate.resolve();await f.close()}
|
|
|
+})
|
|
|
+
|
|
|
+it('accepts a restarted Runner generation after fencing the previous connection epoch',async()=>{
|
|
|
+ const f=fixture()
|
|
|
+ try{
|
|
|
+ const old=f.turn();await old.value.call('managed_browser_open',{},'old')
|
|
|
+ const taskGrant=f.calls.find(call=>call.op==='open')
|
|
|
+ const earlier=await request(f.app.callback()).post(controlURL(f.base,'/take')).send({requestId:randomUUID()}).expect(200)
|
|
|
+ await request(f.app.callback()).post(controlURL(f.base,'/giveback')).send({controlId:earlier.body.controlId,token:earlier.body.token}).expect(200)
|
|
|
+ const take=await request(f.app.callback()).post(controlURL(f.base,'/take')).send({requestId:randomUUID()}).expect(200)
|
|
|
+ await old.value.close()
|
|
|
+ f.setEpoch(randomUUID())
|
|
|
+ expect(f.service.allowed(f.runner(),taskGrant.scope,taskGrant.grantId)).toBe(false)
|
|
|
+ expect(f.service.allowed(f.runner(),taskGrant.scope,take.body.controlId)).toBe(false)
|
|
|
+ const start=f.calls.length,newTurn=f.turn(),state=await newTurn.value.call('managed_browser_open',{},'new') as BrowserState
|
|
|
+ expect(f.calls.slice(start).map(call=>call.op)).toEqual(['close','status','open','status'])
|
|
|
+ expect(state.open).toBe(true);expect(state.generation).toBeLessThan(take.body.generation)
|
|
|
+ await newTurn.value.close()
|
|
|
+ expect((await f.service.state('owner',f.agent.id)).open).toBe(true)
|
|
|
+ }finally{await f.close()}
|
|
|
+})
|