Skip to content

Commit 9cdf2c9

Browse files
feat(test): add failure triage for batch root-cause grouping
Add testsprite test failure triage --project <id> to group failed tests into root-cause clusters using existing M2.1 analysis fields. Returns a representative test per cluster, confidence score, and fix priority without downloading failure bundles. Includes grouping library, command wiring, unit/integration tests, docs, CHANGELOG entry, agent skill update, and help snapshot.
1 parent 15e95de commit 9cdf2c9

10 files changed

Lines changed: 1120 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ All notable changes to `@testsprite/testsprite-cli` are documented here. The for
44

55
## [Unreleased]
66

7+
### Added
8+
9+
- **`test failure triage --project <id>`** — groups all failed tests in a project into root-cause clusters using existing M2.1 analysis fields (`failureKind`, `recommendedFixTarget.reference`, `rootCauseHypothesis`). Returns a representative test per cluster, affected test ids, confidence score, and fix priority — without downloading failure bundles. Supports `--type`, `--filter`, and `--max-concurrency`. Client-side Phase-0 triage until native backend clustering ships.
10+
711
## [0.1.2] - 2026-06-19
812

913
### Added

DOCUMENTATION.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,31 @@ testsprite test failure summary test_xxxxxxxx --output json
248248
testsprite test failure summary test_xxxxxxxx --dry-run --output json
249249
```
250250

251+
#### `testsprite test failure triage --project <id>`
252+
253+
When many tests fail in the same project, triage them into a few root-cause clusters before downloading bundles. The CLI lists all failed tests, fetches a lightweight `failure/summary` per test (no screenshots or video), and groups them client-side by:
254+
255+
1. shared `recommendedFixTarget.reference`
256+
2. env-wide `failureKind` (`infra`, `network`, `network_timeout`, `routing_404`)
257+
3. normalized `rootCauseHypothesis` prefix
258+
4. singleton (one test per cluster when no shared signal exists)
259+
260+
Each cluster includes a `representativeTestId`, `memberTestIds`, `confidence`, and `fixPriority` (lower = fix first). After triage, pull one bundle from the representative test:
261+
262+
```bash
263+
# Triage all failed tests in a project
264+
testsprite test failure triage --project proj_xxxxxxxx --output json
265+
266+
# Limit to backend tests whose name contains "checkout"
267+
testsprite test failure triage --project proj_xxxxxxxx --type backend --filter checkout --output json
268+
269+
# Then investigate the highest-priority cluster's representative test
270+
testsprite test failure get <representativeTestId> --out ./.testsprite/failure
271+
272+
# Learn the JSON shape offline
273+
testsprite test failure triage --project proj_xxxxxxxx --dry-run --output json
274+
```
275+
251276
### Write commands
252277

253278
Require the `write:tests` scope.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ Prefer to configure each step by hand (or learn the surface offline with `--dry-
101101
| | `test result` | Latest result; `--history` lists a test's prior runs |
102102
| | `test failure get` | The agent entry point: one self-contained latest-failure bundle |
103103
| | `test failure summary` | One-screen triage card (no media download) |
104+
| | `test failure triage` | Group all failed tests in a project into root-cause clusters (no bundle download) |
104105
| **Write** | `test create` / `test create-batch` | Create a test (or bulk-create from a plan file); `--produces` / `--needs` / `--category` wire BE dependency metadata |
105106
| | `test update` / `test delete` / `test delete-batch` | Edit metadata / soft-delete |
106107
| | `test code put` | Replace generated code (etag-guarded) |

skills/testsprite-verify.skill.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,22 @@ testing agent's observation; don't auto-fix on the recommendation alone. If you
398398
genuinely can't tell: report `inconclusive` with the signal that triggered the
399399
call and ask.
400400

401-
## 5. On failure → download the artifact
401+
## 5. On failure → triage first, then download one bundle
402+
403+
When **multiple tests failed** in the same project (batch run, regression, or
404+
`test list --status failed` shows more than one red row), triage before pulling
405+
every bundle:
406+
407+
```bash
408+
testsprite test failure triage --project <projectId> --output json
409+
```
410+
411+
Read the clusters: each has a `representativeTestId`, `memberTestIds`,
412+
`confidence`, and `fixPriority` (lower = fix first). Investigate the
413+
representative test from the highest-priority cluster — not an arbitrary failed
414+
test. After a fix, rerun that representative before rerunning the full suite.
415+
416+
For a **single** failed test, skip triage and go straight to the artifact:
402417

