Files
stack/packages/cli/tests/gate-lane.test.mjs
2026-10-21 13:28:40 -05:00

126 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs';
import { execFileSync, spawnSync } from 'node:child_process';
import { join } from 'node:path';
const api = await import('../../../scripts/testing/gate-lane.mjs').catch(e => {
if (e.code !== 'ERR_MODULE_NOT_FOUND') throw e;
return {};
});
function fixture(t) {
const root = mkdtempSync('/mnt/storage/scratch/gt-');
// Node appends six characters, keeping this root at 30 bytes.
t.after(() => rmSync(root, { recursive: true, force: true }));
const checkout = join(root, 'checkout'); mkdirSync(checkout);
for (const [f,text] of [['package.json','{}'],['RELEASE','0.0.1\n']]) writeFileSync(join(checkout,f),text);
execFileSync('git',['init','-q',checkout]);
execFileSync('git',['-C',checkout,'add','.']);
execFileSync('git',['-C',checkout,'-c','user.name=Fixture','-c','user.email=f@invalid','commit','-qm','fixture']);
symlinkSync('/mnt/storage/src/mosaic-stack/node_modules',join(checkout,'node_modules'));
return {lane:'a',candidate:execFileSync('git',['-C',checkout,'rev-parse','HEAD'],{encoding:'utf8'}).trim(),checkout,out:join(root,'out'),tempRoot:root,stateRoot:join(root,'state'),grant:'fixture-grant',hostFacts:{unprivilegedPortStart:1024,ephemeral:[32768,60999],listeners:[]}};
}
test('preflight refuses unsafe lane roots, missing grants and live port 9 before side effects', t => {
assert.equal(typeof api.validateRun,'function','validateRun is required');
const f=fixture(t);
assert.equal(api.validateRun(f).lane,'a');
for(const [patch,reason] of [[{lane:'c'},/lane/],[{grant:''},/grant/],[{tempRoot:'/tmp'},/tmp/],[{tempRoot:join(f.tempRoot,'deeper')},/30|missing/],[{candidate:'a'.repeat(40)},/candidate/],[{hostFacts:{...f.hostFacts,listeners:[{address:'0.0.0.0',port:9}]}},/port.9/],[{hostFacts:{...f.hostFacts,unprivilegedPortStart:0}},/privileged/]]) assert.throws(()=>api.validateRun({...f,...patch}),reason);
const link=join(f.tempRoot,'link');symlinkSync('/tmp',link);assert.throws(()=>api.validateRun({...f,tempRoot:link}),/tmp/);
writeFileSync(join(f.checkout,'RELEASE'),'changed');assert.throws(()=>api.validateRun(f),/dirty/);
});
test('discovery preserves all package and script steps while ignoring non-file shell names', t => {
const f=fixture(t);mkdirSync(join(f.checkout,'packages'));
for(const p of ['z','queue','a']){mkdirSync(join(f.checkout,'packages',p,'tests'),{recursive:true});writeFileSync(join(f.checkout,'packages',p,'tests','one.test.mjs'),'');}
mkdirSync(join(f.checkout,'scripts'));writeFileSync(join(f.checkout,'scripts','test-z.sh'),'');writeFileSync(join(f.checkout,'scripts','test-a.sh'),'');mkdirSync(join(f.checkout,'scripts','test-directory.sh'));
const steps=api.discoverSteps(f.checkout);
assert.deepEqual(steps.map(s=>s.name),['pkg-a','pkg-z','script-a','script-z']);
assert.deepEqual(steps[0].argv,['node','--test','--test-concurrency=2','tests/one.test.mjs']);
});
test('a failed step stops the gate and records its nonzero exit without running the next step',async t=>{
assert.equal(typeof api.executeSteps,'function','executeSteps is required');
const f=fixture(t),events=[],out=f.out;mkdirSync(out);const marker=join(f.tempRoot,'should-not-exist');
const result=await api.executeSteps([{name:'first',cwd:f.checkout,argv:['bash','-c','exit 7']},{name:'second',cwd:f.checkout,argv:['touch',marker]}],{out,env:process.env,record:r=>events.push(r)});
assert.equal(result.rc,7);assert.equal(events.filter(e=>e.kind==='step').length,1);assert.ok(events.find(e=>e.kind==='step').wallMs>=0);
const {existsSync}=await import('node:fs');assert.equal(existsSync(marker),false);
});
test('a cancelled step stops its process group and records failure',async t=>{
const f=fixture(t);mkdirSync(f.out);const controller=new AbortController(),events=[];
const child=new URL('./fixtures/gate-lane-child.mjs',import.meta.url).pathname;
const result=await api.executeSteps([{name:'child',cwd:f.checkout,argv:[process.execPath,child,'wait',join(f.tempRoot,'started')]}],{out:f.out,env:process.env,record:e=>{events.push(e);if(e.kind==='child')setTimeout(()=>controller.abort(),100);},signal:controller.signal});
assert.notEqual(result.rc,0);const owned=events.find(e=>e.kind==='child').owner;const {sameProcess}=await import('../../../scripts/testing/lane-state.mjs');assert.equal(sameProcess(owned),false);
});
test('step receipts retain pass fail skipped counts and hashes of their actual logs',async t=>{
const f=fixture(t);mkdirSync(f.out);const events=[];
await api.executeSteps([{name:'counts',cwd:f.checkout,argv:[process.execPath,'-e',"console.log('ℹ pass 2\\nℹ fail 0\\nℹ skipped 1')"]}],{out:f.out,env:process.env,record:r=>events.push(r)});
const step=events.find(e=>e.kind==='step');assert.deepEqual(step.counts,{pass:2,fail:0,skipped:1});assert.match(step.logSha256,/^[a-f0-9]{64}$/);
});
test('preflight rejects a real 31-byte root, missing dependency tree, occupied band and missing inventory',t=>{
const f=fixture(t),long=f.tempRoot+'x';mkdirSync(long,{mode:0o700});t.after(()=>rmSync(long,{recursive:true,force:true}));assert.equal(Buffer.byteLength(long),31);
assert.throws(()=>api.validateRun({...f,tempRoot:long}),/30 bytes/);
assert.throws(()=>api.validateRun({...f,hostFacts:{...f.hostFacts,ephemeral:[60000,62000]}}),/overlaps/);
assert.throws(()=>api.validateRun({...f,hostFacts:{...f.hostFacts,listeners:[{address:'127.0.0.1',port:61005}]}}),/live listener/);
assert.throws(()=>api.validateRun({...f,hostFacts:{}}),/inventory/);
rmSync(join(f.checkout,'node_modules'));assert.throws(()=>api.validateRun(f),/node_modules/);
});
test('preflight refuses canonical and builder checkouts and an existing output',t=>{
const f=fixture(t);
for(const checkout of ['/mnt/storage/src/mosaic-stack',new URL('../../../',import.meta.url).pathname])assert.throws(()=>api.validateRun({...f,checkout}),/canonical or builder/);
mkdirSync(f.out);assert.throws(()=>api.validateRun(f),/output already exists/);
});
test('both candidates must carry every approved launcher file',t=>{
const f=fixture(t),other=join(f.tempRoot,'second');mkdirSync(other);
for(const checkout of [f.checkout,other]){
const target=join(checkout,'scripts','testing');fsExtra.cpSync(new URL('../../../scripts/testing',import.meta.url),target,{recursive:true});
api.validateTooling(checkout);
}
fsExtra.appendFileSync(join(other,'scripts/testing/lane-state.mjs'),'\n// changed\n');
assert.throws(()=>api.validateTooling(other),/tooling differs/);
fsExtra.rmSync(join(f.checkout,'scripts/testing/lane-fixtures.mjs'));
assert.throws(()=>api.validateTooling(f.checkout),/tooling differs/);
});
test('the 92-byte socket example passes and an over-107-byte pathname refuses',()=>{
const root='/mnt/storage/scratch/gt-ABCDEF';assert.equal(Buffer.byteLength(root),30);
const socket=root+'/mosaic-cli-ABCDEF/data/launches/acme/r0123456789ab/tools.sock';
assert.equal(Buffer.byteLength(socket),92);assert.equal(stateApi.socketPath(socket),socket);
assert.throws(()=>stateApi.socketPath(socket+'x'.repeat(16)),/107 bytes/);
});
test('package/script duplicates stay and every supervised step clears NODE_TEST_CONTEXT',async t=>{
const f=fixture(t);mkdirSync(join(f.checkout,'packages/cli/tests'),{recursive:true});mkdirSync(join(f.checkout,'scripts'));
writeFileSync(join(f.checkout,'packages/cli/tests/repeated.test.mjs'),'');writeFileSync(join(f.checkout,'scripts/test-cli.sh'),'node --test packages/cli/tests/repeated.test.mjs\n');
const steps=api.discoverSteps(f.checkout);assert.deepEqual(steps.map(s=>s.name),['pkg-cli','script-cli']);
const original=process.env.NODE_TEST_CONTEXT;process.env.NODE_TEST_CONTEXT='parent-context';
let env;try{env=api.cleanEnv(f,{project:'fixture'},'/fake/docker');}finally{if(original===undefined)delete process.env.NODE_TEST_CONTEXT;else process.env.NODE_TEST_CONTEXT=original;}
assert.equal(env.NODE_TEST_CONTEXT,undefined);assert.equal(env.HOME,process.env.HOME);
mkdirSync(f.out);const events=[];
for(const s of steps)s.argv=[process.execPath,'-e',"if(process.env.NODE_TEST_CONTEXT)process.exit(9);console.log('cleared')"];
const result=await api.executeSteps(steps,{out:f.out,env,record:e=>events.push(e)});
assert.equal(result.rc,0);assert.equal(events.filter(e=>e.kind==='step').length,2);
});
test('scoped ss loopback and wildcard port 9 listeners refuse',t=>{
const f=fixture(t);
for(const address of ['127.0.0.1%lo','0.0.0.0%lo','::%lo','::ffff:127.0.0.1%lo'])assert.throws(()=>api.validateRun({...f,hostFacts:{...f.hostFacts,listeners:[{address,port:9}]}}),/port 9/);
});
import * as fsExtra from 'node:fs';
import * as stateApi from '../../../scripts/testing/lane-state.mjs';
test('step children use legacy umask 022 while the supervisor retains 077', async t => {
const f=fixture(t);mkdirSync(f.out,{mode:0o700});const events=[],made=join(f.tempRoot,'step-file');
const original=process.umask(0o077);
try {
const result=await api.executeSteps([{name:'umask',cwd:f.checkout,argv:[process.execPath,'-e',`const fs=require('node:fs');console.log(process.umask().toString(8));fs.writeFileSync(${JSON.stringify(made)},'fixture',{mode:0o666});`]}],{out:f.out,env:process.env,record:r=>events.push(r)});
assert.equal(result.rc,0);assert.equal(process.umask(),0o077);
assert.equal(fsExtra.readFileSync(join(f.out,'umask.log'),'utf8').trim(),'22');
assert.equal(fsExtra.statSync(made).mode&0o777,0o644);
assert.equal(fsExtra.statSync(join(f.out,'umask.log')).mode&0o777,0o600);
} finally {process.umask(original);}
});
test('lane wrapper keeps its state directory and lock files private', t => {
const f=fixture(t),script=new URL('../../../scripts/testing/gate-lane.sh',import.meta.url).pathname;
const result=spawnSync('bash',[script,'recover','--state-root',f.stateRoot,'--run-id','000000000000','--grant','fixture-private-modes'],{encoding:'utf8'});
assert.equal(result.status,2,result.stdout+result.stderr);assert.match(result.stderr,/run not found/);
assert.equal(fsExtra.statSync(f.stateRoot).mode&0o777,0o700);
for(const name of ['a.lock','b.lock','image.lock'])assert.equal(fsExtra.statSync(join(f.stateRoot,name)).mode&0o777,0o600);
});