Files
minecraft-lab/tests/deployment.test.mjs
T
2026-10-04 00:30:09 +07:00

123 lines
7.9 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fixture, seal } from './helpers/runtime-fixture.mjs';
import { runtime, verifyRelease, withRuntimeLock } from '../platform/runtime.mjs';
import { deployOnHost, deployableRelease, releaseName, sha256 } from '../platform/deployment.mjs';
import { sshTarget, shellQuote } from '../platform/ssh-deploy.mjs';
import { parseArgs } from '../platform/commands.mjs';
import { setup, addConfig } from './helpers/deployment-fixture.mjs';
test('first deployment publishes checked release, starts under lock and commits active only after success',async t=>{
const f=setup(t),state=await f.deploy();
assert.equal(state.first_deploy,true);assert.equal(state.backup_snapshot,null);assert.equal(state.phase,'complete');
assert.equal(fs.statSync(path.join(f.host,'releases',state.target_release)).mode&0o777,0o755);
assert.equal(fs.readFileSync(path.join(f.host,'active-release'),'utf8').trim(),'fixture-1.0.0');
assert(!fs.existsSync(path.join(f.host,'previous-release')));
assert(!fs.existsSync(path.join(f.host,'.mcpack-runtime.lock')));
assert(f.events().filter(e=>e.command==='up').every(e=>e.locked));
});
test('repeat same healthy release does not upload or restart',async t=>{
const f=setup(t);await f.deploy();
const result=await f.deploy(f.release,()=>{throw new Error('should not upload');});
assert.equal(result.noop,true);assert.equal(f.events().filter(e=>e.command==='up').length,1);
});
test('same name/version with another manifest is rejected before stopping',async t=>{
const f=setup(t);await f.deploy();
fs.appendFileSync(path.join(f.release,'fixture.mrpack'),'changed');seal(f.release);
await assert.rejects(f.deploy(),/immutable release collision/);
assert.equal(f.state().phase,'complete');assert.equal(f.events().filter(e=>e.command==='stop').length,0);
});
test('update requires verified backup, preserves unmanaged world and advances previous/active',async t=>{
const f=setup(t);await f.deploy();
fs.mkdirSync(path.join(f.host,'runtime/world'));fs.writeFileSync(path.join(f.host,'runtime/world/level.dat'),'valuable');
fs.mkdirSync(path.join(f.host,'runtime/mod-state'));fs.writeFileSync(path.join(f.host,'runtime/mod-state/progress'),'42');
const result=await f.deploy(f.next());
assert(result.backup_snapshot);assert(fs.existsSync(path.join(result.backup_snapshot,'runtime.tar.zst')));
assert.equal(fs.readFileSync(path.join(f.host,'runtime/world/level.dat'),'utf8'),'valuable');
assert.equal(fs.readFileSync(path.join(f.host,'runtime/mod-state/progress'),'utf8'),'42');
assert.equal(fs.readFileSync(path.join(f.host,'previous-release'),'utf8').trim(),'fixture-1.0.0');
assert.equal(fs.readFileSync(path.join(f.host,'active-release'),'utf8').trim(),'fixture-2.0.0');
});
test('manual managed edit fails before stop/uploaded config replacement, untracked configs retained',async t=>{
const f=setup(t);addConfig(f.release);await f.deploy();
fs.writeFileSync(path.join(f.host,'runtime/config/marker.txt'),'manual-edit');
fs.writeFileSync(path.join(f.host,'runtime/config/unmanaged.txt'),'keep');
await assert.rejects(f.deploy(f.next()),/Managed config conflicts/);
assert.equal(f.state().conflicts[0].reason,'managed-file-edited');
assert.match(f.state().conflicts[0].diff,/\+manual-edit/);
assert.match(f.state().conflicts[0].diff,/-original/);
assert.equal(f.events().filter(e=>e.command==='stop').length,0);
assert.equal(fs.readFileSync(path.join(f.host,'runtime/config/marker.txt'),'utf8'),'manual-edit');
assert.equal(fs.readFileSync(path.join(f.host,'runtime/config/unmanaged.txt'),'utf8'),'keep');
});
test('transfer interruption does not stop source and records failed recoverable state',async t=>{
const f=setup(t);await f.deploy();
await assert.rejects(f.deploy(f.next(),()=>{throw new Error('transfer failed');}),/transfer failed/);
assert.equal(f.state().phase,'failed');assert.equal(f.state().applied,false);
assert.equal(f.events().filter(e=>e.command==='stop').length,0);
assert(!fs.readdirSync(path.join(f.host,'releases')).some(n=>n.startsWith('.incoming')));
await assert.rejects(f.deploy(f.next('3.0.0')),/Unresolved deployment state/);
});
test('backup failure prohibits target startup and preserves source pointer/binding',async t=>{
const f=setup(t);await f.deploy();
const binding=fs.readFileSync(path.join(f.host,'.mcpack-runtime.json'),'utf8');
fs.writeFileSync(path.join(f.root,'.tools/tar'),'#!/bin/sh\nexit 42\n',{mode:0o755});
await assert.rejects(f.deploy(f.next()),/No snapshot published/);
assert.equal(f.state().applied,false);assert.equal(f.state().backup_snapshot,null);
assert.equal(fs.readFileSync(path.join(f.host,'.mcpack-runtime.json'),'utf8'),binding);
assert.equal(fs.readFileSync(path.join(f.host,'active-release'),'utf8').trim(),'fixture-1.0.0');
assert.equal(f.events().filter(e=>e.command==='up').length,1);
});
test('failed target startup leaves stopped target binding, source pointers and source backup',async t=>{
const f=setup(t);await f.deploy();
const next=f.next();fs.writeFileSync(path.join(f.root,'.tools/fail-start'),'fail');
await assert.rejects(f.deploy(next),/Target startup\/health failed/);
const state=f.state();assert.equal(state.phase,'failed');assert.equal(state.applied,true);assert(state.backup_snapshot);assert.equal(state.stopped_confirmed,true);
assert.equal(fs.readFileSync(path.join(f.host,'active-release'),'utf8').trim(),'fixture-1.0.0');
assert.equal(JSON.parse(fs.readFileSync(path.join(f.host,'.mcpack-runtime.json'))).release_manifest_sha256,verifyRelease(next).identity);
});
test('async shared lock remains held during upload and rejects concurrent lifecycle/deploy',async t=>{
const f=setup(t);let releaseUpload,entered;
const waiting=new Promise(resolve=>entered=resolve),block=new Promise(resolve=>releaseUpload=resolve);
const deployment=f.deploy(f.release,async stage=>{entered();await block;fs.cpSync(f.release,stage,{recursive:true});});
await waiting;
assert(fs.existsSync(path.join(f.host,'.mcpack-runtime.lock')));
assert.throws(()=>f.run(['up','-d']),/runtime operation/);
await assert.rejects(f.deploy(),/runtime operation/);
releaseUpload();await deployment;
});
test('dirty release and unsafe SSH arguments rejected; CLI deploy flags scoped',t=>{
const f=setup(t);const manifest=JSON.parse(fs.readFileSync(path.join(f.release,'manifest.json')));manifest.git.dirty=true;fs.writeFileSync(path.join(f.release,'manifest.json'),JSON.stringify(manifest));seal(f.release);
assert.throws(()=>deployableRelease(f.release),/clean Git/);
for(const [host,dir]of [['-oProxyCommand=bad','/srv/mc'],['user@host;touch bad','/srv/mc'],['alias','/'],['alias','/srv/../mc'],['alias','relative']])assert.throws(()=>sshTarget(host,dir));
assert.deepEqual(sshTarget('user@mc-host','/srv/minecraft'),{host:'user@mc-host',directory:'/srv/minecraft'});
assert.equal(shellQuote("a'b"),"'a'\\''b'");
assert.equal(parseArgs(['deploy','--artifact','/release']).artifact,'/release');
for(const args of [['deploy','--yes'],['deploy','--release'],['build','--artifact','/release']])assert.throws(()=>parseArgs(args));
});
test('manual edit incorporated exactly in clean target is accepted',async t=>{
const f=setup(t);addConfig(f.release);await f.deploy();
fs.writeFileSync(path.join(f.host,'runtime/config/marker.txt'),'manual-edit');
const next=f.next('2.0.0',release=>{
fs.writeFileSync(path.join(release,'server/config/marker.txt'),'manual-edit');
const manifest=JSON.parse(fs.readFileSync(path.join(release,'manifest.json')));
manifest.managed_files.server[0].hashes.sha256=sha256(path.join(release,'server/config/marker.txt'));
fs.writeFileSync(path.join(release,'manifest.json'),JSON.stringify(manifest));
});
assert.equal((await f.deploy(next)).phase,'complete');
assert.equal(fs.readFileSync(path.join(f.host,'runtime/config/marker.txt'),'utf8'),'manual-edit');
});