diff --git a/common/acs_test_framework_manifests/logs-to-json.yaml b/common/acs_test_framework_manifests/logs-to-json.yaml
index 5a52efde..dedc0377 100644
--- a/common/acs_test_framework_manifests/logs-to-json.yaml
+++ b/common/acs_test_framework_manifests/logs-to-json.yaml
@@ -244,6 +244,7 @@ suites:
sub_test_result: "PASSED"
sub_Test_Level: 1
sub_Test_Path: "RULE : 1"
+ reason: []
- name: sct_logs_to_json_specific
diff --git a/common/log_parser/acs-results-schema.json b/common/log_parser/acs-results-schema.json
index 3d863057..66fde4f2 100644
--- a/common/log_parser/acs-results-schema.json
+++ b/common/log_parser/acs-results-schema.json
@@ -464,7 +464,7 @@
"additionalProperties": false
},
"bsa_subtest": {
- "$comment": "BSA subtest shape with required IDs and result.",
+ "$comment": "BSA subtest shape with required IDs, result and reason.",
"type": "object",
"properties": {
"sub_Test_Number": {
@@ -482,6 +482,12 @@
"sub_Test_Path": {
"type": "string"
},
+ "reason": {
+ "anyOf": [
+ { "type": "string" },
+ { "$ref": "#/definitions/string_list" }
+ ]
+ },
"sub_Rule_ID": {
"type": "string"
},
@@ -505,7 +511,8 @@
"required": [
"sub_Test_Number",
"sub_Test_Description",
- "sub_test_result"
+ "sub_test_result",
+ "reason"
],
"additionalProperties": false
},
@@ -627,7 +634,8 @@
"Test_case",
"Test_case_description",
"Test_result",
- "Test_case_summary"
+ "Test_case_summary",
+ "reason"
],
"properties": {
"Test_case": {
@@ -639,6 +647,12 @@
"Test_result": {
"type": "string"
},
+ "reason": {
+ "anyOf": [
+ { "type": "string" },
+ { "$ref": "#/definitions/string_list" }
+ ]
+ },
"Test_case_summary": {
"$ref": "#/definitions/bsa_suite_summary"
},
diff --git a/common/log_parser/bsa/json_to_html.py b/common/log_parser/bsa/json_to_html.py
index f38a4a81..63c0680b 100755
--- a/common/log_parser/bsa/json_to_html.py
+++ b/common/log_parser/bsa/json_to_html.py
@@ -45,6 +45,17 @@ def has_nested_subtests(subtests):
return True
return False
+def has_reason(records):
+ """Return True when a testcase or any nested subtest has check details."""
+ return any(
+ record.get("reason") or has_reason(record.get("subtests", []))
+ for record in records
+ )
+
+def format_reason(reason):
+ """Display ordered reason lines and accept earlier newline-separated strings."""
+ return "\n".join(reason) if isinstance(reason, list) else reason
+
def annotate_nested_subtests(test_results):
"""Mark each testcase that needs expand/collapse controls in HTML."""
for test_suite in test_results:
@@ -261,6 +272,35 @@ def generate_html(
text-align: center;
font-weight: normal;
}
+ td.test-reason {
+ white-space: normal;
+ font-weight: normal;
+ }
+ .test-reason .acs-long-reason > .acs-long-reason-text {
+ white-space: pre-wrap;
+ min-width: 0;
+ line-height: 1.7;
+ font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
+ font-size: 11px;
+ }
+ .reason-rule-toggle {
+ border: 0;
+ padding: 0;
+ background: transparent;
+ color: #175cd3;
+ cursor: pointer;
+ text-decoration: underline dotted;
+ text-underline-offset: 4px;
+ }
+ .reason-rule-toggle:hover {
+ text-decoration-style: solid;
+ }
+ .reason-rule-toggle:focus-visible,
+ .test-reason summary:focus-visible {
+ outline: 2px solid #175cd3;
+ outline-offset: 3px;
+ border-radius: 4px;
+ }
/* Keep rule numbers readable while indentation shows nesting. */
.subtest-number {
white-space: nowrap;
@@ -404,10 +444,19 @@ def generate_html(
+ {% macro render_reason(reason, reason_id) %}
+ {% if reason %}
+
+ View full reason
+ {{ format_reason(reason) | e }}
+
+ {% endif %}
+ {% endmacro %}
{# Render BSA/SBSA subtests recursively so the HTML follows the same
parent-child order as the JSON and original nested log. #}
- {% macro render_subtest_rows(subtests, parent_path='') %}
+ {% macro render_subtest_rows(subtests, parent_path='', reason_prefix='reason') %}
{% for subtest in subtests %}
+ {% set reason_id = reason_prefix ~ '-' ~ loop.index0 %}
{% set nesting_level = subtest.sub_Test_Level | default(1) | int %}
{% set subtest_path = subtest.sub_Test_Path | default(subtest.sub_Test_Number) %}
{% set has_children = subtest.subtests is defined and subtest.subtests %}
@@ -420,7 +469,11 @@ def generate_html(
{% else %}
{% endif %}
+ {% if subtest.reason %}
+
+ {% else %}
{{ subtest.sub_Test_Number }}
+ {% endif %}
{{ subtest.sub_Test_Description }} |
@@ -433,11 +486,14 @@ def generate_html(
N/A
{% endif %}
|
+ {% if show_reason %}
+ {{ render_reason(subtest.reason, reason_id) }} |
+ {% endif %}
{% if has_children %}
{# Child rows are printed immediately after their parent with
additional indentation from sub_Test_Level. #}
- {{ render_subtest_rows(subtest.subtests, subtest_path) }}
+ {{ render_subtest_rows(subtest.subtests, subtest_path, reason_id) }}
{% endif %}
{% endfor %}
{% endmacro %}
@@ -522,11 +578,13 @@ def generate_html(
Test Case Description |
Test Result |
Waiver Reason |
+ {% if show_reason %}Reason | {% endif %}
{% for testcase in test.testcases %}
{% set subtest_table_id = "subtests-" ~ suite_index ~ "-" ~ loop.index0 %}
+ {% set reason_id = "reason-" ~ suite_index ~ "-" ~ loop.index0 %}
|
{% if testcase.subtests %}
@@ -534,7 +592,11 @@ def generate_html(
{% else %}
{% endif %}
+ {% if testcase.reason %}
+
+ {% else %}
{{ testcase.Test_case }}
+ {% endif %}
|
{{ testcase.Test_case_description }} |
@@ -547,10 +609,13 @@ def generate_html(
N/A
{% endif %}
|
+ {% if show_reason %}
+ {{ render_reason(testcase.reason, reason_id) }} |
+ {% endif %}
{% if testcase.subtests %}
- |
+ |
|
- {{ render_subtest_rows(testcase.subtests) }}
+ {{ render_subtest_rows(testcase.subtests, reason_prefix=reason_id) }}
@@ -748,6 +814,49 @@ def generate_html(
syncTableControls
);
+ // Reason disclosures are independent of rule hierarchy controls.
+ var reasonPanels = Array.prototype.slice.call(
+ document.querySelectorAll('td.test-reason > details')
+ );
+ function syncReasonPanel(panel) {
+ var button = document.querySelector(
+ '.reason-rule-toggle[aria-controls="' + panel.id + '"]'
+ );
+ panel.querySelector('summary').textContent = (
+ panel.open ? 'Hide reason' : 'View full reason'
+ );
+ if (button) {
+ button.setAttribute('aria-expanded', String(panel.open));
+ button.title = (panel.open ? 'Hide reason for ' : 'Show reason for ')
+ + button.textContent;
+ }
+ }
+ reasonPanels.forEach(function (panel) {
+ panel.addEventListener('toggle', function () { syncReasonPanel(panel); });
+ });
+
+ var reasonPrintState = null;
+ window.addEventListener('beforeprint', function () {
+ if (reasonPrintState !== null) {
+ return;
+ }
+ reasonPrintState = reasonPanels.map(function (panel) { return panel.open; });
+ reasonPanels.forEach(function (panel) {
+ panel.open = true;
+ syncReasonPanel(panel);
+ });
+ });
+ window.addEventListener('afterprint', function () {
+ if (reasonPrintState === null) {
+ return;
+ }
+ reasonPanels.forEach(function (panel, index) {
+ panel.open = reasonPrintState[index];
+ syncReasonPanel(panel);
+ });
+ reasonPrintState = null;
+ });
+
document.addEventListener('click', function (event) {
// One delegated handler covers every generated subtest row
// and the per-testcase Expand all / Collapse all buttons.
@@ -758,7 +867,13 @@ def generate_html(
return;
}
- if (hasClass(button, 'testcase-toggle')) {
+ if (hasClass(button, 'reason-rule-toggle')) {
+ var panel = document.getElementById(button.getAttribute('aria-controls'));
+ if (panel) {
+ panel.open = !panel.open;
+ syncReasonPanel(panel);
+ }
+ } else if (hasClass(button, 'testcase-toggle')) {
setTestcaseExpanded(
button,
button.getAttribute('aria-expanded') !== 'true'
@@ -826,6 +941,8 @@ def generate_html(
total_not_implemented=suite_summary.get("total_not_implemented", 0),
total_pal_not_supported=suite_summary.get("total_pal_not_supported", 0),
test_results=test_results,
+ format_reason=format_reason,
+ show_reason=any(has_reason(test.get("testcases", [])) for test in test_results),
is_summary_page=is_summary_page,
test_suite_name=test_suite_name.upper() # Ensure uppercase for consistency
)
diff --git a/common/log_parser/bsa/logs_to_json.py b/common/log_parser/bsa/logs_to_json.py
index e95fb03d..01ca18bc 100644
--- a/common/log_parser/bsa/logs_to_json.py
+++ b/common/log_parser/bsa/logs_to_json.py
@@ -26,13 +26,57 @@
ANSI_ESCAPE_RE = re.compile(r'\x1b\[[0-9;]*m')
BRACKET_TIMESTAMP_RE = re.compile(r'^\s*\[.*?\]\s?')
SIM_TUBE_PREFIX_RE = re.compile(r'^\s*#\s*\d+\s+ns\s+tube:\s+[^:]+:\s?')
+SIM_CONTINUATION_PREFIX_RE = re.compile(r'^\s*#\s?')
SUITE_HEADER_RE = re.compile(r'\*\*\*\s+Running\s+(.+?)\s+tests\s+\*\*\*')
REFERENCED_RULES_MARKER_RE = re.compile(
r'===\s+(Start|End)\s+tests\s+for\s+rules\s+referenced\s+by\s+([A-Za-z0-9_]+)\s+===',
re.IGNORECASE
)
-RULE_LINE_RE = re.compile(r'\b([A-Za-z0-9_]+)\s*:\s*(-|\d+)\s*:\s*(.*)$')
+RULE_LINE_RE = re.compile(r'^([A-Za-z0-9_]+)\s*:\s*(-|\d+)\s*:\s*(.*)$')
RESULT_RE = re.compile(r'\bResult:\s*(.*)$', re.IGNORECASE)
+INTERNAL_TEST_RE = re.compile(r'^Test\s+\d+\s*:\s*.*:\s*\S.*$', re.IGNORECASE)
+DIAGNOSTIC_PREFIX_RE = re.compile(
+ r'^(?:ERROR|WARN(?:ING)?|INFO|DEBUG|TRACE|FATAL|TEST)\s*:',
+ re.IGNORECASE
+)
+REASON_PREFIX_RE = re.compile(r'^(?:ERROR|WARN(?:ING)?|FATAL|TEST)\s*:\s*\S', re.IGNORECASE)
+# INFO, fast-print and direct PAL messages can omit the severity label. Match
+# their outcome explanations, not arbitrary UART payload, debug or progress text.
+PLAIN_REASON_RE = re.compile(
+ r'(?:(?:(?:Failed|Skipped|Passed) at )?checkpoint\s*-\s*(?:0x[\da-f]+|\d+).*'
+ r'|(?:Failed|Skipped|Invalid|Unsupported|Mismatch|Timeout)\b.*'
+ r'|pal_\w+ is not implemented\.?'
+ r'|Please (?:implement|rerun)\b.*'
+ r'|(?:or )?conduct (?:an offline|manual) review\b.*'
+ r'|This test requires\b.*'
+ r'|No .+\b(?:defined|discovered|detected|found|present|available|supported|shared|skipping|reported|implemented)\b.*'
+ r'|No (?:Proximity domains in the system|translation support)\b.*'
+ r'|And no system wake up timer\b.*'
+ r'|ACS could not detect\b.*'
+ r'|PCIe Subsystem not discovered\b.*'
+ r'|(?:RAS|SRAT)\b.*\bnot found\b.*'
+ r'|Pre-requisite rule\b.*\bdid not pass\b.*'
+ r'|SMMU_02 not applicable\s*:\s*Stall model is not supported'
+ r'|Endpoint to (?:Host|Endpoint) DMA test (?:PASSED|FAILED|SKIPPED)'
+ r'|Counter frequency is \d+ (?:MHz|KHz)'
+ r'|FEAT_\w+ (?:not supported\b.*|supported; manual verification required\b.*)'
+ r'|(?:Received )?Failsafe interrupt\b.*'
+ r'|MSC Storage value mismatch\b.*'
+ r'|GET_CXL_COMPONENT_INFO\s*:\s*\S.*'
+ r'|Rule is validated by\b.*'
+ r'|If\b.*\bmanual(?:ly)? (?:validation|review|verify)\b.*'
+ r'|For\b.*\bmanually verify\b.*'
+ r'|The test must be considered fail\b.*'
+ r'|(?:GIC Install Handler Failed|Did not receive UART interrupt)\b.*)',
+ re.IGNORECASE
+)
+FIRMWARE_OUTPUT_RE = re.compile(r'ConvertPages\s*:', re.IGNORECASE)
+# These diagnostics can begin before the legacy 49-character display boundary.
+# Recognize the complete diagnostic instead of treating a truncated word as one.
+HEADER_REASON_RE = re.compile(
+ r'\b(?:No\s+ECAMs\s+discovered\b|GET_CXL_COMPONENT_INFO\s*:|Received\s+Failsafe\s+interrupt\b)',
+ re.IGNORECASE
+)
MAX_SUBTEST_DESCRIPTION_CHARS = 49
def detect_file_encoding(file_path):
@@ -140,7 +184,8 @@ def normalize_log_line(raw_line):
# Remove only that wrapper text and leave the ACS rule/result text intact.
line = ANSI_ESCAPE_RE.sub('', raw_line)
line = BRACKET_TIMESTAMP_RE.sub('', line)
- return SIM_TUBE_PREFIX_RE.sub('', line)
+ line = SIM_TUBE_PREFIX_RE.sub('', line)
+ return SIM_CONTINUATION_PREFIX_RE.sub('', line)
def extract_status_text(status_text):
# Some logs print a Result and the next marker/header on the same line.
@@ -156,6 +201,18 @@ def limit_subtest_description(description):
"""Limit a description to 49 characters."""
return (description or "").strip()[:MAX_SUBTEST_DESCRIPTION_CHARS].rstrip()
+def extract_reason_line(line):
+ """Select a diagnostic record, preserving its printed spacing and content."""
+ firmware = FIRMWARE_OUTPUT_RE.search(line)
+ if firmware:
+ line = line[:firmware.start()].rstrip()
+ # Normalize only for matching; stored reasons retain their internal spacing.
+ normalized = " ".join(line.split())
+ if (REASON_PREFIX_RE.match(normalized) or INTERNAL_TEST_RE.match(normalized) or
+ PLAIN_REASON_RE.fullmatch(normalized)):
+ return line
+ return None
+
# A frame is one rule that has started but has not reached its Result/END line.
# Keeping these frames on a stack lets the parser attach each completed child
# rule to the nearest still-open parent rule.
@@ -193,6 +250,13 @@ def make_rule_frame(suite, rule_id, test_index, description, parent, current_sou
}
if parent is None:
frame["root"] = frame
+ elif len(description.strip()) > MAX_SUBTEST_DESCRIPTION_CHARS:
+ header_match = HEADER_REASON_RE.search(description)
+ header_text = (description[header_match.start():] if header_match else
+ description.strip()[len(limit_subtest_description(description)):].strip())
+ header_reason = extract_reason_line(header_text)
+ if header_reason:
+ frame["reason_lines"] = [header_reason]
return frame
def subtest_entry_from_frame(frame, formatted_result):
@@ -205,7 +269,8 @@ def subtest_entry_from_frame(frame, formatted_result):
),
"sub_test_result": formatted_result,
"sub_Test_Level": frame.get("level", 1),
- "sub_Test_Path": " / ".join(frame.get("path", []))
+ "sub_Test_Path": " / ".join(frame.get("path", [])),
+ "reason": list(frame.get("reason_lines", []))
}
subtests = frame.get("subtests", [])
if subtests:
@@ -217,7 +282,8 @@ def testcase_from_frame(frame, formatted_result):
"Test_case": frame.get("number", make_test_number(frame.get("rule_id"), frame.get("index"))),
"Test_case_description": frame.get("description", ""),
"Test_result": formatted_result,
- "_source": frame.get("source", "unknown")
+ "_source": frame.get("source", "unknown"),
+ "reason": list(frame.get("reason_lines", []))
}
subtests = frame.get("subtests", [])
if subtests:
@@ -343,6 +409,14 @@ def main(input_files, output_file):
if not processing:
continue
+ reason_line = extract_reason_line(line)
+ # These are diagnostic records even when their text contains rule
+ # or Result-like fields. The producer can emit them for any outcome.
+ if INTERNAL_TEST_RE.match(line) or DIAGNOSTIC_PREFIX_RE.match(line):
+ if rule_stack and reason_line:
+ rule_stack[-1].setdefault("reason_lines", []).append(reason_line)
+ continue
+
# ---------------- New log format support ----------------
# Newer BSA/SBSA logs can nest rule groups:
# : :
@@ -423,6 +497,10 @@ def main(input_files, output_file):
continue
frame = rule_stack.pop()
+ # A diagnostic and its final Result can share a physical line.
+ joined_reason = extract_reason_line(line[:result_match.start()].strip())
+ if joined_reason:
+ frame.setdefault("reason_lines", []).append(joined_reason)
remove_marker_frame(marker_stack, frame)
formatted_result, summary_category = classify_status(status_text)
complete_rule_frame(
@@ -497,7 +575,10 @@ def main(input_files, output_file):
)
continue
- # Ignore all other lines (debug, informational, etc.)
+ # Accept known plain diagnostic forms only inside their owning rule.
+ # Result status does not determine whether a reason is retained.
+ if rule_stack and reason_line and not suite_hdr and not referenced_rules_marker:
+ rule_stack[-1].setdefault("reason_lines", []).append(reason_line)
continue
# Post-process UEFI/Linux duplicates per testcase
@@ -540,6 +621,9 @@ def main(input_files, output_file):
if key != "B_PER_08 : -":
existing_tc["Test_result"] = linux_tc.get("Test_result")
existing_tc["Test_case_summary"] = linux_tc.get("Test_case_summary")
+ # The reason must describe the same execution as the result,
+ # including when an older Linux log has no check details.
+ existing_tc["reason"] = linux_tc.get("reason", [])
# Override only matching subtests. Linux-only subtests are not
# appended because the UEFI tree is the report structure.
diff --git a/docs/acs_results_structure.md b/docs/acs_results_structure.md
index 3cdee468..0a01a8af 100644
--- a/docs/acs_results_structure.md
+++ b/docs/acs_results_structure.md
@@ -30,7 +30,7 @@ Comprehensive reference for JSON output structures across all test suite parsers
| **Test Entry Point GUID** | - | - | String | - | String | - | - | - | - |
| **Test_result** | String | - | String | - | String | - | - | - | - |
| **Returned Status Code** | - | - | String | - | String | - | - | - | - |
-| **reason** | - | - | String | - | String | - | - | - | String |
+| **reason** | Array of Strings (required; legacy String accepted) | - | String | - | String | - | - | - | String |
| | | | | | | | | | |
| **═══ SUBTEST GROUP (subtests[]) ═══** | | | | | | | | | |
| **subtests[]** | Array | Array | Array | Array | Array | Array | Array | Array | Array |
@@ -40,7 +40,7 @@ Comprehensive reference for JSON output structures across all test suite parsers
| **sub_Test_Level** | Integer | - | - | - | - | - | - | - | - |
| **sub_Test_Path** | String | - | - | - | - | - | - | - | - |
| **sub_test_result** | **String:**
• PASSED
• FAILED
• SKIPPED | **dict:**
• PASSED
• FAILED
• SKIPPED
• ABORTED
• WARNINGS
• FAILED_WITH_WAIVER
• pass_reasons (Array)
• fail_reasons (Array)
• skip_reasons (Array)
• abort_reasons (Array)
• warning_reasons (Array) | **String:**
• PASSED
• FAILED
• SKIPPED
• ABORTED
• WARNING | **dict:**
• PASSED
• FAILED
• SKIPPED
• ABORTED
• WARNINGS
• FAILED_WITH_WAIVER
• pass_reasons (Array)
• fail_reasons (Array)
• skip_reasons (Array)
• abort_reasons (Array)
• warning_reasons (Array) | **String:**
• PASSED
• FAILED
• SKIPPED
• ABORTED
• WARNING | **String:**
• PASSED
• FAILED
• SKIPPED
• ABORTED
• WARNING | **dict:**
• PASSED
• FAILED
• SKIPPED
• ABORTED
• WARNINGS
• FAILED_WITH_WAIVER
• pass_reasons (Array)
• fail_reasons (Array)
• skip_reasons (Array)
• abort_reasons (Array)
• warning_reasons (Array)
• waiver_reason (Array) | **dict:**
• PASSED
• FAILED
• SKIPPED
• ABORTED
• WARNINGS
• pass_reasons (Array)
• fail_reasons (Array)
• skip_reasons (Array)
• abort_reasons (Array)
• warning_reasons (Array) | **String:**
• PASSED
• FAILED
• SKIPPED
• ABORTED
• WARNING |
-| **reason** | - | - | String | - | String | String | - | - | String |
+| **reason** | Array of Strings (required; legacy String accepted) | - | String | - | String | String | - | - | String |
**Legend:**
- `-` Field not present in this suite
@@ -56,6 +56,8 @@ Comprehensive reference for JSON output structures across all test suite parsers
1. **PFDI** - ONLY suite with top-level Array (not dict with test_results)
2. **BSA** - Uses unique summary field names (Passed, Failed, Skipped vs total_passed, total_failed, total_skipped)
- BSA/SBSA `subtests[]` can be nested recursively and use `sub_Test_Path` for exact log-path identity.
+ - For reason capture and display behavior, see the [log parser guide](log_parser_guide.md#complete-standalone-bsa-example).
+ - When UEFI and Linux results are combined, `reason` follows the selected result source. The existing `B_PER_08` exception keeps its UEFI testcase result and reason; matching nested rules still use Linux results and reasons.
- New BSA/SBSA JSON does not emit `sub_Rule_ID`; waiver files may still use it as a legacy matcher.
3. **Naming variations:**
- `suite_summary` (most) vs `Suite_summary` (PFDI - capital S)
diff --git a/docs/log_parser_guide.md b/docs/log_parser_guide.md
index b9f667f3..4a52beb1 100644
--- a/docs/log_parser_guide.md
+++ b/docs/log_parser_guide.md
@@ -670,6 +670,20 @@ This command discovers BSA logs, applies BSA waivers, enriches metadata,
validates raw BSA JSON, generates suite HTML, and creates selected-only merged
and combined summaries.
+BSA, SBSA and PFDI JSON always includes a `reason` array on testcases and
+nested subtests. It stores accepted diagnostics and individual check results
+printed between the rule header and final result, one line per entry in log
+order, for any outcome. Messages belong to their owning rule; `reason` is
+`[]` when no messages qualify. The schema requires this field.
+
+Matching is case-insensitive and tolerates whitespace. Unrecognized text and
+known UART, firmware and debug noise are excluded. Reasons do not change test
+results or summary counts. Legacy string reasons remain supported.
+
+Reasons are collapsed in detailed HTML. Select the rule name or **View full
+reason** to expand them. The Reason column is hidden when all reasons are empty.
+Search includes hidden reasons, and printing shows their full content.
+
### Doctor Preflight
`--doctor` checks suite/mode compatibility, the registry, parser files, required