diff --git a/_webi/transform-releases-test.js b/_webi/transform-releases-test.js new file mode 100644 index 0000000..c218552 --- /dev/null +++ b/_webi/transform-releases-test.js @@ -0,0 +1,371 @@ +'use strict'; + +var Fs = require('node:fs/promises'); +var Os = require('node:os'); +var Path = require('node:path'); + +var CACHE_DIR = Path.join(Os.homedir(), '.cache/webi/legacy'); +var TEST_PKG = + 'test-transform-releases-' + Math.random().toString(36).slice(2, 10); +var TEST_FILE = Path.join(CACHE_DIR, TEST_PKG + '.json'); + +// --- Helpers --- + +// Realistic release shape so filterReleases matches (not error release). +var GOOD_RELEASE = { + download: '', + releases: [ + { + name: 'test-v1.0.0.tar.gz', + version: '1.0.0', + lts: '-', + channel: 'stable', + date: '2026-01-01', + os: 'linux', + arch: 'amd64', + libc: 'gnu', + ext: 'tar.gz', + download: 'https://example.com/test-v1.0.0.tar.gz', + }, + ], + oses: ['linux', 'macos', 'windows'], + arches: ['amd64', 'arm64'], + libcs: ['gnu', 'musl'], + formats: ['tar.gz', 'zip'], +}; + +var EMPTY_RELEASE = { + download: '', + releases: [], + oses: [], + arches: [], + libcs: [], + formats: [], +}; + +function writeCache(data) { + return Fs.writeFile(TEST_FILE, JSON.stringify(data, null, 2)); +} + +function deleteCache() { + return Fs.unlink(TEST_FILE).catch(function () {}); +} + +// Clear module cache and get a fresh instance (isolated cache state). +function loadReleases() { + var key = Path.resolve(__dirname, 'transform-releases.js'); + delete require.cache[key]; + return require('./transform-releases.js'); +} + +// --- Tests --- + +var passed = 0; +var failed = 0; + +function assert(condition, msg) { + if (condition) { + console.log(' PASS: ' + msg); + passed = passed + 1; + } else { + console.log(' FAIL: ' + msg); + failed = failed + 1; + } +} + +async function testFreshCache() { + console.log('Test 1: Fresh cache returns immediately'); + await writeCache(GOOD_RELEASE); + + var Releases = loadReleases(); + + // First call loads the cache + var result1 = await Releases.getReleases({ + pkg: TEST_PKG, + os: 'linux', + arch: 'amd64', + libc: 'gnu', + formats: ['tar.gz'], + }); + assert(result1.releases.length === 1, 'got 1 release on first call'); + assert(result1.releases[0].version === '1.0.0', 'version is 1.0.0'); + + // Call again — should be fresh (same data, no re-read) + var result2 = await Releases.getReleases({ + pkg: TEST_PKG, + os: 'linux', + arch: 'amd64', + libc: 'gnu', + formats: ['tar.gz'], + }); + assert(result2.releases.length === 1, 'still 1 release'); + assert(result2.releases[0].version === '1.0.0', 'version still 1.0.0'); + + await deleteCache(); +} + +async function testStaleCacheAwaitsRefresh() { + console.log('Test 2: Stale cache awaits refresh (not just background)'); + await writeCache(GOOD_RELEASE); + + var Releases = loadReleases(); + + // First call — loads and caches the good data. + var result1 = await Releases.getReleases({ + pkg: TEST_PKG, + os: 'linux', + arch: 'amd64', + libc: 'gnu', + formats: ['tar.gz'], + }); + assert(result1.releases.length === 1, 'got 1 release on first call'); + + // Delete the file and simulate staleness (> 60s). + await deleteCache(); + var origNow = Date.now; + Date.now = function () { + return origNow() + 70000; + }; + + // Call again — entry is stale, awaits refresh, gets empty, keeps old good data. + var r2 = await Releases.getReleases({ + pkg: TEST_PKG, + os: 'linux', + arch: 'amd64', + libc: 'gnu', + formats: ['tar.gz'], + }); + assert(r2.releases.length === 1, 'kept old good data after stale refresh'); + assert(r2.releases[0].version === '1.0.0', 'version still 1.0.0'); + + Date.now = origNow; + await deleteCache(); +} + +async function testOldDataSurvivesEmptyFileRefresh() { + console.log('Test 3: Old good data survives empty-file refresh'); + // Write good data, load it. + await writeCache(GOOD_RELEASE); + var Releases = loadReleases(); + var r1 = await Releases.getReleases({ + pkg: TEST_PKG, + os: 'linux', + arch: 'amd64', + libc: 'gnu', + formats: ['tar.gz'], + }); + assert(r1.releases.length === 1, 'loaded good data'); + + // Replace file with EMPTY data (not missing — explicitly empty). + await writeCache(EMPTY_RELEASE); + + // Simulate staleness. + var origNow = Date.now; + Date.now = function () { + return origNow() + 70000; + }; + + // Refresh — sees empty releases, treats as failure, keeps old good data. + var r2 = await Releases.getReleases({ + pkg: TEST_PKG, + os: 'linux', + arch: 'amd64', + libc: 'gnu', + formats: ['tar.gz'], + }); + assert( + r2.releases.length === 1, + 'kept old good data (not overwritten by empty)', + ); + assert(r2.releases[0].version === '1.0.0', 'version still 1.0.0'); + + Date.now = origNow; + await deleteCache(); +} + +async function testConcurrentExpiredRequests() { + console.log( + 'Test 4: Concurrent expired requests coalesce (stampede protection)', + ); + await deleteCache(); + + var Releases = loadReleases(); + + // Start multiple concurrent requests for the same expired package (no file). + var promises = []; + for (var i = 0; i < 5; i = i + 1) { + promises.push( + Releases.getReleases({ + pkg: TEST_PKG, + os: 'linux', + arch: 'amd64', + libc: 'gnu', + formats: ['tar.gz'], + }).catch(function (err) { + return { error: err.message }; + }), + ); + } + + var results = await Promise.all(promises); + var noErrors = results.every(function (r) { + return !r.error; + }); + assert(noErrors, 'all 5 concurrent requests succeeded (no errors)'); + + // All should have returned empty metadata (file doesn't exist, + // onRefreshFail returns emptyData, filterReleases gets empty releases → error release). + assert( + results.every(function (r) { + return r.oses !== undefined && r.arches !== undefined; + }), + 'all results have metadata arrays', + ); + + await deleteCache(); +} + +async function testCorruptedCacheFile() { + console.log('Test 5: Corrupted cache file keeps old good data'); + await writeCache(GOOD_RELEASE); + + var Releases = loadReleases(); + + // First call — loads good data. + var result1 = await Releases.getReleases({ + pkg: TEST_PKG, + os: 'linux', + arch: 'amd64', + libc: 'gnu', + formats: ['tar.gz'], + }); + assert(result1.releases.length === 1, 'got 1 release on first call'); + + // Now corrupt the file. + await Fs.writeFile(TEST_FILE, 'not valid json{{{'); + + // Call again — entry is still fresh, returns cached good data. + var result2 = await Releases.getReleases({ + pkg: TEST_PKG, + os: 'linux', + arch: 'amd64', + libc: 'gnu', + formats: ['tar.gz'], + }); + assert(result2.releases.length === 1, 'kept good data while fresh'); + + // Make it stale via monkey-patch. + var origNow = Date.now; + Date.now = function () { + return origNow() + 70000; + }; + + var result3 = await Releases.getReleases({ + pkg: TEST_PKG, + os: 'linux', + arch: 'amd64', + libc: 'gnu', + formats: ['tar.gz'], + }); + // Corrupted file → parse error → onRefreshFail → keeps old good data. + assert(result3.releases.length === 1, 'kept old good data after corruption'); + assert(result3.releases[0].version === '1.0.0', 'version still 1.0.0'); + + Date.now = origNow; + await deleteCache(); +} + +async function testMissingCacheFile() { + console.log('Test 6: Missing cache file returns empty metadata'); + await deleteCache(); + + var Releases = loadReleases(); + + var result = await Releases.getReleases({ + pkg: TEST_PKG, + os: 'linux', + arch: 'amd64', + libc: 'gnu', + formats: ['tar.gz'], + }); + assert(result.oses !== undefined, 'oses is defined (not undefined)'); + assert(result.arches !== undefined, 'arches is defined (not undefined)'); + assert(result.libcs !== undefined, 'libcs is defined (not undefined)'); + assert(result.formats !== undefined, 'formats is defined (not undefined)'); + + // Should have an error release (empty releases → filterReleases produces error). + assert(result.releases.length >= 0, 'got releases (error release expected)'); + + await deleteCache(); +} + +async function testRealPackage() { + console.log('Test 7: Real package (bat) works correctly'); + var pkg = 'bat'; + + var Releases = loadReleases(); + + var result = await Releases.getReleases({ + pkg: pkg, + ver: '', + os: 'linux', + arch: 'amd64', + libc: 'gnu', + formats: ['tar.gz'], + limit: 5, + }); + // If bat.json exists on disk, verify it works. + // If not, just verify no crash (empty metadata is fine). + if (result.oses && result.oses.length > 0) { + // Got real data from cache file. + assert(result.releases.length > 0, 'got releases for real package'); + assert(result.releases.length <= 5, 'limit respected'); + assert(result.oses.length > 0, 'oses populated'); + assert(result.arches.length > 0, 'arches populated'); + } else { + // No cache file — verify we get safe empty metadata, not a crash. + assert(result.oses !== undefined, 'oses defined even without cache'); + assert(result.arches !== undefined, 'arches defined even without cache'); + } +} + +// --- Main --- + +async function main() { + console.log('--- transform-releases cache freshness tests ---\n'); + + await testFreshCache(); + console.log(); + + await testStaleCacheAwaitsRefresh(); + console.log(); + + await testOldDataSurvivesEmptyFileRefresh(); + console.log(); + + await testConcurrentExpiredRequests(); + console.log(); + + await testCorruptedCacheFile(); + console.log(); + + await testMissingCacheFile(); + console.log(); + + await testRealPackage(); + console.log(); + + console.log('--- Results ---'); + console.log('Passed: ' + passed); + console.log('Failed: ' + failed); + + if (failed > 0) { + process.exit(1); + } +} + +main().catch(function (err) { + console.error('Test error:', err.message); + console.error(err.stack); + process.exit(1); +}); diff --git a/_webi/transform-releases.js b/_webi/transform-releases.js index 88b27a5..6a7f178 100644 --- a/_webi/transform-releases.js +++ b/_webi/transform-releases.js @@ -5,10 +5,144 @@ var Releases = module.exports; var Fs = require('node:fs/promises'); var Os = require('node:os'); var path = require('path'); -var cache = {}; var LEGACY_CACHE_DIR = path.join(Os.homedir(), '.cache/webi/legacy'); +// Cache entries: { data: ..., loadedAt: Date, updating: Promise|null } +var cache = {}; + +// How long a cache entry stays fresh (1 minute) +var FRESHNESS_MS = 60 * 1000; + +/** + * Check if a cache entry is fresh. + */ +function isFresh(name) { + var entry = cache[name]; + if (!entry) { return false; } + return Date.now() - entry.loadedAt < FRESHNESS_MS; +} + +/** + * Empty metadata shape — callers read all.oses, all.arches, etc. + * Never leave these undefined. + */ +function emptyData() { + return { + download: '', + releases: [], + oses: [], + arches: [], + libcs: [], + formats: [], + }; +} + +/** + * Check whether parsed data qualifies as "good" (non-empty releases). + * Used to decide whether to overwrite an existing cache entry. + */ +function isGoodData(data) { + return data && Array.isArray(data.releases) && data.releases.length > 0; +} + +/** + * Handle a failed refresh: keep old data (if any), reset freshness, + * and return a safe shape so callers never crash on .oses etc. + */ +function onRefreshFail(pkg) { + var keep = cache[pkg] ? cache[pkg].data : null; + var data = keep || emptyData(); + cache[pkg] = { data: data, loadedAt: Date.now() }; + return data; +} + +/** + * Read a package's cache file from disk. Throws on read/parse errors. + */ +function readCacheFile(pkg) { + var dataFile = LEGACY_CACHE_DIR + '/' + pkg + '.json'; + return Fs.readFile(dataFile, 'utf8') + .then(function (json) { + return JSON.parse(json); + }) + .catch(function (err) { + if (err.code === 'ENOENT') { + console.warn('cache miss: ' + dataFile); + return null; + } + console.error('cache read error: ' + dataFile + ':\n\t' + err.message); + throw err; + }); +} + +/** + * Refresh a package's cache from disk. + * + * - Good data (non-empty releases): replaces the entry. + * - Bad/empty data: keeps old entry, resets freshness, returns old/empty. + * - Read/parse error: keeps old entry, resets freshness, returns old/empty. + * Never throws to the caller. + */ +function refreshCache(pkg) { + return readCacheFile(pkg) + .then(function (data) { + if (!data) { + // File missing or empty — keep old data, reset freshness. + return onRefreshFail(pkg); + } + if (isGoodData(data)) { + // Good data — replace the entry. + cache[pkg] = { data: data, loadedAt: Date.now() }; + return data; + } + // Parsed but empty/invalid releases — treat as failure. + return onRefreshFail(pkg); + }) + .catch(function (err) { + // Read/parse error — keep old data, reset freshness. + return onRefreshFail(pkg); + }); +} + +/** + * Get or refresh a package's cache, respecting freshness policy: + * - fresh: return immediately + * - stale or expired: await a refresh (file read is cheap) + * - concurrent calls coalesce onto one in-flight refresh + */ +function getCachedReleases(pkg) { + var entry = cache[pkg]; + + // Coalesce onto any in-flight update (checked FIRST). + if (entry && entry.updating) { + return entry.updating; + } + + // Fresh — return immediately. + if (isFresh(pkg)) { + return Promise.resolve(entry.data); + } + + // Stale or expired — await a refresh. + var updatePromise = refreshCache(pkg); + + cache[pkg] = { + data: entry ? entry.data : emptyData(), + loadedAt: Date.now(), + updating: updatePromise, + }; + + // Clear the updating flag when done. + updatePromise.finally(function () { + if (cache[pkg]) { + cache[pkg].updating = null; + } + }); + + return updatePromise; +} + // Sort releases by ext preference and libc within the same version. // The cache is already sorted by version (stable before beta, newest first), // so we only re-order within the same version string. @@ -41,42 +175,6 @@ function createFormatsSorter(formats) { }; } -async function getCachedReleases(pkg) { - // returns { download: '', releases: [{ version, date, os, arch, lts, channel, download}] } - - if (cache[pkg]) { - return cache[pkg]; - } - - let dataFile = `${LEGACY_CACHE_DIR}/${pkg}.json`; - - let json = await Fs.readFile(dataFile, 'utf8').catch(function (err) { - if (err.code === 'ENOENT') { - return null; - } - throw err; - }); - - if (!json) { - let empty = { download: '', releases: [] }; - cache[pkg] = empty; - return empty; - } - - let all; - try { - all = JSON.parse(json); - } catch (e) { - console.error(`error: ${dataFile}:\n\t${e.message}`); - let empty = { download: '', releases: [] }; - cache[pkg] = empty; - return empty; - } - - cache[pkg] = all; - return all; -} - async function filterReleases( all, { ver, os, arch, libc, lts, channel, formats, limit }, @@ -96,9 +194,7 @@ async function filterReleases( // freebsd, etc., but NOT windows). let isPosix = rel.os === 'posix' || rel.os.startsWith('posix_20'); let osMatches = - rel.os === '*' || - rel.os === os || - (isPosix && os !== 'windows'); + rel.os === '*' || rel.os === os || (isPosix && os !== 'windows'); if (!osMatches) { return false; } @@ -321,19 +417,17 @@ Releases.getReleases = function ({ }; if (require.main === module) { - return Releases - .getReleases({ - pkg: 'node', - ver: '', - os: 'macos', - arch: 'amd64', - lts: true, - libc: 'libc', - channel: 'stable', - formats: ['tar', 'exe', 'zip', 'xz', 'dmg', 'pkg'], - limit: 10, - }) - .then(function (all) { - console.info(JSON.stringify(all)); - }); + return Releases.getReleases({ + pkg: 'node', + ver: '', + os: 'macos', + arch: 'amd64', + lts: true, + libc: 'libc', + channel: 'stable', + formats: ['tar', 'exe', 'zip', 'xz', 'dmg', 'pkg'], + limit: 10, + }).then(function (all) { + console.info(JSON.stringify(all)); + }); }