systemUpdatePanel.test.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. import { flushPromises, mount } from '@vue/test-utils'
  2. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  3. import SystemUpdatePanel from '@/components/app/SystemUpdatePanel.vue'
  4. const api = vi.hoisted(() => ({
  5. applySystemUpdate: vi.fn(),
  6. activateSystemUpdate: vi.fn(),
  7. checkSystemUpdate: vi.fn(),
  8. rollbackSystemUpdate: vi.fn(),
  9. systemUpdateJob: vi.fn(),
  10. systemUpdateStatus: vi.fn(),
  11. }))
  12. vi.mock('@/api/systemUpdate', () => api)
  13. const manifest = {
  14. schemaVersion: 1 as const,
  15. releaseVersion: '0.2.17',
  16. webVersion: '0.2.17',
  17. pluginVersion: '1.7.3',
  18. gitTag: 'v0.2.17',
  19. }
  20. const activeJob = {
  21. id: 'job-1',
  22. operation: 'update' as const,
  23. state: 'verifying' as const,
  24. message: '正在验证',
  25. createdAt: '2026-08-29T00:00:00Z',
  26. updatedAt: '2026-08-29T00:00:01Z',
  27. }
  28. const doneJob = { ...activeJob, state: 'succeeded' as const, message: '更新完成' }
  29. const readyStatus = {
  30. current: manifest,
  31. installedPluginVersion: '1.7.3',
  32. versionsMatch: true,
  33. installationMode: 'release' as const,
  34. latest: manifest,
  35. updateAvailable: false,
  36. supported: true,
  37. canRollback: true,
  38. }
  39. beforeEach(() => {
  40. vi.useFakeTimers()
  41. api.systemUpdateStatus.mockReset()
  42. api.checkSystemUpdate.mockReset()
  43. api.systemUpdateJob.mockReset()
  44. api.applySystemUpdate.mockReset()
  45. api.activateSystemUpdate.mockReset()
  46. api.rollbackSystemUpdate.mockReset()
  47. api.systemUpdateStatus.mockResolvedValue(readyStatus)
  48. api.checkSystemUpdate.mockResolvedValue(readyStatus)
  49. api.systemUpdateJob.mockResolvedValue(doneJob)
  50. })
  51. afterEach(() => {
  52. document.body.innerHTML = ''
  53. vi.clearAllTimers()
  54. vi.useRealTimers()
  55. vi.restoreAllMocks()
  56. })
  57. describe('SystemUpdatePanel', () => {
  58. it('downloads npm updates without restarting and restores the restart action after reopening offline', async () => {
  59. const target = { ...manifest, releaseVersion: '0.3.0', webVersion: '0.3.0', gitTag: 'v0.3.0' }
  60. const npmStatus = { ...readyStatus, updateMethod: 'npm', installationMode: 'npm', latest: target, updateAvailable: true }
  61. const preparedJob = { ...activeJob, state: 'prepared', target, message: '服务器更新已准备完成' }
  62. api.systemUpdateStatus.mockResolvedValue(npmStatus)
  63. api.checkSystemUpdate.mockResolvedValue(npmStatus)
  64. api.applySystemUpdate.mockResolvedValue({ ...activeJob, state: 'downloading', received: 37, total: 100 })
  65. api.systemUpdateJob.mockResolvedValue(preparedJob)
  66. const confirm = vi.spyOn(window, 'confirm')
  67. let wrapper = mount(SystemUpdatePanel, { global: { stubs: { AppIcon: true } } })
  68. await flushPromises()
  69. expect(wrapper.text()).toContain('不保留旧版本')
  70. expect(wrapper.text()).not.toContain('回滚上一版本')
  71. await wrapper.get('.solid-button').trigger('click'); await flushPromises()
  72. expect(api.applySystemUpdate).toHaveBeenCalledWith('0.3.0')
  73. expect(confirm).not.toHaveBeenCalled()
  74. expect(wrapper.get('[role="progressbar"]').attributes('aria-valuenow')).toBe('37')
  75. api.systemUpdateStatus.mockResolvedValue({ ...npmStatus, latest: undefined, job: preparedJob })
  76. await vi.advanceTimersByTimeAsync(1000); await flushPromises()
  77. expect(wrapper.get('.restart-server').text()).toContain('重启服务器')
  78. expect(wrapper.get('.version-grid').text()).toContain('0.3.0')
  79. expect(wrapper.emitted('lock-change')?.at(-1)).toEqual([false])
  80. expect(api.activateSystemUpdate).not.toHaveBeenCalled()
  81. wrapper.unmount()
  82. api.checkSystemUpdate.mockRejectedValue(new Error('npm 暂时离线'))
  83. wrapper = mount(SystemUpdatePanel, { global: { stubs: { AppIcon: true } } })
  84. await flushPromises()
  85. expect(wrapper.get<HTMLButtonElement>('.restart-server').element.disabled).toBe(false)
  86. let finish!: (job: typeof activeJob) => void
  87. api.activateSystemUpdate.mockReturnValue(new Promise(resolve => { finish = resolve }))
  88. await wrapper.get('.restart-server').trigger('click'); await wrapper.get('.restart-server').trigger('click')
  89. expect(api.activateSystemUpdate).toHaveBeenCalledExactlyOnceWith('job-1')
  90. await wrapper.setProps({ active: false }); finish(activeJob); await flushPromises()
  91. wrapper.unmount()
  92. })
  93. it('hides a previous successful update that does not match the running Web version', async () => {
  94. const old = { ...doneJob, target: { ...manifest, webVersion: '0.1.0', releaseVersion: '0.1.0' }, message: '已升级 Web 0.1.0' }
  95. api.systemUpdateStatus.mockResolvedValue({ ...readyStatus, job: old })
  96. api.checkSystemUpdate.mockResolvedValue({ ...readyStatus, job: old })
  97. const wrapper = mount(SystemUpdatePanel, { global: { stubs: { AppIcon: true } } })
  98. await flushPromises()
  99. expect(wrapper.text()).toContain(manifest.webVersion)
  100. expect(wrapper.text()).not.toContain('已升级 Web 0.1.0')
  101. expect(wrapper.text()).toContain('回滚上一版本')
  102. wrapper.unmount()
  103. })
  104. it('clears a finished job when refreshed status no longer includes it', async () => {
  105. api.systemUpdateStatus.mockResolvedValue({ ...readyStatus, job: { ...doneJob, target: manifest } })
  106. const wrapper = mount(SystemUpdatePanel, { global: { stubs: { AppIcon: true } } })
  107. await flushPromises()
  108. expect(wrapper.find('.update-progress').exists()).toBe(false)
  109. wrapper.unmount()
  110. })
  111. it('shows GitHub failures without claiming the installed version is latest', async () => {
  112. api.systemUpdateStatus.mockResolvedValue({ ...readyStatus, releaseSource: 'https://github.com/Lsamien/yaoyao.git', releasePageUrl: 'https://github.com/Lsamien/yaoyao/releases' })
  113. api.checkSystemUpdate.mockRejectedValue(new Error('GitHub 请求受限,请稍后重试'))
  114. const wrapper = mount(SystemUpdatePanel, { global: { stubs: { AppIcon: true } } })
  115. await flushPromises()
  116. expect(wrapper.text()).toContain('GitHub 请求受限')
  117. expect(wrapper.text()).not.toContain('已是最新版本')
  118. expect(wrapper.get('.solid-button').attributes('disabled')).toBeDefined()
  119. expect(wrapper.get('a').attributes('href')).toBe('https://github.com/Lsamien/yaoyao/releases')
  120. wrapper.unmount()
  121. })
  122. it('allows Web updates with unknown plugin state and explains the offline boundary', async () => {
  123. const offlineStatus = { ...readyStatus, installedPluginVersion: undefined, versionsMatch: false, updateAvailable: true,
  124. latest: { ...manifest, releaseVersion: '0.3.0', webVersion: '0.3.0', gitTag: 'v0.3.0' } }
  125. api.systemUpdateStatus.mockResolvedValue(offlineStatus)
  126. api.checkSystemUpdate.mockResolvedValue(offlineStatus)
  127. api.applySystemUpdate.mockResolvedValue(doneJob)
  128. const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true)
  129. const wrapper = mount(SystemUpdatePanel, { global: { stubs: { AppIcon: true } } })
  130. await flushPromises()
  131. expect(wrapper.text()).toContain('查看 Web 版本、检查更新或回滚到上一个版本')
  132. expect(wrapper.text()).not.toContain('当前插件')
  133. expect(wrapper.find('.version-warning').exists()).toBe(false)
  134. const apply = wrapper.find<HTMLButtonElement>('.solid-button')
  135. expect(apply.element.disabled).toBe(false)
  136. await apply.trigger('click'); await flushPromises()
  137. expect(confirm).toHaveBeenCalledWith('将 Web 升级到 0.3.0?')
  138. expect(api.applySystemUpdate).toHaveBeenCalledWith('0.3.0')
  139. expect(wrapper.text()).toContain('更新完成')
  140. wrapper.unmount()
  141. })
  142. it('resumes polling an existing non-terminal job and releases the settings lock', async () => {
  143. api.systemUpdateStatus
  144. .mockResolvedValueOnce({ ...readyStatus, job: activeJob })
  145. .mockResolvedValueOnce({ ...readyStatus, job: doneJob })
  146. const wrapper = mount(SystemUpdatePanel, {
  147. props: { active: true },
  148. global: { stubs: { AppIcon: true } },
  149. })
  150. await flushPromises()
  151. expect(wrapper.emitted('lock-change')?.some(event => event[0] === true)).toBe(true)
  152. await vi.advanceTimersByTimeAsync(1_000)
  153. await flushPromises()
  154. expect(api.systemUpdateJob).toHaveBeenCalledWith('job-1')
  155. expect(wrapper.text()).toContain('更新完成')
  156. expect(wrapper.emitted('lock-change')?.at(-1)).toEqual([false])
  157. expect(api.checkSystemUpdate).not.toHaveBeenCalled()
  158. })
  159. it('locks while an update mutation is starting and ignores a stale response after deactivation', async () => {
  160. const updateAvailable = {
  161. ...readyStatus,
  162. latest: { ...manifest, releaseVersion: '0.2.18', webVersion: '0.2.18', gitTag: 'v0.2.18' },
  163. updateAvailable: true,
  164. }
  165. api.systemUpdateStatus.mockResolvedValue(updateAvailable)
  166. api.checkSystemUpdate.mockResolvedValue(updateAvailable)
  167. let resolveApply: ((job: typeof activeJob) => void) | undefined
  168. api.applySystemUpdate.mockReturnValue(new Promise(resolve => { resolveApply = resolve }))
  169. vi.spyOn(window, 'confirm').mockReturnValue(true)
  170. const wrapper = mount(SystemUpdatePanel, {
  171. props: { active: true },
  172. global: { stubs: { AppIcon: true } },
  173. })
  174. await flushPromises()
  175. const apply = wrapper.findAll<HTMLButtonElement>('button').find(button => button.text().includes('升级 Web'))!
  176. await apply.trigger('click')
  177. await wrapper.vm.$nextTick()
  178. expect(wrapper.emitted('lock-change')?.at(-1)).toEqual([true])
  179. const check = wrapper.findAll<HTMLButtonElement>('button').find(button => button.text().includes('检查更新'))!
  180. expect(check.element.disabled).toBe(true)
  181. await wrapper.setProps({ active: false })
  182. resolveApply?.(activeJob)
  183. await flushPromises()
  184. expect(api.systemUpdateJob).not.toHaveBeenCalled()
  185. expect(wrapper.emitted('lock-change')?.at(-1)).toEqual([false])
  186. })
  187. })