403418
```bash
404419
testsprite test artifact get <run-id> --out ./.testsprite/runs/<run-id>/

src/commands/test.test.ts

Lines changed: 231 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
runDelete,
3030
runFailureGet,
3131
runFailureSummary,
32+
runFailureTriage,
3233
runGet,
3334
runList,
3435
runPlanPut,
@@ -147,7 +148,7 @@ describe('createTestCommand — surface', () => {
147148
expect(failure).toBeDefined();
148149
// M2.1 piece 3 adds `summary`. `get` is the bundle entry point;
149150
// `summary` is the lightweight analysis-only triage card.
150-
expect(failure!.commands.map(c => c.name()).sort()).toEqual(['get', 'summary']);
151+
expect(failure!.commands.map(c => c.name()).sort()).toEqual(['get', 'summary', 'triage']);
151152
});
152153

153154
it('list exposes the documented filter and pagination flags (including --cursor alias)', () => {
@@ -283,6 +284,16 @@ describe('createTestCommand — surface', () => {
283284
expect(help).toContain('--dry-run');
284285
});
285286

287+
it('test failure triage --help includes GLOBAL_OPTS_HINT and --project', () => {
288+
const test = createTestCommand();
289+
const failure = test.commands.find(c => c.name() === 'failure')!;
290+
const failureTriage = failure.commands.find(c => c.name() === 'triage')!;
291+
const help = captureHelp(failureTriage);
292+
expect(help).toContain('testsprite --help');
293+
expect(help).toContain('--project');
294+
expect(help).toContain('--max-concurrency');
295+
});
296+
286297
it('M2 sweep: all remaining leaf subcommands include GLOBAL_OPTS_HINT', () => {
287298
// Covers list, get, create, create-batch, steps, result, update, delete,
288299
// code get, code put, plan put — the full M2 surface that the dogfood
@@ -3168,6 +3179,225 @@ describe('runFailureSummary', () => {
31683179
});
31693180
});
31703181

3182+
// ---------- runFailureTriage ----------
3183+
3184+
describe('runFailureTriage', () => {
3185+
const FAILED_TEST_A = {
3186+
id: 'test_a',
3187+
projectId: 'proj_1',
3188+
name: 'Checkout submit',
3189+
type: 'frontend' as const,
3190+
createdFrom: 'cli' as const,
3191+
status: 'failed' as const,
3192+
createdAt: '2026-06-26T10:00:00.000Z',
3193+
updatedAt: '2026-06-26T12:00:00.000Z',
3194+
};
3195+
const FAILED_TEST_B = {
3196+
...FAILED_TEST_A,
3197+
id: 'test_b',
3198+
name: 'Checkout validation',
3199+
updatedAt: '2026-06-26T12:01:00.000Z',
3200+
};
3201+
const FAILED_TEST_C = {
3202+
...FAILED_TEST_A,
3203+
id: 'test_c',
3204+
name: 'Health check',
3205+
type: 'backend' as const,
3206+
updatedAt: '2026-06-26T12:02:00.000Z',
3207+
};
3208+
3209+
const SHARED_REF = 'src/components/CheckoutForm.tsx:412';
3210+
3211+
function summaryFor(testId: string, overrides: Record<string, unknown> = {}) {
3212+
return {
3213+
testId,
3214+
status: 'failed' as const,
3215+
failureKind: 'assertion' as const,
3216+
snapshotId: `snap_${testId}`,
3217+
rootCauseHypothesis: 'Submit button is disabled.',
3218+
recommendedFixTarget: {
3219+
kind: 'code' as const,
3220+
reference: SHARED_REF,
3221+
rationale: 'Fix validation predicate.',
3222+
},
3223+
...overrides,
3224+
};
3225+
}
3226+
3227+
it('JSON mode clusters failed tests by shared fix target', async () => {
3228+
const { credentialsPath } = makeCreds();
3229+
const seen: string[] = [];
3230+
const fetchImpl = makeFetch(url => {
3231+
seen.push(url);
3232+
if (url.includes('/tests?') && url.includes('status=failed')) {
3233+
return { body: { items: [FAILED_TEST_A, FAILED_TEST_B, FAILED_TEST_C], nextToken: null } };
3234+
}
3235+
if (url.includes('/tests/test_a/failure/summary')) {
3236+
return { body: summaryFor('test_a') };
3237+
}
3238+
if (url.includes('/tests/test_b/failure/summary')) {
3239+
return { body: summaryFor('test_b') };
3240+
}
3241+
if (url.includes('/tests/test_c/failure/summary')) {
3242+
return {
3243+
body: summaryFor('test_c', {
3244+
failureKind: 'network_timeout',
3245+
rootCauseHypothesis: null,
3246+
recommendedFixTarget: null,
3247+
}),
3248+
};
3249+
}
3250+
throw new Error(`unexpected url: ${url}`);
3251+
});
3252+
const out: string[] = [];
3253+
const got = await runFailureTriage(
3254+
{
3255+
profile: 'default',
3256+
output: 'json',
3257+
debug: false,
3258+
projectId: 'proj_1',
3259+
maxConcurrency: 5,
3260+
},
3261+
{ credentialsPath, fetchImpl, stdout: line => out.push(line) },
3262+
);
3263+
3264+
expect(seen.some(u => u.includes('status=failed'))).toBe(true);
3265+
expect(got.summary.totalFailed).toBe(3);
3266+
expect(got.clusters).toHaveLength(2);
3267+
3268+
const codeCluster = got.clusters.find(c => c.groupReason === 'fix_target');
3269+
expect(codeCluster?.memberTestIds).toEqual(['test_a', 'test_b']);
3270+
// test_b is fresher (updatedAt) and both members have a hypothesis
3271+
expect(codeCluster?.representativeTestId).toBe('test_b');
3272+
3273+
const envCluster = got.clusters.find(c => c.groupReason === 'failure_kind');
3274+
expect(envCluster?.memberTestIds).toEqual(['test_c']);
3275+
3276+
expect(JSON.parse(out[0]!).clusters).toHaveLength(2);
3277+
});
3278+
3279+
it('text mode renders cluster summary lines', async () => {
3280+
const { credentialsPath } = makeCreds();
3281+
const fetchImpl = makeFetch(url => {
3282+
if (url.includes('/tests?')) {
3283+
return { body: { items: [FAILED_TEST_A], nextToken: null } };
3284+
}
3285+
return { body: summaryFor('test_a') };
3286+
});
3287+
const out: string[] = [];
3288+
await runFailureTriage(
3289+
{
3290+
profile: 'default',
3291+
output: 'text',
3292+
debug: false,
3293+
projectId: 'proj_1',
3294+
maxConcurrency: 5,
3295+
},
3296+
{ credentialsPath, fetchImpl, stdout: line => out.push(line) },
3297+
);
3298+
const block = out.join('\n');
3299+
expect(block).toContain('projectId: proj_1');
3300+
expect(block).toContain('representative: test_a');
3301+
expect(block).toContain('Shared fix target:');
3302+
});
3303+
3304+
it('dry-run emits canned clusters without network', async () => {
3305+
const out: string[] = [];
3306+
const got = await runFailureTriage(
3307+
{
3308+
profile: 'default',
3309+
output: 'json',
3310+
debug: false,
3311+
dryRun: true,
3312+
projectId: 'proj_dry',
3313+
maxConcurrency: 5,
3314+
},
3315+
{ stdout: line => out.push(line) },
3316+
);
3317+
expect(got.summary.clusterCount).toBe(2);
3318+
expect(got.clusters[0]?.groupReason).toBe('failure_kind');
3319+
expect(JSON.parse(out[0]!).projectId).toBe('proj_dry');
3320+
});
3321+
3322+
it('returns empty clusters when no failed tests match', async () => {
3323+
const { credentialsPath } = makeCreds();
3324+
const fetchImpl = makeFetch(() => ({ body: { items: [], nextToken: null } }));
3325+
const out: string[] = [];
3326+
const got = await runFailureTriage(
3327+
{
3328+
profile: 'default',
3329+
output: 'json',
3330+
debug: false,
3331+
projectId: 'proj_empty',
3332+
maxConcurrency: 5,
3333+
},
3334+
{ credentialsPath, fetchImpl, stdout: line => out.push(line) },
3335+
);
3336+
expect(got.clusters).toEqual([]);
3337+
expect(got.summary.totalFailed).toBe(0);
3338+
expect(JSON.parse(out[0]!).clusters).toEqual([]);
3339+
});
3340+
3341+
it('skips tests whose failure summary returns NOT_FOUND', async () => {
3342+
const { credentialsPath } = makeCreds();
3343+
const stderrLines: string[] = [];
3344+
const fetchImpl = makeFetch(url => {
3345+
if (url.includes('/tests?')) {
3346+
return { body: { items: [FAILED_TEST_A, FAILED_TEST_B], nextToken: null } };
3347+
}
3348+
if (url.includes('/tests/test_a/failure/summary')) {
3349+
return { body: summaryFor('test_a') };
3350+
}
3351+
return {
3352+
status: 404,
3353+
body: {
3354+
error: {
3355+
code: 'NOT_FOUND',
3356+
message: 'Test has no failing run.',
3357+
nextAction: 'No failing run.',
3358+
requestId: 'req_test',
3359+
details: { resource: 'test', id: 'test_b', reason: 'no_failing_run' },
3360+
},
3361+
},
3362+
};
3363+
});
3364+
const got = await runFailureTriage(
3365+
{
3366+
profile: 'default',
3367+
output: 'json',
3368+
debug: false,
3369+
projectId: 'proj_1',
3370+
maxConcurrency: 5,
3371+
},
3372+
{
3373+
credentialsPath,
3374+
fetchImpl,
3375+
stdout: () => undefined,
3376+
stderr: line => stderrLines.push(line),
3377+
},
3378+
);
3379+
expect(got.summary.totalFailed).toBe(1);
3380+
expect(got.summary.skipped).toBe(1);
3381+
expect(got.skipped?.[0]).toEqual({ testId: 'test_b', reason: 'no_failing_run' });
3382+
expect(stderrLines.some(l => l.includes('skipped'))).toBe(true);
3383+
});
3384+
3385+
it('rejects missing projectId with VALIDATION_ERROR (exit 5)', async () => {
3386+
await expect(
3387+
runFailureTriage(
3388+
{
3389+
profile: 'default',
3390+
output: 'json',
3391+
debug: false,
3392+
projectId: '',
3393+
maxConcurrency: 5,
3394+
},
3395+
{ stdout: () => undefined },
3396+
),
3397+
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
3398+
});
3399+
});
3400+
31713401
// ---------- §6.7 runFailureGet ----------
31723402

31733403
const FAILED_STEPS: CliTestStep[] = [

0 commit comments

Comments
 (0)