Parser Strategy
This document is the authoritative reference for QA Lens's parser layer (Phase 3+). It covers the abstraction contract, format detection logic, extraction strategy, extension points, and the reasoning behind key design decisions.
---
Design Principles
score flakiness, or generate insights.
ExtractionWarning entries on TestRun.warnings, not exceptions.
its rendered DOM; Extent's embedded var testdata = {...} is more reliable than scraping CSS classes. DOM traversal is a fallback, not the primary path.
to its detection verdict, letting the Detector pick the best match rather than failing on ambiguity.
- Extract only — parsers produce
TestRun; they never classify failures, - Warn, don't crash — missing or malformed optional fields become
- Deterministic — identical input always produces identical output.
- Prefer structured hints — Allure's
widgets/JSON is more stable than - Confidence-based detection — each parser assigns a float confidence score
---
Parser Contract
Every QA Lens parser must subclass BaseParser (defined in src/qalens/parsers/base.py):
class BaseParser(ABC):
parser_key: str # "allure", "extent", …
parser_name: str # human-readable label
@abstractmethod
def can_parse(self, report_path: Path) -> DetectionResult: ...
@abstractmethod
def parse(self, report_path: Path) -> TestRun: ...
Why ABC and not Protocol?
Protocol would require all callers to be structurally compatible, making it harder to attach shared helpers (warning accumulation, default plumbing). ABC makes the contract explicit and forces implementors to read the base class, which is acceptable for the small number of expected implementations.
---
DetectionResult
DetectionResult is a frozen Pydantic model returned by every can_parse() call:
| Field | Type | Description |
|---|---|---|
parser_key | str | Identifies the parser, e.g. "allure" |
parser_name | str | Human-readable name |
confidence | float | 0.0–1.0 |
reasons | list[str] | Evidence strings (logged and surfaced to the user) |
matched_files | list[Path] | Files that contributed to the verdict |
warnings | list[str] | Non-fatal issues during detection |
matched _(computed)_ | bool | confidence >= 0.5 |
Confidence thresholds
| Range | Label | Meaning |
|---|---|---|
| ≥ 0.80 | High | Definitive signal present (meta tag, JSON file) |
| 0.50–0.79 | Medium | Multiple corroborating signals |
| 0.30–0.49 | Low | Weak or single signal — matched is False |
| < 0.30 | None | No recognizable signal |
DetectionResult.no_match() returns confidence=0.0. DetectionResult.unknown() returns parser_key="unknown" and confidence=0.0.
---
Detector
Detector (in src/qalens/parsers/detector.py) is a registry and dispatcher:
detector = Detector() # registers AllureHtmlParser + ExtentHtmlParser
result = detector.detect(path) # returns the highest-confidence DetectionResult
parser = detector.get_parser("allure") # retrieve by key
parser = detector.get_parser_for_path(path) # detect + retrieve in one call
The default parser registration order is [AllureHtmlParser, ExtentHtmlParser]. When two parsers tie on confidence, the one registered first wins. The minimum confidence for a successful match is 0.30; below that detect() returns DetectionResult.unknown().
Custom parsers
detector = Detector(parsers=[]) # empty registry
detector.register(MyParser()) # add a custom parser
detector.unregister("allure") # remove a built-in parser
Registering a parser with an existing parser_key replaces the old entry.
---
Report Detection — Signal Overview
Allure detection signals (AllureHtmlParser.can_parse)
| Signal | File / Artefact | Confidence |
|---|---|---|
Both widgets/summary.json and data/suites.json exist | JSON | 0.96 |
widgets/summary.json exists | JSON | 0.90 |
data/suites.json exists | JSON | 0.85 |
Extra data JSON files (behaviors.json, categories.json, …) | JSON | 0.80 |
app.js contains the string "allure" | JS | 0.75 |
Entry HTML has ng-app attribute (AngularJS) | DOM | 0.70 |
<title> contains "allure" | DOM | 0.65 |
Script src attribute references "allure" | DOM | 0.65 |
Extent detection signals (ExtentHtmlParser.can_parse)
| Signal | File / Artefact | Confidence |
|---|---|---|
<meta name="generator" content="ExtentReports…"> | DOM | 0.95 |
var reportConfig = {…} or var testdata = {…} in script | DOM/JS | 0.85 |
| Known Extent CSS class names in DOM | DOM | 0.70 |
config.js, spark-config.js, extent.js present | FS | 0.65 |
spartan-sources/, spark/, assets/ directories | FS | 0.55 |
Why structured hints over DOM scraping?
DOM structure is a rendering artefact — Extent and Allure both ship bundled JS that rehydrates the page. CSS class names and heading text may change between minor versions without any semantic change to the data. By contrast:
data source and predate the rendered DOM.
- Allure's
widgets/summary.jsonhas been structurally stable since Allure 2.0. - Extent's
<meta name="generator">tag is documented and version-stamped. - Extent's
var reportConfig/var testdatascript blobs are the authoritative
DOM markers are kept as fallback signals (medium confidence) to handle edge cases where only the HTML file is available.
---
Extent Report Parser
Detection
See the signal table above. The parser uses BeautifulSoup (html.parser) for meta-tag extraction and regex for the embedded script variable check. Both checks are fast (no network, no subprocess) and are done in can_parse.
Phase 2 extraction (metadata only)
parse() currently:
- Resolves the entry HTML (
index.html,report.html, or user-supplied file). - Reads the
<meta name="generator">tag for the report version. - Reads the
<title>tag (falling back toreportConfig.reportName) for the project name. - Builds and returns
RunMetadata. - Returns an empty
test_cases=[]list with aHIGH-severityExtractionWarning.
Phase 3 extraction (implemented)
parse() now performs full test case extraction:
absent (e.g. older Extent versions that did not embed data).
and started_at/finished_at derived from the min/max test timestamps.
- Resolves the entry HTML and loads it into BeautifulSoup.
- Extracts the
var reportConfig = {…}JSON blob for project metadata. - Extracts the
var testdata = {…}JSON blob containing the complete test tree. - Iterates
testdata.tests→ each node →_extract_test_case_from_node(). - Falls back to DOM traversal of
.test-contentnodes when the JSON blob is - Builds
RunMetadatawith project name, report version (5.xfrom meta generator),
Partial extraction preference: if exception or details blocks are missing for a failed test, a LOW-severity warning is emitted and the step-level failure information is used instead. Only a completely absent testdata blob causes a HIGH-severity warning.
Known limitations:
attachment extraction may be incomplete.
for filtering by callers.
- Extent v3 reports without the
var testdatablob use DOM fallback; tag and - Nested sub-test nodes beyond depth 2 are supported but step
depthis tracked
Extent → Canonical field mapping
| Extent Field | Canonical Field |
|---|---|
test.name | TestCaseResult.name |
test.status | TestCaseResult.status |
test.startTime / endTime | started_at / finished_at |
test.nodes | TestCaseResult.steps |
test.media | TestCaseResult.attachments |
test.exception.message | FailureInfo.message |
test.exception.stackTrace | FailureInfo.stack_trace |
test.category | TestCaseResult.tags |
test.author | TestCaseResult.owner |
---
Allure Report Parser
Detection
The primary check is file-system existence of widgets/summary.json and data/suites.json — no file content is read during detection, making it almost free. Secondary signals (app.js, DOM markers) require file reads and are only evaluated if the primary check is inconclusive.
Phase 2 extraction (metadata only)
parse() currently:
- Resolves the report root directory.
- Loads
widgets/summary.jsonto extractreportNameandtime.start/time.stop. - Converts epoch-millisecond timestamps to UTC
datetimeobjects. - Builds and returns
RunMetadatawithstarted_atandfinished_atpopulated. - Returns
test_cases=[]with aHIGH-severityExtractionWarning.
Phase 3 extraction (implemented)
parse() now performs full test case extraction:
attachments, labels, links, parameters, retry count).
- Resolves the report root directory.
- Loads
widgets/summary.jsonfor run-level metadata (reportName, timestamps). - Loads
data/suites.jsonand recursively walks the suite tree to collect test UIDs. - For each UID, reads
data/test-cases/<uid>.jsonfor full detail (steps, failure, - Converts epoch-millisecond timestamps to UTC
datetimeobjects.
Extraction order preference: per-test JSON files (data/test-cases/<uid>.json) are the authoritative source. The suites tree (data/suites.json) is used only as the discovery index (UID + summary status/name as fallbacks when detail files are missing).
Partial extraction preference: when a test detail file is missing (network mount, partially copied report), the summary-level name and status from data/suites.json still produce a TestCaseResult — steps, failure, and attachments are empty and no warning is raised unless a detail parse error occurs.
Known limitations:
confidence via DOM markers but produce empty test_cases with a HIGH-severity warning (v1 detail JSON layout differs).
appear as separate TestCaseResult entries with is_retry=True.
- Allure v1 reports (flat HTML, no
data/directory) are detected at medium - Retry deduplication (grouping by
historyId) is not yet implemented; retries
Allure → Canonical field mapping
| Allure Field | Canonical Field |
|---|---|
name | TestCaseResult.name |
status | TestCaseResult.status |
time.start / time.stop | started_at / finished_at |
statusMessage | FailureInfo.message |
statusTrace | FailureInfo.stack_trace |
steps | TestCaseResult.steps |
attachments | TestCaseResult.attachments |
labels[name=feature] | TestCaseResult.feature |
labels[name=suite] | TestCaseResult.suite |
parameters | TestCaseResult.parameters |
links | TestCaseResult.links |
---
Partial Extraction and Warnings
When an expected field is absent or cannot be parsed, the parser calls self._warn(...) (provided by BaseParser) instead of raising:
self._warn(
field="FailureInfo.stack_trace",
reason="stackTrace field absent in Extent JSON payload",
test_name="LoginTest.testInvalidPassword",
severity=WarningSeverity.LOW,
)
All accumulated warnings are flushed into TestRun.warnings via self._collect_warnings() at the end of parse(). This guarantees that partial data with warnings is always returned rather than raising and losing everything that was already parsed.
---
Adding a New Parser
Detector(parsers=[..., MyParser()]) or detector.register(MyParser()).
- Create
src/qalens/parsers/<format>.pyand subclassBaseParser. - Set
parser_keyandparser_nameas class-level string attributes. - Implement
can_parse()→ returnDetectionResultwith confidence evidence. - Implement
parse()→ returnTestRun; useself._warn()for missing fields. - Add fixtures under
tests/fixtures/<format>_sample/. - Add tests in
tests/test_<format>_parser.py. - Export from
src/qalens/parsers/__init__.py. - The
Detectorwill pick it up automatically if registered via