test: updater component tests
Rewrite useUpdater hook tests and add UpdateBanner component tests: Hook tests (10 cases): - Starts in idle state - Does nothing when not in Tauri - Checks for updates after 3s delay - Stays idle when no update available - Transitions to available when update found - Handles missing release body gracefully - Stays idle on network error (fails silently) - Dismiss returns to idle - openReleaseNotes opens correct URL - startDownload transitions through downloading to ready Component tests (10 cases): - Renders nothing when idle - Renders nothing on error - Shows version and buttons when available - Update Now calls startDownload - Release Notes calls openReleaseNotes - Dismiss button works - Shows progress bar during download - Shows 0% at start of download - Shows restart button when ready - Restart button calls restartApp All 457 tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
114
src/components/UpdateBanner.test.tsx
Normal file
114
src/components/UpdateBanner.test.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { UpdateBanner } from './UpdateBanner'
|
||||
import type { UpdateStatus, UpdateActions } from '../hooks/useUpdater'
|
||||
|
||||
// Mock restartApp to prevent dynamic import issues in tests
|
||||
vi.mock('../hooks/useUpdater', async () => {
|
||||
const actual = await vi.importActual('../hooks/useUpdater')
|
||||
return {
|
||||
...actual,
|
||||
restartApp: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
function makeActions(overrides?: Partial<UpdateActions>): UpdateActions {
|
||||
return {
|
||||
startDownload: vi.fn(),
|
||||
openReleaseNotes: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('UpdateBanner', () => {
|
||||
it('renders nothing when idle', () => {
|
||||
const status: UpdateStatus = { state: 'idle' }
|
||||
const { container } = render(<UpdateBanner status={status} actions={makeActions()} />)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('renders nothing on error state', () => {
|
||||
const status: UpdateStatus = { state: 'error' }
|
||||
const { container } = render(<UpdateBanner status={status} actions={makeActions()} />)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('shows version and action buttons when update is available', () => {
|
||||
const status: UpdateStatus = { state: 'available', version: '1.5.0', notes: 'Bug fixes' }
|
||||
const actions = makeActions()
|
||||
render(<UpdateBanner status={status} actions={actions} />)
|
||||
|
||||
expect(screen.getByTestId('update-banner')).toBeTruthy()
|
||||
expect(screen.getByText(/Laputa 1\.5\.0/)).toBeTruthy()
|
||||
expect(screen.getByText('is available')).toBeTruthy()
|
||||
expect(screen.getByTestId('update-now-btn')).toBeTruthy()
|
||||
expect(screen.getByTestId('update-release-notes')).toBeTruthy()
|
||||
expect(screen.getByTestId('update-dismiss')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('"Update Now" calls startDownload', () => {
|
||||
const status: UpdateStatus = { state: 'available', version: '1.5.0', notes: undefined }
|
||||
const actions = makeActions()
|
||||
render(<UpdateBanner status={status} actions={actions} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('update-now-btn'))
|
||||
expect(actions.startDownload).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('"Release Notes" link calls openReleaseNotes', () => {
|
||||
const status: UpdateStatus = { state: 'available', version: '1.5.0', notes: undefined }
|
||||
const actions = makeActions()
|
||||
render(<UpdateBanner status={status} actions={actions} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('update-release-notes'))
|
||||
expect(actions.openReleaseNotes).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('dismiss button calls dismiss action', () => {
|
||||
const status: UpdateStatus = { state: 'available', version: '1.5.0', notes: undefined }
|
||||
const actions = makeActions()
|
||||
render(<UpdateBanner status={status} actions={actions} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('update-dismiss'))
|
||||
expect(actions.dismiss).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows progress bar during download', () => {
|
||||
const status: UpdateStatus = { state: 'downloading', version: '1.5.0', progress: 0.65 }
|
||||
render(<UpdateBanner status={status} actions={makeActions()} />)
|
||||
|
||||
expect(screen.getByText(/Downloading Laputa 1\.5\.0/)).toBeTruthy()
|
||||
expect(screen.getByText('65%')).toBeTruthy()
|
||||
|
||||
const progressBar = screen.getByTestId('update-progress')
|
||||
expect(progressBar.style.width).toBe('65%')
|
||||
})
|
||||
|
||||
it('shows 0% at start of download', () => {
|
||||
const status: UpdateStatus = { state: 'downloading', version: '2.0.0', progress: 0 }
|
||||
render(<UpdateBanner status={status} actions={makeActions()} />)
|
||||
|
||||
expect(screen.getByText('0%')).toBeTruthy()
|
||||
const progressBar = screen.getByTestId('update-progress')
|
||||
expect(progressBar.style.width).toBe('0%')
|
||||
})
|
||||
|
||||
it('shows restart button when update is ready', () => {
|
||||
const status: UpdateStatus = { state: 'ready', version: '1.5.0' }
|
||||
render(<UpdateBanner status={status} actions={makeActions()} />)
|
||||
|
||||
expect(screen.getByText(/Laputa 1\.5\.0/)).toBeTruthy()
|
||||
expect(screen.getByText(/restart to apply/)).toBeTruthy()
|
||||
expect(screen.getByTestId('update-restart-btn')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('restart button calls restartApp', async () => {
|
||||
const { restartApp } = await import('../hooks/useUpdater')
|
||||
const status: UpdateStatus = { state: 'ready', version: '1.5.0' }
|
||||
render(<UpdateBanner status={status} actions={makeActions()} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('update-restart-btn'))
|
||||
expect(restartApp).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { useUpdater } from './useUpdater'
|
||||
|
||||
@@ -25,7 +25,6 @@ describe('useUpdater', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
@@ -34,74 +33,166 @@ describe('useUpdater', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('does nothing when not in Tauri', () => {
|
||||
it('starts in idle state', () => {
|
||||
vi.mocked(isTauri).mockReturnValue(false)
|
||||
const { result } = renderHook(() => useUpdater())
|
||||
expect(result.current.status).toEqual({ state: 'idle' })
|
||||
})
|
||||
|
||||
it('does nothing when not in Tauri', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(false)
|
||||
renderHook(() => useUpdater())
|
||||
vi.advanceTimersByTime(5000)
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
expect(mockCheck).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('checks for updates after delay when in Tauri', async () => {
|
||||
it('checks for updates after 3s delay when in Tauri', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
mockCheck.mockResolvedValue(null) // no update
|
||||
|
||||
renderHook(() => useUpdater())
|
||||
expect(mockCheck).not.toHaveBeenCalled()
|
||||
|
||||
// Advance past the 3s delay, then flush microtasks for dynamic imports
|
||||
await vi.advanceTimersByTimeAsync(3500)
|
||||
// Dynamic imports are resolved by the mock, but need microtask flush
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCheck).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows confirm dialog when update is available', async () => {
|
||||
it('stays idle when no update is available', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
mockCheck.mockResolvedValue(null)
|
||||
|
||||
const { result } = renderHook(() => useUpdater())
|
||||
await vi.advanceTimersByTimeAsync(3500)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCheck).toHaveBeenCalled()
|
||||
})
|
||||
expect(result.current.status).toEqual({ state: 'idle' })
|
||||
})
|
||||
|
||||
it('transitions to available when update is found', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
mockCheck.mockResolvedValue({
|
||||
version: '1.2.0',
|
||||
body: 'Bug fixes and improvements',
|
||||
downloadAndInstall: vi.fn().mockResolvedValue(undefined),
|
||||
downloadAndInstall: vi.fn(),
|
||||
})
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
|
||||
renderHook(() => useUpdater())
|
||||
const { result } = renderHook(() => useUpdater())
|
||||
await vi.advanceTimersByTimeAsync(3500)
|
||||
|
||||
expect(window.confirm).toHaveBeenCalledWith(
|
||||
expect.stringContaining('1.2.0')
|
||||
)
|
||||
expect(mockRelaunch).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => {
|
||||
expect(result.current.status).toEqual({
|
||||
state: 'available',
|
||||
version: '1.2.0',
|
||||
notes: 'Bug fixes and improvements',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('downloads, installs, and relaunches when user accepts', async () => {
|
||||
it('handles missing body gracefully', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
const mockDownloadAndInstall = vi.fn().mockResolvedValue(undefined)
|
||||
mockCheck.mockResolvedValue({
|
||||
version: '1.2.0',
|
||||
body: '',
|
||||
downloadAndInstall: mockDownloadAndInstall,
|
||||
version: '2.0.0',
|
||||
body: null,
|
||||
downloadAndInstall: vi.fn(),
|
||||
})
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
mockRelaunch.mockResolvedValue(undefined)
|
||||
|
||||
renderHook(() => useUpdater())
|
||||
const { result } = renderHook(() => useUpdater())
|
||||
await vi.advanceTimersByTimeAsync(3500)
|
||||
|
||||
expect(mockDownloadAndInstall).toHaveBeenCalled()
|
||||
expect(mockRelaunch).toHaveBeenCalled()
|
||||
await vi.waitFor(() => {
|
||||
expect(result.current.status).toEqual({
|
||||
state: 'available',
|
||||
version: '2.0.0',
|
||||
notes: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('logs warning on check failure without crashing', async () => {
|
||||
it('stays idle on network error (fails silently)', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
mockCheck.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
renderHook(() => useUpdater())
|
||||
const { result } = renderHook(() => useUpdater())
|
||||
await vi.advanceTimersByTimeAsync(3500)
|
||||
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
'[updater] Failed to check for updates:',
|
||||
expect.any(Error)
|
||||
await vi.waitFor(() => {
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
'[updater] Failed to check for updates'
|
||||
)
|
||||
})
|
||||
expect(result.current.status).toEqual({ state: 'idle' })
|
||||
})
|
||||
|
||||
it('dismiss returns to idle from available', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
mockCheck.mockResolvedValue({
|
||||
version: '1.2.0',
|
||||
body: 'Notes',
|
||||
downloadAndInstall: vi.fn(),
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useUpdater())
|
||||
await vi.advanceTimersByTimeAsync(3500)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(result.current.status.state).toBe('available')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.actions.dismiss()
|
||||
})
|
||||
|
||||
expect(result.current.status).toEqual({ state: 'idle' })
|
||||
})
|
||||
|
||||
it('openReleaseNotes opens the release notes URL', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(false)
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
|
||||
const { result } = renderHook(() => useUpdater())
|
||||
|
||||
act(() => {
|
||||
result.current.actions.openReleaseNotes()
|
||||
})
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://refactoringhq.github.io/laputa-app/',
|
||||
'_blank'
|
||||
)
|
||||
})
|
||||
|
||||
it('startDownload transitions through downloading to ready', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
|
||||
const mockDownload = vi.fn(async (callback: (event: { event: string; data?: Record<string, unknown> }) => void) => {
|
||||
callback({ event: 'Started', data: { contentLength: 1000 } })
|
||||
callback({ event: 'Progress', data: { chunkLength: 500 } })
|
||||
callback({ event: 'Progress', data: { chunkLength: 500 } })
|
||||
callback({ event: 'Finished' })
|
||||
})
|
||||
|
||||
mockCheck.mockResolvedValue({
|
||||
version: '1.2.0',
|
||||
body: 'Notes',
|
||||
downloadAndInstall: mockDownload,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useUpdater())
|
||||
await vi.advanceTimersByTimeAsync(3500)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(result.current.status.state).toBe('available')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.actions.startDownload()
|
||||
})
|
||||
|
||||
expect(result.current.status).toEqual({ state: 'ready', version: '1.2.0' })
|
||||
expect(mockDownload).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user