Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions src/auto-updater/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ function isAutoUpdateSupported () {

let updateNotification = null // must be a global to avoid gc
let feedback = false
let updateStarted = false // true while a download is in progress

function setup () {
const ctx = getCtx()
Expand All @@ -36,7 +37,15 @@ function setup () {
logger.error(`[updater] stack: ${err.stack}`)
}

// Show dialog for all errors (background and manual checks)
// Surface the error only for manual checks or once a download has
// started. Stay silent on transient errors from background checks.
if (!feedback && !updateStarted) {
return
}

updateStarted = false
feedback = false

const opt = showDialog({
title: i18n.t('autoUpdateError.title'),
message: i18n.t('autoUpdateError.message'),
Expand All @@ -50,16 +59,11 @@ function setup () {
if (opt === 1) {
shell.openExternal('https://github.com/ipfs/ipfs-desktop/releases/latest')
}

if (!feedback) {
return
}

feedback = false
})

autoUpdater.on('update-available', async ({ version, releaseNotes }) => {
logger.info(`[updater] update to ${version} available, download will start`)
updateStarted = true

try {
await autoUpdater.downloadUpdate()
Expand Down Expand Up @@ -124,6 +128,7 @@ function setup () {

autoUpdater.on('update-downloaded', ({ version }) => {
logger.info(`[updater] update to ${version} downloaded`)
updateStarted = false // download finished

const feedbackDialog = () => {
const opt = showDialog({
Expand Down
151 changes: 151 additions & 0 deletions test/unit/auto-updater.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
const proxyquire = require('proxyquire').noCallThru()
const sinon = require('sinon')
const { test, expect } = require('@playwright/test')
const { EventEmitter } = require('events')

// These tests cover the error-dialog gating in src/auto-updater. The module
// registers electron-updater event handlers and decides whether an error is
// worth a modal dialog. The rule: stay silent on transient errors during
// background checks, but surface errors from manual checks or once a download
// has started.
//
// We drive the real public entrypoint with a fake electron-updater that is an
// EventEmitter, then emit the events electron-updater would emit and assert on
// the dialog. `feedback` (manual-check mode) is reached through the same
// `manualCheckForUpdates` trigger the About menu uses.

function loadUpdater () {
const fakeAutoUpdater = Object.assign(new EventEmitter(), {
checkForUpdates: sinon.stub().resolves(),
downloadUpdate: sinon.stub().resolves(),
quitAndInstall: sinon.stub()
})

const showDialog = sinon.stub().returns(0) // default: "Later"
const shellOpenExternal = sinon.spy()
const notifications = []
class FakeNotification {
constructor (opts) {
this.opts = opts
this.on = sinon.spy()
this.show = sinon.spy()
notifications.push(this)
}
}

const ctxProps = {}
const fakeCtx = {
getFn: () => sinon.stub().resolves(0),
setProp: (key, value) => { ctxProps[key] = value },
getProp: (key) => ctxProps[key]
}

const updater = proxyquire('../../src/auto-updater', {
electron: {
shell: { openExternal: shellOpenExternal },
app: { removeAllListeners: sinon.spy() },
BrowserWindow: { getAllWindows: () => [] },
Notification: FakeNotification,
ipcMain: { emit: sinon.spy() },
autoUpdater: { on: sinon.spy() } // electron's built-in updater (before-quit-for-update)
},
'electron-updater': { autoUpdater: fakeAutoUpdater },
i18next: { t: (key) => key },
'../common/logger': { info: () => {}, error: () => {} },
'../dialogs': { showDialog },
'../common/consts': { IS_MAC: true, IS_WIN: false, IS_APPIMAGE: false },
'../common/ipc-main-events': { UPDATING: 'updating', UPDATING_ENDED: 'updating-ended' },
'../context': () => fakeCtx,
'../common/store': { get: () => false },
'../common/config-keys': { DISABLE_AUTO_UPDATE: 'disableAutoUpdate' }
})

return { updater, fakeAutoUpdater, showDialog, shellOpenExternal, notifications, ctxProps }
}

// Run the real init in the supported-platform path so the event handlers and
// the manual-check trigger get wired. NODE_ENV is forced off 'test' (which
// otherwise short-circuits init), and the 12h setInterval is neutralised so it
// does not keep the worker alive.
async function initSupported (h) {
const setIntervalStub = sinon.stub(global, 'setInterval')
const prevEnv = process.env.NODE_ENV
process.env.NODE_ENV = 'production'
try {
await h.updater()
await new Promise(resolve => setImmediate(resolve)) // let the startup check settle
} finally {
process.env.NODE_ENV = prevEnv
setIntervalStub.restore()
}
}

const tick = () => new Promise(resolve => setImmediate(resolve))

test.describe('auto-updater error dialog gating', () => {
test('background-check error stays silent', async () => {
const h = loadUpdater()
await initSupported(h)

h.fakeAutoUpdater.emit('error', new Error('network not ready after wake'))

expect(h.showDialog.called).toBe(false)
})

test('error while a download is in flight shows the dialog', async () => {
const h = loadUpdater()
await initSupported(h)

h.fakeAutoUpdater.emit('update-available', { version: '1.2.3' }) // sets updateStarted = true
await tick()
h.fakeAutoUpdater.emit('error', new Error('download failed'))

expect(h.showDialog.calledOnce).toBe(true)
})

test('error after a completed download stays silent', async () => {
// Regression guard: updateStarted must be cleared on update-downloaded, so a
// later transient background error after a finished download is not surfaced.
const h = loadUpdater()
await initSupported(h)

h.fakeAutoUpdater.emit('update-available', { version: '1.2.3' }) // updateStarted = true
await tick()
h.fakeAutoUpdater.emit('update-downloaded', { version: '1.2.3' }) // updateStarted = false
h.fakeAutoUpdater.emit('error', new Error('later transient error'))

expect(h.showDialog.called).toBe(false)
})

test('manual-check error shows the dialog', async () => {
const h = loadUpdater()
await initSupported(h)

h.ctxProps.manualCheckForUpdates() // feedback = true
h.fakeAutoUpdater.emit('error', new Error('manual check failed'))

expect(h.showDialog.calledOnce).toBe(true)
})

test('a shown error resets feedback so the next background error is silent', async () => {
const h = loadUpdater()
await initSupported(h)

h.ctxProps.manualCheckForUpdates() // feedback = true
h.fakeAutoUpdater.emit('error', new Error('first, manual')) // shown, resets feedback
h.fakeAutoUpdater.emit('error', new Error('second, background')) // silent

expect(h.showDialog.calledOnce).toBe(true)
})

test('choosing "Download Now" on an error opens the releases page', async () => {
const h = loadUpdater()
await initSupported(h)
h.showDialog.returns(1) // "Download Now"

h.ctxProps.manualCheckForUpdates()
h.fakeAutoUpdater.emit('error', new Error('boom'))

expect(h.shellOpenExternal.calledOnceWith('https://github.com/ipfs/ipfs-desktop/releases/latest')).toBe(true)
})
})
Loading