cc-haha/desktop/electron/services/updater.test.ts
程序员阿江(Relakkes) e5f1c415f8 fix(desktop): repair update proxy/session targeting, quit-and-install crash, and about layout
Update downloads never used the configured proxy because electron-updater
runs all traffic on its own session partition, while the proxy was applied
to app/defaultSession only. Point proxy config at autoUpdater.netSession,
and make the system fallback an explicit `mode: 'system'` since an empty
setProxy config means fixed_servers with no rules (= direct), which also
silently downgraded the default session away from the OS proxy.

Disable differential download: blockmap-driven ranged requests against the
GitHub CDN are RTT-bound and run far below line speed on both macOS and
Windows; full downloads restore expected bandwidth.

Guard captureWindowState and the resize forwarder against destroyed
windows: quitAndInstall tears the window down while late move/resize/close
events still fire, crashing the main process with "Object has been
destroyed".

Widen the About page container from max-w-lg to max-w-2xl so release notes
markdown is no longer cramped.
2026-07-09 20:16:12 +08:00

186 lines
7.5 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import { ElectronUpdaterService, normalizeUpdateInfo, updaterSessionProxyConfig, type ElectronUpdaterLike } from './updater'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
function fakeUpdater(): ElectronUpdaterLike & {
emitProgress(progress: { transferred?: number, total?: number }): void
checkForUpdates: ReturnType<typeof vi.fn>
downloadUpdate: ReturnType<typeof vi.fn>
quitAndInstall: ReturnType<typeof vi.fn>
} {
let progressHandler: ((progress: { transferred?: number, total?: number }) => void) | null = null
const updater = {
autoDownload: true,
checkForUpdates: vi.fn(),
downloadUpdate: vi.fn(),
quitAndInstall: vi.fn(),
on: vi.fn((_event, handler) => {
progressHandler = handler
return updater
}),
off: vi.fn((_event, handler) => {
if (progressHandler === handler) progressHandler = null
return updater
}),
emitProgress(progress) {
progressHandler?.(progress)
},
} as ElectronUpdaterLike & {
emitProgress(progress: { transferred?: number, total?: number }): void
checkForUpdates: ReturnType<typeof vi.fn>
downloadUpdate: ReturnType<typeof vi.fn>
quitAndInstall: ReturnType<typeof vi.fn>
}
return updater
}
const updater = fakeUpdater()
describe('Electron updater service', () => {
it('normalizes update metadata from electron-updater', () => {
expect(normalizeUpdateInfo({ version: '1.2.3', releaseNotes: 'Notes' })).toEqual({
version: '1.2.3',
body: 'Notes',
})
expect(normalizeUpdateInfo({ version: '1.2.4', releaseNotes: [{ note: 'One' }, { note: 'Two' }] })).toEqual({
version: '1.2.4',
body: 'One\n\nTwo',
})
expect(normalizeUpdateInfo(undefined)).toBeNull()
})
it('keeps electron-updater autoDownload disabled and emits store-compatible progress', async () => {
updater.checkForUpdates.mockResolvedValue({ updateInfo: { version: '1.2.3', body: 'Fixes' } })
updater.downloadUpdate.mockImplementation(async () => {
updater.emitProgress({ transferred: 40, total: 100 })
updater.emitProgress({ transferred: 100, total: 100 })
})
const service = new ElectronUpdaterService(updater)
const events: unknown[] = []
await expect(service.checkForUpdates()).resolves.toEqual({ version: '1.2.3', body: 'Fixes' })
await service.downloadUpdate(event => events.push(event))
expect(updater.autoDownload).toBe(false)
expect(updater.logger).toBeNull()
expect(events).toEqual([
{ event: 'Started', data: { contentLength: 100 } },
{ event: 'Progress', data: { chunkLength: 40 } },
{ event: 'Progress', data: { chunkLength: 60 } },
{ event: 'Finished' },
])
})
it('treats missing unpacked app update metadata as no update', async () => {
const service = new ElectronUpdaterService(updater)
updater.checkForUpdates.mockRejectedValueOnce(Object.assign(new Error("ENOENT: no such file or directory, open '/App/Contents/Resources/app-update.yml'"), {
code: 'ENOENT',
path: '/App/Contents/Resources/app-update.yml',
}))
await expect(service.checkForUpdates()).resolves.toBeNull()
})
it('skips electron-updater when packaged update config is absent', async () => {
const localUpdater = fakeUpdater()
const tempDir = mkdtempSync(join(tmpdir(), 'cc-haha-updater-'))
try {
const service = new ElectronUpdaterService(localUpdater, undefined, {
updateConfigPath: join(tempDir, 'app-update.yml'),
})
await expect(service.checkForUpdates()).resolves.toBeNull()
expect(localUpdater.checkForUpdates).not.toHaveBeenCalled()
} finally {
rmSync(tempDir, { recursive: true, force: true })
}
})
it('treats missing GitHub channel metadata as no update', async () => {
const service = new ElectronUpdaterService(updater)
updater.checkForUpdates.mockRejectedValueOnce(Object.assign(
new Error('Cannot find latest-mac.yml in the latest release artifacts (https://github.com/NanmiCoder/cc-haha/releases/download/v0.3.2/latest-mac.yml): HttpError: 404'),
{ code: 'ERR_UPDATER_CHANNEL_FILE_NOT_FOUND' },
))
await expect(service.checkForUpdates()).resolves.toBeNull()
})
it('treats missing GitHub channel metadata as no update even without an error code', async () => {
const service = new ElectronUpdaterService(updater)
updater.checkForUpdates.mockRejectedValueOnce(
new Error('Cannot find latest-mac.yml in the latest release artifacts (https://github.com/NanmiCoder/cc-haha/releases/download/v0.3.2/latest-mac.yml): HttpError: 404'),
)
await expect(service.checkForUpdates()).resolves.toBeNull()
})
it('treats stringified missing GitHub channel metadata as no update', async () => {
const service = new ElectronUpdaterService(updater)
updater.checkForUpdates.mockRejectedValueOnce(
'Error: Cannot find latest-mac.yml in the latest release artifacts (https://github.com/NanmiCoder/cc-haha/releases/download/v0.3.2/latest-mac.yml): HttpError: 404',
)
await expect(service.checkForUpdates()).resolves.toBeNull()
})
it('applies manual updater proxy before checking and clears it when returning to system proxy', async () => {
const localUpdater = fakeUpdater()
const proxyController = {
apply: vi.fn().mockResolvedValue(undefined),
}
localUpdater.checkForUpdates.mockResolvedValue({ updateInfo: { version: '1.2.5' } })
const service = new ElectronUpdaterService(localUpdater, proxyController)
await service.checkForUpdates({ proxy: 'http://127.0.0.1:7890' })
await service.checkForUpdates({ proxy: 'http://127.0.0.1:7890' })
await service.checkForUpdates()
expect(proxyController.apply).toHaveBeenNthCalledWith(1, 'http://127.0.0.1:7890')
expect(proxyController.apply).toHaveBeenNthCalledWith(2, null)
expect(proxyController.apply).toHaveBeenCalledTimes(2)
expect(localUpdater.checkForUpdates).toHaveBeenCalledTimes(3)
})
it('disables differential download so update downloads run at full bandwidth', () => {
const localUpdater = fakeUpdater()
void new ElectronUpdaterService(localUpdater)
expect(localUpdater.disableDifferentialDownload).toBe(true)
})
it('maps proxy settings to the updater net session proxy config', () => {
expect(updaterSessionProxyConfig(null)).toEqual({ mode: 'system' })
expect(updaterSessionProxyConfig('http://127.0.0.1:7890')).toEqual({
proxyRules: 'http://127.0.0.1:7890',
proxyBypassRules: '<local>',
})
})
it('does not hide non-metadata updater failures', async () => {
const service = new ElectronUpdaterService(updater)
updater.checkForUpdates.mockRejectedValueOnce(new Error('feed unavailable'))
await expect(service.checkForUpdates()).rejects.toThrow('feed unavailable')
})
it('stages then installs through quitAndInstall only after an update has downloaded', async () => {
const service = new ElectronUpdaterService(updater)
updater.checkForUpdates.mockResolvedValue({ updateInfo: { version: '1.2.4' } })
updater.downloadUpdate.mockResolvedValue(undefined)
expect(() => service.stageDownloadedUpdate()).toThrow('No Electron update is ready')
await service.checkForUpdates()
expect(() => service.stageDownloadedUpdate()).toThrow('has not finished downloading')
await service.downloadUpdate(() => {})
service.stageDownloadedUpdate()
service.quitAndInstallDownloadedUpdate()
expect(updater.quitAndInstall).toHaveBeenCalledWith(false, true)
})
})