Reference

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.

collect → parse → link → aggregate → render
  .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:

  1. Config loadingcli.py discovers and reads the JSON config, validating that features and output are present. The layer keys are normalised into module-keyed objects, with "" as the unscoped bucket.
  2. File collectionFileCollector resolves literal file paths and recursively scans directories for .feature, .xml, and .json, once per module key.
  3. 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.
  4. LinkingResultLinker matches @scenario:VALUE on results against @id:VALUE on scenarios. E2E results additionally match on their own @id:VALUE tag, since their Cucumber JSON is generated by running the scenario's own feature file. Only explicit identity pairs create a link.
  5. AggregationReportAggregator computes every stat, view, breakdown, and health check from the linked data.
  6. RenderingHtmlRenderer feeds the aggregate into a Jinja2 template and writes the HTML. If output_json is set, report_model.build_report() reshapes the same aggregate into schema-conformant JSON.

Module layout

ModuleLinesResponsibility
models.py90The whole type system: Scenario, TestResult, RequiredLayer, ScenarioView, plus helpers.
collectors.py44File discovery. Resolves configured paths, scanning directories recursively.
parsers.py193Three parsers: Gherkin (line-by-line), JUnit XML (ElementTree), Cucumber JSON (stdlib json).
linker.py26Identity-based pairing. The smallest module and the most important one.
aggregator.py229Completion stats, layer breakdowns, feature trees, health checks, unlinked results. Pure logic, no I/O.
renderers.py1208The Jinja2 template — embedded HTML, CSS, and JS — plus render helpers. Produces the self-contained file.
report_model.py138Reshapes the aggregate into the JSON schema. Only runs when output_json is set.
cli.py200Orchestration: 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:

Tag resolution, in order:

  1. Collect all tags on lines immediately above the Scenario: line.
  2. Classify anything matching @require-(unit|integration|e2e)(:module)? as a layer requirement.
  3. Keep everything else as-is, including @id:VALUE and classification tags.
  4. 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.

MethodProduces
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:

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.

LayerFrameworkOutput formatFed back as
UnitpytestJUnit XMLunit
IntegrationpytestJUnit XMLintegration
E2EbehaveCucumber JSONe2e

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.

the dogfooding pipeline, simplified
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.