@@ -3664,6 +3664,196 @@ function mapRunStepToCliTestStep(step: RunStepDto, run: RunResponse): CliTestSte
36643664 } ;
36653665}
36663666
3667+ export interface DiffOptions extends CommonOptions {
3668+ runA : string ;
3669+ runB : string ;
3670+ }
3671+
3672+ /** One step whose status flipped between the two compared runs. */
3673+ export interface CliDiffStep {
3674+ stepIndex : number ;
3675+ statusA : string ;
3676+ statusB : string ;
3677+ /** First divergent failing side's error text, when the wire carried one. */
3678+ errorA ?: string | null ;
3679+ errorB ?: string | null ;
3680+ }
3681+
3682+ export interface CliRunDiff {
3683+ runA : {
3684+ runId : string ;
3685+ testId : string ;
3686+ status : string ;
3687+ failureKind : string | null ;
3688+ failedStepIndex : number | null ;
3689+ codeVersion : string | null ;
3690+ } ;
3691+ runB : {
3692+ runId : string ;
3693+ testId : string ;
3694+ status : string ;
3695+ failureKind : string | null ;
3696+ failedStepIndex : number | null ;
3697+ codeVersion : string | null ;
3698+ } ;
3699+ verdictChanged : boolean ;
3700+ failedStepIndexChanged : boolean ;
3701+ failureKindChanged : boolean ;
3702+ codeVersionChanged : boolean ;
3703+ /** True when the two runs belong to DIFFERENT tests (deltas may be meaningless). */
3704+ crossTest : boolean ;
3705+ changedSteps : CliDiffStep [ ] ;
3706+ }
3707+
3708+ /**
3709+ * `test diff <runA> <runB>` (issue #124): isolate what regressed between two
3710+ * runs, the first question when CI goes red ("what changed since the last
3711+ * green run?"). Pure client-side composition of the existing per-run read
3712+ * (`GET /runs/{id}?includeSteps=true`); the endpoint accepts any two run-ids,
3713+ * so a cross-test pair is a WARNING, not an error. Exit 0 when the verdicts
3714+ * match, exit 1 when they differ, so the command is CI-scriptable.
3715+ */
3716+ export async function runDiff ( opts : DiffOptions , deps : TestDeps = { } ) : Promise < CliRunDiff > {
3717+ const out = makeOutput ( opts . output , deps ) ;
3718+ const stderrFn = deps . stderr ?? ( ( line : string ) => process . stderr . write ( `${ line } \n` ) ) ;
3719+
3720+ if ( opts . dryRun ) {
3721+ emitDryRunBanner ( stderrFn ) ;
3722+ const sample : CliRunDiff = {
3723+ runA : {
3724+ runId : opts . runA ,
3725+ testId : 'test_dryrun' ,
3726+ status : 'passed' ,
3727+ failureKind : null ,
3728+ failedStepIndex : null ,
3729+ codeVersion : 'v1' ,
3730+ } ,
3731+ runB : {
3732+ runId : opts . runB ,
3733+ testId : 'test_dryrun' ,
3734+ status : 'failed' ,
3735+ failureKind : 'assertion' ,
3736+ failedStepIndex : 2 ,
3737+ codeVersion : 'v1' ,
3738+ } ,
3739+ verdictChanged : true ,
3740+ failedStepIndexChanged : true ,
3741+ failureKindChanged : true ,
3742+ codeVersionChanged : false ,
3743+ crossTest : false ,
3744+ changedSteps : [ { stepIndex : 2 , statusA : 'passed' , statusB : 'failed' } ] ,
3745+ } ;
3746+ out . print ( sample , ( ) => renderRunDiffText ( sample ) ) ;
3747+ return sample ;
3748+ }
3749+
3750+ const client = makeClient ( opts , deps ) ;
3751+ const [ runA , runB ] = await Promise . all ( [
3752+ client . getRun ( opts . runA , { includeSteps : true } ) ,
3753+ client . getRun ( opts . runB , { includeSteps : true } ) ,
3754+ ] ) ;
3755+
3756+ const crossTest = runA . testId !== runB . testId ;
3757+ if ( crossTest ) {
3758+ stderrFn (
3759+ `⚠ the two runs belong to different tests (${ runA . testId } vs ${ runB . testId } ) — step deltas may be meaningless` ,
3760+ ) ;
3761+ }
3762+
3763+ const stepsByIndex = (
3764+ run : RunResponse ,
3765+ ) : Map < number , { status : string ; error : string | null } > => {
3766+ const map = new Map < number , { status : string ; error : string | null } > ( ) ;
3767+ for ( const step of run . steps ?? [ ] ) {
3768+ const index = parseInt ( step . stepIndex , 10 ) ;
3769+ if ( Number . isInteger ( index ) )
3770+ map . set ( index , { status : step . status ?? 'unknown' , error : step . error } ) ;
3771+ }
3772+ return map ;
3773+ } ;
3774+ const stepsA = stepsByIndex ( runA ) ;
3775+ const stepsB = stepsByIndex ( runB ) ;
3776+ const allIndexes = [ ...new Set ( [ ...stepsA . keys ( ) , ...stepsB . keys ( ) ] ) ] . sort (
3777+ ( left , right ) => left - right ,
3778+ ) ;
3779+ const changedSteps : CliDiffStep [ ] = [ ] ;
3780+ for ( const index of allIndexes ) {
3781+ const sideA = stepsA . get ( index ) ;
3782+ const sideB = stepsB . get ( index ) ;
3783+ const statusA = sideA ?. status ?? 'absent' ;
3784+ const statusB = sideB ?. status ?? 'absent' ;
3785+ if ( statusA === statusB ) continue ;
3786+ changedSteps . push ( {
3787+ stepIndex : index ,
3788+ statusA,
3789+ statusB,
3790+ ...( sideA ?. error ? { errorA : sideA . error } : { } ) ,
3791+ ...( sideB ?. error ? { errorB : sideB . error } : { } ) ,
3792+ } ) ;
3793+ }
3794+
3795+ const summarize = ( run : RunResponse ) => ( {
3796+ runId : run . runId ,
3797+ testId : run . testId ,
3798+ status : run . status ,
3799+ failureKind : run . failureKind ?? null ,
3800+ failedStepIndex : run . failedStepIndex ,
3801+ codeVersion : run . codeVersion ?? null ,
3802+ } ) ;
3803+ const diff : CliRunDiff = {
3804+ runA : summarize ( runA ) ,
3805+ runB : summarize ( runB ) ,
3806+ verdictChanged : runA . status !== runB . status ,
3807+ failedStepIndexChanged : runA . failedStepIndex !== runB . failedStepIndex ,
3808+ failureKindChanged : ( runA . failureKind ?? null ) !== ( runB . failureKind ?? null ) ,
3809+ codeVersionChanged : ( runA . codeVersion ?? null ) !== ( runB . codeVersion ?? null ) ,
3810+ crossTest,
3811+ changedSteps,
3812+ } ;
3813+ out . print ( diff , ( ) => renderRunDiffText ( diff ) ) ;
3814+
3815+ if ( diff . verdictChanged ) {
3816+ // Result already printed; the typed exit makes `test diff` a CI gate.
3817+ throw new CLIError (
3818+ `verdicts differ: ${ runA . runId } =${ runA . status } vs ${ runB . runId } =${ runB . status } ` ,
3819+ 1 ,
3820+ ) ;
3821+ }
3822+ return diff ;
3823+ }
3824+
3825+ function renderRunDiffText ( diff : CliRunDiff ) : string {
3826+ const lines : string [ ] = [ ] ;
3827+ lines . push ( `runA: ${ diff . runA . runId } ${ diff . runA . status } (test ${ diff . runA . testId } )` ) ;
3828+ lines . push ( `runB: ${ diff . runB . runId } ${ diff . runB . status } (test ${ diff . runB . testId } )` ) ;
3829+ lines . push (
3830+ `verdict: ${ diff . verdictChanged ? `${ diff . runA . status } -> ${ diff . runB . status } ` : `unchanged (${ diff . runA . status } )` } ` ,
3831+ ) ;
3832+ if ( diff . failureKindChanged )
3833+ lines . push (
3834+ `failureKind: ${ diff . runA . failureKind ?? '(none)' } -> ${ diff . runB . failureKind ?? '(none)' } ` ,
3835+ ) ;
3836+ if ( diff . failedStepIndexChanged )
3837+ lines . push (
3838+ `failedStepIndex: ${ diff . runA . failedStepIndex ?? '(none)' } -> ${ diff . runB . failedStepIndex ?? '(none)' } ` ,
3839+ ) ;
3840+ lines . push (
3841+ `codeVersion: ${ diff . codeVersionChanged ? `${ diff . runA . codeVersion ?? '(none)' } -> ${ diff . runB . codeVersion ?? '(none)' } (code drift)` : 'unchanged' } ` ,
3842+ ) ;
3843+ if ( diff . changedSteps . length === 0 ) {
3844+ lines . push ( 'steps: no per-step status changes' ) ;
3845+ } else {
3846+ lines . push ( `steps changed: ${ diff . changedSteps . length } ` ) ;
3847+ for ( const step of diff . changedSteps ) {
3848+ lines . push ( ` #${ step . stepIndex } ${ step . statusA } -> ${ step . statusB } ` ) ;
3849+ if ( step . errorB ) lines . push ( ` error(B): ${ step . errorB . replace ( / \s + / g, ' ' ) . trim ( ) } ` ) ;
3850+ else if ( step . errorA )
3851+ lines . push ( ` error(A): ${ step . errorA . replace ( / \s + / g, ' ' ) . trim ( ) } ` ) ;
3852+ }
3853+ }
3854+ return lines . join ( '\n' ) ;
3855+ }
3856+
36673857export async function runSteps (
36683858 opts : StepsOptions ,
36693859 deps : TestDeps = { } ,
@@ -7413,6 +7603,16 @@ export function createTestCommand(deps: TestDeps = {}): Command {
74137603 ) ;
74147604 } ) ;
74157605
7606+ test
7607+ . command ( 'diff <run-a> <run-b>' )
7608+ . description (
7609+ 'Compare two runs and print what regressed: verdict, failureKind, failedStepIndex, per-step status flips, codeVersion drift. Exit 0 when verdicts match, 1 when they differ.' ,
7610+ )
7611+ . addHelpText ( 'after' , GLOBAL_OPTS_HINT )
7612+ . action ( async ( runA : string , runB : string , _cmdOpts : unknown , command : Command ) => {
7613+ await runDiff ( { ...resolveCommonOptions ( command ) , runA, runB } , deps ) ;
7614+ } ) ;
7615+
74167616 test
74177617 . command ( 'result <test-id>' )
74187618 . description (
0 commit comments