diff --git a/src/app/pages/client/DesktopUpdater.test.tsx b/src/app/pages/client/DesktopUpdater.test.tsx new file mode 100644 index 000000000..aeb33cd4b --- /dev/null +++ b/src/app/pages/client/DesktopUpdater.test.tsx @@ -0,0 +1,145 @@ +import type { ReactNode } from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAtomValue, useSetAtom, Provider } from 'jotai'; +import { globalBannersAtom } from '$state/globalBanners'; +import { triggerUpdateCheckAtom } from '$state/desktopUpdate'; +import { DesktopUpdatePill } from '$components/tauri/DesktopUpdatePill'; +import { DesktopUpdater } from './DesktopUpdater'; + +const { checkFn } = vi.hoisted(() => ({ checkFn: vi.fn<() => Promise>() })); + +vi.mock('@tauri-apps/plugin-updater', () => ({ check: checkFn })); + +vi.mock('$utils/platform', async (importOriginal) => { + const mod = (await importOriginal()) as Record; + return { ...mod, isDesktopTauri: () => true }; +}); + +vi.mock('$state/hooks/desktopSettings', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + useDesktopSetting: () => [true, vi.fn<() => void>()], +})); + +vi.mock('$utils/tauriTitlebar', async (importOriginal) => { + const mod = (await importOriginal()) as Record; + return { ...mod, hasCustomDesktopTitlebar: () => true }; +}); + +// Make exit animations synchronous so hidden elements leave the DOM immediately. +vi.mock('framer-motion', async (importOriginal) => { + const mod = (await importOriginal()) as Record; + return { + ...mod, + AnimatePresence: ({ children }: { children?: ReactNode }) => <>{children}, + }; +}); + +function BannersProbe() { + const banners = useAtomValue(globalBannersAtom); + return ( +
+ {banners.map((b) => ( +
+ {b.title} + + {b.secondaryAction && ( + + )} +
+ ))} +
+ ); +} + +function CheckNowButton() { + const trigger = useSetAtom(triggerUpdateCheckAtom); + return ( + + ); +} + +function makeUpdate(version: string) { + return { + version, + body: `changelog ${version}`, + download: vi.fn<() => Promise>().mockResolvedValue(undefined), + install: vi.fn<() => Promise>().mockResolvedValue(undefined), + close: vi.fn<() => Promise>().mockResolvedValue(undefined), + }; +} + +beforeEach(() => { + localStorage.clear(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('DesktopUpdater', () => { + it('reopens the update banner from the pill after dismissing it', async () => { + localStorage.setItem('sable_fake_desktop_update', '1'); + + render( + + + + + + ); + + const pill = await screen.findByRole('button', { name: 'Update Available' }); + expect(screen.queryByTestId('banner-desktop-update-ready')).not.toBeInTheDocument(); + + fireEvent.click(pill); + await screen.findByTestId('banner-desktop-update-ready'); + expect(screen.getByRole('button', { name: 'Download & Install' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Later' })); + await waitFor(() => { + expect(screen.queryByTestId('banner-desktop-update-ready')).not.toBeInTheDocument(); + }); + const pillAgain = await screen.findByRole('button', { name: 'Update Available' }); + + fireEvent.click(pillAgain); + await screen.findByTestId('banner-desktop-update-ready'); + expect(screen.getByRole('button', { name: 'Download & Install' })).toBeInTheDocument(); + }); + + it('releases the previous update handle when a new check replaces it after a dismiss', async () => { + const update1 = makeUpdate('1.0.0'); + const update2 = makeUpdate('2.0.0'); + checkFn.mockResolvedValueOnce(update1).mockResolvedValueOnce(update2); + + render( + + + + + + + ); + + await waitFor(() => expect(checkFn).toHaveBeenCalledTimes(1)); + const pill = await screen.findByRole('button', { name: 'Update Available' }); + + fireEvent.click(pill); + await screen.findByTestId('banner-desktop-update-ready'); + fireEvent.click(screen.getByRole('button', { name: 'Later' })); + await waitFor(() => { + expect(screen.queryByTestId('banner-desktop-update-ready')).not.toBeInTheDocument(); + }); + expect(update1.close).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'check' })); + await waitFor(() => expect(update1.close).toHaveBeenCalledTimes(1)); + expect(update2.close).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/pages/client/DesktopUpdater.tsx b/src/app/pages/client/DesktopUpdater.tsx index bfc39272e..3ec84277b 100644 --- a/src/app/pages/client/DesktopUpdater.tsx +++ b/src/app/pages/client/DesktopUpdater.tsx @@ -206,13 +206,7 @@ export function DesktopUpdater() { const handleDismiss = useCallback(() => { setDismissed(true); setBannerVisible(false); - if (!installStartedRef.current) { - closePendingUpdate(pendingUpdateRef.current); - pendingUpdateRef.current = null; - setUpdateInfo(null); - setIsDownloaded(false); - } - }, [closePendingUpdate, setBannerVisible]); + }, [setBannerVisible]); const bannerData = useMemo(() => { if (!bannerVisible || !updateInfo || dismissed) return null;