Files
makelore/tests/unit/app-updater.test.ts

284 lines
11 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const electronMocks = vi.hoisted(() => ({
getVersion: vi.fn(() => '0.9.1'),
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 = {
autoDownload: false,
autoInstallOnAppQuit: true,
logger: null as unknown,
channel: 'latest',
on: vi.fn((event: string, listener: (...args: unknown[]) => void) => {
const current = listeners.get(event) ?? [];
current.push(listener);
listeners.set(event, current);
return autoUpdater;
}),
setFeedURL: vi.fn(),
checkForUpdates: vi.fn(),
downloadUpdate: vi.fn(),
quitAndInstall: vi.fn(),
};
return { autoUpdater, listeners };
});
vi.mock('electron', () => ({
app: {
getVersion: electronMocks.getVersion,
isPackaged: true,
},
BrowserWindow: vi.fn(),
ipcMain: { handle: electronMocks.ipcHandle },
}));
vi.mock('electron-updater', () => ({ autoUpdater: updaterMocks.autoUpdater }));
vi.mock('@electron/utils/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: loggerMocks.error,
debug: vi.fn(),
},
}));
import { AppUpdater, resolveUpdateFeed } from '@electron/main/updater';
afterEach(() => {
vi.restoreAllMocks();
});
describe('resolveUpdateFeed', () => {
it('routes stable Windows x64 updates through Works Square', () => {
expect(resolveUpdateFeed('0.9.2', 'win32', 'x64')).toEqual({
channel: 'latest',
url: 'https://square.nianxx.cn/api/app-updates/windows/x64',
});
});
it.each(['x64', 'arm64'])('routes stable macOS %s updates through Works Square', (architecture) => {
expect(resolveUpdateFeed('0.9.2', 'darwin', architecture)).toEqual({
channel: 'latest',
url: `https://square.nianxx.cn/api/app-updates/mac/${architecture}`,
});
});
it('keeps prerelease updates on the existing OSS channel feed', () => {
expect(resolveUpdateFeed('0.9.3-beta.1', 'darwin', 'arm64')).toEqual({
channel: 'beta',
url: 'https://oss.intelli-spectrum.com/beta',
});
});
it.each([
['linux', 'x64'],
['win32', 'arm64'],
['darwin', 'ia32'],
])('reports unsupported stable target %s/%s without throwing', (platform, architecture) => {
expect(resolveUpdateFeed('0.9.2', platform, architecture)).toMatchObject({
channel: 'latest',
error: expect.stringContaining('Unsupported update platform'),
});
});
});
describe('AppUpdater feed delegation', () => {
beforeEach(() => {
vi.clearAllMocks();
electronMocks.getVersion.mockReturnValue('0.9.1');
updaterMocks.autoUpdater.channel = 'latest';
updaterMocks.autoUpdater.checkForUpdates.mockResolvedValue(null);
});
it('delegates stable checks directly to electron-updater without a manifest preflight', async () => {
const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
const architectureDescriptor = Object.getOwnPropertyDescriptor(process, 'arch');
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' });
Object.defineProperty(process, 'arch', { configurable: true, value: 'x64' });
const fetchSpy = vi.spyOn(globalThis, 'fetch');
updaterMocks.autoUpdater.checkForUpdates.mockResolvedValue({
isUpdateAvailable: true,
updateInfo: { version: '0.9.2' },
});
try {
const updater = new AppUpdater();
await expect(updater.checkForUpdates()).resolves.toMatchObject({ version: '0.9.2' });
expect(updaterMocks.autoUpdater.setFeedURL).toHaveBeenCalledWith({
provider: 'generic',
url: 'https://square.nianxx.cn/api/app-updates/windows/x64',
useMultipleRangeRequest: false,
});
expect(updaterMocks.autoUpdater.setFeedURL).toHaveBeenCalledTimes(1);
expect(updaterMocks.autoUpdater.checkForUpdates).toHaveBeenCalledTimes(1);
expect(fetchSpy).not.toHaveBeenCalled();
expect(updater.getStatus()).toMatchObject({
status: 'available',
info: { version: '0.9.2' },
});
} finally {
if (platformDescriptor) Object.defineProperty(process, 'platform', platformDescriptor);
if (architectureDescriptor) Object.defineProperty(process, 'arch', architectureDescriptor);
}
});
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({
isUpdateAvailable: false,
updateInfo: { version: '0.9.2-beta.1' },
});
const updater = new AppUpdater();
await updater.checkForUpdates();
expect(updaterMocks.autoUpdater.setFeedURL).toHaveBeenCalledWith({
provider: 'generic',
url: 'https://oss.intelli-spectrum.com/beta',
useMultipleRangeRequest: false,
});
expect(updaterMocks.autoUpdater.checkForUpdates).toHaveBeenCalledTimes(1);
expect(updater.getStatus()).toMatchObject({ status: 'not-available' });
});
it('defers unsupported stable target errors until an update check', async () => {
const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
const architectureDescriptor = Object.getOwnPropertyDescriptor(process, 'arch');
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' });
Object.defineProperty(process, 'arch', { configurable: true, value: 'x64' });
try {
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]);
expect(updater.getStatus()).toEqual({ status: 'idle' });
expect(updaterMocks.autoUpdater.setFeedURL).not.toHaveBeenCalled();
await expect(updater.checkForUpdates()).rejects.toThrow('Unsupported update platform: linux/x64');
expect(updater.getStatus()).toEqual({
status: 'error',
error: 'Unsupported update platform: linux/x64',
info: undefined,
progress: undefined,
});
expect(errorListener).toHaveBeenCalledWith(expect.objectContaining({
message: 'Unsupported update platform: linux/x64',
}));
expect(rendererSend.mock.calls.filter(([channel, status]) => (
channel === 'update:status-changed' && status?.status === 'error'
))).toHaveLength(1);
expect(updaterMocks.autoUpdater.checkForUpdates).not.toHaveBeenCalled();
} finally {
if (platformDescriptor) Object.defineProperty(process, 'platform', platformDescriptor);
if (architectureDescriptor) Object.defineProperty(process, 'arch', architectureDescriptor);
}
});
});