prepare-release.test.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. /**
  2. * Unit tests for `scripts/prepare-release.mjs`.
  3. *
  4. * The script reads CHANGELOG.md and package.json from `process.cwd()`,
  5. * so the tests run it via `node` in a temp directory after staging
  6. * those files. Real script, real fs — keeps the test honest about what
  7. * the workflow will actually do.
  8. */
  9. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  10. import { execFileSync } from 'node:child_process';
  11. import * as fs from 'node:fs';
  12. import * as path from 'node:path';
  13. import * as os from 'node:os';
  14. const SCRIPT = path.resolve(__dirname, '..', 'scripts', 'prepare-release.mjs');
  15. function run(cwd: string, ...args: string[]) {
  16. const out = execFileSync('node', [SCRIPT, ...args], { cwd, encoding: 'utf8' });
  17. return out.trim();
  18. }
  19. function setup(changelog: string, version = '1.2.3') {
  20. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'prepare-release-'));
  21. fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), changelog);
  22. fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version }));
  23. return dir;
  24. }
  25. const HEADER = `# Changelog
  26. Some intro.
  27. `;
  28. describe('prepare-release.mjs', () => {
  29. let dir: string;
  30. afterEach(() => {
  31. if (dir && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
  32. });
  33. describe('Case A: [version] block does not yet exist', () => {
  34. it('renames [Unreleased] to [version] - <today> and adds a fresh empty [Unreleased]', () => {
  35. dir = setup(
  36. HEADER +
  37. `## [Unreleased]\n\n### Added\n- New feature foo\n- New feature bar\n\n### Fixed\n- Fixed thing\n\n## [1.2.2] - 2026-01-01\n\n### Added\n- Old entry\n`,
  38. );
  39. const out = run(dir);
  40. expect(out).toMatch(/renamed \[Unreleased\] to \[1\.2\.3\]/);
  41. const result = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
  42. // [Unreleased] is now empty and at the top.
  43. expect(result).toMatch(/## \[Unreleased\]\n\n\n## \[1\.2\.3\]/);
  44. // [1.2.3] gets a date.
  45. expect(result).toMatch(/## \[1\.2\.3\] - \d{4}-\d{2}-\d{2}/);
  46. // Promoted content lives under [1.2.3].
  47. const v123Section = result.split('## [1.2.3]')[1].split('## [1.2.2]')[0];
  48. expect(v123Section).toContain('### Added');
  49. expect(v123Section).toContain('- New feature foo');
  50. expect(v123Section).toContain('- New feature bar');
  51. expect(v123Section).toContain('### Fixed');
  52. expect(v123Section).toContain('- Fixed thing');
  53. // [1.2.2] is intact.
  54. expect(result).toContain('## [1.2.2] - 2026-01-01');
  55. expect(result).toContain('- Old entry');
  56. });
  57. });
  58. describe('Case B: [version] already exists AND [Unreleased] has content', () => {
  59. it('merges Unreleased sub-sections into the matching [version] sub-sections', () => {
  60. // The v0.9.5 scenario verbatim: sparse [0.9.5] with two Fixed
  61. // entries, full [Unreleased] above it with Added + more Fixed.
  62. dir = setup(
  63. HEADER +
  64. `## [Unreleased]\n\n### Added\n- Big feature 1\n- Big feature 2\n\n### Fixed\n- Watcher fix\n- Worktree fix\n\n## [1.2.3] - 2026-02-02\n\n### Fixed\n- Old fix A\n- Old fix B\n\n## [1.2.2] - 2026-01-01\n`,
  65. );
  66. const out = run(dir);
  67. expect(out).toMatch(/merged \d+ Unreleased entries/);
  68. const result = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
  69. // [Unreleased] is emptied.
  70. const unrelSection = result.split('## [Unreleased]')[1].split('## [1.2.3]')[0];
  71. expect(unrelSection.trim()).toBe('');
  72. // [1.2.3] now has BOTH the original Fixed entries AND the
  73. // Unreleased Fixed entries, plus the new Added sub-section.
  74. const v123Section = result.split('## [1.2.3]')[1].split('## [1.2.2]')[0];
  75. expect(v123Section).toContain('### Added');
  76. expect(v123Section).toContain('- Big feature 1');
  77. expect(v123Section).toContain('- Big feature 2');
  78. expect(v123Section).toContain('### Fixed');
  79. expect(v123Section).toContain('- Old fix A');
  80. expect(v123Section).toContain('- Old fix B');
  81. expect(v123Section).toContain('- Watcher fix');
  82. expect(v123Section).toContain('- Worktree fix');
  83. // Date on [1.2.3] is preserved (we don't re-stamp it).
  84. expect(result).toContain('## [1.2.3] - 2026-02-02');
  85. });
  86. it('appends sub-sections that exist only in [Unreleased] to the [version] block', () => {
  87. dir = setup(
  88. HEADER +
  89. `## [Unreleased]\n\n### Security\n- CVE patch\n\n## [1.2.3] - 2026-02-02\n\n### Fixed\n- Old fix\n`,
  90. );
  91. run(dir);
  92. const result = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
  93. const v123 = result.split('## [1.2.3]')[1];
  94. expect(v123).toContain('### Fixed');
  95. expect(v123).toContain('- Old fix');
  96. expect(v123).toContain('### Security');
  97. expect(v123).toContain('- CVE patch');
  98. });
  99. });
  100. describe('Case C: [Unreleased] has no entries', () => {
  101. it('is a no-op when [Unreleased] is empty', () => {
  102. dir = setup(HEADER + `## [Unreleased]\n\n## [1.2.3] - 2026-02-02\n\n### Fixed\n- thing\n`);
  103. const before = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
  104. const out = run(dir);
  105. expect(out).toMatch(/nothing to do/);
  106. const after = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
  107. expect(after).toBe(before);
  108. });
  109. it('is a no-op when [Unreleased] has only sub-section headings with no bullets', () => {
  110. dir = setup(
  111. HEADER + `## [Unreleased]\n\n### Added\n\n### Fixed\n\n## [1.2.3] - 2026-02-02\n`,
  112. );
  113. const before = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
  114. const out = run(dir);
  115. expect(out).toMatch(/nothing to do/);
  116. const after = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
  117. expect(after).toBe(before);
  118. });
  119. });
  120. describe('idempotency', () => {
  121. it('running twice produces the same output as running once', () => {
  122. dir = setup(
  123. HEADER +
  124. `## [Unreleased]\n\n### Added\n- Thing A\n\n## [1.2.2] - 2026-01-01\n\n### Added\n- Old\n`,
  125. );
  126. run(dir); // first run promotes
  127. const afterFirst = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
  128. const out2 = run(dir); // second run should be a no-op
  129. const afterSecond = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
  130. expect(out2).toMatch(/nothing to do/);
  131. expect(afterSecond).toBe(afterFirst);
  132. });
  133. });
  134. describe('version source', () => {
  135. it('reads the target version from package.json by default', () => {
  136. dir = setup(HEADER + `## [Unreleased]\n\n### Added\n- x\n`, '9.9.9');
  137. run(dir);
  138. const result = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
  139. expect(result).toContain('## [9.9.9]');
  140. });
  141. it('accepts an explicit version argument that overrides package.json', () => {
  142. dir = setup(HEADER + `## [Unreleased]\n\n### Added\n- x\n`, '9.9.9');
  143. run(dir, '5.5.5');
  144. const result = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
  145. expect(result).toContain('## [5.5.5]');
  146. expect(result).not.toContain('## [9.9.9]');
  147. });
  148. });
  149. describe('extractor integration', () => {
  150. it('the resulting [version] block is what extract-release-notes.mjs would surface', () => {
  151. // Run prepare, then extract — confirm the output contains all the
  152. // promoted entries.
  153. dir = setup(
  154. HEADER +
  155. `## [Unreleased]\n\n### Added\n- Feature A\n- Feature B\n\n### Fixed\n- Bug fix\n\n## [1.2.2] - 2026-01-01\n`,
  156. );
  157. run(dir);
  158. const extractor = path.resolve(__dirname, '..', 'scripts', 'extract-release-notes.mjs');
  159. const notes = execFileSync('node', [extractor, '1.2.3'], { cwd: dir, encoding: 'utf8' });
  160. expect(notes).toContain('### Added');
  161. expect(notes).toContain('Feature A');
  162. expect(notes).toContain('Feature B');
  163. expect(notes).toContain('### Fixed');
  164. expect(notes).toContain('Bug fix');
  165. });
  166. });
  167. });