import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, symlinkSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; import test from 'node:test'; import { captureReleaseSource, verifyReleaseEvidence, validateSkipRequest, confinedPath } from '../release-gate-result.mjs'; import { coveragePlan, localSpecArgs } from '../full-local-coverage.mjs'; import { FULL_LOCAL_STAGES, newStageRecord, updateStageRecord, validateStages } from '../qa-stage-record.mjs'; const time = seconds => new Date(Date.UTC(2026,8,18,0,0,seconds)).toISOString(); function stages(ids, profile, start=1) { return {schemaVersion:1,profile,status:'PASS',exitCode:0,startedAt:time(start),endedAt:time(start+ids.length*2+1),stages:ids.map((id,index)=>({id,status:'PASS',exitCode:0,startedAt:time(start+index*2),endedAt:time(start+index*2+1)}))}; } function fixture(context) { const root=mkdtempSync(path.join(os.tmpdir(),'hyb-release-evidence-')); context.after(()=>rmSync(root,{recursive:true,force:true})); mkdirSync(path.join(root,'client/tests/e2e'),{recursive:true}); const rows=Array.from({length:10},(_,i)=>({file:`client/tests/e2e/flow-${i}.spec.ts`,id:`flow-${i}`,title:`flow-${i} @local-candidate:flow-${i}`,line:1})); for(const row of rows) writeFileSync(path.join(root,row.file),'synthetic'); const source={commit:'a'.repeat(40),tree:'b'.repeat(40),digest:'c'.repeat(64),untrackedDigest:'d'.repeat(64),dirty:false,repository:{trackedCoverageComplete:true,trackedDigest:'e'.repeat(64),indexDigest:'f'.repeat(64),porcelainV1Z:''}}; const policy={digest:'policy',environmentDigest:'environment',runnerDigest:'runner',probeDigest:'probe',toolchain:{node:process.version,playwright:'1.58.2',browserRegistry:'browser',browserVersions:{chromium:'synthetic',webkit:'synthetic'},platform:'linux',arch:'x64',ci:false,workers:4}}; const facts={root,source,policy,contract:{schemaVersion:1,approvalReference:'synthetic',exceptions:[]},manifest:{localCandidateFlows:rows.map((row,i)=>({bundle:`flow-${i}`,witnessTag:`@local-candidate:flow-${i}`,witnessSpec:row.file})),acceptanceSpecs:rows.map(row=>row.file)}}; const report=executed=>({errors:[],suites:rows.map(row=>({file:row.file,specs:[{...row,tests:[{projectName:'',repeatEachIndex:0,...(executed?{expectedStatus:'passed',status:'expected',results:[{status:'passed',retry:0}]}:{})}]}]}))}); const discovery=report(false),execution=report(true); execution.config={forbidOnly:true,projects:[{name:'',retries:0,repeatEach:1,timeout:180000,testIgnore:['**/manual/**']}],shard:null,maxFailures:0,workers:4,version:'1.58.2'}; execution.stats={startTime:time(26),duration:500,expected:10,unexpected:0,flaky:0,skipped:0}; const environment={schemaVersion:1,profile:'isolated-online-v1',mode:'full-local',policyDigest:'environment',platform:'linux',status:'PASS',exitCode:0,childExit:0,childSignal:null,interrupted:null,startedAt:time(3),endedAt:time(30),sourceBefore:structuredClone(source),sourceAfter:structuredClone(source),sourceUnchanged:true,repositoryUnchanged:true,candidateIntegrityStatus:'UNCHANGED',cleanup:{containerOwnerVerified:true,containerRemoved:true,scratchRemoved:true},environment:{schemaVersion:1,profile:'isolated-online-v1',policyDigest:'environment',platform:'linux',runnerDigest:'runner',probeDigest:'probe',supervisorObservation:{source:'live-supervisor-unix-ipc',sampledAt:time(3),namespace:'parent'},namespace:{inheritedRunner:'parent',ownedChild:'child',runnerCreated:true,currentMatchesOwnedChild:true,topologyMatched:true},nativeOnlineProbe:{valid:true,online:true,browserNamespace:'child',browserMatchesOwnedChild:true,noNavigatorOverride:true,engines:['chromium','webkit'].map(name=>({name,version:'synthetic',online:true,valid:true,noNavigatorOverride:true,browserNamespace:'child',browserMatchesOwnedChild:true}))},bootstrapObservation:{valid:true,observationPoint:'guest-entry-black-box',freshContext:true,initialSessionHint:false,listenerBeforeNavigation:true,noRouteMocks:true,loginFormVisible:true,refreshRequestCount:0,loginRequestCount:0,nativeOnlineSamples:{beforeNavigation:true,documentStart:true,afterForm:true},browserNamespace:'child',browserMatchesOwnedChild:true,noNavigatorOverride:true,documentStatus:200,stayedOnLogin:true}}}; const record={schemaVersion:1,profile:'release-predeploy',mode:'verify-candidate',runId:'a'.repeat(24),startedAt:time(0),finishedAt:time(33),executionExit:0,policy:structuredClone(policy),sourceBefore:structuredClone(source),sourceAfter:structuredClone(source),phases:stages(['static','full-local'],'release-predeploy-stages')}; record.phases.endedAt=time(32);record.phases.stages[1].endedAt=time(31); const bundle={environment,phases:stages(FULL_LOCAL_STAGES,'full-local-stages',4),discovery,execution,plan:coveragePlan(root,discovery,facts.contract,source)}; bundle.selection={schemaVersion:1,profile:'full-local-selection',specArgs:localSpecArgs(bundle.plan),additionalArgs:[],retries:0,forbidOnly:true,workers:4,mode:'local'}; return {root,record,bundle,facts,verify:()=>verifyReleaseEvidence(record,bundle,facts)}; } test('完整同源分阶段结果获得发布前资格,但公网交付仍未完成',context=>{ const f=fixture(context), result=f.verify();assert.equal(result.status,'PASS',JSON.stringify(result.issues));assert.equal(result.deploymentEligible,true);assert.equal(result.deliveryStatus,'INCOMPLETE');assert.equal(result.localCases.length,10);assert.equal(result.witnessSummary.passed,10); }); test('dirty 开发树可完成本地检查,但始终 PARTIAL/无发布资格',context=>{ const f=fixture(context); for(const source of [f.facts.source,f.record.sourceBefore,f.record.sourceAfter,f.bundle.environment.sourceBefore,f.bundle.environment.sourceAfter]){source.dirty=true;source.repository.porcelainV1Z=' M client/file\0';} const result=f.verify();assert.equal(result.localChecksPassed,true,JSON.stringify(result.issues));assert.equal(result.status,'PARTIAL');assert.equal(result.deploymentEligible,false); }); const mutations={ 'missing-receipt':f=>{f.record={};}, 'old-schema':f=>{f.record.schemaVersion=0;}, 'focused-profile':f=>{f.record.profile='local-candidate';}, 'no-finish':f=>{f.record.finishedAt=null;}, 'failed-run':f=>{f.record.executionExit=17;}, 'interrupted':f=>{f.record.interrupted='SIGTERM';}, 'phase-missing':f=>{f.record.phases.stages.pop();}, 'phase-skipped':f=>{f.record.phases.stages[0].status='SKIP';}, 'phase-failed':f=>{f.record.phases.stages[0].exitCode=3;}, 'phase-reversed':f=>{f.record.phases.stages.reverse();}, 'phase-after-completion':f=>{f.record.phases.endedAt=time(40);}, 'inner-phase-missing':f=>{f.bundle.phases.stages.pop();}, 'inner-phase-failed':f=>{f.bundle.phases.stages[2].status='FAIL';}, 'inner-profile-invalid':f=>{f.bundle.phases.profile='diagnostic';}, 'inner-time-outside-run':f=>{f.bundle.phases.startedAt=time(0);}, 'changed-commit':f=>{f.facts.source.commit='1'.repeat(40);}, 'changed-source':f=>{f.facts.source.digest='1'.repeat(64);}, 'changed-index':f=>{f.facts.source.repository.indexDigest='1'.repeat(64);}, 'changed-untracked-input':f=>{f.facts.source.untrackedDigest='1'.repeat(64);}, 'source-unknown':f=>{f.record.sourceBefore.repository.trackedCoverageComplete=false;}, 'policy-drift':f=>{f.record.policy.digest='old';}, 'toolchain-drift':f=>{f.record.policy.toolchain.playwright='old';}, 'environment-missing':f=>{delete f.bundle.environment;}, 'environment-focused':f=>{f.bundle.environment.mode='retained-local';}, 'environment-child-failed':f=>{f.bundle.environment.childExit=2;}, 'environment-exit-failed':f=>{f.bundle.environment.exitCode=2;}, 'environment-source-mismatch':f=>{f.bundle.environment.sourceBefore.digest='1'.repeat(64);}, 'environment-self-claimed':f=>{delete f.bundle.environment.environment.namespace;}, 'native-offline':f=>{f.bundle.environment.environment.nativeOnlineProbe.online=false;}, 'webkit-version-drift':f=>{f.bundle.environment.environment.nativeOnlineProbe.engines[1].version='old';}, 'webkit-native-offline':f=>{f.bundle.environment.environment.nativeOnlineProbe.engines[1].online=false;}, 'guest-refresh':f=>{f.bundle.environment.environment.bootstrapObservation.refreshRequestCount=1;}, 'guest-mock':f=>{f.bundle.environment.environment.bootstrapObservation.noRouteMocks=false;}, 'runner-digest-drift':f=>{f.bundle.environment.environment.runnerDigest='old';}, 'cleanup-missing':f=>{f.bundle.environment.cleanup.containerRemoved=false;}, 'discovery-truncated':f=>{f.bundle.discovery.suites.pop();}, 'plan-truncated':f=>{f.bundle.plan.localCases.pop();}, 'execution-missing':f=>{f.bundle.execution.suites.pop();}, 'execution-duplicate':f=>{f.bundle.execution.suites.push(f.bundle.execution.suites[0]);}, 'execution-unknown':f=>{f.bundle.execution.suites[0].specs[0].id='unknown';}, 'execution-skipped':f=>{f.bundle.execution.suites[0].specs[0].tests[0].results[0].status='skipped';}, 'execution-retried':f=>{f.bundle.execution.suites[0].specs[0].tests[0].results[0].retry=1;}, 'execution-xfail':f=>{f.bundle.execution.suites[0].specs[0].tests[0].expectedStatus='failed';}, 'execution-global-error':f=>{f.bundle.execution.errors.push({message:'synthetic'});}, 'selection-grep':f=>{f.bundle.selection.additionalArgs=['--grep=one'];}, 'selection-missing':f=>{delete f.bundle.selection;}, 'config-missing':f=>{delete f.bundle.execution.config;}, 'projects-missing':f=>{delete f.bundle.execution.config.projects;}, 'only-allowed':f=>{f.bundle.execution.config.forbidOnly=false;}, 'sharded':f=>{f.bundle.execution.config.shard={current:1,total:2};}, 'serialized':f=>{f.bundle.execution.config.workers=1;}, 'budget-increased':f=>{f.bundle.execution.config.projects[0].timeout=360000;}, 'stats-false-pass':f=>{f.bundle.execution.stats.expected=11;}, 'browser-time-mismatch':f=>{f.bundle.execution.stats.startTime=time(0);}, 'acceptance-missing':f=>{f.facts.manifest.acceptanceSpecs.push('client/tests/e2e/absent.spec.ts');}, 'witness-missing':f=>{f.bundle.execution.suites[0].specs[0].title='missing';}, }; for(const [name,mutate] of Object.entries(mutations))test(`拒绝伪通过:${name}`,context=>{ const f=fixture(context);mutate(f);const r=verifyReleaseEvidence(f.record,f.bundle,f.facts);assert.equal(r.deploymentEligible,false);assert.ok(r.issues.length); }); test('阶段记录保留真实失败、未跑阶段与清理失败,不吞早期错误',()=>{ const r=newStageRecord(['a','b'],'fixture');assert.throws(()=>updateStageRecord(r,'start','b')); updateStageRecord(r,'start','a');updateStageRecord(r,'finish',null,23); assert.equal(r.status,'FAIL');assert.equal(r.exitCode,23);assert.equal(r.stages[0].exitCode,23);assert.equal(r.stages[1].status,'NOT_RUN');assert.throws(()=>updateStageRecord(r,'start','b')); const complete=newStageRecord(['a'],'fixture');updateStageRecord(complete,'start','a');updateStageRecord(complete,'end','a',0);updateStageRecord(complete,'finish',null,31);assert.ok(validateStages(complete,['a'],'fixture').length); }); test('skip 精确值和批准记录,仅诊断 PARTIAL,普通发布前入口拒绝',()=>{ assert.equal(validateSkipRequest('verify-candidate',{}),null); for(const value of ['true','yes','01','', '2'])assert.throws(()=>validateSkipRequest('project-wide',{PROJECT_WIDE_SKIP_VISUAL:value})); const env={PROJECT_WIDE_SKIP_VISUAL:'1',PROJECT_WIDE_SKIP_REASON:'synthetic',PROJECT_WIDE_SKIP_OPERATOR:'fixture',PROJECT_WIDE_SKIP_APPROVAL_REF:'fixture-only'}; assert.throws(()=>validateSkipRequest('verify-candidate',env)); for(const key of ['PROJECT_WIDE_SKIP_REASON','PROJECT_WIDE_SKIP_OPERATOR','PROJECT_WIDE_SKIP_APPROVAL_REF'])assert.throws(()=>validateSkipRequest('project-wide',{...env,[key]:''})); const r=validateSkipRequest('project-wide',env);assert.equal(r.status,'PARTIAL');assert.equal(r.deploymentEligible,false);assert.deepEqual(r.affectedStages,['project-wide-visual']); }); test('回执路径拒绝越界、现存或断裂符号链接',context=>{ const f=fixture(context); for(const input of ['../outside','/absolute','a/../b','a//b','a\\b'])assert.throws(()=>confinedPath(f.root,input)); symlinkSync('/nonexistent-synthetic-target',path.join(f.root,'link')); assert.throws(()=>confinedPath(f.root,'link')); symlinkSync(f.root,path.join(f.root,'directory')); assert.throws(()=>confinedPath(f.root,'directory/file')); }); test('原有未跟踪部署文件内容变化,即使 Git status 相同也使来源变化',context=>{ const f=fixture(context); for(const args of [['init','-q'],['add','.'],['-c','user.name=QA Fixture','-c','user.email=qa@example.invalid','commit','-qm','synthetic fixture']])assert.equal(spawnSync('git',args,{cwd:f.root}).status,0); mkdirSync(path.join(f.root,'deploy'));writeFileSync(path.join(f.root,'deploy/new.sh'),'first'); const before=captureReleaseSource(f.root);writeFileSync(path.join(f.root,'deploy/new.sh'),'second');const after=captureReleaseSource(f.root); assert.equal(before.repository.porcelainV1Z,after.repository.porcelainV1Z);assert.notEqual(before.untrackedDigest,after.untrackedDigest); });