Skip to content

Recovered: feat(test): add failure triage for batch root-cause grouping (#44 by @SahilRakhaiya05)#245

Open
jangjos-128 wants to merge 8 commits into
TestSprite:mainfrom
SahilRakhaiya05:feat/failure-triage
Open

Recovered: feat(test): add failure triage for batch root-cause grouping (#44 by @SahilRakhaiya05)#245
jangjos-128 wants to merge 8 commits into
TestSprite:mainfrom
SahilRakhaiya05:feat/failure-triage

Conversation

@jangjos-128

@jangjos-128 jangjos-128 commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

This PR adds a new CLI command:

testsprite test failure triage --project <project-id> --output json

The command groups failed tests into root-cause clusters instead of returning a flat list of unrelated failures. This helps agents and developers quickly identify the highest-priority issue, investigate one representative test first, and avoid fixing the same underlying problem multiple times.

Each cluster includes:

  • A human-readable label
  • A representative test to investigate first
  • All affected test IDs
  • A confidence score
  • A fix priority, where lower means higher priority

The command uses existing TestSprite APIs only, so no backend changes are required. It fetches lightweight failure summary data per test and does not download screenshots, videos, or full failure bundles.


Problem

Today, when a batch run fails many tests, the CLI and agents only see individual failures:

testsprite test run --all --project proj_xxx --wait
# → tests failed, each reported separately

This makes agents and developers:

  1. Review many separate failed rows.
  2. Guess which failure to investigate first.
  3. Download multiple failure bundles.
  4. Often fix the same underlying issue more than once.

The CLI already has strong per-test analysis through test failure get, test failure summary, rootCauseHypothesis, recommendedFixTarget, and failureKind.

What was missing is cross-test grouping after a batch failure.


Solution

test failure triage works in three steps:

  1. Lists all failed tests for a project.
  2. Fetches failure summaries for each failed test in parallel.
  3. Groups failures client-side using deterministic heuristics.

The grouping algorithm uses the following signals:

  • Shared recommendedFixTarget.reference
  • Environment-wide failureKind, such as network_timeout or infra
  • Similar rootCauseHypothesis
  • Singleton fallback when no grouping signal exists

Clusters are ordered by fix priority first, then by member count.


Command surface

testsprite test failure triage --project <project-id> [options]

Supported options:

  • --project <id> — required project ID
  • --type frontend|backend — filter failed tests by type
  • --filter <substr> — filter tests by name substring
  • --max-concurrency <n> — parallel summary fetches, default 5
  • --output json|text — machine or human output
  • --endpoint-url <url> — override API host

Also supports global flags such as --dry-run, --profile, --verbose, and --debug.


Recommended agent workflow

# 1. Batch run fails
testsprite test run --all --project <project-id> --wait --output json

# 2. Triage failures into clusters
testsprite test failure triage --project <project-id> --output json

# 3. Download one bundle from the highest-priority representative test
testsprite test failure get <representativeTestId> --out ./.testsprite/failure

# 4. Fix the issue and rerun the representative first
testsprite test rerun <representativeTestId> --wait

# 5. Run full regression after the representative passes
testsprite test rerun --all --project <project-id> --wait

The agent skill was also updated to recommend triage before downloading bundles when multiple tests fail.


Implementation details

Added new grouping logic in:

src/lib/failure-triage.ts

This includes:

  • normalizeHypothesis()
  • computeGroupKey()
  • pickRepresentativeTestId()
  • computeClusterConfidence()
  • computeFixPriority()
  • buildFailureClusters()
  • renderFailureTriageText()

Added command implementation in:

src/commands/test.ts

The command validates inputs, paginates failed tests, applies filters, fetches summaries with bounded concurrency, handles stale failed rows, and emits JSON or text output through the existing output system.


Test coverage

This PR adds 18 automated tests:

  • 11 unit tests for src/lib/failure-triage.test.ts
  • 7 integration tests for src/commands/test.test.ts

Coverage includes:

  • Grouping by fix target
  • Grouping by failure kind
  • Grouping by hypothesis
  • Singleton fallback
  • Representative test selection
  • Cluster confidence and priority
  • Empty projects
  • Stale failed rows
  • Missing project validation
  • JSON and text output
  • Help surface

Future work

Out of scope for this PR:

  • Native GET /projects/{id}/failures/clusters API
  • Semantic embedding clustering on rootCauseHypothesis
  • BE wave/cascade graph integration
  • --rerun-representatives --wait orchestration flag

Checklist

  • New command with JSON and text output
  • Uses existing APIs only
  • Deterministic grouping, no CLI LLM calls
  • 18 automated tests added
  • Typecheck, lint, and build pass
  • Documentation updated
  • Agent skill updated
  • Help snapshot added
  • Manual production API smoke test completed

The Pr solving issue #116

Summary by CodeRabbit

  • New Features

    • Added test failure triage --project <id> to group failed tests into root-cause clusters.
    • Reports representative tests, affected test IDs, confidence, and fix priority without downloading failure bundles.
    • Supports filtering by test type or name, configurable concurrency, JSON output, and dry-run previews.
  • Documentation

    • Added command usage, examples, and guidance for investigating representative failures first.

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.
…iage

- Fix test failure triage help snapshot default value quoting

- Run prettier on changed files

- Add filter and max-concurrency validation tests

- Remove draft issue/PR markdown files from repo
- Move --dry-run payload to sampleFailureTriageResult in samples.ts
- Use .option for --project so requireProjectId emits exit 5
- Build cluster labels from the chosen representative test
- Append short hash suffix to clusterId to avoid slug collisions
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Adds a client-side test failure triage command that fetches failure summaries, clusters failed tests by root-cause signals, returns scored representatives and members, supports filtering, concurrency limits, dry-run output, and documents the workflow.

Changes

Failure triage

Layer / File(s) Summary
Failure clustering engine
src/lib/failure-triage.ts, src/lib/failure-triage.test.ts
Defines triage contracts and deterministic grouping, representative selection, scoring, cluster IDs, sorting, rendering, and tests.
CLI triage orchestration
src/commands/test.ts, src/commands/test.test.ts, test/help.snapshot.test.ts
Adds runFailureTriage, bounded summary fetching, filters, validation, skipped-test handling, CLI wiring, and help-surface tests.
Dry-run triage fixtures
src/lib/dry-run/samples.ts, src/lib/dry-run/samples.test.ts
Provides and validates canned triage results for dry-run execution.
Triage workflow documentation
CHANGELOG.md, DOCUMENTATION.md, README.md, skills/testsprite-verify.skill.md
Documents the command, clustering behavior, examples, outputs, and conditional failure-investigation workflow.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CLI
  participant runFailureTriage
  participant TestsAPI
  participant FailureSummaryAPI
  participant buildFailureClusters
  CLI->>runFailureTriage: triage options
  runFailureTriage->>TestsAPI: fetch failed tests
  runFailureTriage->>FailureSummaryAPI: fetch failure summaries
  runFailureTriage->>buildFailureClusters: clustering inputs
  buildFailureClusters-->>runFailureTriage: scored clusters
  runFailureTriage-->>CLI: JSON or text output
Loading

Possibly related issues

Suggested reviewers: ruili-testsprite, zeshi-du

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the new test failure triage feature and its root-cause grouping purpose.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch feat/failure-triage

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@skills/testsprite-verify.skill.md`:
- Line 434: Update the single-failure workflow instruction to use the
test-id-based failure bundle command, replacing the run-id-dependent artifact
retrieval with testsprite test failure get <test-id>. Keep the instruction
scoped to the single failed test path and preserve the existing triage-skipping
behavior.

In `@src/commands/test.ts`:
- Around line 4520-4557: Update the failure-summary fan-out around startNext and
its request catch handler so only NOT_FOUND errors whose details explicitly
identify no_failing_run are added to skipped; propagate all other errors
unchanged for the existing documented exit-code mapping. Track terminal
rejection state, prevent startNext from scheduling additional requests after the
first terminal error, and ensure in-flight completions cannot resolve
successfully afterward. Add coverage for a distinct NOT_FOUND reason that
verifies propagation and confirms no further requests are started.

In `@src/lib/dry-run/samples.ts`:
- Around line 350-422: Update sampleFailureTriageResult to construct canned
FailureTriageInput[] matching these scenarios and return the result of
buildFailureClusters instead of hard-coded clusters and derived fields. Preserve
the intended network-timeout and CheckoutForm fix-target cases, allowing
production-generated priorities, hashed cluster IDs, labels, and summaries to
flow through. Add the required matching dry-run parity test using this sample
and the production clustering output.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1c011c06-262d-4835-9950-72c7ddcdc1f7

📥 Commits

Reviewing files that changed from the base of the PR and between 60d55e4 and 59a6b6e.

⛔ Files ignored due to path filters (1)
  • test/__snapshots__/help.snapshot.test.ts.snap is excluded by !**/*.snap, !**/*.snap
📒 Files selected for processing (11)
  • CHANGELOG.md
  • DOCUMENTATION.md
  • README.md
  • skills/testsprite-verify.skill.md
  • src/commands/test.test.ts
  • src/commands/test.ts
  • src/lib/dry-run/samples.test.ts
  • src/lib/dry-run/samples.ts
  • src/lib/failure-triage.test.ts
  • src/lib/failure-triage.ts
  • test/help.snapshot.test.ts

representative test from the highest-priority cluster — not an arbitrary failed
test. After a fix, rerun that representative before rerunning the full suite.

For a **single** failed test, skip triage and go straight to the artifact:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== skills/testsprite-verify.skill.md around line 434 ==\n'
sed -n '410,455p' skills/testsprite-verify.skill.md

printf '\n== DOCUMENTATION.md search for failure-related commands ==\n'
rg -n "test failure get|test list --status failed|run ID|run id|latest-failure|failure" DOCUMENTATION.md skills/testsprite-verify.skill.md

Repository: TestSprite/testsprite-cli

Length of output: 7971


Use the test-id-based failure bundle here. test artifact get needs a run-id; for the single-failure path, call testsprite test failure get <test-id> instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@skills/testsprite-verify.skill.md` at line 434, Update the single-failure
workflow instruction to use the test-id-based failure bundle command, replacing
the run-id-dependent artifact retrieval with testsprite test failure get
<test-id>. Keep the instruction scoped to the single failed test path and
preserve the existing triage-skipping behavior.

Comment thread src/commands/test.ts
Comment on lines +4520 to +4557
await new Promise<void>((resolve, reject) => {
function startNext(): void {
while (inFlight < concurrencyLimit && nextIdx < failedTests.length) {
const test = failedTests[nextIdx++]!;
inFlight++;
client
.get<CliFailureSummary>(`/tests/${encodeURIComponent(test.id)}/failure/summary`)
.then(summary => {
triageInputs.push({
testId: test.id,
testName: test.name,
testType: test.type,
updatedAt: test.updatedAt,
summary: {
status: summary.status,
failureKind: summary.failureKind,
snapshotId: summary.snapshotId,
rootCauseHypothesis: summary.rootCauseHypothesis,
recommendedFixTarget: summary.recommendedFixTarget,
},
});
inFlight--;
startNext();
if (inFlight === 0 && nextIdx >= failedTests.length) resolve();
})
.catch(err => {
if (err instanceof ApiError && err.code === 'NOT_FOUND') {
skipped.push({ testId: test.id, reason: 'no_failing_run' });
if (opts.verbose) {
stderrFn(`[triage] skipped ${test.id} — no failing run (race or stale list row)`);
}
} else {
reject(err);
}
inFlight--;
startNext();
if (inFlight === 0 && nextIdx >= failedTests.length) resolve();
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not swallow unrelated NOT_FOUND errors or continue fan-out after rejection.

Every NOT_FOUND currently becomes no_failing_run, so missing-resource or unsupported-route errors return success instead of their mapped exit code. Moreover, startNext() continues scheduling requests after reject(err). Only skip errors whose details explicitly identify no_failing_run, and stop scheduling after the first terminal error.

Add coverage for another NOT_FOUND reason and assert that no further requests start. As per path instructions, “error paths must map to the documented exit code; don't introduce ad-hoc codes.”

Proposed approach
   let nextIdx = 0;
   let inFlight = 0;
+  let aborted = false;

   await new Promise<void>((resolve, reject) => {
     function startNext(): void {
-      while (inFlight < concurrencyLimit && nextIdx < failedTests.length) {
+      while (!aborted && inFlight < concurrencyLimit && nextIdx < failedTests.length) {
         // ...
       }
     }

     // ...
     .catch(err => {
-      if (err instanceof ApiError && err.code === 'NOT_FOUND') {
+      const reason =
+        err instanceof ApiError
+          ? err.getDetail('reason', (value): value is string => typeof value === 'string')
+          : undefined;
+
+      if (err instanceof ApiError && err.code === 'NOT_FOUND' && reason === 'no_failing_run') {
         skipped.push({ testId: test.id, reason: 'no_failing_run' });
       } else {
+        aborted = true;
         reject(err);
       }
       inFlight--;
       startNext();
-      if (inFlight === 0 && nextIdx >= failedTests.length) resolve();
+      if (!aborted && inFlight === 0 && nextIdx >= failedTests.length) resolve();
     });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await new Promise<void>((resolve, reject) => {
function startNext(): void {
while (inFlight < concurrencyLimit && nextIdx < failedTests.length) {
const test = failedTests[nextIdx++]!;
inFlight++;
client
.get<CliFailureSummary>(`/tests/${encodeURIComponent(test.id)}/failure/summary`)
.then(summary => {
triageInputs.push({
testId: test.id,
testName: test.name,
testType: test.type,
updatedAt: test.updatedAt,
summary: {
status: summary.status,
failureKind: summary.failureKind,
snapshotId: summary.snapshotId,
rootCauseHypothesis: summary.rootCauseHypothesis,
recommendedFixTarget: summary.recommendedFixTarget,
},
});
inFlight--;
startNext();
if (inFlight === 0 && nextIdx >= failedTests.length) resolve();
})
.catch(err => {
if (err instanceof ApiError && err.code === 'NOT_FOUND') {
skipped.push({ testId: test.id, reason: 'no_failing_run' });
if (opts.verbose) {
stderrFn(`[triage] skipped ${test.id} — no failing run (race or stale list row)`);
}
} else {
reject(err);
}
inFlight--;
startNext();
if (inFlight === 0 && nextIdx >= failedTests.length) resolve();
});
let nextIdx = 0;
let inFlight = 0;
let aborted = false;
await new Promise<void>((resolve, reject) => {
function startNext(): void {
while (!aborted && inFlight < concurrencyLimit && nextIdx < failedTests.length) {
const test = failedTests[nextIdx++]!;
inFlight++;
client
.get<CliFailureSummary>(`/tests/${encodeURIComponent(test.id)}/failure/summary`)
.then(summary => {
triageInputs.push({
testId: test.id,
testName: test.name,
testType: test.type,
updatedAt: test.updatedAt,
summary: {
status: summary.status,
failureKind: summary.failureKind,
snapshotId: summary.snapshotId,
rootCauseHypothesis: summary.rootCauseHypothesis,
recommendedFixTarget: summary.recommendedFixTarget,
},
});
inFlight--;
startNext();
if (inFlight === 0 && nextIdx >= failedTests.length) resolve();
})
.catch(err => {
const reason =
err instanceof ApiError
? err.getDetail('reason', (value): value is string => typeof value === 'string')
: undefined;
if (err instanceof ApiError && err.code === 'NOT_FOUND' && reason === 'no_failing_run') {
skipped.push({ testId: test.id, reason: 'no_failing_run' });
if (opts.verbose) {
stderrFn(`[triage] skipped ${test.id} — no failing run (race or stale list row)`);
}
} else {
aborted = true;
reject(err);
}
inFlight--;
startNext();
if (!aborted && inFlight === 0 && nextIdx >= failedTests.length) resolve();
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/test.ts` around lines 4520 - 4557, Update the failure-summary
fan-out around startNext and its request catch handler so only NOT_FOUND errors
whose details explicitly identify no_failing_run are added to skipped; propagate
all other errors unchanged for the existing documented exit-code mapping. Track
terminal rejection state, prevent startNext from scheduling additional requests
after the first terminal error, and ensure in-flight completions cannot resolve
successfully afterward. Add coverage for a distinct NOT_FOUND reason that
verifies propagation and confirms no further requests are started.

Source: Path instructions

Comment on lines +350 to +422
export function sampleFailureTriageResult(projectId: string): FailureTriageResult {
return {
projectId,
clusters: [
{
clusterId: 'cluster_kind_network_timeout',
label: 'Environment issue (network_timeout)',
groupKey: 'kind:network_timeout',
groupReason: 'failure_kind',
failureKind: 'network_timeout',
representativeTestId: 'test_dryrun_a',
memberTestIds: ['test_dryrun_a', 'test_dryrun_b'],
members: [
{
testId: 'test_dryrun_a',
testName: 'Dry-run checkout flow',
testType: 'frontend',
updatedAt: '2026-06-26T12:00:00.000Z',
status: 'failed',
failureKind: 'network_timeout',
snapshotId: 'snap_dryrun_a',
rootCauseHypothesis: null,
recommendedFixTarget: null,
},
{
testId: 'test_dryrun_b',
testName: 'Dry-run profile update',
testType: 'frontend',
updatedAt: '2026-06-26T12:01:00.000Z',
status: 'failed',
failureKind: 'network_timeout',
snapshotId: 'snap_dryrun_b',
rootCauseHypothesis: null,
recommendedFixTarget: null,
},
],
canonicalRootCause: null,
confidence: 0.88,
fixPriority: 1,
},
{
clusterId: 'cluster_ref_src_components_checkoutform_tsx_412',
label: 'Shared fix target: src/components/CheckoutForm.tsx:412',
groupKey: 'ref:src/components/CheckoutForm.tsx:412',
groupReason: 'fix_target',
failureKind: 'assertion',
representativeTestId: 'test_dryrun_c',
memberTestIds: ['test_dryrun_c'],
members: [
{
testId: 'test_dryrun_c',
testName: 'Dry-run submit checkout',
testType: 'frontend',
updatedAt: '2026-06-26T12:02:00.000Z',
status: 'failed',
failureKind: 'assertion',
snapshotId: 'snap_dryrun_c',
rootCauseHypothesis:
'Submit button is disabled because the credit-card field is empty.',
recommendedFixTarget: {
kind: 'code',
reference: 'src/components/CheckoutForm.tsx:412',
rationale: 'Disabled state originates from `isFormValid()`.',
},
},
],
canonicalRootCause: 'Submit button is disabled because the credit-card field is empty.',
confidence: 0.7,
fixPriority: 3,
},
],
summary: { totalFailed: 3, clusterCount: 2, skipped: 0 },
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Generate the dry-run result through the production clustering engine.

This fixture has already drifted from live output: a one-member fix_target cluster receives priority 6 in buildFailureClusters, not 3, and production cluster IDs include hash suffixes. Build canned FailureTriageInput[] and pass them to buildFailureClusters so derived fields cannot diverge.

As per path instructions, dry-run parity requires a matching canned sample and test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/dry-run/samples.ts` around lines 350 - 422, Update
sampleFailureTriageResult to construct canned FailureTriageInput[] matching
these scenarios and return the result of buildFailureClusters instead of
hard-coded clusters and derived fields. Preserve the intended network-timeout
and CheckoutForm fix-target cases, allowing production-generated priorities,
hashed cluster IDs, labels, and summaries to flow through. Add the required
matching dry-run parity test using this sample and the production clustering
output.

Source: Path instructions

@jangjos-128

Copy link
Copy Markdown
Contributor Author

Hi @SahilRakhaiya05 👋 — this is the recovery of your PR #44 , which was auto-closed by an error in our 2026-07-09 release process. Your commits are intact — thank you for the contribution!

It currently shows merge conflicts because main has moved on since your branch was cut. To clear them, update your PR branch (BRANCH) against the latest main — either option works and both update this PR in place:

Option A — merge (no force-push):

git checkout BRANCH
git fetch upstream          # your remote for TestSprite/testsprite-cli (may be "origin")
git merge upstream/main     # resolve conflicts, commit
git push

Option B — rebase (cleaner history, needs force):

git checkout BRANCH
git fetch upstream
git rebase upstream/main    # resolve conflicts
git push --force-with-lease

Once you push, the conflicts here resolve automatically and we'll take it into review.

Sorry again for the extra step!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants