From 2e41b59da534160a52c50fc271123200ce45b81e Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Mon, 8 Jun 2026 06:12:04 +0530 Subject: [PATCH] feat(updater): add UpdaterService wrapping electron-updater lifecycle --- src/main/updater/updater-service.js | 42 +++++++++++++ .../unit/main/updater/updater-service.test.js | 60 +++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 src/main/updater/updater-service.js create mode 100644 tests/unit/main/updater/updater-service.test.js diff --git a/src/main/updater/updater-service.js b/src/main/updater/updater-service.js new file mode 100644 index 0000000..1bc1bad --- /dev/null +++ b/src/main/updater/updater-service.js @@ -0,0 +1,42 @@ +const { EventEmitter } = require('events'); +const DEBOUNCE_MS = 60_000; + +class UpdaterService extends EventEmitter { + constructor(autoUpdater) { + super(); + this.autoUpdater = autoUpdater; + this.state = 'idle'; + this.lastCheckAt = 0; + this._wire(); + } + + _wire() { + const au = this.autoUpdater; + au.on('checking-for-update', () => this._emit({ state: 'checking' })); + au.on('update-available', (info) => this._emit({ state: 'available', version: info.version })); + au.on('download-progress', (p) => this._emit({ state: 'downloading', percent: p.percent })); + au.on('update-downloaded', (info) => this._emit({ state: 'ready', version: info.version })); + au.on('update-not-available', () => this._emit({ state: 'idle' })); + au.on('error', (err) => { + const code = err && /ENOTFOUND|ETIMEDOUT|ECONNREFUSED/.test(err.message) ? 'NETWORK' : 'UNKNOWN'; + this._emit({ state: 'error', code }); + }); + } + + _emit(payload) { + this.state = payload.state; + this.emit('status', payload); + } + + async check() { + if (Date.now() - this.lastCheckAt < DEBOUNCE_MS) return; + this.lastCheckAt = Date.now(); + await this.autoUpdater.checkForUpdates(); + } + + install() { + this.autoUpdater.quitAndInstall(); + } +} + +module.exports = { UpdaterService }; \ No newline at end of file diff --git a/tests/unit/main/updater/updater-service.test.js b/tests/unit/main/updater/updater-service.test.js new file mode 100644 index 0000000..473b122 --- /dev/null +++ b/tests/unit/main/updater/updater-service.test.js @@ -0,0 +1,60 @@ +const EventEmitter = require('events'); +const { UpdaterService } = require('../../../../src/main/updater/updater-service'); + +describe('UpdaterService', () => { + let emitter; + let mockAutoUpdater; + let service; + + beforeEach(() => { + emitter = new EventEmitter(); + mockAutoUpdater = { + on: jest.fn((event, cb) => emitter.on(event, cb)), + checkForUpdates: jest.fn(), + downloadUpdate: jest.fn(), + quitAndInstall: jest.fn(), + feedConfig: jest.fn(), + }; + service = new UpdaterService(mockAutoUpdater); + }); + + test('starts in idle state', () => { + expect(service.state).toBe('idle'); + }); + + test('check() emits checking then available on update', async () => { + const states = []; + service.on('status', (s) => states.push(s)); + mockAutoUpdater.checkForUpdates.mockImplementation(() => { + emitter.emit('checking-for-update'); + emitter.emit('update-available', { version: '5.0.2' }); + }); + await service.check(); + expect(states).toEqual([ + { state: 'checking' }, + { state: 'available', version: '5.0.2' }, + ]); + }); + + test('check() emits error on network failure', async () => { + const states = []; + service.on('status', (s) => states.push(s)); + mockAutoUpdater.checkForUpdates.mockImplementation(() => { + emitter.emit('checking-for-update'); + emitter.emit('error', new Error('ENOTFOUND')); + }); + await service.check(); + expect(states[states.length - 1]).toEqual({ state: 'error', code: 'NETWORK' }); + }); + + test('check() debounces second call within 60s', async () => { + service.lastCheckAt = Date.now(); + await service.check(); + expect(mockAutoUpdater.checkForUpdates).not.toHaveBeenCalled(); + }); + + test('install() calls quitAndInstall on the autoUpdater', () => { + service.install(); + expect(mockAutoUpdater.quitAndInstall).toHaveBeenCalled(); + }); +}); \ No newline at end of file