import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import {execFileSync} from 'node:child_process'; import {release,validVersion} from '../platform/release.mjs'; import {parseArgs} from '../platform/commands.mjs'; import {fixture,commit} from './helpers/build-fixture.mjs'; import {build} from '../platform/build.mjs'; import {verifyRelease} from '../platform/runtime.mjs'; const git=(root,args)=>execFileSync('git',args,{cwd:root,encoding:'utf8'}).trim(); test('release CLI and supported SemVer reject ambiguous flags and versions',()=>{ assert.equal(parseArgs(['release','0.0.6']).version,'0.0.6'); for(const args of [['release'],['release','0.0.6','--release'],['release','0.0.6','--yes'],['release','0.0.6','--backup','x']])assert.throws(()=>parseArgs(args)); for(const version of ['0.0.6','1.0.0-rc.1'])assert(validVersion(version)); for(const version of ['01.0.0','1.0','1.0.0-01','v1.0.0','../1','1.0.0+metadata'])assert(!validVersion(version)); }); test('release builds twice, commits only version, verifies checksums and tags final commit',t=>{ const root=fixture(t),start=git(root,['rev-parse','HEAD']),original=fs.readFileSync(path.join(root,'pack/pack.toml'),'utf8'); const result=release({root,version:'0.0.6',log:()=>{}}); assert.notEqual(result.commit,start);assert.equal(git(root,['rev-parse','v0.0.6^{}']),result.commit); assert.equal(git(root,['diff-tree','--no-commit-id','--name-only','-r',result.commit]),'pack/pack.toml'); assert.equal(git(root,['status','--porcelain']),'');assert.equal(verifyRelease(result.directory).manifest.git.dirty,false); assert(fs.readFileSync(path.join(root,'pack/pack.toml'),'utf8').includes('version = "0.0.6"')); assert(!fs.existsSync(path.join(root,'.tools/release.lock'))); assert(fs.readdirSync(path.join(root,'dist')).every(name=>!name.startsWith('.release-check-'))); assert.notEqual(original,fs.readFileSync(path.join(root,'pack/pack.toml'),'utf8')); }); test('dirty tree, existing tag and existing artifact refuse before changing source',t=>{ const root=fixture(t),file=path.join(root,'pack/pack.toml'),original=fs.readFileSync(file,'utf8'); fs.writeFileSync(path.join(root,'untracked.txt'),'dirty');assert.throws(()=>release({root,version:'0.0.6'}),/clean/);fs.unlinkSync(path.join(root,'untracked.txt')); git(root,['tag','v0.0.6']);assert.throws(()=>release({root,version:'0.0.6'}),/already exists/); fs.mkdirSync(path.join(root,'dist/minecraft-lab-neoforge-spike-0.0.7'));assert.throws(()=>release({root,version:'0.0.7'}),/already exists/); assert.equal(fs.readFileSync(file,'utf8'),original); }); test('candidate export failure restores exact source and leaves HEAD/tag/output untouched',t=>{ const root=fixture(t),file=path.join(root,'pack/pack.toml'),original=fs.readFileSync(file,'utf8'),head=git(root,['rev-parse','HEAD']); fs.writeFileSync(path.join(root,'.tools/state.json'),JSON.stringify({exportFailure:true})); assert.throws(()=>release({root,version:'0.0.6',log:()=>{}}),/Version restored/); assert.equal(fs.readFileSync(file,'utf8'),original);assert.equal(git(root,['rev-parse','HEAD']),head);assert.equal(git(root,['tag','--list']),'');assert.equal(git(root,['status','--porcelain']),''); assert(!fs.existsSync(path.join(root,'dist/minecraft-lab-neoforge-spike-0.0.6'))); }); test('final build failure retains clean version commit but never creates tag',t=>{ const root=fixture(t),head=git(root,['rev-parse','HEAD']);let calls=0; assert.throws(()=>release({root,version:'0.0.6',log:()=>{},buildRelease:options=>{if(++calls===2)throw new Error('final failure');return build(options);}}),/Version commit retained/); assert.notEqual(git(root,['rev-parse','HEAD']),head);assert.equal(git(root,['tag','--list']),'');assert.equal(git(root,['status','--porcelain']),''); }); test('release shared preparation lock refuses concurrent operation',t=>{ const root=fixture(t);fs.mkdirSync(path.join(root,'.tools/release.lock')); assert.throws(()=>release({root,version:'0.0.6'}),/locked/); }); test('commit hook rejection restores source and unstages only the release version',t=>{ const root=fixture(t),file=path.join(root,'pack/pack.toml'),original=fs.readFileSync(file,'utf8'),head=git(root,['rev-parse','HEAD']); const hook=path.join(root,'.git/hooks/pre-commit');fs.writeFileSync(hook,'#!/bin/sh\nexit 1\n',{mode:0o755}); assert.throws(()=>release({root,version:'0.0.6',log:()=>{}}),/Version restored/); assert.equal(fs.readFileSync(file,'utf8'),original);assert.equal(git(root,['status','--porcelain']),'');assert.equal(git(root,['rev-parse','HEAD']),head); }); test('concurrent source edit during candidate validation is preserved on failure',t=>{ const root=fixture(t),file=path.join(root,'pack/pack.toml'); assert.throws(()=>release({root,version:'0.0.6',log:()=>{},buildRelease:()=>{fs.appendFileSync(file,'\n# concurrent operator edit\n');throw new Error('candidate failed');}}),/Concurrent changes retained/); assert(fs.readFileSync(file,'utf8').endsWith('# concurrent operator edit\n'));assert.equal(git(root,['tag','--list']),''); });