Skip to content

Commit e8b29d4

Browse files
authored
[DX-4017] Adds chainlink-test-diagnosis Skill + diagnose Command Improvements (#22323)
* Adds test diagnosis skill and harness improvements * add summary lines * more summary info * CODEOWNERS * More summary data * Prettier analysis * Add tests for panic, timeout, and build failure
1 parent d14b0fe commit e8b29d4

37 files changed

Lines changed: 8600 additions & 524 deletions

.github/CODEOWNERS

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,8 @@
9494
/.github/CODEOWNERS @smartcontractkit/core @smartcontractkit/foundations
9595
/.github/workflows/build-publish.yml @smartcontractkit/devex-cicd
9696
/.github/workflows/devenv* @smartcontractkit/devex-tooling @smartcontractkit/devex-cicd @smartcontractkit/core
97-
/tools/plugout/ @smartcontractkit/devex-cicd
97+
/tools/plugout/ @smartcontractkit/devex-cicd @smartcontractkit/core
98+
/tools/test/ @smartcontractkit/devex-cicd @smartcontractkit/devex-tooling @smartcontractkit/core
9899

99100
/core/chainlink.Dockerfile @smartcontractkit/devex-cicd @smartcontractkit/foundations @smartcontractkit/core
100101

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
---
2+
name: chainlink-test-diagnosis
3+
description: >-
4+
Diagnoses and fixes unstable Chainlink Go tests (flakes, races, timeouts, deadlocks,
5+
slow runs). Use for non-deterministic failures, CI-only instability, or test runtime.
6+
Do NOT use for deterministic failures, routine runs, or full-suite CI prep.
7+
---
8+
9+
<absolute_constraints>
10+
- DO NOT use this skill if the user already has a known fix (apply it directly).
11+
- DO NOT use for deterministic first-run failures (use normal debug).
12+
- DO NOT use for full-suite CI prep (use `make new_test` or `make new_gotestsum` instead).
13+
- ONLY run tests in these packages without explicit user approval: `core/`, `deployment/`. Warn the user if running outside these.
14+
- DO NOT modify the test's core goal to make it pass.
15+
- DO NOT remove tests/assertions unless replacing with better ones or deleting confirmed dead code.
16+
- DO NOT modify package-wide helpers (`testutils`) to fix localized tests.
17+
- IF Postgres sandbox error occurs (`operation not permitted`), ask the user to run the command or approve unsandboxed execution.
18+
- For runs expected >2m: Execute in background. Perform a single 30s crash check, then suspend task and wait for the report.json system notification. DO NOT poll.
19+
</absolute_constraints>
20+
21+
<context_compaction>
22+
When summarizing context, strictly maintain state in this format:
23+
24+
## [TestName]
25+
Failure: [suspected failure reasons]
26+
SuspectedFix: [the fix you've implemented or want to try]
27+
NextStep: [the next step for diagnosing/fixing/verifying the test]
28+
</context_compaction>
29+
30+
## Initialization
31+
1. Verify target scope (test, package, or issue). If unknown, prompt user.
32+
2. Formulate initial hypothesis: flake, timeout, slow, panic, deadlock, or race.
33+
3. Run bounded diagnosis (`--fail-fast` or low `--iterations`).
34+
35+
<cli_reference>
36+
Base Command: `go -C tools/test run . diagnose [harness_flags] -- [go_test_flags] ./path`
37+
- ALWAYS use `--ai-output` before the `--`.
38+
- Harness flags (before `--`): `--iterations N`, `--fail-fast-on=(timeout|slow)`, `--parallel-iterations N`
39+
- Go test flags (after `--`): `--run '^TestName$'`, `--timeout 10m`, `--race`
40+
- Help: `go -C tools/test run . diagnose -h`
41+
- Shuffle test order: `go test -shuffle=on -count=50 -failfast ./path/to/package`
42+
- CPU/Memory load: `go test -cpu=1,2,4 -count=20 -failfast ./path/to/package`
43+
- Lint check: `golangci-lint run ./<packages-you-change> --fix`
44+
</cli_reference>
45+
46+
## Execution & Analysis
47+
- **Postgres:** Serial diagnose restores DB between iterations. Parallel gives each worker an ephemeral DB. Neither resets between tests *within* one iteration.
48+
- **Report Analysis:** Read `<resultsDir>/report.json` using `jq`. Top-level buckets: `flakes`, `failures`, `timeouts`, `slow`. Harness and `go test` invocation: `jq .run` (argv, iteration count, fail-fast, shuffle, etc.).
49+
- **Narrowing:** If many tests flag, look for similarities in their failures. If found, present that to the user and ask if they want to continue with that assumption. If not, try to focus on the most problematic test.
50+
- **Profiles:** When logs/report are insufficient, use standard `go test` profile flags (`-race`, `-cpuprofile`, `-trace`, etc.). View with `go tool pprof` or `go tool trace`.
51+
52+
<logs_structure>
53+
<resultsDir>/
54+
|-- iteration-n.log.jsonl # DO NOT READ unless absolutely necessary; full log outputs, long and messy
55+
|-- postgres-state-n.md # Final state of postgres DB after test iteration. Read if diagnosing DB-based errors or hangs.
56+
|-- report.json # Read this; summary of full `diagnose` run (include `jq .run` for go test args and harness flags)
57+
|-- report.csv # DO NOT READ; human readable csv
58+
|-- logs/ # Extracted individual test logs
59+
|---- pkg_TestName_iter-n.log # Logs for individual slow/failing test
60+
</logs_structure>
61+
62+
<sub_agent_protocol>
63+
When reading log files from the `logs/` directory or `iteration-n.log.jsonl`, you MUST spawn a sub-agent to read from the end up.
64+
The sub-agent MUST output ONLY valid JSON matching this exact structure, with no markdown, no explanations, and no yapping:
65+
{
66+
"logs_read": ["log_path_1.log", "log_path_2.log"],
67+
"failure_diagnosis": [
68+
{
69+
"possible_reason": "explanation",
70+
"evidence": "reasoning and evidence"
71+
}
72+
]
73+
}
74+
</sub_agent_protocol>
75+
76+
## Playbook & General Fixes
77+
Lead with your hypothesis before writing code. Show contextual diffs, do not describe fixes abstractly.
78+
79+
1. **Check Known Patterns:** See `<known_patterns>` below for common flaky test patterns and fixes in this repo. Try them first.
80+
2. **Isolate (Pass alone, fail in package):** Cross-test dependency. Missing `t.Cleanup`, global state (`var` singletons, loggers), or shared mock servers. Fix by moving state to per-test constructors or using `t.Cleanup`.
81+
3. **Order (Shuffle changes pass rate):** Same as isolation. Fix cross-test leakage. Capture failing seed and provide to user.
82+
4. **Race:** Triggers on weird stack traces or nil pointers. Use `-race`. Fix with `sync.Mutex`, `atomic.*`, or narrow shared fields.
83+
5. **Timeout:** Check logs for blocking (chan receive, `Wait`, `testutils.WaitTimeout`). Use `synctest` to improve tests relying on channels.
84+
6. **Slow:** Compare `p50` vs `max_elapsed`. Look for `time.Sleep` or coarse polling loops. Replace with `require.eventually` or channel sync. Simulated chains are frequent offenders.
85+
7. **Resources:** If failing under load/CI only, DB connections might be exhausted by `t.Parallel()`. Use separate schema/user per test.
86+
87+
<known_patterns>
88+
<pattern name="LogPoller Timing Race">
89+
<symptom>
90+
The dominant flake pattern in simulated-chain tests that enable `Feature.LogPoller = true`. Error message contains `"failed to retrieve log value pointer of block N: not found"` and the stack trace points to a `FilterXxx` call that immediately follows a `backend.Commit()`. Note: Raw geth bindings do NOT have this race, only interface types backed by LogPoller.
91+
</symptom>
92+
93+
<fix_a_receipt_parsing>
94+
For one-shot events where you only need a value emitted at creation (e.g. `SubscriptionCreated`, `RequestSent`): parse the tx receipt directly instead of calling `FilterXxx`.
95+
```go
96+
// AFTER (deterministic):
97+
tx, err := coordinator.CreateSubscription(auth)
98+
require.NoError(t, err)
99+
backend.Commit()
100+
receipt, err := backend.Client().TransactionReceipt(ctx, tx.Hash())
101+
require.NoError(t, err)
102+
require.Equal(t, uint64(1), receipt.Status)
103+
var subID *big.Int
104+
for _, log := range receipt.Logs {
105+
if log.Address != coordinatorAddress {
106+
continue
107+
}
108+
// SubscriptionCreated(uint64 indexed subId, address owner): Topics[1] = subId
109+
subID = new(big.Int).SetBytes(log.Topics[1].Bytes())
110+
break
111+
}
112+
require.NotNil(t, subID, "no SubscriptionCreated log in receipt")
113+
```
114+
</fix_a_receipt_parsing>
115+
116+
<fix_b_non_fatal_filter>
117+
For diagnostic/verification filters called inside a polling loop: a transient LogPoller error must not crash the test — it should retry.
118+
```go
119+
// AFTER (retries):
120+
require.Eventually(t, func() bool {
121+
// LogPoller may not have indexed the latest block yet; skip and retry.
122+
it, err := coordinator.FilterRandomWordsForced(nil, ids, subs, addrs)
123+
if err == nil {
124+
for it.Next() {
125+
require.Equal(t, expected, it.Event.Field)
126+
}
127+
}
128+
return utils.IsEmpty(commitment[:])
129+
}, timeout, tick)
130+
```
131+
</fix_b_non_fatal_filter>
132+
133+
<fix_c_dynamic_reference>
134+
If `require.Eventually` commits new blocks on each iteration, compute the reference block number inside the closure so it doesn't become stale.
135+
```go
136+
// AFTER (dynamic):
137+
require.Eventually(t, func() bool {
138+
backend.Commit()
139+
tip, err := backend.Client().HeaderByNumber(ctx, nil)
140+
if err != nil || tip == nil || tip.Number.Uint64() < 256 {
141+
return false
142+
}
143+
_, err = bhsContract.GetBlockhash(nil, new(big.Int).SetUint64(tip.Number.Uint64()-256))
144+
return err == nil
145+
}, testutils.WaitTimeoutCustom(t, 5*time.Minute), time.Second)
146+
```
147+
</fix_c_dynamic_reference>
148+
</pattern>
149+
150+
<pattern name="TXM broadcast latency (parallel load)">
151+
<symptom>
152+
Under 5+ parallel test workers, TXM broadcasts transactions asynchronously. A heartbeat/fulfillment tx may be logged as "sent" by the service but not yet in the mempool when the next `backend.Commit()` fires. Test detects service as active, but stored block is `N+1` or later than the fixed reference.
153+
</symptom>
154+
<fix>
155+
Use the dynamic reference fix (`fix_c_dynamic_reference` from LogPoller Timing Race) so the check tracks wherever the tx actually lands.
156+
</fix>
157+
</pattern>
158+
</known_patterns>

tools/test/.claude/skills/skills

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
./.agents/skills

tools/test/AGENTS.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@ A test runner harness for the /chainlink repo.
33
<goals>
44
- Provide a single, easy command to setup and run tests in /chainlink repo, eliminating `make` command chaining.
55
- Enable automatically re-running tests and analyzing results to catch and diagnose flakes and slow tests
6-
- Provide an AI skill for the process in `.agents/skills/diagnose-tests/SKILL.md`
6+
- Provide an AI skill for the process in `.agents/skills/chainlink-test-diagnosis/SKILL.md` (under `tools/test/`)
77
</goals>
88

99
<rules>
1010
- From /chainlink root, document `make new_test`, `make new_gotestsum`, and `make new_test_diagnose`. When working only inside this module, `go run . …` is fine.
11-
- Each output should account for a pretty, human-readable terminal experience, and a minimal version meant for AI ingestion
11+
- Each output should account for a pretty, human-readable terminal experience, and a minimal version meant for AI ingestion.
12+
- Harness-owned terminal messages go through `internal/output` (`--ai-output` vs human, inline progress policy); child test processes still use raw stdout/stderr passthrough where appropriate.
1213
</rules>
1314

1415
<modes>

tools/test/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ make new_gotestsum ARGS="--format=testname -- -count=1 ./core/..."
2020
# Diagnose and fix flaky tests
2121
go -C tools/test run . diagnose --iterations 5 -- --timeout=9m ./core/...
2222
make new_test_diagnose ARGS="--iterations 5 -- --timeout=9m ./core/..."
23+
24+
# Stop diagnose early only when a specific signal appears
25+
go -C tools/test run . diagnose --iterations 20 --fail-fast-on=timeout -- --timeout=9m ./core/...
26+
go -C tools/test run . diagnose --iterations 20 --fail-fast-on=slow --slow-threshold=10s -- ./core/...
2327
```
2428

2529
When **developing only inside this directory** (nested module), use `go run .` instead of `go -C tools/test`:
@@ -32,7 +36,7 @@ go run . diagnose --iterations 5 -- ./core/...
3236

3337
### AI Skill
3438

35-
Use the [/diagnose-tests](/.agents/skills/diagnose-tests/SKILL.md) ai skill with your favorite agent to run a `diagnose` loop.
39+
Use the [chainlink-test-diagnosis](./.agents/skills/chainlink-test-diagnosis/SKILL.md) skill with your favorite agent to find, diagnose, and fix flaky, slow, and otherwise unstable tests.
3640

3741
## Why not just `go test`?
3842

tools/test/internal/cmd/diagnose.go

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ package cmd
22

33
import (
44
"errors"
5-
"fmt"
6-
"os"
75
"time"
86

97
"github.com/spf13/cobra"
108

119
"github.com/smartcontractkit/chainlink/v2/tools/test/internal/config"
10+
"github.com/smartcontractkit/chainlink/v2/tools/test/internal/db"
11+
"github.com/smartcontractkit/chainlink/v2/tools/test/internal/output"
1212
"github.com/smartcontractkit/chainlink/v2/tools/test/internal/runner"
1313
)
1414

@@ -30,28 +30,56 @@ go -C tools/test run . diagnose --iterations 10 -- ./core/...`,
3030
if err != nil {
3131
return err
3232
}
33+
out := output.NewFromApp(conf)
3334

34-
defer func() {
35-
if err := dbHandle.Cleanup(); err != nil {
36-
fmt.Fprintf(os.Stderr, "error tearing down postgres: %v\n", err)
37-
}
38-
}()
35+
if err = validateDiagnoseConfig(conf); err != nil {
36+
return err
37+
}
3938

40-
if conf.Iterations < 1 {
41-
return errors.New("--iterations must be >= 1")
39+
if err = runner.WarnDiagnoseGoTestCount(out.WarnWriter(), args); err != nil {
40+
return err
4241
}
4342

44-
if err := runner.WarnDiagnoseGoTestCount(os.Stderr, args); err != nil {
43+
pool, err := db.EnsurePool(cmd.Context(), conf, out, runner.EffectiveParallelIterations(conf))
44+
if err != nil {
4545
return err
4646
}
47+
defer func() {
48+
if err := pool.Cleanup(); err != nil {
49+
out.Stderrf("error tearing down postgres: %v\n", err)
50+
}
51+
}()
4752

48-
return runner.Diagnose(cmd.Context(), conf, args, dbHandle.Reset, dbHandle.DumpDiagnostics)
53+
return runner.Diagnose(cmd.Context(), conf, out, args, pool.Resources())
4954
},
5055
}
5156

5257
func init() {
5358
diagnoseCmd.Flags().Int("iterations", 1, "number of full test runs")
59+
diagnoseCmd.Flags().Int("parallel-iterations", 1, "maximum number of diagnose iterations to run concurrently; each worker uses its own ephemeral Postgres")
5460
diagnoseCmd.Flags().Duration("slow-threshold", 30*time.Second, "tests whose max Elapsed exceeds this are flagged slow")
5561
diagnoseCmd.Flags().Bool("fail-fast", false, "stop this diagnose run immediately if any iteration fails")
62+
diagnoseCmd.Flags().StringSlice("fail-fast-on", nil, `stop this diagnose run immediately when an iteration matches one or more categories: "failure", "timeout", "slow", or "any"`)
5663
diagnoseCmd.Flags().Bool("shuffle-seed", false, "randomize test order each iteration; a unique seed is generated per iteration and recorded in report.json for reproduction")
5764
}
65+
66+
func validateDiagnoseConfig(conf *config.App) error {
67+
if conf.Iterations < 1 {
68+
return errors.New("--iterations must be >= 1")
69+
}
70+
if conf.ParallelIterations < 1 {
71+
return errors.New("--parallel-iterations must be >= 1")
72+
}
73+
if conf.ParallelIterations > conf.Iterations {
74+
return errors.New("--parallel-iterations must be <= --iterations")
75+
}
76+
if conf.ParallelIterations > 1 && conf.DatabaseURL != "" {
77+
return errors.New("--parallel-iterations > 1 cannot be used with --database-url")
78+
}
79+
failFastOn, err := config.NormalizeFailFastOn(conf.FailFastOn)
80+
if err != nil {
81+
return err
82+
}
83+
conf.FailFastOn = failFastOn
84+
return nil
85+
}

tools/test/internal/cmd/gotestsum.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/spf13/cobra"
99

1010
"github.com/smartcontractkit/chainlink/v2/tools/test/internal/config"
11+
"github.com/smartcontractkit/chainlink/v2/tools/test/internal/output"
1112
"github.com/smartcontractkit/chainlink/v2/tools/test/internal/runner"
1213
)
1314

@@ -31,9 +32,14 @@ go -C tools/test run . --ai-output gotestsum --format=testname -- -count=1 ./cor
3132
// runGotestsum runs the gotestsum path. lookPath and deferCleanup are injectable for tests.
3233
// deferCleanup must run on every exit path after PersistentPreRunE may have started Postgres.
3334
func runGotestsum(cmd *cobra.Command, args []string, lookPath func(string) (string, error), deferCleanup func() error) error {
35+
var out *output.Printer
3436
defer func() {
3537
if err := deferCleanup(); err != nil {
36-
fmt.Fprintf(os.Stderr, "error tearing down postgres: %v\n", err)
38+
if out != nil {
39+
out.Stderrf("error tearing down postgres: %v\n", err)
40+
} else {
41+
_, _ = fmt.Fprintf(os.Stderr, "error tearing down postgres: %v\n", err)
42+
}
3743
}
3844
}()
3945
if _, err := lookPath("gotestsum"); err != nil {
@@ -43,5 +49,6 @@ func runGotestsum(cmd *cobra.Command, args []string, lookPath func(string) (stri
4349
if err != nil {
4450
return err
4551
}
52+
out = output.NewFromApp(conf)
4653
return runner.Gotestsum(cmd.Context(), conf, args)
4754
}

tools/test/internal/cmd/root.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

1313
"github.com/smartcontractkit/chainlink/v2/tools/test/internal/config"
1414
"github.com/smartcontractkit/chainlink/v2/tools/test/internal/db"
15+
"github.com/smartcontractkit/chainlink/v2/tools/test/internal/output"
1516
)
1617

1718
var dbHandle *db.Handle
@@ -36,12 +37,15 @@ go -C tools/test run . gotestsum --format=dots -- -count=1 ./core/...
3637
# Run the full core test suite 10 times and collect statistics, debug logs, and more
3738
go -C tools/test run . diagnose --iterations 10 -- --timeout=15m ./core/...`,
3839
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
40+
if cmd.Name() == "setup-testdb" || cmd.Name() == "remove-testdb" || cmd.Name() == "diagnose" {
41+
return nil
42+
}
3943
conf, err := config.Load(cmd)
4044
if err != nil {
4145
return err
4246
}
4347

44-
dbHandle, err = db.Ensure(cmd.Context(), conf)
48+
dbHandle, err = db.Ensure(cmd.Context(), conf, output.NewFromApp(conf))
4549
if err != nil {
4650
return err
4751
}

0 commit comments

Comments
 (0)