@@ -3630,6 +3630,196 @@ function mapRunStepToCliTestStep(step: RunStepDto, run: RunResponse): CliTestSte
36303630 } ;
36313631}
36323632
3633+ export interface DiffOptions extends CommonOptions {
3634+ runA : string ;
3635+ runB : string ;
3636+ }
3637+
3638+ /** One step whose status flipped between the two compared runs. */
3639+ export interface CliDiffStep {
3640+ stepIndex : number ;
3641+ statusA : string ;
3642+ statusB : string ;
3643+ /** First divergent failing side's error text, when the wire carried one. */
3644+ errorA ?: string | null ;
3645+ errorB ?: string | null ;
3646+ }
3647+
3648+ export interface CliRunDiff {
3649+ runA : {
3650+ runId : string ;
3651+ testId : string ;
3652+ status : string ;
3653+ failureKind : string | null ;
3654+ failedStepIndex : number | null ;
3655+ codeVersion : string | null ;
3656+ } ;
3657+ runB : {
3658+ runId : string ;
3659+ testId : string ;
3660+ status : string ;
3661+ failureKind : string | null ;
3662+ failedStepIndex : number | null ;
3663+ codeVersion : string | null ;
3664+ } ;
3665+ verdictChanged : boolean ;
3666+ failedStepIndexChanged : boolean ;
3667+ failureKindChanged : boolean ;
3668+ codeVersionChanged : boolean ;
3669+ /** True when the two runs belong to DIFFERENT tests (deltas may be meaningless). */
3670+ crossTest : boolean ;
3671+ changedSteps : CliDiffStep [ ] ;
3672+ }
3673+
3674+ /**
3675+ * `test diff <runA> <runB>` (issue #124): isolate what regressed between two
3676+ * runs, the first question when CI goes red ("what changed since the last
3677+ * green run?"). Pure client-side composition of the existing per-run read
3678+ * (`GET /runs/{id}?includeSteps=true`); the endpoint accepts any two run-ids,
3679+ * so a cross-test pair is a WARNING, not an error. Exit 0 when the verdicts
3680+ * match, exit 1 when they differ, so the command is CI-scriptable.
3681+ */
3682+ export async function runDiff ( opts : DiffOptions , deps : TestDeps = { } ) : Promise < CliRunDiff > {
3683+ const out = makeOutput ( opts . output , deps ) ;
3684+ const stderrFn = deps . stderr ?? ( ( line : string ) => process . stderr . write ( `${ line } \n` ) ) ;
3685+
3686+ if ( opts . dryRun ) {
3687+ emitDryRunBanner ( stderrFn ) ;
3688+ const sample : CliRunDiff = {
3689+ runA : {
3690+ runId : opts . runA ,
3691+ testId : 'test_dryrun' ,
3692+ status : 'passed' ,
3693+ failureKind : null ,
3694+ failedStepIndex : null ,
3695+ codeVersion : 'v1' ,
3696+ } ,
3697+ runB : {
3698+ runId : opts . runB ,
3699+ testId : 'test_dryrun' ,
3700+ status : 'failed' ,
3701+ failureKind : 'assertion' ,
3702+ failedStepIndex : 2 ,
3703+ codeVersion : 'v1' ,
3704+ } ,
3705+ verdictChanged : true ,
3706+ failedStepIndexChanged : true ,
3707+ failureKindChanged : true ,
3708+ codeVersionChanged : false ,
3709+ crossTest : false ,
3710+ changedSteps : [ { stepIndex : 2 , statusA : 'passed' , statusB : 'failed' } ] ,
3711+ } ;
3712+ out . print ( sample , ( ) => renderRunDiffText ( sample ) ) ;
3713+ return sample ;
3714+ }
3715+
3716+ const client = makeClient ( opts , deps ) ;
3717+ const [ runA , runB ] = await Promise . all ( [
3718+ client . getRun ( opts . runA , { includeSteps : true } ) ,
3719+ client . getRun ( opts . runB , { includeSteps : true } ) ,
3720+ ] ) ;
3721+
3722+ const crossTest = runA . testId !== runB . testId ;
3723+ if ( crossTest ) {
3724+ stderrFn (
3725+ `⚠ the two runs belong to different tests (${ runA . testId } vs ${ runB . testId } ) — step deltas may be meaningless` ,
3726+ ) ;
3727+ }
3728+
3729+ const stepsByIndex = (
3730+ run : RunResponse ,
3731+ ) : Map < number , { status : string ; error : string | null } > => {
3732+ const map = new Map < number , { status : string ; error : string | null } > ( ) ;
3733+ for ( const step of run . steps ?? [ ] ) {
3734+ const index = parseInt ( step . stepIndex , 10 ) ;
3735+ if ( Number . isInteger ( index ) )
3736+ map . set ( index , { status : step . status ?? 'unknown' , error : step . error } ) ;
3737+ }
3738+ return map ;
3739+ } ;
3740+ const stepsA = stepsByIndex ( runA ) ;
3741+ const stepsB = stepsByIndex ( runB ) ;
3742+ const allIndexes = [ ...new Set ( [ ...stepsA . keys ( ) , ...stepsB . keys ( ) ] ) ] . sort (
3743+ ( left , right ) => left - right ,
3744+ ) ;
3745+ const changedSteps : CliDiffStep [ ] = [ ] ;
3746+ for ( const index of allIndexes ) {
3747+ const sideA = stepsA . get ( index ) ;
3748+ const sideB = stepsB . get ( index ) ;
3749+ const statusA = sideA ?. status ?? 'absent' ;
3750+ const statusB = sideB ?. status ?? 'absent' ;
3751+ if ( statusA === statusB ) continue ;
3752+ changedSteps . push ( {
3753+ stepIndex : index ,
3754+ statusA,
3755+ statusB,
3756+ ...( sideA ?. error ? { errorA : sideA . error } : { } ) ,
3757+ ...( sideB ?. error ? { errorB : sideB . error } : { } ) ,
3758+ } ) ;
3759+ }
3760+
3761+ const summarize = ( run : RunResponse ) => ( {
3762+ runId : run . runId ,
3763+ testId : run . testId ,
3764+ status : run . status ,
3765+ failureKind : run . failureKind ?? null ,
3766+ failedStepIndex : run . failedStepIndex ,
3767+ codeVersion : run . codeVersion ?? null ,
3768+ } ) ;
3769+ const diff : CliRunDiff = {
3770+ runA : summarize ( runA ) ,
3771+ runB : summarize ( runB ) ,
3772+ verdictChanged : runA . status !== runB . status ,
3773+ failedStepIndexChanged : runA . failedStepIndex !== runB . failedStepIndex ,
3774+ failureKindChanged : ( runA . failureKind ?? null ) !== ( runB . failureKind ?? null ) ,
3775+ codeVersionChanged : ( runA . codeVersion ?? null ) !== ( runB . codeVersion ?? null ) ,
3776+ crossTest,
3777+ changedSteps,
3778+ } ;
3779+ out . print ( diff , ( ) => renderRunDiffText ( diff ) ) ;
3780+
3781+ if ( diff . verdictChanged ) {
3782+ // Result already printed; the typed exit makes `test diff` a CI gate.
3783+ throw new CLIError (
3784+ `verdicts differ: ${ runA . runId } =${ runA . status } vs ${ runB . runId } =${ runB . status } ` ,
3785+ 1 ,
3786+ ) ;
3787+ }
3788+ return diff ;
3789+ }
3790+
3791+ function renderRunDiffText ( diff : CliRunDiff ) : string {
3792+ const lines : string [ ] = [ ] ;
3793+ lines . push ( `runA: ${ diff . runA . runId } ${ diff . runA . status } (test ${ diff . runA . testId } )` ) ;
3794+ lines . push ( `runB: ${ diff . runB . runId } ${ diff . runB . status } (test ${ diff . runB . testId } )` ) ;
3795+ lines . push (
3796+ `verdict: ${ diff . verdictChanged ? `${ diff . runA . status } -> ${ diff . runB . status } ` : `unchanged (${ diff . runA . status } )` } ` ,
3797+ ) ;
3798+ if ( diff . failureKindChanged )
3799+ lines . push (
3800+ `failureKind: ${ diff . runA . failureKind ?? '(none)' } -> ${ diff . runB . failureKind ?? '(none)' } ` ,
3801+ ) ;
3802+ if ( diff . failedStepIndexChanged )
3803+ lines . push (
3804+ `failedStepIndex: ${ diff . runA . failedStepIndex ?? '(none)' } -> ${ diff . runB . failedStepIndex ?? '(none)' } ` ,
3805+ ) ;
3806+ lines . push (
3807+ `codeVersion: ${ diff . codeVersionChanged ? `${ diff . runA . codeVersion ?? '(none)' } -> ${ diff . runB . codeVersion ?? '(none)' } (code drift)` : 'unchanged' } ` ,
3808+ ) ;
3809+ if ( diff . changedSteps . length === 0 ) {
3810+ lines . push ( 'steps: no per-step status changes' ) ;
3811+ } else {
3812+ lines . push ( `steps changed: ${ diff . changedSteps . length } ` ) ;
3813+ for ( const step of diff . changedSteps ) {
3814+ lines . push ( ` #${ step . stepIndex } ${ step . statusA } -> ${ step . statusB } ` ) ;
3815+ if ( step . errorB ) lines . push ( ` error(B): ${ step . errorB . replace ( / \s + / g, ' ' ) . trim ( ) } ` ) ;
3816+ else if ( step . errorA )
3817+ lines . push ( ` error(A): ${ step . errorA . replace ( / \s + / g, ' ' ) . trim ( ) } ` ) ;
3818+ }
3819+ }
3820+ return lines . join ( '\n' ) ;
3821+ }
3822+
36333823export async function runSteps (
36343824 opts : StepsOptions ,
36353825 deps : TestDeps = { } ,
@@ -7234,6 +7424,16 @@ export function createTestCommand(deps: TestDeps = {}): Command {
72347424 ) ;
72357425 } ) ;
72367426
7427+ test
7428+ . command ( 'diff <run-a> <run-b>' )
7429+ . description (
7430+ '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.' ,
7431+ )
7432+ . addHelpText ( 'after' , GLOBAL_OPTS_HINT )
7433+ . action ( async ( runA : string , runB : string , _cmdOpts : unknown , command : Command ) => {
7434+ await runDiff ( { ...resolveCommonOptions ( command ) , runA, runB } , deps ) ;
7435+ } ) ;
7436+
72377437 test
72387438 . command ( 'result <test-id>' )
72397439 . description (
0 commit comments