updateManager.test.ts 5.5 KB

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