Files
SubMiner/src/main/runtime/update/update-service.test.ts
T

263 lines
8.2 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { createUpdateService, type UpdateServiceDeps, type UpdateState } from './update-service';
function createDeps(overrides: Partial<UpdateServiceDeps> = {}) {
let state: UpdateState = {};
const calls: string[] = [];
const deps: UpdateServiceDeps = {
getConfig: () => ({
enabled: true,
checkIntervalHours: 24,
notificationType: 'system',
channel: 'stable',
}),
getCurrentVersion: () => '0.14.0',
now: () => 1_000_000,
readState: async () => state,
writeState: async (nextState) => {
state = nextState;
calls.push(`state:${JSON.stringify(nextState)}`);
},
checkAppUpdate: async () => ({ available: false, version: '0.14.0' }),
fetchLatestStableRelease: async () => ({
tag_name: 'v0.14.0',
prerelease: false,
draft: false,
assets: [],
}),
updateLauncher: async () => ({ status: 'skipped' }),
showNoUpdateDialog: async (version) => {
calls.push(`no-update:${version}`);
},
showUpdateAvailableDialog: async (version) => {
calls.push(`available-dialog:${version}`);
return 'close';
},
showUpdateFailedDialog: async (message) => {
calls.push(`failed:${message}`);
},
downloadAppUpdate: async () => {
calls.push('download');
},
showRestartDialog: async () => {
calls.push('restart-dialog');
return 'later';
},
quitAndInstall: () => {
calls.push('quit-install');
},
notifyUpdateAvailable: async (version) => {
calls.push(`notify:${version}`);
},
log: (message) => calls.push(`log:${message}`),
...overrides,
};
return {
deps,
calls,
getState: () => state,
setState: (nextState: UpdateState) => (state = nextState),
};
}
test('manual update check shows latest-version dialog when already current', async () => {
const { deps, calls } = createDeps();
const service = createUpdateService(deps);
const result = await service.checkForUpdates({ source: 'manual' });
assert.equal(result.status, 'up-to-date');
assert.deepEqual(calls, ['no-update:0.14.0']);
});
test('manual update check falls back to GitHub release when app metadata is unavailable', async () => {
const { deps, calls } = createDeps({
checkAppUpdate: async () => {
throw new Error('latest-linux.yml missing');
},
fetchLatestStableRelease: async () => ({
tag_name: 'v0.15.0',
prerelease: false,
draft: false,
assets: [],
}),
});
const service = createUpdateService(deps);
const result = await service.checkForUpdates({ source: 'manual' });
assert.equal(result.status, 'update-available');
assert.deepEqual(calls, ['available-dialog:0.15.0']);
});
test('manual update check reports available when no update asset was applied', async () => {
const { deps, calls } = createDeps({
checkAppUpdate: async () => ({ available: false, version: '0.14.0', canUpdate: false }),
fetchLatestStableRelease: async () => ({
tag_name: 'v0.15.0',
prerelease: false,
draft: false,
assets: [],
}),
showUpdateAvailableDialog: async (version) => {
calls.push(`available-dialog:${version}`);
return 'update';
},
updateLauncher: async (_launcherPath, channel) => {
calls.push(`launcher:${channel}`);
return { status: 'skipped' };
},
});
const service = createUpdateService(deps);
const result = await service.checkForUpdates({ source: 'manual' });
assert.equal(result.status, 'update-available');
assert.deepEqual(calls, ['available-dialog:0.15.0', 'launcher:stable']);
});
test('automatic update check skips inside configured interval', async () => {
const { deps, calls, setState } = createDeps();
setState({ lastAutomaticCheckAt: 1_000_000 - 60 * 60 * 1000 });
const service = createUpdateService(deps);
const result = await service.checkForUpdates({ source: 'automatic' });
assert.equal(result.status, 'skipped');
assert.deepEqual(calls, []);
});
test('automatic update check notifies once per version and records check time', async () => {
const { deps, calls, getState } = createDeps({
checkAppUpdate: async () => ({ available: true, version: '0.15.0' }),
});
const service = createUpdateService(deps);
const first = await service.checkForUpdates({ source: 'automatic' });
const second = await service.checkForUpdates({ source: 'automatic', force: true });
assert.equal(first.status, 'update-available');
assert.equal(second.status, 'update-available');
assert.deepEqual(
calls.filter((call) => call === 'notify:0.15.0'),
['notify:0.15.0'],
);
assert.equal(getState().lastNotifiedVersion, '0.15.0');
assert.equal(getState().lastAutomaticCheckAt, 1_000_000);
});
test('concurrent update checks share one in-flight check', async () => {
let checkCount = 0;
let resolveCheck: (value: { available: boolean; version: string }) => void = () => {};
const { deps } = createDeps({
checkAppUpdate: () =>
new Promise((resolve) => {
checkCount += 1;
resolveCheck = resolve;
}),
});
const service = createUpdateService(deps);
const first = service.checkForUpdates({ source: 'manual' });
const second = service.checkForUpdates({ source: 'manual' });
await Promise.resolve();
resolveCheck({ available: false, version: '0.14.0' });
await Promise.all([first, second]);
assert.equal(checkCount, 1);
});
test('manual update check does not reuse in-flight automatic check', async () => {
let checkCount = 0;
const resolveChecks: Array<(value: { available: boolean; version: string }) => void> = [];
const { deps } = createDeps({
checkAppUpdate: () =>
new Promise((resolve) => {
checkCount += 1;
resolveChecks.push(resolve);
}),
});
const service = createUpdateService(deps);
const automatic = service.checkForUpdates({ source: 'automatic', force: true });
const manual = service.checkForUpdates({ source: 'manual' });
await Promise.resolve();
assert.equal(checkCount, 2);
for (const resolve of resolveChecks) {
resolve({ available: false, version: '0.14.0' });
}
await Promise.all([automatic, manual]);
});
test('manual update check passes selected GitHub release to launcher update', async () => {
const selectedRelease = {
tag_name: 'v0.15.0',
prerelease: false,
draft: false,
assets: [],
};
let forwardedRelease: unknown;
const { deps, calls } = createDeps({
checkAppUpdate: async () => ({ available: true, version: '0.15.0' }),
fetchLatestStableRelease: async () => selectedRelease,
showUpdateAvailableDialog: async (version) => {
calls.push(`available-dialog:${version}`);
return 'update';
},
updateLauncher: (async (...args: unknown[]) => {
calls.push(`launcher:${args[1]}`);
forwardedRelease = args[2];
return { status: 'updated' };
}) as UpdateServiceDeps['updateLauncher'],
});
const service = createUpdateService(deps);
const result = await service.checkForUpdates({ source: 'manual' });
assert.equal(result.status, 'updated');
assert.equal(forwardedRelease, selectedRelease);
});
test('manual prerelease update check uses prerelease release and launcher channel', async () => {
const { deps, calls } = createDeps({
getConfig: () => ({
enabled: true,
checkIntervalHours: 24,
notificationType: 'system',
channel: 'prerelease',
}),
checkAppUpdate: async () => ({ available: true, version: '0.15.0-beta.1' }),
fetchLatestStableRelease: async (channel) => {
calls.push(`fetch:${channel}`);
return {
tag_name: 'v0.15.0-beta.1',
prerelease: true,
draft: false,
assets: [],
};
},
showUpdateAvailableDialog: async (version) => {
calls.push(`available-dialog:${version}`);
return 'update';
},
updateLauncher: async (_launcherPath, channel) => {
calls.push(`launcher:${channel}`);
return { status: 'skipped' };
},
});
const service = createUpdateService(deps);
const result = await service.checkForUpdates({ source: 'manual' });
assert.equal(result.status, 'updated');
assert.deepEqual(calls, [
'fetch:prerelease',
'available-dialog:0.15.0-beta.1',
'download',
'launcher:prerelease',
'restart-dialog',
]);
});