managedBrowser.test.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. import {afterEach,beforeEach,expect,it,vi} from 'vitest'
  2. import {flushPromises,mount} from '@vue/test-utils'
  3. import ComputerPanel from '@/components/workspace/ComputerPanel.vue'
  4. import Preview from '@/components/workspace/LocalVmChatPanel.vue'
  5. import Viewer from '@/views/ComputerViewerView.vue'
  6. import BrowserToolbar from '@/components/workspace/BrowserToolbar.vue'
  7. import {apiRequest} from '@/api/client'
  8. import type {ManagedBrowserState} from '@shared/managedBrowser'
  9. vi.mock('@/api/client',()=>({apiRequest:vi.fn()}))
  10. const route={params:{agentId:'bot'},query:{} as Record<string,unknown>}
  11. vi.mock('vue-router',()=>({useRoute:()=>route}))
  12. const request=vi.mocked(apiRequest)
  13. const agent={id:'bot',name:'竹儿',profile:'default',nodeId:'local'} as any
  14. const permission={supported:true,authorized:true,screen:true,accessibility:true,ready:true,fullAuthorized:true}
  15. const stubs={AppIcon:true,GrokAuthPanel:true}
  16. let enabled:boolean|undefined,state:ManagedBrowserState,controlled:boolean,canResume:boolean
  17. beforeEach(()=>{
  18. vi.spyOn(document,'hidden','get').mockReturnValue(false)
  19. route.query={};enabled=true;controlled=false;canResume=false
  20. state={enabled:true,available:true,supportsRetention:true,open:false,generation:4,profile:'temporary',tabs:[],downloads:[]}
  21. request.mockReset()
  22. request.mockImplementation(async(path,options)=>{
  23. const url=new URL(path,'http://fixture'),backend=url.searchParams.get('backend')
  24. if(url.pathname==='/api/app/agents')return {agents:[agent]} as any
  25. if(url.pathname.endsWith('/host-tools'))return {scriptMachine:true,serverComputer:true,cloud:true,vm:true,managedBrowser:enabled} as any
  26. if(url.pathname.endsWith('/desktop-environment'))return {hosts:[{id:'local',name:'服务器',online:true,local:permission}]} as any
  27. if(url.pathname.endsWith('/cloud-computer'))return {configured:true,running:true,connected:true} as any
  28. if(url.pathname.endsWith('/local-vm'))return {enabled:true,image:true,container:'running',ready:true} as any
  29. if(url.pathname.endsWith('/managed-browser'))return structuredClone(state) as any
  30. if(url.pathname.endsWith('/managed-browser/close')){controlled=false;state.open=false;state.tabs=[];return {mode:'off',backend:'managed-browser',generation:4} as any}
  31. if(url.pathname.endsWith('/managed-browser/action'))return {ok:true} as any
  32. if(url.pathname.endsWith('/frame'))return {id:'frame',data:backend,width:100,height:80,generation:4} as any
  33. if(url.pathname.includes('/computer')){
  34. if(options?.method==='POST'&&url.pathname.endsWith('/take')){
  35. controlled=true;if(!state.open)state.tabs=[{id:'tab',title:'示例页面',url:'https://example.com',active:true}];state.open=true
  36. }
  37. if(options?.method==='POST'&&url.pathname.endsWith('/giveback'))controlled=false
  38. return {mode:controlled?'human':backend==='managed-browser'&&!state.open?'off':'idle',backend:backend==='managed-browser'?'managed-browser':'local',hostName:'托管浏览器',generation:4,canResume,controlId:controlled?'control':undefined,token:'fixture-token'} as any
  39. }
  40. throw new Error(`Unexpected request ${path}`)
  41. })
  42. })
  43. afterEach(()=>{vi.useRealTimers();vi.restoreAllMocks();vi.clearAllMocks()})
  44. it.each([false,undefined])('keeps managed browsing absent with an off or older settings response (%s)',async(value)=>{
  45. enabled=value
  46. const wrapper=mount(ComputerPanel,{props:{agents:[agent],standalone:true},global:{stubs}})
  47. try{
  48. await flushPromises()
  49. expect(wrapper.find('option[value="managed-browser"]').exists()).toBe(false)
  50. expect(request.mock.calls.some(([path])=>path.includes('/managed-browser'))).toBe(false)
  51. expect(request.mock.calls.some(([,options])=>options?.method==='POST')).toBe(false)
  52. }finally{wrapper.unmount()}
  53. })
  54. it('shows the independent browser beside existing environments without opening it during preview',async()=>{
  55. const wrapper=mount(Preview,{props:{agents:[agent],isAdmin:true,active:true},global:{stubs}})
  56. try{
  57. await flushPromises()
  58. for(const key of ['desktop:local','cloud','vm','managed-browser'])expect(wrapper.find(`option[value="${key}"]`).exists()).toBe(true)
  59. await wrapper.get('[aria-label="显示的桌面"]').setValue('managed-browser');await flushPromises()
  60. expect(wrapper.get('.preview').text()).toContain('浏览器尚未打开')
  61. expect(wrapper.find('.preview img').exists()).toBe(false)
  62. expect(request.mock.calls.some(([path,options])=>path.includes('managed-browser')&&options?.method==='POST')).toBe(false)
  63. const section=wrapper.get('[aria-label="托管浏览器"]')
  64. await section.get('button').trigger('click')
  65. expect(wrapper.emitted('desktop')?.at(-1)).toEqual([agent,'managed-browser',undefined])
  66. expect(request.mock.calls.some(([path])=>path.includes('/computer/take'))).toBe(false)
  67. }finally{wrapper.unmount()}
  68. })
  69. it('requires takeover before navigation and routes browser actions and Linux shortcuts to the managed target',async()=>{
  70. const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend:'managed-browser',standalone:true},global:{stubs}})
  71. try{
  72. await flushPromises()
  73. expect(wrapper.text()).toContain('浏览器尚未打开')
  74. expect(request.mock.calls.some(([,options])=>options?.method==='POST')).toBe(false)
  75. expect(wrapper.get('[aria-label="浏览器地址"]').attributes('disabled')).toBeDefined()
  76. await wrapper.findAll('button').find(button=>button.text()==='打开并控制')!.trigger('click');await flushPromises()
  77. expect(wrapper.get('.computer-screen img').attributes('src')).toContain('base64,managed-browser')
  78. expect(request.mock.calls.find(([path])=>path.includes('/computer/take'))?.[0]).toContain('backend=managed-browser')
  79. await wrapper.get('[aria-label="浏览器地址"]').setValue('https://example.org/')
  80. await wrapper.get('.browser-toolbar form').trigger('submit');await flushPromises()
  81. expect(request).toHaveBeenCalledWith('/api/app/agents/bot/managed-browser/action',{method:'POST',body:expect.objectContaining({controlId:'control',token:'fixture-token',requestId:expect.any(String),generation:4,action:{kind:'navigate',url:'https://example.org/'}})})
  82. expect(request.mock.calls.some(([path])=>path.includes('/computer/browser')||path.endsWith('/browser'))).toBe(false)
  83. const action=request.mock.calls.find(([path])=>path.endsWith('/managed-browser/action'))?.[1]?.body
  84. expect(action).not.toHaveProperty('frameId')
  85. await wrapper.findAll('button').find(button=>button.text()==='键盘与操作')!.trigger('click')
  86. await wrapper.findAll('button').find(button=>button.text()==='全选')!.trigger('click');await flushPromises()
  87. expect(request).toHaveBeenCalledWith('/api/app/agents/bot/computer/input?backend=managed-browser',{method:'POST',body:expect.objectContaining({generation:4,frameId:'frame',action:{kind:'key',key:'a',modifiers:['ctrl']}})})
  88. await wrapper.get('.computer-screen img').trigger('keydown',{key:'c',metaKey:true});await flushPromises()
  89. expect(request).toHaveBeenCalledWith('/api/app/agents/bot/computer/input?backend=managed-browser',{method:'POST',body:expect.objectContaining({action:{kind:'key',key:'c',modifiers:['ctrl']}})})
  90. await wrapper.findAll('button').find(button=>button.text()==='结束接管')!.trigger('click');await flushPromises()
  91. expect(request.mock.calls.find(([path])=>path.includes('/giveback'))?.[0]).toContain('backend=managed-browser')
  92. }finally{wrapper.unmount()}
  93. })
  94. it('returns control and hides its viewer while keeping the same browser page open',async()=>{
  95. state.open=true;state.tabs=[{id:'retained',title:'跨任务保留的页面',url:'https://example.com/retained',active:true}]
  96. const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend:'managed-browser',standalone:true,autoTake:true},global:{stubs}})
  97. try{
  98. await flushPromises()
  99. await wrapper.get('.return-control').trigger('click');await flushPromises()
  100. expect(wrapper.emitted('close')).toHaveLength(1)
  101. expect(request.mock.calls.filter(([path])=>path.includes('/giveback'))).toHaveLength(1)
  102. expect(request.mock.calls.some(([path])=>path.endsWith('/managed-browser/close'))).toBe(false)
  103. expect(state).toMatchObject({open:true,tabs:[{id:'retained'}]})
  104. }finally{wrapper.unmount()}
  105. const reopened=mount(ComputerPanel,{props:{agents:[agent],backend:'managed-browser',standalone:true,autoTake:true},global:{stubs}})
  106. try{
  107. await flushPromises()
  108. expect(reopened.get('[aria-label="浏览器标签页"]').text()).toContain('跨任务保留的页面')
  109. expect(state.tabs[0]?.id).toBe('retained')
  110. }finally{reopened.unmount()}
  111. })
  112. it('explicitly closes only the managed browser using the current control receipt',async()=>{
  113. const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend:'managed-browser',standalone:true},global:{stubs}})
  114. try{
  115. await flushPromises()
  116. expect(wrapper.find('.browser-session-actions').exists()).toBe(false)
  117. await wrapper.findAll('button').find(button=>button.text()==='打开并控制')!.trigger('click');await flushPromises()
  118. expect(wrapper.get('.return-control').text()).toBe('交还并收起')
  119. await wrapper.get('.browser-session-actions button').trigger('click');await flushPromises()
  120. expect(request).toHaveBeenCalledWith('/api/app/agents/bot/managed-browser/close',{method:'POST',body:{controlId:'control',token:'fixture-token'}})
  121. expect(wrapper.find('.browser-session-actions').exists()).toBe(false)
  122. expect(wrapper.find('.computer-screen img').exists()).toBe(false)
  123. expect(wrapper.get('.return-control').text()).toBe('收起窗口')
  124. expect(wrapper.text()).toContain('浏览器尚未打开')
  125. expect(wrapper.emitted('close')).toBeUndefined()
  126. expect(request.mock.calls.some(([path,options])=>options?.method==='POST'&&(/local-vm|giveback/.test(path)))).toBe(false)
  127. expect(wrapper.emitted('changed')).toHaveLength(1)
  128. }finally{wrapper.unmount()}
  129. })
  130. it('does not allow closing the browser while a bot task can resume',async()=>{
  131. canResume=true
  132. const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend:'managed-browser',standalone:true,autoTake:true},global:{stubs}})
  133. try{
  134. await flushPromises()
  135. expect(wrapper.text()).toContain('人工控制中')
  136. expect(wrapper.find('.browser-session-actions').exists()).toBe(false)
  137. expect(request.mock.calls.some(([path])=>path.endsWith('/managed-browser/close'))).toBe(false)
  138. }finally{wrapper.unmount()}
  139. })
  140. it.each([
  141. [true,'交还或收起只会释放操作权,页面会保留'],
  142. [false,'此节点需更新后才能跨任务保留页面;任务结束后浏览器会关闭'],
  143. [undefined,'交还或收起会结束当前接管'],
  144. ] as const)('describes retention only when the node reports support (%s)',async(supportsRetention,description)=>{
  145. state.supportsRetention=supportsRetention
  146. const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend:'managed-browser',standalone:true,autoTake:true},global:{stubs}})
  147. try{
  148. await flushPromises()
  149. await wrapper.findAll('button').find(button=>button.text()==='键盘与操作')!.trigger('click')
  150. expect(wrapper.get('footer').text()).toContain(description)
  151. if(supportsRetention!==true)expect(wrapper.get('footer').text()).not.toContain('页面会保留')
  152. expect(request.mock.calls.some(([path])=>path.endsWith('/managed-browser/close'))).toBe(false)
  153. }finally{wrapper.unmount()}
  154. })
  155. it.each(['desktop','cloud','vm'] as const)('does not expose managed browser close for a %s target',async(backend)=>{
  156. const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend,standalone:true,autoTake:true},global:{stubs}})
  157. try{
  158. await flushPromises()
  159. expect(wrapper.text()).toContain('人工控制中')
  160. expect(wrapper.find('.browser-session-actions').exists()).toBe(false)
  161. await wrapper.get('.return-control').trigger('click');await flushPromises()
  162. expect(request.mock.calls.some(([path])=>path.endsWith('/managed-browser/close'))).toBe(false)
  163. }finally{wrapper.unmount()}
  164. })
  165. it('retains control when explicit browser close fails so the user can retry or return it',async()=>{
  166. const original=request.getMockImplementation()!
  167. request.mockImplementation((path,options)=>path.endsWith('/managed-browser/close')?Promise.reject(new Error('浏览器仍被任务使用,请先交还')):original(path,options))
  168. const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend:'managed-browser',standalone:true,autoTake:true},global:{stubs}})
  169. try{
  170. await flushPromises()
  171. await wrapper.get('.browser-session-actions button').trigger('click');await flushPromises()
  172. expect(wrapper.text()).toContain('人工控制中')
  173. expect(wrapper.get('.browser-session-actions button').attributes('disabled')).toBeUndefined()
  174. expect(wrapper.get('.return-control').text()).toBe('交还并收起')
  175. expect(wrapper.emitted('close')).toBeUndefined()
  176. expect(state.open).toBe(true)
  177. expect(wrapper.get('[role="alert"]').text()).toBe('浏览器仍被任务使用,请先交还')
  178. }finally{wrapper.unmount()}
  179. })
  180. it('never switches an unavailable requested browser to another computer',async()=>{
  181. state.available=false;state.reason='执行节点离线'
  182. const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend:'managed-browser',standalone:true,autoTake:true},global:{stubs}})
  183. try{
  184. await flushPromises()
  185. expect(wrapper.get('.browser-availability [role="status"]').text()).toBe('执行节点离线')
  186. expect(wrapper.find('[role="alert"]').exists()).toBe(false)
  187. expect(request.mock.calls.some(([path])=>path.includes('/computer/take'))).toBe(false)
  188. expect(wrapper.find('.computer-screen img').exists()).toBe(false)
  189. }finally{wrapper.unmount()}
  190. })
  191. it('ignores browser metadata that arrives after a target switch',async()=>{
  192. state.open=true;state.tabs=[{id:'tab',title:'原页面',url:'https://example.com',active:true}]
  193. let release!:(value:unknown)=>void,count=0
  194. const original=request.getMockImplementation()!
  195. request.mockImplementation((path,options)=>path.endsWith('/managed-browser')&&++count===2?new Promise(resolve=>{release=resolve}):original(path,options))
  196. const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend:'managed-browser',standalone:true},global:{stubs}})
  197. try{
  198. await flushPromises()
  199. await wrapper.get('[aria-label="切换电脑环境"]').setValue('desktop:local');await flushPromises()
  200. release({...state,tabs:[{id:'late',title:'延迟旧页面',url:'https://old.example',active:true}]});await flushPromises()
  201. expect(wrapper.findComponent(BrowserToolbar).exists()).toBe(false)
  202. expect(wrapper.text()).not.toContain('延迟旧页面')
  203. }finally{wrapper.unmount()}
  204. })
  205. it('accepts managed browser native viewer links but rejects a host mixed into them',async()=>{
  206. route.query={backend:'managed-browser'}
  207. const wrapper=mount(Viewer,{global:{stubs:{ComputerPanel:true}}})
  208. try{await flushPromises();expect(wrapper.getComponent(ComputerPanel).props()).toMatchObject({backend:'managed-browser',autoTake:true})}finally{wrapper.unmount()}
  209. route.query={backend:'managed-browser',host:'local'}
  210. const invalid=mount(Viewer,{global:{stubs:{ComputerPanel:true}}})
  211. try{await flushPromises();expect(invalid.text()).toContain('所选电脑无效');expect(invalid.findComponent(ComputerPanel).exists()).toBe(false)}finally{invalid.unmount()}
  212. })
  213. it('keeps a missing browser selectable and prepares it once before automatic takeover',async()=>{
  214. vi.useFakeTimers()
  215. state.available=false;state.installation={status:'missing',message:'首次使用会自动准备 Chromium',updatedAt:1}
  216. const original=request.getMockImplementation()!
  217. request.mockImplementation(async(path,options)=>{
  218. if(path.endsWith('/managed-browser/prepare')){state.installation={status:'installing',message:'正在下载 Chromium 45%',progress:45,updatedAt:2};return structuredClone(state) as any}
  219. return original(path,options)
  220. })
  221. const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend:'managed-browser',standalone:true,autoTake:true},global:{stubs}})
  222. try{
  223. await flushPromises()
  224. expect(wrapper.get('option[value="managed-browser"]').attributes('disabled')).toBeUndefined()
  225. expect(wrapper.text()).toContain('正在下载 Chromium 45%')
  226. expect(wrapper.get('progress').attributes('value')).toBe('45')
  227. expect(request.mock.calls.filter(([path])=>path.endsWith('/prepare'))).toHaveLength(1)
  228. expect(request.mock.calls.some(([path])=>path.includes('/computer/take'))).toBe(false)
  229. state.available=true;state.installation={status:'ready',message:'浏览器已准备',updatedAt:3}
  230. await vi.advanceTimersByTimeAsync(1000);await flushPromises()
  231. expect(request.mock.calls.filter(([path])=>path.includes('/computer/take'))).toHaveLength(1)
  232. expect(wrapper.text()).toContain('人工控制中')
  233. expect(request.mock.calls.filter(([path])=>path.endsWith('/prepare'))).toHaveLength(1)
  234. }finally{wrapper.unmount()}
  235. })
  236. it('observes an existing installation without restarting it and cancels waiting when closed',async()=>{
  237. vi.useFakeTimers()
  238. state.available=false;state.installation={status:'installing',message:'正在下载 Chromium',updatedAt:1}
  239. const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend:'managed-browser',standalone:true,autoTake:true},global:{stubs}})
  240. try{
  241. await flushPromises()
  242. expect(request.mock.calls.some(([path])=>path.endsWith('/prepare'))).toBe(false)
  243. expect(wrapper.get('.return-control').attributes('disabled')).toBeUndefined()
  244. await wrapper.get('.return-control').trigger('click');await flushPromises()
  245. expect(wrapper.emitted('close')).toHaveLength(1)
  246. const calls=request.mock.calls.length
  247. state.available=true;state.installation={status:'ready',message:'就绪',updatedAt:2}
  248. await vi.advanceTimersByTimeAsync(5000);await flushPromises()
  249. expect(request.mock.calls).toHaveLength(calls)
  250. expect(request.mock.calls.some(([path])=>path.includes('/computer/take'))).toBe(false)
  251. }finally{wrapper.unmount()}
  252. })
  253. it('shows installation failure with an explicit retry and resumes takeover after recovery',async()=>{
  254. vi.useFakeTimers()
  255. state.available=false;state.installation={status:'missing',message:'待准备',updatedAt:1}
  256. let attempts=0
  257. const original=request.getMockImplementation()!
  258. request.mockImplementation(async(path,options)=>{
  259. if(path.endsWith('/prepare')){
  260. attempts++
  261. state.installation=attempts===1?{status:'failed',message:'下载失败',error:'节点网络不可用',updatedAt:2}:{status:'ready',message:'已准备',updatedAt:3}
  262. state.available=attempts>1
  263. return structuredClone(state) as any
  264. }
  265. return original(path,options)
  266. })
  267. const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend:'managed-browser',standalone:true,autoTake:true},global:{stubs}})
  268. try{
  269. await flushPromises()
  270. expect(wrapper.text()).toContain('节点网络不可用')
  271. expect(attempts).toBe(1)
  272. await wrapper.findAll('button').find(button=>button.text()==='重试并接管浏览器')!.trigger('click');await flushPromises()
  273. expect(request.mock.calls.filter(([path])=>path.endsWith('/prepare')).at(-1)?.[1]?.body).toEqual({retry:true})
  274. expect(attempts).toBe(2)
  275. expect(wrapper.text()).toContain('人工控制中')
  276. }finally{wrapper.unmount()}
  277. })
  278. it('returns a takeover receipt that arrives after its viewer unmounts to the original browser',async()=>{
  279. let resolve!:(value:unknown)=>void
  280. const original=request.getMockImplementation()!
  281. request.mockImplementation((path,options)=>path.includes('/computer/take')?new Promise(done=>{resolve=done}):original(path,options))
  282. const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend:'managed-browser',standalone:true,autoTake:true},global:{stubs}})
  283. await flushPromises();wrapper.unmount()
  284. resolve({mode:'human',controlId:'late-control',token:'late-token',generation:4});await flushPromises()
  285. expect(request).toHaveBeenCalledWith('/api/app/agents/bot/computer/giveback?backend=managed-browser',{method:'POST',body:{controlId:'late-control',token:'late-token',notes:''}})
  286. })
  287. it('exposes preparation in the side panel while read-only viewing never installs the browser',async()=>{
  288. state.available=false;state.installation={status:'missing',message:'首次接管将准备浏览器',updatedAt:1}
  289. const wrapper=mount(Preview,{props:{agents:[agent],isAdmin:true,active:true},global:{stubs}})
  290. try{
  291. await flushPromises()
  292. const browser=wrapper.get('[aria-label="托管浏览器"]')
  293. expect(browser.text()).toContain('首次接管将准备浏览器')
  294. expect(browser.get('button.primary').attributes('disabled')).toBeUndefined()
  295. expect(request.mock.calls.some(([path])=>path.endsWith('/prepare'))).toBe(false)
  296. await browser.get('button.primary').trigger('click')
  297. expect(wrapper.emitted('desktop')?.at(-1)).toEqual([agent,'managed-browser',undefined])
  298. }finally{wrapper.unmount()}
  299. })
  300. it('rechecks an offline browser in the side panel and restores takeover with GET requests only',async()=>{
  301. state.available=false;state.reason='执行节点离线'
  302. const wrapper=mount(Preview,{props:{agents:[agent],isAdmin:true,active:true},global:{stubs}})
  303. try{
  304. await flushPromises()
  305. await wrapper.get('[aria-label="显示的桌面"]').setValue('managed-browser');await flushPromises()
  306. const browser=wrapper.get('[aria-label="托管浏览器"]')
  307. expect(browser.get('button.primary').attributes('disabled')).toBeDefined()
  308. expect(browser.get('[role="status"]').text()).toBe('执行节点离线')
  309. expect(browser.find('progress').exists()).toBe(false)
  310. const before=request.mock.calls.length
  311. state.available=true;state.reason=undefined
  312. await browser.findAll('button').find(button=>button.text()==='重新检测')!.trigger('click');await flushPromises()
  313. expect(browser.get('button.primary').attributes('disabled')).toBeUndefined()
  314. expect(wrapper.get<HTMLSelectElement>('[aria-label="显示的桌面"]').element.value).toBe('managed-browser')
  315. expect(request.mock.calls.slice(before).map(([path])=>path)).toEqual(['/api/app/agents/bot/managed-browser'])
  316. expect(request.mock.calls.some(([,options])=>options?.method==='POST')).toBe(false)
  317. await browser.get('button.primary').trigger('click')
  318. expect(wrapper.emitted('desktop')?.at(-1)).toEqual([agent,'managed-browser',undefined])
  319. }finally{wrapper.unmount()}
  320. })
  321. it('recovers an explicitly selected browser after polling without starting or switching computers',async()=>{
  322. vi.useFakeTimers()
  323. state.available=false;state.reason='执行节点离线'
  324. const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend:'managed-browser',standalone:true,autoTake:true},global:{stubs}})
  325. try{
  326. await flushPromises()
  327. expect(wrapper.get('.browser-availability').text()).toContain('重新检测')
  328. expect(wrapper.find('progress').exists()).toBe(false)
  329. expect(wrapper.findAll('button').find(button=>button.text()==='打开并控制')!.attributes('disabled')).toBeDefined()
  330. state.available=true;state.reason=undefined
  331. await vi.advanceTimersByTimeAsync(1200);await flushPromises()
  332. expect(wrapper.findAll('button').find(button=>button.text()==='打开并控制')!.attributes('disabled')).toBeUndefined()
  333. expect(wrapper.get<HTMLSelectElement>('[aria-label="切换电脑环境"]').element.value).toBe('managed-browser')
  334. expect(wrapper.find('.browser-availability').exists()).toBe(false)
  335. expect(request.mock.calls.filter(([path])=>path.includes('/computer')).every(([path])=>path.includes('backend=managed-browser'))).toBe(true)
  336. expect(request.mock.calls.some(([,options])=>options?.method==='POST')).toBe(false)
  337. }finally{wrapper.unmount()}
  338. })
  339. it('refreshes viewer targets on explicit recheck without preparing the browser',async()=>{
  340. state.available=false;state.reason='执行节点离线'
  341. const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend:'managed-browser',standalone:true},global:{stubs}})
  342. try{
  343. await flushPromises()
  344. const before=request.mock.calls.length
  345. state.reason=undefined;state.installation={status:'missing',message:'首次使用会自动准备 Chromium',updatedAt:1}
  346. await wrapper.get('.browser-availability button').trigger('click');await flushPromises()
  347. const calls=request.mock.calls.slice(before)
  348. expect(calls.some(([path])=>path.endsWith('/host-tools'))).toBe(true)
  349. expect(calls.some(([path])=>path.endsWith('/managed-browser'))).toBe(true)
  350. expect(calls.some(([,options])=>options?.method==='POST')).toBe(false)
  351. expect(wrapper.get('option[value="managed-browser"]').attributes('disabled')).toBeUndefined()
  352. expect(wrapper.text()).toContain('首次使用会自动准备 Chromium')
  353. expect(wrapper.find('progress').exists()).toBe(false)
  354. }finally{wrapper.unmount()}
  355. })
  356. it.each(['viewer','side panel'])('clears stale installation progress after a failed status read in the %s',async(view)=>{
  357. vi.useFakeTimers()
  358. state.available=false;state.installation={status:'installing',message:'正在下载 Chromium 40%',progress:40,updatedAt:1}
  359. const original=request.getMockImplementation()!
  360. let disconnected=false
  361. request.mockImplementation((path,options)=>disconnected&&path.endsWith('/managed-browser')?Promise.reject(new Error('执行节点连接已断开')):original(path,options))
  362. const wrapper=view==='viewer'
  363. ?mount(ComputerPanel,{props:{agents:[agent],backend:'managed-browser',standalone:true},global:{stubs}})
  364. :mount(Preview,{props:{agents:[agent],isAdmin:true,active:true},global:{stubs}})
  365. try{
  366. await flushPromises()
  367. expect(wrapper.find('progress').exists()).toBe(true)
  368. disconnected=true
  369. await vi.advanceTimersByTimeAsync(3000);await flushPromises()
  370. expect(wrapper.find('progress').exists()).toBe(false)
  371. expect(wrapper.text()).not.toContain('正在下载 Chromium 40%')
  372. expect(wrapper.text()).toContain('执行节点连接已断开')
  373. expect(wrapper.text()).toContain('重新检测')
  374. expect(request.mock.calls.some(([,options])=>options?.method==='POST')).toBe(false)
  375. }finally{wrapper.unmount()}
  376. })
  377. it('keeps a newer explicit browser detection result when an older periodic response arrives',async()=>{
  378. state.available=false;state.reason='执行节点离线'
  379. let release!:(value:unknown)=>void,count=0
  380. const original=request.getMockImplementation()!
  381. request.mockImplementation((path,options)=>path.endsWith('/managed-browser')&&++count===1?new Promise(resolve=>{release=resolve}):original(path,options))
  382. const wrapper=mount(Preview,{props:{agents:[agent],isAdmin:true,active:true},global:{stubs}})
  383. try{
  384. await flushPromises()
  385. const browser=wrapper.get('[aria-label="托管浏览器"]')
  386. state.available=true;state.reason=undefined
  387. await browser.findAll('button').find(button=>button.text()==='重新检测')!.trigger('click');await flushPromises()
  388. expect(browser.get('button.primary').attributes('disabled')).toBeUndefined()
  389. release({...state,available:false,reason:'过期的离线状态'});await flushPromises()
  390. expect(browser.get('button.primary').attributes('disabled')).toBeUndefined()
  391. expect(browser.text()).not.toContain('过期的离线状态')
  392. expect(request.mock.calls.some(([,options])=>options?.method==='POST')).toBe(false)
  393. }finally{wrapper.unmount()}
  394. })