Files
orca/src/main/updater.check-settlement.test.ts
T
mirror 34fa2cd5ca
Skill update round trip / roundtrip (false, macos-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, macos-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, ubuntu-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, ubuntu-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, ubuntu-latest, symlink, latest) (push) Canceled after 0s
Skill update round trip / roundtrip (false, windows-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, windows-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, macos-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, macos-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, ubuntu-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, ubuntu-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, windows-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, windows-latest, symlink, 1.5.17) (push) Canceled after 0s
source snapshot 2026-09-02
2026-09-02 02:03:16 +08:00

359 lines
13 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { loadUpdaterModule, warmUpdaterModule } from './updater-test-module-loader'
const {
autoUpdaterMock,
fetchChangelogMock,
fetchNewerReleaseTagsMock,
closeLocalBuildFeedMock,
moduleFactories,
resetUpdaterMocks
} = await vi.hoisted(async () => (await import('./updater-test-harness')).createUpdaterMocks())
vi.mock('electron', () => moduleFactories.electron())
vi.mock('electron-updater', () => moduleFactories.electronUpdater())
vi.mock('./electron-updater-loader', () => moduleFactories.electronUpdaterLoader())
vi.mock('@electron-toolkit/utils', () => moduleFactories.electronToolkitUtils())
vi.mock('./ipc/pty', () => moduleFactories.ipcPty())
vi.mock('./linux-update-package-type', () => moduleFactories.linuxUpdatePackageType())
vi.mock('./updater-lifecycle-diagnostics', () => moduleFactories.updaterLifecycleDiagnostics())
vi.mock('./updater-changelog', () => moduleFactories.updaterChangelog())
vi.mock('./updater-nudge', () => moduleFactories.updaterNudge())
vi.mock('./update-install-exit-watchdog', () => moduleFactories.updateInstallExitWatchdog())
vi.mock('./updater-prerelease-feed', () => moduleFactories.updaterPrereleaseFeed())
vi.mock('./local-builds/local-build-switch', () => moduleFactories.localBuildSwitch())
vi.mock('./local-builds/local-build-feed-server', () => moduleFactories.localBuildFeedServer())
warmUpdaterModule()
describe('updater', () => {
beforeEach(() => {
resetUpdaterMocks()
})
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 loadUpdaterModule()
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 loadUpdaterModule()
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 loadUpdaterModule()
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 loadUpdaterModule()
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 loadUpdaterModule()
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 loadUpdaterModule()
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 loadUpdaterModule()
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 loadUpdaterModule()
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 loadUpdaterModule()
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 loadUpdaterModule()
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()
})
})