desktopSync.test.ts 12 KB

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