service-manager.test.mjs 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
  1. import {test} from 'node:test'
  2. import assert from 'node:assert/strict'
  3. import {createServer} from 'node:http'
  4. import {dataKey,DesktopServiceManager} from './service-manager.mjs'
  5. import {mkdtemp,realpath,writeFile,rm} from 'node:fs/promises'
  6. import {tmpdir} from 'node:os'
  7. import {join} from 'node:path'
  8. import {DesktopOnboarding} from './onboarding.mjs'
  9. test('local inspection defers Hermes and Runner until entry and does not retain activation after disconnect',async()=>{
  10. const home=await realpath(await mkdtemp(join(tmpdir(),'yaoyao-deferred-start-')))
  11. const record={protocol:1,instanceId:'deferred-fixture',pid:process.pid,dataKey:dataKey(home),version:'0.4.71',token:'private-token'}
  12. let required=true,hermesStarts=0,runnerStarts=0
  13. const server=createServer((req,res)=>{
  14. assert.equal(req.headers['x-yaoyao-desktop-token'],record.token)
  15. const {token,...identity}=record
  16. res.setHeader('Content-Type','application/json')
  17. if(req.url==='/desktop/service/activate'&&req.method==='POST'){required=false;hermesStarts++;res.end(JSON.stringify({activated:true}))}
  18. else{assert.equal(req.url,'/desktop/service');assert.equal(req.method,'GET');res.end(JSON.stringify({...identity,activationRequired:required}))}
  19. })
  20. await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve))
  21. record.url=`http://127.0.0.1:${server.address().port}`
  22. await writeFile(join(home,'service-instance.json'),JSON.stringify(record))
  23. const manager=new DesktopServiceManager({home,version:record.version})
  24. const flow=new DesktopOnboarding({remoteServer:()=>'',prepareLocal:async()=>{await manager.start();return manager.state.url},inspect:async()=>({authenticated:true}),
  25. activate:async()=>{await manager.activate();runnerStarts++},navigate:async()=>{}})
  26. try{
  27. flow.select('local');await flow.prepare()
  28. assert.equal(flow.state.phase,'ready');assert.equal(hermesStarts,0);assert.equal(runnerStarts,0)
  29. await flow.submit()
  30. assert.equal(hermesStarts,1);assert.equal(runnerStarts,1)
  31. await manager.activate();assert.equal(hermesStarts,1)
  32. // A process replacement while actively using local mode retains this entry's intent.
  33. required=true;await manager.check();assert.equal(hermesStarts,2)
  34. await manager.stop();required=true
  35. flow.open({mode:'local'});await flow.prepare()
  36. assert.equal(hermesStarts,2);assert.equal(runnerStarts,1)
  37. await flow.submit()
  38. assert.equal(hermesStarts,3);assert.equal(runnerStarts,2)
  39. }finally{await manager.stop();await new Promise(resolve=>server.close(resolve));await rm(home,{recursive:true,force:true})}
  40. })
  41. test('checks recorded and bundled versions while permitting a separately owned older service',async()=>{
  42. const root='/fixture/desktop',record={protocol:1,instanceId:'fixture',pid:1234,dataKey:dataKey(root),version:'0.3.32',token:'fixture'}
  43. let actual={...record};delete actual.token
  44. const server=createServer((request,response)=>{response.setHeader('Content-Type','application/json');response.end(JSON.stringify(actual))})
  45. await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve))
  46. record.url=`http://127.0.0.1:${server.address().port}`
  47. const manager=new DesktopServiceManager({version:'0.3.33'});manager.root=root
  48. try{
  49. assert.equal((await manager.verify(record)).version,'0.3.32')
  50. actual={...actual,version:'0.3.31'}
  51. await assert.rejects(manager.verify(record),/身份/)
  52. actual={...actual,version:'0.3.32'};manager.child={pid:record.pid}
  53. await assert.rejects(manager.verify(record),/版本不匹配/)
  54. }finally{await new Promise(resolve=>server.close(resolve))}
  55. })
  56. test('busy synchronization connects the verified existing Web and remains retryable without accepting other errors',async()=>{
  57. const home=await realpath(await mkdtemp(join(tmpdir(),'yaoyao-startup-busy-')))
  58. const record={protocol:1,instanceId:'busy-fixture',pid:process.pid,dataKey:dataKey(home),version:'0.3.31',token:'fixture'}
  59. const server=createServer((request,response)=>{const {token,...identity}=record;response.setHeader('Content-Type','application/json');response.end(JSON.stringify(identity))})
  60. await new Promise(done=>server.listen(0,'127.0.0.1',done))
  61. record.url=`http://127.0.0.1:${server.address().port}`
  62. await writeFile(join(home,'service-instance.json'),JSON.stringify(record))
  63. let attempts=0
  64. const options={home,version:'0.3.32',fork:()=>{throw new Error('must not fork')},synchronize:async()=>{if(++attempts===1)throw Object.assign(new Error('已有任务,同步待完成'),{code:'service_busy'})}}
  65. const manager=new DesktopServiceManager(options)
  66. try{
  67. const state=await manager.start()
  68. assert.equal(state.phase,'ready');assert.equal(state.external,true);assert.match(state.updateNotice,/同步待完成/)
  69. assert.equal((await manager.retrySynchronization()).updateNotice,undefined);assert.equal(attempts,2)
  70. await manager.stop()
  71. assert.equal(manager.state.phase,'disconnected')
  72. assert.doesNotMatch(manager.state.message,/服务已停止/)
  73. assert.equal((await manager.reconnect()).phase,'ready')
  74. assert.equal(manager.state.pid,process.pid)
  75. await assert.rejects(manager.stopBackground(),/不由本 App 管理/)
  76. assert.equal(manager.state.phase,'error')
  77. assert.equal((await manager.reconnect()).phase,'ready')
  78. const invalid=new DesktopServiceManager({...options,synchronize:async()=>{throw new Error('invalid package')}})
  79. await assert.rejects(invalid.start(),/invalid package/)
  80. assert.equal(invalid.state.phase,'error')
  81. }finally{await new Promise(done=>server.close(done));await rm(home,{recursive:true,force:true})}
  82. })
  83. test('publishes stopped only after the native service manager confirms shutdown',async()=>{
  84. let finish,fail=false
  85. const manager=new DesktopServiceManager({stopBackground:()=>fail?Promise.reject(new Error('still alive')):new Promise(resolve=>{finish=resolve})})
  86. const stopping=manager.stopBackground()
  87. await new Promise(resolve=>setImmediate(resolve))
  88. assert.equal(manager.state.phase,'stopping')
  89. finish();await stopping
  90. assert.equal(manager.state.phase,'stopped')
  91. fail=true;await assert.rejects(manager.stopBackground(),/still alive/)
  92. assert.equal(manager.state.phase,'error')
  93. assert.doesNotMatch(manager.state.message,/服务已停止/)
  94. })
  95. test('a late activate health check cannot reconnect after quit has begun',async()=>{
  96. const manager=new DesktopServiceManager({})
  97. manager.state={phase:'ready'}
  98. let finish,starts=0
  99. manager.check=()=>new Promise(resolve=>{finish=resolve})
  100. manager.start=async()=>{starts++}
  101. const reconnect=manager.reconnect()
  102. await manager.stop();finish();await reconnect
  103. assert.equal(starts,0);assert.equal(manager.state.phase,'disconnected')
  104. })
  105. test('unknown same-version builds offer an explicit one-shot overwrite without changing ordinary retries',async()=>{
  106. const home=await realpath(await mkdtemp(join(tmpdir(),'yaoyao-force-sync-'))),attempts=[]
  107. const manager=new DesktopServiceManager({home,version:'0.4.18',synchronize:async(_progress,options)=>{
  108. attempts.push(options.force)
  109. if(attempts.length===1)throw Object.assign(new Error('同版本构建需要选择'),{code:'service_build_unknown'})
  110. }})
  111. manager.readRecord=async()=>({url:'http://127.0.0.1:1'})
  112. manager.verify=async()=>({url:'http://127.0.0.1:1',version:'0.4.18',pid:process.pid})
  113. try{
  114. await assert.rejects(manager.start(),/需要选择/)
  115. assert.equal(manager.state.canForceSync,true);assert.equal(manager.canForceSynchronization,true)
  116. await manager.retrySynchronization({force:true});assert.equal(manager.state.phase,'ready')
  117. await manager.retrySynchronization();assert.deepEqual(attempts,[false,true,false])
  118. manager.state={phase:'error',message:'签名校验失败'}
  119. await assert.rejects(manager.retrySynchronization({force:true}),/当前状态不能覆盖/)
  120. }finally{await rm(home,{recursive:true,force:true})}
  121. })