126 lines
10 KiB
JavaScript
126 lines
10 KiB
JavaScript
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);
|
||
});
|