Hunt bugs
What a bug report states
Testkit files each bug as an issue in GitHub or Gitea. The issue has five sections, names the test that fails, and ends with a facts block.
On this page
From a bug record to an issue
A bug report starts as a bug record. The bug record is a JSON file that your coding agent writes during a campaign, or that you write yourself. Three commands turn it into an issue.
| Command | What it does |
|---|---|
testkit bug guide | Prints the bug record format, the severities, and the cause, impact, and escape lists |
testkit bug record <file> | Checks a bug record, stores it as a draft under .testkit/bugs/, and prints the draft id |
testkit bug file <id> | Runs the draft's test and files the bug as an issue |
The commands work with or without an active campaign. Bug filing needs no Turnstone account.
The five sections
Every issue that Testkit files has five sections.
| Section | What it answers | Where it comes from |
|---|---|---|
| Expected behaviour | What should the product do? | expected in the bug record |
| Authority | Where is that written down? | authority in the bug record |
| Observed behaviour | What does the product do instead? | observed in the bug record |
| Root cause | Which code makes it happen? | rootCause in the bug record |
| Reproduce | Which command shows it? | Testkit writes the command from test.file |
A report with all five can be checked by someone who was not there. Every field of the bug record is required, so no report lacks one.
Write the bug record
$npx testkit bug guideThe command prints the fields of a bug record, the severities, and the lists of cause, impact, and escape ids.
| Field | Value |
|---|---|
title | One line of at most 200 characters that names the broken behaviour. It becomes the issue title. |
severity | critical, high, medium, or low |
expected | What the product should do |
authority | The source that says so: documentation, a specification, an API contract, or the code's own stated intent. Name it and quote the part that applies. |
observed | What the product does instead |
rootCause | Why the code behaves this way |
test.file | The Testkit test file that fails because of the bug |
test.title | The failure's title, in one line of at most 200 characters |
fault.path | The source file that holds the mistake |
fault.lines | The first and last faulty line as [start, end], counted from 1. One faulty line is [41, 41]. |
cause | One cause id |
impact | One impact id |
confidence.cause | high or low. How sure the writer is of the cause. |
confidence.impact | high or low. How sure the writer is of the impact. |
escape | One escape id |
{
"title": "A refund pays out twice",
"severity": "high",
"expected": "The second POST /orders/{id}/refunds for the same order returns 409 and pays nothing.",
"authority": "`docs/api/refunds.md`, \"A refund is idempotent per order.\"",
"observed": "The second request returns 201 and creates a second payout.",
"rootCause": "`src/checkout/refunds.ts` retries the payout call without an idempotency key.",
"test": {
"file": "src/checkout/__testkit__/refund.contract.testkit.ts",
"title": "a refund pays out once"
},
"fault": { "path": "src/checkout/refunds.ts", "lines": [41, 58] },
"cause": "mishandled-error",
"impact": "financial-error",
"confidence": { "cause": "high", "impact": "high" },
"escape": "no-test"
}These rules apply to the fields.
- Write
test.fileandfault.pathrelative to the product directory, the directory that holdstestkit.config.ts. test.titlemust be the failure's title exactly as Testkit reports it. In a Vitest or Playwright file, that is the names of thedescribeblocks around the test and the test's own name, joined by single spaces. In a k6 file, that is the label of the failed assertion.- A k6 script that fails as a whole has no failure title. Write in
test.titlewhat the script tests. Testkit then files one issue that covers the whole file. - Keep credentials, tokens, and passwords out of the record.
Severities
| Severity | Meaning |
|---|---|
critical | The product is unsafe to run or to trust. |
high | A core behaviour is broken with no acceptable workaround. |
medium | A behaviour is broken and a workaround exists, or the reach is limited. |
low | A minor defect with little consequence. |
Cause, impact, and escape
Each of these fields takes one id from a list. testkit bug guide prints each id's definition and the rule that separates it from its neighbours.
| Field | Question | Ids |
|---|---|---|
cause | What kind of mistake is in the code? | missing-validation, mishandled-error, race-condition, unbounded-or-inefficient-work, incorrect-query-or-selection, missing-access-control, null-or-default-mishandling, stale-state, interface-mismatch, omitted-step, incorrect-logic, other |
impact | What is at stake for the business? | unauthorized-access, financial-error, audit-or-compliance-gap, data-loss-or-corruption, workflow-breakdown, availability-or-performance, blocked-operation, wrong-information-shown, other |
escape | Why did the existing tests not catch the bug? | no-test, untested-input, untested-interleaving, wrong-assertion, test-double, other |
A bug gets one id for each field. The impact list is in precedence order, and the first id that applies wins. Use other when none fits.
The test that comes with it
The bug record names one test. The test's name states the expected behaviour, so the name is true once the product is fixed.
import { defineFile } from "@gfdelarue/testkit";
import { contractScenario, expect } from "@gfdelarue/testkit/node-vitest/contract";
export const testkit = defineFile({ engine: "node-vitest" });
contractScenario("a refund pays out once", async ({ http }) => {
const first = await http().post("/orders/8841/refunds");
const second = await http().post("/orders/8841/refunds");
expect(first.status).toBe(201);
expect(second.status).toBe(409);
});Against the broken product, the test fails on the line that states the expected behaviour.
$npx testkit run --file src/checkout/__testkit__/refund.contract.testkit.ts[1/1] FAIL api contract src/checkout/__testkit__/refund.contract.testkit.ts 0s
AssertionError: expected 201 to be 409 // Object.is equality at
/repo/src/checkout/__testkit__/refund.contract.testkit.ts:10:24The issue Testkit files
The issue's title is the title of the bug record.
Labels
| Label | Meaning |
|---|---|
testkit | Filed by Testkit with a regression test. Every issue that Testkit files carries it. |
severity:<level> | The bug's severity, such as severity:high |
area:<id> | The product area that holds the fault. The label's description is the area's name. |
Testkit creates a label that does not exist yet and never changes one that does. An issue carries an area:<id> label when areas is set.
Severity and area are labels because people re-triage both. To change a bug's severity or area, replace the label. An issue with two severity: labels counts as having none, and the same holds for area: labels.
Body
The body has the five sections, three lines that locate the bug, and the facts block.
## Expected behaviour
The second POST /orders/{id}/refunds for the same order returns 409 and pays nothing.
## Authority
`docs/api/refunds.md`, "A refund is idempotent per order."
## Observed behaviour
The second request returns 201 and creates a second payout.
## Root cause
`src/checkout/refunds.ts` retries the payout call without an idempotency key.
## Reproduce
```sh
npx testkit run --file src/checkout/__testkit__/refund.contract.testkit.ts
```
Test: `src/checkout/__testkit__/refund.contract.testkit.ts`, "a refund pays out once".
Found at `4f0c2d1e9b7a4c3d8e6f5a4b3c2d1e0f9a8b7c6d`.
Fault: `src/checkout/refunds.ts`, lines 41 to 58.
<details>
<summary>Testkit facts</summary>
<!-- testkit-facts:start -->
```json
{
"v": 1,
"cause": "mishandled-error",
"impact": "financial-error",
"confidence": { "cause": "high", "impact": "high" },
"escape": "no-test",
"caughtBy": "campaign",
"campaign": "refunds-2026-10",
"testType": "contract",
"test": {
"file": "src/checkout/__testkit__/refund.contract.testkit.ts",
"title": "a refund pays out once"
},
"foundAt": "4f0c2d1e9b7a4c3d8e6f5a4b3c2d1e0f9a8b7c6d",
"fault": { "path": "src/checkout/refunds.ts", "lines": [41, 58] },
"area": { "id": "checkout", "name": "Checkout", "share": 0.14 },
"introducedBy": "9b7a4c3d8e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b",
"introducedMethod": "blame"
}
```
<!-- testkit-facts:end -->
</details>The file names, the commits, and the campaign in this body are examples.
The reproduce command runs from the repo root. When testkit.config.ts is in a subdirectory, the command carries --dir <directory>, and the --file path is relative to that directory. The Test: and Fault: lines always give paths from the repo root. A fault on one line reads line 41.
Facts block
The facts block is one JSON object between two marker comments. It sits in a collapsed section named Testkit facts, and tools read it. Facts lists what Turnstone reads from it.
| Field | Written | Meaning |
|---|---|---|
v | Always | The format version, 1 |
cause | Always | The kind of mistake in the code |
impact | Always | What is at stake for the business |
confidence.cause | Always | The writer's confidence in cause, high or low |
confidence.impact | Always | The writer's confidence in impact, high or low |
escape | Always | Why the existing tests did not catch the bug |
caughtBy | Always | campaign when a campaign was active when the bug was recorded, and manual otherwise |
campaign | With caughtBy: "campaign" | The id of the campaign that caught the bug |
testType | Always | The type of the regression test, from its file suffix |
test.file | Always | The regression test file |
test.title | When the issue covers one test | The failure's title |
foundAt | Always | The commit the test failed at |
fault.path | Always | The source file that holds the mistake |
fault.lines | Always | The first and last faulty line at foundAt, counted from 1 |
area.id | When an area claims fault.path | The area's key in areas |
area.name | With area.id | The area's name when the bug was filed |
area.share | With area.id | The area's share of the repo's source bytes at foundAt, from 0 to 1 |
introducedBy | When the blame resolves | The newest commit among the fault lines at foundAt |
introducedMethod | With introducedBy | How Testkit found introducedBy. The value is blame. |
All paths in the block use / and are relative to the repo root. Commits are full commit ids.
The package ships the full format, with the rules for a tool that reads it, as node_modules/@gfdelarue/testkit/docs/bug-records.md.
What testkit bug file checks
testkit bug file runs the test at the checked-out commit and files the issue only when the test fails. It files nothing in these cases.
testkit bug file stops when | What to do |
|---|---|
| Tracked files have uncommitted changes | Commit or discard the changes. Then run the command again. |
| No remote branch contains the commit | Push the commit. Then run the command again. |
| An open issue already covers the test | Read that issue. Testkit keeps one open bug for each test. |
areas is set, and no area claims the fault's file or two areas do | The message names the file. Change areas in testkit.config.ts so that one area claims the file. |
test.title is not one of the failure titles that the file reports | The message lists the titles that a bug record can use. |
The token needs permission to create issues and labels. Set the token lists the variables that Testkit reads it from.
Secrets
Testkit redacts the secrets it recognises in the title, in every section, and in test.title. It does so when it records the bug and again when it files the issue. Testkit cannot recognise every secret, so keep credentials, tokens, and passwords out of the record.
Testkit does not rewrite a path or an area's name. It stops in these cases.
testkit bug recordrejects a record whosetest.fileorfault.pathholds text that Testkit redacts.testkit bug filestops when the area's name or the reproduce command holds such text.- Testkit files no bug under a failure title that holds such text. Rename the test, or give the assertion a label.
Known failures
A known failure is a failing test that an open issue with the testkit label covers. bugs.knownFailures in testkit.config.ts decides what a known failure does to a run.
| Value | What a run does |
|---|---|
"fail" | Every failing test fails the run. This is the default. |
"allow" | A run whose failures are all known passes. One new failure fails the run. |
export default defineConfig({
bugs: { knownFailures: "allow" },
});Use "allow" to keep CI passing while a filed bug waits for its fix. These rules apply.
- An issue covers a failure when its facts block gives the failing file as
test.fileand the failure's title astest.title. An issue with notest.titlecovers the whole file. - Closing an issue makes its test count again.
- A failure that names no test is always new. A crash, a timeout, and a file that does not load are such failures.
- When Testkit cannot read the issues, every failure is new.
- A run that fails outside its tests fails under both values.
With "allow", the run reads the open issues, so testkit run needs a token. Pass a run whose failures are all filed shows the CI step.
Where the evidence lives
The issue points to evidence where it already is. Do not copy it somewhere new.
| Evidence | Where it lives |
|---|---|
| The failing test | Your repo, at the commit on the issue's Found at line |
| The command that shows the failure | The issue's Reproduce section |
| The faulty lines | Your repo, at the path and lines on the issue's Fault: line |
| The full run | The CI job's uploaded .testkit/results |
Read a run's results shows how to find a file's output in a run.
Turnstone holds none of this evidence. What Turnstone keeps lists the facts that Turnstone stores.
What a report never does
- It never skips the failing test.
- It never asserts the broken behaviour so that the test passes.
- It never reports a failure without a cause. A failing test with no root cause is a lead, and the agent keeps working on it.