How it works
SpecTracer is about 3,800 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. Withrender_mode: "server"(default) the aggregate is baked into the markup; withrender_mode: "client"it is first reshaped byreport_model.build_report()and embedded as JSON that the browser renders. Ifoutput_jsonis set, the samebuild_report()output is also written as a schema-conformant JSON file. The client build additionally derives a per-module breakdown (report_model._modules) and renders it as module-card list + per-module scoped trees under#/modulesand#/modules/<key>; the server build has no module pages.
Module layout
| Module | Lines | Responsibility |
|---|---|---|
models.py | 109 | The whole type system: Scenario, TestResult, RequiredLayer, ScenarioView, plus helpers. |
collectors.py | 64 | File discovery. Resolves configured paths, scanning directories recursively. |
parsers.py | 211 | Three parsers: Gherkin (line-by-line), JUnit XML (ElementTree), Cucumber JSON (stdlib json). |
linker.py | 29 | Identity-based pairing. The smallest module and the most important one. |
aggregator.py | 268 | Completion stats, layer breakdowns, feature trees, health checks, unlinked results. Pure logic, no I/O. |
renderers.py | 2556 | The Jinja2 templates — embedded HTML, CSS, and JS — plus render helpers. Two page builders live here: the server-rendered default and the client-rendered shell that builds the same pages from embedded JSON in the browser (plus the per-module pages, client mode only). Both templates read ui.tabs (via the resolved show_dashboard/show_pyramid/show_failures/show_unlinked render args server-side, or REPORT.config.ui.tabs client-side) to control tab visibility (every optional tab is opt-in) and render the run-metadata strip from the metadata render arg / REPORT.metadata. |
report_model.py | 332 | Reshapes the aggregate into the JSON schema (spectracer-report.schema.json), including the per-module cards. Runs when output_json is set or render_mode is "client" — client mode embeds this exact object. |
cli.py | 318 | Orchestration: load config, collect, parse, link, aggregate, write. Also parses arguments (--meta KEY=VALUE), validates and resolves ui.tabs via _resolved_ui_tabs(), and merges run metadata via _resolved_metadata(). |
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 five health statuses — progress, pyramid, E2E runtime, unlinked, unconfigured modules |
unlinked_results() | Results that matched no scenario |
unconfigured_requirements() | Module-scoped requirements whose module isn't a registered config key — a likely typo, distinct from a genuine "missing" gap |
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 produces 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. It has two page builders over the same data:
- Server rendering (default) — Jinja2 bakes the aggregate into the markup, so the file is readable with or without JavaScript.
- Client rendering (
render_mode: "client") — the report object is embedded as\uXXXX-escaped JSON in a<script type="application/json">block and a small renderer builds the exact same DOM in the browser, reusing the shared routing, theme, sort, and search JavaScript unchanged. The embedded JSON is the same objectbuild_report()writes, so the two cannot drift.
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.