477 lines
17 KiB
JavaScript
477 lines
17 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const path = require('node:path');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const crypto = require('node:crypto');
|
|
const Module = require('node:module');
|
|
const { pathToFileURL } = require('node:url');
|
|
|
|
const { isNewer, resolveReleaseVersion, fetchGithubReleaseNotes, prepareUpdate, launchPreparedUpdate, pickSetupAsset, parseLatestYml, createUpdateAnnouncementState, abortUpdate } = require('../lib/updater');
|
|
const releasePlanUrl = pathToFileURL(path.resolve(__dirname, '../scripts/release-plan.mjs')).href;
|
|
|
|
test('bridge title resolves product version instead of transport tag', () => {
|
|
assert.equal(resolveReleaseVersion({ name: 'Multi-Hoster-Upload v2.0.1', tag_name: 'v3.3.109' }), '2.0.1');
|
|
assert.equal(isNewer('2.0.1', '2.0.1'), false);
|
|
assert.equal(isNewer('2.0.2', '2.0.1'), true);
|
|
});
|
|
|
|
test('release arguments reject a malformed transport tag', async () => {
|
|
const { parseReleaseArgs } = await import(releasePlanUrl);
|
|
assert.throws(
|
|
() => parseReleaseArgs(['2.0.1', '--transport-tag', '3.3.109', 'Bridge', '--dry-run']),
|
|
/--transport-tag must match vX\.Y\.Z/
|
|
);
|
|
});
|
|
|
|
test('matching GitHub release notes replace the private release body', async () => {
|
|
const calls = [];
|
|
const notes = await fetchGithubReleaseNotes('2.1.0', 'Private fallback', async (url, options) => {
|
|
calls.push({ url, options });
|
|
return { ok: true, json: async () => ({ tag_name: 'v2.1.0', body: '## Public changes\n\n- English UI' }) };
|
|
});
|
|
|
|
assert.equal(notes, '## Public changes\n\n- English UI');
|
|
assert.equal(calls.length, 1);
|
|
assert.equal(calls[0].url, 'https://api.github.com/repos/Sucukdeluxe/Multi-Hoster-Upload/releases/tags/v2.1.0');
|
|
});
|
|
|
|
test('GitHub release-note failures preserve the private release body', async () => {
|
|
const notes = await fetchGithubReleaseNotes('2.1.0', 'Private fallback', async () => ({ ok: false }));
|
|
assert.equal(notes, 'Private fallback');
|
|
});
|
|
|
|
test('update preparation writes a verified installer without launching it', async () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-updater-test-'));
|
|
const installer = Buffer.alloc(128 * 1024, 0);
|
|
installer[0] = 0x4d;
|
|
installer[1] = 0x5a;
|
|
let reads = 0;
|
|
const progress = [];
|
|
try {
|
|
const prepared = await prepareUpdate(value => progress.push(value), {
|
|
checkResult: {
|
|
available: true,
|
|
assetUrl: 'https://update.invalid/setup.exe',
|
|
assetName: 'setup.exe',
|
|
assetSize: installer.length,
|
|
remoteVersion: '2.2.0',
|
|
latestYmlUrl: 'https://update.invalid/latest.yml'
|
|
},
|
|
tempDir,
|
|
fetchImpl: async url => url.endsWith('latest.yml')
|
|
? {
|
|
ok: true,
|
|
status: 200,
|
|
text: async () => `version: 2.2.0\npath: setup.exe\nsha512: ${crypto.createHash('sha512').update(installer).digest('base64')}\nsize: ${installer.length}\n`
|
|
}
|
|
: {
|
|
ok: true,
|
|
status: 200,
|
|
body: {
|
|
getReader: () => ({
|
|
read: async () => {
|
|
reads++;
|
|
return reads === 1 ? { done: false, value: installer } : { done: true };
|
|
}
|
|
})
|
|
}
|
|
}
|
|
});
|
|
|
|
assert.equal(prepared.installerPath, path.join(tempDir, 'setup.exe'));
|
|
assert.deepEqual(fs.readFileSync(prepared.installerPath), installer);
|
|
assert.equal(progress.at(-1).stage, 'prepared');
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('buffered installer downloads yield between progress updates so the renderer can paint', async () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-updater-progress-test-'));
|
|
const installer = Buffer.alloc(128 * 1024, 0);
|
|
installer[0] = 0x4d;
|
|
installer[1] = 0x5a;
|
|
const chunks = [
|
|
installer.subarray(0, 32 * 1024),
|
|
installer.subarray(32 * 1024, 64 * 1024),
|
|
installer.subarray(64 * 1024, 96 * 1024),
|
|
installer.subarray(96 * 1024)
|
|
];
|
|
const progress = [];
|
|
let readerIndex = 0;
|
|
let preparationFinished = false;
|
|
let rendererObservedProgressBeforeFinish = false;
|
|
let rendererObservationScheduled = false;
|
|
let nowMs = 0;
|
|
|
|
try {
|
|
await prepareUpdate(value => {
|
|
progress.push(value);
|
|
if (value.stage !== 'downloading' || rendererObservationScheduled) return;
|
|
rendererObservationScheduled = true;
|
|
setImmediate(() => {
|
|
rendererObservedProgressBeforeFinish = !preparationFinished;
|
|
});
|
|
}, {
|
|
checkResult: {
|
|
available: true,
|
|
assetUrl: 'https://update.invalid/setup.exe',
|
|
assetName: 'setup.exe',
|
|
remoteVersion: '2.2.0',
|
|
latestYmlUrl: 'https://update.invalid/latest.yml'
|
|
},
|
|
tempDir,
|
|
now: () => nowMs,
|
|
fetchImpl: async url => url.endsWith('latest.yml')
|
|
? {
|
|
ok: true,
|
|
status: 200,
|
|
text: async () => `version: 2.2.0\npath: setup.exe\nsha512: ${crypto.createHash('sha512').update(installer).digest('base64')}\nsize: ${installer.length}\n`
|
|
}
|
|
: {
|
|
ok: true,
|
|
status: 200,
|
|
body: {
|
|
getReader: () => ({
|
|
read: async () => {
|
|
nowMs += 1000;
|
|
return readerIndex < chunks.length
|
|
? { done: false, value: chunks[readerIndex++] }
|
|
: { done: true };
|
|
}
|
|
})
|
|
}
|
|
}
|
|
});
|
|
preparationFinished = true;
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
|
|
assert.equal(rendererObservedProgressBeforeFinish, true);
|
|
assert.deepEqual(
|
|
progress.filter(value => value.stage === 'downloading').map(value => value.percent),
|
|
[25, 50, 75, 100]
|
|
);
|
|
assert.deepEqual(
|
|
progress.filter(value => value.stage === 'downloading').map(value => [value.bytesPerSecond, value.etaSeconds]),
|
|
[[32768, 3], [32768, 2], [32768, 1], [32768, 0]]
|
|
);
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('cancelling an active installer download reports an aborted state', async () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-updater-abort-test-'));
|
|
const installer = Buffer.alloc(128 * 1024, 0);
|
|
installer[0] = 0x4d;
|
|
installer[1] = 0x5a;
|
|
const progress = [];
|
|
let reads = 0;
|
|
|
|
try {
|
|
await assert.rejects(
|
|
prepareUpdate(value => {
|
|
progress.push(value);
|
|
if (value.stage === 'downloading') abortUpdate();
|
|
}, {
|
|
checkResult: {
|
|
available: true,
|
|
assetUrl: 'https://update.invalid/setup.exe',
|
|
assetName: 'setup.exe',
|
|
assetSize: installer.length,
|
|
remoteVersion: '2.2.0',
|
|
latestYmlUrl: 'https://update.invalid/latest.yml'
|
|
},
|
|
tempDir,
|
|
fetchImpl: async url => url.endsWith('latest.yml')
|
|
? {
|
|
ok: true,
|
|
status: 200,
|
|
text: async () => `version: 2.2.0\npath: setup.exe\nsha512: ${crypto.createHash('sha512').update(installer).digest('base64')}\nsize: ${installer.length}\n`
|
|
}
|
|
: {
|
|
ok: true,
|
|
status: 200,
|
|
body: {
|
|
getReader: () => ({
|
|
read: async () => {
|
|
reads++;
|
|
return reads === 1
|
|
? { done: false, value: installer.subarray(0, installer.length / 2) }
|
|
: { done: false, value: installer.subarray(installer.length / 2) };
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}),
|
|
/Update abgebrochen/
|
|
);
|
|
|
|
assert.equal(progress.at(-1).stage, 'aborted');
|
|
assert.equal(progress.at(-1).error, 'Update abgebrochen');
|
|
assert.equal(fs.existsSync(path.join(tempDir, 'setup.exe')), false);
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('update preparation refreshes a cached release before downloading the installer', async () => {
|
|
const updaterPath = require.resolve('../lib/updater');
|
|
const originalLoad = Module._load;
|
|
const originalFetch = global.fetch;
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-updater-refresh-test-'));
|
|
const installer = Buffer.alloc(128 * 1024, 0);
|
|
installer[0] = 0x4d;
|
|
installer[1] = 0x5a;
|
|
const sha512 = crypto.createHash('sha512').update(installer).digest('base64');
|
|
const oldVersion = '2.1.23';
|
|
const latestVersion = '2.1.24';
|
|
const createRelease = version => ({
|
|
name: `Multi-Hoster-Upload v${version}`,
|
|
tag_name: `v${version}`,
|
|
html_url: `https://update.invalid/releases/${version}`,
|
|
body: `Release ${version}`,
|
|
assets: [
|
|
{
|
|
name: `Multi-Hoster-Upload Setup ${version}.exe`,
|
|
size: installer.length,
|
|
browser_download_url: `https://update.invalid/${version}/setup.exe`
|
|
},
|
|
{
|
|
name: 'latest.yml',
|
|
browser_download_url: `https://update.invalid/${version}/latest.yml`
|
|
}
|
|
]
|
|
});
|
|
const createResponse = payload => ({
|
|
ok: true,
|
|
status: 200,
|
|
text: async () => JSON.stringify(payload),
|
|
json: async () => payload
|
|
});
|
|
const createInstallerResponse = () => ({
|
|
ok: true,
|
|
status: 200,
|
|
body: {
|
|
getReader: () => {
|
|
let served = false;
|
|
return {
|
|
read: async () => {
|
|
if (served) return { done: true };
|
|
served = true;
|
|
return { done: false, value: installer };
|
|
}
|
|
};
|
|
}
|
|
}
|
|
});
|
|
const createManifestResponse = version => ({
|
|
ok: true,
|
|
status: 200,
|
|
text: async () => `version: ${version}\npath: Multi-Hoster-Upload Setup ${version}.exe\nsha512: ${sha512}\nsize: ${installer.length}\n`
|
|
});
|
|
|
|
Module._load = function load(request, parent, isMain) {
|
|
if (request === 'electron') return { app: { getVersion: () => '2.1.22', getPath: () => tempDir } };
|
|
return originalLoad.call(this, request, parent, isMain);
|
|
};
|
|
delete require.cache[updaterPath];
|
|
const isolatedUpdater = require(updaterPath);
|
|
|
|
try {
|
|
global.fetch = async url => {
|
|
const value = String(url);
|
|
if (value.includes('/api/v1/repos/')) return createResponse([createRelease(oldVersion)]);
|
|
if (value.includes('api.github.com')) return createResponse({ body: `Release ${oldVersion}` });
|
|
throw new Error(`Unexpected initial request: ${value}`);
|
|
};
|
|
const oldCheck = await isolatedUpdater.checkForUpdate();
|
|
assert.equal(oldCheck.remoteVersion, oldVersion);
|
|
|
|
global.fetch = async url => {
|
|
const value = String(url);
|
|
if (value.includes('/api/v1/repos/')) return createResponse([createRelease(latestVersion)]);
|
|
if (value.includes('api.github.com')) return createResponse({ body: `Release ${latestVersion}` });
|
|
if (value.endsWith(`${oldVersion}/latest.yml`)) return createManifestResponse(oldVersion);
|
|
if (value.endsWith(`${latestVersion}/latest.yml`)) return createManifestResponse(latestVersion);
|
|
if (value.endsWith(`${oldVersion}/setup.exe`) || value.endsWith(`${latestVersion}/setup.exe`)) return createInstallerResponse();
|
|
throw new Error(`Unexpected refreshed request: ${value}`);
|
|
};
|
|
|
|
const prepared = await isolatedUpdater.prepareUpdate(null, { tempDir });
|
|
assert.equal(prepared.remoteVersion, latestVersion);
|
|
assert.equal(prepared.assetName, `Multi-Hoster-Upload Setup ${latestVersion}.exe`);
|
|
} finally {
|
|
global.fetch = originalFetch;
|
|
Module._load = originalLoad;
|
|
delete require.cache[updaterPath];
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('an update found before renderer readiness remains announceable afterwards', () => {
|
|
const state = createUpdateAnnouncementState();
|
|
const update = { available: true, remoteVersion: '2.1.24' };
|
|
|
|
assert.equal(state.canAnnounce(update, false), false);
|
|
assert.equal(state.canAnnounce(update, true), true);
|
|
assert.equal(state.canAnnounce(update, true), true);
|
|
state.markAnnounced(update);
|
|
assert.equal(state.canAnnounce(update, true), false);
|
|
|
|
state.reset();
|
|
assert.equal(state.canAnnounce(update, true), true);
|
|
});
|
|
|
|
test('update preparation fails closed when checksum metadata is unavailable', async () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-updater-test-'));
|
|
try {
|
|
await assert.rejects(
|
|
prepareUpdate(null, {
|
|
checkResult: {
|
|
available: true,
|
|
assetUrl: 'https://update.invalid/setup.exe',
|
|
assetName: 'setup.exe',
|
|
assetSize: 128 * 1024,
|
|
remoteVersion: '2.2.0',
|
|
latestYmlUrl: null
|
|
},
|
|
tempDir,
|
|
fetchImpl: async () => assert.fail('installer download must not start without checksum metadata')
|
|
}),
|
|
/Prüfsummen-Metadaten fehlen/
|
|
);
|
|
assert.equal(fs.existsSync(path.join(tempDir, 'setup.exe')), false);
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('setup selection never falls back to a portable executable', () => {
|
|
const portable = { name: 'Multi-Hoster-Upload 2.2.0.exe' };
|
|
assert.equal(pickSetupAsset([portable], '2.2.0'), null);
|
|
assert.deepEqual(
|
|
pickSetupAsset([portable, { name: 'Multi-Hoster-Upload Setup 2.2.0.exe' }], '2.2.0'),
|
|
{ name: 'Multi-Hoster-Upload Setup 2.2.0.exe' }
|
|
);
|
|
});
|
|
|
|
test('checksum metadata must match the selected version, installer name, size, and SHA-512 shape', async () => {
|
|
const sha = crypto.randomBytes(64).toString('base64');
|
|
const fetchImpl = async () => ({
|
|
ok: true,
|
|
status: 200,
|
|
text: async () => `version: 2.2.0\npath: Multi-Hoster-Upload Setup 2.2.0.exe\nsha512: ${sha}\nsize: 456\n`
|
|
});
|
|
const metadata = await parseLatestYml('https://update.invalid/latest.yml', {
|
|
version: '2.2.0',
|
|
assetName: 'Multi-Hoster-Upload Setup 2.2.0.exe',
|
|
assetSize: 456
|
|
}, fetchImpl);
|
|
|
|
assert.deepEqual(metadata, {
|
|
version: '2.2.0',
|
|
path: 'Multi-Hoster-Upload Setup 2.2.0.exe',
|
|
size: 456,
|
|
sha512: sha
|
|
});
|
|
});
|
|
|
|
test('checksum metadata rejects a path that belongs to another artifact', async () => {
|
|
const sha = crypto.randomBytes(64).toString('base64');
|
|
await assert.rejects(
|
|
parseLatestYml('https://update.invalid/latest.yml', {
|
|
version: '2.2.0',
|
|
assetName: 'Multi-Hoster-Upload Setup 2.2.0.exe',
|
|
assetSize: 456
|
|
}, async () => ({
|
|
ok: true,
|
|
status: 200,
|
|
text: async () => `version: 2.2.0\npath: Multi-Hoster-Upload 2.2.0.exe\nsha512: ${sha}\nsize: 456\n`
|
|
})),
|
|
/gehören nicht zum ausgewählten Installer/
|
|
);
|
|
});
|
|
|
|
test('a prepared installer launches at most once', () => {
|
|
const calls = [];
|
|
const child = { unrefCalls: 0, unref() { this.unrefCalls++; } };
|
|
const prepared = { installerPath: 'C:\\Temp\\mhu-setup.exe' };
|
|
const spawnImpl = (...args) => {
|
|
calls.push(args);
|
|
return child;
|
|
};
|
|
|
|
assert.equal(launchPreparedUpdate(prepared, { spawnImpl }), true);
|
|
assert.equal(launchPreparedUpdate(prepared, { spawnImpl }), false);
|
|
assert.equal(calls.length, 1);
|
|
assert.deepEqual(calls[0], [
|
|
prepared.installerPath,
|
|
['/S', '--updated', '--force-run'],
|
|
{ detached: true, stdio: 'ignore' }
|
|
]);
|
|
assert.equal(child.unrefCalls, 1);
|
|
});
|
|
|
|
test('release plan keeps product artifacts separate from the transport tag', async () => {
|
|
const { createReleasePlan, parseReleaseArgs, renderLatestYml } = await import(releasePlanUrl);
|
|
const plan = createReleasePlan(parseReleaseArgs(['2.0.7', '--transport-tag', 'v3.3.115', 'Update', 'visibility']));
|
|
const latestYml = renderLatestYml(plan, 'abc123', 456, '2026-08-07T12:00:00.000Z');
|
|
assert.deepEqual({
|
|
version: plan.version,
|
|
transportTag: plan.transportTag,
|
|
releaseTitle: plan.releaseTitle,
|
|
releaseBody: plan.releaseBody,
|
|
expectedArtifacts: plan.expectedArtifacts,
|
|
latestYml
|
|
}, {
|
|
version: '2.0.7',
|
|
transportTag: 'v3.3.115',
|
|
releaseTitle: 'Multi-Hoster-Upload v2.0.7',
|
|
releaseBody: 'Update visibility',
|
|
expectedArtifacts: [
|
|
'Multi-Hoster-Upload Setup 2.0.7.exe',
|
|
'Multi-Hoster-Upload 2.0.7.exe',
|
|
'Multi-Hoster-Upload Setup 2.0.7.exe.blockmap',
|
|
'latest.yml'
|
|
],
|
|
latestYml: "version: 2.0.7\nfiles:\n - url: Multi-Hoster-Upload Setup 2.0.7.exe\n sha512: abc123\n size: 456\npath: Multi-Hoster-Upload Setup 2.0.7.exe\nsha512: abc123\nreleaseDate: '2026-08-07T12:00:00.000Z'\n"
|
|
});
|
|
});
|
|
|
|
test('compatible existing release preserves the recovery id', async () => {
|
|
const { createReleasePlan, parseReleaseArgs, resolveExistingReleaseId } = await import(releasePlanUrl);
|
|
const plan = createReleasePlan(parseReleaseArgs(['2.0.1', '--transport-tag', 'v3.3.109', 'Bridge notes']));
|
|
const release = {
|
|
id: 81,
|
|
tag_name: 'v3.3.109',
|
|
name: 'Multi-Hoster-Upload v2.0.1',
|
|
body: 'Bridge notes',
|
|
draft: false,
|
|
prerelease: false,
|
|
assets: []
|
|
};
|
|
|
|
assert.equal(resolveExistingReleaseId(plan, release), 81);
|
|
});
|
|
|
|
test('incompatible existing release title fails closed', async () => {
|
|
const { createReleasePlan, parseReleaseArgs, resolveExistingReleaseId } = await import(releasePlanUrl);
|
|
const plan = createReleasePlan(parseReleaseArgs(['2.0.1', '--transport-tag', 'v3.3.109', 'Bridge notes']));
|
|
const release = {
|
|
id: 81,
|
|
tag_name: 'v3.3.109',
|
|
name: 'Multi-Hoster-Upload v3.3.109',
|
|
body: 'Old transport release',
|
|
draft: false,
|
|
prerelease: false,
|
|
assets: []
|
|
};
|
|
|
|
assert.throws(
|
|
() => resolveExistingReleaseId(plan, release),
|
|
/Refusing recovery for v3\.3\.109: existing release title "Multi-Hoster-Upload v3\.3\.109" does not match "Multi-Hoster-Upload v2\.0\.1"/
|
|
);
|
|
});
|