npmUpdate.spec.ts 6.2 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
  1. import { expect, test } from '@playwright/test'
  2. import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  3. import { createHash } from 'node:crypto'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { pathToFileURL } from 'node:url'
  7. import { once } from 'node:events'
  8. import type { AddressInfo } from 'node:net'
  9. import serve from 'koa-static'
  10. import send from 'koa-send'
  11. import { writeNpmFixture } from '../fixtures/npm-release.mjs'
  12. test('npm update downloads in place, survives reopening, and refreshes only after explicit server restart', async ({ page }, testInfo) => {
  13. const root = mkdtempSync(join(tmpdir(), 'yaoyao-npm-ui-')), home = join(root, 'data'), projectRoot = join(root, 'initial')
  14. const current = JSON.parse(readFileSync('release.json', 'utf8'))
  15. const version = current.webVersion.split('.').map((value: string, index: number) => index === 2 ? String(Number(value) + 1) : value).join('.')
  16. writeNpmFixture(projectRoot, current.webVersion)
  17. const releaseRoot = join(root, 'programs')
  18. const target = { ...current, releaseVersion: version, webVersion: version, gitTag: `v${version}` }
  19. const archive = Buffer.from('fixture npm archive')
  20. const { createApplication, createNodeServer } = await import(pathToFileURL(join(process.cwd(), 'dist-server/server/app.js')).href)
  21. const { SystemUpdateManager } = await import(pathToFileURL(join(process.cwd(), 'dist-server/server/updateManager.js')).href)
  22. const config = { host: '127.0.0.1', port: 0, upstream: new URL('http://127.0.0.1:1'), allowedHosts: new Set(['127.0.0.1']),
  23. home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home, mediaOwner: 'test', allowInsecureLan: false, insecureLan: false, production: true,
  24. releaseRoot, releaseSource: 'npm:@lsamien/yaoyao' }
  25. let finishDownload!: () => void, downloads = 0, restarts = 0, confirmations = 0
  26. const updates = new SystemUpdateManager(config, { projectRoot, platform: 'darwin',
  27. inspectRemote: async () => ({ manifest: target, npm: { version, tarball: 'https://registry.npmjs.org/fixture.tgz', integrity: `sha512-${createHash('sha512').update(archive).digest('base64')}` } }),
  28. // Native archive installation and actual process replacement are covered by
  29. // verify-npm-update.mjs; this fixture exercises the real UI/API/state flow.
  30. launchUpdater: (path: string) => {
  31. const job = JSON.parse(readFileSync(path, 'utf8'))
  32. if (job.plan.prepared) {
  33. restarts++
  34. writeFileSync(join(projectRoot, 'release.json'), JSON.stringify(target))
  35. writeFileSync(path, JSON.stringify({ ...job, state: 'succeeded', message: '服务器更新完成' }))
  36. rmSync(join(home, 'updates/active.lock'))
  37. } else {
  38. downloads++
  39. writeFileSync(path, JSON.stringify({ ...job, state: 'downloading', received: 37, total: 100, message: '正在下载服务器更新 · 37%' }))
  40. finishDownload = () => {
  41. const prepared = { archive: join(home, 'updates/npm', job.id, 'release.tgz') }
  42. mkdirSync(join(prepared.archive, '..'), { recursive: true }); writeFileSync(prepared.archive, archive)
  43. writeFileSync(path, JSON.stringify({ ...job, state: 'prepared', message: '新版本已下载完成,点击重启服务器覆盖更新', plan: { ...job.plan, prepared } }))
  44. rmSync(join(home, 'updates/active.lock'))
  45. }
  46. }
  47. },
  48. })
  49. const runtime = createApplication({ config, updates })
  50. runtime.app.use(serve(join(process.cwd(), 'dist')))
  51. runtime.app.use(async (ctx: Parameters<ReturnType<typeof serve>>[0]) => {
  52. if (ctx.method === 'GET' && ctx.accepts('html')) await send(ctx, 'index.html', { root: join(process.cwd(), 'dist') })
  53. })
  54. const node = createNodeServer(runtime); node.server.listen(0, '127.0.0.1'); await once(node.server, 'listening')
  55. const origin = `http://127.0.0.1:${(node.server.address() as AddressInfo).port}`
  56. async function openUpdates() {
  57. await page.getByRole('button', { name: /^(打开我的设置|设置与模式)$/ }).click()
  58. await page.getByRole('menuitem', { name: '我的设置', exact: true }).click()
  59. const dialog = page.getByRole('dialog', { name: '我的设置' })
  60. await dialog.getByRole('button', { name: '更新与回滚', exact: true }).click()
  61. return dialog
  62. }
  63. try {
  64. const boot = await (await page.request.get(origin + '/api/app/bootstrap')).json()
  65. const signedIn = await page.request.post(origin + '/api/app/setup', { headers: { Origin: origin, 'X-CSRF-Token': boot.csrfToken }, data: { username: 'admin', password: 'npm-e2e-password' } })
  66. expect(signedIn.ok()).toBe(true)
  67. page.on('dialog', dialog => { confirmations++; void dialog.dismiss() })
  68. await page.goto(origin + '/chat')
  69. let dialog = await openUpdates()
  70. await expect(dialog).toContainText('不保留旧版本')
  71. await expect(dialog.getByRole('button', { name: '回滚上一版本', exact: true })).toHaveCount(0)
  72. await dialog.getByRole('button', { name: '下载更新', exact: true }).click()
  73. await expect(dialog.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '37')
  74. expect(downloads).toBe(1); expect(restarts).toBe(0)
  75. await page.screenshot({ path: testInfo.outputPath('npm-downloading.png') })
  76. finishDownload()
  77. await expect(dialog.getByRole('button', { name: '重启服务器', exact: true })).toBeEnabled()
  78. await expect(dialog.locator('.version-grid')).toContainText(version)
  79. await page.screenshot({ path: testInfo.outputPath('npm-ready.png') })
  80. await page.reload()
  81. dialog = await openUpdates()
  82. await expect(dialog.getByRole('button', { name: '重启服务器', exact: true })).toBeEnabled()
  83. expect(restarts).toBe(0)
  84. const refreshed = page.waitForEvent('framenavigated', { predicate: frame => frame === page.mainFrame() })
  85. await dialog.getByRole('button', { name: '重启服务器', exact: true }).click()
  86. await refreshed
  87. expect(restarts).toBe(1); expect(confirmations).toBe(0)
  88. const status = await (await page.request.get(origin + '/api/app/system/update/status')).json()
  89. expect(status.current.webVersion).toBe(version)
  90. expect(status.job.state).toBe('succeeded')
  91. } finally {
  92. await page.goto('about:blank')
  93. node.server.closeAllConnections(); await node.close()
  94. rmSync(root, { recursive: true, force: true })
  95. }
  96. })