import { execFileSync } from 'node:child_process' import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { ServerConfig } from '../../src/server/config.js' import { compareReleaseVersions, parseReleaseManifest } from '../../src/server/releases.js' import { inspectGitRemote, releaseManifestURL, SystemUpdateManager } from '../../src/server/updateManager.js' const roots: string[] = [] const current = { schemaVersion: 1 as const, releaseVersion: '0.2.0', webVersion: '0.2.0', gitTag: 'v0.2.0', } const latest = { schemaVersion: 1 as const, releaseVersion: '0.3.0', webVersion: '0.3.0', gitTag: 'v0.3.0', } const latestRemote = { manifest: latest, commit: 'a'.repeat(40) } afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) function fixture() { const root = mkdtempSync(join(tmpdir(), 'hermes-update-manager-')) roots.push(root) writeFileSync(join(root, 'release.json'), JSON.stringify(current)) const config: ServerConfig = { host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'), allowedHosts: new Set(), home: join(root, 'data'), mediaRoot: join(root, 'media'), attachmentsRoot: join(root, 'attachments'), imagesRoot: join(root, 'images'), mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false, releaseRoot: join(root, 'installed'), releaseSource: 'https://example.test/hermes-yaoyao.git', } const launched: string[] = [] const manager = new SystemUpdateManager(config, { projectRoot: root, inspectRemote: async () => latestRemote, launchUpdater: path => { launched.push(path) }, platform: 'darwin', }) return { root, config, manager, launched } } describe('system release contract', () => { it('maps an HTTPS Git source to the lightweight tagged release manifest', () => { expect(releaseManifestURL( 'https://git.samien.cn/samien/hermes-yaoyao.git', 'v0.2.1', )?.href).toBe('https://git.samien.cn/samien/hermes-yaoyao/raw/v0.2.1/release.json') expect(releaseManifestURL('/srv/git/hermes-yaoyao.git', 'v0.2.1')).toBeUndefined() }) it('validates Web versions and semantic ordering', () => { expect(parseReleaseManifest(current)).toEqual(current) expect(compareReleaseVersions('0.3.0', '0.2.9')).toBeGreaterThan(0) expect(compareReleaseVersions('1.0.0', '1.0.0-beta.1')).toBeGreaterThan(0) expect(parseReleaseManifest(current)).not.toHaveProperty('pluginVersion') }) it('locks an annotated remote tag to its peeled Git commit', async () => { const root = mkdtempSync(join(tmpdir(), 'hermes-update-remote-')) roots.push(root) const repository = join(root, 'repository') execFileSync('git', ['init', '-q', repository]) writeFileSync(join(repository, 'release.json'), JSON.stringify(latest)) execFileSync('git', ['-C', repository, 'add', 'release.json']) execFileSync('git', ['-C', repository, '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'commit', '-qm', 'release']) execFileSync('git', ['-C', repository, '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'tag', '-am', 'release', 'v0.3.0']) const commit = execFileSync('git', ['-C', repository, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() await expect(inspectGitRemote(repository, current)).resolves.toEqual({ manifest: latest, commit }) }) }) describe('SystemUpdateManager', () => { it('reports the current Web release and discovers a newer fixed release', async () => { const { manager } = fixture() const status = await manager.check() expect(status.current).toEqual(current) expect(status.latest).toEqual(latest) expect(status.updateAvailable).toBe(true) expect(status).not.toHaveProperty('installedPluginVersion') expect(status.installationMode).toBe('source') }) it('creates a durable updater job without exposing its internal plan', async () => { const { manager, launched } = fixture() const job = await manager.startUpdate('0.3.0') expect(job).toMatchObject({ operation: 'update', state: 'queued', target: latest }) expect(launched).toHaveLength(1) expect(existsSync(launched[0]!)).toBe(true) const stored = JSON.parse(readFileSync(launched[0]!, 'utf8')) as Record expect(stored.plan).toBeTruthy() expect(manager.job(job.id)).not.toHaveProperty('plan') await expect(manager.startUpdate('0.3.0')).rejects.toThrow('正在执行') }) it('rejects arbitrary target versions and rollback without a recovery record', async () => { const { manager } = fixture() await expect(manager.startUpdate('9.9.9')).rejects.toThrow('不是当前发布源') expect(() => manager.startRollback()).toThrow('没有可回滚') }) })