lifecycle.test.mjs 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
  1. import { enterLocal } from './test-support/onboarding.mjs'
  2. import { test } from 'node:test'
  3. import assert from 'node:assert/strict'
  4. import { mkdtemp, readFile, writeFile, stat, rm, mkdir, realpath } from 'node:fs/promises'
  5. import { tmpdir } from 'node:os'
  6. import { join, resolve } from 'node:path'
  7. import { createServer } from 'node:net'
  8. import { spawn,execFile } from 'node:child_process'
  9. import { promisify } from 'node:util'
  10. import { createHash } from 'node:crypto'
  11. import { _electron as electron } from '@playwright/test'
  12. import { localJSON, DesktopServiceManager } from './service-manager.mjs'
  13. const root = resolve(import.meta.dirname, '..')
  14. async function freePort() {
  15. const server = createServer(); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
  16. const port = server.address().port; await new Promise(resolve => server.close(resolve));return port
  17. }
  18. async function until(read, accept, timeout = 30000) {
  19. const end = Date.now() + timeout;let value
  20. while (Date.now() < end) {
  21. try { value = await read(); if (accept(value)) return value } catch {}
  22. await new Promise(resolve => setTimeout(resolve, 100))
  23. }
  24. throw new Error('等待验收状态超时')
  25. }
  26. test('real desktop launches its bundled service, survives window close and recovers its own crashed child', { timeout: 90000 }, async () => {
  27. const home = await mkdtemp(join(tmpdir(), 'yaoyao-desktop-e2e-'))
  28. const port = await freePort(), upstreamPort = await freePort()
  29. const upstream = spawn(process.execPath, [join(root, 'tests/fixtures/fake-hermes.mjs')], {
  30. cwd: root, env: { ...process.env, FAKE_HERMES_PORT: String(upstreamPort), FAKE_HERMES_LOCAL_AUTH: '1' }, stdio: 'ignore',
  31. })
  32. let desktop
  33. let servicePID, runnerPID, runnerID
  34. try {
  35. await until(() => localJSON(`http://127.0.0.1:${upstreamPort}/api/status`), Boolean)
  36. desktop = await electron.launch({ ...(process.env.DESKTOP_TEST_EXECUTABLE ? { executablePath: process.env.DESKTOP_TEST_EXECUTABLE } : {}),
  37. args: process.env.DESKTOP_TEST_EXECUTABLE ? [] : [root], cwd: root, env: { ...process.env,
  38. HERMES_YAOYAO_DESKTOP_TEST_HOME: home, HERMES_YAOYAO_DESKTOP_PORT: String(port),
  39. HERMES_YAOYAO_UPSTREAM: `http://127.0.0.1:${upstreamPort}` }, timeout: 30000 })
  40. desktop.process().stderr.on('data', b => { if (process.env.DESKTOP_TEST_LOGS) process.stderr.write(b) })
  41. const page = await desktop.firstWindow()
  42. await enterLocal(page)
  43. await page.waitForURL(`http://127.0.0.1:${port}/**`, { timeout: 30000 })
  44. const owner = await until(async () => JSON.parse(await readFile(join(home, 'service-instance.json'), 'utf8')), r => r.url)
  45. servicePID = owner.pid
  46. const identity = await localJSON(`http://127.0.0.1:${port}/desktop/service`, { 'x-yaoyao-desktop-token': owner.token })
  47. assert.equal(identity.pid, owner.pid);assert.equal(identity.desktopOwned, true)
  48. assert.equal(identity.token, undefined)
  49. assert.ok((await localJSON(`http://127.0.0.1:${port}/healthz`)).ok)
  50. const denied = await page.evaluate(async () => {
  51. try { await window.yaoyaoDesktop.retry();return false } catch { return true }
  52. })
  53. assert.equal(denied, true, 'the web app must not have the boot screen native control privilege')
  54. const registration=await page.evaluate(async()=>{
  55. const bootstrap=await (await fetch('/api/app/bootstrap')).json()
  56. const headers={'Content-Type':'application/json','X-CSRF-Token':bootstrap.csrfToken}
  57. const setup=await fetch('/api/app/login',{method:'POST',headers,body:JSON.stringify({username:'desktop-fixture',password:'desktop-fixture-password'})})
  58. if(!setup.ok)throw new Error('fixture setup failed')
  59. headers['X-CSRF-Token']=(await (await fetch('/api/app/bootstrap')).json()).csrfToken
  60. const response=await fetch('/api/app/admin/runners',{method:'POST',headers,body:JSON.stringify({name:'桌面验收节点',allowedProfiles:['default']})})
  61. if(!response.ok)throw new Error(`fixture registration failed HTTP ${response.status}: ${(await response.json()).code}`)
  62. return response.json()
  63. })
  64. runnerID=registration.runner.id
  65. const configuration={protocol:1,runnerId:runnerID,token:registration.token,serverURL:`http://127.0.0.1:${port}`,hermesURL:`http://127.0.0.1:${upstreamPort}`,allowedProfiles:['default'],artifactRoots:[]}
  66. const imported=join(home,'downloaded-runner.json');await writeFile(imported,JSON.stringify(configuration),{mode:0o600})
  67. await desktop.evaluate(({dialog,Menu},path)=>{
  68. dialog.showOpenDialog=async()=>({canceled:false,filePaths:[path]})
  69. Menu.getApplicationMenu().getMenuItemById('runner-import').click()
  70. },imported)
  71. await until(()=>page.evaluate(async()=>{const data=await (await fetch('/api/app/admin/runners')).json();return data.runners?.some(r=>r.online)}),Boolean)
  72. const encrypted=await readFile(join(home,'desktop-runner.enc'))
  73. assert.equal(encrypted.includes(registration.token),false)
  74. assert.equal((await stat(join(home,'desktop-runner.enc'))).mode&0o077,0)
  75. runnerPID=JSON.parse(await readFile(join(home,'runner-state',runnerID,'service-instance.json'),'utf8')).pid
  76. await rm(imported)
  77. await desktop.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].close())
  78. assert.equal(await desktop.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isVisible()), false)
  79. assert.ok((await localJSON(`http://127.0.0.1:${port}/healthz`)).ok)
  80. await desktop.evaluate(({ app }) => app.emit('activate'))
  81. assert.equal(await desktop.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isVisible()), true)
  82. await mkdir(join(root, 'test-results/desktop'), { recursive: true })
  83. await page.screenshot({ path: join(root, 'test-results/desktop/first-launch.png') })
  84. process.kill(owner.pid, 'SIGKILL')
  85. const replacement = await until(async () => JSON.parse(await readFile(join(home, 'service-instance.json'), 'utf8')), r => r.pid !== owner.pid && r.url)
  86. servicePID = replacement.pid
  87. await until(() => localJSON(`http://127.0.0.1:${port}/healthz`), r => r.ok)
  88. assert.notEqual(replacement.instanceId, owner.instanceId)
  89. await until(()=>page.evaluate(async()=>{const data=await (await fetch('/api/app/admin/runners')).json();return data.runners?.some(r=>r.online)}),Boolean)
  90. const updateStatus=await page.evaluate(async()=>await (await fetch('/api/app/system/update/status')).json())
  91. assert.equal(updateStatus.installationMode,'desktop');assert.equal(updateStatus.supported,false)
  92. assert.equal(await desktop.evaluate(({Menu})=>Menu.getApplicationMenu().getMenuItemById('desktop-background').checked),false)
  93. await desktop.evaluate(async({Menu})=>{const item=Menu.getApplicationMenu().getMenuItemById('desktop-background');await item.click(item)})
  94. assert.deepEqual(await until(async()=>JSON.parse(await readFile(join(home,'desktop-preferences.json'),'utf8')),value=>value.backgroundAtLogin===true,5000),{backgroundAtLogin:true,startupChoice:'ask',remoteServer:''})
  95. const external = new DesktopServiceManager({ home, port, fork: () => { throw new Error('must not fork a second service') } })
  96. assert.equal((await external.start()).external, true)
  97. await external.stop()
  98. assert.ok((await localJSON(`http://127.0.0.1:${port}/healthz`)).ok, 'a connected client must not stop the owned service')
  99. await desktop.close();desktop = null
  100. await until(async () => { try { process.kill(replacement.pid, 0);return false } catch { return true } }, Boolean, 10000)
  101. await until(async()=>{try{process.kill(runnerPID,0);return false}catch{return true}},Boolean,10000)
  102. desktop=await electron.launch({...(process.env.DESKTOP_TEST_EXECUTABLE?{executablePath:process.env.DESKTOP_TEST_EXECUTABLE}:{}),args:process.env.DESKTOP_TEST_EXECUTABLE?[]:[root],cwd:root,env:{...process.env,HERMES_YAOYAO_DESKTOP_TEST_HOME:home,HERMES_YAOYAO_DESKTOP_PORT:String(port),HERMES_YAOYAO_UPSTREAM:`http://127.0.0.1:${upstreamPort}`}})
  103. const reopened=await desktop.firstWindow();await reopened.waitForURL(`http://127.0.0.1:${port}/**`)
  104. await until(()=>reopened.evaluate(async()=>{const data=await (await fetch('/api/app/admin/runners')).json();return data.runners?.some(r=>r.online)}),Boolean)
  105. assert.equal(await desktop.evaluate(({Menu})=>Menu.getApplicationMenu().getMenuItemById('desktop-background').checked),true)
  106. assert.equal(await desktop.evaluate(({BrowserWindow})=>BrowserWindow.getAllWindows()[0].isVisible()),true,'manual reopening still shows the window')
  107. servicePID=JSON.parse(await readFile(join(home,'service-instance.json'),'utf8')).pid
  108. runnerPID=JSON.parse(await readFile(join(home,'runner-state',runnerID,'service-instance.json'),'utf8')).pid
  109. await desktop.close();desktop=null
  110. await until(async()=>{try{process.kill(runnerPID,0);return false}catch{return true}},Boolean,10000)
  111. } finally {
  112. await desktop?.close().catch(() => {})
  113. if(runnerPID){try{process.kill(runnerPID,'SIGTERM')}catch{}}
  114. if (servicePID) { try { process.kill(servicePID, 'SIGTERM') } catch {} }
  115. upstream.kill('SIGTERM')
  116. const account=createHash('sha256').update(await realpath(home)).digest('hex')
  117. await promisify(execFile)('/usr/bin/security',['delete-generic-password','-s','cn.samien.yaoyao.runner-key.v1','-a',account]).catch(()=>{})
  118. await rm(home, { recursive: true, force: true })
  119. }
  120. })