desktopSync.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. // @vitest-environment node
  2. import { afterEach, describe, expect, it } from 'vitest'
  3. import { DatabaseSync } from 'node:sqlite'
  4. import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, existsSync, symlinkSync, realpathSync } from 'node:fs'
  5. import { tmpdir } from 'node:os'
  6. import { join } from 'node:path'
  7. import { syncDecision, sealRuntime, verifyRuntimePackage } from '../../bin/lib/runtime-release.mjs'
  8. import { assertRollbackCompatible, backupData, databaseSchema } from '../../bin/lib/service-data.mjs'
  9. import { currentRelease, reserveUpdate, switchRelease, synchronizeDesktop, transitionService, updateMutex, writeJSON, recoverTransition } from '../../bin/lib/service-update.mjs'
  10. import { RunnerHub } from '../../src/server/runnerHub'
  11. const homes: string[] = []
  12. afterEach(() => { for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true }) })
  13. const temporary = () => { const home = realpathSync(mkdtempSync(join(tmpdir(), 'yaoyao-sync-unit-'))); homes.push(home); return home }
  14. function runtime(root: string, commit = 'b'.repeat(40), version = '0.3.32', ancestors = ['a'.repeat(40)]) {
  15. mkdirSync(join(root, 'bin'), { recursive: true }); mkdirSync(join(root, 'ui'))
  16. for (const file of ['node', 'server.mjs', 'ui/index.html', 'bin/hermes-yaoyao.mjs', 'bin/hermes-yaoyao-updater.mjs']) writeFileSync(join(root, file), file)
  17. writeJSON(join(root, 'release.json'), { schemaVersion: 1, releaseVersion: version, webVersion: version, gitTag: `v${version}` })
  18. writeJSON(join(root, 'build-info.json'), { commit, ancestors, buildNumber: ancestors.length + 1, dirty: false })
  19. sealRuntime(root); return verifyRuntimePackage(root)
  20. }
  21. const clean = (commit: string, ancestors: string[] = [], version = '0.3.32') => ({ commit, ancestors, version, dirty: false })
  22. describe('desktop release selection and integrity', () => {
  23. it('orders same-version builds by ancestry, preserves newer Web, and refuses unrelated builds', () => {
  24. expect(syncDecision(clean('b', ['a']), clean('a'))).toBe('upgrade')
  25. expect(syncDecision(clean('a'), clean('b', ['a']))).toBe('newer')
  26. expect(syncDecision(clean('b'), clean('b'))).toBe('same')
  27. expect(syncDecision(clean('a', [], '0.3.32'), clean('b', [], '0.3.33'))).toBe('newer')
  28. expect(syncDecision(clean('z'), clean('a'))).toBe('unknown')
  29. expect(syncDecision({ ...clean('a'), dirty: true }, clean('a'))).toBe('unknown')
  30. expect(syncDecision(clean('a', [], '0.4.0-10'), clean('b', [], '0.4.0-2'))).toBe('upgrade')
  31. })
  32. it('detects changed, added, removed and symlinked payload files before touching a service', () => {
  33. const root = join(temporary(), 'runtime'); runtime(root)
  34. writeFileSync(join(root, 'server.mjs'), 'tampered')
  35. expect(() => verifyRuntimePackage(root)).toThrow('校验失败')
  36. sealRuntime(root); writeFileSync(join(root, 'unexpected'), 'extra')
  37. expect(() => verifyRuntimePackage(root)).toThrow('校验失败')
  38. rmSync(join(root, 'unexpected')); sealRuntime(root); rmSync(join(root, 'node'))
  39. expect(() => verifyRuntimePackage(root)).toThrow('校验失败')
  40. symlinkSync('/etc/hosts', join(root, 'node'))
  41. expect(() => verifyRuntimePackage(root)).toThrow('符号链接')
  42. })
  43. it('excludes competing desktop and Web updaters using the same OS lock and file reservation', () => {
  44. const home = temporary(), unlock = updateMutex(home)
  45. expect(() => updateMutex(home)).toThrow('正在执行')
  46. const release = reserveUpdate(home, 'first')
  47. expect(() => reserveUpdate(home, 'second')).toThrow('正在执行')
  48. release(); unlock()
  49. const next = updateMutex(home); next()
  50. })
  51. it('waits for asynchronous image jobs even after their start command has returned', async () => {
  52. const store = { list: () => [], require: () => ({ enabled: true }), get: () => ({ enabled: true }) }
  53. const auth = { pushAuthorizationVersion: () => 1, isAdminActive: () => true }
  54. const hub = new RunnerHub(store as any, auth as any, {} as any)
  55. try {
  56. ;(hub as any).online.set('runner', { features: ['image-management-v1'], seen: Date.now() })
  57. ;(hub as any).request = async (_id: string, _kind: string, body: any) => body.op === 'start' ? { job: { state: 'running' } } : { busy: false }
  58. expect(hub.idleForUpdate).toBe(true)
  59. await hub.images('owner', 'runner', { op: 'start' })
  60. expect(hub.idleForUpdate).toBe(false)
  61. await hub.images('owner', 'runner', { op: 'status' })
  62. expect(hub.idleForUpdate).toBe(true)
  63. } finally { hub.close() }
  64. })
  65. })
  66. describe('transactional service switching', () => {
  67. function fixture() {
  68. const home = temporary(), releaseRoot = join(home, 'updates', 'releases')
  69. mkdirSync(releaseRoot, { recursive: true })
  70. const old = join(releaseRoot, 'old'), target = join(releaseRoot, 'new')
  71. runtime(old, 'a'.repeat(40), '0.3.31'); runtime(target)
  72. switchRelease(releaseRoot, old)
  73. const db = new DatabaseSync(join(home, 'workspace.sqlite3'))
  74. db.exec("PRAGMA journal_mode=WAL; CREATE TABLE messages(value TEXT); INSERT INTO messages VALUES('before')"); db.close()
  75. writeFileSync(join(home, 'settings.json'), 'original')
  76. let stops = 0, starts = 0, fail = false
  77. const driver = {
  78. snapshot: () => ({ root: currentRelease(releaseRoot), wasRunning: true }),
  79. quiesce: async () => {}, resume: async () => {},
  80. stop: async () => { stops++ },
  81. start: async () => {
  82. starts++
  83. if (fail) {
  84. const database = new DatabaseSync(join(home, 'workspace.sqlite3'))
  85. database.exec("ALTER TABLE messages ADD COLUMN extra TEXT; UPDATE messages SET value='migrated'"); database.close()
  86. writeFileSync(join(home, 'settings.json'), 'migrated'); writeFileSync(join(home, 'new-migration-file'), 'new')
  87. }
  88. },
  89. verify: async (expected?: unknown) => { if (expected && fail) throw new Error('wrong running build') },
  90. restore: async () => {},
  91. }
  92. return { home, releaseRoot, target, old, driver, counts: () => ({ stops, starts }), fail: () => { fail = true } }
  93. }
  94. it('restores both schema and data after a failed startup; preserves independent Runner state', async () => {
  95. const f = fixture(), schema = databaseSchema(f.home)
  96. mkdirSync(join(f.home, 'runner-state')); writeFileSync(join(f.home, 'runner-state', 'preserve'), 'runner')
  97. f.fail()
  98. await expect(transitionService({ ...f, finalRoot: f.target })).rejects.toThrow('wrong running build')
  99. expect(currentRelease(f.releaseRoot)).toBe(f.old)
  100. expect(databaseSchema(f.home)).toBe(schema)
  101. const db = new DatabaseSync(join(f.home, 'workspace.sqlite3'), { readOnly: true })
  102. expect(db.prepare('SELECT value FROM messages').get()?.value).toBe('before'); db.close()
  103. expect(readFileSync(join(f.home, 'settings.json'), 'utf8')).toBe('original')
  104. expect(existsSync(join(f.home, 'new-migration-file'))).toBe(false)
  105. expect(readFileSync(join(f.home, 'runner-state', 'preserve'), 'utf8')).toBe('runner')
  106. expect(existsSync(join(f.home, 'updates', 'last-success.json'))).toBe(false)
  107. })
  108. it('does not stop or mark a busy service as synchronized', async () => {
  109. const f = fixture(); f.driver.quiesce = async () => { throw new Error('busy') }
  110. await expect(synchronizeDesktop({ ...f, runtimeRoot: f.target })).rejects.toThrow('busy')
  111. expect(f.counts()).toEqual({ stops: 0, starts: 0 })
  112. expect(existsSync(join(f.home, 'updates', 'desktop-sync.json'))).toBe(false)
  113. })
  114. it('synchronizes an App build once, allowing Web to move independently afterwards', async () => {
  115. const f = fixture()
  116. await synchronizeDesktop({ ...f, runtimeRoot: f.target })
  117. expect(f.counts()).toEqual({ stops: 1, starts: 1 })
  118. switchRelease(f.releaseRoot, f.old)
  119. const result = await synchronizeDesktop({ ...f, runtimeRoot: f.target })
  120. expect(result.action).toBe('already-synced')
  121. expect(f.counts()).toEqual({ stops: 1, starts: 1 })
  122. })
  123. it('recovers an interrupted migration before another update and restores the previous rollback record', async () => {
  124. const f = fixture(), snapshot = join(f.home, 'updates', 'backups', 'interrupted')
  125. backupData(f.home, snapshot)
  126. writeJSON(join(f.home, 'updates', 'transition.json'), { home: f.home, releaseRoot: f.releaseRoot, previousCurrent: f.old,
  127. previous: { root: f.old, wasRunning: true }, snapshot, previousSuccess: { jobId: 'older-success' } })
  128. switchRelease(f.releaseRoot, f.target)
  129. writeFileSync(join(f.home, 'settings.json'), 'partial migration')
  130. await recoverTransition(f)
  131. expect(currentRelease(f.releaseRoot)).toBe(f.old)
  132. expect(readFileSync(join(f.home, 'settings.json'), 'utf8')).toBe('original')
  133. expect(JSON.parse(readFileSync(join(f.home, 'updates', 'last-success.json'), 'utf8')).jobId).toBe('older-success')
  134. expect(existsSync(join(f.home, 'updates', 'transition.json'))).toBe(false)
  135. })
  136. it('allows data-preserving rollback only when the schema remains compatible', () => {
  137. const f = fixture(), schema = databaseSchema(f.home)
  138. const record = { beforeSchema: schema, afterSchema: schema }
  139. const db = new DatabaseSync(join(f.home, 'workspace.sqlite3'))
  140. db.exec("INSERT INTO messages VALUES('new message after upgrade')")
  141. expect(() => assertRollbackCompatible(f.home, record)).not.toThrow()
  142. db.exec('ALTER TABLE messages ADD COLUMN migrated TEXT')
  143. expect(() => assertRollbackCompatible(f.home, record)).toThrow('数据库结构')
  144. expect(() => assertRollbackCompatible(f.home, {})).toThrow('兼容性记录')
  145. expect(db.prepare('SELECT COUNT(*) AS count FROM messages').get()?.count).toBe(2)
  146. db.close()
  147. })
  148. it('does not restore data twice when restarting the restored service needs another attempt', async () => {
  149. const f = fixture(), snapshot = join(f.home, 'updates', 'backups', 'retry')
  150. backupData(f.home, snapshot)
  151. writeJSON(join(f.home, 'updates', 'transition.json'), { home: f.home, releaseRoot: f.releaseRoot, previousCurrent: f.old,
  152. previous: { root: f.old, wasRunning: true }, snapshot })
  153. f.driver.restore = async () => { throw new Error('restart pending') }
  154. await expect(recoverTransition(f)).rejects.toThrow('restart pending')
  155. writeFileSync(join(f.home, 'settings.json'), 'data after restore')
  156. f.driver.restore = async () => {}
  157. await recoverTransition(f)
  158. expect(readFileSync(join(f.home, 'settings.json'), 'utf8')).toBe('data after restore')
  159. expect(f.counts().stops).toBe(1)
  160. })
  161. })