Files
tolaria/src/hooks/useUpdater.test.ts
lucaronin 2799d8f0a6 feat: add canary release channel and local feature flags
Add update_channel setting (stable/canary) to Settings with UI toggle.
Stable channel uses Tauri updater plugin; canary fetches latest-canary.json
and opens GitHub release page for manual download. Add useFeatureFlag()
hook with localStorage overrides and compile-time defaults (no remote
dependencies). Add release-canary.yml CI workflow for canary branch builds.
Update stable workflow to preserve latest-canary.json on GH Pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 17:51:33 +01:00

313 lines
9.0 KiB
TypeScript

import { renderHook, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { useUpdater } from './useUpdater'
// Mock isTauri
vi.mock('../mock-tauri', () => ({
isTauri: vi.fn(() => false),
}))
// Mock openExternalUrl
const mockOpenExternalUrl = vi.fn()
vi.mock('../utils/url', () => ({
openExternalUrl: (...args: unknown[]) => mockOpenExternalUrl(...args),
}))
// Mock the dynamic imports
const mockCheck = vi.fn()
const mockRelaunch = vi.fn()
vi.mock('@tauri-apps/plugin-updater', () => ({
check: (...args: unknown[]) => mockCheck(...args),
}))
vi.mock('@tauri-apps/plugin-process', () => ({
relaunch: (...args: unknown[]) => mockRelaunch(...args),
}))
const mockGetVersion = vi.fn().mockResolvedValue('0.20260101.1')
vi.mock('@tauri-apps/api/app', () => ({
getVersion: () => mockGetVersion(),
}))
import { isTauri } from '../mock-tauri'
describe('useUpdater', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
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())
await vi.advanceTimersByTimeAsync(5000)
expect(mockCheck).not.toHaveBeenCalled()
})
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()
await vi.advanceTimersByTimeAsync(3500)
await vi.waitFor(() => {
expect(mockCheck).toHaveBeenCalledOnce()
})
})
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(),
})
const { result } = renderHook(() => useUpdater())
await vi.advanceTimersByTimeAsync(3500)
await vi.waitFor(() => {
expect(result.current.status).toEqual({
state: 'available',
version: '1.2.0',
notes: 'Bug fixes and improvements',
})
})
})
it('handles missing body gracefully', async () => {
vi.mocked(isTauri).mockReturnValue(true)
mockCheck.mockResolvedValue({
version: '2.0.0',
body: null,
downloadAndInstall: vi.fn(),
})
const { result } = renderHook(() => useUpdater())
await vi.advanceTimersByTimeAsync(3500)
await vi.waitFor(() => {
expect(result.current.status).toEqual({
state: 'available',
version: '2.0.0',
notes: undefined,
})
})
})
it('stays idle on network error (fails silently)', async () => {
vi.mocked(isTauri).mockReturnValue(true)
mockCheck.mockRejectedValue(new Error('Network error'))
const { result } = renderHook(() => useUpdater())
await vi.advanceTimersByTimeAsync(3500)
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 via openExternalUrl', async () => {
vi.mocked(isTauri).mockReturnValue(false)
const { result } = renderHook(() => useUpdater())
act(() => {
result.current.actions.openReleaseNotes()
})
expect(mockOpenExternalUrl).toHaveBeenCalledWith(
'https://refactoringhq.github.io/laputa-app/'
)
})
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()
})
describe('canary channel', () => {
it('fetches latest-canary.json when channel is canary', async () => {
vi.mocked(isTauri).mockReturnValue(true)
mockGetVersion.mockResolvedValue('0.20260101.1')
const mockFetch = vi.mocked(globalThis.fetch)
mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({
version: '0.20260325.99-canary',
notes: 'Canary build',
platforms: {
'darwin-aarch64': {
url: 'https://github.com/refactoringhq/laputa-app/releases/download/v0.20260325.99-canary/laputa.app.tar.gz',
signature: 'sig123',
},
},
}), { status: 200 }))
const { result } = renderHook(() => useUpdater('canary'))
let checkResult: string | undefined
await act(async () => {
checkResult = await result.current.actions.checkForUpdates()
})
expect(checkResult).toBe('available')
expect(result.current.status).toEqual({
state: 'available',
version: '0.20260325.99-canary',
notes: 'Canary build',
})
expect(mockFetch).toHaveBeenCalledWith(
'https://refactoringhq.github.io/laputa-app/latest-canary.json'
)
})
})
describe('checkForUpdates (manual)', () => {
it('returns up-to-date when no update is available', async () => {
vi.mocked(isTauri).mockReturnValue(true)
mockCheck.mockResolvedValue(null)
const { result } = renderHook(() => useUpdater())
let checkResult: string | undefined
await act(async () => {
checkResult = await result.current.actions.checkForUpdates()
})
expect(checkResult).toBe('up-to-date')
expect(result.current.status).toEqual({ state: 'idle' })
})
it('returns available and sets status when update exists', async () => {
vi.mocked(isTauri).mockReturnValue(true)
mockCheck.mockResolvedValue({
version: '3.0.0',
body: 'Major release',
downloadAndInstall: vi.fn(),
})
const { result } = renderHook(() => useUpdater())
let checkResult: string | undefined
await act(async () => {
checkResult = await result.current.actions.checkForUpdates()
})
expect(checkResult).toBe('available')
expect(result.current.status).toEqual({
state: 'available',
version: '3.0.0',
notes: 'Major release',
})
})
it('returns error on network failure', async () => {
vi.mocked(isTauri).mockReturnValue(true)
mockCheck.mockRejectedValue(new Error('No internet'))
const { result } = renderHook(() => useUpdater())
let checkResult: string | undefined
await act(async () => {
checkResult = await result.current.actions.checkForUpdates()
})
expect(checkResult).toBe('error')
expect(console.warn).toHaveBeenCalledWith('[updater] Failed to check for updates')
})
it('returns up-to-date when not in Tauri', async () => {
vi.mocked(isTauri).mockReturnValue(false)
const { result } = renderHook(() => useUpdater())
let checkResult: string | undefined
await act(async () => {
checkResult = await result.current.actions.checkForUpdates()
})
expect(checkResult).toBe('up-to-date')
expect(mockCheck).not.toHaveBeenCalled()
})
})
})