/** * @license * Copyright 2026 AionUi (aionui.com) * SPDX-License-Identifier: Apache-2.0 */ import React from 'react'; import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { AutoUpdateStatus, InstallerLastFailureMarker, UpdateDownloadProgressEvent, UpdateDownloadRequest, } from '@/common/update/updateTypes'; const mocks = vi.hoisted(() => ({ manualProgressHandler: null as ((evt: UpdateDownloadProgressEvent) => void) | null, autoStatusHandler: null as ((evt: AutoUpdateStatus) => void) | null, updateOpenHandler: null as ((evt: { source?: 'menu' | 'about' | 'tray' }) => void) | null, autoUpdateCheckMock: vi.fn(), autoUpdateRestoreDownloadedMock: vi.fn(), autoUpdateDownloadMock: vi.fn(), autoUpdateCancelDownloadMock: vi.fn(), autoUpdateQuitAndInstallMock: vi.fn(), consumeInstallerLastFailureMock: vi.fn(), updateCheckMock: vi.fn(), updateDownloadMock: vi.fn(), updateCancelDownloadMock: vi.fn(), openFeedbackMock: vi.fn(), shellOpenExternalMock: vi.fn(), shellOpenFileMock: vi.fn(), shellShowItemInFolderMock: vi.fn(), })); vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }), })); vi.mock('@/renderer/components/Markdown', () => ({ default: ({ children }: { children: React.ReactNode }) =>
{children}
, })); vi.mock('@/renderer/hooks/context/FeedbackContext', () => ({ useFeedback: () => ({ openFeedback: mocks.openFeedbackMock }), })); vi.mock('@/common', () => ({ ipcBridge: { autoUpdate: { check: { invoke: mocks.autoUpdateCheckMock }, restoreDownloaded: { invoke: mocks.autoUpdateRestoreDownloadedMock }, download: { invoke: mocks.autoUpdateDownloadMock }, cancelDownload: { invoke: mocks.autoUpdateCancelDownloadMock }, quitAndInstall: { invoke: mocks.autoUpdateQuitAndInstallMock }, status: { on: vi.fn((handler: (evt: AutoUpdateStatus) => void) => { mocks.autoStatusHandler = handler; return vi.fn(); }), }, }, update: { check: { invoke: mocks.updateCheckMock }, consumeInstallerLastFailure: { invoke: mocks.consumeInstallerLastFailureMock }, download: { invoke: mocks.updateDownloadMock }, cancelDownload: { invoke: mocks.updateCancelDownloadMock }, downloadProgress: { on: vi.fn((handler: (evt: UpdateDownloadProgressEvent) => void) => { mocks.manualProgressHandler = handler; return vi.fn(); }), }, open: { on: vi.fn((handler: (evt: { source?: 'menu' | 'about' | 'tray' }) => void) => { mocks.updateOpenHandler = handler; return vi.fn(); }), }, }, shell: { openExternal: { invoke: mocks.shellOpenExternalMock }, openFile: { invoke: mocks.shellOpenFileMock }, showItemInFolder: { invoke: mocks.shellShowItemInFolderMock }, }, }, })); import UpdateNotificationCard from '@/renderer/components/settings/UpdateNotificationCard'; describe('UpdateNotificationCard', () => { beforeEach(() => { vi.stubGlobal('__APP_VERSION__', '2.1.15'); mocks.manualProgressHandler = null; mocks.autoStatusHandler = null; mocks.updateOpenHandler = null; mocks.autoUpdateCheckMock.mockResolvedValue({ success: true }); mocks.autoUpdateRestoreDownloadedMock.mockResolvedValue({ success: true, data: { ready: false } }); mocks.autoUpdateDownloadMock.mockResolvedValue({ success: true }); mocks.autoUpdateCancelDownloadMock.mockResolvedValue({ success: true }); mocks.autoUpdateQuitAndInstallMock.mockResolvedValue(undefined); mocks.consumeInstallerLastFailureMock.mockResolvedValue({ success: true, data: null }); mocks.updateCancelDownloadMock.mockResolvedValue({ success: true }); mocks.updateCheckMock.mockResolvedValue({ success: true, data: { currentVersion: '2.1.13', updateAvailable: true, latest: { tagName: 'v2.1.14', version: '2.1.14', name: 'v2.1.14', body: 'notes', htmlUrl: 'https://github.com/iOfficeAI/AionUi/releases/tag/v2.1.14', prerelease: false, draft: false, assets: [], recommendedAsset: { name: 'AionUi-2.1.14-mac-arm64.dmg', url: 'https://static.aionui.com/releases/2.1.14/AionUi-2.1.14-mac-arm64.dmg', fallbackUrl: 'https://github.com/iOfficeAI/AionUi/releases/download/v2.1.14/AionUi-2.1.14-mac-arm64.dmg', size: 123, }, }, }, }); mocks.updateDownloadMock.mockImplementation(async (request: UpdateDownloadRequest) => ({ success: true, data: { downloadId: request.downloadId ?? 'manual-download', file_path: '/tmp/AionUi-2.1.14-mac-arm64.dmg', }, })); }); afterEach(() => { cleanup(); vi.clearAllMocks(); vi.unstubAllGlobals(); }); it('renders a bottom-right notification card for auto-update availability without a dialog', async () => { render(); await waitFor(() => { expect(mocks.autoStatusHandler).toBeTruthy(); }); await act(async () => { mocks.autoStatusHandler?.({ status: 'available', version: '2.1.14', currentVersion: '2.1.13', releaseNotes: 'auto notes', }); }); const card = await screen.findByTestId('update-notification-card'); expect(card).toHaveClass('fixed'); expect(card).toHaveClass('right-24px'); expect(card).toHaveClass('bottom-24px'); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); await waitFor(() => { expect(mocks.updateCheckMock).toHaveBeenCalled(); }); // Release notes moved to a centered modal opened via the Release Log link. expect(screen.queryByText('notes')).not.toBeInTheDocument(); fireEvent.click(screen.getByText('update.releaseLog')); expect(await screen.findByText('notes')).toBeInTheDocument(); }); it('restores a cached completed auto-update on mount', async () => { mocks.autoUpdateRestoreDownloadedMock.mockResolvedValue({ success: true, data: { ready: true, version: '2.1.14', filePath: '/cache/pending/AionUi-2.1.14-mac.zip', }, }); render(); expect(await screen.findByTestId('update-notification-card')).toBeInTheDocument(); // Downloaded state shows the "download complete" label + restart button (no progress bar). expect(screen.getByText('update.downloadCompleteTitle')).toBeInTheDocument(); expect(screen.getByText('update.restartNow')).toBeInTheDocument(); expect(mocks.updateCheckMock).not.toHaveBeenCalled(); await act(async () => { mocks.updateOpenHandler?.({ source: 'menu' }); }); expect(screen.getByText('update.restartNow')).toBeInTheDocument(); expect(mocks.autoUpdateCheckMock).not.toHaveBeenCalled(); expect(mocks.updateCheckMock).not.toHaveBeenCalled(); }); it('keeps a restored auto-update ready when opened before effects flush', async () => { let resolveRestore!: (value: { success: boolean; data: { ready: boolean; version: string } }) => void; mocks.autoUpdateRestoreDownloadedMock.mockImplementation( () => new Promise((resolve) => { resolveRestore = resolve; }) ); render(); await waitFor(() => { expect(mocks.updateOpenHandler).toBeTruthy(); }); await act(async () => { resolveRestore({ success: true, data: { ready: true, version: '2.1.14', }, }); await Promise.resolve(); mocks.updateOpenHandler?.({ source: 'menu' }); }); expect(await screen.findByText('update.restartNow')).toBeInTheDocument(); expect(mocks.autoUpdateCheckMock).not.toHaveBeenCalled(); expect(mocks.updateCheckMock).not.toHaveBeenCalled(); }); it('does not flash the initial available state while cached restore is pending', async () => { let resolveRestore!: (value: { success: boolean; data: { ready: boolean; version: string } }) => void; mocks.autoUpdateRestoreDownloadedMock.mockImplementation( () => new Promise((resolve) => { resolveRestore = resolve; }) ); render(); await waitFor(() => { expect(mocks.autoStatusHandler).toBeTruthy(); }); await act(async () => { mocks.autoStatusHandler?.({ status: 'available', version: '2.1.14', currentVersion: '2.1.13', releaseNotes: 'auto notes', }); }); expect(screen.queryByTestId('update-notification-card')).not.toBeInTheDocument(); expect(mocks.updateCheckMock).not.toHaveBeenCalled(); await act(async () => { resolveRestore({ success: true, data: { ready: true, version: '2.1.14', }, }); }); expect(await screen.findByText('update.downloadCompleteTitle')).toBeInTheDocument(); expect(screen.getByText('update.restartNow')).toBeInTheDocument(); expect(screen.queryByText('update.downloadButton')).not.toBeInTheDocument(); }); it('keeps the download progress bar stable when update entry points are opened again', async () => { render(); await waitFor(() => { expect(mocks.autoStatusHandler).toBeTruthy(); expect(mocks.updateOpenHandler).toBeTruthy(); }); await act(async () => { mocks.autoStatusHandler?.({ status: 'available', version: '2.1.14', currentVersion: '2.1.13', releaseNotes: 'auto notes', }); }); fireEvent.click(await screen.findByText('update.downloadButton')); await act(async () => { mocks.autoStatusHandler?.({ status: 'downloading', progress: { bytesPerSecond: 524288, percent: 42, transferred: 1048576, total: 4194304, }, }); }); const progressBar = await screen.findByRole('progressbar'); expect(progressBar).toHaveAttribute('aria-valuenow', '42'); expect(screen.getByText('42%')).toBeInTheDocument(); expect(screen.getByText('1.0 MB / 4.0 MB')).toBeInTheDocument(); expect(screen.getByText('512.0 KB/s')).toBeInTheDocument(); await act(async () => { mocks.updateOpenHandler?.({ source: 'menu' }); }); expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '42'); expect(screen.getByText('42%')).toBeInTheDocument(); expect(mocks.autoUpdateCheckMock).not.toHaveBeenCalled(); }); it('renders the initial available state without a top-right close button or manual install action', async () => { render(); await waitFor(() => { expect(mocks.autoStatusHandler).toBeTruthy(); }); await act(async () => { mocks.autoStatusHandler?.({ status: 'available', version: '2.1.14', currentVersion: '2.1.13', releaseNotes: 'auto notes', }); }); const card = await screen.findByTestId('update-notification-card'); expect(card).toHaveTextContent('2.1.13'); expect(card).toHaveTextContent('2.1.14'); expect(screen.queryByLabelText('common.close')).not.toBeInTheDocument(); expect(screen.queryByText('update.manualInstall')).not.toBeInTheDocument(); expect(screen.getByText('update.later')).toBeInTheDocument(); expect(screen.getByText('update.downloadButton')).toBeInTheDocument(); }); it('shows release-note loading and failure states instead of empty notes', async () => { mocks.updateCheckMock.mockImplementation(() => new Promise(() => undefined)); render(); await waitFor(() => { expect(mocks.autoStatusHandler).toBeTruthy(); }); await act(async () => { mocks.autoStatusHandler?.({ status: 'available', version: '2.1.14', currentVersion: '2.1.13', }); }); fireEvent.click(await screen.findByText('update.releaseLog')); expect(await screen.findByText('update.releaseNotesLoading')).toBeInTheDocument(); cleanup(); mocks.updateCheckMock.mockRejectedValue(new Error('network failed')); render(); await waitFor(() => { expect(mocks.autoStatusHandler).toBeTruthy(); }); await act(async () => { mocks.autoStatusHandler?.({ status: 'available', version: '2.1.14', currentVersion: '2.1.13', }); }); fireEvent.click(await screen.findByText('update.releaseLog')); expect(await screen.findByText('update.releaseNotesFailed')).toBeInTheDocument(); expect(screen.getByText('update.viewRelease')).toBeInTheDocument(); }); it('shows only a close (cancel) icon while downloading and cancel restores the initial state', async () => { render(); await waitFor(() => { expect(mocks.autoStatusHandler).toBeTruthy(); }); await act(async () => { mocks.autoStatusHandler?.({ status: 'available', version: '2.1.14', currentVersion: '2.1.13', releaseNotes: 'auto notes', }); }); fireEvent.click(await screen.findByText('update.downloadButton')); await act(async () => { mocks.autoStatusHandler?.({ status: 'downloading', progress: { bytesPerSecond: 1048576, percent: 18, transferred: 1048576, total: 4194304, }, }); }); // Downloading hides text buttons; the only action is the top-right close (cancel) icon. expect(screen.queryByText('update.later')).not.toBeInTheDocument(); expect(screen.queryByText('update.cancel')).not.toBeInTheDocument(); expect(screen.queryByText('update.minimize')).not.toBeInTheDocument(); fireEvent.click(screen.getByLabelText('update.cancel')); await waitFor(() => { expect(mocks.autoUpdateCancelDownloadMock).toHaveBeenCalled(); }); expect(await screen.findByText('update.downloadButton')).toBeInTheDocument(); expect(screen.getByText('update.later')).toBeInTheDocument(); expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); }); it('shows restart guidance text and later/restart actions after download completes', async () => { render(); await waitFor(() => { expect(mocks.autoStatusHandler).toBeTruthy(); }); await act(async () => { mocks.autoStatusHandler?.({ status: 'available', version: '2.1.14', currentVersion: '2.1.13', releaseNotes: 'auto notes', }); }); fireEvent.click(await screen.findByText('update.downloadButton')); await act(async () => { mocks.autoStatusHandler?.({ status: 'downloading', progress: { bytesPerSecond: 1048576, percent: 64, transferred: 1048576, total: 4194304, }, }); mocks.autoStatusHandler?.({ status: 'downloaded', version: '2.1.14', }); }); expect(await screen.findByText('update.downloadCompleteTitle')).toBeInTheDocument(); expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); expect(screen.getByText('update.later')).toBeInTheDocument(); expect(screen.getByText('update.restartNow')).toBeInTheDocument(); }); it('shows preparing install loading state after restart is clicked and hides later action', async () => { let resolveInstall!: () => void; mocks.autoUpdateQuitAndInstallMock.mockImplementation( () => new Promise((resolve) => { resolveInstall = resolve; }) ); render(); await waitFor(() => { expect(mocks.autoStatusHandler).toBeTruthy(); }); await act(async () => { mocks.autoStatusHandler?.({ status: 'available', version: '2.1.14', currentVersion: '2.1.13', releaseNotes: 'auto notes', }); }); fireEvent.click(await screen.findByText('update.downloadButton')); await act(async () => { mocks.autoStatusHandler?.({ status: 'downloading', progress: { bytesPerSecond: 1048576, percent: 100, transferred: 4194304, total: 4194304, }, }); mocks.autoStatusHandler?.({ status: 'downloaded', version: '2.1.14', }); }); fireEvent.click(await screen.findByText('update.restartNow')); expect(await screen.findByText('update.preparingInstall')).toBeInTheDocument(); expect(screen.getByText('update.downloadCompleteTitle')).toBeInTheDocument(); expect(screen.queryByText('update.later')).not.toBeInTheDocument(); expect(mocks.autoUpdateQuitAndInstallMock).toHaveBeenCalledTimes(1); fireEvent.click(screen.getByRole('button', { name: 'update.preparingInstall' })); expect(mocks.autoUpdateQuitAndInstallMock).toHaveBeenCalledTimes(1); await act(async () => { resolveInstall(); }); }); it('does not render a close button in the error state', async () => { mocks.updateCheckMock.mockRejectedValue(new Error('network failed')); render(); await waitFor(() => { expect(mocks.updateOpenHandler).toBeTruthy(); }); await act(async () => { mocks.updateOpenHandler?.({ source: 'menu' }); }); expect(await screen.findByText('network failed')).toBeInTheDocument(); expect(screen.queryByLabelText('common.close')).not.toBeInTheDocument(); }); it('shows consumed silent installer failure marker with retry, log, and feedback actions', async () => { const marker: InstallerLastFailureMarker = { schemaVersion: 1, kind: 'app-cannot-be-closed', phase: 'customCheckAppRunning', silent: true, updated: true, retryCount: 3, instDir: 'D:\\AionUi', logPath: 'C:\\Users\\me\\AppData\\Local\\Temp\\aionui-installer-2.1.27-20260702-151830-ab12cd34ef56.log', at: '2026-07-01T00:00:00.000Z', }; mocks.consumeInstallerLastFailureMock.mockResolvedValue({ success: true, data: marker }); render(); expect(await screen.findByText('update.installerLastFailure.title')).toBeInTheDocument(); expect(screen.getByText('update.installerLastFailure.description')).toBeInTheDocument(); expect(screen.getByText('update.installerLastFailure.retryUpdate')).toBeInTheDocument(); expect(screen.getByText('update.installerLastFailure.viewLog')).toBeInTheDocument(); expect(screen.getByText('settings.oneClickFeedback')).toBeInTheDocument(); fireEvent.click(screen.getByText('update.installerLastFailure.viewLog')); await waitFor(() => { expect(mocks.shellShowItemInFolderMock).toHaveBeenCalledWith(marker.logPath); }); fireEvent.click(screen.getByText('settings.oneClickFeedback')); await waitFor(() => { expect(mocks.openFeedbackMock).toHaveBeenCalledWith({ module: 'installer-update', autoScreenshot: true, tags: { kind: 'app-cannot-be-closed', message: 'installer-last-failure', }, extra: { installerLastFailure: marker, }, }); }); fireEvent.click(screen.getByText('update.installerLastFailure.retryUpdate')); await waitFor(() => { expect(mocks.autoUpdateCheckMock).toHaveBeenCalled(); }); }); });