diff --git a/scripts/fetch-hive-history.js b/scripts/fetch-hive-history.js index 4e901af0..43394978 100644 --- a/scripts/fetch-hive-history.js +++ b/scripts/fetch-hive-history.js @@ -412,10 +412,10 @@ async function fetchContributorWeeklyStats(repos = FALLBACK_FACTORY_REPOS) { return finalizeContributorStats(acc); } -function loadHistory() { +function loadHistory(file = OUTPUT_FILE) { try { - if (fs.existsSync(OUTPUT_FILE)) { - return JSON.parse(fs.readFileSync(OUTPUT_FILE, "utf8")); + if (fs.existsSync(file)) { + return JSON.parse(fs.readFileSync(file, "utf8")); } } catch { // ignore corrupt file — start fresh @@ -571,8 +571,10 @@ module.exports = { createStatsAccumulator, extractMetrics, finalizeContributorStats, + loadHistory, MAX_WEEKLY_SERIES, MAX_WEEKS, registryHeaders, + safeNum, trackedProjectRepos, }; diff --git a/scripts/fetch-hive-history.test.js b/scripts/fetch-hive-history.test.js index c1be8025..7b293276 100644 --- a/scripts/fetch-hive-history.test.js +++ b/scripts/fetch-hive-history.test.js @@ -1,5 +1,8 @@ const test = require("node:test"); const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { tmpdir } = require("node:os"); const { accumulateRepoStats, @@ -7,8 +10,10 @@ const { createStatsAccumulator, extractMetrics, finalizeContributorStats, + loadHistory, MAX_WEEKS, registryHeaders, + safeNum, trackedProjectRepos, } = require("./fetch-hive-history.js"); @@ -434,3 +439,76 @@ test("extractMetrics survives wrong-typed containers without throwing", () => { assert.equal(metrics.mergedToday, undefined); assert.equal(metrics.medianMergeMins, undefined); }); + +// safeNum is the coercion gate behind every numeric field: it turns anything +// that is not a finite number into undefined so a chart plots a gap, not a +// garbage value. loadHistory is the read path for the tracked seed file — a +// regression here either silently drops history or crashes the run. + +test("safeNum keeps only finite numbers", () => { + assert.equal(safeNum(42), 42); + assert.equal(safeNum(0), 0); + assert.equal(safeNum(3.14), 3.14); +}); + +test("safeNum rejects non-numbers, NaN and +/- Infinity", () => { + for (const v of [ + NaN, + Infinity, + -Infinity, + "12", + null, + undefined, + {}, + [], + true, + { a: 1 }, + ]) { + assert.equal(safeNum(v), undefined, `expected undefined for ${String(v)}`); + } +}); + +test("loadHistory returns the seeded default when no file exists", () => { + const missing = path.join(tmpdir(), `hive-missing-${process.pid}.json`); + assert.equal(fs.existsSync(missing), false); + + const history = loadHistory(missing); + + assert.deepEqual(history, { + entries: [], + contributors: {}, + contributorsByRepo: {}, + contributorStats: {}, + contributorWeekStarts: [], + lastContributorFetch: null, + lastWeeklyStatsFetch: null, + }); +}); + +test("loadHistory parses a valid history file", () => { + const file = path.join(tmpdir(), `hive-valid-${process.pid}.json`); + const seed = { + entries: [{ t: 1, acmmLevel: 3 }], + contributors: { a: 5 }, + contributorWeekStarts: [1735689600], + }; + fs.writeFileSync(file, JSON.stringify(seed), "utf8"); + try { + assert.deepEqual(loadHistory(file), seed); + } finally { + fs.rmSync(file); + } +}); + +test("loadHistory starts fresh on a corrupt file instead of throwing", () => { + const file = path.join(tmpdir(), `hive-corrupt-${process.pid}.json`); + fs.writeFileSync(file, "{ not: valid json,,, ", "utf8"); + try { + const history = loadHistory(file); + assert.deepEqual(history.entries, []); + assert.deepEqual(history.contributors, {}); + assert.deepEqual(history.contributorWeekStarts, []); + } finally { + fs.rmSync(file); + } +});