diff --git a/src/renderer/src/components/UpdateCard.test.ts b/src/renderer/src/components/UpdateCard.test.ts index e3143835b6e..2faa8abd8a7 100644 --- a/src/renderer/src/components/UpdateCard.test.ts +++ b/src/renderer/src/components/UpdateCard.test.ts @@ -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') }) diff --git a/src/renderer/src/components/UpdateCard.tsx b/src/renderer/src/components/UpdateCard.tsx index e2023ae21e1..1094fce2439 100644 --- a/src/renderer/src/components/UpdateCard.tsx +++ b/src/renderer/src/components/UpdateCard.tsx @@ -108,7 +108,6 @@ export function UpdateCard(): React.JSX.Element | null { status, dismissedVersion, cachedVersion, - hasStartedDownload: hasStartedDownload.current, updateUserInitiatedCycle, autoDismissed, collapsed diff --git a/src/renderer/src/components/maintenance/update-card/update-card-visibility.ts b/src/renderer/src/components/maintenance/update-card/update-card-visibility.ts index f18629a4fbe..665e732cdc5 100644 --- a/src/renderer/src/components/maintenance/update-card/update-card-visibility.ts +++ b/src/renderer/src/components/maintenance/update-card/update-card-visibility.ts @@ -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 diff --git a/src/renderer/src/components/status-bar/UpdateStatusSegment.test.tsx b/src/renderer/src/components/status-bar/UpdateStatusSegment.test.tsx new file mode 100644 index 00000000000..085795246cf --- /dev/null +++ b/src/renderer/src/components/status-bar/UpdateStatusSegment.test.tsx @@ -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( + + + + + ) +} + +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([ + { 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() + } + ) +}) diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts b/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts index d913f998d2c..9cbdcdf4d3e 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts @@ -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 diff --git a/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts index 942ff9610a5..c80e08157cf 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts @@ -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) }, diff --git a/tests/e2e/update-status-error-details.spec.ts b/tests/e2e/update-status-error-details.spec.ts new file mode 100644 index 00000000000..a007a10de13 --- /dev/null +++ b/tests/e2e/update-status-error-details.spec.ts @@ -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') + }) +}