updateManager.test.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
  1. import { execFileSync } from 'node:child_process'
  2. import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { afterEach, describe, expect, it } from 'vitest'
  6. import type { ServerConfig } from '../../src/server/config.js'
  7. import { compareReleaseVersions, parseReleaseManifest } from '../../src/server/releases.js'
  8. import { inspectGitRemote, releaseManifestURL, SystemUpdateManager } from '../../src/server/updateManager.js'
  9. const roots: string[] = []
  10. const current = {
  11. schemaVersion: 1 as const,
  12. releaseVersion: '0.2.0',
  13. webVersion: '0.2.0',
  14. pluginVersion: '1.7.1',
  15. gitTag: 'v0.2.0',
  16. }
  17. const latest = {
  18. schemaVersion: 1 as const,
  19. releaseVersion: '0.3.0',
  20. webVersion: '0.3.0',
  21. pluginVersion: '1.8.0',
  22. gitTag: 'v0.3.0',
  23. }
  24. const latestRemote = { manifest: latest, commit: 'a'.repeat(40) }
  25. afterEach(() => {
  26. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
  27. })
  28. function fixture() {
  29. const root = mkdtempSync(join(tmpdir(), 'hermes-update-manager-'))
  30. roots.push(root)
  31. writeFileSync(join(root, 'release.json'), JSON.stringify(current))
  32. const config: ServerConfig = {
  33. host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
  34. allowedHosts: new Set(), home: join(root, 'data'), mediaRoot: join(root, 'media'),
  35. attachmentsRoot: join(root, 'attachments'), imagesRoot: join(root, 'images'),
  36. mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
  37. releaseRoot: join(root, 'installed'), releaseSource: 'https://example.test/hermes-yaoyao.git',
  38. }
  39. const launched: string[] = []
  40. const manager = new SystemUpdateManager(config, {
  41. projectRoot: root,
  42. inspectRemote: async () => latestRemote,
  43. launchUpdater: path => { launched.push(path) },
  44. platform: 'darwin',
  45. })
  46. return { root, config, manager, launched }
  47. }
  48. describe('system release contract', () => {
  49. it('maps an HTTPS Git source to the lightweight tagged release manifest', () => {
  50. expect(releaseManifestURL(
  51. 'https://git.samien.cn/samien/hermes-yaoyao.git',
  52. 'v0.2.1',
  53. )?.href).toBe('https://git.samien.cn/samien/hermes-yaoyao/raw/v0.2.1/release.json')
  54. expect(releaseManifestURL('/srv/git/hermes-yaoyao.git', 'v0.2.1')).toBeUndefined()
  55. })
  56. it('validates paired versions and semantic ordering', () => {
  57. expect(parseReleaseManifest(current)).toEqual(current)
  58. expect(compareReleaseVersions('0.3.0', '0.2.9')).toBeGreaterThan(0)
  59. expect(compareReleaseVersions('1.0.0', '1.0.0-beta.1')).toBeGreaterThan(0)
  60. expect(() => parseReleaseManifest({ ...current, pluginVersion: 'latest' })).toThrow('pluginVersion')
  61. })
  62. it('locks an annotated remote tag to its peeled Git commit', async () => {
  63. const root = mkdtempSync(join(tmpdir(), 'hermes-update-remote-'))
  64. roots.push(root)
  65. const repository = join(root, 'repository')
  66. execFileSync('git', ['init', '-q', repository])
  67. writeFileSync(join(repository, 'release.json'), JSON.stringify(latest))
  68. execFileSync('git', ['-C', repository, 'add', 'release.json'])
  69. execFileSync('git', ['-C', repository, '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'commit', '-qm', 'release'])
  70. execFileSync('git', ['-C', repository, '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'tag', '-am', 'release', 'v0.3.0'])
  71. const commit = execFileSync('git', ['-C', repository, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim()
  72. await expect(inspectGitRemote(repository, current)).resolves.toEqual({ manifest: latest, commit })
  73. })
  74. })
  75. describe('SystemUpdateManager', () => {
  76. it('reports the current Web/plugin pair and discovers a newer fixed release', async () => {
  77. const { manager } = fixture()
  78. const status = await manager.check('1.7.1')
  79. expect(status.current).toEqual(current)
  80. expect(status.latest).toEqual(latest)
  81. expect(status.updateAvailable).toBe(true)
  82. expect(status.versionsMatch).toBe(true)
  83. expect(status.installationMode).toBe('source')
  84. })
  85. it('creates a durable updater job without exposing its internal plan', async () => {
  86. const { manager, launched } = fixture()
  87. const job = await manager.startUpdate('0.3.0', '1.7.1')
  88. expect(job).toMatchObject({ operation: 'update', state: 'queued', target: latest })
  89. expect(launched).toHaveLength(1)
  90. expect(existsSync(launched[0]!)).toBe(true)
  91. const stored = JSON.parse(readFileSync(launched[0]!, 'utf8')) as Record<string, unknown>
  92. expect(stored.plan).toBeTruthy()
  93. expect(manager.job(job.id)).not.toHaveProperty('plan')
  94. await expect(manager.startUpdate('0.3.0', '1.7.1')).rejects.toThrow('正在执行')
  95. })
  96. it('rejects arbitrary target versions and rollback without a recovery record', async () => {
  97. const { manager } = fixture()
  98. await expect(manager.startUpdate('9.9.9', '1.7.1')).rejects.toThrow('不是当前发布源')
  99. expect(() => manager.startRollback()).toThrow('没有可回滚')
  100. })
  101. })