|
| 1 | +import { describe, expect, test } from "bun:test"; |
| 2 | +import { |
| 3 | + categorySlugSort, |
| 4 | + compareVersionsDesc, |
| 5 | + isReleased, |
| 6 | + parsePullRequests, |
| 7 | + prepareForGitHub, |
| 8 | + pullRequestsToMarkdown, |
| 9 | +} from "./changelog"; |
| 10 | + |
| 11 | +const SITE = "https://terragrunt.gruntwork.io"; |
| 12 | + |
| 13 | +describe("compareVersionsDesc", () => { |
| 14 | + test("orders semver versions descending", () => { |
| 15 | + const sorted = ["v1.0.10", "v1.0.2", "v1.0.0"].sort(compareVersionsDesc); |
| 16 | + expect(sorted).toEqual(["v1.0.10", "v1.0.2", "v1.0.0"]); |
| 17 | + }); |
| 18 | + |
| 19 | + test("non-version strings sort before semver versions", () => { |
| 20 | + const sorted = ["v1.0.0", "draft", "v0.99.0"].sort(compareVersionsDesc); |
| 21 | + expect(sorted[0]).toBe("draft"); |
| 22 | + }); |
| 23 | +}); |
| 24 | + |
| 25 | +describe("isReleased", () => { |
| 26 | + test("returns true when version is at or below latest", () => { |
| 27 | + expect(isReleased("v1.0.3", "1.0.3")).toBe(true); |
| 28 | + expect(isReleased("v1.0.0", "1.0.3")).toBe(true); |
| 29 | + }); |
| 30 | + |
| 31 | + test("returns false when version is newer than latest", () => { |
| 32 | + expect(isReleased("v1.0.4", "1.0.3")).toBe(false); |
| 33 | + }); |
| 34 | + |
| 35 | + test("returns false for non-semver tags", () => { |
| 36 | + expect(isReleased("draft", "1.0.3")).toBe(false); |
| 37 | + }); |
| 38 | +}); |
| 39 | + |
| 40 | +describe("categorySlugSort", () => { |
| 41 | + test("uses the canonical category order", () => { |
| 42 | + const slugs = ["bug-fixes", "breaking-changes", "new-features"].sort(categorySlugSort); |
| 43 | + expect(slugs).toEqual(["breaking-changes", "new-features", "bug-fixes"]); |
| 44 | + }); |
| 45 | +}); |
| 46 | + |
| 47 | +describe("parsePullRequests", () => { |
| 48 | + test("returns empty array on null body", () => { |
| 49 | + expect(parsePullRequests(null)).toEqual([]); |
| 50 | + }); |
| 51 | + |
| 52 | + test("groups items under their conventional type and stops at next h2", () => { |
| 53 | + const body = [ |
| 54 | + "## What's Changed", |
| 55 | + "* feat: add a thing by @alice in https://github.com/o/r/pull/1", |
| 56 | + "* fix(parser): tighten regex by @bob in https://github.com/o/r/pull/2", |
| 57 | + "* feat!: rip out the old API by @carol in https://github.com/o/r/pull/3", |
| 58 | + "* docs: tidy README by @dan in https://github.com/o/r/pull/4", |
| 59 | + "", |
| 60 | + "## New Contributors", |
| 61 | + "* should not be parsed by @eve in https://github.com/o/r/pull/99", |
| 62 | + ].join("\n"); |
| 63 | + |
| 64 | + const groups = parsePullRequests(body); |
| 65 | + const labels = groups.map((g) => g.type.key); |
| 66 | + |
| 67 | + expect(labels).toEqual(["breaking", "feat", "fix", "docs"]); |
| 68 | + expect(groups[1].items[0].prNumber).toBe(1); |
| 69 | + expect(groups[0].items[0].prNumber).toBe(3); |
| 70 | + expect(groups.flatMap((g) => g.items.map((i) => i.author))).not.toContain("eve"); |
| 71 | + }); |
| 72 | + |
| 73 | + test("ignores text outside the What's Changed section", () => { |
| 74 | + const body = [ |
| 75 | + "Intro paragraph", |
| 76 | + "* fix: should not be picked up by @x in https://github.com/o/r/pull/1", |
| 77 | + "## What's Changed", |
| 78 | + "* feat: real entry by @y in https://github.com/o/r/pull/2", |
| 79 | + ].join("\n"); |
| 80 | + |
| 81 | + const groups = parsePullRequests(body); |
| 82 | + expect(groups).toHaveLength(1); |
| 83 | + expect(groups[0].items[0].author).toBe("y"); |
| 84 | + }); |
| 85 | +}); |
| 86 | + |
| 87 | +describe("pullRequestsToMarkdown", () => { |
| 88 | + test("formats grouped items with author and PR links", () => { |
| 89 | + const md = pullRequestsToMarkdown( |
| 90 | + parsePullRequests( |
| 91 | + [ |
| 92 | + "## What's Changed", |
| 93 | + "* feat: add a thing by @alice in https://github.com/o/r/pull/1", |
| 94 | + ].join("\n"), |
| 95 | + ), |
| 96 | + ); |
| 97 | + |
| 98 | + expect(md).toContain("## Pull Requests"); |
| 99 | + expect(md).toContain("### ✨ Features"); |
| 100 | + expect(md).toContain("[@alice](https://github.com/alice)"); |
| 101 | + expect(md).toContain("[#1](https://github.com/o/r/pull/1)"); |
| 102 | + }); |
| 103 | +}); |
| 104 | + |
| 105 | +describe("prepareForGitHub", () => { |
| 106 | + test("rewrites root-relative links to absolute URLs", () => { |
| 107 | + const out = prepareForGitHub("See [the docs](/features/x).", SITE); |
| 108 | + expect(out).toBe(`See [the docs](${SITE}/features/x).`); |
| 109 | + }); |
| 110 | + |
| 111 | + test("flattens entry and category headings to h2 and strips horizontal rules", () => { |
| 112 | + // The wrapping page renders categories as `### Label` and entries as |
| 113 | + // `#### Title`. The two-step rewrite below collapses both to `##`, since |
| 114 | + // pass two also catches the `###` produced by pass one. GitHub releases |
| 115 | + // render `##` as the largest distinguishable section heading. |
| 116 | + const out = prepareForGitHub( |
| 117 | + ["### ✨ New Features", "", "---", "", "#### Did a thing", "", "Body."].join("\n"), |
| 118 | + SITE, |
| 119 | + ); |
| 120 | + expect(out).toBe(["## ✨ New Features", "", "## Did a thing", "", "Body."].join("\n")); |
| 121 | + }); |
| 122 | + |
| 123 | + test("strips MDX import lines", () => { |
| 124 | + const out = prepareForGitHub( |
| 125 | + [ |
| 126 | + "import { Aside } from '@astrojs/starlight/components'", |
| 127 | + "", |
| 128 | + "#### Title", |
| 129 | + "", |
| 130 | + "Body.", |
| 131 | + ].join("\n"), |
| 132 | + SITE, |
| 133 | + ); |
| 134 | + expect(out).toBe(["## Title", "", "Body."].join("\n")); |
| 135 | + }); |
| 136 | + |
| 137 | + test("converts a tip Aside with a title to a GitHub TIP alert", () => { |
| 138 | + const out = prepareForGitHub( |
| 139 | + ['<Aside type="tip" title="Heads up">', "Be aware.", "</Aside>"].join("\n"), |
| 140 | + SITE, |
| 141 | + ); |
| 142 | + expect(out).toBe(["> [!TIP]", "> **Heads up**", ">", "> Be aware."].join("\n")); |
| 143 | + }); |
| 144 | + |
| 145 | + test("uses NOTE for default Aside type and omits title line when absent", () => { |
| 146 | + const out = prepareForGitHub("<Aside>Just a note.</Aside>", SITE); |
| 147 | + expect(out).toBe(["> [!NOTE]", "> Just a note."].join("\n")); |
| 148 | + }); |
| 149 | + |
| 150 | + test("maps Starlight caution and danger to WARNING and CAUTION", () => { |
| 151 | + const caution = prepareForGitHub('<Aside type="caution">be careful</Aside>', SITE); |
| 152 | + const danger = prepareForGitHub('<Aside type="danger">stop</Aside>', SITE); |
| 153 | + expect(caution).toContain("> [!WARNING]"); |
| 154 | + expect(danger).toContain("> [!CAUTION]"); |
| 155 | + }); |
| 156 | + |
| 157 | + test("transforms multiple Asides independently", () => { |
| 158 | + const out = prepareForGitHub( |
| 159 | + ['<Aside type="tip">first</Aside>', "", '<Aside type="caution">second</Aside>'].join("\n"), |
| 160 | + SITE, |
| 161 | + ); |
| 162 | + expect(out).toContain("> [!TIP]"); |
| 163 | + expect(out).toContain("> first"); |
| 164 | + expect(out).toContain("> [!WARNING]"); |
| 165 | + expect(out).toContain("> second"); |
| 166 | + }); |
| 167 | + |
| 168 | + test("preserves blank lines inside an Aside body as `>` continuation rows", () => { |
| 169 | + const out = prepareForGitHub( |
| 170 | + [ |
| 171 | + '<Aside type="note" title="Heads up">', |
| 172 | + "para one", |
| 173 | + "", |
| 174 | + "para two", |
| 175 | + "</Aside>", |
| 176 | + ].join("\n"), |
| 177 | + SITE, |
| 178 | + ); |
| 179 | + expect(out).toContain("> para one"); |
| 180 | + expect(out).toContain(">\n> para two"); |
| 181 | + }); |
| 182 | + |
| 183 | + test("collapses runs of blank lines to a single blank line", () => { |
| 184 | + const out = prepareForGitHub("a\n\n\n\nb", SITE); |
| 185 | + expect(out).toBe("a\n\nb"); |
| 186 | + }); |
| 187 | +}); |
0 commit comments