Files
deepseek-harness/apps/desktop/tests/update-coordinator.spec.ts
T

452 lines
21 KiB
TypeScript

import { EventEmitter } from 'node:events'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AppUpdater } from 'electron-updater'
import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts'
import { parseDesktopRelease } from '../src/release.ts'
import type { DesktopUpdateState } from '../src/ipc.ts'
import { DesktopUpdatePreparationError } from '../src/update-error.ts'
import { zh } from '../src/locale.ts'
vi.mock('electron', () => ({ app: { isPackaged: false } }))
vi.mock('electron-updater', () => ({
default: { autoUpdater: { autoDownload: true, autoInstallOnAppQuit: true } },
}))
const { DesktopUpdateCoordinator } = await import('../src/update-coordinator.ts')
describe('desktop release metadata', () => {
it('accepts one exact release identity for Electron and dsh', () => {
expect(parseDesktopRelease({
schemaVersion: 1,
version: '1.2.3',
hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION,
nodeVersion: '24.17.0',
pnpmVersion: '11.7.0',
})).toEqual({
schemaVersion: 1,
version: '1.2.3',
hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION,
nodeVersion: '24.17.0',
pnpmVersion: '11.7.0',
})
})
it('rejects invalid versions and unsupported host protocols', () => {
const base = {
schemaVersion: 1,
version: '1.2.3',
hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION,
nodeVersion: '24.17.0',
pnpmVersion: '11.7.0',
}
expect(() => parseDesktopRelease({ ...base, version: 'stable' })).toThrow(/invalid desktop release metadata/u)
expect(() => parseDesktopRelease({ ...base, hostProtocolVersion: 999 })).toThrow(/invalid desktop release metadata/u)
})
})
const coordinators: InstanceType<typeof DesktopUpdateCoordinator>[] = []
afterEach(() => { for (const item of coordinators.splice(0)) item.dispose() })
function fixture(channel: 'nightly' | 'stable' = 'nightly', version = '1.1.0-alpha.1', discard?: (updater: AppUpdater) => Promise<void>) {
const events = new EventEmitter()
const checkForUpdates = vi.fn(async () => ({
isUpdateAvailable: true,
updateInfo: { version: '1.1.0-rc.2' },
}))
const downloadUpdate = vi.fn(async () => {
events.emit('download-progress', { percent: 58 })
events.emit('download-progress', { percent: 100 })
events.emit('update-downloaded', { version: '1.1.0-rc.2' })
return ['verified-package']
})
const quitAndInstall = vi.fn()
const beforeRestart = vi.fn(async () => true)
const downloadResult = vi.fn()
const states: DesktopUpdateState[] = []
const updater = Object.assign(events, { checkForUpdates, downloadUpdate, quitAndInstall }) as unknown as AppUpdater
const coordinator = new DesktopUpdateCoordinator(
(state) => { states.push(state); return state },
beforeRestart, updater, () => true, () => version, downloadResult, channel, discard,
)
coordinators.push(coordinator)
return { coordinator, updater, events, states, checkForUpdates, downloadUpdate, quitAndInstall, beforeRestart, downloadResult }
}
describe('desktop update coordinator', () => {
it.each(['stop-failed', 'fullscreen-timeout'] as const)('keeps safe %s diagnostics separate and clears them on an explicit retry', async (kind) => {
const f = fixture()
await f.coordinator.check()
await f.coordinator.download('1.1.0-rc.2')
f.beforeRestart.mockRejectedValueOnce(new DesktopUpdatePreparationError(kind, zh.updateStopFailed, 'exit 0; shutdown acknowledged false'))
expect(await f.coordinator.install('1.1.0-rc.2')).toEqual({ phase: 'error', version: '1.1.0-rc.2',
failedOperation: 'install', preparationFailure: kind, message: zh.updateStopFailed,
technicalDetails: 'exit 0; shutdown acknowledged false' })
expect(f.quitAndInstall).not.toHaveBeenCalled()
f.beforeRestart.mockResolvedValueOnce(false)
expect(await f.coordinator.install('1.1.0-rc.2')).toEqual({ phase: 'ready', version: '1.1.0-rc.2' })
expect(f.quitAndInstall).not.toHaveBeenCalled()
})
it('retains an asynchronous installer failure after quitAndInstall returns', async () => {
const f = fixture()
await f.coordinator.check()
await f.coordinator.download('1.1.0-rc.2')
await f.coordinator.install('1.1.0-rc.2')
f.events.emit('error', new Error('installer could not start'))
expect(f.coordinator.state).toMatchObject({ phase: 'error', failedOperation: 'install', message: 'installer could not start' })
})
it('waits for platform preparation after its downloaded event and rejects stale download approval', async () => {
const f = fixture()
const prepared = Promise.withResolvers<string[]>()
f.downloadUpdate.mockImplementationOnce(async () => {
f.events.emit('update-downloaded', { version: '1.1.0-rc.2' })
return prepared.promise
})
await f.coordinator.check()
await expect(f.coordinator.download('1.1.0-rc.1')).rejects.toThrow(/stale/u)
const downloading = f.coordinator.download('1.1.0-rc.2')
await Promise.resolve()
await Promise.resolve()
try {
expect(f.coordinator.state.phase).not.toBe('ready')
await expect(f.coordinator.install('1.1.0-rc.2')).rejects.toThrow(/not ready/u)
} finally { prepared.resolve(['verified']); await downloading }
expect(f.coordinator.state.phase).toBe('ready')
})
it('consumes late updater errors until an in-flight check settles after disposal', async () => {
const f = fixture()
const checked = Promise.withResolvers<{ isUpdateAvailable: boolean; updateInfo: { version: string } }>()
f.checkForUpdates.mockImplementation(() => checked.promise)
const pending = f.coordinator.check()
await Promise.resolve()
f.coordinator.dispose()
expect(() => f.events.emit('error', new Error('late network failure'))).not.toThrow()
checked.reject(new Error('offline'))
await pending
await Promise.resolve()
expect(f.events.listenerCount('error')).toBe(0)
})
it('checks, downloads on demand, then requires separate installation approval', async () => {
const f = fixture()
await f.coordinator.check()
expect(f.downloadUpdate).not.toHaveBeenCalled()
await expect(f.coordinator.install('1.1.0-rc.2')).rejects.toThrow(/not ready/u)
await f.coordinator.download('1.1.0-rc.2')
expect(f.quitAndInstall).not.toHaveBeenCalled()
expect(f.beforeRestart).not.toHaveBeenCalled()
expect(f.coordinator.state).toEqual({ phase: 'ready', version: '1.1.0-rc.2' })
await f.coordinator.install('1.1.0-rc.2')
expect(f.beforeRestart).toHaveBeenCalledOnce()
expect(f.quitAndInstall).toHaveBeenCalledWith(true, true)
expect(f.states.map(state => state.phase)).toEqual([
'available', 'downloading', 'downloading', 'verifying', 'ready', 'installing',
])
expect(f.updater).toMatchObject({
autoDownload: false, autoInstallOnAppQuit: false, channel: 'nightly',
allowPrerelease: true, allowDowngrade: false,
})
})
it('joins checks and downloads without retargeting a prepared release', async () => {
const f = fixture()
const checked = Promise.withResolvers<{ isUpdateAvailable: boolean; updateInfo: { version: string } }>()
f.checkForUpdates.mockImplementation(() => checked.promise)
const checking = f.coordinator.check()
const manual = f.coordinator.check(true)
const firstDownload = f.coordinator.download('1.1.0-rc.2')
const secondDownload = f.coordinator.download('1.1.0-rc.2')
checked.resolve({ isUpdateAvailable: true, updateInfo: { version: '1.1.0-rc.2' } })
await Promise.all([checking, manual, firstDownload, secondDownload])
expect(f.checkForUpdates).toHaveBeenCalledOnce()
expect(f.downloadUpdate).toHaveBeenCalledOnce()
await f.coordinator.check(true)
expect(f.checkForUpdates).toHaveBeenCalledOnce()
await expect(f.coordinator.install('1.2.0')).rejects.toThrow(/not ready/u)
})
it('keeps automatic failures silent and exposes a manual caller joining the same check', async () => {
const f = fixture()
f.checkForUpdates.mockRejectedValue(new Error('offline'))
expect(await f.coordinator.check()).toMatchObject({ phase: 'error', failedOperation: 'check' })
expect(f.states).toEqual([])
await Promise.all([f.coordinator.check(), f.coordinator.check(true)])
expect(f.states).toEqual([expect.objectContaining({ phase: 'error', failedOperation: 'check' })])
expect(f.downloadUpdate).not.toHaveBeenCalled()
})
it.each(['1.0.0', '1.1.0-alpha.1', 'invalid'])('does not download or install inapplicable version %s', async (version) => {
const f = fixture()
f.checkForUpdates.mockResolvedValue({ isUpdateAvailable: true, updateInfo: { version } })
await f.coordinator.check()
await expect(f.coordinator.download('1.1.0-rc.2')).rejects.toThrow(/no checked update/u)
expect(f.downloadUpdate).not.toHaveBeenCalled()
})
it('retains retry state after download failure and never equates 100 percent with readiness', async () => {
const f = fixture()
f.downloadUpdate.mockImplementationOnce(async () => {
f.events.emit('download-progress', { percent: 100 })
throw new Error('signature rejected')
})
await f.coordinator.check()
expect(await f.coordinator.download('1.1.0-rc.2')).toMatchObject({ phase: 'error', failedOperation: 'download' })
await expect(f.coordinator.install('1.1.0-rc.2')).rejects.toThrow(/not ready/u)
await f.coordinator.download('1.1.0-rc.2')
expect(f.coordinator.state.phase).toBe('ready')
expect(f.quitAndInstall).not.toHaveBeenCalled()
})
it('refuses handoff after failed task preparation while retaining the prepared package', async () => {
const f = fixture()
f.beforeRestart.mockRejectedValueOnce(new Error('tasks could not stop'))
await f.coordinator.check()
await f.coordinator.download('1.1.0-rc.2')
expect(await f.coordinator.install('1.1.0-rc.2')).toMatchObject({ phase: 'error', failedOperation: 'install' })
expect(f.quitAndInstall).not.toHaveBeenCalled()
await f.coordinator.install('1.1.0-rc.2')
expect(f.downloadUpdate).toHaveBeenCalledOnce()
expect(f.quitAndInstall).toHaveBeenCalledOnce()
})
it('does not publish late check completion after disposal', async () => {
const f = fixture()
const checked = Promise.withResolvers<{ isUpdateAvailable: boolean; updateInfo: { version: string } }>()
f.checkForUpdates.mockImplementation(() => checked.promise)
const pending = f.coordinator.check()
f.coordinator.dispose()
checked.resolve({ isUpdateAvailable: true, updateInfo: { version: '1.1.0-rc.2' } })
await pending
expect(f.states).toEqual([])
expect(f.events.listenerCount('download-progress')).toBe(0)
})
})
it('reports one download result when callers share an operation and none for cached readiness', async () => {
const f = fixture()
await f.coordinator.check()
await Promise.all([f.coordinator.download('1.1.0-rc.2'), f.coordinator.download('1.1.0-rc.2')])
await f.coordinator.download('1.1.0-rc.2')
expect(f.downloadResult).toHaveBeenCalledExactlyOnceWith(true)
})
it.each([new DesktopUpdatePreparationError('stop-failed', 'private diagnostic'), new Error('private URL')])('reports safe download failure classification: %s', async (error) => {
const f = fixture()
await f.coordinator.check()
f.downloadUpdate.mockRejectedValueOnce(error)
await f.coordinator.download('1.1.0-rc.2')
expect(f.downloadResult).toHaveBeenCalledExactlyOnceWith(false, error instanceof DesktopUpdatePreparationError ? 'stop-failed' : 'download_failed')
expect(JSON.stringify(f.downloadResult.mock.calls)).not.toContain('private')
})
it.each(['nightly', 'stable'] as const)('accepts newer prereleases on the selected %s channel', async (channel) => {
const f = fixture(channel, '1.0.0')
expect(f.updater.channel).toBe(channel)
expect(f.updater.allowPrerelease).toBe(true)
expect(f.updater.allowDowngrade).toBe(false)
for (const version of ['1.1.0-alpha.1', '1.1.0-rc.2', '1.1.0']) {
f.checkForUpdates.mockResolvedValueOnce({ isUpdateAvailable: true, updateInfo: { version } })
expect(await f.coordinator.check()).toMatchObject({ phase: 'available', version })
expect(f.updater.channel).toBe(channel)
}
})
it.each(['nightly', 'stable'] as const)('rejects older, equal and malformed versions on %s', async (channel) => {
for (const version of ['0.9.0', '1.0.0-rc.1', '1.0.0', 'invalid']) {
const f = fixture(channel, '1.0.0')
f.checkForUpdates.mockResolvedValueOnce({ isUpdateAvailable: true, updateInfo: { version } })
expect((await f.coordinator.check()).phase).toBe(version === 'invalid' ? 'error' : 'idle')
await expect(f.coordinator.download(version)).rejects.toThrow(/no checked update/u)
await expect(f.coordinator.install(version)).rejects.toThrow(/confirmed target is not ready/u)
expect(f.downloadUpdate).not.toHaveBeenCalled()
expect(f.quitAndInstall).not.toHaveBeenCalled()
}
})
it('retires a downloaded target and rejects its old confirmation after switching', async () => {
const f = fixture()
await f.coordinator.check()
const revision = f.coordinator.revision
await f.coordinator.download('1.1.0-rc.2')
const save = vi.fn(async () => {})
f.checkForUpdates.mockResolvedValueOnce({ isUpdateAvailable: true, updateInfo: { version: '1.2.0' } })
await f.coordinator.switchChannel('stable', save)
expect(save).toHaveBeenCalledOnce()
expect(f.coordinator.selectedChannel).toBe('stable')
expect(f.updater.allowDowngrade).toBe(false)
expect(f.coordinator.state).toMatchObject({ phase: 'available', version: '1.2.0' })
await f.coordinator.install('1.1.0-rc.2', revision)
expect(f.quitAndInstall).not.toHaveBeenCalled()
})
it('leaves the current target intact if saving the choice fails', async () => {
const f = fixture()
await f.coordinator.check()
await f.coordinator.download('1.1.0-rc.2')
await expect(f.coordinator.switchChannel('stable', () => Promise.reject(new Error('read only')))).rejects.toThrow('read only')
expect(f.coordinator.selectedChannel).toBe('nightly')
expect(f.coordinator.state.phase).toBe('ready')
await f.coordinator.install('1.1.0-rc.2')
expect(f.quitAndInstall).toHaveBeenCalledOnce()
})
it('discards a check result that belongs to the previous channel', async () => {
const f = fixture()
const started = Promise.withResolvers<undefined>()
const checked = Promise.withResolvers<{ isUpdateAvailable: boolean; updateInfo: { version: string } }>()
f.checkForUpdates.mockImplementationOnce(() => { started.resolve(undefined); return checked.promise })
const checking = f.coordinator.check()
await started.promise
const saved = Promise.withResolvers<undefined>()
const switching = f.coordinator.switchChannel('stable', async () => { saved.resolve(undefined) })
await saved.promise
f.checkForUpdates.mockResolvedValueOnce({ isUpdateAvailable: true, updateInfo: { version: '1.2.0' } })
checked.resolve({ isUpdateAvailable: true, updateInfo: { version: '9.0.0-alpha.1' } })
await Promise.all([checking, switching])
expect(f.states.some(state => state.version === '9.0.0-alpha.1')).toBe(false)
expect(f.coordinator.state).toMatchObject({ phase: 'available', version: '1.2.0' })
})
it('does not check or install while startup channel configuration is invalid', async () => {
const f = fixture()
f.coordinator.suspendConfiguration('/settings.json')
expect(f.coordinator.configurationFailure).toBe('startup')
await f.coordinator.check(true)
await f.coordinator.download('1.1.0-rc.2')
expect(f.checkForUpdates).not.toHaveBeenCalled()
expect(f.downloadUpdate).not.toHaveBeenCalled()
expect(f.coordinator.state).toMatchObject({ phase: 'error', failedOperation: 'configuration' })
await f.coordinator.switchChannel('nightly', async () => {})
expect(f.coordinator.state.phase).toBe('available')
expect(f.coordinator.configurationFailure).toBeUndefined()
})
it('waits for a cancelled download before checking the new channel and ignores late progress', async () => {
const f = fixture()
const entered = Promise.withResolvers<undefined>()
const finished = Promise.withResolvers<string[]>()
f.downloadUpdate.mockImplementationOnce(async () => { entered.resolve(undefined); return finished.promise })
await f.coordinator.check()
const downloading = f.coordinator.download('1.1.0-rc.2')
await entered.promise
const persisted = Promise.withResolvers<undefined>()
const switching = f.coordinator.switchChannel('stable', async () => { persisted.resolve(undefined) })
await persisted.promise
await Promise.resolve()
const count = f.states.length
f.events.emit('download-progress', { percent: 99 })
f.events.emit('update-downloaded', { version: '1.1.0-rc.2' })
expect(f.states).toHaveLength(count)
expect(f.checkForUpdates).toHaveBeenCalledTimes(1)
f.checkForUpdates.mockResolvedValueOnce({ isUpdateAvailable: true, updateInfo: { version: '1.2.0' } })
finished.resolve([])
await Promise.all([downloading, switching])
expect(f.coordinator.state).toEqual({ phase: 'available', version: '1.2.0' })
expect(f.downloadResult).not.toHaveBeenCalled()
expect(f.updater.allowDowngrade).toBe(false)
})
it('rejects a channel switch during installation preparation without persisting it', async () => {
const f = fixture()
await f.coordinator.check()
await f.coordinator.download('1.1.0-rc.2')
const preparation = Promise.withResolvers<boolean>()
f.beforeRestart.mockReturnValueOnce(preparation.promise)
const installing = f.coordinator.install('1.1.0-rc.2')
const save = vi.fn(async () => {})
await expect(f.coordinator.switchChannel('stable', save)).rejects.toThrow('installation is in progress')
expect(save).not.toHaveBeenCalled()
preparation.resolve(false)
await installing
expect(f.coordinator.state.phase).toBe('ready')
})
it('does not accept a channel switch after installer handoff even if a later error is reported', async () => {
const f = fixture()
await f.coordinator.check()
await f.coordinator.download('1.1.0-rc.2')
await f.coordinator.install('1.1.0-rc.2')
f.events.emit('error', new Error('native updater failed after handoff'))
expect(f.coordinator.state.phase).toBe('error')
const save = vi.fn(async () => {})
await expect(f.coordinator.switchChannel('stable', save)).rejects.toThrow('installation is in progress')
expect(save).not.toHaveBeenCalled()
})
it('suspends updates if retiring a downloaded target fails and recovers on a later switch', async () => {
const discard = vi.fn().mockRejectedValueOnce(new Error('native target busy')).mockResolvedValueOnce(undefined)
const f = fixture('nightly', '1.1.0-alpha.1', discard)
await f.coordinator.check()
await f.coordinator.download('1.1.0-rc.2')
await expect(f.coordinator.switchChannel('stable', async () => {})).rejects.toThrow('native target busy')
expect(f.coordinator.configurationFailure).toBe('cleanup')
expect(f.coordinator.state).toMatchObject({ phase: 'error', failedOperation: 'configuration' })
const checks = f.checkForUpdates.mock.calls.length
await f.coordinator.check(true)
await expect(f.coordinator.install('1.1.0-rc.2')).rejects.toThrow('channel selection is not ready')
expect(f.checkForUpdates).toHaveBeenCalledTimes(checks)
expect(f.quitAndInstall).not.toHaveBeenCalled()
await f.coordinator.switchChannel('stable', async () => {})
expect(f.coordinator.configurationFailure).toBeUndefined()
})
it.each(['idle', 'downloading', 'ready'] as const)('preserves %s when the current channel is selected again', async (phase) => {
const f = fixture()
const save = vi.fn(async () => {})
const pending = Promise.withResolvers<string[]>()
const started = Promise.withResolvers<undefined>()
let downloading: Promise<DesktopUpdateState> | undefined
if (phase !== 'idle') {
await f.coordinator.check()
if (phase === 'downloading') f.downloadUpdate.mockImplementationOnce(() => { started.resolve(undefined); return pending.promise })
downloading = f.coordinator.download('1.1.0-rc.2')
if (phase === 'ready') await downloading
else await started.promise
}
const before = f.coordinator.state
const revision = f.coordinator.revision
const checks = f.checkForUpdates.mock.calls.length
try {
expect(await f.coordinator.switchChannel('nightly', save)).toBe('unchanged')
expect(save).not.toHaveBeenCalled()
expect(f.coordinator.state).toBe(before)
expect(f.coordinator.revision).toBe(revision)
expect(f.checkForUpdates).toHaveBeenCalledTimes(checks)
} finally {
pending.resolve(['verified-package'])
await downloading
}
})
it.each([
['ENOSPC', 'space'], ['EACCES', 'permission'], ['EPERM', 'permission'], ['EROFS', 'permission'], ['EBUSY', 'busy'],
] as const)('reports the current %s download failure without attributing it to cleanup', async (code, kind) => {
const f = fixture()
await f.coordinator.check()
f.downloadUpdate.mockRejectedValueOnce(Object.assign(new Error('write failed'), {
code, path: '/cache/new.zip', dest: '/cache/pending/new.zip',
}))
const state = await f.coordinator.download('1.1.0-rc.2')
expect(state).toMatchObject({ phase: 'error', failedOperation: 'download', downloadFailure: kind,
technicalDetails: `${code}\nwrite failed\n/cache/new.zip\n/cache/pending/new.zip` })
await expect(f.coordinator.install('1.1.0-rc.2')).rejects.toThrow('not ready')
expect((await f.coordinator.download('1.1.0-rc.2')).phase).toBe('ready')
expect(f.coordinator.state.downloadFailure).toBeUndefined()
})
it('does not diagnose a filesystem cause from error wording alone', async () => {
const f = fixture()
await f.coordinator.check()
f.downloadUpdate.mockRejectedValueOnce(new Error('ENOSPC or EACCES in a remote response'))
expect((await f.coordinator.download('1.1.0-rc.2')).downloadFailure).toBeUndefined()
})