Skip to content

Commit 9457583

Browse files
authored
feat(test): add "test diff <run-a> <run-b>" to isolate run-to-run regressions (#168)
* feat(test): add "test diff <run-a> <run-b>" to isolate run-to-run regressions * test(diff): cover runDiff --dry-run branch (offline canned sample)
1 parent dcbcbee commit 9457583

3 files changed

Lines changed: 332 additions & 0 deletions

File tree

src/commands/test.test.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
runCreateBatch,
2828
runCreateFromPlan,
2929
runDelete,
30+
runDiff,
3031
runFailureGet,
3132
runFailureSummary,
3233
runGet,
@@ -121,6 +122,7 @@ describe('createTestCommand — surface', () => {
121122
'create-batch',
122123
'delete',
123124
'delete-batch',
125+
'diff',
124126
'failure',
125127
'get',
126128
'list',
@@ -2833,6 +2835,131 @@ describe('runSteps', () => {
28332835
});
28342836
});
28352837

2838+
describe('runDiff', () => {
2839+
const baseRun = {
2840+
testId: 'test_fe',
2841+
projectId: 'project_alice',
2842+
userId: 'u1',
2843+
source: 'cli',
2844+
createdAt: '2026-06-01T10:00:00.000Z',
2845+
startedAt: '2026-06-01T10:00:01.000Z',
2846+
finishedAt: '2026-06-01T10:00:30.000Z',
2847+
targetUrl: 'https://example.com',
2848+
createdFrom: null,
2849+
error: null,
2850+
videoUrl: null,
2851+
stepSummary: { total: 2, completed: 2, passedCount: 2, failedCount: 0 },
2852+
};
2853+
const makeStep = (index: string, status: string, error: string | null = null) => ({
2854+
stepIndex: index,
2855+
type: 'action',
2856+
action: `step ${index}`,
2857+
status,
2858+
description: `Step ${index}`,
2859+
error,
2860+
screenshotUrl: null,
2861+
htmlSnapshotUrl: null,
2862+
createdAt: '2026-06-01T10:00:05.000Z',
2863+
});
2864+
const RUN_GREEN = {
2865+
...baseRun,
2866+
runId: 'run_green',
2867+
status: 'passed',
2868+
codeVersion: 'v1',
2869+
failedStepIndex: null,
2870+
failureKind: null,
2871+
steps: [makeStep('0001', 'passed'), makeStep('0002', 'passed')],
2872+
};
2873+
const RUN_RED = {
2874+
...baseRun,
2875+
runId: 'run_red',
2876+
status: 'failed',
2877+
codeVersion: 'v2',
2878+
failedStepIndex: 2,
2879+
failureKind: 'assertion',
2880+
steps: [makeStep('0001', 'passed'), makeStep('0002', 'failed', 'heading not visible')],
2881+
};
2882+
const fetchForRuns = () =>
2883+
makeFetch(url => ({ body: url.includes('run_green') ? RUN_GREEN : RUN_RED }));
2884+
2885+
it('reports the verdict flip, the changed step with its error, and code drift, then exits 1', async () => {
2886+
const { credentialsPath } = makeCreds();
2887+
const out: string[] = [];
2888+
const rejection = await runDiff(
2889+
{ profile: 'default', output: 'json', debug: false, runA: 'run_green', runB: 'run_red' },
2890+
{ credentialsPath, fetchImpl: fetchForRuns(), stdout: line => out.push(line) },
2891+
).catch((error: unknown) => error);
2892+
expect(rejection).toMatchObject({ exitCode: 1 });
2893+
const printed = JSON.parse(out.join('')) as {
2894+
verdictChanged: boolean;
2895+
codeVersionChanged: boolean;
2896+
crossTest: boolean;
2897+
changedSteps: Array<{ stepIndex: number; statusA: string; statusB: string; errorB?: string }>;
2898+
};
2899+
expect(printed.verdictChanged).toBe(true);
2900+
expect(printed.codeVersionChanged).toBe(true);
2901+
expect(printed.crossTest).toBe(false);
2902+
expect(printed.changedSteps).toHaveLength(1);
2903+
expect(printed.changedSteps[0]).toMatchObject({
2904+
stepIndex: 2,
2905+
statusA: 'passed',
2906+
statusB: 'failed',
2907+
errorB: 'heading not visible',
2908+
});
2909+
});
2910+
2911+
it('identical verdicts resolve with exit 0 and no step changes', async () => {
2912+
const { credentialsPath } = makeCreds();
2913+
const fetchImpl = makeFetch(() => ({ body: RUN_GREEN }));
2914+
const diff = await runDiff(
2915+
{ profile: 'default', output: 'json', debug: false, runA: 'run_green', runB: 'run_green' },
2916+
{ credentialsPath, fetchImpl, stdout: () => undefined },
2917+
);
2918+
expect(diff.verdictChanged).toBe(false);
2919+
expect(diff.changedSteps).toHaveLength(0);
2920+
});
2921+
2922+
it('warns (not fails) when the runs belong to different tests', async () => {
2923+
const { credentialsPath } = makeCreds();
2924+
const OTHER = { ...RUN_GREEN, runId: 'run_red', testId: 'test_other' };
2925+
const fetchImpl = makeFetch(url => ({
2926+
body: url.includes('run_green') ? RUN_GREEN : OTHER,
2927+
}));
2928+
const errs: string[] = [];
2929+
const diff = await runDiff(
2930+
{ profile: 'default', output: 'json', debug: false, runA: 'run_green', runB: 'run_red' },
2931+
{ credentialsPath, fetchImpl, stdout: () => undefined, stderr: line => errs.push(line) },
2932+
);
2933+
expect(diff.crossTest).toBe(true);
2934+
expect(errs.join('\n')).toContain('different tests');
2935+
});
2936+
2937+
it('--dry-run returns the canned sample fully offline (no credentials, no fetch)', async () => {
2938+
// Dry-run must not require credentials or hit the network — it returns a
2939+
// canned CliRunDiff so `--dry-run` shows the shape offline.
2940+
const diff = await runDiff(
2941+
{
2942+
profile: 'default',
2943+
output: 'json',
2944+
debug: false,
2945+
dryRun: true,
2946+
runA: 'run_aaa',
2947+
runB: 'run_bbb',
2948+
},
2949+
{ stdout: () => undefined, stderr: () => undefined },
2950+
);
2951+
expect(diff.runA.runId).toBe('run_aaa');
2952+
expect(diff.runB.runId).toBe('run_bbb');
2953+
expect(diff.verdictChanged).toBe(true);
2954+
expect(diff.changedSteps).toHaveLength(1);
2955+
expect(diff.changedSteps[0]).toMatchObject({
2956+
stepIndex: 2,
2957+
statusA: 'passed',
2958+
statusB: 'failed',
2959+
});
2960+
});
2961+
});
2962+
28362963
describe('runResult', () => {
28372964
it('JSON mode prints the §6.5 LatestResult shape verbatim', async () => {
28382965
const { credentialsPath } = makeCreds();

src/commands/test.ts

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
36673857
export 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(

test/__snapshots__/help.snapshot.test.ts.snap

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,11 @@ Commands:
200200
steps [options] <test-id> List the steps for a test (server
201201
returns the cumulative log across every
202202
run; use --run-id to scope to one run)
203+
diff <run-a> <run-b> Compare two runs and print what
204+
regressed: verdict, failureKind,
205+
failedStepIndex, per-step status flips,
206+
codeVersion drift. Exit 0 when verdicts
207+
match, 1 when they differ.
203208
result [options] <test-id> Get the latest result for a test (default) or list prior runs (--history).
204209
205210
--output json shape differs by mode:

0 commit comments

Comments
 (0)