mirror of
https://github.com/webinstall/webi-installers.git
synced 2026-06-10 01:36:35 +00:00
New test programs that exercise the cache-only Node server path and compare its output against the legacy upstream-fetching path: - test-cache-api-ready.js: pre-flight check that the cache file matches what /api/releases would return for each name in _cache/<yyyy-mm>/. - test-cache-compat.js: parameterized cache vs. fresh-fetch diff suite, walks every package and OS/arch combo. - test-api-compat.js, test-installer-resolve.js, test-broad-resolve.js: installer-side parity checks across the resolver. - test-live-compare.js, test-live-cache-diff.js, test-live-installer-diff.js: TSV-output sweeps comparing two remote URLs across the cached package x OS/arch matrix; supports --concurrency and --packages filters. - test-fleet-diff.js: fleet-wide TSV diff with package/OS/arch filtering, used to validate beta and next. Also adds 36 golden snapshots under _webi/testdata/live_*.json covering 6 packages (bat, caddy, go, jq, node, rg) x 4 OS/arch combos plus the unfiltered baseline per package.
340 lines
9.8 KiB
JavaScript
340 lines
9.8 KiB
JavaScript
'use strict';
|
|
|
|
// Fleet-wide diff: compare a candidate host (e.g. beta.webi.sh) against
|
|
// production for every cached package, across multiple OS/arch combos.
|
|
// Outputs TSV for grep/sort.
|
|
//
|
|
// Usage:
|
|
// node _webi/test-fleet-diff.js
|
|
// --cand-url=https://beta.webi.sh
|
|
// --prod-url=https://webinstall.dev # default
|
|
// --kind=api # default; or "installer"
|
|
// --pkgs=bat,go,... # default: all cached packages
|
|
// --concurrency=8 # default
|
|
// --out=fleet-api.tsv # default: stdout
|
|
|
|
let Fs = require('node:fs/promises');
|
|
let Os = require('node:os');
|
|
let Path = require('node:path');
|
|
let Https = require('node:https');
|
|
|
|
let CACHE_DIR = Path.join(Os.homedir(), '.cache/webi/legacy');
|
|
|
|
function arg(name, dflt) {
|
|
for (let a of process.argv) {
|
|
if (a.startsWith(`--${name}=`)) {
|
|
return a.slice(name.length + 3);
|
|
}
|
|
}
|
|
return dflt;
|
|
}
|
|
|
|
let CAND_URL = arg('cand-url', 'https://beta.webi.sh').replace(/\/+$/, '');
|
|
let PROD_URL = arg('prod-url', 'https://webinstall.dev').replace(/\/+$/, '');
|
|
let KIND = arg('kind', 'api');
|
|
let PKGS_ARG = arg('pkgs', '');
|
|
let CONCURRENCY = parseInt(arg('concurrency', '8'), 10);
|
|
let OUT = arg('out', '');
|
|
|
|
// OS/arch matrix for API mode
|
|
let API_MATRIX = [
|
|
{ os: 'macos', arch: 'amd64' },
|
|
{ os: 'macos', arch: 'arm64' },
|
|
{ os: 'linux', arch: 'amd64' },
|
|
{ os: 'linux', arch: 'arm64' },
|
|
{ os: 'linux', arch: 'armv7l' },
|
|
{ os: 'windows', arch: 'amd64' },
|
|
{ os: 'freebsd', arch: 'amd64' },
|
|
];
|
|
|
|
// UA strings for installer mode
|
|
let INSTALLER_MATRIX = [
|
|
{ label: 'macos_arm64', ua: 'aarch64/unknown Darwin/24.2.0 libc' },
|
|
{ label: 'macos_amd64', ua: 'x86_64/unknown Darwin/23.0.0 libc' },
|
|
{ label: 'linux_amd64', ua: 'x86_64/unknown Linux/5.15.0 libc' },
|
|
{ label: 'linux_arm64', ua: 'aarch64/unknown Linux/5.15.0 libc' },
|
|
{ label: 'linux_musl', ua: 'x86_64/unknown Linux/5.15.0 musl' },
|
|
{ label: 'windows_amd64', ua: 'x86_64/unknown Windows/10.0.19041 msvc' },
|
|
];
|
|
|
|
function httpsGet(url, headers) {
|
|
return new Promise(function (resolve, reject) {
|
|
let opts = { headers: headers || {}, timeout: 15000 };
|
|
let req = Https.get(url, opts, function (res) {
|
|
// Follow one redirect
|
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
let redir = res.headers.location;
|
|
if (redir.startsWith('/')) {
|
|
let m = url.match(/^(https?:\/\/[^/]+)/);
|
|
redir = (m ? m[1] : '') + redir;
|
|
}
|
|
Https.get(redir, opts, function (res2) {
|
|
let data = '';
|
|
res2.on('data', function (c) { data += c; });
|
|
res2.on('end', function () { resolve({ status: res2.statusCode, body: data }); });
|
|
}).on('error', reject);
|
|
return;
|
|
}
|
|
let data = '';
|
|
res.on('data', function (c) { data += c; });
|
|
res.on('end', function () { resolve({ status: res.statusCode, body: data }); });
|
|
});
|
|
req.on('error', reject);
|
|
req.on('timeout', function () { req.destroy(new Error('timeout')); });
|
|
});
|
|
}
|
|
|
|
async function listCachedPkgs() {
|
|
let entries = await Fs.readdir(CACHE_DIR);
|
|
return entries
|
|
.filter(function (n) { return n.endsWith('.json') && !n.endsWith('.updated.txt'); })
|
|
.map(function (n) { return n.slice(0, -5); })
|
|
.sort();
|
|
}
|
|
|
|
function safeFirst(json) {
|
|
try {
|
|
let arr = JSON.parse(json);
|
|
if (!Array.isArray(arr) || arr.length === 0) {
|
|
return null;
|
|
}
|
|
return arr[0];
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parseInstallerVars(script) {
|
|
let vars = {};
|
|
let re = /^(?:export\s+)?(WEBI_\w+|PKG_NAME)='([^']*)'/gm;
|
|
let m;
|
|
while ((m = re.exec(script)) !== null) {
|
|
vars[m[1]] = m[2];
|
|
}
|
|
return vars;
|
|
}
|
|
|
|
async function diffApi(pkg, os, arch) {
|
|
let qs = `?os=${os}&arch=${arch}&limit=1`;
|
|
let candUrl = `${CAND_URL}/api/releases/${pkg}@stable.json${qs}`;
|
|
let prodUrl = `${PROD_URL}/api/releases/${pkg}@stable.json${qs}`;
|
|
|
|
let cand, prod;
|
|
try {
|
|
[cand, prod] = await Promise.all([httpsGet(candUrl), httpsGet(prodUrl)]);
|
|
} catch (e) {
|
|
return { pkg, os, arch, status: 'fetch_error', detail: e.message };
|
|
}
|
|
|
|
if (cand.status !== 200 || prod.status !== 200) {
|
|
return {
|
|
pkg, os, arch, status: 'http_error',
|
|
detail: `cand=${cand.status} prod=${prod.status}`,
|
|
};
|
|
}
|
|
|
|
let candFirst = safeFirst(cand.body);
|
|
let prodFirst = safeFirst(prod.body);
|
|
|
|
let candErr = !candFirst || candFirst.channel === 'error';
|
|
let prodErr = !prodFirst || prodFirst.channel === 'error';
|
|
|
|
if (candErr && prodErr) {
|
|
return { pkg, os, arch, status: 'both_error', detail: '' };
|
|
}
|
|
if (candErr && !prodErr) {
|
|
return {
|
|
pkg, os, arch, status: 'cand_only_error',
|
|
detail: `prod=${prodFirst.version}/${prodFirst.ext}`,
|
|
};
|
|
}
|
|
if (!candErr && prodErr) {
|
|
return {
|
|
pkg, os, arch, status: 'prod_only_error',
|
|
detail: `cand=${candFirst.version}/${candFirst.ext}`,
|
|
};
|
|
}
|
|
|
|
// Both succeeded — diff the key fields
|
|
let diffs = [];
|
|
for (let f of ['os', 'arch', 'libc', 'ext']) {
|
|
if (candFirst[f] !== prodFirst[f]) {
|
|
diffs.push(`${f}:cand=${candFirst[f]}|prod=${prodFirst[f]}`);
|
|
}
|
|
}
|
|
|
|
let ver = candFirst.version === prodFirst.version
|
|
? candFirst.version
|
|
: `cand=${candFirst.version}|prod=${prodFirst.version}`;
|
|
|
|
return {
|
|
pkg, os, arch,
|
|
status: diffs.length === 0 ? 'match' : 'diff',
|
|
detail: diffs.length === 0 ? `v${ver} ${candFirst.ext}` : `v${ver} ${diffs.join(',')}`,
|
|
};
|
|
}
|
|
|
|
async function diffInstaller(pkg, label, ua) {
|
|
let candUrl = `${CAND_URL}/api/installers/${pkg}@stable.sh`;
|
|
let prodUrl = `${PROD_URL}/api/installers/${pkg}@stable.sh`;
|
|
let headers = { 'User-Agent': ua };
|
|
|
|
let cand, prod;
|
|
try {
|
|
[cand, prod] = await Promise.all([
|
|
httpsGet(candUrl, headers),
|
|
httpsGet(prodUrl, headers),
|
|
]);
|
|
} catch (e) {
|
|
return { pkg, label, status: 'fetch_error', detail: e.message };
|
|
}
|
|
|
|
if (cand.status !== 200 || prod.status !== 200) {
|
|
return {
|
|
pkg, label, status: 'http_error',
|
|
detail: `cand=${cand.status} prod=${prod.status}`,
|
|
};
|
|
}
|
|
|
|
let candVars = parseInstallerVars(cand.body);
|
|
let prodVars = parseInstallerVars(prod.body);
|
|
|
|
let candHas = candVars.WEBI_PKG_URL && candVars.WEBI_EXT && candVars.WEBI_EXT !== 'err';
|
|
let prodHas = prodVars.WEBI_PKG_URL && prodVars.WEBI_EXT && prodVars.WEBI_EXT !== 'err';
|
|
|
|
if (!candHas && !prodHas) {
|
|
return { pkg, label, status: 'both_error', detail: '' };
|
|
}
|
|
if (!candHas && prodHas) {
|
|
return {
|
|
pkg, label, status: 'cand_only_error',
|
|
detail: `prod=${prodVars.WEBI_VERSION}/${prodVars.WEBI_EXT}`,
|
|
};
|
|
}
|
|
if (candHas && !prodHas) {
|
|
return {
|
|
pkg, label, status: 'prod_only_error',
|
|
detail: `cand=${candVars.WEBI_VERSION}/${candVars.WEBI_EXT}`,
|
|
};
|
|
}
|
|
|
|
// Diff WEBI_OS, WEBI_ARCH, WEBI_EXT (PKG_NAME may differ for aliases)
|
|
let diffs = [];
|
|
for (let v of ['WEBI_OS', 'WEBI_ARCH', 'WEBI_EXT']) {
|
|
if (candVars[v] !== prodVars[v]) {
|
|
diffs.push(`${v}:cand=${candVars[v]}|prod=${prodVars[v]}`);
|
|
}
|
|
}
|
|
|
|
let ver = candVars.WEBI_VERSION === prodVars.WEBI_VERSION
|
|
? candVars.WEBI_VERSION
|
|
: `cand=${candVars.WEBI_VERSION}|prod=${prodVars.WEBI_VERSION}`;
|
|
|
|
return {
|
|
pkg, label,
|
|
status: diffs.length === 0 ? 'match' : 'diff',
|
|
detail: diffs.length === 0 ? `v${ver} ${candVars.WEBI_EXT}` : `v${ver} ${diffs.join(',')}`,
|
|
};
|
|
}
|
|
|
|
async function pool(items, fn, concurrency) {
|
|
let results = new Array(items.length);
|
|
let i = 0;
|
|
async function worker() {
|
|
while (true) {
|
|
let idx = i++;
|
|
if (idx >= items.length) {
|
|
return;
|
|
}
|
|
try {
|
|
results[idx] = await fn(items[idx], idx);
|
|
} catch (e) {
|
|
results[idx] = { status: 'exception', detail: e.message, _item: items[idx] };
|
|
}
|
|
}
|
|
}
|
|
let workers = [];
|
|
for (let k = 0; k < concurrency; k++) {
|
|
workers.push(worker());
|
|
}
|
|
await Promise.all(workers);
|
|
return results;
|
|
}
|
|
|
|
async function main() {
|
|
let pkgs;
|
|
if (PKGS_ARG) {
|
|
pkgs = PKGS_ARG.split(',').filter(Boolean);
|
|
} else {
|
|
pkgs = await listCachedPkgs();
|
|
}
|
|
|
|
console.error(`Comparing ${pkgs.length} packages: ${CAND_URL} (cand) vs ${PROD_URL} (prod)`);
|
|
console.error(`Mode: ${KIND}, concurrency: ${CONCURRENCY}`);
|
|
|
|
let jobs = [];
|
|
if (KIND === 'api') {
|
|
for (let pkg of pkgs) {
|
|
for (let combo of API_MATRIX) {
|
|
jobs.push({ pkg, os: combo.os, arch: combo.arch });
|
|
}
|
|
}
|
|
} else if (KIND === 'installer') {
|
|
for (let pkg of pkgs) {
|
|
for (let combo of INSTALLER_MATRIX) {
|
|
jobs.push({ pkg, label: combo.label, ua: combo.ua });
|
|
}
|
|
}
|
|
} else {
|
|
console.error(`Unknown kind: ${KIND}`);
|
|
process.exit(2);
|
|
}
|
|
|
|
let started = Date.now();
|
|
let results = await pool(jobs, async function (job) {
|
|
if (KIND === 'api') {
|
|
return diffApi(job.pkg, job.os, job.arch);
|
|
}
|
|
return diffInstaller(job.pkg, job.label, job.ua);
|
|
}, CONCURRENCY);
|
|
let elapsed = ((Date.now() - started) / 1000).toFixed(1);
|
|
|
|
// TSV output
|
|
let lines = [];
|
|
if (KIND === 'api') {
|
|
lines.push(['pkg', 'os', 'arch', 'status', 'detail'].join('\t'));
|
|
for (let r of results) {
|
|
lines.push([r.pkg, r.os, r.arch, r.status, r.detail || ''].join('\t'));
|
|
}
|
|
} else {
|
|
lines.push(['pkg', 'target', 'status', 'detail'].join('\t'));
|
|
for (let r of results) {
|
|
lines.push([r.pkg, r.label, r.status, r.detail || ''].join('\t'));
|
|
}
|
|
}
|
|
let body = lines.join('\n') + '\n';
|
|
|
|
if (OUT) {
|
|
await Fs.writeFile(OUT, body, 'utf8');
|
|
console.error(`Wrote ${OUT}`);
|
|
} else {
|
|
process.stdout.write(body);
|
|
}
|
|
|
|
// Summary to stderr
|
|
let counts = {};
|
|
for (let r of results) {
|
|
counts[r.status] = (counts[r.status] || 0) + 1;
|
|
}
|
|
console.error('');
|
|
console.error(`=== Summary (${elapsed}s, ${results.length} jobs) ===`);
|
|
for (let s of Object.keys(counts).sort()) {
|
|
console.error(` ${s}: ${counts[s]}`);
|
|
}
|
|
}
|
|
|
|
main().catch(function (err) {
|
|
console.error(err.stack);
|
|
process.exit(1);
|
|
});
|