fix(updater): open background check errors from the status bar (#20270)

* fix(updater): open background check errors from the status bar

* docs(updater): describe error disclosure initialization

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-09-12 19:56:46 -07:00
committed by GitHub
co-authored by m4air
parent 0a44b29741
commit 90b02cba60
7 changed files with 276 additions and 63 deletions
+35 -46
View File
@@ -317,8 +317,8 @@ type VisibilityInput = {
status: UpdateStatus
dismissedVersion: string | null
cachedVersion: string | null
hasStartedDownload: boolean
updateUserInitiatedCycle?: boolean
collapsed?: boolean
}
type VisibilityResult = 'hidden' | 'visible'
@@ -338,8 +338,7 @@ describe('UpdateCard visibility gates', () => {
computeVisibility({
status: { state: 'idle' },
dismissedVersion: null,
cachedVersion: null,
hasStartedDownload: false
cachedVersion: null
})
).toBe('hidden')
})
@@ -353,8 +352,7 @@ describe('UpdateCard visibility gates', () => {
computeVisibility({
status: { state: 'checking' },
dismissedVersion: null,
cachedVersion: null,
hasStartedDownload: false
cachedVersion: null
})
).toBe('hidden')
})
@@ -364,8 +362,7 @@ describe('UpdateCard visibility gates', () => {
computeVisibility({
status: { state: 'checking', userInitiated: true },
dismissedVersion: null,
cachedVersion: null,
hasStartedDownload: false
cachedVersion: null
})
).toBe('visible')
})
@@ -375,8 +372,7 @@ describe('UpdateCard visibility gates', () => {
computeVisibility({
status: { state: 'not-available' },
dismissedVersion: null,
cachedVersion: null,
hasStartedDownload: false
cachedVersion: null
})
).toBe('hidden')
})
@@ -386,8 +382,7 @@ describe('UpdateCard visibility gates', () => {
computeVisibility({
status: { state: 'not-available', userInitiated: true },
dismissedVersion: null,
cachedVersion: null,
hasStartedDownload: false
cachedVersion: null
})
).toBe('visible')
})
@@ -397,8 +392,7 @@ describe('UpdateCard visibility gates', () => {
computeVisibility({
status: { state: 'available', version: '1.2.0', changelog: null },
dismissedVersion: null,
cachedVersion: null,
hasStartedDownload: false
cachedVersion: null
})
).toBe('visible')
})
@@ -408,8 +402,7 @@ describe('UpdateCard visibility gates', () => {
computeVisibility({
status: { state: 'available', version: '1.2.0', changelog: RICH_CHANGELOG },
dismissedVersion: null,
cachedVersion: null,
hasStartedDownload: false
cachedVersion: null
})
).toBe('visible')
})
@@ -419,8 +412,7 @@ describe('UpdateCard visibility gates', () => {
computeVisibility({
status: { state: 'available', version: '1.2.0', changelog: null },
dismissedVersion: '1.2.0',
cachedVersion: '1.2.0',
hasStartedDownload: false
cachedVersion: '1.2.0'
})
).toBe('hidden')
})
@@ -431,7 +423,6 @@ describe('UpdateCard visibility gates', () => {
status: { state: 'available', version: '1.2.0', changelog: null },
dismissedVersion: '1.2.0',
cachedVersion: '1.2.0',
hasStartedDownload: false,
updateUserInitiatedCycle: true
})
).toBe('visible')
@@ -442,8 +433,7 @@ describe('UpdateCard visibility gates', () => {
computeVisibility({
status: { state: 'downloading', percent: 42, version: '1.2.0' },
dismissedVersion: '1.2.0',
cachedVersion: '1.2.0',
hasStartedDownload: true
cachedVersion: '1.2.0'
})
).toBe('visible')
})
@@ -453,19 +443,22 @@ describe('UpdateCard visibility gates', () => {
computeVisibility({
status: { state: 'downloaded', version: '1.2.0' },
dismissedVersion: '1.2.0',
cachedVersion: '1.2.0',
hasStartedDownload: false
cachedVersion: '1.2.0'
})
).toBe('hidden')
})
it('hides background errors silently', () => {
const store = createTestStore()
setState(store, { state: 'checking' })
setState(store, { state: 'error', message: 'network' })
expect(
computeVisibility({
status: { state: 'error', message: 'network' },
status: store.getState().updateStatus,
collapsed: store.getState().updateCardCollapsed,
dismissedVersion: null,
cachedVersion: null,
hasStartedDownload: false
cachedVersion: null
})
).toBe('hidden')
})
@@ -475,8 +468,7 @@ describe('UpdateCard visibility gates', () => {
computeVisibility({
status: { state: 'error', message: 'network', userInitiated: true },
dismissedVersion: null,
cachedVersion: null,
hasStartedDownload: false
cachedVersion: null
})
).toBe('visible')
})
@@ -486,8 +478,7 @@ describe('UpdateCard visibility gates', () => {
computeVisibility({
status: { state: 'error', message: 'ENOSPC' },
dismissedVersion: null,
cachedVersion: '1.2.0',
hasStartedDownload: true
cachedVersion: '1.2.0'
})
).toBe('visible')
})
@@ -497,8 +488,7 @@ describe('UpdateCard visibility gates', () => {
computeVisibility({
status: { state: 'error', message: 'ENOSPC' },
dismissedVersion: null,
cachedVersion: '1.2.0',
hasStartedDownload: false
cachedVersion: '1.2.0'
})
).toBe('visible')
})
@@ -517,8 +507,7 @@ describe('UpdateCard visibility gates', () => {
}
},
dismissedVersion: null,
cachedVersion: null,
hasStartedDownload: false
cachedVersion: null
})
).toBe('visible')
})
@@ -528,8 +517,7 @@ describe('UpdateCard visibility gates', () => {
computeVisibility({
status: { state: 'error', message: 'invalid metadata', version: '1.2.0' },
dismissedVersion: null,
cachedVersion: null,
hasStartedDownload: false
cachedVersion: null
})
).toBe('visible')
})
@@ -539,8 +527,7 @@ describe('UpdateCard visibility gates', () => {
computeVisibility({
status: { state: 'downloaded', version: '1.2.0' },
dismissedVersion: null,
cachedVersion: '1.2.0',
hasStartedDownload: true
cachedVersion: '1.2.0'
})
).toBe('visible')
})
@@ -550,8 +537,7 @@ describe('UpdateCard visibility gates', () => {
computeVisibility({
status: { state: 'available', version: '1.3.0', changelog: null },
dismissedVersion: '1.2.0',
cachedVersion: '1.3.0',
hasStartedDownload: false
cachedVersion: '1.3.0'
})
).toBe('visible')
})
@@ -561,19 +547,23 @@ describe('UpdateCard visibility gates', () => {
computeVisibility({
status: { state: 'error', message: 'fail', userInitiated: true },
dismissedVersion: '1.2.0',
cachedVersion: '1.2.0',
hasStartedDownload: false
cachedVersion: '1.2.0'
})
).toBe('visible')
})
it('hides check errors once a new checking cycle cleared the cached version', () => {
const store = createTestStore()
setState(store, { state: 'available', version: '1.2.0', changelog: null })
setState(store, { state: 'checking' })
setState(store, { state: 'error', message: 'network timeout' })
expect(
computeVisibility({
status: { state: 'error', message: 'network timeout' },
status: store.getState().updateStatus,
collapsed: store.getState().updateCardCollapsed,
dismissedVersion: '1.2.0',
cachedVersion: null,
hasStartedDownload: false
cachedVersion: null
})
).toBe('hidden')
})
@@ -649,8 +639,7 @@ describe('full update lifecycle through setUpdateStatus', () => {
computeVisibility({
status: store.getState().updateStatus,
dismissedVersion: store.getState().dismissedUpdateVersion,
cachedVersion: '1.3.0',
hasStartedDownload: false
cachedVersion: '1.3.0'
})
).toBe('visible')
})
@@ -108,7 +108,6 @@ export function UpdateCard(): React.JSX.Element | null {
status,
dismissedVersion,
cachedVersion,
hasStartedDownload: hasStartedDownload.current,
updateUserInitiatedCycle,
autoDismissed,
collapsed
@@ -4,7 +4,6 @@ export function isUpdateCardVisible({
status,
dismissedVersion,
cachedVersion,
hasStartedDownload,
updateUserInitiatedCycle,
autoDismissed = false,
collapsed = false
@@ -12,18 +11,11 @@ export function isUpdateCardVisible({
status: UpdateStatus
dismissedVersion: string | null
cachedVersion: string | null
hasStartedDownload: boolean
updateUserInitiatedCycle: boolean
autoDismissed?: boolean
collapsed?: boolean
}): boolean {
const isUserInitiated = 'userInitiated' in status && Boolean(status.userInitiated)
const shouldShowDetailedErrorCard =
status.state === 'error' &&
(hasStartedDownload ||
cachedVersion !== null ||
status.version !== undefined ||
status.recovery?.kind === 'linux-package-install')
if (status.state === 'checking' && !isUserInitiated) {
return false
@@ -34,9 +26,6 @@ export function isUpdateCardVisible({
if (status.state === 'idle') {
return false
}
if (status.state === 'error' && !shouldShowDetailedErrorCard && !isUserInitiated) {
return false
}
if (cachedVersion && dismissedVersion === cachedVersion && !updateUserInitiatedCycle) {
if (status.state !== 'downloading' && status.state !== 'error') {
return false
@@ -0,0 +1,156 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { UpdateStatus } from '../../../../shared/update-status-types'
import { useAppStore } from '../../store'
import { UpdateCard } from '../UpdateCard'
import { TooltipProvider } from '../ui/tooltip'
import { UpdateStatusSegment } from './UpdateStatusSegment'
const check = vi.fn()
const message = 'Could not reach the update server: net::ERR_CONNECTION_REFUSED'
const error: UpdateStatus = { state: 'error', message }
const actionableErrors: UpdateStatus[] = [
{ ...error, userInitiated: true },
{ ...error, version: '1.4.200' },
{
...error,
recovery: {
kind: 'linux-package-install',
packageType: 'deb',
reason: 'manual-install-required',
version: '1.4.200'
}
}
]
function setStatus(status: UpdateStatus): void {
act(() => useAppStore.getState().setUpdateStatus(status))
}
function renderUpdateControls(): void {
render(
<TooltipProvider>
<UpdateCard />
<UpdateStatusSegment compact={false} iconOnly={false} />
</TooltipProvider>
)
}
function errorToggle(): HTMLElement {
return screen.getByRole('button', { name: 'Update failed. Click to expand.' })
}
beforeEach(() => {
useAppStore.setState(useAppStore.getInitialState(), true)
check.mockReset().mockResolvedValue(undefined)
vi.stubGlobal(
'matchMedia',
vi.fn().mockReturnValue({
matches: true,
addEventListener: vi.fn(),
removeEventListener: vi.fn()
})
)
Object.defineProperty(window, 'api', {
configurable: true,
value: { updater: { check } }
})
})
afterEach(() => {
cleanup()
useAppStore.setState(useAppStore.getInitialState(), true)
vi.unstubAllGlobals()
})
describe('update status disclosure', () => {
it('opens background check failure details on the first click and offers a re-check', () => {
renderUpdateControls()
setStatus({ state: 'checking' })
setStatus(error)
expect(screen.queryByRole('complementary', { name: 'Update error' })).toBeNull()
fireEvent.click(errorToggle())
expect(screen.getByRole('complementary', { name: 'Update error' })).toBeTruthy()
expect(errorToggle().getAttribute('aria-expanded')).toBe('true')
fireEvent.click(screen.getByRole('button', { name: 'Show details' }))
expect(screen.getByText(message)).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Re-check' }))
expect(check).toHaveBeenCalledWith({ includePrerelease: false })
fireEvent.click(errorToggle())
expect(screen.queryByRole('complementary', { name: 'Update error' })).toBeNull()
expect(errorToggle().getAttribute('aria-expanded')).toBe('false')
})
it('announces a quiet automatic check failure as collapsed', () => {
setStatus(error)
renderUpdateControls()
expect(screen.queryByRole('complementary', { name: 'Update error' })).toBeNull()
expect(errorToggle().getAttribute('aria-expanded')).toBe('false')
})
it('preserves the disclosure choice until the next automatic check', () => {
renderUpdateControls()
setStatus(error)
fireEvent.click(errorToggle())
setStatus({ ...error, message: 'Still unavailable' })
expect(screen.getByRole('complementary', { name: 'Update error' })).toBeTruthy()
fireEvent.click(errorToggle())
setStatus(error)
expect(screen.queryByRole('complementary', { name: 'Update error' })).toBeNull()
fireEvent.click(errorToggle())
setStatus({ state: 'checking' })
setStatus(error)
expect(screen.queryByRole('complementary', { name: 'Update error' })).toBeNull()
expect(errorToggle().getAttribute('aria-expanded')).toBe('false')
})
it.each<UpdateStatus>([
{ state: 'checking', userInitiated: true },
{ state: 'available', version: '1.4.200', changelog: null },
{ state: 'downloading', version: '1.4.200', percent: 50 },
{ state: 'downloaded', version: '1.4.200' }
])('opens an error after $state without requiring a status click', (previousStatus) => {
setStatus(previousStatus)
setStatus(error)
renderUpdateControls()
expect(screen.getByRole('complementary', { name: 'Update error' })).toBeTruthy()
expect(errorToggle().getAttribute('aria-expanded')).toBe('true')
})
it.each(actionableErrors)(
'opens an explicit actionable error on initial receipt: %j',
(status) => {
setStatus(status)
renderUpdateControls()
expect(screen.getByRole('complementary', { name: 'Update error' })).toBeTruthy()
expect(
screen.getByRole('button', { name: /Click to expand/ }).getAttribute('aria-expanded')
).toBe('true')
}
)
it.each(actionableErrors)(
'opens a newly actionable error and preserves dismissal on repeat: %j',
(status) => {
renderUpdateControls()
setStatus(error)
expect(screen.queryByRole('complementary', { name: 'Update error' })).toBeNull()
setStatus(status)
expect(screen.getByRole('complementary', { name: 'Update error' })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /Click to expand/ }))
setStatus({ ...status })
expect(screen.queryByRole('complementary', { name: 'Update error' })).toBeNull()
}
)
})
@@ -182,7 +182,7 @@ export type UISlicePersistence = {
/** Dev-only channel override; null follows the running build's own channel. */
releaseChannelOverride: ReleaseChannel | null
setReleaseChannelOverride: (channel: ReleaseChannel | null) => void
// Why: ephemeral, renderer-only — never persisted; resets each session and on every phase transition (see setUpdateStatus).
// Ephemeral disclosure state; setUpdateStatus initializes it when the phase or error actionability changes.
updateCardCollapsed: boolean
setUpdateCardCollapsed: (collapsed: boolean) => void
updateReassuranceSeen: boolean
@@ -8,7 +8,7 @@ export function createUiUpdateActions(set: UISliceSet, get: UISliceGet): Partial
return {
updateStatus: { state: 'idle' },
setUpdateStatus: (status) => {
const prevState = get().updateStatus.state
const { updateStatus: previousStatus, updateUserInitiatedCycle } = get()
const update: Partial<
Pick<
UISlice,
@@ -34,9 +34,22 @@ export function createUiUpdateActions(set: UISliceSet, get: UISliceGet): Partial
update.updateChangelog = null
}
// 'downloading'/'downloaded'/'error': leave updateChangelog untouched to keep the original 'available' content.
if (status.state !== prevState) {
// Why: re-surface the card on each phase transition so a collapsed `downloading` doesn't bury `downloaded`/`error`.
update.updateCardCollapsed = false
const errorBecameActionable =
status.state === 'error' &&
previousStatus.state === 'error' &&
((status.userInitiated === true && !previousStatus.userInitiated) ||
(status.version !== undefined && previousStatus.version === undefined) ||
(status.recovery?.kind === 'linux-package-install' &&
previousStatus.recovery?.kind !== 'linux-package-install'))
if (status.state !== previousStatus.state || errorBecameActionable) {
// Quiet check failures start collapsed so the status bar can still disclose them.
update.updateCardCollapsed =
status.state === 'error' &&
!status.userInitiated &&
!updateUserInitiatedCycle &&
status.version === undefined &&
status.recovery?.kind !== 'linux-package-install' &&
!('version' in previousStatus && previousStatus.version !== undefined)
}
set(update)
},
@@ -0,0 +1,67 @@
import { expect, test } from './helpers/orca-app'
import { waitForSessionReady } from './helpers/store'
const CHECK_ERROR = 'E2E update check failed: connection refused'
test.use({ seedTestRepo: false })
for (const theme of ['dark', 'light'] as const) {
test(`automatic update failure opens details from the status bar (${theme})`, async ({
orcaPage
}, testInfo) => {
await waitForSessionReady(orcaPage)
await orcaPage.setViewportSize({ width: 1200, height: 800 })
await orcaPage.evaluate(async (theme) => {
const state = window.__store!.getState()
await state.updateSettingsOrThrow({ theme })
state.setUpdateStatus({ state: 'checking', userInitiated: false })
}, theme)
await expect(orcaPage.locator('html')).toHaveClass(theme === 'dark' ? /\bdark\b/ : /\blight\b/)
await orcaPage.evaluate((message) => {
window.__store!.getState().setUpdateStatus({
state: 'error',
message,
userInitiated: false
})
}, CHECK_ERROR)
const statusButton = orcaPage.getByRole('button', {
name: 'Update failed. Click to expand.',
exact: true
})
const card = orcaPage.getByRole('complementary', { name: 'Update error', exact: true })
await expect(statusButton).toBeVisible()
await expect(card).toBeHidden()
await statusButton.click()
// Capture before the assertion so the broken build provides the same visual evidence.
const statusClickScreenshot = testInfo.outputPath(
`update-error-after-status-click-${theme}.png`
)
await orcaPage.screenshot({ path: statusClickScreenshot, animations: 'disabled' })
await testInfo.attach(`update-error-after-status-click-${theme}`, {
path: statusClickScreenshot,
contentType: 'image/png'
})
await expect(card).toBeVisible()
await expect(statusButton).toHaveAttribute('aria-expanded', 'true')
await expect(card.getByRole('heading', { name: 'Update Check Failed' })).toBeVisible()
await expect(card.getByRole('button', { name: 'Re-check', exact: true })).toBeVisible()
await card.getByRole('button', { name: 'Show details', exact: true }).click()
await expect(card.getByText(CHECK_ERROR, { exact: true })).toBeVisible()
const detailsScreenshot = testInfo.outputPath(`update-error-expanded-details-${theme}.png`)
await orcaPage.screenshot({ path: detailsScreenshot, animations: 'disabled' })
await testInfo.attach(`update-error-expanded-details-${theme}`, {
path: detailsScreenshot,
contentType: 'image/png'
})
await card.getByRole('button', { name: 'Minimize to status bar', exact: true }).click()
await expect(card).toBeHidden()
await expect(statusButton).toHaveAttribute('aria-expanded', 'false')
await statusButton.click()
await expect(card).toBeVisible()
await expect(statusButton).toHaveAttribute('aria-expanded', 'true')
})
}