How it works
SpecTracer is about 2,100 lines of Python with one runtime dependency of consequence. This page is for people evaluating it, debugging it, or contributing to it — it's not needed to use the tool.
The pipeline
Five stages, strictly linear, no state carried between runs.
.feature JUnit XML Cucumber JSON
| | |
v v v
FeatureParser JunitParser CucumberParser
| | |
+-----------+------------+-----------------------+
| |
Scenario[] TestResult[]
| |
+-------> ResultLinker <-------+
|
ScenarioView[]
|
ReportAggregator
|
views + stats + layer_stats + health
|
+------------+------------+
| |
HtmlRenderer build_report()
| |
spectracer-report.html spectracer-report.json (optional)
Step by step, what happens when you type spec-tracer:
- Config loading —
cli.pydiscovers and reads the JSON config, validating thatfeaturesandoutputare present. The layer keys are normalised into module-keyed objects, with""as the unscoped bucket. - File collection —
FileCollectorresolves literal file paths and recursively scans directories for.feature,.xml, and.json, once per module key. - Parsing — each file type goes to its own parser and comes back as typed data classes. Every test result is stamped with the module key it was collected under.
- Linking —
ResultLinkermatches@scenario:VALUEon results against@id:VALUEon scenarios. E2E results additionally match on their own@id:VALUEtag, since their Cucumber JSON is generated by running the scenario's own feature file. Only explicit identity pairs create a link. - Aggregation —
ReportAggregatorcomputes every stat, view, breakdown, and health check from the linked data. - Rendering —
HtmlRendererfeeds the aggregate into a Jinja2 template and writes the HTML. Ifoutput_jsonis set,report_model.build_report()reshapes the same aggregate into schema-conformant JSON.
Module layout
| Module | Lines | Responsibility |
|---|---|---|
models.py | 90 | The whole type system: Scenario, TestResult, RequiredLayer, ScenarioView, plus helpers. |
collectors.py | 44 | File discovery. Resolves configured paths, scanning directories recursively. |
parsers.py | 193 | Three parsers: Gherkin (line-by-line), JUnit XML (ElementTree), Cucumber JSON (stdlib json). |
linker.py | 26 | Identity-based pairing. The smallest module and the most important one. |
aggregator.py | 229 | Completion stats, layer breakdowns, feature trees, health checks, unlinked results. Pure logic, no I/O. |
renderers.py | 1208 | The Jinja2 template — embedded HTML, CSS, and JS — plus render helpers. Produces the self-contained file. |
report_model.py | 138 | Reshapes the aggregate into the JSON schema. Only runs when output_json is set. |
cli.py | 200 | Orchestration: load config, collect, parse, link, aggregate, write. |
The size distribution is the design: the template is by far the biggest file, and every module that makes a decision about your data is small enough to read in one sitting.
Parsers
FeatureParser
Reads .feature files line by line. There's no heavy Gherkin dependency — it extracts
only what the report needs:
Feature:lines, for the feature name- Scenario names from
Scenario:/Scenario Outline:lines - Tags on the lines immediately above a scenario
- Given/When/Then steps, for display
Tag resolution, in order:
- Collect all tags on lines immediately above the
Scenario:line. - Classify anything matching
@require-(unit|integration|e2e)(:module)?as a layer requirement. - Keep everything else as-is, including
@id:VALUEand classification tags. - If zero requirement tags were found, default the scenario to a bare, unscoped
@require-e2e.
The trade-off of the lightweight approach: Rule:, Background:, and
non-English dialects aren't interpreted, and a Scenario Outline: is treated as one
scenario rather than expanded per Examples row.
JunitParser
Uses xml.etree.ElementTree. Reads tags from three locations — the
name attribute, the classname attribute, and
<properties><property> elements — so it works with whichever convention your
framework uses. Handles <failure>, <error>, and
<skipped> children for status classification, and stamps each result with the
config module key.
CucumberParser
Accepts both a single feature object and an array of them. Extracts the scenario-level
tags array, the name, the status, and the duration — handling the nanosecond-to-second
conversion Cucumber emits. Module scope works the same as for the other two layers.
Aggregator
ReportAggregator turns linked data into everything the report displays. All methods
are static and side-effect free, which is why the unit suite for it is large and fast.
| Method | Produces |
|---|---|
build_views() | Per-scenario, per-layer result groups with required-layer status |
completion_stats() | Complete/incomplete counts and the headline percentage |
feature_breakdown() | Per-feature completion stats |
layer_stats() | Per-layer counts, durations, percentages, and bar widths |
failure_breakdown() | Failures grouped by feature and scenario, with stack traces |
health_checks() | All four health statuses — progress, pyramid, E2E runtime, unlinked |
unlinked_results() | Results that matched no scenario |
Two outputs, one model
report_model.build_report() receives the exact same views,
stats, layer_stats, health_checks, and
unlinked_results that HtmlRenderer.render() consumes. There is no second
parsing or aggregation path — if there were, the HTML and the JSON could quietly disagree, which is
precisely the failure mode a machine-readable twin exists to avoid.
What the JSON layer does change, and why:
- Duration units — the internal model stores seconds; the schema specifies milliseconds, so the conversion happens on the way out.
- Omit, don't null —
durationandfailureMessageare left out of a result entirely when the source data didn't provide them, rather than serialised asnull. Consumers can then distinguish "unknown" from "zero". - Health rollup — the aggregator's four independent
pass/warn/failchecks reduce to a single worst-ofsummary.health.status, with each non-passing check's message appended toreasons[]. No health logic is reimplemented. - Relative feature paths —
feature.fileis computed relative to the config file's directory viaos.path.relpath, never absolute. Reports generated on different machines stay comparable.
The HTML side
HtmlRenderer uses Jinja2 to produce one self-contained file. Inlined: the full
stylesheet with light/dark themes driven by data-theme and falling back to
prefers-color-scheme; the JavaScript for hash routing, the theme toggle, sortable
tables, and search; and the logo as a base64 PNG. Report data is rendered into the markup
server-side by Jinja2 rather than shipped as an embedded JSON payload.
The only external request is the Cascadia Mono webfont from jsDelivr, which degrades to system
ui-monospace when offline.
Dogfooding
SpecTracer tests itself using its own input formats — outside-in, feature file first.
| Layer | Framework | Output format | Fed back as |
|---|---|---|---|
| Unit | pytest | JUnit XML | unit |
| Integration | pytest | JUnit XML | integration |
| E2E | behave | Cucumber JSON | e2e |
Every feature starts as a .feature file and is validated by a behave scenario before
it's implemented. CI runs all three layers and feeds the outputs back into SpecTracer to produce a
self-report — the screenshots throughout this site.
uv run pytest tests/unit --junitxml=reports/unit.xml
uv run pytest tests/integration --junitxml=reports/int.xml
# E2E is split per module so @require-e2e:<module> can be dogfooded too
uv run behave features/linking.feature -f json -o reports/e2e-linker.json
uv run behave features/health.feature -f json -o reports/e2e-aggregator.json
uv run behave features/dashboard.feature -f json -o reports/e2e-renderers.json
uv run behave features/module_scope.feature -f json -o reports/e2e-parsers.json
# …plus collectors / report_model / edge-case splits — see .github/workflows/ci.yml
uv run spec-tracer
# → reports/spectracer-report.html (the self-report)
# → reports/spectracer-report.json (same data, machine-readable)
SpecTracer's own scenarios use module-scoped tags such as @require-e2e:linker and
@require-unit:parsers, with spectracer.config.json registering each
Cucumber JSON file under the matching module key. The emitted JSON is validated against
spectracer-report.schema.json with jsonschema.Draft7Validator by both the
integration suite and the behave suite — the schema is enforced by tests, not merely documented.
What it deliberately doesn't do
Run tests
It only parses results after your suites have finished. Test execution stays entirely under your CI's control.
Read source code
It never opens a .py, .java, or .js file. Only
.feature files and test-result output.
Evaluate tag expressions
Matching is exact string equality. No and/or/not
logic, no wildcards.
Store history
Every run is independent. Trends live in whatever metrics system you already run — see Historical trends.
Measure line coverage
Different question, different tool. Run both; they don't overlap.
Need a server
No database, no daemon, no hosted component. A CLI in, a static file out.