# ClientUtils
An ExUnit formatter with JSON output and distributed test coordination. Designed for editor integrations and CI/CD pipelines that need machine-readable test results and the ability to handle concurrent test requests.
## Features
- **JSON Output** - Machine-readable test results
- **Streaming Mode** - Real-time JSON events as tests complete
- **Distributed Test Coordination** - Multiple concurrent test requests are serialized, with results cached and replayed to waiting callers
- **CLI Passthrough** - Standard ExUnit terminal output is preserved
## Installation
```elixir
def deps do
[{:client_utils, "~> 0.1.0"}]
end
```
## Basic Usage
Configure the formatter in `test/test_helper.exs`:
```elixir
ExUnit.start(formatters: [ClientUtils.TestFormatter])
```
### File Output
```elixir
ExUnit.start(formatters: [{ClientUtils.TestFormatter, output_file: "test-results.json"}])
```
Or via environment variable:
```bash
EXUNIT_JSON_OUTPUT_FILE=test-results.json mix test
```
### Streaming Mode
Stream test events in real-time while writing the final summary to a file:
```elixir
ExUnit.start(formatters: [{ClientUtils.TestFormatter, streaming: true, output_file: "test-results.json"}])
```
## JSON Output Format
### Final Summary
```json
{
"stats": {
"duration": 1234.56,
"start": "2024-01-15T10:30:00.000000",
"end": "2024-01-15T10:30:01.234000",
"passes": 40,
"failures": 2,
"pending": 3,
"tests": 45,
"suites": 5
},
"tests": [
{"title": "test name", "fullTitle": "ModuleName: test name"}
],
"failures": [
{
"title": "failing test",
"fullTitle": "ModuleName: failing test",
"error": {
"file": "test/my_test.exs",
"line": 42,
"message": "Assertion failed"
}
}
],
"pending": [
{"title": "skipped test", "fullTitle": "ModuleName: skipped test", "pending": true}
]
}
```
### Streaming Events
```json
{"type":"suite:start","start":"2024-01-15T10:30:00.000000"}
{"type":"test:pass","test":{"title":"works","fullTitle":"MyModule: works"}}
{"type":"test:fail","test":{"title":"breaks","fullTitle":"MyModule: breaks","error":{...}}}
{"type":"suite:end","stats":{...}}
```
## Distributed Test Coordination
The `mix agent_test` task coordinates multiple concurrent test requests. This is useful for editor integrations where multiple "run test" commands might be triggered in quick succession.
### How It Works
```mermaid
sequenceDiagram
participant A1 as Agent 1 (Runner)
participant A2 as Agent 2 (Waiter)
participant Lock as Lock File
participant Cache as Event Cache
participant Tests as Mix Test
A1->>Lock: Create caller file
A2->>Lock: Create caller file
A1->>Lock: Acquire lock
A2->>Lock: Fail acquire lock
A2->>A2: Wait
A1->>Tests: Run mix test with custom formatter
Tests-->>A1: Stream test events to terminal
Tests->>Cache: Store events
A1->>Lock: Release lock
A2->>Cache: Get events for my files after my timestamp
Cache-->>A2: Return cached events
A2->>A2: Replay events to CLI formatter
```
- **Runner**: First caller acquires the lock, runs tests normally, caches all test events
- **Waiter**: Subsequent callers wait for the runner to finish, then receive cached results for their requested files
### Usage
```bash
mix agent_test test/my_test.exs
```
Multiple concurrent invocations are automatically coordinated—only one runs tests at a time, others receive cached results.
### Cache API
Query the test cache programmatically:
```elixir
alias ClientUtils.TestFormatter.TestCache
# Check if files were tested recently
TestCache.file_tested_after?("test/my_test.exs", datetime)
TestCache.files_tested_after?(["test/a.exs", "test/b.exs"], datetime)
# Retrieve cached events
TestCache.get_events_for_file("test/my_test.exs", since)
TestCache.get_events_after(since)
```
## Configuration
| Environment Variable | Description |
| ------------------------- | --------------------------- |
| `EXUNIT_JSON_OUTPUT_FILE` | Path for JSON output file |
| `EXUNIT_JSON_STREAMING` | Enable streaming mode |
| `AGENT_TEST_EVENTS_FILE` | Custom path for event cache |
## License
Apache 2.0