// Tests for the atomic plugin-release helper. // Pure planner is the unit under test — the I/O shell (read files, git tag/commit/push) // is exercised by the CLI against the live tree, not here. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { planRelease, reconcileReadmeLabel, preflightErrors, applyRelease, shouldCreateTag } from './release-plugin.mjs'; import { classifyPlugin } from './check-versions.mjs'; const marketplace = () => ({ name: 'ktg-plugin-marketplace', plugins: [ { name: 'alpha', source: { source: 'url', url: 'https://x/alpha.git', ref: 'v1.0.0' }, description: 'a' }, { name: 'beta', source: { source: 'url', url: 'https://x/beta.git', ref: 'v2.3.0' }, description: 'b' }, ], }); const observed = (o = {}) => ({ pluginVersion: '1.1.0', readmeBadge: '1.1.0', tags: ['v1.1.0', 'v1.0.0'], ...o, }); test('READY: consistent plugin, tag exists, catalog ref behind → bump planned', () => { const p = planRelease({ marketplace: marketplace(), name: 'alpha', observed: observed() }); assert.equal(p.verdict, 'READY'); assert.equal(p.targetVersion, '1.1.0'); assert.equal(p.currentRef, 'v1.0.0'); assert.equal(p.newRef, 'v1.1.0'); assert.deepEqual(p.blockers, []); assert.equal(p.commitSubject, 'chore(catalog): bump alpha v1.0.0 -> v1.1.0'); }); test('READY plan bumps ONLY the target plugin and does not mutate the input', () => { const mkt = marketplace(); const p = planRelease({ marketplace: mkt, name: 'alpha', observed: observed() }); // input untouched assert.equal(mkt.plugins[0].source.ref, 'v1.0.0'); // output bumped on alpha only const out = p.newMarketplace.plugins; assert.equal(out.find(x => x.name === 'alpha').source.ref, 'v1.1.0'); assert.equal(out.find(x => x.name === 'beta').source.ref, 'v2.3.0'); }); test('READY plan is green by construction (post-bump classifyPlugin is OK)', () => { const o = observed(); const p = planRelease({ marketplace: marketplace(), name: 'alpha', observed: o }); const post = classifyPlugin({ name: 'alpha', catalogRef: p.newRef, pluginVersion: o.pluginVersion, readmeBadge: o.readmeBadge, tags: o.tags, }); assert.equal(post.status, 'OK'); }); test('explicit --version targets that version when consistent', () => { const o = observed({ pluginVersion: '1.1.0', readmeBadge: '1.1.0', tags: ['v1.1.0', 'v1.0.0'] }); const p = planRelease({ marketplace: marketplace(), name: 'alpha', observed: o, targetVersion: '1.1.0' }); assert.equal(p.verdict, 'READY'); assert.equal(p.newRef, 'v1.1.0'); }); test('NOOP: catalog ref already pins the target version', () => { const o = observed({ pluginVersion: '1.0.0', readmeBadge: '1.0.0', tags: ['v1.0.0'] }); const p = planRelease({ marketplace: marketplace(), name: 'alpha', observed: o }); assert.equal(p.verdict, 'NOOP'); assert.equal(p.newRef, 'v1.0.0'); assert.equal(p.newMarketplace, null); }); test('BLOCKED: target version has no git tag in the plugin repo', () => { const o = observed({ pluginVersion: '1.1.0', readmeBadge: '1.1.0', tags: ['v1.0.0'] }); // no v1.1.0 const p = planRelease({ marketplace: marketplace(), name: 'alpha', observed: o }); assert.equal(p.verdict, 'BLOCKED'); assert.ok(p.blockers.some(b => /tag v1\.1\.0/.test(b) && /not found|tag the plugin/.test(b))); assert.equal(p.newMarketplace, null); }); test('BLOCKED: plugin.json version != target (asked to release an undeclared version)', () => { const o = observed({ pluginVersion: '1.1.0', readmeBadge: '1.1.0', tags: ['v1.2.0', 'v1.1.0'] }); const p = planRelease({ marketplace: marketplace(), name: 'alpha', observed: o, targetVersion: '1.2.0' }); assert.equal(p.verdict, 'BLOCKED'); assert.ok(p.blockers.some(b => /plugin\.json/.test(b) && /1\.1\.0/.test(b))); }); test('BLOCKED: README badge disagrees with plugin.json (internal corruption)', () => { const o = observed({ pluginVersion: '1.1.0', readmeBadge: '1.0.0', tags: ['v1.1.0'] }); const p = planRelease({ marketplace: marketplace(), name: 'alpha', observed: o }); assert.equal(p.verdict, 'BLOCKED'); assert.ok(p.blockers.some(b => /badge/i.test(b))); }); test('BLOCKED: plugin not present in the catalog', () => { const p = planRelease({ marketplace: marketplace(), name: 'ghost', observed: observed() }); assert.equal(p.verdict, 'BLOCKED'); assert.ok(p.blockers.some(b => /not in (the )?catalog/i.test(b))); assert.equal(p.currentRef, null); }); test('BLOCKED: target version cannot be resolved (no --version, no plugin.json)', () => { const o = observed({ pluginVersion: null }); const p = planRelease({ marketplace: marketplace(), name: 'alpha', observed: o }); assert.equal(p.verdict, 'BLOCKED'); assert.ok(p.blockers.some(b => /resolve.*version|version.*not/i.test(b))); }); // --- reconcileReadmeLabel: keeps the catalog README label in lock-step with the ref --- test('reconcileReadmeLabel bumps only the target plugin heading label', () => { const readme = [ '### [Config-Audit](https://git.fromaitochitta.com/open/config-audit) `v5.5.0`', 'body text', '### [Voyage](https://git.fromaitochitta.com/open/voyage) `v5.1.1`', ].join('\n'); const out = reconcileReadmeLabel(readme, 'config-audit', 'v5.7.0'); assert.ok(out.includes('/open/config-audit) `v5.7.0`')); assert.ok(out.includes('/open/voyage) `v5.1.1`')); // untouched }); test('reconcileReadmeLabel leaves a trailing lang/flag badge intact', () => { const readme = '### [MS AI Architect](https://x/open/ms-ai-architect) `v1.15.0` `🇳🇴 Norwegian`'; const out = reconcileReadmeLabel(readme, 'ms-ai-architect', 'v1.16.0'); assert.equal(out, '### [MS AI Architect](https://x/open/ms-ai-architect) `v1.16.0` `🇳🇴 Norwegian`'); }); test('reconcileReadmeLabel returns null when the label already matches (no-op)', () => { const readme = '### [Config-Audit](https://x/open/config-audit) `v5.7.0`'; assert.equal(reconcileReadmeLabel(readme, 'config-audit', 'v5.7.0'), null); }); test('reconcileReadmeLabel returns null when the plugin has no heading', () => { const readme = '### [Other](https://x/open/other) `v1.0.0`'; assert.equal(reconcileReadmeLabel(readme, 'ghost', 'v2.0.0'), null); }); // --- pre-flight gate: check-versions must run BEFORE the writes, not after ----- // // The old order wrote marketplace.json + the README label first and only THEN ran the // gate (which throws on exit 1) — leaving a half-applied release in the working tree // that a parallel session could carry to the public remote. These tests pin the // ORDER, so they must assert on writes-not-taken, not just on a verdict string. const gateResult = (statuses) => { const results = Object.entries(statuses).map(([name, status]) => ({ name, status, findings: [] })); return { results, hasError: results.some(r => r.status === 'ERROR'), hasWarn: results.some(r => r.status === 'WARN'), failed: results.some(r => r.status === 'ERROR'), }; }; const fakeIo = (gate, readmeText = '### [Alpha](https://x/open/alpha) `v1.0.0`') => { const writes = []; return { writes, runGate: () => gate, readFileSync: () => readmeText, writeFileSync: (p) => { writes.push(p); }, }; }; const paths = { catalogDir: '/cat', mktPath: '/cat/.claude-plugin/marketplace.json', readmePath: '/cat/README.md' }; test('preflightErrors names every ERROR plugin, catalog-wide (not just the target)', () => { assert.deepEqual(preflightErrors(gateResult({ alpha: 'WARN', beta: 'ERROR', gamma: 'ERROR' })), ['beta', 'gamma']); assert.deepEqual(preflightErrors(gateResult({ alpha: 'OK', beta: 'WARN', gamma: 'SKIP' })), []); }); test('pre-flight ERROR aborts BEFORE any file is written (ordering, not just verdict)', () => { const plan = planRelease({ marketplace: marketplace(), name: 'alpha', observed: observed() }); const io = fakeIo(gateResult({ alpha: 'WARN', beta: 'ERROR' })); // ERROR on a DIFFERENT plugin const r = applyRelease({ plan, ...paths }, io); assert.equal(r.verdict, 'BLOCKED'); assert.deepEqual(r.preflightErrors, ['beta']); assert.deepEqual(io.writes, [], 'no file may be written when the gate is red'); }); test('pre-bump WARN is the NORMAL state and must NOT block the release', () => { const plan = planRelease({ marketplace: marketplace(), name: 'alpha', observed: observed() }); // Real classifier, pre-bump: catalog ref still v1.0.0 while plugin.json is 1.1.0. // This is exactly what a release looks like before it is applied — it MUST be WARN, // or gating on `failed`/`--strict` would brick every release. const pre = classifyPlugin({ name: 'alpha', catalogRef: plan.currentRef, pluginVersion: '1.1.0', readmeBadge: '1.1.0', tags: ['v1.0.0', 'v1.1.0'], }); assert.equal(pre.status, 'WARN'); const io = fakeIo({ results: [pre], hasError: false, hasWarn: true, failed: false }); const r = applyRelease({ plan, ...paths }, io); assert.equal(r.verdict, 'WROTE'); assert.deepEqual(io.writes, [paths.mktPath, paths.readmePath]); }); test('green pre-flight writes the ref and reports an already-correct README label as unchanged', () => { const plan = planRelease({ marketplace: marketplace(), name: 'alpha', observed: observed() }); const io = fakeIo(gateResult({ alpha: 'OK' }), '### [Alpha](https://x/open/alpha) `v1.1.0`'); const r = applyRelease({ plan, ...paths }, io); assert.equal(r.verdict, 'WROTE'); assert.equal(r.readme, 'unchanged'); assert.deepEqual(io.writes, [paths.mktPath]); }); test('a missing catalog README does not abort the ref bump', () => { const plan = planRelease({ marketplace: marketplace(), name: 'alpha', observed: observed() }); const io = fakeIo(gateResult({ alpha: 'OK' })); io.readFileSync = () => { throw new Error('ENOENT'); }; const r = applyRelease({ plan, ...paths }, io); assert.equal(r.verdict, 'WROTE'); assert.equal(r.readme, 'missing'); assert.deepEqual(io.writes, [paths.mktPath]); }); // --- shouldCreateTag: --create-tag is a WRITE, so it must obey --write -------- // // `--create-tag` mints AND PUSHES a tag to a public remote — the one genuinely // irreversible side effect in this helper. It used to fire on the documented // dry-run entry point (` --create-tag`, no --write), which contradicts // "dry-run by default": the tag was already public before the plan was printed. // The decision (2026-08-10) was to gate it on --write and leave the catalog-wide // pre-flight where it is — that gate can only prevent an EARLY tag, never a WRONG // one, since the preconditions below already make the tag correct by construction. const tagArgs = (o = {}) => ({ createTag: true, write: true, ...o }); test('shouldCreateTag: --create-tag --write on a consistent plugin with no tag → create', () => { assert.equal(shouldCreateTag(tagArgs(), observed({ tags: ['v1.0.0'] }), '1.1.0'), 'create'); }); test('shouldCreateTag: --create-tag WITHOUT --write never pushes (dry-run stays dry)', () => { assert.equal(shouldCreateTag(tagArgs({ write: false }), observed({ tags: ['v1.0.0'] }), '1.1.0'), 'dry-run'); }); test('shouldCreateTag: no --create-tag → skip, even with --write', () => { assert.equal(shouldCreateTag(tagArgs({ createTag: false }), observed({ tags: ['v1.0.0'] }), '1.1.0'), 'skip'); }); test('shouldCreateTag: tag already exists → skip (retry after a red gate is idempotent)', () => { assert.equal(shouldCreateTag(tagArgs(), observed({ tags: ['v1.0.0', 'v1.1.0'] }), '1.1.0'), 'skip'); }); test('shouldCreateTag: skips when the plugin is not internally consistent', () => { // plugin.json behind the target — planRelease would BLOCK anyway; never mint for it. assert.equal(shouldCreateTag(tagArgs(), observed({ pluginVersion: '1.0.0', readmeBadge: '1.0.0', tags: ['v1.0.0'] }), '1.1.0'), 'skip'); // README badge disagrees with plugin.json assert.equal(shouldCreateTag(tagArgs(), observed({ readmeBadge: '1.0.0', tags: ['v1.0.0'] }), '1.1.0'), 'skip'); // no target version resolved assert.equal(shouldCreateTag(tagArgs(), observed({ tags: ['v1.0.0'] }), null), 'skip'); // plugin repo absent (gitTags returned null) — nothing to tag assert.equal(shouldCreateTag(tagArgs(), observed({ tags: null }), '1.1.0'), 'skip'); }); test('shouldCreateTag: a null README badge is tolerated (badge-less plugin)', () => { assert.equal(shouldCreateTag(tagArgs(), observed({ readmeBadge: null, tags: ['v1.0.0'] }), '1.1.0'), 'create'); });