mode-switch.test.mjs 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
  1. import {test} from 'node:test'
  2. import assert from 'node:assert/strict'
  3. import {mkdtemp,readFile,writeFile,realpath,rm} from 'node:fs/promises'
  4. import {tmpdir} from 'node:os'
  5. import {join,resolve} from 'node:path'
  6. import {createServer} from 'node:http'
  7. import {_electron as electron,expect} from '@playwright/test'
  8. import {dataKey} from './service-manager.mjs'
  9. test('signed-in desktop pages can switch both ways; other windows cannot control the runtime',{timeout:60000},async()=>{
  10. const root=resolve(import.meta.dirname,'..'),home=await realpath(await mkdtemp(join(tmpdir(),'yaoyao-mode-switch-')))
  11. const version=JSON.parse(await readFile(join(root,'.desktop-build/release.json'),'utf8')).webVersion
  12. const identity={protocol:1,instanceId:'mode-switch',pid:process.pid,dataKey:dataKey(home),version}
  13. let releaseIdentity
  14. const identityReady=new Promise(resolve=>{releaseIdentity=resolve})
  15. const local=createServer(async(req,res)=>{
  16. if(req.url==='/desktop/service'){await identityReady;res.setHeader('Content-Type','application/json');res.end(JSON.stringify(identity))}
  17. else if(req.url.split('?')[0]==='/api/app/bootstrap'){res.setHeader('Content-Type','application/json');res.end(JSON.stringify({authenticated:true,csrfToken:'fixture'}))}
  18. else {res.setHeader('Content-Type','text/html; charset=utf-8');res.end('<title>已登录的本机页面</title>')}
  19. })
  20. let enrollments = 0, exchanges = 0, enrollmentFails = true
  21. const deviceId = '99999999-9999-4999-8999-999999999999', deviceToken = 'fixture-machine-token-with-at-least-32-characters'
  22. const remote=createServer((_req,res)=>{
  23. if(_req.url.split('?')[0]==='/api/app/bootstrap'){
  24. res.setHeader('Content-Type','application/json')
  25. res.setHeader('Set-Cookie',['session=remembered; Path=/; HttpOnly','csrf=fixture; Path=/; HttpOnly'])
  26. res.end(JSON.stringify({authenticated:true,csrfToken:'fixture',user:{id:'admin',role:'admin',username:'管理员'}}));return
  27. }
  28. if(_req.url==='/api/app/admin/desktop-hosts'&&_req.method==='POST'){
  29. enrollments++
  30. assert.equal(_req.headers.origin,remoteURL)
  31. assert.equal(_req.headers['x-csrf-token'],'fixture')
  32. assert.match(_req.headers.cookie,/session=remembered/)
  33. res.setHeader('Content-Type','application/json')
  34. if(enrollmentFails){res.statusCode=503;res.end(JSON.stringify({error:'fixture enrollment unavailable'}));return}
  35. res.statusCode=201;res.end(JSON.stringify({host:{id:deviceId},token:deviceToken}));return
  36. }
  37. if(_req.url===`/api/desktop-host/v1/${deviceId}/exchange`){
  38. exchanges++
  39. assert.equal(_req.headers.authorization,`Bearer ${deviceToken}`)
  40. assert.equal(_req.headers.origin,undefined)
  41. assert.equal(_req.headers.cookie,undefined)
  42. res.setHeader('Content-Type','application/json');res.end(JSON.stringify({commands:[]}));return
  43. }
  44. res.setHeader('Content-Type','text/html; charset=utf-8');res.end('<title>已登录的服务器页面</title>')
  45. })
  46. await new Promise(done=>local.listen(0,'127.0.0.1',done))
  47. await new Promise(done=>remote.listen(0,'127.0.0.1',done))
  48. const origin=`http://127.0.0.1:${local.address().port}`,remoteURL=`http://127.0.0.1:${remote.address().port}`
  49. await writeFile(join(home,'service-instance.json'),JSON.stringify({...identity,url:origin,token:'fixture'}))
  50. await writeFile(join(home,'desktop-preferences.json'),JSON.stringify({startupChoice:'local',remoteServer:remoteURL,backgroundAtLogin:false}))
  51. let app
  52. try{
  53. app=await electron.launch({args:[root],cwd:root,env:{...process.env,HERMES_YAOYAO_DESKTOP_TEST_HOME:home,HERMES_YAOYAO_DESKTOP_PORT:String(local.address().port),HERMES_YAOYAO_DESKTOP_TEST_SYNC:'0'}})
  54. const page=await app.firstWindow();releaseIdentity();await page.waitForURL(origin+'/**')
  55. assert.deepEqual(await page.evaluate(()=>window.yaoyaoDesktop.modeState()),{mode:'server',serverURL:origin,switching:false,platform:'darwin',supportedModes:['client','server']})
  56. assert.deepEqual(await page.evaluate(()=>window.yaoyaoDesktop.authorizeComputer('csrf-test')),{registered:false})
  57. assert.equal(await page.evaluate(()=>window.yaoyaoDesktop.switchMode('invalid').then(()=>false,()=>true)),true)
  58. await page.evaluate(()=>{void window.yaoyaoDesktop.switchMode('client')})
  59. await page.waitForURL(remoteURL+'/**')
  60. assert.equal(enrollments,1,'entering an already signed-in remote session restores this computer authorization')
  61. assert.deepEqual(await page.evaluate(()=>window.yaoyaoDesktop.modeState()),{mode:'client',serverURL:remoteURL,switching:false,platform:'darwin',supportedModes:['client','server']})
  62. await expect.poll(async()=>JSON.parse(await readFile(join(home,'desktop-preferences.json'),'utf8')).startupChoice).toBe('remote')
  63. // Unchecking "ask at startup" must retain the active client role.
  64. await app.evaluate(({Menu})=>{const item=Menu.getApplicationMenu().getMenuItemById('desktop-host-ask');item.click(item)})
  65. await expect.poll(async()=>JSON.parse(await readFile(join(home,'desktop-preferences.json'),'utf8')).startupChoice).toBe('ask')
  66. await app.evaluate(({Menu})=>{const item=Menu.getApplicationMenu().getMenuItemById('desktop-host-ask');item.click(item)})
  67. await expect.poll(async()=>JSON.parse(await readFile(join(home,'desktop-preferences.json'),'utf8')).startupChoice).toBe('remote')
  68. const denied=await app.evaluate(async({BrowserWindow},{origin,preload})=>{
  69. const outsider=new BrowserWindow({show:false,webPreferences:{sandbox:true,contextIsolation:true,preload}})
  70. try{
  71. await outsider.loadURL(origin)
  72. return await outsider.webContents.executeJavaScript('Promise.all([window.yaoyaoDesktop.modeState(),window.yaoyaoDesktop.switchMode("server"),window.yaoyaoDesktop.openRemoteLogin(),window.yaoyaoDesktop.authorizeComputer("csrf-test")].map(p=>p.then(()=>false,()=>true)))')
  73. }finally{outsider.destroy()}
  74. },{origin:remoteURL,preload:join(root,'desktop/preload.cjs')})
  75. assert.deepEqual(denied,[true,true,true,true])
  76. await page.evaluate(()=>{void window.yaoyaoDesktop.switchMode('server')})
  77. await page.waitForURL(origin+'/**')
  78. await expect.poll(()=>page.evaluate(()=>window.yaoyaoDesktop.modeState())).toEqual({mode:'server',serverURL:origin,switching:false,platform:'darwin',supportedModes:['client','server']})
  79. assert.equal(JSON.parse(await readFile(join(home,'desktop-preferences.json'),'utf8')).startupChoice,'local')
  80. assert.equal(local.listening,true,'switching must leave an independent service running')
  81. assert.equal(remote.listening,true)
  82. // Changing servers reuses the main page, including when opened repeatedly.
  83. await page.evaluate(()=>{void window.yaoyaoDesktop.openRemoteLogin()})
  84. await page.waitForURL('**/boot.html')
  85. assert.equal(app.windows().length,1)
  86. await page.getByRole('radio',{name:/本机运行/}).check()
  87. await page.locator('#prepare-local').click()
  88. await page.locator('#submit').click()
  89. await page.waitForURL(origin+'/**')
  90. enrollmentFails = false
  91. await app.evaluate(async (_electron, credentialsModule) => {
  92. // Fixture credentials stay in this temporary directory; do not access Keychain.
  93. const module = process.getBuiltinModule('module')
  94. const { DesktopCredentials } = module.createRequire(credentialsModule)(credentialsModule)
  95. DesktopCredentials.prototype.encrypt = async value => Buffer.from(value)
  96. DesktopCredentials.prototype.decrypt = async value => value.toString('utf8')
  97. globalThis.fetch = async () => { throw new Error('fixture Node transport is unavailable') }
  98. const http = process.getBuiltinModule('http')
  99. const originalRequest = http.request
  100. http.request = (url, ...args) => {
  101. if (url?.pathname?.startsWith('/api/desktop-host/')) throw new Error('fixture Node socket is unavailable')
  102. return originalRequest(url, ...args)
  103. }
  104. module.syncBuiltinESMExports()
  105. }, join(root, 'desktop/credentials.mjs'))
  106. await page.evaluate(()=>{void window.yaoyaoDesktop.switchMode('client')})
  107. await page.waitForURL(remoteURL+'/**')
  108. await expect.poll(() => exchanges).toBeGreaterThan(0)
  109. assert.equal(enrollments,2,'restoring the session retries device registration without a password login')
  110. await page.evaluate(()=>{void window.yaoyaoDesktop.openRemoteLogin()})
  111. await page.waitForURL('**/boot.html')
  112. assert.equal(app.windows().length,1)
  113. await page.getByRole('radio',{name:/本机运行/}).check()
  114. await page.locator('#prepare-local').click()
  115. await page.locator('#submit').click()
  116. await page.waitForURL(origin+'/**')
  117. await expect.poll(()=>page.evaluate(()=>window.yaoyaoDesktop.modeState())).toEqual({mode:'server',serverURL:origin,switching:false,platform:'darwin',supportedModes:['client','server']})
  118. }finally{
  119. releaseIdentity();await app?.close().catch(()=>{})
  120. await Promise.all([local,remote].map(server=>new Promise(done=>{server.close(done);server.closeAllConnections()})))
  121. await rm(home,{recursive:true,force:true})
  122. }
  123. })