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 %} - +
Subtests: {% if testcase.has_nested_subtests %} @@ -573,10 +638,11 @@ def generate_html( Sub Test Description Sub Test Result Waiver Reason + {% if show_reason %}Reason{% endif %} - {{ 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