Skip to content

Commit b785773

Browse files
committed
feat(test): add "test diff <run-a> <run-b>" to isolate run-to-run regressions
1 parent e53257d commit b785773

3 files changed

Lines changed: 307 additions & 0 deletions

File tree

src/commands/test.test.ts

Lines changed: 102 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',
@@ -2721,6 +2723,106 @@ describe('runSteps', () => {
27212723
});
27222724
});
27232725

2726+
describe('runDiff', () => {
2727+
const baseRun = {
2728+
testId: 'test_fe',
2729+
projectId: 'project_alice',
2730+
userId: 'u1',
2731+
source: 'cli',
2732+
createdAt: '2026-06-01T10:00:00.000Z',
2733+
startedAt: '2026-06-01T10:00:01.000Z',
2734+
finishedAt: '2026-06-01T10:00:30.000Z',
2735+
targetUrl: 'https://example.com',
2736+
createdFrom: null,
2737+
error: null,
2738+
videoUrl: null,
2739+
stepSummary: { total: 2, completed: 2, passedCount: 2, failedCount: 0 },
2740+
};
2741+
const makeStep = (index: string, status: string, error: string | null = null) => ({
2742+
stepIndex: index,
2743+
type: 'action',
2744+
action: `step ${index}`,
2745+
status,
2746+
description: `Step ${index}`,
2747+
error,
2748+
screenshotUrl: null,
2749+
htmlSnapshotUrl: null,
2750+
createdAt: '2026-06-01T10:00:05.000Z',
2751+
});
2752+
const RUN_GREEN = {
2753+
...baseRun,
2754+
runId: 'run_green',
2755+
status: 'passed',
2756+
codeVersion: 'v1',
2757+
failedStepIndex: null,
2758+
failureKind: null,
2759+
steps: [makeStep('0001', 'passed'), makeStep('0002', 'passed')],
2760+
};
2761+
const RUN_RED = {
2762+
...baseRun,
2763+
runId: 'run_red',
2764+
status: 'failed',
2765+
codeVersion: 'v2',
2766+
failedStepIndex: 2,
2767+
failureKind: 'assertion',
2768+
steps: [makeStep('0001', 'passed'), makeStep('0002', 'failed', 'heading not visible')],
2769+
};
2770+
const fetchForRuns = () =>
2771+
makeFetch(url => ({ body: url.includes('run_green') ? RUN_GREEN : RUN_RED }));
2772+
2773+
it('reports the verdict flip, the changed step with its error, and code drift, then exits 1', async () => {
2774+
const { credentialsPath } = makeCreds();
2775+
const out: string[] = [];
2776+
const rejection = await runDiff(
2777+
{ profile: 'default', output: 'json', debug: false, runA: 'run_green', runB: 'run_red' },
2778+
{ credentialsPath, fetchImpl: fetchForRuns(), stdout: line => out.push(line) },
2779+
).catch((error: unknown) => error);
2780+
expect(rejection).toMatchObject({ exitCode: 1 });
2781+
const printed = JSON.parse(out.join('')) as {
2782+
verdictChanged: boolean;
2783+
codeVersionChanged: boolean;
2784+
crossTest: boolean;
2785+
changedSteps: Array<{ stepIndex: number; statusA: string; statusB: string; errorB?: string }>;
2786+
};
2787+
expect(printed.verdictChanged).toBe(true);
2788+
expect(printed.codeVersionChanged).toBe(true);
2789+
expect(printed.crossTest).toBe(false);
2790+
expect(printed.changedSteps).toHaveLength(1);
2791+
expect(printed.changedSteps[0]).toMatchObject({
2792+
stepIndex: 2,
2793+
statusA: 'passed',
2794+
statusB: 'failed',
2795+
errorB: 'heading not visible',
2796+
});
2797+
});
2798+
2799+
it('identical verdicts resolve with exit 0 and no step changes', async () => {
2800+
const { credentialsPath } = makeCreds();
2801+
const fetchImpl = makeFetch(() => ({ body: RUN_GREEN }));
2802+
const diff = await runDiff(
2803+
{ profile: 'default', output: 'json', debug: false, runA: 'run_green', runB: 'run_green' },
2804+
{ credentialsPath, fetchImpl, stdout: () => undefined },
2805+
);
2806+
expect(diff.verdictChanged).toBe(false);
2807+
expect(diff.changedSteps).toHaveLength(0);
2808+
});
2809+
2810+
it('warns (not fails) when the runs belong to different tests', async () => {
2811+
const { credentialsPath } = makeCreds();
2812+
const OTHER = { ...RUN_GREEN, runId: 'run_red', testId: 'test_other' };
2813+
const fetchImpl = makeFetch(url => ({
2814+
body: url.includes('run_green') ? RUN_GREEN : OTHER,
2815+
}));
2816+
const errs: string[] = [];
2817+
const diff = await runDiff(
2818+
{ profile: 'default', output: 'json', debug: false, runA: 'run_green', runB: 'run_red' },
2819+
{ credentialsPath, fetchImpl, stdout: () => undefined, stderr: line => errs.push(line) },
2820+
);
2821+
expect(diff.crossTest).toBe(true);
2822+
expect(errs.join('\n')).toContain('different tests');
2823+
});
2824+
});
2825+
27242826
describe('runResult', () => {
27252827
it('JSON mode prints the §6.5 LatestResult shape verbatim', async () => {
27262828
const { credentialsPath } = makeCreds();

src/commands/test.ts

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

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

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

0 commit comments

Comments
 (0)