update-restart.test.mjs 7.0 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
  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. for (const scenario of ['remote', 'local', 'remote-failure', 'local-failure', 'remote-checking-failure']) test(`${scenario} restart releases the lock, preserves independent servers and recovers on failure`, { timeout: 60000 }, async () => {
  10. const mode = scenario.startsWith('local') ? 'local' : 'remote', fail = scenario.endsWith('failure'), checking = scenario === 'remote-checking-failure'
  11. const root = resolve(import.meta.dirname, '..'), home = await realpath(await mkdtemp(join(tmpdir(), 'yaoyao-update-restart-')))
  12. const version = JSON.parse(await readFile(join(root, 'release.json'), 'utf8')).webVersion
  13. const identity = { protocol: 1, instanceId: 'update-restart', pid: process.pid, dataKey: dataKey(home), version }
  14. let activationRequired = true, activations = 0, inspections = 0
  15. let releaseIdentity; const identityReady = new Promise(resolve => { releaseIdentity = resolve })
  16. const server = createServer(async (req, res) => {
  17. if (req.url === '/desktop/service/session') { res.setHeader('Content-Type','application/json'); res.end(JSON.stringify({user:{role:'admin',localDesktop:true},setCookies:['hermes_yaoyao_session=fixture; Path=/; HttpOnly; Max-Age=31536000']})); return }
  18. if (req.url === '/api/app/desktop-session') { res.setHeader('Set-Cookie','hermes_yaoyao_session=fixture; Path=/; HttpOnly; Max-Age=31536000'); res.end('{}'); return }
  19. res.setHeader('Content-Type', 'application/json')
  20. if (req.url === '/desktop/service') { await identityReady; inspections++; res.end(JSON.stringify({ ...identity, activationRequired })) }
  21. else if (req.url === '/desktop/service/activate') { assert.equal(req.method, 'POST'); assert.equal(req.headers['x-yaoyao-desktop-token'], 'fixture'); if (activationRequired) activations++; activationRequired = false; res.end(JSON.stringify({ activated: true })) }
  22. else if (req.url.split('?')[0] === '/api/app/bootstrap') res.end(JSON.stringify({ authenticated: !checking, csrfToken: 'fixture', user: { role: 'member' } }))
  23. else { res.setHeader('Content-Type', 'text/html'); res.end('<title>Connected server</title>') }
  24. })
  25. await new Promise(done => server.listen(0, '127.0.0.1', done))
  26. const origin = `http://127.0.0.1:${server.address().port}`
  27. await writeFile(join(home, 'desktop-preferences.json'), JSON.stringify({ startupChoice: mode, remoteServer: origin }))
  28. if (mode === 'local') await writeFile(join(home, 'service-instance.json'), JSON.stringify({ ...identity, url: origin, token: 'fixture' }))
  29. let app
  30. try {
  31. app = await electron.launch({ args: [root], cwd: root, env: { ...process.env, HERMES_YAOYAO_DESKTOP_TEST_HOME: home,
  32. HERMES_YAOYAO_DESKTOP_TEST_AUTO_UPDATE: '1', HERMES_YAOYAO_DESKTOP_TEST_SYNC: '0', HERMES_YAOYAO_DESKTOP_PORT: mode === 'local' ? String(server.address().port) : '1' } })
  33. const page = await app.firstWindow(); releaseIdentity()
  34. if (checking) await expect.poll(() => page.evaluate(() => window.yaoyaoDesktop.status().then(state => state.phase))).toBe('ready')
  35. else await page.waitForURL(origin + '/**')
  36. assert.equal(activations, mode === 'local' && !checking ? 1 : 0)
  37. if (mode === 'local' && fail) {
  38. // Failed quit must resume only a local runtime the user already entered.
  39. activationRequired = true
  40. const before = inspections
  41. await app.evaluate(({ dialog, Menu }) => { dialog.showMessageBox = async () => ({ response: 0 }); Menu.getApplicationMenu().getMenuItemById('desktop-stop-and-quit').click() })
  42. await expect.poll(() => inspections).toBeGreaterThan(before)
  43. await expect.poll(() => app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isVisible())).toBe(true)
  44. if (!checking) await expect.poll(() => activations).toBe(2)
  45. else assert.equal(activations, 0)
  46. }
  47. await app.evaluate(async ({ app }, { root, home, fail }) => {
  48. const { createRequire } = process.getBuiltinModule('node:module'), { writeFileSync } = process.getBuiltinModule('node:fs')
  49. const { autoUpdater } = createRequire(root + '/package.json')(root + '/.desktop-build/electron-updater.cjs')
  50. autoUpdater.checkForUpdates = async () => ({ isUpdateAvailable: true, updateInfo: { version: '99.0.0' } })
  51. autoUpdater.downloadUpdate = async () => { autoUpdater.emit('update-downloaded'); return [] }
  52. autoUpdater.quitAndInstall = () => {
  53. writeFileSync(home + '/restart-proof.json', JSON.stringify({ holdsAppLock: app.hasSingleInstanceLock() }))
  54. if (fail) throw new Error('fixture restart failed')
  55. app.quit()
  56. }
  57. }, { root, home, fail })
  58. if (checking || mode === 'local' && fail) {
  59. await app.evaluate(({ Menu }) => Menu.getApplicationMenu().getMenuItemById('desktop-update-check').click())
  60. await expect.poll(() => page.evaluate(() => window.yaoyaoDesktop.updateState().then(state => state.available))).toBe(true)
  61. } else await page.evaluate(() => window.yaoyaoDesktop.openUpdates())
  62. assert.equal(app.windows().length, 1)
  63. await page.evaluate(() => window.yaoyaoDesktop.updateAction('download'))
  64. await expect.poll(() => page.evaluate(() => window.yaoyaoDesktop.updateState().then(state => state.phase))).toBe('ready')
  65. const closed = fail ? undefined : app.waitForEvent('close')
  66. if (mode === 'local' && fail) activationRequired = true
  67. await page.evaluate(() => window.yaoyaoDesktop.updateAction('install')).catch(error => {
  68. // Recovery can replace the main renderer while this IPC is completing.
  69. if (!page.isClosed() && !(fail && /Execution context was destroyed/.test(error.message))) throw error
  70. })
  71. if (fail) {
  72. await expect.poll(() => page.evaluate(() => window.yaoyaoDesktop.updateState().then(state => state.error)).catch(() => '')).toContain('fixture restart failed')
  73. assert.equal(await app.evaluate(({ app }) => app.hasSingleInstanceLock()), true)
  74. if (checking) {
  75. assert.equal((await page.evaluate(() => window.yaoyaoDesktop.status())).active, true)
  76. assert.equal(activations, 0)
  77. } else {
  78. await page.waitForURL(origin + '/**')
  79. await expect.poll(() => page.evaluate(() => window.yaoyaoDesktop.modeState().then(state => state.mode)).catch(() => null)).toBe(mode === 'local' ? 'server' : 'client')
  80. if (mode === 'local') assert.equal(activations, 3)
  81. }
  82. } else { await closed; app = undefined }
  83. assert.deepEqual(JSON.parse(await readFile(join(home, 'restart-proof.json'), 'utf8')), { holdsAppLock: false })
  84. assert.equal((await fetch(origin + '/api/app/bootstrap')).status, 200)
  85. } finally {
  86. releaseIdentity(); await app?.close().catch(() => {})
  87. await new Promise(done => { server.close(done); server.closeAllConnections() })
  88. await rm(home, { recursive: true, force: true })
  89. }
  90. })