import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { test } from "node:test"; import { BREAKING_HEADING, classifyBump, evaluateRelease, hasBreakingSection, nextVersions, parseVersion, } from "./check-changelog-semver.mjs"; const checkerPath = fileURLToPath(new URL("./check-changelog-semver.mjs", import.meta.url)); function changelogFor(version, { breaking } = {}) { return [ "# Changelog", "", "All notable changes to this package are documented in this file.", "", `## [${version}]`, "", "### Changed", "", "- something changed", "", ...(breaking ? [BREAKING_HEADING, "", "- something public was removed", ""] : []), ].join("\n"); } test("parseVersion rejects non-semver strings", () => { assert.throws(() => parseVersion("1.0"), /not a MAJOR.MINOR.PATCH version/); assert.throws(() => parseVersion("v1.0.0"), /not a MAJOR.MINOR.PATCH version/); }); test("nextVersions enumerates exactly the three legitimate next versions", () => { const prev = parseVersion("1.2.3"); assert.deepEqual(nextVersions(prev), { major: { major: 2, minor: 0, patch: 0 }, minor: { major: 1, minor: 3, patch: 0 }, patch: { major: 1, minor: 2, patch: 4 }, }); }); test("classifyBump identifies each lane and rejects everything else", () => { const prev = parseVersion("1.2.3"); assert.equal(classifyBump(prev, parseVersion("2.0.0")), "major"); assert.equal(classifyBump(prev, parseVersion("1.3.0")), "minor"); assert.equal(classifyBump(prev, parseVersion("1.2.4")), "patch"); assert.deepEqual(classifyBump(prev, parseVersion("1.2.3")), { invalid: "no-op" }); assert.deepEqual(classifyBump(prev, parseVersion("1.2.2")), { invalid: "reverse" }); assert.deepEqual(classifyBump(prev, parseVersion("1.2.5")), { invalid: "skip" }); assert.deepEqual(classifyBump(prev, parseVersion("1.4.0")), { invalid: "skip" }); assert.deepEqual(classifyBump(prev, parseVersion("3.0.0")), { invalid: "skip" }); }); test("hasBreakingSection scopes to the named version's section only", () => { const text = [ "# Changelog", "", "## [1.0.60]", "", "### Breaking", "", "- removed CultInspectorAssetPath", "", "## [1.0.59]", "", "### Added", "", "- something older, not breaking", "", ].join("\n"); assert.equal(hasBreakingSection(text, "1.0.60"), true); assert.equal(hasBreakingSection(text, "1.0.59"), false); assert.throws(() => hasBreakingSection(text, "0.9.0"), /no "## \[0\.9\.0\]" entry/); }); // Table-driven per the operator's required cases (spec: breaking+major=pass, // breaking+patch=fail, breaking+minor=fail, non-breaking+minor=pass, // 0.y.z breaking+minor=pass, skipped version=fail, downgrade=fail). const cases = [ { name: "breaking + major = pass", previousVersion: "1.0.60", version: "2.0.0", breaking: true, ok: true, }, { name: "breaking + patch = fail", previousVersion: "1.0.60", version: "1.0.61", breaking: true, ok: false, }, { name: "breaking + minor = fail", previousVersion: "1.0.60", version: "1.1.0", breaking: true, ok: false, }, { name: "non-breaking + minor = pass", previousVersion: "1.0.60", version: "1.1.0", breaking: false, ok: true, }, { name: "0.y.z breaking + minor = pass", previousVersion: "0.2.3", version: "0.3.0", breaking: true, ok: true, }, { name: "0.y.z breaking + patch = fail", previousVersion: "0.2.3", version: "0.2.4", breaking: true, ok: false, }, { name: "skipped version = fail", previousVersion: "1.0.60", version: "1.0.62", breaking: false, ok: false, }, { name: "downgrade = fail", previousVersion: "1.0.60", version: "1.0.59", breaking: false, ok: false, }, { name: "first release has nothing to compare against but still needs a changelog entry", previousVersion: null, version: "0.1.0", breaking: false, ok: true, }, ]; for (const testCase of cases) { test(`evaluateRelease: ${testCase.name}`, () => { const result = evaluateRelease({ packageName: "test-package", changelogText: changelogFor(testCase.version, { breaking: testCase.breaking }), version: testCase.version, previousVersion: testCase.previousVersion, }); assert.equal(result.ok, testCase.ok, result.reason); }); } test("evaluateRelease fails loudly when the version has no changelog entry at all", () => { const result = evaluateRelease({ packageName: "test-package", changelogText: changelogFor("1.0.0"), version: "1.2.0", previousVersion: "1.0.0", }); assert.equal(result.ok, false); assert.match(result.reason, /no "## \[1\.2\.0\]" entry/); }); test("evaluateRelease names package, previous version, new version, and the breaking heading on failure", () => { const result = evaluateRelease({ packageName: "org.gamecult.cultlib", changelogText: changelogFor("1.0.61", { breaking: true }), version: "1.0.61", previousVersion: "1.0.60", }); assert.equal(result.ok, false); assert.match(result.reason, /org\.gamecult\.cultlib/); assert.match(result.reason, /1\.0\.60/); assert.match(result.reason, /1\.0\.61/); assert.match(result.reason, new RegExp(BREAKING_HEADING.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); }); // --- CLI-level integration tests: exercise the git-backed previous-version // resolution and the "don't re-litigate an already-tagged release" skip, // which the pure evaluateRelease() tests above cannot reach. --- function withTempGitRepo(fn) { const dir = mkdtempSync(join(tmpdir(), "cultlib-semver-check-")); try { execFileSync("git", ["init", "-q"], { cwd: dir }); execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: dir }); execFileSync("git", ["config", "user.name", "Test"], { cwd: dir }); fn(dir); } finally { rmSync(dir, { recursive: true, force: true }); } } function runChecker(cwd, args) { return execFileSync(process.execPath, [checkerPath, ...args, "--cwd", cwd], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }); } function runCheckerExpectFailure(cwd, args) { try { runChecker(cwd, args); throw new Error("expected the checker to exit non-zero"); } catch (err) { assert.equal(err.status, 1); return err.stdout + err.stderr; } } test("CLI: resolves the previous version from git tags and passes a valid bump", () => { withTempGitRepo((dir) => { execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); const changelogPath = join(dir, "CHANGELOG.md"); writeFileSync(changelogPath, changelogFor("1.1.0", { breaking: false })); const output = runChecker(dir, ["--package", "widget", "--changelog", changelogPath, "--version", "1.1.0", "--tag-prefix", "widget"]); assert.match(output, /1\.1\.0 is a minor bump over 1\.0\.0/); }); }); test("CLI: fails when the changelog claims breaking under too small a bump", () => { withTempGitRepo((dir) => { execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); const changelogPath = join(dir, "CHANGELOG.md"); writeFileSync(changelogPath, changelogFor("1.0.1", { breaking: true })); const output = runCheckerExpectFailure(dir, ["--package", "widget", "--changelog", changelogPath, "--version", "1.0.1", "--tag-prefix", "widget"]); assert.match(output, /needs a major bump/); }); }); test("CLI: does not re-litigate a version already published as a tag", () => { withTempGitRepo((dir) => { execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); // 1.0.0 itself is a scar: its own changelog claims breaking under a // patch-looking first release. Rebuilding it locally (no new version) // must not fail just because history already shipped that way. execFileSync("git", ["tag", "widget-v0.9.0"], { cwd: dir }); const changelogPath = join(dir, "CHANGELOG.md"); writeFileSync(changelogPath, changelogFor("1.0.0", { breaking: true })); const output = runChecker(dir, ["--package", "widget", "--changelog", changelogPath, "--version", "1.0.0", "--tag-prefix", "widget"]); assert.match(output, /already published/); }); });