|
| 1 | +import { describe, expect, it } from 'vitest'; |
| 2 | +import fs from 'node:fs'; |
| 3 | +import path from 'node:path'; |
| 4 | +import { fileURLToPath } from 'node:url'; |
| 5 | + |
| 6 | +// @ts-expect-error — plain-JS CI helper, intentionally untyped |
| 7 | +import { findMajorBumps, parseFrontmatterBumps } from '../check-changeset-no-major.mjs'; |
| 8 | + |
| 9 | +/** |
| 10 | + * objectui's major is not a count of its own breaking changes — it is pinned to |
| 11 | + * the `@objectstack` major so that "same major ⇒ compatible" holds across the |
| 12 | + * two repos (AGENTS.md §版本号策略). Since every publishable package sits in one |
| 13 | + * `fixed` group, ONE `major` in ONE changeset publishes all 39 of them as the |
| 14 | + * next major and breaks that pin. |
| 15 | + * |
| 16 | + * Four pending changesets had scored `major` (17 package entries) during the |
| 17 | + * 17.x line, which would have shipped 39 packages as 18.0.0 against an |
| 18 | + * `@objectstack` still on 17. The rule was written down and nothing ran it: |
| 19 | + * `ci.yml` and `lint.yml` both `paths-ignore` `.changeset/**`, so a |
| 20 | + * changeset-only PR started no workflow at all. |
| 21 | + * |
| 22 | + * These tests are the second lock. `.github/workflows/changeset-guard.yml` |
| 23 | + * catches it on the PR that adds the changeset; the repo-state test below |
| 24 | + * catches it in `pnpm test` even if that workflow is ever removed or its |
| 25 | + * trigger stops matching. |
| 26 | + */ |
| 27 | +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); |
| 28 | +const changesetDir = path.join(repoRoot, '.changeset'); |
| 29 | +const workflowDir = path.join(repoRoot, '.github/workflows'); |
| 30 | + |
| 31 | +describe('parseFrontmatterBumps', () => { |
| 32 | + it('reads the package/bump pairs out of the frontmatter block', () => { |
| 33 | + const source = [ |
| 34 | + '---', |
| 35 | + '"@object-ui/layout": minor', |
| 36 | + "'@object-ui/fields': patch", |
| 37 | + '@object-ui/core: minor', |
| 38 | + '---', |
| 39 | + '', |
| 40 | + 'Body text.', |
| 41 | + ].join('\n'); |
| 42 | + |
| 43 | + expect(parseFrontmatterBumps(source)).toEqual([ |
| 44 | + { pkg: '@object-ui/layout', bump: 'minor', line: 2 }, |
| 45 | + { pkg: '@object-ui/fields', bump: 'patch', line: 3 }, |
| 46 | + { pkg: '@object-ui/core', bump: 'minor', line: 4 }, |
| 47 | + ]); |
| 48 | + }); |
| 49 | + |
| 50 | + it('stops at the closing `---`, so prose about a bump is not a bump', () => { |
| 51 | + // Changeset bodies in this repo discuss the major/minor call explicitly — |
| 52 | + // and a body may hold a fenced YAML example. Neither may register as a |
| 53 | + // declaration, or the guard would fail on a changeset that is obeying it. |
| 54 | + const source = [ |
| 55 | + '---', |
| 56 | + '"@object-ui/layout": minor', |
| 57 | + '---', |
| 58 | + '', |
| 59 | + 'Scored `minor`, not `major`, per the fixed-group rule.', |
| 60 | + '', |
| 61 | + '```yaml', |
| 62 | + '"@object-ui/layout": major', |
| 63 | + '```', |
| 64 | + ].join('\n'); |
| 65 | + |
| 66 | + expect(parseFrontmatterBumps(source)).toEqual([ |
| 67 | + { pkg: '@object-ui/layout', bump: 'minor', line: 2 }, |
| 68 | + ]); |
| 69 | + expect(findMajorBumps([{ file: 'x.md', source }])).toEqual([]); |
| 70 | + }); |
| 71 | + |
| 72 | + it('returns nothing for a file with no frontmatter', () => { |
| 73 | + expect(parseFrontmatterBumps('# Just a readme\n')).toEqual([]); |
| 74 | + }); |
| 75 | +}); |
| 76 | + |
| 77 | +describe('findMajorBumps', () => { |
| 78 | + it('reports every major entry with a file:line to jump to', () => { |
| 79 | + const source = ['---', '"@object-ui/auth": major', '"@object-ui/react": minor', '---'].join( |
| 80 | + '\n' |
| 81 | + ); |
| 82 | + |
| 83 | + expect(findMajorBumps([{ file: '.changeset/a.md', source }])).toEqual([ |
| 84 | + { file: '.changeset/a.md', pkg: '@object-ui/auth', line: 2 }, |
| 85 | + ]); |
| 86 | + }); |
| 87 | +}); |
| 88 | + |
| 89 | +describe('the repository itself', () => { |
| 90 | + it('has no pending changeset declaring a `major` bump', () => { |
| 91 | + const changesets = fs |
| 92 | + .readdirSync(changesetDir) |
| 93 | + .filter((name) => name.endsWith('.md') && name !== 'README.md') |
| 94 | + .map((name) => ({ |
| 95 | + file: path.join('.changeset', name), |
| 96 | + source: fs.readFileSync(path.join(changesetDir, name), 'utf8'), |
| 97 | + })); |
| 98 | + |
| 99 | + const offenders = findMajorBumps(changesets) as { file: string; pkg: string; line: number }[]; |
| 100 | + |
| 101 | + expect( |
| 102 | + offenders.map(({ file, pkg, line }) => `${pkg} at ${file}:${line}`), |
| 103 | + 'A `major` publishes all 39 fixed-group packages as the next major, off the @objectstack ' + |
| 104 | + 'major objectui is pinned to. Score it `minor` and describe the break in the body — ' + |
| 105 | + 'see AGENTS.md §版本号策略.' |
| 106 | + ).toEqual([]); |
| 107 | + }); |
| 108 | +}); |
| 109 | + |
| 110 | +describe('changeset-guard.yml', () => { |
| 111 | + const guard = fs.readFileSync(path.join(workflowDir, 'changeset-guard.yml'), 'utf8'); |
| 112 | + |
| 113 | + it('runs the guard script', () => { |
| 114 | + expect(guard).toContain('node scripts/check-changeset-no-major.mjs'); |
| 115 | + }); |
| 116 | + |
| 117 | + it('triggers on `.changeset/**` — the paths every other workflow ignores', () => { |
| 118 | + // The inverse pin. `ci.yml` cannot host this check: a changeset-only PR |
| 119 | + // matches its `paths-ignore` twice over (`**/*.md` and `.changeset/**`), so |
| 120 | + // no job in it would ever run. If that ever stops being true, this test |
| 121 | + // fails and the separate workflow can be folded back in. |
| 122 | + expect(guard).toMatch(/paths:\s*\n\s*- '\.changeset\/\*\*'/); |
| 123 | + |
| 124 | + const ci = fs.readFileSync(path.join(workflowDir, 'ci.yml'), 'utf8'); |
| 125 | + expect(ci).toContain("paths-ignore:"); |
| 126 | + expect(ci).toContain("- '**/*.md'"); |
| 127 | + }); |
| 128 | +}); |
0 commit comments