import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import {execFileSync} from 'node:child_process'; import {parseArgs,run,withLock,redact,redactObject,publicResult} from './core.mjs'; import {resolveContext,gitState,pull,EXPECTED_REMOTE} from './git.mjs'; import {freshArtifacts,snapshot,build,sourceFingerprint} from './build.mjs'; import {execute} from '../ops.mjs'; async function fixture(t) { const root=await fs.mkdtemp(path.join(os.tmpdir(),'ycode-ops-fixture-')); t.after(()=>fs.rm(root,{recursive:true,force:true})); const git=(...args)=>execFileSync('git',['-C',root,...args],{encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim(); git('init','-b','master');git('config','user.name','Ops Fixture');git('config','user.email','ops@example.invalid'); await fs.writeFile(path.join(root,'package.json'),JSON.stringify({name:'ycode'}));git('add','.');git('commit','-m','fixture'); return {root,git,ctx:await resolveContext({root,timeoutMs:10000})}; } test('strict parser accepts only fixed action and bounded flags',()=>{ assert.equal(parseArgs(['dev','--json']).action,'dev');assert.equal(parseArgs(['stop-dev']).action,'stop-dev'); for(const args of [['dev;touch','/tmp/pwn'],['dev','--','echo'],['dev','--root','relative'],['build','--timeout-ms','1'],['status','--json','--json'],['dev','--root','/tmp','--command','x']])assert.throws(()=>parseArgs(args)); }); test('shell metacharacters remain literal argv, not commands',async t=>{ const root=await fs.mkdtemp(path.join(os.tmpdir(),'ops-argv-'));t.after(()=>fs.rm(root,{recursive:true,force:true})); const value='; touch '+path.join(root,'injected'); const result=await run(process.execPath,['-e','process.stdout.write(process.argv[1])',value],{cwd:root}); assert.equal(result.stdout,value);await assert.rejects(fs.access(path.join(root,'injected'))); }); test('explicit root never silently picks parent or another repository',async t=>{ const {root}=await fixture(t);await fs.mkdir(path.join(root,'nested')); await assert.rejects(resolveContext({root:path.join(root,'nested')}),e=>e.code==='ROOT_NOT_TOPLEVEL'); await fs.writeFile(path.join(root,'package.json'),'{"name":"other"}');await assert.rejects(resolveContext({root}),e=>e.code==='WRONG_REPOSITORY'); }); test('dirty checkout blocks pull without discarding changes',async t=>{ const {root,ctx}=await fixture(t);await fs.writeFile(path.join(root,'local.txt'),'preserve'); await assert.rejects(pull(ctx),e=>e.code==='DIRTY_WORKTREE');assert.equal(await fs.readFile(path.join(root,'local.txt'),'utf8'),'preserve');assert.equal((await gitState(ctx)).dirty,true); }); test('pull refuses wrong branch and remote before network access',async t=>{ const {ctx,git}=await fixture(t);git('checkout','-b','other');await assert.rejects(pull(ctx),e=>e.code==='WRONG_BRANCH'); git('checkout','master');git('remote','add','gitea','https://example.invalid/other.git');await assert.rejects(pull(ctx),e=>e.code==='REMOTE_MISMATCH'); }); test('workspace lock excludes concurrent mutations and preserves unknown lock',async t=>{ const {ctx}=await fixture(t); await withLock(ctx,'test',async()=>{await assert.rejects(withLock(ctx,'test',async()=>{}),e=>e.code==='WORKSPACE_LOCKED');}); await withLock(ctx,'test',async()=>{ctx.keepLock=true;}); await assert.rejects(withLock(ctx,'test',async()=>{}),e=>e.code==='WORKSPACE_LOCKED'); }); test('timeout is bounded and records actual timeout',async()=>{ const result=await run(process.execPath,['-e','setInterval(()=>{},1000)'],{timeoutMs:50});assert.equal(result.timedOut,true); }); test('artifact freshness rejects stale outputs and accepts rewritten identical bytes',async t=>{ const {root}=await fixture(t);await fs.writeFile(path.join(root,'artifact'),'data');const before=await snapshot(root,f=>f.endsWith('artifact')); assert.equal(freshArtifacts(before,before,Date.now()).length,0); const after={artifact:{...before.artifact,mtimeMs:Date.now()+5}};assert.equal(freshArtifacts(before,after,Date.now()).length,1); }); test('logs redact credentials and JSON errors are machine-readable',async t=>{ assert.equal(redact('https://user:password@example.invalid/token'), 'https://[REDACTED]@example.invalid/token'); const {root}=await fixture(t);const result=await run(process.execPath,['-e','console.error("token=fixture-secret")'],{logDir:path.join(root,'logs')}); assert.doesNotMatch(await fs.readFile(result.logPath,'utf8'),/fixture-secret/); const failure=await execute(['dev','--command','bad']);assert.equal(failure.exitCode,2);assert.equal(failure.schemaVersion,1); }); test('package never invokes installer on unsupported platforms',async t=>{ if(process.platform==='win32'&&process.arch==='x64')return t.skip('native packaging covered by Windows smoke'); const {ctx}=await fixture(t);await assert.rejects(build(ctx,'package'),e=>e.code==='WINDOWS_X64_REQUIRED'); }); test('fresh local fetch fast-forwards exactly; ahead/divergent and fetch failure preserve checkout',async t=>{ if(process.platform==='win32')return t.skip('POSIX git shim fixture; Windows covered by native smoke'); const {root,ctx,git}=await fixture(t); const realGit=execFileSync('which',['git'],{encoding:'utf8'}).trim(); const remote=path.join(root,'.git','fixture-remote.git');execFileSync(realGit,['clone','--bare',root,remote],{stdio:'ignore'}); git('remote','add','gitea',EXPECTED_REMOTE); // Wrapper substitutes ONLY the fetch transport with a local bare fixture; remote identity remains production contract. const bin=path.join(root,'.git','fixture-bin');await fs.mkdir(bin); const shim=path.join(bin,'git');await fs.writeFile(shim,`#!/usr/bin/env node\nconst {spawnSync}=require('node:child_process'); const a=process.argv.slice(2);const i=a.indexOf('fetch');if(i>=0){if(process.env.OPS_FIXTURE_FETCH_FAIL==='1')process.exit(8);a[a.indexOf('gitea',i)]=process.env.OPS_FIXTURE_REMOTE;}const r=spawnSync(process.env.OPS_FIXTURE_GIT,a,{stdio:'inherit'});process.exit(r.status??1);\n`,{mode:0o755}); const saved={PATH:process.env.PATH,OPS_FIXTURE_REMOTE:process.env.OPS_FIXTURE_REMOTE,OPS_FIXTURE_GIT:process.env.OPS_FIXTURE_GIT}; Object.assign(process.env,{PATH:bin+path.delimiter+process.env.PATH,OPS_FIXTURE_REMOTE:remote,OPS_FIXTURE_GIT:realGit}); t.after(()=>{for(const [k,v]of Object.entries(saved))if(v===undefined)delete process.env[k];else process.env[k]=v;delete process.env.OPS_FIXTURE_FETCH_FAIL;}); const old=git('rev-parse','HEAD');await fs.writeFile(path.join(root,'next'),'next');git('add','.');git('commit','-m','remote next');const latest=git('rev-parse','HEAD');git('push',remote,'master');git('checkout','--detach',old);git('branch','-f','master',old);git('checkout','master'); const synced=await pull(ctx);assert.equal(synced.evidence.fetchedSha,latest);assert.equal(git('rev-parse','HEAD'),latest); await fs.writeFile(path.join(root,'ahead'),'ahead');git('add','.');git('commit','-m','local ahead');const ahead=git('rev-parse','HEAD'); await assert.rejects(pull(ctx),e=>e.code==='NOT_FAST_FORWARD');assert.equal(git('rev-parse','HEAD'),ahead); process.env.OPS_FIXTURE_FETCH_FAIL='1';await assert.rejects(pull(ctx),e=>e.code==='FETCH_FAILED');assert.equal(git('rev-parse','HEAD'),ahead); }); test('source fingerprint notices content changes even when HEAD and dirty boolean do not change',async t=>{const {ctx,root}=await fixture(t);const file=path.join(root,'local');await fs.writeFile(file,'one');const before=await sourceFingerprint(ctx);await fs.writeFile(file,'two');assert.notEqual(await sourceFingerprint(ctx),before);}); test('redaction preserves JSON syntax for embedded token error text',()=>{const encoded=JSON.stringify(redactObject({summary:'token=secret-value',next:1}));assert.equal(JSON.parse(encoded).next,1);assert.doesNotMatch(encoded,/secret-value/);}); test('dev-latest is unsupported before any Git mutation on non-Windows',async t=>{if(process.platform==='win32')return t.skip('non-Windows contract');const {root,git}=await fixture(t);const before=git('rev-parse','HEAD');const result=await execute(['dev-latest','--root',root]);assert.equal(result.status,'UNSUPPORTED');assert.equal(result.code,'WINDOWS_REQUIRED');assert.equal(git('rev-parse','HEAD'),before);}); test('oversized evidence is bounded for small-model JSON consumption',()=>{const result=publicResult({status:'FAIL',summary:'bounded',evidence:{data:'x'.repeat(100000)}});assert.equal(result.evidence.truncated,true);assert.ok(JSON.stringify(result).length<65536);});