inline-update.test.mjs 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
  1. import { test } from 'node:test'
  2. import assert from 'node:assert/strict'
  3. import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
  4. import { createServer } from 'node:net'
  5. import { spawn } from 'node:child_process'
  6. import { tmpdir } from 'node:os'
  7. import { join, resolve } from 'node:path'
  8. import { _electron as electron, expect } from '@playwright/test'
  9. async function freePort() {
  10. const server = createServer()
  11. await new Promise(done => server.listen(0, '127.0.0.1', done))
  12. const port = server.address().port
  13. await new Promise(done => server.close(done))
  14. return port
  15. }
  16. test('the desktop update icon appears beside the bell only for updates and waits for an explicit restart', { timeout: 60000 }, async () => {
  17. const root = resolve(import.meta.dirname, '..'), home = await mkdtemp(join(tmpdir(), 'yaoyao-inline-update-'))
  18. const port = await freePort(), upstream = await freePort(), origin = `http://127.0.0.1:${port}`
  19. await mkdir(join(home, 'server'))
  20. const server = spawn(process.execPath, ['--import', 'tsx', 'tests/fixtures/workspace-server.ts'], {
  21. cwd: root, env: { ...process.env, WORKSPACE_FIXTURE_HOME: join(home, 'server'), WORKSPACE_FIXTURE_PORT: String(port), WORKSPACE_FIXTURE_UPSTREAM_PORT: String(upstream) }, stdio: 'ignore',
  22. })
  23. const desktopHome = join(home, 'desktop'); await mkdir(desktopHome)
  24. await writeFile(join(desktopHome, 'desktop-preferences.json'), JSON.stringify({ startupChoice: 'remote', remoteServer: origin }))
  25. let app
  26. try {
  27. await expect.poll(async () => { try { return (await fetch(origin + '/healthz')).status } catch { return 0 } }, { timeout: 30000 }).toBe(200)
  28. app = await electron.launch({ args: [root], cwd: root, env: { ...process.env, HERMES_YAOYAO_DESKTOP_CLIENT_ONLY: '1', HERMES_YAOYAO_DESKTOP_TEST_HOME: desktopHome, HERMES_YAOYAO_DESKTOP_TEST_AUTO_UPDATE: '1' } })
  29. const page = await app.firstWindow()
  30. await app.evaluate(({ app }, root) => {
  31. const { createRequire } = process.getBuiltinModule('node:module')
  32. const { autoUpdater } = createRequire(root + '/package.json')(root + '/.desktop-build-client/electron-updater.cjs')
  33. globalThis.inlineFixture = { installs: 0, available: false }
  34. autoUpdater.checkForUpdates = async () => ({ isUpdateAvailable: inlineFixture.available, updateInfo: { version: '99.0.0' } })
  35. autoUpdater.downloadUpdate = () => new Promise(resolve => {
  36. inlineFixture.ready = () => resolve([])
  37. inlineFixture.stage = () => autoUpdater.emit('update-downloaded')
  38. autoUpdater.emit('download-progress', { transferred: 37, total: 100 })
  39. })
  40. autoUpdater.quitAndInstall = () => { inlineFixture.installs++ }
  41. }, root)
  42. await expect.poll(() => page.evaluate(() => window.yaoyaoDesktop.status().then(state => state.phase))).toBe('ready')
  43. await page.reload()
  44. await page.locator('#username').fill('fixture')
  45. await page.locator('#password').fill('fixture-pass')
  46. await page.locator('#submit').click()
  47. await page.waitForURL(origin + '/**')
  48. await page.goto(origin + '/conversations')
  49. await expect(page.locator('.desktop-sidebar .sidebar-account-switcher__main')).toBeVisible()
  50. const entry = page.locator('.desktop-sidebar .desktop-update-entry')
  51. await page.evaluate(() => window.yaoyaoDesktop.openUpdates())
  52. await expect(entry).toHaveCount(0)
  53. await expect(page.locator('#desktop-update-entry')).toHaveCount(0)
  54. // Older servers receive the local updater script through the real main
  55. // process. Its entry must follow the same bell placement and visibility.
  56. await page.route(origin + '/legacy-update', route => route.fulfill({ contentType: 'text/html', body: '<aside class="desktop-sidebar"><div class="bot-list-header"><button class="unread-entry">铃铛</button><button>搜索</button></div></aside>' }))
  57. await page.goto(origin + '/legacy-update')
  58. await expect(page.locator('#desktop-update-entry')).toBeHidden()
  59. await page.evaluate(() => window.yaoyaoDesktop.openUpdates())
  60. await expect(page.locator('#desktop-update-entry')).toBeHidden()
  61. await app.evaluate(() => { inlineFixture.available = true })
  62. await page.evaluate(() => window.yaoyaoDesktop.updateAction('check'))
  63. await expect(page.locator('.unread-entry + #desktop-update-entry').getByRole('button', { name: '可更新', exact: true })).toBeVisible()
  64. await page.goto(origin + '/conversations')
  65. await expect(entry.getByRole('button', { name: '可更新', exact: true })).toBeVisible()
  66. await expect(page.locator('#desktop-update-entry')).toHaveCount(0)
  67. assert.equal(await entry.evaluate(element => element.previousElementSibling.classList.contains('unread-entry')), true)
  68. await expect(page.locator('.sidebar-footer .desktop-update-entry')).toHaveCount(0)
  69. assert.equal(app.windows().length, 1)
  70. const output = join(root, 'test-results/desktop/inline-workspace'); await mkdir(output, { recursive: true })
  71. await page.screenshot({ path: join(output, 'available.png') })
  72. await entry.getByRole('button', { name: '可更新', exact: true }).click()
  73. await expect(entry.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '37')
  74. await expect(page.locator('#desktop-update-entry')).toHaveCount(0)
  75. await page.screenshot({ path: join(output, 'downloading.png') })
  76. await page.locator('.desktop-sidebar .sidebar-account-switcher__main').click()
  77. await expect(page.locator('.workspace-settings-menu .desktop-update-entry')).toHaveCount(0)
  78. await expect(page.locator('.desktop-update-check')).toBeDisabled()
  79. await page.keyboard.press('Escape')
  80. await app.evaluate(() => inlineFixture.stage())
  81. await expect(entry).toContainText('正在准备…')
  82. await page.reload()
  83. await expect(entry).toContainText('正在准备…')
  84. await expect(entry.getByRole('button')).toBeDisabled()
  85. await app.evaluate(() => inlineFixture.ready())
  86. await expect(entry.getByRole('button', { name: '重新启动', exact: true })).toBeEnabled()
  87. await expect(entry).toHaveClass(/desktop-update-entry--ready/)
  88. assert.equal(await app.evaluate(() => inlineFixture.installs), 0)
  89. assert.equal(app.windows().length, 1)
  90. await page.screenshot({ path: join(output, 'ready.png') })
  91. await page.evaluate(() => document.documentElement.classList.add('dark'))
  92. await page.screenshot({ path: join(output, 'ready-dark.png') })
  93. await page.goto(origin + '/legacy-update')
  94. await expect(page.locator('.unread-entry + #desktop-update-entry').getByRole('button', { name: '重新启动', exact: true })).toBeVisible()
  95. await expect(page.locator('#desktop-update-icon')).toBeVisible()
  96. await expect(page.locator('#desktop-update-ring')).toBeHidden()
  97. await page.goto(origin + '/chat')
  98. await expect(page.locator('.sidebar-status-actions .desktop-update-entry')).toBeVisible()
  99. await expect(page.locator('#desktop-update-entry')).toHaveCount(0)
  100. await page.screenshot({ path: join(output, 'ready-chat.png') })
  101. await page.getByRole('button', { name: '折叠侧边栏', exact: true }).click()
  102. await expect(entry.getByRole('button', { name: '重新启动', exact: true })).toBeVisible()
  103. await page.screenshot({ path: join(output, 'ready-collapsed.png') })
  104. await entry.getByRole('button', { name: '重新启动', exact: true }).click()
  105. await expect.poll(() => app.evaluate(() => inlineFixture.installs)).toBe(1)
  106. await expect(entry.getByRole('button', { name: '正在重启…', exact: true })).toBeDisabled()
  107. assert.equal((await fetch(origin + '/healthz')).status, 200)
  108. } finally {
  109. await app?.close().catch(() => {})
  110. server.kill('SIGTERM')
  111. await new Promise(done => {
  112. if (server.exitCode !== null) return done()
  113. const timer = setTimeout(() => { server.kill('SIGKILL'); done() }, 3000)
  114. server.once('exit', () => { clearTimeout(timer); done() })
  115. })
  116. await rm(home, { recursive: true, force: true })
  117. }
  118. })