import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { handleModuleVersion } from './module-version'; let dir: string; const MANIFEST = (kind: string | null) => [ 'celilo_contract: "1.0"', 'id: lunacycle', 'name: LunaCycle', 'version: 1.0.1 # keep this comment', ...(kind ? ['version_source:', ` kind: ${kind}`] : []), 'description: an app', ].join('\n'); async function seed(kind: string | null, changesets: Record = {}) { await writeFile(join(dir, 'manifest.yml'), MANIFEST(kind)); await mkdir(join(dir, '.changeset'), { recursive: true }); await writeFile(join(dir, '.changeset', 'README.md'), '# Changesets\n'); for (const [name, body] of Object.entries(changesets)) { await writeFile(join(dir, '.changeset', name), body); } } beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'celilo-modver-')); }); afterEach(async () => { await rm(dir, { recursive: true, force: true }); }); describe('handleModuleVersion (changeset kind)', () => { it('bumps the manifest version, writes CHANGELOG, consumes changesets', async () => { await seed('changeset', { 'a.md': '---\n"lunacycle": minor\n---\n\nAdded tasks view.\n', 'b.md': '---\n"lunacycle": patch\n---\n\nFixed a typo.\n', }); const res = await handleModuleVersion([dir], {}); expect(res.success).toBe(true); const manifest = await readFile(join(dir, 'manifest.yml'), 'utf-8'); expect(manifest).toContain('version: 1.1.0'); // max(minor,patch) = minor expect(manifest).toContain('# keep this comment'); // comment preserved? (line replaced, but others intact) expect(manifest).toContain('kind: changeset'); const changelog = await readFile(join(dir, 'CHANGELOG.md'), 'utf-8'); expect(changelog).toContain('# lunacycle'); expect(changelog).toContain('## 1.1.0'); expect(changelog).toContain('### Minor Changes'); expect(changelog).toContain('- Added tasks view.'); const remaining = (await readdir(join(dir, '.changeset'))).filter((f) => f !== 'README.md'); expect(remaining).toEqual([]); // both consumed }); it('prepends newest-on-top to an existing CHANGELOG', async () => { await seed('changeset', { 'a.md': '---\nlunacycle: patch\n---\nfix\n' }); await writeFile( join(dir, 'CHANGELOG.md'), '# lunacycle\n\n## 1.0.1\n\n### Patch Changes\n\n- old\n', ); await handleModuleVersion([dir], {}); const changelog = await readFile(join(dir, 'CHANGELOG.md'), 'utf-8'); expect(changelog.indexOf('## 1.0.2')).toBeLessThan(changelog.indexOf('## 1.0.1')); expect(changelog.split('# lunacycle').length).toBe(2); // header not duplicated }); it('is a clean no-op when no changeset targets the module', async () => { await seed('changeset', { 'a.md': '---\nnigredo: minor\n---\nother app\n' }); const res = await handleModuleVersion([dir], {}); expect(res.success).toBe(true); const manifest = await readFile(join(dir, 'manifest.yml'), 'utf-8'); expect(manifest).toContain('version: 1.0.1'); // unchanged // the non-targeting changeset is left in place expect((await readdir(join(dir, '.changeset'))).includes('a.md')).toBe(true); }); it('is a clean no-op with no changesets at all', async () => { await seed('changeset', {}); const res = await handleModuleVersion([dir], {}); expect(res.success).toBe(true); expect(await readFile(join(dir, 'manifest.yml'), 'utf-8')).toContain('version: 1.0.1'); }); it('fails loudly on a malformed changeset', async () => { await seed('changeset', { 'bad.md': 'no frontmatter here' }); const res = await handleModuleVersion([dir], {}); expect(res.success).toBe(false); }); }); describe('handleModuleVersion (other kinds)', () => { it('recipe kind is a no-op', async () => { await seed('recipe', { 'a.md': '---\nlunacycle: minor\n---\nx\n' }); const res = await handleModuleVersion([dir], {}); expect(res.success).toBe(true); expect(await readFile(join(dir, 'manifest.yml'), 'utf-8')).toContain('version: 1.0.1'); }); it('absent version_source defaults to recipe (no-op)', async () => { await seed(null, { 'a.md': '---\nlunacycle: minor\n---\nx\n' }); const res = await handleModuleVersion([dir], {}); expect(res.success).toBe(true); expect(await readFile(join(dir, 'manifest.yml'), 'utf-8')).toContain('version: 1.0.1'); }); it('pin kind is a not-yet-implemented notice (no change)', async () => { await seed('pin', {}); const res = await handleModuleVersion([dir], {}); expect(res.success).toBe(true); expect(res.success && res.message).toContain('pin'); }); });