Files
orca/src/main/updater.test.ts
T
Neil e1071f59e9 Say why an update install failed instead of stalling for three minutes (#12224)
Surfaces the real install-failure cause instead of letting a failed elevation stall silently, and keeps the reconnect wait inside its total budget by recomputing the remaining time after each awaited RPC.

Relates to #11906 — this fixes the observability half. The functional half (a .deb/.rpm host cannot elevate and can never self-update) is unchanged, so the issue stays open.
2026-08-04 02:02:53 -07:00

4420 lines
160 KiB
TypeScript

/* eslint-disable max-lines */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createHash } from 'node:crypto'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type * as UpdaterModule from './updater'
import type * as RecoveryModule from './linux-package-update-recovery'
import type { UpdateStatus } from '../shared/types'
type RevalidationVerdict = Awaited<
ReturnType<typeof RecoveryModule.revalidateLinuxPackageForInstall>
>
// Captured before any vi.useFakeTimers() call: the only handle left that still yields to libuv.
const realSetTimeout = globalThis.setTimeout
type StagedLinuxPackages = {
cacheRoot: string
debPath: string
debSha512: string
rpmPath: string
rpmSha512: string
}
/**
* Stages real packages inside a real updater cache: every install re-proves the retained digest by
* streaming the file off disk, so a path that never existed would abort before reaching the native
* updater. Returns the actual digests for the download events.
*/
function stageLinuxUpdateCache(): StagedLinuxPackages {
const cacheRoot = mkdtempSync(join(tmpdir(), 'orca-updater-cache-'))
const pendingDir = join(cacheRoot, 'orca-updater', 'pending')
mkdirSync(pendingDir, { recursive: true })
const stagePackage = (fileName: string): { path: string; sha512: string } => {
const packagePath = join(pendingDir, fileName)
const bytes = Buffer.from(`orca test package ${fileName}`)
writeFileSync(packagePath, bytes)
return { path: packagePath, sha512: createHash('sha512').update(bytes).digest('base64') }
}
const deb = stagePackage('orca-ide_1.0.61_amd64.deb')
const rpm = stagePackage('orca-ide-1.0.61.x86_64.rpm')
return {
cacheRoot,
debPath: deb.path,
debSha512: deb.sha512,
rpmPath: rpm.path,
rpmSha512: rpm.sha512
}
}
const {
appMock,
browserWindowMock,
nativeUpdaterMock,
autoUpdaterMock,
isMock,
killAllPtyMock,
powerMonitorOnMock
} = vi.hoisted(() => {
const appEventHandlers = new Map<string, ((...args: unknown[]) => void)[]>()
const eventHandlers = new Map<string, ((...args: unknown[]) => void)[]>()
const appOn = vi.fn((event: string, handler: (...args: unknown[]) => void) => {
const handlers = appEventHandlers.get(event) ?? []
handlers.push(handler)
appEventHandlers.set(event, handlers)
return appMock
})
const appEmit = (event: string, ...args: unknown[]) => {
for (const handler of appEventHandlers.get(event) ?? []) {
handler(...args)
}
}
const on = vi.fn((event: string, handler: (...args: unknown[]) => void) => {
const handlers = eventHandlers.get(event) ?? []
handlers.push(handler)
eventHandlers.set(event, handlers)
return autoUpdaterMock
})
const emit = (event: string, ...args: unknown[]) => {
for (const handler of eventHandlers.get(event) ?? []) {
handler(...args)
}
}
const reset = () => {
appEventHandlers.clear()
appOn.mockClear()
eventHandlers.clear()
on.mockClear()
autoUpdaterMock.checkForUpdates.mockReset().mockResolvedValue(null)
autoUpdaterMock.downloadUpdate.mockReset()
autoUpdaterMock.quitAndInstall.mockReset()
autoUpdaterMock.setFeedURL.mockClear()
autoUpdaterMock.updateConfigPath = undefined
autoUpdaterMock.allowPrerelease = false
autoUpdaterMock.allowDowngrade = false
autoUpdaterMock.disableDifferentialDownload = false
autoUpdaterMock.autoRunAppAfterInstall = true
autoUpdaterMock.logger = undefined
delete (autoUpdaterMock as Record<string, unknown>).verifyUpdateCodeSignature
}
const autoUpdaterMock = {
autoDownload: false,
autoInstallOnAppQuit: false,
autoRunAppAfterInstall: true,
allowPrerelease: false,
allowDowngrade: false,
disableDifferentialDownload: false,
// Why: setup installs the diagnostic logger adapter here; tests drive child stderr through it.
logger: undefined as { error: (message: unknown) => void } | undefined,
on,
checkForUpdates: vi.fn(),
downloadUpdate: vi.fn(),
quitAndInstall: vi.fn(),
setFeedURL: vi.fn(),
updateConfigPath: undefined as string | undefined,
emit,
reset
}
return {
appMock: {
isPackaged: true,
getVersion: vi.fn(() => '1.0.51'),
on: appOn,
emit: appEmit,
quit: vi.fn()
},
browserWindowMock: {
getAllWindows: vi.fn(() => [])
},
nativeUpdaterMock: {
on: vi.fn()
},
autoUpdaterMock,
isMock: { dev: false },
killAllPtyMock: vi.fn(),
powerMonitorOnMock: vi.fn()
}
})
vi.mock('electron', () => ({
app: appMock,
BrowserWindow: browserWindowMock,
autoUpdater: nativeUpdaterMock,
powerMonitor: { on: powerMonitorOnMock },
shell: { showItemInFolder: vi.fn() },
net: { fetch: vi.fn() }
}))
const { getLinuxRootPackageTypeMock, recordUpdaterLifecycleMock } = vi.hoisted(() => ({
getLinuxRootPackageTypeMock: vi.fn<() => 'deb' | 'rpm' | null>(() => null),
recordUpdaterLifecycleMock: vi.fn()
}))
// Why: macOS keeps the restart advice because quitting does re-stage a Squirrel update.
const PRE_COMMIT_INSTALL_FAILURE =
process.platform === 'darwin'
? 'Could not restart to install the update. Quit and reopen Orca, then try again.'
: 'Could not start the update installer. Orca remains open.'
// Why: only the marker resolver is faked so the real artifact capture/redaction path stays under test.
vi.mock('./linux-update-package-type', () => ({
getLinuxRootPackageType: getLinuxRootPackageTypeMock
}))
vi.mock('./updater-lifecycle-diagnostics', () => ({
recordUpdaterLifecycle: recordUpdaterLifecycleMock
}))
vi.mock('electron-updater', () => ({
autoUpdater: autoUpdaterMock
}))
vi.mock('./electron-updater-loader', () => ({
loadElectronAutoUpdater: () => autoUpdaterMock
}))
vi.mock('@electron-toolkit/utils', () => ({
is: isMock
}))
vi.mock('./ipc/pty', () => ({
killAllPty: killAllPtyMock
}))
const { fetchChangelogMock } = vi.hoisted(() => ({
fetchChangelogMock: vi.fn()
}))
vi.mock('./updater-changelog', () => ({
fetchChangelog: fetchChangelogMock
}))
const { fetchNudgeMock, shouldApplyNudgeMock } = vi.hoisted(() => ({
fetchNudgeMock: vi.fn(),
shouldApplyNudgeMock: vi.fn()
}))
vi.mock('./updater-nudge', () => ({
fetchNudge: fetchNudgeMock,
shouldApplyNudge: shouldApplyNudgeMock
}))
const { armExitWatchdogMock, disarmExitWatchdogMock } = vi.hoisted(() => ({
armExitWatchdogMock: vi.fn(),
disarmExitWatchdogMock: vi.fn()
}))
vi.mock('./update-install-exit-watchdog', () => ({
armUpdateInstallExitWatchdog: armExitWatchdogMock,
disarmUpdateInstallExitWatchdog: disarmExitWatchdogMock
}))
const { fetchNewerReleaseTagsMock } = vi.hoisted(() => ({
fetchNewerReleaseTagsMock: vi.fn()
}))
const { chooseLocalBuildMock, startLocalBuildFeedMock, closeLocalBuildFeedMock } = vi.hoisted(
() => ({
chooseLocalBuildMock: vi.fn(),
startLocalBuildFeedMock: vi.fn(),
closeLocalBuildFeedMock: vi.fn()
})
)
vi.mock('./updater-prerelease-feed', () => ({
fetchNewerReleaseTagsWithReadiness: async (...args: unknown[]) => {
const result = await fetchNewerReleaseTagsMock(...args)
return Array.isArray(result)
? { tags: result, state: result.length > 0 ? 'ready' : 'no-newer' }
: result
},
getReleaseDownloadUrl: (tag: string) =>
`https://github.com/stablyai/orca/releases/download/${tag}`
}))
vi.mock('./local-builds/local-build-switch', () => ({
chooseLocalBuild: chooseLocalBuildMock
}))
vi.mock('./local-builds/local-build-feed-server', () => ({
startLocalBuildFeed: startLocalBuildFeedMock
}))
/** Mirrors AUTO_UPDATE_CHECK_INTERVAL_MS in updater.ts. */
const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
describe('updater', () => {
beforeEach(() => {
vi.resetModules()
autoUpdaterMock.reset()
nativeUpdaterMock.on.mockReset()
browserWindowMock.getAllWindows.mockReset()
browserWindowMock.getAllWindows.mockReturnValue([])
appMock.getVersion.mockReset()
appMock.getVersion.mockReturnValue('1.0.51')
appMock.quit.mockReset()
appMock.isPackaged = true
isMock.dev = false
killAllPtyMock.mockReset()
armExitWatchdogMock.mockReset()
disarmExitWatchdogMock.mockReset()
powerMonitorOnMock.mockReset()
getLinuxRootPackageTypeMock.mockReset().mockReturnValue(null)
recordUpdaterLifecycleMock.mockReset()
fetchNudgeMock.mockReset().mockResolvedValue(null)
shouldApplyNudgeMock.mockReset().mockReturnValue(false)
fetchChangelogMock.mockReset().mockResolvedValue(null)
fetchNewerReleaseTagsMock.mockReset().mockResolvedValue([])
chooseLocalBuildMock.mockReset()
closeLocalBuildFeedMock.mockReset()
startLocalBuildFeedMock.mockReset().mockResolvedValue({
url: 'http://127.0.0.1:1234/token/',
close: closeLocalBuildFeedMock
})
vi.unstubAllGlobals()
vi.useRealTimers()
})
it('does not load or configure electron-updater during dev setup', async () => {
isMock.dev = true
const mainWindow = { webContents: { send: vi.fn() } }
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never)
// Why: E2E dev-mode launches use a default app version that makes electron-updater throw during module load.
expect(autoUpdaterMock.updateConfigPath).toBeUndefined()
expect(autoUpdaterMock.setFeedURL).not.toHaveBeenCalled()
expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled()
expect(powerMonitorOnMock).not.toHaveBeenCalled()
})
it.each([
['hourly', 'v1.4.160-hourly.202607281400', 'Hourly builds are produced only for macOS.'],
['adhoc', 'v1.4.160-adhoc.20260728140533', 'Adhoc builds are produced only for macOS.']
] as const)(
'uses the display label in the mac-only %s pinned-build error',
async (channel, tag, message) => {
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux')
try {
const send = vi.fn()
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater({ webContents: { send } } as never, {
getLastUpdateCheckAt: () => Date.now()
})
checkForUpdatesFromMenu({ channel, targetTag: tag })
expect(send).toHaveBeenCalledWith('updater:status', {
state: 'error',
message,
userInitiated: true
})
expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled()
} finally {
platformSpy.mockRestore()
}
}
)
it.runIf(process.platform === 'darwin')(
'allows a validated local build to downgrade through the normal updater lifecycle',
async () => {
chooseLocalBuildMock.mockResolvedValue({
version: '0.9.0-local.1',
manifestContent: 'version: 0.9.0-local.1',
artifacts: new Map()
})
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
autoUpdaterMock.emit('update-available', { version: '0.9.0-local.1' })
return Promise.resolve(undefined)
})
const send = vi.fn()
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater({ webContents: { send } } as never, {
getLastUpdateCheckAt: () => Date.now()
})
checkForUpdatesFromMenu({ localBuild: true })
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
expect(autoUpdaterMock.allowDowngrade).toBe(true)
expect(autoUpdaterMock.disableDifferentialDownload).toBe(true)
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'http://127.0.0.1:1234/token/'
})
await vi.waitFor(() => {
expect(send).toHaveBeenCalledWith(
'updater:status',
expect.objectContaining({
state: 'available',
version: '0.9.0-local.1',
source: 'local'
})
)
})
setupAutoUpdater({ webContents: { send } } as never, {
getLastUpdateCheckAt: () => Date.now()
})
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'http://127.0.0.1:1234/token/'
})
expect(autoUpdaterMock.allowDowngrade).toBe(true)
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
})
expect(closeLocalBuildFeedMock).toHaveBeenCalledTimes(1)
expect(autoUpdaterMock.allowDowngrade).toBe(false)
expect(autoUpdaterMock.disableDifferentialDownload).toBe(false)
}
)
it.runIf(process.platform === 'darwin')(
'restores ordinary release checks after local build selection fails',
async () => {
chooseLocalBuildMock.mockRejectedValue(new Error('invalid local build'))
const send = vi.fn()
const { setupAutoUpdater, checkForUpdates, checkForUpdatesFromMenu } =
await import('./updater')
setupAutoUpdater({ webContents: { send } } as never, {
getLastUpdateCheckAt: () => Date.now()
})
checkForUpdatesFromMenu({ localBuild: true })
await vi.waitFor(() => {
expect(send).toHaveBeenCalledWith('updater:status', {
state: 'error',
message: 'invalid local build',
userInitiated: true,
source: 'local'
})
})
checkForUpdates()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
expect(autoUpdaterMock.allowDowngrade).toBe(false)
expect(autoUpdaterMock.disableDifferentialDownload).toBe(false)
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/latest/download'
})
}
)
it.runIf(process.platform === 'darwin')(
'restores ordinary release checks after a local build is unavailable',
async () => {
chooseLocalBuildMock.mockResolvedValue({
version: '0.9.0-local.1',
manifestContent: 'version: 0.9.0-local.1',
artifacts: new Map()
})
autoUpdaterMock.checkForUpdates.mockImplementationOnce(() => {
autoUpdaterMock.emit('checking-for-update')
autoUpdaterMock.emit('update-not-available')
return Promise.resolve(undefined)
})
const send = vi.fn()
const { setupAutoUpdater, checkForUpdates, checkForUpdatesFromMenu } =
await import('./updater')
setupAutoUpdater({ webContents: { send } } as never, {
getLastUpdateCheckAt: () => Date.now()
})
checkForUpdatesFromMenu({ localBuild: true })
await vi.waitFor(() => {
expect(closeLocalBuildFeedMock).toHaveBeenCalledTimes(1)
})
expect(send).toHaveBeenCalledWith('updater:status', {
state: 'not-available',
userInitiated: true,
source: 'local'
})
expect(autoUpdaterMock.allowDowngrade).toBe(false)
expect(autoUpdaterMock.disableDifferentialDownload).toBe(false)
checkForUpdates()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
})
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/latest/download'
})
}
)
it.runIf(process.platform === 'darwin')(
'restores ordinary release checks after a local updater failure',
async () => {
chooseLocalBuildMock.mockResolvedValue({
version: '0.9.0-local.1',
manifestContent: 'version: 0.9.0-local.1',
artifacts: new Map()
})
autoUpdaterMock.checkForUpdates.mockRejectedValueOnce(new Error('local feed failed'))
const send = vi.fn()
const { setupAutoUpdater, checkForUpdates, checkForUpdatesFromMenu } =
await import('./updater')
setupAutoUpdater({ webContents: { send } } as never, {
getLastUpdateCheckAt: () => Date.now()
})
checkForUpdatesFromMenu({ localBuild: true })
await vi.waitFor(() => {
expect(send).toHaveBeenCalledWith('updater:status', {
state: 'error',
message: 'local feed failed',
userInitiated: true,
source: 'local'
})
})
expect(closeLocalBuildFeedMock).toHaveBeenCalledTimes(1)
expect(autoUpdaterMock.allowDowngrade).toBe(false)
expect(autoUpdaterMock.disableDifferentialDownload).toBe(false)
checkForUpdates()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
})
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/latest/download'
})
}
)
it.runIf(process.platform === 'darwin')(
'keeps the automatic check chain alive across a local build session',
async () => {
vi.useFakeTimers()
chooseLocalBuildMock.mockResolvedValue({
version: '0.9.0-local.1',
manifestContent: 'version: 0.9.0-local.1',
artifacts: new Map()
})
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
autoUpdaterMock.emit('update-available', { version: '0.9.0-local.1' })
return Promise.resolve(undefined)
})
autoUpdaterMock.downloadUpdate.mockRejectedValue(new Error('local download failed'))
const send = vi.fn()
const { setupAutoUpdater, checkForUpdatesFromMenu, downloadUpdate } =
await import('./updater')
setupAutoUpdater({ webContents: { send } } as never, {
getLastUpdateCheckAt: () => Date.now()
})
checkForUpdatesFromMenu({ localBuild: true })
await vi.advanceTimersByTimeAsync(0)
expect(send).toHaveBeenCalledWith(
'updater:status',
expect.objectContaining({ state: 'available', source: 'local' })
)
// Why: assert through this window rather than the shared updater mock — a status only lands here
// when this module instance owns the check attempt, so sibling tests' timers can't fake a pass.
autoUpdaterMock.checkForUpdates.mockReset().mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
autoUpdaterMock.emit('update-not-available')
return Promise.resolve(undefined)
})
// The scheduled release check fires while the local session still owns the feed, so it defers.
send.mockClear()
await vi.advanceTimersByTimeAsync(AUTO_UPDATE_CHECK_INTERVAL_MS)
expect(send).not.toHaveBeenCalledWith('updater:status', { state: 'not-available' })
// A failed local download ends the session and restores the release source.
downloadUpdate()
await vi.advanceTimersByTimeAsync(0)
expect(autoUpdaterMock.allowDowngrade).toBe(false)
send.mockClear()
await vi.advanceTimersByTimeAsync(AUTO_UPDATE_CHECK_INTERVAL_MS)
expect(send).toHaveBeenCalledWith('updater:status', { state: 'not-available' })
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/latest/download'
})
}
)
it.runIf(process.platform === 'darwin')(
'restores release checks when an offered local build is dismissed',
async () => {
chooseLocalBuildMock.mockResolvedValue({
version: '0.9.0-local.1',
manifestContent: 'version: 0.9.0-local.1',
artifacts: new Map()
})
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
autoUpdaterMock.emit('update-available', { version: '0.9.0-local.1' })
return Promise.resolve(undefined)
})
const send = vi.fn()
const { setupAutoUpdater, checkForUpdates, checkForUpdatesFromMenu, dismissAvailableUpdate } =
await import('./updater')
setupAutoUpdater({ webContents: { send } } as never, {
getLastUpdateCheckAt: () => Date.now()
})
checkForUpdatesFromMenu({ localBuild: true })
await vi.waitFor(() => {
expect(send).toHaveBeenCalledWith(
'updater:status',
expect.objectContaining({ state: 'available', source: 'local' })
)
})
dismissAvailableUpdate()
expect(closeLocalBuildFeedMock).toHaveBeenCalledTimes(1)
expect(autoUpdaterMock.allowDowngrade).toBe(false)
expect(autoUpdaterMock.disableDifferentialDownload).toBe(false)
expect(send).toHaveBeenCalledWith('updater:status', { state: 'idle' })
autoUpdaterMock.checkForUpdates.mockReset().mockResolvedValue(undefined)
checkForUpdates()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/latest/download'
})
}
)
it.runIf(process.platform === 'darwin')(
'keeps the local feed in force when a dismiss lands during a local build download',
async () => {
chooseLocalBuildMock.mockResolvedValue({
version: '0.9.0-local.1',
manifestContent: 'version: 0.9.0-local.1',
artifacts: new Map()
})
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
autoUpdaterMock.emit('update-available', { version: '0.9.0-local.1' })
return Promise.resolve(undefined)
})
autoUpdaterMock.downloadUpdate.mockResolvedValue(undefined)
const send = vi.fn()
const { setupAutoUpdater, checkForUpdatesFromMenu, dismissAvailableUpdate, downloadUpdate } =
await import('./updater')
setupAutoUpdater({ webContents: { send } } as never, {
getLastUpdateCheckAt: () => Date.now()
})
checkForUpdatesFromMenu({ localBuild: true })
await vi.waitFor(() => {
expect(send).toHaveBeenCalledWith(
'updater:status',
expect.objectContaining({ state: 'available', source: 'local' })
)
})
downloadUpdate()
dismissAvailableUpdate()
expect(closeLocalBuildFeedMock).not.toHaveBeenCalled()
expect(autoUpdaterMock.allowDowngrade).toBe(true)
expect(autoUpdaterMock.disableDifferentialDownload).toBe(true)
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'http://127.0.0.1:1234/token/'
})
}
)
it('leaves a dismissed release update on the release source', async () => {
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
autoUpdaterMock.emit('update-available', { version: '2.0.0' })
return Promise.resolve(undefined)
})
const send = vi.fn()
const { setupAutoUpdater, checkForUpdatesFromMenu, dismissAvailableUpdate } =
await import('./updater')
setupAutoUpdater({ webContents: { send } } as never, {
getLastUpdateCheckAt: () => Date.now()
})
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(send).toHaveBeenCalledWith(
'updater:status',
expect.objectContaining({ state: 'available', version: '2.0.0' })
)
})
dismissAvailableUpdate()
// Why: a release dismissal is renderer-only state; main must not clear the offer it can still install.
expect(closeLocalBuildFeedMock).not.toHaveBeenCalled()
expect(send).not.toHaveBeenCalledWith('updater:status', { state: 'idle' })
})
it('deduplicates identical check errors from the event and rejected promise', async () => {
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
queueMicrotask(() => {
autoUpdaterMock.emit('error', new Error('boom'))
})
return Promise.reject(new Error('boom'))
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({ state: 'error', message: 'boom', userInitiated: true })
})
const errorStatuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
.filter((status) => typeof status === 'object' && status !== null && status.state === 'error')
expect(errorStatuses).toEqual([{ state: 'error', message: 'boom', userInitiated: true }])
})
it('surfaces net::ERR_FAILED to user-initiated checks with a friendly message', async () => {
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
queueMicrotask(() => {
autoUpdaterMock.emit('error', new Error('net::ERR_FAILED'))
})
return Promise.reject(new Error('net::ERR_FAILED'))
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual(
expect.objectContaining({
state: 'error',
userInitiated: true,
message: expect.stringContaining("Couldn't reach the update server")
})
)
})
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({ state: 'checking', userInitiated: true })
// Why: the raw electron-updater message is replaced so we never surface "net::ERR_FAILED" to the UI.
expect(statuses).not.toContainEqual(
expect.objectContaining({ state: 'error', message: 'net::ERR_FAILED' })
)
})
it('shows checking immediately for a user-initiated check while feed pinning is pending', async () => {
let resolveTags: (value: { tags: string[]; state: 'no-newer' }) => void = () => {}
fetchNewerReleaseTagsMock.mockImplementation(
() =>
new Promise<{ tags: string[]; state: 'no-newer' }>((resolve) => {
resolveTags = resolve
})
)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
return new Promise(() => {})
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'checking',
userInitiated: true
})
expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled()
resolveTags({ tags: [], state: 'no-newer' })
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
const checkingStatuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
.filter(
(status) => typeof status === 'object' && status !== null && status.state === 'checking'
)
expect(checkingStatuses).toEqual([{ state: 'checking', userInitiated: true }])
})
it('keeps background checks event-driven before checking-for-update fires', async () => {
let resolveTags: (value: { tags: string[]; state: 'no-newer' }) => void = () => {}
fetchNewerReleaseTagsMock.mockImplementation(
() =>
new Promise<{ tags: string[]; state: 'no-newer' }>((resolve) => {
resolveTags = resolve
})
)
autoUpdaterMock.checkForUpdates.mockImplementation(() => new Promise(() => {}))
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => null })
expect(
sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
.some(
(status) => typeof status === 'object' && status !== null && status.state === 'checking'
)
).toBe(false)
resolveTags({ tags: [], state: 'no-newer' })
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
autoUpdaterMock.emit('checking-for-update')
expect(sendMock).toHaveBeenCalledWith(
'updater:status',
expect.objectContaining({ state: 'checking' })
)
})
it('promotes a pending background check to user-initiated without launching a duplicate check', async () => {
let resolveTags: (value: { tags: string[]; state: 'no-newer' }) => void = () => {}
fetchNewerReleaseTagsMock.mockImplementation(
() =>
new Promise<{ tags: string[]; state: 'no-newer' }>((resolve) => {
resolveTags = resolve
})
)
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => null })
checkForUpdatesFromMenu()
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'checking',
userInitiated: true
})
expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled()
resolveTags({ tags: [], state: 'no-newer' })
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
autoUpdaterMock.emit('checking-for-update')
autoUpdaterMock.emit('update-not-available')
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'not-available',
userInitiated: true
})
})
it('keeps a silent background settle user-initiated after menu promotion', async () => {
vi.useFakeTimers()
fetchNewerReleaseTagsMock.mockResolvedValue({ tags: [], state: 'no-newer' })
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => null })
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
await vi.advanceTimersByTimeAsync(0)
sendMock.mockClear()
checkForUpdatesFromMenu()
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'checking',
userInitiated: true
})
await vi.advanceTimersByTimeAsync(1000)
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'not-available',
userInitiated: true
})
})
it('settles a manual check when electron-updater resolves without a terminal event', async () => {
vi.useFakeTimers()
fetchNewerReleaseTagsMock.mockResolvedValue({ tags: [], state: 'no-newer' })
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const sendMock = vi.fn()
const setLastUpdateCheckAt = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => Date.now(),
setLastUpdateCheckAt
})
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
await vi.advanceTimersByTimeAsync(1000)
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'not-available',
userInitiated: true
})
expect(setLastUpdateCheckAt).toHaveBeenCalledTimes(1)
})
it('ignores a stale update-available event after a silent background settle', async () => {
vi.useFakeTimers()
fetchNewerReleaseTagsMock.mockResolvedValue({ tags: [], state: 'no-newer' })
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => null,
setLastUpdateCheckAt: vi.fn()
})
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
await vi.advanceTimersByTimeAsync(1000)
autoUpdaterMock.emit('update-available', { version: '1.0.61' })
await vi.advanceTimersByTimeAsync(0)
expect(fetchChangelogMock).not.toHaveBeenCalled()
expect(sendMock).not.toHaveBeenCalledWith(
'updater:status',
expect.objectContaining({ state: 'available', version: '1.0.61' })
)
})
it('ignores a stale checking-for-update event after a silent manual settle', async () => {
vi.useFakeTimers()
fetchNewerReleaseTagsMock.mockResolvedValue({ tags: [], state: 'no-newer' })
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
await vi.advanceTimersByTimeAsync(1000)
sendMock.mockClear()
autoUpdaterMock.emit('checking-for-update')
expect(sendMock).not.toHaveBeenCalled()
})
it('ignores stale updater events while a new check is still in feed preflight', async () => {
vi.useFakeTimers()
let resolveSecondTags: (value: { tags: string[]; state: 'no-newer' }) => void = () => {}
fetchNewerReleaseTagsMock
.mockResolvedValueOnce({ tags: [], state: 'no-newer' })
.mockImplementationOnce(
() =>
new Promise<{ tags: string[]; state: 'no-newer' }>((resolve) => {
resolveSecondTags = resolve
})
)
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
await vi.advanceTimersByTimeAsync(1000)
sendMock.mockClear()
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(fetchNewerReleaseTagsMock).toHaveBeenCalledTimes(2)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
autoUpdaterMock.emit('checking-for-update')
autoUpdaterMock.emit('update-not-available')
expect(sendMock).not.toHaveBeenCalledWith('updater:status', {
state: 'not-available',
userInitiated: true
})
resolveSecondTags({ tags: [], state: 'no-newer' })
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
})
autoUpdaterMock.emit('checking-for-update')
autoUpdaterMock.emit('update-not-available')
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'not-available',
userInitiated: true
})
})
it('does not let a stale silent settle finish a later manual check', async () => {
vi.useFakeTimers()
fetchNewerReleaseTagsMock.mockResolvedValue({ tags: [], state: 'no-newer' })
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
const callCount = autoUpdaterMock.checkForUpdates.mock.calls.length
autoUpdaterMock.emit('checking-for-update')
if (callCount === 1) {
queueMicrotask(() => {
autoUpdaterMock.emit('update-not-available')
})
return Promise.resolve(undefined)
}
return new Promise(() => {})
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'not-available',
userInitiated: true
})
})
sendMock.mockClear()
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'checking',
userInitiated: true
})
})
await vi.advanceTimersByTimeAsync(1000)
expect(sendMock).not.toHaveBeenCalledWith('updater:status', {
state: 'not-available',
userInitiated: true
})
})
it('does not let a stale pending update-available block a later silent settle', async () => {
vi.useFakeTimers()
let resolveChangelog: (value: null) => void = () => {}
fetchChangelogMock.mockImplementation(
() =>
new Promise<null>((resolve) => {
resolveChangelog = resolve
})
)
fetchNewerReleaseTagsMock.mockResolvedValue({ tags: ['v1.0.52'], state: 'ready' })
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
const callCount = autoUpdaterMock.checkForUpdates.mock.calls.length
autoUpdaterMock.emit('checking-for-update')
if (callCount === 1) {
queueMicrotask(() => {
autoUpdaterMock.emit('update-available', { version: '1.0.52' })
})
}
return Promise.resolve(undefined)
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(fetchChangelogMock).toHaveBeenCalledTimes(1)
})
autoUpdaterMock.emit('error', new Error('boom'))
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'error',
message: 'boom',
userInitiated: undefined
})
})
sendMock.mockClear()
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'checking',
userInitiated: true
})
})
await vi.advanceTimersByTimeAsync(1000)
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'not-available',
userInitiated: true
})
sendMock.mockClear()
resolveChangelog(null)
await vi.advanceTimersByTimeAsync(0)
expect(sendMock).not.toHaveBeenCalledWith(
'updater:status',
expect.objectContaining({ state: 'available', version: '1.0.52' })
)
})
it('ignores a stale update-available event after a new check starts preflight', async () => {
vi.useFakeTimers()
fetchNewerReleaseTagsMock.mockResolvedValue({ tags: [], state: 'no-newer' })
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
if (autoUpdaterMock.checkForUpdates.mock.calls.length === 1) {
return Promise.resolve(undefined)
}
return new Promise(() => {})
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
await vi.advanceTimersByTimeAsync(1000)
sendMock.mockClear()
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'checking',
userInitiated: true
})
})
autoUpdaterMock.emit('update-available', { version: '1.0.61' })
await vi.advanceTimersByTimeAsync(0)
expect(fetchChangelogMock).not.toHaveBeenCalled()
expect(sendMock).not.toHaveBeenCalledWith(
'updater:status',
expect.objectContaining({ state: 'available', version: '1.0.61' })
)
})
it('ignores a stale update-not-available event after a new check starts preflight', async () => {
vi.useFakeTimers()
fetchNewerReleaseTagsMock.mockResolvedValue({ tags: [], state: 'no-newer' })
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
if (autoUpdaterMock.checkForUpdates.mock.calls.length === 1) {
return Promise.resolve(undefined)
}
return new Promise(() => {})
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
await vi.advanceTimersByTimeAsync(1000)
sendMock.mockClear()
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'checking',
userInitiated: true
})
})
autoUpdaterMock.emit('update-not-available')
expect(sendMock).not.toHaveBeenCalledWith('updater:status', {
state: 'not-available',
userInitiated: true
})
})
it('ignores a stale error event after a new check starts preflight', async () => {
vi.useFakeTimers()
let resolveSecondTags: (value: { tags: string[]; state: 'no-newer' }) => void = () => {}
fetchNewerReleaseTagsMock
.mockResolvedValueOnce({ tags: [], state: 'no-newer' })
.mockImplementationOnce(
() =>
new Promise<{ tags: string[]; state: 'no-newer' }>((resolve) => {
resolveSecondTags = resolve
})
)
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
await vi.advanceTimersByTimeAsync(1000)
sendMock.mockClear()
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(fetchNewerReleaseTagsMock).toHaveBeenCalledTimes(2)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
autoUpdaterMock.emit('error', new Error('stale boom'))
expect(sendMock).not.toHaveBeenCalledWith(
'updater:status',
expect.objectContaining({ state: 'error', message: 'stale boom' })
)
resolveSecondTags({ tags: [], state: 'no-newer' })
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
})
autoUpdaterMock.emit('checking-for-update')
autoUpdaterMock.emit('update-not-available')
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'not-available',
userInitiated: true
})
})
it('times out a manual preflight that never reaches electron-updater events', async () => {
vi.useFakeTimers()
fetchNewerReleaseTagsMock.mockImplementation(() => new Promise(() => {}))
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'checking',
userInitiated: true
})
await vi.advanceTimersByTimeAsync(45 * 1000)
expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled()
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'error',
message: 'Update check timed out. Try again in a few minutes.',
userInitiated: true
})
})
it('does not launch electron-updater after a manual preflight timeout settles', async () => {
vi.useFakeTimers()
let resolveTags: (value: { tags: string[]; state: 'no-newer' }) => void = () => {}
fetchNewerReleaseTagsMock.mockImplementation(
() =>
new Promise<{ tags: string[]; state: 'no-newer' }>((resolve) => {
resolveTags = resolve
})
)
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.advanceTimersByTimeAsync(45 * 1000)
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'error',
message: 'Update check timed out. Try again in a few minutes.',
userInitiated: true
})
resolveTags({ tags: [], state: 'no-newer' })
await vi.advanceTimersByTimeAsync(0)
expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled()
})
it('runs a fresh prerelease check when Shift-click promotes an in-flight stable check', async () => {
vi.useFakeTimers()
let resolveStableTags: (value: { tags: string[]; state: 'no-newer' }) => void = () => {}
let resolveStableCheck: () => void = () => {}
fetchNewerReleaseTagsMock
.mockImplementationOnce(
() =>
new Promise<{ tags: string[]; state: 'no-newer' }>((resolve) => {
resolveStableTags = resolve
})
)
.mockResolvedValueOnce({ tags: ['v1.4.36-rc.5'], state: 'ready' })
autoUpdaterMock.checkForUpdates
.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveStableCheck = resolve
})
)
.mockResolvedValueOnce(undefined)
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
appMock.getVersion.mockReturnValue('1.4.35')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => null })
checkForUpdatesFromMenu({ includePrerelease: true })
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'checking',
userInitiated: true
})
expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled()
resolveStableTags({ tags: [], state: 'no-newer' })
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
autoUpdaterMock.emit('checking-for-update')
autoUpdaterMock.emit('update-not-available')
await vi.advanceTimersByTimeAsync(0)
resolveStableCheck()
await vi.waitFor(() => {
expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.4.35', 2, {
includePrerelease: true
})
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
})
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/download/v1.4.36-rc.5'
})
expect(
sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
).not.toContainEqual({ state: 'not-available', userInitiated: true })
})
it('keeps promoted background promise failures user-initiated after a paired error event', async () => {
let resolveTags: (value: { tags: string[]; state: 'no-newer' }) => void = () => {}
fetchNewerReleaseTagsMock.mockImplementation(
() =>
new Promise<{ tags: string[]; state: 'no-newer' }>((resolve) => {
resolveTags = resolve
})
)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
queueMicrotask(() => {
autoUpdaterMock.emit('error', new Error('net::ERR_FAILED'))
})
return Promise.reject(new Error('net::ERR_FAILED'))
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => null })
checkForUpdatesFromMenu()
resolveTags({ tags: [], state: 'no-newer' })
await vi.waitFor(() => {
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual(
expect.objectContaining({
state: 'error',
userInitiated: true,
message: expect.stringContaining("Couldn't reach the update server")
})
)
})
const resultStatuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
.filter(
(status) =>
typeof status === 'object' &&
status !== null &&
(status.state === 'idle' || status.state === 'error')
)
expect(resultStatuses).toEqual([
expect.objectContaining({
state: 'error',
userInitiated: true,
message: expect.stringContaining("Couldn't reach the update server")
})
])
})
it('deduplicates repeated manual checks while the immediate checking status is active', async () => {
let resolveTags: (value: { tags: string[]; state: 'no-newer' }) => void = () => {}
fetchNewerReleaseTagsMock.mockImplementation(
() =>
new Promise<{ tags: string[]; state: 'no-newer' }>((resolve) => {
resolveTags = resolve
})
)
autoUpdaterMock.checkForUpdates.mockImplementation(() => new Promise(() => {}))
const mainWindow = { webContents: { send: vi.fn() } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
checkForUpdatesFromMenu()
resolveTags({ tags: [], state: 'no-newer' })
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
expect(fetchNewerReleaseTagsMock).toHaveBeenCalledTimes(1)
})
it('opts into the RC channel when checkForUpdatesFromMenu is called with includePrerelease', async () => {
appMock.getVersion.mockReturnValue('1.3.17')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.18-rc.1'])
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const mainWindow = { webContents: { send: vi.fn() } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
// Why: recent timestamp defers the startup check so we observe updater state before any RC-mode call, without racing.
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
const setupFeedUrlCalls = autoUpdaterMock.setFeedURL.mock.calls.length
expect(autoUpdaterMock.allowPrerelease).not.toBe(true)
checkForUpdatesFromMenu({ includePrerelease: true })
await vi.waitFor(() => {
expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.3.17', 2, {
includePrerelease: true
})
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/download/v1.3.18-rc.1'
})
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
expect(autoUpdaterMock.allowPrerelease).toBe(true)
expect(autoUpdaterMock.setFeedURL.mock.calls.length).toBe(setupFeedUrlCalls + 1)
})
it('pins the generic feed to a perf-tagged prerelease when requested', async () => {
appMock.getVersion.mockReturnValue('1.4.120')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.4.121-rc.6.perf'])
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const mainWindow = { webContents: { send: vi.fn() } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu({ includePerfPrerelease: true })
await vi.waitFor(() => {
expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.4.120', 2, {
includePrerelease: true,
releaseFilter: 'perf'
})
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/download/v1.4.121-rc.6.perf'
})
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
expect(autoUpdaterMock.allowPrerelease).toBe(true)
})
it('surfaces no-update feedback when no newer perf-tagged prerelease exists', async () => {
appMock.getVersion.mockReturnValue('1.4.120')
fetchNewerReleaseTagsMock.mockResolvedValue({ tags: [], state: 'no-newer' })
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
const setupFeedUrlCalls = autoUpdaterMock.setFeedURL.mock.calls.length
checkForUpdatesFromMenu({ includePerfPrerelease: true })
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'not-available',
userInitiated: true
})
})
expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.4.120', 2, {
includePrerelease: true,
releaseFilter: 'perf'
})
expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled()
expect(autoUpdaterMock.setFeedURL.mock.calls.length).toBe(setupFeedUrlCalls)
})
it('keeps background retries on the stable channel after a perf publishing-window miss', async () => {
vi.useFakeTimers()
appMock.getVersion.mockReturnValue('1.4.120')
fetchNewerReleaseTagsMock
.mockResolvedValueOnce({ tags: [], state: 'not-ready' })
.mockResolvedValueOnce(['v1.4.121'])
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu({ includePerfPrerelease: true })
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'error',
message: "Couldn't reach the update server. Try again in a few minutes.",
userInitiated: true
})
})
await vi.advanceTimersByTimeAsync(60 * 60 * 1000)
await vi.waitFor(() => {
expect(fetchNewerReleaseTagsMock).toHaveBeenNthCalledWith(2, '1.4.120', 1, {
includePrerelease: false
})
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/download/v1.4.121'
})
})
})
it('keeps prerelease publishing-window misses on the generic retry path', async () => {
appMock.getVersion.mockReturnValue('1.4.120-rc.5')
fetchNewerReleaseTagsMock.mockResolvedValue({ tags: [], state: 'not-ready' })
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu({ includePrerelease: true })
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'error',
message: "Couldn't reach the update server. Try again in a few minutes.",
userInitiated: true
})
})
expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.4.120-rc.5', 2, {
includePrerelease: true
})
expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled()
})
it('leaves the feed URL alone for a normal user-initiated check', async () => {
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const mainWindow = { webContents: { send: vi.fn() } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
const initialFeedUrlCalls = autoUpdaterMock.setFeedURL.mock.calls.length
checkForUpdatesFromMenu()
checkForUpdatesFromMenu({ includePrerelease: false })
expect(autoUpdaterMock.setFeedURL.mock.calls.length).toBe(initialFeedUrlCalls)
expect(autoUpdaterMock.allowPrerelease).not.toBe(true)
})
it('still surfaces updater error events while a download is in flight', async () => {
fetchNewerReleaseTagsMock.mockResolvedValue({ tags: ['v1.0.61'], state: 'ready' })
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
queueMicrotask(() => {
autoUpdaterMock.emit('update-available', { version: '1.0.61' })
})
return Promise.resolve(undefined)
})
autoUpdaterMock.downloadUpdate.mockImplementation(() => {
autoUpdaterMock.emit('error', new Error('download failed'))
return new Promise(() => {})
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu, downloadUpdate } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'available',
version: '1.0.61',
changelog: null
})
})
sendMock.mockClear()
downloadUpdate()
expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(1)
expect(sendMock).toHaveBeenCalledWith(
'updater:status',
expect.objectContaining({ state: 'error', message: 'download failed' })
)
})
it('surfaces an accepted retry before electron-updater emits download progress', async () => {
fetchNewerReleaseTagsMock.mockResolvedValue({ tags: ['v1.0.61'], state: 'ready' })
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
queueMicrotask(() => {
autoUpdaterMock.emit('update-available', { version: '1.0.61' })
})
return Promise.resolve(undefined)
})
autoUpdaterMock.downloadUpdate
.mockRejectedValueOnce(new Error('signature check blocked'))
.mockImplementationOnce(() => new Promise(() => {}))
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu, downloadUpdate } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'available',
version: '1.0.61',
changelog: null
})
})
downloadUpdate()
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'error',
message: 'signature check blocked'
})
})
sendMock.mockClear()
downloadUpdate()
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'downloading',
percent: 0,
version: '1.0.61'
})
downloadUpdate()
expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(2)
})
it('defers quitAndInstall through the shared main-process entrypoint', async () => {
vi.useFakeTimers()
const mainWindow = { webContents: { send: vi.fn() } }
const { setupAutoUpdater, quitAndInstall } = await import('./updater')
setupAutoUpdater(mainWindow as never)
quitAndInstall()
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(99)
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledWith(false, true)
})
it('runs pre-quit cleanup before local PTY cleanup during update install', async () => {
vi.useFakeTimers()
const onBeforeQuit = vi.fn()
const mainWindow = { webContents: { send: vi.fn() } }
const { setupAutoUpdater, quitAndInstall } = await import('./updater')
setupAutoUpdater(mainWindow as never, { onBeforeQuit })
quitAndInstall()
await vi.advanceTimersByTimeAsync(100)
expect(onBeforeQuit).toHaveBeenCalledTimes(1)
expect(killAllPtyMock).toHaveBeenCalledTimes(1)
expect(onBeforeQuit.mock.invocationCallOrder[0]).toBeLessThan(
killAllPtyMock.mock.invocationCallOrder[0]
)
})
it('ignores duplicate quitAndInstall requests while the shared delay is pending', async () => {
vi.useFakeTimers()
const mainWindow = { webContents: { send: vi.fn() } }
const { setupAutoUpdater, quitAndInstall } = await import('./updater')
setupAutoUpdater(mainWindow as never)
quitAndInstall()
quitAndInstall()
await vi.advanceTimersByTimeAsync(100)
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
})
it('ignores duplicate quitAndInstall requests while async pre-quit cleanup is running', async () => {
vi.useFakeTimers()
let finishCleanup!: () => void
const onBeforeQuit = vi.fn(
() =>
new Promise<void>((resolve) => {
finishCleanup = resolve
})
)
const mainWindow = { webContents: { send: vi.fn() } }
const { setupAutoUpdater, quitAndInstall } = await import('./updater')
setupAutoUpdater(mainWindow as never, { onBeforeQuit })
quitAndInstall()
await vi.advanceTimersByTimeAsync(100)
expect(onBeforeQuit).toHaveBeenCalledTimes(1)
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
quitAndInstall()
finishCleanup()
await vi.advanceTimersByTimeAsync(0)
expect(onBeforeQuit).toHaveBeenCalledTimes(1)
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
})
it('recovers quit-for-update state on sync quitAndInstall error event without killing PTYs', async () => {
vi.useFakeTimers()
autoUpdaterMock.quitAndInstall.mockImplementation(() => {
// Why: BaseUpdater dispatches 'error' synchronously inside install() for the common "no staged update filepath" path.
autoUpdaterMock.emit(
'error',
new Error("No update filepath provided, can't quit and install")
)
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, quitAndInstall, isQuittingForUpdate } = await import('./updater')
setupAutoUpdater(mainWindow as never)
quitAndInstall()
await vi.advanceTimersByTimeAsync(100)
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
expect(isQuittingForUpdate()).toBe(false)
// Why: destructive prep runs only after quitAndInstall returns still in progress; sync recovery clears flags first so PTYs stay alive.
expect(killAllPtyMock).not.toHaveBeenCalled()
expect(sendMock).toHaveBeenCalledWith(
'updater:status',
expect.objectContaining({
state: 'error',
// Why: a pre-commit install failure is not fixed by restarting, so the copy must not
// suggest it — except on macOS, where quitting does re-stage a Squirrel update.
// The updater's own text is appended because it is the only record of why the install never ran.
message: `${PRE_COMMIT_INSTALL_FAILURE} (No update filepath provided, can't quit and install)`
})
)
})
it('does not recover quit-for-update state from late errors after install commit', async () => {
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
fetchNewerReleaseTagsMock.mockResolvedValue({ tags: ['v1.0.61'], state: 'ready' })
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
queueMicrotask(() => {
autoUpdaterMock.emit('update-available', { version: '1.0.61' })
})
return Promise.resolve(undefined)
})
const { setupAutoUpdater, checkForUpdatesFromMenu, quitAndInstall, isQuittingForUpdate } =
await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
// Why: reach the downloaded state so a late post-commit error isn't mistaken for a download/install UI failure.
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'available',
version: '1.0.61',
changelog: null
})
})
autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' })
// Why: on macOS install commits only once Squirrel is ready; mark it ready so this test covers the post-commit path on all platforms.
if (process.platform === 'darwin') {
const nativeDownloadedHandler = nativeUpdaterMock.on.mock.calls.find(
([eventName]) => eventName === 'update-downloaded'
)?.[1] as (() => void) | undefined
expect(nativeDownloadedHandler).toBeTypeOf('function')
nativeDownloadedHandler?.()
}
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledWith(
'updater:status',
expect.objectContaining({ state: 'downloaded', version: '1.0.61' })
)
})
quitAndInstall()
await vi.waitFor(() => {
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
})
expect(killAllPtyMock).toHaveBeenCalledTimes(1)
expect(isQuittingForUpdate()).toBe(true)
sendMock.mockClear()
autoUpdaterMock.emit('error', new Error('late post-commit install error'))
expect(isQuittingForUpdate()).toBe(true)
// Why: handoff still owns the process after commit — no recovery message or check/download error status.
expect(sendMock).not.toHaveBeenCalled()
})
it('arms the forced-exit watchdog once the install commits', async () => {
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
fetchNewerReleaseTagsMock.mockResolvedValue({ tags: ['v1.0.61'], state: 'ready' })
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
queueMicrotask(() => {
autoUpdaterMock.emit('update-available', { version: '1.0.61' })
})
return Promise.resolve(undefined)
})
const { setupAutoUpdater, checkForUpdatesFromMenu, quitAndInstall } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'available',
version: '1.0.61',
changelog: null
})
})
autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' })
// Why: on macOS install commits only once Squirrel is ready; mark it ready so this test covers the committed path on all platforms.
if (process.platform === 'darwin') {
const nativeDownloadedHandler = nativeUpdaterMock.on.mock.calls.find(
([eventName]) => eventName === 'update-downloaded'
)?.[1] as (() => void) | undefined
expect(nativeDownloadedHandler).toBeTypeOf('function')
nativeDownloadedHandler?.()
}
expect(armExitWatchdogMock).not.toHaveBeenCalled()
quitAndInstall()
await vi.waitFor(() => {
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
})
// Why: the installer (ShipIt/NSIS) waits for this process to exit; the watchdog prevents a wedged async shutdown from stranding the update.
expect(armExitWatchdogMock).toHaveBeenCalledTimes(1)
})
it('disarms the forced-exit watchdog when sync install error recovery keeps the app open', async () => {
vi.useFakeTimers()
autoUpdaterMock.quitAndInstall.mockImplementation(() => {
autoUpdaterMock.emit(
'error',
new Error("No update filepath provided, can't quit and install")
)
})
const mainWindow = { webContents: { send: vi.fn() } }
const { setupAutoUpdater, quitAndInstall, isQuittingForUpdate } = await import('./updater')
setupAutoUpdater(mainWindow as never)
quitAndInstall()
await vi.advanceTimersByTimeAsync(100)
expect(isQuittingForUpdate()).toBe(false)
// Why: recovery leaves the app running; a live watchdog would force-exit a healthy session 20s later.
expect(armExitWatchdogMock).not.toHaveBeenCalled()
expect(disarmExitWatchdogMock).toHaveBeenCalled()
})
it('does not treat pre-native autoUpdater errors as quitAndInstall recovery', async () => {
vi.useFakeTimers()
let finishCleanup!: () => void
const onBeforeQuit = vi.fn(
() =>
new Promise<void>((resolve) => {
finishCleanup = resolve
})
)
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, quitAndInstall, isQuittingForUpdate } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
onBeforeQuit,
getLastUpdateCheckAt: () => Date.now()
})
quitAndInstall()
await vi.advanceTimersByTimeAsync(100)
expect(onBeforeQuit).toHaveBeenCalledTimes(1)
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
expect(isQuittingForUpdate()).toBe(true)
sendMock.mockClear()
// Why: unrelated error during pre-quit cleanup must not clear quittingForUpdate or emit install-recovery status (native not invoked).
autoUpdaterMock.emit('error', new Error('pre-native concurrent error'))
expect(isQuittingForUpdate()).toBe(true)
expect(sendMock).not.toHaveBeenCalled()
finishCleanup()
await vi.advanceTimersByTimeAsync(0)
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
expect(isQuittingForUpdate()).toBe(true)
})
it('runs a startup check immediately when the last background check is stale', async () => {
const mainWindow = { webContents: { send: vi.fn() } }
const setLastUpdateCheckAt = vi.fn()
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => Date.now() - 25 * 60 * 60 * 1000,
setLastUpdateCheckAt
})
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
expect(setLastUpdateCheckAt).not.toHaveBeenCalled()
})
it('starts nudge polling only after updater initialization is complete', async () => {
const mainWindow = { webContents: { send: vi.fn() } }
fetchNudgeMock.mockResolvedValue({ id: 'campaign-1', minVersion: '1.0.0' })
shouldApplyNudgeMock.mockReturnValue(true)
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never)
expect(autoUpdaterMock.setFeedURL).toHaveBeenCalledTimes(1)
expect(autoUpdaterMock.on).toHaveBeenCalled()
expect(fetchNudgeMock).toHaveBeenCalledTimes(1)
expect(autoUpdaterMock.setFeedURL.mock.invocationCallOrder[0]).toBeLessThan(
fetchNudgeMock.mock.invocationCallOrder[0]
)
expect(autoUpdaterMock.on.mock.invocationCallOrder[0]).toBeLessThan(
fetchNudgeMock.mock.invocationCallOrder[0]
)
})
it('waits until the remaining interval before the next background check', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-03T12:00:00Z'))
const mainWindow = { webContents: { send: vi.fn() } }
const setLastUpdateCheckAt = vi.fn()
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => Date.now() - 23 * 60 * 60 * 1000,
setLastUpdateCheckAt
})
expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(59 * 60 * 1000)
expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(60 * 1000)
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
expect(setLastUpdateCheckAt).not.toHaveBeenCalled()
})
it('deduplicates rapid focus-triggered daily checks before checking status arrives', async () => {
let lastUpdateCheckAt = Date.now()
const mainWindow = { webContents: { send: vi.fn() } }
autoUpdaterMock.checkForUpdates.mockImplementation(() => new Promise(() => {}))
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => lastUpdateCheckAt
})
lastUpdateCheckAt = Date.now() - 25 * 60 * 60 * 1000
appMock.emit('browser-window-focus')
appMock.emit('browser-window-focus')
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
})
it('does not persist lastUpdateCheckAt when a focus-triggered check fails benignly', async () => {
let lastUpdateCheckAt = Date.now()
const setLastUpdateCheckAt = vi.fn()
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
queueMicrotask(() => {
autoUpdaterMock.emit('error', new Error('net::ERR_FAILED'))
})
return Promise.reject(new Error('net::ERR_FAILED'))
})
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => lastUpdateCheckAt,
setLastUpdateCheckAt
})
lastUpdateCheckAt = Date.now() - 25 * 60 * 60 * 1000
appMock.emit('browser-window-focus')
await vi.waitFor(() => {
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({ state: 'idle' })
})
expect(setLastUpdateCheckAt).not.toHaveBeenCalled()
})
it('retries background checks sooner after a failed automatic check', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-03T12:00:00Z'))
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
queueMicrotask(() => {
autoUpdaterMock.emit('error', new Error('boom'))
})
return Promise.reject(new Error('boom'))
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => null,
setLastUpdateCheckAt: vi.fn()
})
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
await vi.advanceTimersByTimeAsync(59 * 60 * 1000)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(60 * 1000)
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
})
})
it('reschedules the next automatic check 24 hours after finding an available update', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-03T12:00:00Z'))
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
queueMicrotask(() => {
autoUpdaterMock.emit('update-available', { version: '1.0.61' })
})
return Promise.resolve(undefined)
})
const sendMock = vi.fn()
const setLastUpdateCheckAt = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => null,
setLastUpdateCheckAt
})
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
await vi.advanceTimersByTimeAsync(0)
expect(setLastUpdateCheckAt).toHaveBeenCalledTimes(1)
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'available',
version: '1.0.61',
changelog: null
})
await vi.advanceTimersByTimeAsync(23 * 60 * 60 * 1000 + 59 * 60 * 1000)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(60 * 1000)
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
})
})
it('does not leak a nudge marker into a later ordinary update cycle', async () => {
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
fetchNudgeMock.mockResolvedValue({ id: 'campaign-1', minVersion: '1.0.0' })
shouldApplyNudgeMock.mockReturnValue(true)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
return Promise.resolve(undefined)
})
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
// Why: recent timestamp defers the startup check so the nudge check runs without hitting the 'checking' guard.
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => Date.now()
})
await new Promise((resolve) => setTimeout(resolve, 0))
autoUpdaterMock.emit('update-available', { version: '1.0.61' })
await new Promise((resolve) => setTimeout(resolve, 0))
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'available',
version: '1.0.61',
changelog: null,
activeNudgeId: 'campaign-1'
})
sendMock.mockClear()
checkForUpdatesFromMenu()
await vi.waitFor(() => {
const statusCalls = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statusCalls).toContainEqual({ state: 'checking', userInitiated: true })
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
})
autoUpdaterMock.emit('update-available', { version: '1.0.62' })
await new Promise((resolve) => setTimeout(resolve, 0))
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'available',
version: '1.0.62',
changelog: null
})
expect(sendMock).not.toHaveBeenCalledWith(
'updater:status',
expect.objectContaining({ version: '1.0.62', activeNudgeId: 'campaign-1' })
)
})
it('preserves the pending nudge marker across a later background check', async () => {
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
return Promise.resolve(undefined)
})
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => null,
getPendingUpdateNudgeId: () => 'campaign-1',
getDismissedUpdateNudgeId: () => null
})
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
autoUpdaterMock.emit('update-available', { version: '1.0.61' })
await new Promise((resolve) => setTimeout(resolve, 0))
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'available',
version: '1.0.61',
changelog: null,
activeNudgeId: 'campaign-1'
})
})
it('does not trigger a nudge check while an updater check is already in progress', async () => {
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
fetchNudgeMock.mockResolvedValue({ id: 'campaign-1', minVersion: '1.0.0' })
shouldApplyNudgeMock.mockReturnValue(true)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
// Stay in 'checking' state — don't resolve
return new Promise(() => {})
})
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => null,
setLastUpdateCheckAt: vi.fn()
})
// Wait for the startup nudge check to run
await new Promise((resolve) => setTimeout(resolve, 0))
// The 'checking' guard should block runBackgroundUpdateCheck while the startup check is in progress.
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
it('respects the activation/resume cooldown for nudge checks', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-12T12:00:00Z'))
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
fetchNudgeMock.mockResolvedValue({ id: 'campaign-1', minVersion: '1.0.0' })
shouldApplyNudgeMock.mockReturnValue(true)
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never)
// Wait for the startup nudge check to complete
await vi.advanceTimersByTimeAsync(0)
// Startup check set lastNudgeCheckAt, so browser-window-focus is blocked by the 5-minute cooldown.
fetchNudgeMock.mockClear()
appMock.emit('browser-window-focus')
await vi.advanceTimersByTimeAsync(0)
// fetchNudge should NOT have been called again — cooldown blocks it
expect(fetchNudgeMock).not.toHaveBeenCalled()
// Advance past the cooldown
vi.advanceTimersByTime(5 * 60 * 1000 + 1)
appMock.emit('browser-window-focus')
await vi.advanceTimersByTimeAsync(0)
expect(fetchNudgeMock).toHaveBeenCalledTimes(1)
})
it('clears pending nudge campaign when the follow-up check ends in not-available', async () => {
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const setPendingUpdateNudgeId = vi.fn()
const setDismissedUpdateNudgeId = vi.fn()
fetchNudgeMock.mockResolvedValue({ id: 'campaign-1', minVersion: '1.0.0' })
shouldApplyNudgeMock.mockReturnValue(true)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
return Promise.resolve(undefined)
})
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => Date.now(),
setPendingUpdateNudgeId,
getPendingUpdateNudgeId: () => null,
getDismissedUpdateNudgeId: () => null,
setDismissedUpdateNudgeId
})
await new Promise((resolve) => setTimeout(resolve, 0))
// Nudge was applied — pending id was set
expect(setPendingUpdateNudgeId).toHaveBeenCalledWith('campaign-1')
// Now simulate the updater finding no update
autoUpdaterMock.emit('update-not-available')
// Pending cleared and campaign auto-dismissed so it doesn't re-fire next poll cycle.
expect(setPendingUpdateNudgeId).toHaveBeenCalledWith(null)
expect(setDismissedUpdateNudgeId).toHaveBeenCalledWith('campaign-1')
})
it('clears pending nudge campaign when a silent follow-up check settles not-available', async () => {
vi.useFakeTimers()
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
let pendingNudgeId: string | null = null
const setPendingUpdateNudgeId = vi.fn((id: string | null) => {
pendingNudgeId = id
})
const setDismissedUpdateNudgeId = vi.fn()
fetchNudgeMock.mockResolvedValue({ id: 'campaign-1', minVersion: '1.0.0' })
shouldApplyNudgeMock.mockReturnValue(true)
fetchNewerReleaseTagsMock.mockResolvedValue({ tags: [], state: 'no-newer' })
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => Date.now(),
setPendingUpdateNudgeId,
getPendingUpdateNudgeId: () => pendingNudgeId,
getDismissedUpdateNudgeId: () => null,
setDismissedUpdateNudgeId
})
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
expect(setPendingUpdateNudgeId).toHaveBeenCalledWith('campaign-1')
await vi.advanceTimersByTimeAsync(1000)
expect(sendMock).toHaveBeenCalledWith('updater:status', { state: 'not-available' })
expect(setPendingUpdateNudgeId).toHaveBeenCalledWith(null)
expect(setDismissedUpdateNudgeId).toHaveBeenCalledWith('campaign-1')
expect(pendingNudgeId).toBe(null)
})
it('auto-dismisses nudge campaign when the follow-up check errors out', async () => {
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const setPendingUpdateNudgeId = vi.fn()
const setDismissedUpdateNudgeId = vi.fn()
fetchNudgeMock.mockResolvedValue({ id: 'campaign-1', minVersion: '1.0.0' })
shouldApplyNudgeMock.mockReturnValue(true)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
return Promise.resolve(undefined)
})
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => Date.now(),
setPendingUpdateNudgeId,
getPendingUpdateNudgeId: () => null,
getDismissedUpdateNudgeId: () => null,
setDismissedUpdateNudgeId
})
await new Promise((resolve) => setTimeout(resolve, 0))
expect(setPendingUpdateNudgeId).toHaveBeenCalledWith('campaign-1')
// Simulate an error during the nudge-triggered check
autoUpdaterMock.emit('error', new Error('network timeout'))
// Campaign should be auto-dismissed to prevent re-fire loop
expect(setDismissedUpdateNudgeId).toHaveBeenCalledWith('campaign-1')
expect(setPendingUpdateNudgeId).toHaveBeenCalledWith(null)
})
it('does not preserve a pending nudge for ordinary manifest transition errors', async () => {
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const setPendingUpdateNudgeId = vi.fn()
const setDismissedUpdateNudgeId = vi.fn()
const missingManifest = new Error(
'Cannot find channel "latest-mac.yml" update info: HttpError: 404'
)
fetchNudgeMock.mockResolvedValue({ id: 'campaign-1', minVersion: '1.0.0' })
shouldApplyNudgeMock.mockReturnValue(true)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
return Promise.reject(missingManifest)
})
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => Date.now(),
setPendingUpdateNudgeId,
getPendingUpdateNudgeId: () => null,
getDismissedUpdateNudgeId: () => null,
setDismissedUpdateNudgeId
})
await vi.waitFor(() => {
expect(setPendingUpdateNudgeId).toHaveBeenCalledWith(null)
})
expect(setDismissedUpdateNudgeId).toHaveBeenCalledWith('campaign-1')
})
it('moves pending nudge to dismissed when dismissNudge is called', async () => {
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const setPendingUpdateNudgeId = vi.fn()
const setDismissedUpdateNudgeId = vi.fn()
fetchNudgeMock.mockResolvedValue({ id: 'campaign-1', minVersion: '1.0.0' })
shouldApplyNudgeMock.mockReturnValue(true)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
return Promise.resolve(undefined)
})
const { setupAutoUpdater, dismissNudge } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => Date.now(),
setPendingUpdateNudgeId,
getPendingUpdateNudgeId: () => 'campaign-1',
getDismissedUpdateNudgeId: () => null,
setDismissedUpdateNudgeId
})
await new Promise((resolve) => setTimeout(resolve, 0))
// Simulate update found, then user dismisses
autoUpdaterMock.emit('update-available', { version: '1.0.61' })
await new Promise((resolve) => setTimeout(resolve, 0))
dismissNudge()
expect(setDismissedUpdateNudgeId).toHaveBeenCalledWith('campaign-1')
expect(setPendingUpdateNudgeId).toHaveBeenCalledWith(null)
})
// Why: a no-op verifyUpdateCodeSignature override would silently accept every installer; keep electron-updater's Authenticode check (issue #631 resolved).
it('does not disable Windows Authenticode verification on win32', async () => {
vi.stubGlobal('process', { ...process, platform: 'win32' })
const { setupAutoUpdater } = await import('./updater')
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
setupAutoUpdater(mainWindow as never)
expect((autoUpdaterMock as Record<string, unknown>).verifyUpdateCodeSignature).toBeUndefined()
})
it('does not override verifyUpdateCodeSignature on non-Windows platforms', async () => {
vi.stubGlobal('process', { ...process, platform: 'darwin' })
const { setupAutoUpdater } = await import('./updater')
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
setupAutoUpdater(mainWindow as never)
expect((autoUpdaterMock as Record<string, unknown>).verifyUpdateCodeSignature).toBeUndefined()
})
// Why: native github provider + allowPrerelease traps RC users on the RC channel, so resolve the newest tag ourselves and pin the generic feed to it.
it('repins the generic feed to the newest RC tag for a prerelease user', async () => {
appMock.getVersion.mockReturnValue('1.3.17-rc.1')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.17-rc.2'])
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
const mainWindow = { webContents: { send: vi.fn() } }
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
// Setup pins the default generic feed; resolver only runs per check.
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/latest/download'
})
expect(autoUpdaterMock.allowPrerelease).not.toBe(true)
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.3.17-rc.1', 2, {
includePrerelease: true
})
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/download/v1.3.17-rc.2'
})
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
})
// Why: RC users couldn't upgrade to newer stable (PR #1053); resolver must pick the stable tag for a prerelease user.
it('repins the generic feed to a newer stable tag for a prerelease user', async () => {
appMock.getVersion.mockReturnValue('1.3.19-rc.6')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.19'])
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
const mainWindow = { webContents: { send: vi.fn() } }
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/download/v1.3.19'
})
})
expect(autoUpdaterMock.allowPrerelease).not.toBe(true)
})
// Why: if the atom resolver fails or finds nothing newer, fall back to /releases/latest/download so the check completes as "not-available" instead of erroring.
it('falls back to /releases/latest/download when the atom resolver returns null', async () => {
appMock.getVersion.mockReturnValue('1.3.19-rc.6')
fetchNewerReleaseTagsMock.mockResolvedValue([])
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
const mainWindow = { webContents: { send: vi.fn() } }
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/latest/download'
})
})
it('keeps unavailable release probes on generic copy without launching a moving feed', async () => {
appMock.getVersion.mockReturnValue('1.4.141')
fetchNewerReleaseTagsMock.mockResolvedValue({
tags: [],
state: 'unavailable',
unavailableReason: 'manifest'
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
const feedCallsBeforeCheck = autoUpdaterMock.setFeedURL.mock.calls.length
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'error',
message: "Couldn't reach the update server. Try again in a few minutes.",
userInitiated: true
})
})
expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled()
expect(autoUpdaterMock.setFeedURL.mock.calls.slice(feedCallsBeforeCheck)).not.toContainEqual([
{
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/latest/download'
}
])
})
it('keeps Atom feed outages on the existing moving-feed fallback', async () => {
appMock.getVersion.mockReturnValue('1.4.141')
fetchNewerReleaseTagsMock.mockResolvedValue({
tags: [],
state: 'unavailable',
unavailableReason: 'feed'
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/latest/download'
})
expect(sendMock).not.toHaveBeenCalledWith(
'updater:status',
expect.objectContaining({ state: 'error' })
)
})
it('uses last-good concrete feed when a user-initiated check lands during publishing', async () => {
appMock.getVersion.mockReturnValue('1.4.26')
fetchNewerReleaseTagsMock.mockResolvedValue({
tags: [],
state: 'not-ready',
lastGoodTag: 'v1.4.26'
})
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
queueMicrotask(() => {
autoUpdaterMock.emit('update-not-available')
})
return Promise.resolve(undefined)
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
const feedCallsBeforeCheck = autoUpdaterMock.setFeedURL.mock.calls.length
checkForUpdatesFromMenu()
await vi.waitFor(() => {
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({ state: 'checking', userInitiated: true })
expect(statuses).toContainEqual({ state: 'not-available', userInitiated: true })
})
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/download/v1.4.26'
})
expect(autoUpdaterMock.setFeedURL).not.toHaveBeenCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/download/v1.4.27'
})
expect(autoUpdaterMock.setFeedURL.mock.calls.slice(feedCallsBeforeCheck)).not.toContainEqual([
{
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/latest/download'
}
])
expect(sendMock).not.toHaveBeenCalledWith(
'updater:status',
expect.objectContaining({ state: 'error' })
)
})
it('can offer an older fully published update while the newest release is publishing', async () => {
appMock.getVersion.mockReturnValue('1.4.25')
fetchNewerReleaseTagsMock.mockResolvedValue({
tags: [],
state: 'not-ready',
lastGoodTag: 'v1.4.26'
})
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
queueMicrotask(() => {
autoUpdaterMock.emit('update-available', { version: '1.4.26' })
})
return Promise.resolve(undefined)
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'available',
version: '1.4.26',
changelog: null
})
})
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/download/v1.4.26'
})
})
it('keeps background publishing-window fallback on the short retry cadence', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-05-24T21:40:00Z'))
appMock.getVersion.mockReturnValue('1.4.26')
fetchNewerReleaseTagsMock
.mockResolvedValueOnce({
tags: [],
state: 'not-ready',
lastGoodTag: 'v1.4.26'
})
.mockResolvedValueOnce(['v1.4.27'])
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
return new Promise(() => {})
})
const setLastUpdateCheckAt = vi.fn()
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => null,
setLastUpdateCheckAt
})
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/download/v1.4.26'
})
expect(setLastUpdateCheckAt).not.toHaveBeenCalled()
autoUpdaterMock.emit('update-not-available')
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledWith('updater:status', { state: 'not-available' })
})
expect(setLastUpdateCheckAt).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(59 * 60 * 1000)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(60 * 1000)
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
})
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/download/v1.4.27'
})
})
it('keeps silent publishing-window fallback on the short retry cadence', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-05-24T21:40:00Z'))
appMock.getVersion.mockReturnValue('1.4.26')
fetchNewerReleaseTagsMock
.mockResolvedValueOnce({
tags: [],
state: 'not-ready',
lastGoodTag: 'v1.4.26'
})
.mockResolvedValueOnce(['v1.4.27'])
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
return Promise.resolve(undefined)
})
const setLastUpdateCheckAt = vi.fn()
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => null,
setLastUpdateCheckAt
})
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
await vi.advanceTimersByTimeAsync(1000)
expect(sendMock).toHaveBeenCalledWith(
'updater:status',
expect.objectContaining({ state: 'not-available' })
)
expect(setLastUpdateCheckAt).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(59 * 60 * 1000)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(60 * 1000)
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
})
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/download/v1.4.27'
})
})
it('keeps background checks retryable while newer release assets are still publishing', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-05-24T21:40:00Z'))
appMock.getVersion.mockReturnValue('1.4.26')
fetchNewerReleaseTagsMock
.mockResolvedValueOnce({ tags: [], state: 'not-ready' })
.mockResolvedValueOnce(['v1.4.27'])
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const setLastUpdateCheckAt = vi.fn()
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => null,
setLastUpdateCheckAt
})
await vi.waitFor(() => {
expect(fetchNewerReleaseTagsMock).toHaveBeenCalledTimes(1)
})
expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.4.26', 1, {
includePrerelease: false
})
expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled()
expect(setLastUpdateCheckAt).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(60 * 60 * 1000)
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/download/v1.4.27'
})
})
it.each([
{
caseName: 'stable-not-ready',
version: '1.4.26',
candidateLimit: 1,
includePrerelease: false,
result: { tags: [], state: 'not-ready' as const }
},
{
caseName: 'prerelease-not-ready',
version: '1.4.26-rc.1',
candidateLimit: 2,
includePrerelease: true,
result: { tags: [], state: 'not-ready' as const }
},
{
caseName: 'stable-manifest-unavailable',
version: '1.4.26',
candidateLimit: 1,
includePrerelease: false,
result: { tags: [], state: 'unavailable' as const, unavailableReason: 'manifest' as const }
}
])(
'keeps a nudge campaign pending for $caseName',
async ({ version, candidateLimit, includePrerelease, result }) => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-05-24T21:40:00Z'))
appMock.getVersion.mockReturnValue(version)
fetchNudgeMock.mockResolvedValueOnce({ id: 'campaign-1', minVersion: '1.0.0' })
fetchNudgeMock.mockResolvedValue(null)
shouldApplyNudgeMock.mockReturnValue(true)
fetchNewerReleaseTagsMock
.mockResolvedValueOnce(result)
.mockResolvedValueOnce({ tags: ['v1.4.27'], state: 'ready' })
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
return Promise.resolve(undefined)
})
let pendingNudgeId: string | null = null
const setPendingUpdateNudgeId = vi.fn((id: string | null) => {
pendingNudgeId = id
})
const setDismissedUpdateNudgeId = vi.fn()
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => Date.now(),
getPendingUpdateNudgeId: () => pendingNudgeId,
getDismissedUpdateNudgeId: () => null,
setPendingUpdateNudgeId,
setDismissedUpdateNudgeId
})
await vi.waitFor(() => {
expect(fetchNewerReleaseTagsMock).toHaveBeenCalledTimes(1)
})
expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith(version, candidateLimit, {
includePrerelease
})
expect(setPendingUpdateNudgeId).toHaveBeenCalledWith('campaign-1')
expect(setPendingUpdateNudgeId).not.toHaveBeenCalledWith(null)
expect(setDismissedUpdateNudgeId).not.toHaveBeenCalled()
expect(pendingNudgeId).toBe('campaign-1')
await vi.advanceTimersByTimeAsync(60 * 60 * 1000)
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
autoUpdaterMock.emit('update-available', { version: '1.4.27' })
await vi.advanceTimersByTimeAsync(0)
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'available',
version: '1.4.27',
changelog: null,
activeNudgeId: 'campaign-1'
})
expect(setDismissedUpdateNudgeId).not.toHaveBeenCalled()
}
)
it('does not dismiss a nudge when last-good fallback is current during publishing', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-05-24T21:40:00Z'))
appMock.getVersion.mockReturnValue('1.4.26')
fetchNudgeMock.mockResolvedValue({ id: 'campaign-1', minVersion: '1.0.0' })
shouldApplyNudgeMock.mockReturnValue(true)
fetchNewerReleaseTagsMock
.mockResolvedValueOnce({
tags: [],
state: 'not-ready',
lastGoodTag: 'v1.4.26'
})
.mockResolvedValueOnce(['v1.4.27'])
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
if (autoUpdaterMock.checkForUpdates.mock.calls.length === 1) {
queueMicrotask(() => {
autoUpdaterMock.emit('update-not-available')
})
}
return Promise.resolve(undefined)
})
let pendingNudgeId: string | null = null
const setPendingUpdateNudgeId = vi.fn((id: string | null) => {
pendingNudgeId = id
})
const setDismissedUpdateNudgeId = vi.fn()
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => Date.now(),
getPendingUpdateNudgeId: () => pendingNudgeId,
getDismissedUpdateNudgeId: () => null,
setPendingUpdateNudgeId,
setDismissedUpdateNudgeId
})
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
expect(sendMock).toHaveBeenCalledWith('updater:status', { state: 'not-available' })
})
expect(setPendingUpdateNudgeId).toHaveBeenCalledWith('campaign-1')
expect(setPendingUpdateNudgeId).not.toHaveBeenCalledWith(null)
expect(setDismissedUpdateNudgeId).not.toHaveBeenCalled()
expect(pendingNudgeId).toBe('campaign-1')
await vi.advanceTimersByTimeAsync(60 * 60 * 1000)
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates.mock.calls.length).toBeGreaterThanOrEqual(2)
})
autoUpdaterMock.emit('update-available', { version: '1.4.27' })
await vi.advanceTimersByTimeAsync(0)
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'available',
version: '1.4.27',
changelog: null,
activeNudgeId: 'campaign-1'
})
})
it('does not attach nudge dismissal to an older last-good available update', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-05-24T21:40:00Z'))
appMock.getVersion.mockReturnValue('1.4.25')
fetchNudgeMock.mockResolvedValueOnce({ id: 'campaign-1', minVersion: '1.0.0' })
fetchNudgeMock.mockResolvedValue(null)
shouldApplyNudgeMock.mockReturnValue(true)
fetchNewerReleaseTagsMock
.mockResolvedValueOnce({
tags: [],
state: 'not-ready',
lastGoodTag: 'v1.4.26'
})
.mockResolvedValueOnce(['v1.4.27'])
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
if (autoUpdaterMock.checkForUpdates.mock.calls.length === 1) {
queueMicrotask(() => {
autoUpdaterMock.emit('update-available', { version: '1.4.26' })
})
}
return Promise.resolve(undefined)
})
let pendingNudgeId: string | null = null
const setPendingUpdateNudgeId = vi.fn((id: string | null) => {
pendingNudgeId = id
})
const setDismissedUpdateNudgeId = vi.fn()
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, dismissNudge } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => Date.now(),
getPendingUpdateNudgeId: () => pendingNudgeId,
getDismissedUpdateNudgeId: () => null,
setPendingUpdateNudgeId,
setDismissedUpdateNudgeId
})
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'available',
version: '1.4.26',
changelog: null
})
})
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
const lastGoodStatus = statuses.find(
(status) => status.state === 'available' && status.version === '1.4.26'
)
if (lastGoodStatus && 'activeNudgeId' in lastGoodStatus) {
dismissNudge()
}
expect(setPendingUpdateNudgeId).toHaveBeenCalledWith('campaign-1')
expect(setPendingUpdateNudgeId).not.toHaveBeenCalledWith(null)
expect(setDismissedUpdateNudgeId).not.toHaveBeenCalled()
expect(pendingNudgeId).toBe('campaign-1')
await vi.advanceTimersByTimeAsync(60 * 60 * 1000)
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates.mock.calls.length).toBeGreaterThanOrEqual(2)
})
autoUpdaterMock.emit('update-available', { version: '1.4.27' })
await vi.advanceTimersByTimeAsync(0)
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'available',
version: '1.4.27',
changelog: null,
activeNudgeId: 'campaign-1'
})
})
it('retries a prerelease check once against the previous feed tag when the manifest is missing', async () => {
appMock.getVersion.mockReturnValue('1.3.51-rc.6')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.51-rc.7', 'v1.3.51-rc.6'])
const missingManifest = new Error(
'Cannot find channel "latest-mac.yml" update info: HttpError: 404'
)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
if (autoUpdaterMock.checkForUpdates.mock.calls.length === 1) {
queueMicrotask(() => {
autoUpdaterMock.emit('error', missingManifest)
})
return Promise.reject(missingManifest)
}
queueMicrotask(() => {
autoUpdaterMock.emit('update-not-available')
})
return Promise.resolve(undefined)
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
expect(autoUpdaterMock.setFeedURL).toHaveBeenCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/download/v1.3.51-rc.7'
})
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/download/v1.3.51-rc.6'
})
})
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({ state: 'not-available', userInitiated: true })
expect(statuses).not.toContainEqual(expect.objectContaining({ state: 'error' }))
})
it('surfaces a promise-only prerelease fallback failure after the primary error event', async () => {
appMock.getVersion.mockReturnValue('1.3.51-rc.6')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.51-rc.7', 'v1.3.51-rc.6'])
const missingManifest = new Error(
'Cannot find channel "latest-mac.yml" update info: HttpError: 404'
)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
if (autoUpdaterMock.checkForUpdates.mock.calls.length === 1) {
queueMicrotask(() => {
autoUpdaterMock.emit('error', missingManifest)
})
return new Promise(() => {})
}
return Promise.reject(missingManifest)
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({
state: 'error',
message: "Couldn't reach the update server. Try again in a few minutes.",
userInitiated: true
})
})
})
it('allows the short background retry to launch after a promise-only prerelease fallback failure', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-03T12:00:00Z'))
appMock.getVersion.mockReturnValue('1.3.51-rc.6')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.51-rc.7', 'v1.3.51-rc.6'])
const missingManifest = new Error(
'Cannot find channel "latest-mac.yml" update info: HttpError: 404'
)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
const callCount = autoUpdaterMock.checkForUpdates.mock.calls.length
if (callCount === 1) {
autoUpdaterMock.emit('checking-for-update')
queueMicrotask(() => {
autoUpdaterMock.emit('error', missingManifest)
})
return new Promise(() => {})
}
if (callCount === 2) {
return Promise.reject(missingManifest)
}
return new Promise(() => {})
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => null })
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({ state: 'idle' })
})
await vi.advanceTimersByTimeAsync(59 * 60 * 1000)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(60 * 1000)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(3)
})
it('does not let user-initiated promise-only fallback failures taint the next background check', async () => {
let lastUpdateCheckAt = Date.now()
appMock.getVersion.mockReturnValue('1.3.51-rc.6')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.51-rc.7', 'v1.3.51-rc.6'])
const missingManifest = new Error(
'Cannot find channel "latest-mac.yml" update info: HttpError: 404'
)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
const callCount = autoUpdaterMock.checkForUpdates.mock.calls.length
if (callCount === 1) {
autoUpdaterMock.emit('checking-for-update')
queueMicrotask(() => {
autoUpdaterMock.emit('error', missingManifest)
})
return new Promise(() => {})
}
if (callCount === 2) {
return Promise.reject(missingManifest)
}
autoUpdaterMock.emit('checking-for-update')
queueMicrotask(() => {
autoUpdaterMock.emit('update-not-available')
})
return Promise.resolve(undefined)
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => lastUpdateCheckAt })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({
state: 'error',
message: "Couldn't reach the update server. Try again in a few minutes.",
userInitiated: true
})
})
sendMock.mockClear()
lastUpdateCheckAt = Date.now() - 25 * 60 * 60 * 1000
appMock.emit('browser-window-focus')
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(3)
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({ state: 'not-available' })
expect(statuses).not.toContainEqual({ state: 'checking', userInitiated: true })
expect(statuses).not.toContainEqual({ state: 'not-available', userInitiated: true })
})
})
it('preserves user-initiated state for delayed prerelease fallback not-available', async () => {
vi.useFakeTimers()
appMock.getVersion.mockReturnValue('1.3.51-rc.6')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.51-rc.7', 'v1.3.51-rc.6'])
const missingManifest = new Error(
'Cannot find channel "latest-mac.yml" update info: HttpError: 404'
)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
const callCount = autoUpdaterMock.checkForUpdates.mock.calls.length
if (callCount === 1) {
autoUpdaterMock.emit('checking-for-update')
queueMicrotask(() => {
autoUpdaterMock.emit('error', missingManifest)
})
return Promise.reject(missingManifest)
}
setTimeout(() => {
autoUpdaterMock.emit('update-not-available')
}, 10)
return Promise.resolve(undefined)
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
})
await vi.advanceTimersByTimeAsync(10)
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({ state: 'not-available', userInitiated: true })
})
it('ignores a delayed primary error after a promise-launched prerelease fallback', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-03T12:00:00Z'))
appMock.getVersion.mockReturnValue('1.3.51-rc.6')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.51-rc.7', 'v1.3.51-rc.6'])
const missingManifest = new Error(
'Cannot find channel "latest-mac.yml" update info: HttpError: 404'
)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
const callCount = autoUpdaterMock.checkForUpdates.mock.calls.length
autoUpdaterMock.emit('checking-for-update')
if (callCount === 1) {
setTimeout(() => {
autoUpdaterMock.emit('error', missingManifest)
}, 10)
return Promise.reject(missingManifest)
}
if (callCount === 2) {
setTimeout(() => {
autoUpdaterMock.emit('update-not-available')
}, 20)
return Promise.resolve(undefined)
}
return new Promise(() => {})
})
const sendMock = vi.fn()
const setLastUpdateCheckAt = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => null,
setLastUpdateCheckAt
})
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
})
await vi.advanceTimersByTimeAsync(30)
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({ state: 'not-available' })
expect(statuses).not.toContainEqual(expect.objectContaining({ state: 'error' }))
expect(setLastUpdateCheckAt).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(59 * 60 * 1000)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(60 * 1000)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(3)
})
it('handles an event-only fallback error after a promise-only primary failure', async () => {
appMock.getVersion.mockReturnValue('1.3.51-rc.6')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.51-rc.7', 'v1.3.51-rc.6'])
const missingManifestMessage =
'Cannot find channel "latest-mac.yml" update info: HttpError: 404'
const primaryMissingManifest = new Error(missingManifestMessage)
const fallbackMissingManifest = new Error(missingManifestMessage)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
const callCount = autoUpdaterMock.checkForUpdates.mock.calls.length
autoUpdaterMock.emit('checking-for-update')
if (callCount === 1) {
return Promise.reject(primaryMissingManifest)
}
queueMicrotask(() => {
autoUpdaterMock.emit('error', fallbackMissingManifest)
})
return new Promise(() => {})
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses.at(-1)).toEqual({
state: 'error',
message: "Couldn't reach the update server. Try again in a few minutes.",
userInitiated: true
})
})
})
it('suppresses a delayed background fallback error after the fallback promise handled it', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-03T12:00:00Z'))
appMock.getVersion.mockReturnValue('1.3.51-rc.6')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.51-rc.7', 'v1.3.51-rc.6'])
const missingManifest = new Error(
'Cannot find channel "latest-mac.yml" update info: HttpError: 404'
)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
const callCount = autoUpdaterMock.checkForUpdates.mock.calls.length
autoUpdaterMock.emit('checking-for-update')
if (callCount === 1) {
queueMicrotask(() => {
autoUpdaterMock.emit('error', missingManifest)
})
return new Promise(() => {})
}
if (callCount === 2) {
setTimeout(() => {
autoUpdaterMock.emit('error', missingManifest)
}, 10)
return Promise.reject(missingManifest)
}
return new Promise(() => {})
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => null })
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({ state: 'idle' })
})
sendMock.mockClear()
await vi.advanceTimersByTimeAsync(10)
const statusesAfterLateError = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statusesAfterLateError).not.toContainEqual(
expect.objectContaining({ state: 'error', message: missingManifest.message })
)
await vi.advanceTimersByTimeAsync(59 * 60 * 1000)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(60 * 1000)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(3)
})
it('suppresses a delayed user fallback error after the fallback promise handled it', async () => {
vi.useFakeTimers()
appMock.getVersion.mockReturnValue('1.3.51-rc.6')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.51-rc.7', 'v1.3.51-rc.6'])
const missingManifest = new Error(
'Cannot find channel "latest-mac.yml" update info: HttpError: 404'
)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
const callCount = autoUpdaterMock.checkForUpdates.mock.calls.length
autoUpdaterMock.emit('checking-for-update')
if (callCount === 1) {
queueMicrotask(() => {
autoUpdaterMock.emit('error', missingManifest)
})
return new Promise(() => {})
}
setTimeout(() => {
autoUpdaterMock.emit('error', missingManifest)
}, 10)
return Promise.reject(missingManifest)
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({
state: 'error',
message: "Couldn't reach the update server. Try again in a few minutes.",
userInitiated: true
})
})
sendMock.mockClear()
await vi.advanceTimersByTimeAsync(10)
const statusesAfterLateError = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statusesAfterLateError).not.toContainEqual(
expect.objectContaining({ state: 'error', message: missingManifest.message })
)
})
it('keeps background prerelease fallback not-available on the short retry cadence', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-03T12:00:00Z'))
appMock.getVersion.mockReturnValue('1.3.51-rc.6')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.51-rc.7', 'v1.3.51-rc.6'])
const missingManifest = new Error(
'Cannot find channel "latest-mac.yml" update info: HttpError: 404'
)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
if (autoUpdaterMock.checkForUpdates.mock.calls.length === 1) {
queueMicrotask(() => {
autoUpdaterMock.emit('error', missingManifest)
})
return new Promise(() => {})
}
if (autoUpdaterMock.checkForUpdates.mock.calls.length === 2) {
queueMicrotask(() => {
autoUpdaterMock.emit('update-not-available')
})
return Promise.resolve(undefined)
}
return new Promise(() => {})
})
const sendMock = vi.fn()
const setLastUpdateCheckAt = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => null,
setLastUpdateCheckAt
})
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({ state: 'not-available' })
})
expect(setLastUpdateCheckAt).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(59 * 60 * 1000)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(60 * 1000)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(3)
})
it('keeps user prerelease fallback not-available on the short retry cadence', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-03T12:00:00Z'))
appMock.getVersion.mockReturnValue('1.3.51-rc.6')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.51-rc.7', 'v1.3.51-rc.6'])
const missingManifest = new Error(
'Cannot find channel "latest-mac.yml" update info: HttpError: 404'
)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
const callCount = autoUpdaterMock.checkForUpdates.mock.calls.length
autoUpdaterMock.emit('checking-for-update')
if (callCount === 1) {
queueMicrotask(() => {
autoUpdaterMock.emit('error', missingManifest)
})
return new Promise(() => {})
}
if (callCount === 2) {
queueMicrotask(() => {
autoUpdaterMock.emit('update-not-available')
})
return Promise.resolve(undefined)
}
return new Promise(() => {})
})
const sendMock = vi.fn()
const setLastUpdateCheckAt = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => Date.now(),
setLastUpdateCheckAt
})
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({ state: 'not-available', userInitiated: true })
})
expect(setLastUpdateCheckAt).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(59 * 60 * 1000)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(60 * 1000)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(3)
})
it('keeps user prerelease fallback available on the short retry cadence', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-03T12:00:00Z'))
appMock.getVersion.mockReturnValue('1.3.51-rc.5')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.51-rc.7', 'v1.3.51-rc.6'])
const missingManifest = new Error(
'Cannot find channel "latest-mac.yml" update info: HttpError: 404'
)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
const callCount = autoUpdaterMock.checkForUpdates.mock.calls.length
autoUpdaterMock.emit('checking-for-update')
if (callCount === 1) {
queueMicrotask(() => {
autoUpdaterMock.emit('error', missingManifest)
})
return new Promise(() => {})
}
if (callCount === 2) {
queueMicrotask(() => {
autoUpdaterMock.emit('update-available', { version: '1.3.51-rc.6' })
})
return Promise.resolve(undefined)
}
return new Promise(() => {})
})
const sendMock = vi.fn()
const setLastUpdateCheckAt = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => Date.now(),
setLastUpdateCheckAt
})
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({
state: 'available',
version: '1.3.51-rc.6',
changelog: null
})
})
expect(setLastUpdateCheckAt).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(59 * 60 * 1000)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(60 * 1000)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(3)
})
it('surfaces the failure when the bounded prerelease fallback also misses its manifest', async () => {
appMock.getVersion.mockReturnValue('1.3.51-rc.6')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.51-rc.7', 'v1.3.51-rc.6'])
const missingManifest = new Error(
'Cannot find channel "latest-mac.yml" update info: HttpError: 404'
)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
queueMicrotask(() => {
autoUpdaterMock.emit('error', missingManifest)
})
return Promise.reject(missingManifest)
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
})
await vi.waitFor(() => {
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({
state: 'error',
message: "Couldn't reach the update server. Try again in a few minutes.",
userInitiated: true
})
})
})
// Why: /releases/latest/download is a moving redirect; a relative ZIP URL from an old manifest can resolve against a newer release and 404.
it('pins the generic feed to a concrete stable tag for a stable user', async () => {
appMock.getVersion.mockReturnValue('1.3.17')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.18'])
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
const mainWindow = { webContents: { send: vi.fn() } }
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.3.17', 1, {
includePrerelease: false
})
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/download/v1.3.18'
})
})
// Why: native GitHub provider can pick cancelled prerelease tags with missing manifests, so keep the manifest-probed generic feed.
it('uses the manifest-probed generic feed after a Shift-click RC opt-in', async () => {
appMock.getVersion.mockReturnValue('1.3.17')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.18-rc.1'])
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
const mainWindow = { webContents: { send: vi.fn() } }
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu({ includePrerelease: true })
await vi.waitFor(() => {
expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.3.17', 2, {
includePrerelease: true
})
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
expect(autoUpdaterMock.allowPrerelease).toBe(true)
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/download/v1.3.18-rc.1'
})
})
describe('linux root package install recovery', () => {
let staged: StagedLinuxPackages
let EXIT_127: string
// Why: the pre-install digest re-proof streams the package off real disk. Fake timers never
// advance libuv, so the quit timer needs fake time while the read needs real event-loop turns.
const settleQuitAndInstall = async (): Promise<void> => {
await vi.advanceTimersByTimeAsync(100)
for (let turn = 0; turn < 40; turn += 1) {
await new Promise((resolve) => realSetTimeout(resolve, 0))
}
await vi.advanceTimersByTimeAsync(0)
}
beforeEach(() => {
staged = stageLinuxUpdateCache()
vi.stubEnv('XDG_CACHE_HOME', staged.cacheRoot)
EXIT_127 = `Command failed: /usr/bin/pkexec /usr/bin/dpkg -i ${staged.debPath}, exited with code 127`
})
afterEach(() => {
vi.doUnmock('./linux-package-update-recovery')
vi.unstubAllEnvs()
rmSync(staged.cacheRoot, { recursive: true, force: true })
})
/**
* Holds the pre-install re-proof open so a verdict can be delivered at an exact point in the
* cycle. Only that call is replaced — the artifact state stays real. Must precede startUpdater.
*/
const holdRevalidation = (): {
settle: (verdict: RevalidationVerdict) => void
fail: (error: Error) => void
} => {
let pending: {
resolve: (verdict: RevalidationVerdict) => void
reject: (error: Error) => void
} | null = null
vi.doMock('./linux-package-update-recovery', async () => {
const actual = await vi.importActual<typeof RecoveryModule>(
'./linux-package-update-recovery'
)
return {
...actual,
revalidateLinuxPackageForInstall: vi.fn(
() =>
new Promise<RevalidationVerdict>((resolve, reject) => {
pending = { resolve, reject }
})
)
}
})
return {
settle: (verdict) => {
pending?.resolve(verdict)
pending = null
},
fail: (error) => {
pending?.reject(error)
pending = null
}
}
}
const lastStatus = (send: ReturnType<typeof vi.fn>): UpdateStatus | undefined =>
send.mock.calls.findLast(([channel]) => channel === 'updater:status')?.[1]
const PRE_COMMIT_FAILURE_MESSAGE = PRE_COMMIT_INSTALL_FAILURE
const AGENT_STDERR =
'pkexec: Error executing command as another user: No authentication agent found.'
const downloadedEvent = (overrides?: Record<string, unknown>): Record<string, unknown> => ({
version: '1.0.61',
downloadedFile: staged.debPath,
files: [{ url: 'orca-ide_1.0.61_amd64.deb', sha512: staged.debSha512 }],
...overrides
})
const rpmDownloadedEvent = (): Record<string, unknown> =>
downloadedEvent({
downloadedFile: staged.rpmPath,
files: [{ url: 'orca-ide-1.0.61.x86_64.rpm', sha512: staged.rpmSha512 }]
})
const startUpdater = async (
packageType: 'deb' | 'rpm' | null
): Promise<{ send: ReturnType<typeof vi.fn>; updater: typeof UpdaterModule }> => {
getLinuxRootPackageTypeMock.mockReturnValue(packageType)
vi.useFakeTimers()
fetchNewerReleaseTagsMock.mockResolvedValue({ tags: ['v1.0.61'], state: 'ready' })
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
queueMicrotask(() => autoUpdaterMock.emit('update-available', { version: '1.0.61' }))
return Promise.resolve(undefined)
})
const send = vi.fn()
const updater = await import('./updater')
updater.setupAutoUpdater({ webContents: { send } } as never, {
getLastUpdateCheckAt: () => Date.now()
})
return { send, updater }
}
const reachDownloaded = async (
updater: typeof UpdaterModule,
event: Record<string, unknown>
): Promise<void> => {
updater.checkForUpdatesFromMenu()
await vi.advanceTimersByTimeAsync(0)
autoUpdaterMock.emit('update-downloaded', event)
if (process.platform === 'darwin') {
const nativeReady = nativeUpdaterMock.on.mock.calls.find(
([eventName]) => eventName === 'update-downloaded'
)?.[1] as (() => void) | undefined
nativeReady?.()
}
await vi.advanceTimersByTimeAsync(0)
}
it('disables install-on-quit for deb and rpm root packages', async () => {
for (const packageType of ['deb', 'rpm'] as const) {
vi.resetModules()
autoUpdaterMock.autoInstallOnAppQuit = true
getLinuxRootPackageTypeMock.mockReturnValue(packageType)
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater({ webContents: { send: vi.fn() } } as never, {
getLastUpdateCheckAt: () => Date.now(),
installMode: 'interactive'
})
expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(false)
}
})
it('keeps interactive install-on-quit when no root-package marker is present', async () => {
autoUpdaterMock.autoInstallOnAppQuit = false
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater({ webContents: { send: vi.fn() } } as never, {
getLastUpdateCheckAt: () => Date.now(),
installMode: 'interactive'
})
expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(true)
})
it('leaves headless serve installs supervisor-controlled', async () => {
for (const installMode of [
'supervised-headless-serve',
'unsupported-headless-serve'
] as const) {
vi.resetModules()
autoUpdaterMock.autoInstallOnAppQuit = true
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater({ webContents: { send: vi.fn() } } as never, {
getLastUpdateCheckAt: () => Date.now(),
installMode
})
expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(false)
}
})
it('sends structured recovery when quitAndInstall throws synchronously', async () => {
const { send, updater } = await startUpdater('deb')
await reachDownloaded(updater, downloadedEvent())
autoUpdaterMock.quitAndInstall.mockImplementation(() => {
autoUpdaterMock.logger?.error(`${AGENT_STDERR} target ${staged.debPath}`)
throw new Error(EXIT_127)
})
updater.quitAndInstall()
await settleQuitAndInstall()
// Why: the sync throw ends capture before the catch, so the stashed text must survive.
expect(send).toHaveBeenCalledWith('updater:status', {
state: 'error',
message: `${AGENT_STDERR} target <package>`,
recovery: {
kind: 'linux-package-install',
packageType: 'deb',
reason: 'authentication-agent-unavailable',
version: '1.0.61'
}
})
})
it('recovers an event-driven pre-commit failure without tearing down the session', async () => {
const openWindow = { removeAllListeners: vi.fn() }
browserWindowMock.getAllWindows.mockReturnValue([openWindow] as never)
const { send, updater } = await startUpdater('rpm')
await reachDownloaded(updater, rpmDownloadedEvent())
autoUpdaterMock.quitAndInstall.mockImplementation(() => {
autoUpdaterMock.emit('error', new Error('Command failed, exited with code 1'))
})
updater.quitAndInstall()
await settleQuitAndInstall()
expect(send).toHaveBeenCalledWith('updater:status', {
state: 'error',
message: 'Command failed, exited with code 1',
recovery: {
kind: 'linux-package-install',
packageType: 'rpm',
reason: 'package-install-failed',
version: '1.0.61'
}
})
expect(killAllPtyMock).not.toHaveBeenCalled()
expect(openWindow.removeAllListeners).not.toHaveBeenCalled()
expect(updater.isQuittingForUpdate()).toBe(false)
expect(disarmExitWatchdogMock).toHaveBeenCalled()
})
it('keeps the generic install-failure copy when no artifact was retained', async () => {
const { send, updater } = await startUpdater('deb')
// Release metadata without a digest must not enable cached-package recovery.
await reachDownloaded(
updater,
downloadedEvent({ files: [{ url: 'orca-ide_1.0.61_amd64.deb' }] })
)
autoUpdaterMock.quitAndInstall.mockImplementation(() => {
autoUpdaterMock.emit('error', new Error(EXIT_127))
})
updater.quitAndInstall()
await settleQuitAndInstall()
expect(send).toHaveBeenCalledWith('updater:status', {
state: 'error',
message: `${PRE_COMMIT_FAILURE_MESSAGE} (${EXIT_127})`
})
expect(recordUpdaterLifecycleMock).not.toHaveBeenCalledWith(
'linux_package_install_failed',
expect.anything(),
expect.anything()
)
})
it('advises a restart only for a failure before the native invoke', async () => {
const { send, updater } = await startUpdater('deb')
await reachDownloaded(updater, downloadedEvent())
// The source calls this out as the pre-native "cleanup/tracing exception" case.
recordUpdaterLifecycleMock.mockImplementation((event: unknown) => {
if (event === 'quit_and_install_invoking_native') {
throw new Error('tracing sink unavailable')
}
})
updater.quitAndInstall()
await settleQuitAndInstall()
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
expect(send).toHaveBeenCalledWith('updater:status', {
state: 'error',
message: 'Could not restart to install the update. Quit and reopen Orca, then try again.'
})
expect(updater.isQuittingForUpdate()).toBe(false)
})
it('keeps a committed install intact when post-commit cleanup throws', async () => {
const { send, updater } = await startUpdater('deb')
await reachDownloaded(updater, downloadedEvent())
// Why: spawnSync already installed the package; a teardown throw must not be reported as failure.
killAllPtyMock.mockImplementation(() => {
throw new Error('pty teardown failed')
})
send.mockClear()
updater.quitAndInstall()
await settleQuitAndInstall()
expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith(
'post_commit_cleanup_failed',
{ errorType: 'Error' },
expect.objectContaining({ level: 'warn' })
)
expect(send).not.toHaveBeenCalled()
expect(updater.isQuittingForUpdate()).toBe(true)
expect(armExitWatchdogMock).toHaveBeenCalledTimes(1)
expect(disarmExitWatchdogMock).not.toHaveBeenCalled()
})
it('still suppresses late post-commit errors while an artifact is retained', async () => {
const { send, updater } = await startUpdater('deb')
await reachDownloaded(updater, downloadedEvent())
updater.quitAndInstall()
await settleQuitAndInstall()
expect(killAllPtyMock).toHaveBeenCalledTimes(1)
send.mockClear()
autoUpdaterMock.emit('error', new Error(EXIT_127))
expect(send).not.toHaveBeenCalled()
expect(updater.isQuittingForUpdate()).toBe(true)
})
it('retries the automatic install without redownloading the package', async () => {
const { send, updater } = await startUpdater('deb')
await reachDownloaded(updater, downloadedEvent())
autoUpdaterMock.quitAndInstall.mockImplementation(() => {
autoUpdaterMock.emit('error', new Error(EXIT_127))
})
updater.quitAndInstall()
await settleQuitAndInstall()
updater.quitAndInstall()
await settleQuitAndInstall()
// Why: a retry usually fails identically; a deduped status would strand the preload restart relay.
expect(
send.mock.calls.filter(
([channel, status]) =>
channel === 'updater:status' &&
(status as { recovery?: { kind?: string } })?.recovery?.kind === 'linux-package-install'
)
).toHaveLength(2)
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(2)
expect(autoUpdaterMock.downloadUpdate).not.toHaveBeenCalled()
expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith('linux_package_recovery_requested', {
action: 'retry-automatic',
packageType: 'deb',
version: '1.0.61'
})
})
// Why: the cache path is user-writable, so the bytes verified when the recovery card
// rendered are not necessarily the bytes a root package manager would read on retry.
it('aborts the retry when the retained package no longer matches its digest', async () => {
const { send, updater } = await startUpdater('deb')
await reachDownloaded(updater, downloadedEvent())
// The escalation fails, which is what puts the recovery card (and its retry) on screen.
autoUpdaterMock.quitAndInstall.mockImplementation(() => {
autoUpdaterMock.emit('error', new Error(EXIT_127))
})
updater.quitAndInstall()
await settleQuitAndInstall()
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
// A local process swaps the verified package for its own between failure and retry.
writeFileSync(staged.debPath, Buffer.from('attacker supplied package'))
send.mockClear()
killAllPtyMock.mockClear()
updater.quitAndInstall()
await settleQuitAndInstall()
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
expect(killAllPtyMock).not.toHaveBeenCalled()
expect(updater.isQuittingForUpdate()).toBe(false)
expect(send).toHaveBeenCalledWith('updater:status', {
state: 'error',
message:
'The downloaded package no longer matches the verified release, so Orca will not hand it to a package manager. Download the update again, or get it from the official release page.'
})
expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith(
'linux_package_revalidation_failed',
expect.objectContaining({ action: 'retry-automatic', reason: 'hash-mismatch' }),
expect.anything()
)
})
// Why: "Restart to Update" is the common path and can sit unclicked for hours, so the same
// user-writable package reaches a root installer with a far longer window than any retry.
it('aborts the first install when the downloaded package was swapped', async () => {
const { send, updater } = await startUpdater('deb')
await reachDownloaded(updater, downloadedEvent())
writeFileSync(staged.debPath, Buffer.from('attacker supplied package'))
send.mockClear()
updater.quitAndInstall()
await settleQuitAndInstall()
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
expect(killAllPtyMock).not.toHaveBeenCalled()
expect(updater.isQuittingForUpdate()).toBe(false)
expect(send).toHaveBeenCalledWith('updater:status', {
state: 'error',
message:
'The downloaded package no longer matches the verified release, so Orca will not hand it to a package manager. Download the update again, or get it from the official release page.'
})
expect(send).toHaveBeenCalledWith('updater:quitAndInstallAborted')
expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith(
'linux_package_revalidation_failed',
expect.objectContaining({ action: 'restart-to-install', reason: 'hash-mismatch' }),
expect.anything()
)
})
it('installs normally when the retained package still matches its digest', async () => {
const { send, updater } = await startUpdater('deb')
await reachDownloaded(updater, downloadedEvent())
updater.quitAndInstall()
await settleQuitAndInstall()
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
expect(killAllPtyMock).toHaveBeenCalledTimes(1)
// Why: an abort push here would clear the restart flag mid-quit and re-arm the dirty-buffer
// prompt against the install that is already committed.
expect(send).not.toHaveBeenCalledWith('updater:quitAndInstallAborted')
expect(recordUpdaterLifecycleMock).not.toHaveBeenCalledWith(
'linux_package_revalidation_failed',
expect.anything(),
expect.anything()
)
})
// Why: hashing 160 MB outlives the cycle it started in, and Check for Updates stays enabled
// while it runs — a verdict from the old cycle must not replace the card that took over.
it('drops an abort verdict once a newer check replaced the card', async () => {
const revalidation = holdRevalidation()
const { send, updater } = await startUpdater('deb')
await reachDownloaded(updater, downloadedEvent())
updater.quitAndInstall()
await vi.advanceTimersByTimeAsync(100)
// The user gives up waiting and checks again; that check owns the card from here.
updater.checkForUpdatesFromMenu()
await vi.advanceTimersByTimeAsync(0)
expect(lastStatus(send)).toMatchObject({ state: 'available', version: '1.0.61' })
revalidation.settle({ ok: false, reason: 'hash-mismatch' })
await settleQuitAndInstall()
// The install is still abandoned — only the stale status is withheld.
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
expect(lastStatus(send)).toMatchObject({ state: 'available', version: '1.0.61' })
// Withholding the status must not also withhold the abort: the renderer armed its restart and
// would otherwise skip its unsaved-work prompt for the rest of the session.
expect(send).toHaveBeenCalledWith('updater:quitAndInstallAborted')
expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith(
'linux_package_revalidation_failed',
expect.objectContaining({ reason: 'hash-mismatch' }),
expect.anything()
)
})
// Why: EMFILE/EIO during the stream says nothing about the bytes, so the copy must not claim
// the package changed and the card must keep the actions that still work.
it('keeps the recovery card usable when the re-proof cannot read the package', async () => {
const revalidation = holdRevalidation()
const { send, updater } = await startUpdater('deb')
await reachDownloaded(updater, downloadedEvent())
autoUpdaterMock.quitAndInstall.mockImplementation(() => {
autoUpdaterMock.emit('error', new Error(EXIT_127))
})
updater.quitAndInstall()
await vi.advanceTimersByTimeAsync(100)
revalidation.settle({ ok: true })
await settleQuitAndInstall()
send.mockClear()
updater.quitAndInstall()
await vi.advanceTimersByTimeAsync(100)
revalidation.settle({ ok: false, reason: 'read-failed' })
await settleQuitAndInstall()
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
expect(lastStatus(send)).toEqual({
state: 'error',
message:
'Orca could not read the downloaded package. Download the update again, or get it from the official release page.',
recovery: {
kind: 'linux-package-install',
packageType: 'deb',
reason: 'package-install-failed',
version: '1.0.61'
}
})
})
// Why: the re-proof runs before performQuitAndInstall's own error handling, so a rejection
// there would strand the quit timer and make every later install a silent no-op.
it('stays installable after a re-proof that rejects outright', async () => {
const revalidation = holdRevalidation()
const { send, updater } = await startUpdater('deb')
await reachDownloaded(updater, downloadedEvent())
updater.quitAndInstall()
await vi.advanceTimersByTimeAsync(100)
revalidation.fail(new Error('hash worker crashed'))
await settleQuitAndInstall()
// Fails closed: an unprovable package is not handed to a root package manager.
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
expect(lastStatus(send)).toMatchObject({
state: 'error',
message:
'Orca could not read the downloaded package. Download the update again, or get it from the official release page.'
})
updater.quitAndInstall()
await vi.advanceTimersByTimeAsync(100)
revalidation.settle({ ok: true })
await settleQuitAndInstall()
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
})
// Why: a second click during the multi-second hash must not schedule a parallel install.
it('ignores a second install request while the digest re-proof runs', async () => {
const { updater } = await startUpdater('deb')
await reachDownloaded(updater, downloadedEvent())
updater.quitAndInstall()
// Fires the quit timer, which starts the hash; the read itself is still outstanding.
await vi.advanceTimersByTimeAsync(100)
updater.quitAndInstall()
await settleQuitAndInstall()
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
})
it('records classification-only lifecycle data for a package install failure', async () => {
const { updater } = await startUpdater('deb')
await reachDownloaded(updater, downloadedEvent())
autoUpdaterMock.quitAndInstall.mockImplementation(() => {
autoUpdaterMock.logger?.error(`${AGENT_STDERR} target ${staged.debPath}`)
autoUpdaterMock.emit('error', new Error(EXIT_127))
})
updater.quitAndInstall()
await settleQuitAndInstall()
const failure = recordUpdaterLifecycleMock.mock.calls.find(
([event]) => event === 'linux_package_install_failed'
)
expect(failure?.[1]).toEqual({
packageType: 'deb',
reason: 'authentication-agent-unavailable',
exitCode: 127,
version: '1.0.61',
errorType: 'Error'
})
const durable = JSON.stringify(recordUpdaterLifecycleMock.mock.calls)
expect(durable).not.toContain(staged.debPath)
expect(durable).not.toContain('authentication agent')
})
it('omits exitCode from lifecycle data when the child status is unparseable', async () => {
const { updater } = await startUpdater('deb')
await reachDownloaded(updater, downloadedEvent())
autoUpdaterMock.quitAndInstall.mockImplementation(() => {
autoUpdaterMock.emit('error', new Error('dpkg was interrupted'))
})
updater.quitAndInstall()
await settleQuitAndInstall()
const failure = recordUpdaterLifecycleMock.mock.calls.find(
([event]) => event === 'linux_package_install_failed'
)
// Why: an absent key, not an explicit null, keeps the breadcrumb schema honest.
expect(failure?.[1]).toEqual({
packageType: 'deb',
reason: 'package-install-failed',
version: '1.0.61',
errorType: 'Error'
})
expect(Object.keys(failure?.[1] as object)).not.toContain('exitCode')
})
})
})