Files
cloudflare__vinext/scripts/create-changeset.test.ts

509 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
import {
affectedPackages,
applyOverrides,
bumpToOverride,
changesetBodyMessage,
changesetFrontmatterBump,
type Commit,
type CommitOverride,
compareVersions,
decideGeneration,
findOverride,
latestTagVersionFromTags,
latestPackageTagVersionFromTags,
loadOverrides,
maxBump,
parseBumpFromSubject,
renderChangeset,
rewriteSubjectType,
shaFromChangesetFilename,
TYPE_BUMP,
} from "./create-changeset.mts";
describe("parseBumpFromSubject", () => {
it("maps feat → minor", () => {
expect(parseBumpFromSubject("feat: add thing")).toBe("minor");
expect(parseBumpFromSubject("feat(scope): add thing (#1)")).toBe("minor");
});
it("maps fix → patch", () => {
expect(parseBumpFromSubject("fix: correct thing")).toBe("patch");
expect(parseBumpFromSubject("fix(link): correct thing (#2)")).toBe("patch");
});
it("maps perf → patch", () => {
expect(parseBumpFromSubject("perf: speed up build")).toBe("patch");
});
it("maps revert → patch", () => {
expect(parseBumpFromSubject("revert: bad change")).toBe("patch");
});
it("treats `feat!` and `fix!` as major", () => {
expect(parseBumpFromSubject("feat!: drop node 18")).toBe("major");
expect(parseBumpFromSubject("fix(api)!: change signature")).toBe("major");
});
it("treats a BREAKING CHANGE footer as major regardless of type", () => {
expect(parseBumpFromSubject("fix: tweak", "body\n\nBREAKING CHANGE: removes API")).toBe(
"major",
);
expect(parseBumpFromSubject("chore: stuff", "BREAKING CHANGE: x")).toBe("major");
expect(parseBumpFromSubject("feat: add", "BREAKING-CHANGE: hyphen variant")).toBe("major");
});
it("skips non-release types", () => {
for (const type of ["chore", "docs", "test", "ci", "build", "refactor", "style"]) {
expect(parseBumpFromSubject(`${type}: something`)).toBeNull();
}
});
it("skips non-conventional subjects", () => {
expect(parseBumpFromSubject("just a normal commit")).toBeNull();
expect(parseBumpFromSubject("Merge branch main")).toBeNull();
expect(parseBumpFromSubject("")).toBeNull();
});
it("has a documented, stable type→bump table", () => {
expect(TYPE_BUMP.feat).toBe("minor");
expect(TYPE_BUMP.fix).toBe("patch");
expect(TYPE_BUMP.perf).toBe("patch");
expect(TYPE_BUMP.chore).toBeNull();
});
});
describe("maxBump", () => {
it("returns the higher-precedence bump", () => {
expect(maxBump("patch", "minor")).toBe("minor");
expect(maxBump("minor", "major")).toBe("major");
expect(maxBump("major", "patch")).toBe("major");
expect(maxBump("patch", "patch")).toBe("patch");
});
it("handles null operands", () => {
expect(maxBump(null, "patch")).toBe("patch");
expect(maxBump("minor", null)).toBe("minor");
expect(maxBump(null, null)).toBeNull();
});
});
describe("affectedPackages", () => {
const map = {
"packages/vinext": "vinext",
"packages/other": "other",
};
it("attributes a path to the single owning package", () => {
expect(affectedPackages(["packages/vinext/src/index.ts"], map)).toEqual(["vinext"]);
});
it("attributes paths across multiple packages", () => {
expect(affectedPackages(["packages/vinext/src/a.ts", "packages/other/src/b.ts"], map)).toEqual([
"other",
"vinext",
]);
});
it("returns nothing for paths outside any publishable package", () => {
expect(affectedPackages(["tests/foo.test.ts", "README.md"], map)).toEqual([]);
});
it("normalizes backslashes", () => {
expect(affectedPackages(["packages\\vinext\\src\\x.ts"], map)).toEqual(["vinext"]);
});
it("matches the most specific (longest) package dir first", () => {
const nested = {
"packages/vinext": "vinext",
"packages/vinext/plugins/sub": "vinext-sub",
};
expect(affectedPackages(["packages/vinext/plugins/sub/x.ts"], nested)).toEqual(["vinext-sub"]);
});
});
describe("compareVersions", () => {
it("orders stable versions", () => {
expect(compareVersions("0.0.55", "0.0.5")).toBe(1);
expect(compareVersions("0.0.5", "0.0.55")).toBe(-1);
expect(compareVersions("1.2.3", "1.2.3")).toBe(0);
expect(compareVersions("1.0.0", "0.9.9")).toBe(1);
});
it("orders prerelease versions using SemVer precedence", () => {
expect(compareVersions("0.3.0-beta.1", "0.3.0-beta.0")).toBe(1);
expect(compareVersions("0.3.0-beta.10", "0.3.0-beta.2")).toBe(1);
expect(compareVersions("0.3.0-beta.0", "0.3.0-beta.0")).toBe(0);
expect(compareVersions("0.3.0", "0.3.0-beta.1")).toBe(1);
expect(compareVersions("0.3.0-beta.1", "0.3.0")).toBe(-1);
expect(compareVersions("1.0.0-beta", "1.0.0-alpha.1")).toBe(1);
expect(compareVersions("1.0.0-alpha.1", "1.0.0-alpha.beta")).toBe(-1);
expect(compareVersions("1.0.0-beta-feature.1", "1.0.0-beta-feature.0")).toBe(1);
expect(compareVersions("1.0.0+build.2", "1.0.0+build.1")).toBe(0);
});
});
describe("decideGeneration (THE CORRECTNESS RULE)", () => {
it("skips when package.json version > tag version (release merged, awaiting publish)", () => {
expect(decideGeneration("0.1.0", "0.0.55").action).toBe("skip");
});
it("skips when a new prerelease version is awaiting publish", () => {
expect(decideGeneration("0.3.0-beta.1", "0.3.0-beta.0").action).toBe("skip");
});
it("generates when package.json version == tag version (normal accumulation)", () => {
expect(decideGeneration("0.0.55", "0.0.55").action).toBe("generate");
});
it("generates after the matching prerelease tag is published", () => {
expect(decideGeneration("0.3.0-beta.1", "0.3.0-beta.1").action).toBe("generate");
});
it("generates when there is no tag yet", () => {
expect(decideGeneration("0.0.1", null).action).toBe("generate");
});
it("generates when package.json somehow lags the tag (not a skip case)", () => {
// Only strictly-greater package version triggers the publish guard.
expect(decideGeneration("0.0.5", "0.0.55").action).toBe("generate");
});
});
describe("latestTagVersionFromTags (release range source)", () => {
const tags = [
"v0.0.54",
"v0.0.55",
"vinext@0.0.55",
"@vinext/cloudflare@1.0.0",
"@vinext/cloudflare@1.1.0",
"some-other-tag",
];
it("picks the highest scoped tag for a package with the `<name>@<version>` scheme", () => {
expect(latestTagVersionFromTags(tags, "@vinext/cloudflare")).toBe("1.1.0");
});
it("picks the latest prerelease tag using SemVer precedence", () => {
expect(
latestTagVersionFromTags(
["vinext@0.3.0-beta.2", "vinext@0.3.0-beta.10", "vinext@0.3.0-beta.1"],
"vinext",
),
).toBe("0.3.0-beta.10");
});
it("prefers a stable tag over prereleases of the same version", () => {
expect(
latestTagVersionFromTags(
["vinext@0.3.0-beta.1", "vinext@0.3.0", "vinext@0.3.0-beta.2"],
"vinext",
),
).toBe("0.3.0");
});
it("falls back to the legacy global `v<version>` tag for the root package", () => {
// vinext has both schemes here; scoped/global versions agree, highest wins.
expect(latestTagVersionFromTags(tags, "vinext")).toBe("0.0.55");
});
it("falls back to the latest legacy global `v` tag for a never-scoped new package", () => {
// The #1759 case: a brand-new package (no `<name>@<version>` tag of its own)
// still resolves a real range from the latest global `v` tag, instead of the
// non-existent `v<pkg.version>` ref that produced an empty changelog.
expect(latestTagVersionFromTags(tags, "@vinext/brand-new")).toBe("0.0.55");
});
it("returns null when there is no scoped or global tag at all (firstCommit fallback)", () => {
// No matching tag → null, so releaseRangeStart falls back to firstCommit().
expect(latestTagVersionFromTags([], "@vinext/cloudflare")).toBeNull();
expect(latestTagVersionFromTags(["some-other-tag"], "@vinext/cloudflare")).toBeNull();
});
});
describe("latestPackageTagVersionFromTags (publish guard source)", () => {
const tags = ["v0.0.55", "vinext@1.0.0-beta.1", "@vinext/cloudflare@1.0.0-beta.1"];
it("uses a package's own scoped tag", () => {
expect(latestPackageTagVersionFromTags(tags, "@vinext/cloudflare")).toBe("1.0.0-beta.1");
});
it("keeps legacy global tags for the root package", () => {
expect(latestPackageTagVersionFromTags(["v0.0.55"], "vinext")).toBe("0.0.55");
});
it("does not mistake a global tag for a new scoped package release", () => {
const tagVersion = latestPackageTagVersionFromTags(tags, "@vinext/types");
expect(tagVersion).toBeNull();
expect(decideGeneration("1.0.0-beta.1", tagVersion).action).toBe("generate");
});
});
describe("renderChangeset", () => {
it("renders frontmatter sorted by package and a bullet body", () => {
const out = renderChangeset({ vinext: "minor", other: "patch" }, [
"feat: a (#1)",
"fix: b (#2)",
]);
expect(out).toContain('"other": patch');
expect(out).toContain('"vinext": minor');
// other sorts before vinext in frontmatter
expect(out.indexOf('"other"')).toBeLessThan(out.indexOf('"vinext"'));
expect(out).toContain("- feat: a (#1)");
expect(out).toContain("- fix: b (#2)");
expect(out.startsWith("---\n")).toBe(true);
});
it("falls back to a placeholder body when there are no summary lines", () => {
const out = renderChangeset({ vinext: "patch" }, []);
expect(out).toContain("Automated changeset.");
});
});
describe("shaFromChangesetFilename", () => {
it("recognizes a SHA-named changeset (740 hex), lowercasing it", () => {
expect(shaFromChangesetFilename("6005541.md")).toBe("6005541");
expect(shaFromChangesetFilename("ABCDEF0123.md")).toBe("abcdef0123");
expect(shaFromChangesetFilename(`${"a".repeat(40)}.md`)).toBe("a".repeat(40));
});
it("ignores normal changesets, generated files, and docs", () => {
expect(shaFromChangesetFilename("tame-rabbits-sneeze.md")).toBeNull();
expect(shaFromChangesetFilename("auto-vinext_0.1.0-6005541.md")).toBeNull();
expect(shaFromChangesetFilename("README.md")).toBeNull();
expect(shaFromChangesetFilename("config.json")).toBeNull();
});
it("rejects hex strings outside the 740 length window or with non-hex chars", () => {
expect(shaFromChangesetFilename("abc12.md")).toBeNull(); // too short
expect(shaFromChangesetFilename(`${"a".repeat(41)}.md`)).toBeNull(); // too long
expect(shaFromChangesetFilename("abcdefg.md")).toBeNull(); // 'g' is not hex
});
});
describe("changesetFrontmatterBump", () => {
it("reads a single declared bump", () => {
expect(changesetFrontmatterBump('---\n"vinext": patch\n---\n\nbody')).toBe("patch");
});
it("takes the highest bump across multiple packages", () => {
expect(
changesetFrontmatterBump('---\n"vinext": patch\n"@vinext/cloudflare": minor\n---\n'),
).toBe("minor");
});
it("tolerates unquoted names and trailing whitespace", () => {
expect(changesetFrontmatterBump("---\nvinext: major \n---\n")).toBe("major");
});
it("only scans frontmatter, never the body", () => {
expect(changesetFrontmatterBump('---\n"vinext": patch\n---\n\nbumps to minor someday')).toBe(
"patch",
);
});
it("returns null for a package-less or frontmatter-less changeset (suppression)", () => {
expect(changesetFrontmatterBump("---\n---\n\nsuppress this commit")).toBeNull();
expect(changesetFrontmatterBump("no frontmatter here")).toBeNull();
});
});
describe("changesetBodyMessage", () => {
it("returns the body after the frontmatter, collapsed to one line", () => {
expect(changesetBodyMessage('---\n"vinext": patch\n---\n\nFix the thing (#9).')).toBe(
"Fix the thing (#9).",
);
});
it("collapses internal whitespace and newlines into single spaces", () => {
expect(changesetBodyMessage('---\n"vinext": patch\n---\n\nLine one\nLine two\n\n- a b')).toBe(
"Line one Line two - a b",
);
});
it("returns null for an empty body or missing frontmatter", () => {
expect(changesetBodyMessage('---\n"vinext": patch\n---\n')).toBeNull();
expect(changesetBodyMessage('---\n"vinext": patch\n---\n\n \n')).toBeNull();
expect(changesetBodyMessage("no frontmatter here")).toBeNull();
});
});
describe("bumpToOverride", () => {
it("maps bump level → conventional type for grouping", () => {
expect(bumpToOverride("patch")).toEqual({ type: "fix", breaking: false });
expect(bumpToOverride("minor")).toEqual({ type: "feat", breaking: false });
expect(bumpToOverride("major")).toEqual({ type: "feat", breaking: true });
});
});
describe("loadOverrides", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "vinext-overrides-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it("returns [] when the directory is absent", () => {
expect(loadOverrides(join(dir, "does-not-exist"))).toEqual([]);
});
it("derives overrides (type + message) from SHA-named changesets, ignoring everything else", () => {
writeFileSync(join(dir, "6005541.md"), '---\n"vinext": patch\n---\n\nWas only a fix (#9).');
writeFileSync(join(dir, "abcdef0123.md"), '---\n"vinext": minor\n---\n'); // no body
writeFileSync(join(dir, "deadbeef.md"), "---\n---\n\nSuppress this one."); // no bump
writeFileSync(join(dir, "tame-rabbits-sneeze.md"), '---\n"vinext": major\n---\n'); // normal changeset
writeFileSync(join(dir, "auto-vinext_0.1.0-6005541.md"), '---\n"vinext": minor\n---\n'); // generated
writeFileSync(join(dir, "README.md"), "# Changesets");
const overrides = loadOverrides(dir);
const byCommit = Object.fromEntries(overrides.map((o) => [o.commit, o]));
expect(Object.keys(byCommit).sort()).toEqual(["6005541", "abcdef0123", "deadbeef"]);
// patch → fix, body → message
expect(byCommit["6005541"]).toEqual({
commit: "6005541",
type: "fix",
message: "Was only a fix (#9).",
});
expect(byCommit.abcdef0123).toEqual({ commit: "abcdef0123", type: "feat" }); // minor → feat, no body
// no bump → suppress (chore). A suppressed commit is dropped from the
// changelog, so its body message is irrelevant and not captured.
expect(byCommit.deadbeef).toEqual({ commit: "deadbeef", type: "chore" });
});
});
describe("findOverride", () => {
const overrides: CommitOverride[] = [{ commit: "abc1234", type: "fix" }];
it("matches an exact SHA case-insensitively", () => {
expect(findOverride("abc1234", overrides)?.type).toBe("fix");
expect(findOverride("ABC1234", overrides)?.type).toBe("fix");
});
it("matches a full SHA by its stored prefix", () => {
expect(findOverride("abc1234def567890", overrides)?.type).toBe("fix");
});
it("returns null when nothing matches", () => {
expect(findOverride("def5678", overrides)).toBeNull();
// a SHA shorter than the stored prefix is not a prefix match
expect(findOverride("abc12", overrides)).toBeNull();
});
});
describe("rewriteSubjectType", () => {
it("swaps the type token, preserving scope, description, and PR ref", () => {
expect(rewriteSubjectType("feat(router): add streaming (#123)", "fix", false)).toBe(
"fix(router): add streaming (#123)",
);
});
it("drops a scope-less type cleanly", () => {
expect(rewriteSubjectType("feat: add thing", "fix", false)).toBe("fix: add thing");
});
it("adds a breaking marker when asked and drops a stale one when not", () => {
expect(rewriteSubjectType("feat(api): change", "fix", true)).toBe("fix(api)!: change");
expect(rewriteSubjectType("feat(api)!: change", "fix", false)).toBe("fix(api): change");
});
it("synthesizes a prefix for a non-conventional subject", () => {
expect(rewriteSubjectType("just a normal message", "fix", false)).toBe(
"fix: just a normal message",
);
});
it("replaces the description and drops the scope when given a message", () => {
expect(
rewriteSubjectType(
"feat(router): add streaming (#123)",
"fix",
false,
"correct prefetch (#9)",
),
).toBe("fix: correct prefetch (#9)");
// message + breaking
expect(rewriteSubjectType("feat(api): x", "feat", true, "drop the old API (#9)")).toBe(
"feat!: drop the old API (#9)",
);
// an empty message is ignored (falls back to preserving the subject)
expect(rewriteSubjectType("feat(router): add streaming (#123)", "fix", false, "")).toBe(
"fix(router): add streaming (#123)",
);
});
});
describe("applyOverrides", () => {
const commit = (sha: string, subject: string, body = ""): Commit => ({
sha,
subject,
body,
files: ["packages/vinext/src/index.ts"],
});
it("returns the same list when there are no overrides", () => {
const commits = [commit("abc1234", "feat: add thing")];
expect(applyOverrides(commits, [])).toBe(commits);
});
it("rewrites a matched commit's subject and leaves others untouched", () => {
const commits = [
commit("abc1234def", "feat(router): add streaming (#123)"),
commit("999aaaa", "fix(link): correct prefetch (#124)"),
];
const overrides: CommitOverride[] = [{ commit: "abc1234d", type: "fix" }];
const [first, second] = applyOverrides(commits, overrides);
expect(first.subject).toBe("fix(router): add streaming (#123)");
expect(second.subject).toBe("fix(link): correct prefetch (#124)");
});
it("demotes a feat to a patch bump (the headline use case)", () => {
const commits = [commit("abc1234", "feat(router): add streaming (#123)")];
const overrides: CommitOverride[] = [{ commit: "abc1234", type: "fix" }];
const [demoted] = applyOverrides(commits, overrides);
expect(parseBumpFromSubject(demoted.subject, demoted.body)).toBe("patch");
});
it("can suppress a commit entirely by reclassifying it as chore", () => {
const commits = [commit("abc1234", "feat: experimental thing")];
const overrides: CommitOverride[] = [{ commit: "abc1234", type: "chore" }];
const [suppressed] = applyOverrides(commits, overrides);
expect(parseBumpFromSubject(suppressed.subject, suppressed.body)).toBeNull();
});
it("clears the body so a stale BREAKING CHANGE footer no longer escalates the bump", () => {
const commits = [commit("abc1234", "feat: thing", "body\n\nBREAKING CHANGE: removes API")];
// Without the override this would be a major bump.
expect(parseBumpFromSubject(commits[0].subject, commits[0].body)).toBe("major");
const overrides: CommitOverride[] = [{ commit: "abc1234", type: "fix" }];
const [demoted] = applyOverrides(commits, overrides);
expect(demoted.body).toBe("");
expect(parseBumpFromSubject(demoted.subject, demoted.body)).toBe("patch");
});
it("honors an explicit breaking override (major)", () => {
const commits = [commit("abc1234", "fix: small thing")];
const overrides: CommitOverride[] = [{ commit: "abc1234", type: "feat", breaking: true }];
const [escalated] = applyOverrides(commits, overrides);
expect(escalated.subject).toBe("feat!: small thing");
expect(parseBumpFromSubject(escalated.subject, escalated.body)).toBe("major");
});
it("overrides the changelog message from the changeset body, keeping the demoted bump", () => {
const commits = [commit("abc1234", "feat(interception): sibling-style routes (#1804)")];
const overrides: CommitOverride[] = [
{ commit: "abc1234", type: "fix", message: "correct interception route matching (#1804)" },
];
const [overridden] = applyOverrides(commits, overrides);
// message replaces the description and drops the scope → plain bullet
expect(overridden.subject).toBe("fix: correct interception route matching (#1804)");
expect(parseBumpFromSubject(overridden.subject, overridden.body)).toBe("patch");
});
});