desktopSync.test.ts 11 KB

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