修复客户端更新诊断与错误展示
This commit is contained in:
@@ -5,6 +5,10 @@ const electronMocks = vi.hoisted(() => ({
|
||||
ipcHandle: vi.fn(),
|
||||
}));
|
||||
|
||||
const loggerMocks = vi.hoisted(() => ({
|
||||
error: vi.fn(),
|
||||
}));
|
||||
|
||||
const updaterMocks = vi.hoisted(() => {
|
||||
const listeners = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||
const autoUpdater = {
|
||||
@@ -41,7 +45,7 @@ vi.mock('@electron/utils/logger', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
error: loggerMocks.error,
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
@@ -127,6 +131,97 @@ describe('AppUpdater feed delegation', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('reports a concise actionable error when the stable feed has no latest manifest', async () => {
|
||||
const rawError = new Error(
|
||||
'Cannot find channel "latest.yml" update info: HttpError: 404\n' +
|
||||
'GET https://square.nianxx.cn/api/app-updates/windows/x64/latest.yml',
|
||||
);
|
||||
updaterMocks.autoUpdater.checkForUpdates.mockRejectedValue(rawError);
|
||||
const updater = new AppUpdater();
|
||||
const rendererSend = vi.fn();
|
||||
updater.setMainWindow({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: rendererSend },
|
||||
} as unknown as Parameters<AppUpdater['setMainWindow']>[0]);
|
||||
|
||||
await expect(updater.checkForUpdates()).rejects.toBe(rawError);
|
||||
|
||||
expect(updater.getStatus()).toMatchObject({
|
||||
status: 'error',
|
||||
error: '当前平台的正式更新包尚未发布,请稍后重试或从官网下载最新版',
|
||||
});
|
||||
expect(rendererSend.mock.calls.filter(([channel, status]) => (
|
||||
channel === 'update:status-changed' && status?.status === 'error'
|
||||
))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports one error status when electron-updater emits and rejects the same check error', async () => {
|
||||
const rawError = new Error(
|
||||
'Cannot find channel "latest.yml" update info: HttpError: 404\n' +
|
||||
'GET https://square.nianxx.cn/api/app-updates/windows/x64/latest.yml',
|
||||
);
|
||||
const updater = new AppUpdater();
|
||||
const errorListener = vi.fn();
|
||||
const rendererSend = vi.fn();
|
||||
updater.on('error', errorListener);
|
||||
updater.setMainWindow({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: rendererSend },
|
||||
} as unknown as Parameters<AppUpdater['setMainWindow']>[0]);
|
||||
let rejectSharedCheck!: (error: Error) => void;
|
||||
const sharedCheck = new Promise<never>((_resolve, reject) => {
|
||||
rejectSharedCheck = reject;
|
||||
});
|
||||
updaterMocks.autoUpdater.checkForUpdates.mockImplementationOnce(() => sharedCheck);
|
||||
updaterMocks.autoUpdater.checkForUpdates.mockImplementationOnce(() => sharedCheck);
|
||||
updaterMocks.autoUpdater.checkForUpdates.mockImplementationOnce(async () => {
|
||||
const updaterErrorListeners = updaterMocks.listeners.get('error') ?? [];
|
||||
updaterErrorListeners.at(-1)?.(rawError);
|
||||
throw rawError;
|
||||
});
|
||||
|
||||
const firstCheck = updater.checkForUpdates();
|
||||
const concurrentCheck = updater.checkForUpdates();
|
||||
const updaterErrorListeners = updaterMocks.listeners.get('error') ?? [];
|
||||
updaterErrorListeners.at(-1)?.(rawError);
|
||||
rejectSharedCheck(rawError);
|
||||
|
||||
await expect(Promise.allSettled([firstCheck, concurrentCheck])).resolves.toEqual([
|
||||
{ status: 'rejected', reason: rawError },
|
||||
{ status: 'rejected', reason: rawError },
|
||||
]);
|
||||
|
||||
expect(rendererSend.mock.calls.filter(([channel, status]) => (
|
||||
channel === 'update:status-changed' && status?.status === 'error'
|
||||
))).toHaveLength(1);
|
||||
expect(errorListener).toHaveBeenCalledOnce();
|
||||
expect(errorListener).toHaveBeenCalledWith(rawError);
|
||||
expect(loggerMocks.error).toHaveBeenCalledWith('[Updater] Check for updates failed:', rawError);
|
||||
|
||||
await expect(updater.checkForUpdates()).rejects.toBe(rawError);
|
||||
|
||||
expect(rendererSend.mock.calls.filter(([channel, status]) => (
|
||||
channel === 'update:status-changed' && status?.status === 'error'
|
||||
))).toHaveLength(2);
|
||||
expect(errorListener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reports the same actionable error when the macOS stable feed has no latest manifest', async () => {
|
||||
const rawError = new Error(
|
||||
'Cannot find channel "latest-mac.yml" update info: HttpError: 404\n' +
|
||||
'GET https://square.nianxx.cn/api/app-updates/mac/arm64/latest-mac.yml',
|
||||
);
|
||||
updaterMocks.autoUpdater.checkForUpdates.mockRejectedValue(rawError);
|
||||
const updater = new AppUpdater();
|
||||
|
||||
await expect(updater.checkForUpdates()).rejects.toBe(rawError);
|
||||
|
||||
expect(updater.getStatus()).toMatchObject({
|
||||
status: 'error',
|
||||
error: '当前平台的正式更新包尚未发布,请稍后重试或从官网下载最新版',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps prerelease channels on their existing electron-updater feed', async () => {
|
||||
electronMocks.getVersion.mockReturnValue('0.9.2-beta.1');
|
||||
updaterMocks.autoUpdater.checkForUpdates.mockResolvedValue({
|
||||
|
||||
97
tests/unit/update-settings.test.tsx
Normal file
97
tests/unit/update-settings.test.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { UpdateSettings } from '@/components/settings/UpdateSettings';
|
||||
import { useUpdateStore } from '@/stores/update';
|
||||
|
||||
const actionableMessage =
|
||||
'\u5f53\u524d\u5e73\u53f0\u7684\u6b63\u5f0f\u66f4\u65b0\u5305\u5c1a\u672a\u53d1\u5e03\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002';
|
||||
|
||||
vi.mock('react-i18next', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('react-i18next')>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) =>
|
||||
({
|
||||
'updates.currentVersion': 'Current version',
|
||||
'updates.status.failed': 'Generic update failure',
|
||||
'updates.action.retry': 'Retry',
|
||||
'updates.help': 'Keep Makelore updated',
|
||||
})[key] ?? key,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('UpdateSettings update errors', () => {
|
||||
const init = vi.fn().mockResolvedValue(undefined);
|
||||
const checkForUpdates = vi.fn().mockResolvedValue(undefined);
|
||||
const clearError = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useUpdateStore.setState({
|
||||
status: 'error',
|
||||
currentVersion: '1.0.0',
|
||||
updateInfo: null,
|
||||
progress: null,
|
||||
error: actionableMessage,
|
||||
isInitialized: true,
|
||||
isInitializing: false,
|
||||
autoInstallCountdown: null,
|
||||
init,
|
||||
checkForUpdates,
|
||||
clearError,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a concise updater error only once', () => {
|
||||
render(<UpdateSettings />);
|
||||
|
||||
expect(screen.getAllByText(actionableMessage)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'Cannot find channel "latest.yml" update info: HttpError: 404\r\nPlease double check authentication\r\n at ElectronHttpExecutor.handleResponse (app.asar/node_modules/builder-util-runtime/out/httpExecutor.js:121:20)',
|
||||
'Cannot find channel "latest.yml" update info: HttpError: 404\\r\\nPlease double check authentication\\n at ElectronHttpExecutor.handleResponse (app.asar/node_modules/builder-util-runtime/out/httpExecutor.js:121:20)',
|
||||
])('does not expose updater stack details for a long technical error', (error) => {
|
||||
useUpdateStore.setState({ error });
|
||||
|
||||
render(<UpdateSettings />);
|
||||
|
||||
expect(screen.getByText('Generic update failure')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/ElectronHttpExecutor|node_modules|Please double check/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('updates.errorDetails')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'net::ERR_NAME_NOT_RESOLVED',
|
||||
'Error: Update check failed',
|
||||
'Update check temporarily unavailable',
|
||||
"ENOENT: no such file or directory, open 'C:\\Users\\tester\\AppData\\Local\\Makelore\\latest.yml'",
|
||||
'ERR_UPDATER_INVALID_RELEASE_FEED',
|
||||
'{"code":"ERR_UPDATER_INVALID_RELEASE_FEED","message":"Update check failed"}',
|
||||
])('falls back for a short technical updater diagnostic: %s', (error) => {
|
||||
useUpdateStore.setState({ error });
|
||||
|
||||
render(<UpdateSettings />);
|
||||
|
||||
expect(screen.getByText('Generic update failure')).toBeInTheDocument();
|
||||
expect(screen.queryByText(error)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps concise actionable Chinese prose', () => {
|
||||
render(<UpdateSettings />);
|
||||
|
||||
expect(screen.getByText(actionableMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('initializes on mount and retries through the existing store action', async () => {
|
||||
render(<UpdateSettings />);
|
||||
|
||||
await waitFor(() => expect(init).toHaveBeenCalledTimes(1));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
|
||||
|
||||
await waitFor(() => expect(checkForUpdates).toHaveBeenCalledTimes(1));
|
||||
expect(clearError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user