From ca7e1da7bf21d2d8e201bcdc7a82606beee813ea Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 23 Feb 2026 10:45:40 +0100 Subject: [PATCH] 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 --- src/components/UpdateBanner.test.tsx | 114 ++++++++++++++++++++ src/hooks/useUpdater.test.ts | 151 +++++++++++++++++++++------ 2 files changed, 235 insertions(+), 30 deletions(-) create mode 100644 src/components/UpdateBanner.test.tsx diff --git a/src/components/UpdateBanner.test.tsx b/src/components/UpdateBanner.test.tsx new file mode 100644 index 00000000..f8d4e3e0 --- /dev/null +++ b/src/components/UpdateBanner.test.tsx @@ -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 { + 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() + expect(container.innerHTML).toBe('') + }) + + it('renders nothing on error state', () => { + const status: UpdateStatus = { state: 'error' } + const { container } = render() + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + fireEvent.click(screen.getByTestId('update-restart-btn')) + expect(restartApp).toHaveBeenCalled() + }) +}) diff --git a/src/hooks/useUpdater.test.ts b/src/hooks/useUpdater.test.ts index d63d83df..05308826 100644 --- a/src/hooks/useUpdater.test.ts +++ b/src/hooks/useUpdater.test.ts @@ -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 }) => 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() + }) })