Architecture
SpecTracer is a lean Python CLI with no heavy infrastructure. Four modules orchestrate a simple pipeline: collect → parse → link → aggregate → render.
Pipeline Overview
.feature JUnit XML Cucumber JSON
| | |
v v v
FeatureParser JunitParser CucumberParser
| | |
+-----------─────+─────────+
| |
Scenario[] TestResult[]
| |
+--------> Linker <--------+
|
LinkedScenarioView[]
|
Aggregator
|
AggregatedData{views, stats, health}
|
HtmlRenderer
|
report.html
Modules
models.py
Core data classes: Scenario, TestResult, RequiredLayer, ScenarioView. 40 lines — the entire type system.
parsers.py
Three parsers: Gherkin (line-by-line), JUnit XML (ElementTree), Cucumber JSON (json module). Total: 177 lines.
linker.py
OR-based tag matching. Links scenarios to test results by shared tags. 17 lines — the simplest module.
aggregator.py
Computes coverage stats, layer breakdowns, feature trees, health checks, and unlinked results. 188 lines of pure logic.
renderers.py
Jinja2 template with 845 lines of embedded HTML/CSS/JS. Produces the self-contained report file.
cli.py
Orchestrator: loads config, collects files, runs pipeline, writes output. 131 lines.
Parsers
FeatureParser
Reads .feature files line-by-line. No heavy Gherkin parser dependency — just enough to extract:
Feature:lines for the feature name- Scenario names (line following
Scenario:) - Tags on lines above scenarios (distinguishes linking tags from @require-* tags)
- Given/When/Then steps for display in the report
Default behavior: Scenarios without any @require-* tags default to @require-e2e.
JunitParser
Parses JUnit XML using xml.etree.ElementTree. Reads tags from three locations to support different frameworks:
nameattribute on<testcase>classnameattribute on<testcase><properties><property>elements inside<testcase>
Handles <failure>, <error>, and <skipped> child elements for status classification.
CucumberParser
Parses Cucumber JSON output. Handles both array-of-scenarios and single-object formats. Extracts:
- Scenario-level
tagsarray for linking - Scenario name and status
- Duration (handles nanosecond-to-second conversion)
Linker
The linker is the simplest but most important module. It performs OR-based tag matching:
- For each scenario, find all test results where any tag appears in the scenario's linking tags
@require-*tags are stripped before linking — they never participate- Tag matching is exact string equality only — no wildcards, no expressions
- One test result can link to multiple scenarios (tag collision)
def link(scenarios, results):
for scenario in scenarios:
linked = [
r for r in results
if any(tag in r.tags for tag in scenario.linking_tags)
]
yield ScenarioView(scenario, linked)
Aggregator
The ReportAggregator transforms linked data into report views. All methods are static:
| Method | Output |
|---|---|
build_views() | Per-scenario, per-layer result groups with required-layer status |
coverage_stats() | Total tested/untested counts with percentage |
feature_breakdown() | Per-feature coverage stats |
layer_stats() | Per-layer test counts, durations, percentages, visual widths |
failure_breakdown() | Failures grouped by feature/scenario with stack traces |
health_checks() | All four health check statuses (progress, pyramid, E2E time, unlinked) |
unlinked_results() | Test results with no matching scenario |
Renderer
The HtmlRenderer uses Jinja2 to produce a single self-contained HTML file. Everything is inlined:
- CSS: Full stylesheet with dark mode support via
prefers-color-scheme: dark - JavaScript: Client-side hash routing (
#/,#/pyramid, etc.), sortable tables, search/filter - Logo: Optional base64-encoded PNG
- Data: All report data is embedded as JSON in a
<script>tag
The report file has zero external dependencies — it works offline, on any OS, in any modern browser.
Data Flow
Here's exactly what happens when you run uv run python build_pyramid.py:
- Config loading —
cli.pydiscovers and reads the JSON config file. Validates required keys (features,output). - File collection —
FileCollectorresolves glob patterns and directory recursion for feature files, JUnit XML, and Cucumber JSON paths. - Parsing — Each file type is parsed by its dedicated parser. Scenarios and test results are extracted into typed data classes.
- Linking —
ResultLinkercross-references scenario tags against test result tags. OR logic: any shared tag creates a link. - Aggregation —
ReportAggregatorcomputes all stats, views, breakdowns, and health checks from the linked data. - Rendering —
HtmlRendererfeeds the aggregated data into a Jinja2 template and writes the output file.
Tag Resolution Order
When parsing a .feature file, tags are resolved as follows:
- All tags on lines immediately above a
Scenario:line are collected - Tags matching the pattern
@require-(unit|integration|e2e)(:\w+)?are classified as layer requirements - All remaining tags become linking tags
- If zero requirement tags were found, the scenario defaults to
@require-e2e
Feature: line are ignored by SpecTracer. Only scenario-level tags participate in linking and requirement checking.
Testing Strategy (Dogfooding)
SpecTracer tests itself using its own input formats — an approach called outside-in dogfooding.
| Layer | Framework | Output Format | Feeds Tool As |
|---|---|---|---|
| E2E | behave | Cucumber JSON | e2e |
| Unit | pytest | JUnit XML | unit |
| Integration | pytest | JUnit XML | integration |
The CI pipeline runs all three layers, collects the outputs, and feeds them into SpecTracer to generate a self-report — a coverage report for SpecTracer itself. Every feature starts as a .feature file, validated by a behave E2E scenario before implementation.
uv run behave features/ --format json -o reports/e2e.json
uv run pytest tests/unit --junitxml=reports/unit.xml
uv run pytest tests/integration --junitxml=reports/int.xml
uv run python build_pyramid.py
# Output: reports/spectracer-report.html — a self-report