修复客户端更新诊断与错误展示

This commit is contained in:
2026-08-13 15:28:14 +08:00
parent a03043d048
commit f05b9d4cb5
5 changed files with 376 additions and 13 deletions

View File

@@ -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({

View 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);
});
});