Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.4.3
0.5.0
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,33 @@ Completed roadmap tasks. For upcoming work, see [ROADMAP.md](ROADMAP.md).

---

## v0.5.0 (2026-06-01)

### New Features

**Automatic retry-on-flaky (default ON)**

When a run has failures, `mix test.json` now automatically re-runs only the previously-failed tests once (in a subprocess, via ExUnit's native `--failed`) and merges the two runs:

- **confirmed** — failed both runs → stays in `tests`, stays red, exits non-zero.
- **flaky** — failed run 1, passed run 2 → moved to a new top-level `flaky` array (named, never hidden) and no longer blocks the run.

When every first-run failure heals, `summary.result` becomes `"passed"` and the exit code is `0`, so an AI agent isn't blocked by an intermittent async/GenServer/LiveView/Port flake — while each flaky test is still surfaced. A test that fails both runs stays a hard failure.

This is **default behavior** because the motivating problem is that agents run the bare `mix test.json` command and can't be forced to pass `--failed` themselves. Opt out with `--no-retry` or `config :ex_unit_json, retry: false`.

The merged output adds (only when a retry ran): a `flaky` array, a `summary.flaky` count, and a `retry` metadata object (`ran`/`passes`/`retried`/`confirmed`/`flaky`). The schema `version` stays `1` (additive) — default output for green suites is byte-compatible.

Retry is automatically skipped when it would be meaningless or unsupported: `--no-retry`, `config :ex_unit_json, retry: false`, `--failed` (already iterating; also prevents the retry subprocess recursing), `--summary-only`, `--first-failure`, `--compact`, `--group-by-error`, `--filter-out`, a `file:line` target, or umbrella projects. A green suite never triggers a second run (one extra temp-file round-trip, no second test run).

### Internal

- New module `ExUnitJSON.Retry` — pure `merge/2` overlay classifying flaky vs confirmed, matching tests across runs by `{module, name}`
- `ExUnitJSON.Config` gains `retry?/0` (reads `config :ex_unit_json, :retry`, default `true`) and a `:retry` option
- `Mix.Tasks.Test.Json` generalizes the temp-output buffer (cover/quiet/retry), adds the retry orchestration, and uses `System.halt(0)` only on the heal-to-green path (overrides ExUnit's at_exit failure status)

---

## v0.4.3 (2026-04-18)

> Note: v0.4.2 was published to Hex on 2026-02-28 from an out-of-tree state and
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ ExUnitJSON provides structured JSON output from `mix test` for use with AI edito

- Drop-in replacement for `mix test` with JSON output
- **AI-optimized default**: Shows only failures (use `--all` for all tests)
- **Automatic retry-on-flaky** (default): re-runs failed tests once; failures that heal are reported as `flaky` instead of blocking (`--no-retry` to opt out)
- **Code coverage** with `--cover` and **coverage gating** with `--cover-threshold N`
- Detailed failure information with assertion values and stacktraces
- Filtering: `--summary-only`, `--first-failure`, `--filter-out`, `--group-by-error`
Expand Down Expand Up @@ -70,9 +71,26 @@ mix test.json --quiet --all
| `--compact` | Output JSONL with minimal keys (compact format) |
| `--cover-threshold N` | Fail if coverage below N% (requires `--cover`) |
| `--no-warn` | Suppress the "use --failed" tip |
| `--no-retry` | Disable automatic retry of failed tests |

All standard `mix test` flags are passed through (`--failed`, `--only`, `--exclude`, `--seed`, etc.).

### Automatic Retry (Flaky Healing)

When a run has failures, `mix test.json` re-runs only the previously-failed tests once and merges the results:

- **confirmed** — failed both runs → stays red (`tests`), exits non-zero.
- **flaky** — failed then passed → moved to a top-level `flaky` array (named, never hidden) and no longer blocks the run.

When every first-run failure heals, `summary.result` is `"passed"` and the exit code is `0`, so an AI agent isn't blocked by an intermittent async/GenServer/LiveView failure — while each flaky test is still surfaced. A `retry` metadata object (`retried`/`confirmed`/`flaky`) is added when a retry runs.

Retry is skipped for `--no-retry`, `config :ex_unit_json, retry: false`, `--failed`, `--summary-only`, `--first-failure`, `--compact`, `--group-by-error`, `--filter-out`, a `file:line` target, or umbrella projects. A green suite never triggers a second run.

```elixir
# Disable globally in config/test.exs
config :ex_unit_json, retry: false
```

### Code Coverage

```bash
Expand Down
5 changes: 3 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@
- No external dependencies for core functionality

<!-- FOCUS:BEGIN -->
**Focus phase:** 2 — Future Enhancements (8 of 18 done · 0 in progress)
**Focus phase:** 2 — Future Enhancements (9 of 19 done · 0 in progress)

**Last shipped:** no recent shipments
**Last shipped:** Task 30 — Automatic retry-on-flaky (default ON) on 2026-06-01

**Up next:** Task 20 — Consistent failure_message field (Schema v2) [D:2/B:7/U:7 → Eff:3.5] 🎯
<!-- FOCUS:END -->
Expand Down Expand Up @@ -85,6 +85,7 @@ shipped across the v0.3.x–v0.4.x line; pending features sorted by efficiency.
| Task 27 | ⬜ | 🎁 **future** · CI systems integration [D:4/B:5/U:5 → Eff:1.25] 📋 |
| Task 28 | ⬜ | 🎁 **future** · Custom output templates [D:6/B:4/U:4 → Eff:0.67] ⚠️ |
| Task 29 | ⬜ | 🎁 **future** · Optional Jason fallback [D:4/B:5/U:5 → Eff:1.25] 📋 |
| Task 30 | ✅ | 🎁 **failed_iteration** · Automatic retry-on-flaky (default ON) [D:7/B:9/U:9 → Eff:1.29] 📋 |
<!-- TASKS:END -->

---
Expand Down
62 changes: 60 additions & 2 deletions lib/ex_unit_json.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ defmodule ExUnitJSON do

- Drop-in replacement for `mix test` with JSON output
- **AI-optimized default**: Shows only failures (use `--all` for all tests)
- **Automatic retry-on-flaky** (default): failed tests are re-run once; failures
that heal are surfaced as `flaky` instead of blocking (opt out with `--no-retry`)
- **Code coverage** available with `--cover` flag
- **Coverage gating** with `--cover-threshold N` (fails if overall coverage drops below N)
- All test states: passed, failed, skipped, excluded
Expand Down Expand Up @@ -95,8 +97,34 @@ defmodule ExUnitJSON do
# Output JSONL with minimal keys (compact format)
mix test.json --quiet --compact

# Disable automatic retry of failed tests
mix test.json --quiet --no-retry

All standard `mix test` options are also supported (file paths, line numbers, etc.).

## Automatic Retry (Flaky Healing)

By **default**, when a run has failures, `mix test.json` re-runs only the
previously-failed tests once (in a subprocess, using ExUnit's `--failed`) and
merges the results:

- **confirmed** — failed both runs → stays in `tests`, stays red, exits non-zero.
- **flaky** — failed then passed → moved to a top-level `flaky` array (never
hidden) and no longer blocks the run.

When every first-run failure heals, the result goes `"passed"` and the exit code
is `0`, so an AI agent isn't blocked by a flake — while each flaky test is still
named in the output. This is the fix for the common loop where an agent treats an
intermittent async/GenServer/LiveView failure as a real regression.

Retry is skipped (run-1 output reported unchanged) for `--no-retry`,
`config :ex_unit_json, retry: false`, `--failed`, `--summary-only`,
`--first-failure`, `--compact`, `--group-by-error`, `--filter-out`, a `file:line`
target, or umbrella projects. A green suite never triggers a second run.

# Disable globally in config/test.exs
config :ex_unit_json, retry: false

## Code Coverage

Coverage is disabled by default for faster test runs. Use `--cover` to enable:
Expand Down Expand Up @@ -185,6 +213,8 @@ defmodule ExUnitJSON do
"seed": 12345,
"summary": { ... },
"tests": [ ... ],
"flaky": [ ... ],
"retry": { ... },
"error_groups": [ ... ],
"module_failures": [ ... ]
}
Expand All @@ -195,10 +225,35 @@ defmodule ExUnitJSON do
| `seed` | integer | Random seed used for test ordering |
| `summary` | object | Aggregate test statistics |
| `tests` | array | Individual test results (omitted with `--summary-only`) |
| `flaky` | array | Tests that failed then passed on retry (only present when a retry healed something) |
| `retry` | object | Retry metadata (only present when a retry ran) |
| `error_groups` | array | Failures grouped by message (only with `--group-by-error`) |
| `module_failures` | array | setup_all failures (only present when failures occur) |
| `coverage` | object | Code coverage data (included with `--cover`) |

### Retry Object

Present only when an automatic retry ran (a first run had failures):

{
"ran": true,
"passes": 1,
"retried": 3,
"confirmed": 2,
"flaky": 1
}

| Field | Type | Description |
|-------|------|-------------|
| `ran` | boolean | Always `true` when present |
| `passes` | integer | Number of retry passes (currently always 1) |
| `retried` | integer | Number of failed tests re-run |
| `confirmed` | integer | Failures that recurred (still red) |
| `flaky` | integer | Failures that healed on retry |

The `flaky` array contains full test objects (run-1 failure detail preserved);
flaky module failures additionally carry `"scope": "module"`.

### Summary Object

{
Expand All @@ -209,6 +264,7 @@ defmodule ExUnitJSON do
"excluded": 0,
"invalid": 0,
"filtered": 0,
"flaky": 0,
"duration_us": 123456,
"result": "failed"
}
Expand All @@ -217,13 +273,14 @@ defmodule ExUnitJSON do
|-------|------|-------------|
| `total` | integer | Total number of tests |
| `passed` | integer | Tests that passed |
| `failed` | integer | Tests that failed |
| `failed` | integer | Confirmed failures (after retry, if one ran) |
| `skipped` | integer | Tests skipped with `@tag :skip` |
| `excluded` | integer | Tests excluded by tag filters |
| `invalid` | integer | Tests with invalid state |
| `filtered` | integer | Failed tests matching `--filter-out` patterns (only present when non-zero) |
| `flaky` | integer | Failures that healed on retry (only present when a retry ran) |
| `duration_us` | integer | Total duration in microseconds |
| `result` | string | `"passed"` or `"failed"` |
| `result` | string | `"passed"` or `"failed"` (`"passed"` when all failures healed) |

### Test Object

Expand Down Expand Up @@ -413,6 +470,7 @@ defmodule ExUnitJSON do
* `ExUnitJSON.JSONEncoder` - Converts ExUnit structs to JSON maps
* `ExUnitJSON.Config` - Configuration handling
* `ExUnitJSON.Filters` - Test filtering logic
* `ExUnitJSON.Retry` - Merges a run with its retry to classify flaky vs confirmed
* `ExUnitJSON.ErrorGroups` - Groups failures by error message
* `ExUnitJSON.Coverage` - Code coverage collection
* `ExUnitJSON.CompactOutput` - Compact JSONL output format
Expand Down
22 changes: 20 additions & 2 deletions lib/ex_unit_json/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ defmodule ExUnitJSON.Config do
* `:group_by_error` - When true, add error_groups array grouping failures by message
* `:quiet` - When true, suppress Logger output for clean JSON
* `:hint` - Controls the "use --failed" tip behavior
* `:retry` - When false (via `--no-retry`), disable auto-retry of failed tests

"""

Expand All @@ -31,6 +32,7 @@ defmodule ExUnitJSON.Config do
| :group_by_error
| :quiet
| :hint
| :retry

@typedoc "Keyword list of ExUnitJSON options"
@type opts :: [
Expand All @@ -42,7 +44,8 @@ defmodule ExUnitJSON.Config do
compact: boolean(),
group_by_error: boolean(),
quiet: boolean(),
hint: boolean()
hint: boolean(),
retry: boolean()
]

@valid_options [
Expand All @@ -54,7 +57,8 @@ defmodule ExUnitJSON.Config do
:compact,
:group_by_error,
:quiet,
:hint
:hint,
:retry
]

@doc """
Expand Down Expand Up @@ -157,6 +161,20 @@ defmodule ExUnitJSON.Config do
get_opt(:group_by_error, false)
end

@doc """
Checks if automatic retry-on-flaky is enabled via project config.

Reads `config :ex_unit_json, :retry` directly from the application
environment (default `true`), mirroring how `:enforce_failed` is read. This
is a project-level setting evaluated before per-invocation `:opts` are stored,
so it deliberately does not consult `get_opts/0`. The `--no-retry` flag is
honored separately as a per-invocation opt by `Mix.Tasks.Test.Json`.
"""
@spec retry?() :: boolean()
def retry? do
Application.get_env(:ex_unit_json, :retry, true)
end

@doc false
# Validates and filters options to only known keys
defp validate_opts(opts) when is_list(opts) do
Expand Down
Loading