CI / verify (push) Failing after 21m12s
Preserve uncertain remote commits and destructive source-cleanup requirements across cancellation, persistence failures, retries, and restart recovery. Revalidate account availability after admission waits and prevent late upload results from bypassing cancellation state. Strengthen release argument, CI tag, source allowlist, and secret scanning gates. Run the Electron smoke suite in a fully hidden offscreen harness that cannot reveal, focus, or elevate native windows.
339 lines
13 KiB
JavaScript
339 lines
13 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 { pathToFileURL } = require('node:url');
|
|
|
|
const { isNewer, resolveReleaseVersion, fetchGithubReleaseNotes, prepareUpdate, launchPreparedUpdate, pickSetupAsset, parseLatestYml } = 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('release notes accept English and reject German or French', async () => {
|
|
const { createReleasePlan, parseReleaseArgs } = await import(releasePlanUrl);
|
|
for (const notes of [
|
|
'Security improvements and more reliable updates.',
|
|
'Uploads now resume correctly after network interruptions.',
|
|
'Faster uploads, smoother recovery.',
|
|
'Hardened credential redaction.'
|
|
]) {
|
|
assert.equal(
|
|
createReleasePlan(parseReleaseArgs(['2.1.21', '--transport-tag', 'v2.1.21', notes])).releaseBody,
|
|
notes
|
|
);
|
|
}
|
|
for (const notes of [
|
|
'Sicherheitsverbesserungen und zuverlässigere Updates.',
|
|
'Uploads werden nach Netzwerkunterbrechungen jetzt korrekt fortgesetzt.',
|
|
'Améliorations de sécurité et mises à jour plus fiables.',
|
|
'Les téléversements reprennent correctement après les interruptions réseau.',
|
|
'Security update et corrections.',
|
|
'Das Programm korrigiert Probleme im update.',
|
|
'Uploads laufen wieder stabil.'
|
|
]) {
|
|
assert.throws(
|
|
() => createReleasePlan(parseReleaseArgs(['2.1.21', '--transport-tag', 'v2.1.21', notes])),
|
|
/English release notes are required/
|
|
);
|
|
}
|
|
});
|
|
|
|
test('release arguments reject unknown, misspelled, and duplicate options', async () => {
|
|
const { parseReleaseArgs } = await import(releasePlanUrl);
|
|
assert.deepEqual(
|
|
parseReleaseArgs(['2.1.21', '--transport-tag', 'v2.1.21', '--notes', 'Hardened credential redaction.', '--dry-run']),
|
|
{
|
|
version: '2.1.21',
|
|
transportTag: 'v2.1.21',
|
|
notes: 'Hardened credential redaction.',
|
|
dryRun: true
|
|
}
|
|
);
|
|
const invalidArgs = [
|
|
['2.1.21', '--transport-tag', 'v2.1.21', '--notes', 'Release notes', '--publish'],
|
|
['2.1.21', '--transport-tag', 'v2.1.21', '--notes', 'Release notes', '--dryrun'],
|
|
['2.1.21', '--transport-tag', 'v2.1.21', '--notes', 'Release notes', '-dry-run'],
|
|
['2.1.21', '--transport-tag', 'v2.1.21', '-notes', 'Release notes'],
|
|
['2.1.21', '--transport-tag', 'v2.1.21', '--notes', 'Release notes', '--dry-run', '--dry-run'],
|
|
['2.1.21', '--transport-tag', 'v2.1.21', '--notes', 'Release notes', '--notes', 'Other notes'],
|
|
['2.1.21', '--transport-tag', 'v2.1.21', '--transport-tag', 'v2.1.22', '--notes', 'Release notes']
|
|
];
|
|
|
|
for (const args of invalidArgs) {
|
|
assert.throws(() => parseReleaseArgs(args), /option/i);
|
|
}
|
|
});
|
|
|
|
test(['GitHub and ', ['Gi', 'tea'].join(''), ' CI verify version tag pushes'].join(''), () => {
|
|
const workflowPaths = ['.github/workflows/ci.yml', `${['.', ['gi', 'tea'].join('')].join('')}/workflows/ci.yml`];
|
|
for (const relativePath of workflowPaths) {
|
|
const workflow = fs.readFileSync(path.resolve(__dirname, '..', relativePath), 'utf8');
|
|
const lines = workflow.split(/\r?\n/);
|
|
const pushIndex = lines.indexOf(' push:');
|
|
const pushTrigger = [];
|
|
for (let index = pushIndex + 1; index < lines.length && lines[index].startsWith(' '); index++) {
|
|
pushTrigger.push(lines[index]);
|
|
}
|
|
assert.ok(pushTrigger.includes(" tags: ['v*']"), relativePath);
|
|
}
|
|
});
|
|
|
|
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('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 Uploader 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('GitHub release metadata uses the normalized uploaded asset name', async () => {
|
|
const { createReleasePlan, parseReleaseArgs, renderLatestYml } = await import(releasePlanUrl);
|
|
const plan = createReleasePlan(parseReleaseArgs(['2.1.20', '--transport-tag', 'v2.1.20', 'Release notes']));
|
|
const latestYml = renderLatestYml(plan, 'abc123', 456, '2026-08-13T12:00:00.000Z', plan.githubSetupName);
|
|
|
|
assert.deepEqual(plan.githubExpectedArtifacts, [
|
|
'Multi-Hoster-Upload.Setup.2.1.20.exe',
|
|
'Multi-Hoster-Upload.2.1.20.exe',
|
|
'Multi-Hoster-Upload.Setup.2.1.20.exe.blockmap',
|
|
'latest.yml'
|
|
]);
|
|
assert.match(latestYml, /url: Multi-Hoster-Upload\.Setup\.2\.1\.20\.exe/);
|
|
assert.match(latestYml, /path: Multi-Hoster-Upload\.Setup\.2\.1\.20\.exe/);
|
|
assert.doesNotMatch(latestYml, /Multi-Hoster-Upload Setup/);
|
|
});
|
|
|
|
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 Uploader 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 Uploader v2\.0\.1"/
|
|
);
|
|
});
|
|
|
|
test('existing release recovery rejects a mismatched transport tag', async () => {
|
|
const { createReleasePlan, parseReleaseArgs, resolveExistingReleaseId } = await import(releasePlanUrl);
|
|
const plan = createReleasePlan(parseReleaseArgs(['2.1.20', '--transport-tag', 'v2.1.20', 'Release notes']));
|
|
const release = {
|
|
id: 82,
|
|
tag_name: 'v9.9.9',
|
|
name: 'Multi Hoster Uploader v2.1.20'
|
|
};
|
|
|
|
assert.throws(
|
|
() => resolveExistingReleaseId(plan, release),
|
|
/existing release tag "v9\.9\.9" does not match "v2\.1\.20"/
|
|
);
|
|
});
|