diff --git a/common/acs_test_framework_runner/report_ui_browser_smoke.py b/common/acs_test_framework_runner/report_ui_browser_smoke.py index a7f746da..786d9643 100644 --- a/common/acs_test_framework_runner/report_ui_browser_smoke.py +++ b/common/acs_test_framework_runner/report_ui_browser_smoke.py @@ -20,6 +20,7 @@ import html import importlib +import json from functools import partial from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer import os @@ -42,6 +43,14 @@ def _load_enhancer(): return importlib.import_module("report_ui").enhance_html_report +def _load_compliance_injector(): + """Import the merged-data compliance injector under test.""" + sys.path.insert(0, str(LOG_PARSER_DIR)) + return importlib.import_module( + "generate_acs_summary" + ).inject_detail_compliance + + def _chromium_binary() -> str: for candidate in ("chromium", "chromium-browser"): resolved = shutil.which(candidate) @@ -281,16 +290,16 @@ def _run_page(

BSA compact summary smoke

Result Summary

- - - - + + + + - - - - - + + + + +
Total Tests116
Passed22
Failed47
Failed with Waiver0
Total Tests5
Passed1
Failed2
Failed with Waiver1
Aborted0
Skipped12
Warnings12
Passed (Partial)3
Not implemented18
PAL not supported2
Skipped0
Warnings0
Passed (Partial)1
Not implemented0
PAL not supported0
chart
@@ -305,7 +314,7 @@ def _run_page( B_GIC_01GIC versionPASSED(*PARTIAL) B_GIC_02GIC failure oneFAILED B_GIC_03GIC failure twoFAILED - + + {% if ds.summary.total_ignored %}{% endif %}
@@ -333,22 +342,66 @@ def _run_page( var kicker = document.querySelector("body > .acs-report-kicker"); var backLink = document.querySelector(".acs-back-to-main"); var backRect = backLink && backLink.getBoundingClientRect(); + var compliancePanel = document.querySelector(".acs-detail-compliance"); + var complianceTable = compliancePanel && compliancePanel.querySelector( + ".acs-compliance-table"); + var complianceRow = complianceTable && complianceTable.tBodies[0].rows[0]; var rows = Array.prototype.slice.call(summary.querySelectorAll(".acs-progress-row")); var titleRect = summary.querySelector("h2").getBoundingClientRect(); var totalRect = summary.querySelector(".acs-compact-summary-total").getBoundingClientRect(); expect(!document.querySelector(".chart-container") && !document.querySelector("#chart-probe"), "BSA/SBSA legacy chart must be removed from the rendered report"); + expect(compliancePanel && complianceTable && + compliancePanel.querySelector(".acs-compliance-tab h2").textContent === + "Compliance results" && + Array.prototype.map.call(complianceTable.tHead.rows[0].cells, function (cell) { + return cell.textContent.trim(); + }).join(" | ") === "Test suite | Requirement | Compliance", + "Single-suite compliance must use the labelled three-column table"); + expect(complianceTable.tBodies[0].rows.length === 1 && + Array.prototype.map.call(complianceRow.cells, function (cell) { + return cell.textContent.trim(); + }).join(" | ") === "BSA | Mandatory | Not Compliant" && + complianceRow.getAttribute("data-acs-compliance-tone") === "fail", + "Single-suite compliance must come from the merged run record"); + expect(document.querySelector(".acs-report-subtitle").nextElementSibling === + compliancePanel && compliancePanel.nextElementSibling === overview, + "Compliance results must sit between the subtitle and the two summary cards"); + expect(!/Run compliance|Overall|Reason/.test(compliancePanel.textContent), + "Compliance results must not repeat overall status, reasons, or failure counts"); + var complianceStyle = window.getComputedStyle(compliancePanel); + var complianceRail = window.getComputedStyle(complianceRow.cells[0]).boxShadow; + expect(complianceStyle.borderLeftWidth === "1px" && + complianceStyle.borderLeftColor === complianceStyle.borderTopColor && + complianceRail.indexOf("220, 38, 38") >= 0, + "The compliance panel must use a neutral border and only a red result rail"); + if (window.innerWidth > 1100) { + var complianceCells = Array.prototype.map.call( + complianceRow.cells, function (cell) { return cell.getBoundingClientRect(); } + ); + var requirementMidpoint = complianceCells[1].left + complianceCells[1].width / 2; + var overviewGapMidpoint = (summaryRect.right + breakdownRect.left) / 2; + expect(Math.abs(complianceCells[0].width / complianceTable.getBoundingClientRect().width - .4) < .02 && + Math.abs(complianceCells[1].width / complianceTable.getBoundingClientRect().width - .2) < .02 && + Math.abs(complianceCells[2].width / complianceTable.getBoundingClientRect().width - .4) < .02 && + Math.abs(requirementMidpoint - overviewGapMidpoint) < 3, + "The 40/20/40 columns must center Requirement over the overview gap"); + } + expect(complianceTable.getBoundingClientRect().right <= + compliancePanel.getBoundingClientRect().right + 1 && + !complianceRow.hasAttribute("data-acs-row-status"), + "Compliance rows must fit the panel without entering result filters"); expect(summary.querySelector("h2").textContent === "Test-result summary", "Compact summary must use the requested heading"); expect(summary.querySelector(".acs-compact-summary-total").textContent === - "116 suite-reported tests", "Compact summary must show the source total"); + "5 suite-reported tests", "Compact summary must show the source total"); expect(rows.length === 9, "Compact BSA/SBSA summary must preserve every status row"); expect(rows.map(function (row) { return row.querySelector(".acs-progress-label").textContent + " " + row.querySelector(".acs-progress-count").textContent; }).join(" | ") === - "Passed 22 | Failed 47 | Failed with Waiver 0 | Aborted 0 | Skipped 12 | " + - "Warnings 12 | Passed (Partial) 3 | Not implemented 18 | PAL not supported 2", + "Passed 1 | Failed 2 | Failed with Waiver 1 | Aborted 0 | Skipped 0 | " + + "Warnings 0 | Passed (Partial) 1 | Not implemented 0 | PAL not supported 0", "Moving the summary must preserve every status label and count"); var partialSummary = document.querySelector('[data-acs-summary-status="passed-partial"]'); expect(partialSummary && partialSummary.querySelector(".acs-progress-label").textContent === @@ -405,7 +458,7 @@ def _run_page( "Every failure-summary row must remain visible without an internal scroll area"); expect(breakdown.querySelector("h2").textContent === "Failures by test suite" && breakdown.querySelector(".acs-compact-summary-total").textContent === - "2 test suites · 3 failed · 1 waived", + "2 test suites · 2 failed · 1 waived", "Failure breakdown must declare its complete test-suite scope and totals"); expect(failureRows.length === 2 && failureRows.map(function (row) { @@ -413,15 +466,20 @@ def _run_page( row.getAttribute("data-acs-failed") + ":" + row.getAttribute("data-acs-failed-with-waiver") + ":" + row.getAttribute("data-acs-outcomes"); - }).join(" | ") === "GIC:3:1:5 | MEM_MAP:0:0:1", + }).join(" | ") === "GIC:2:1:4 | MEM_MAP:0:0:1", "Every BSA test suite, including the zero-failure suite, must appear exactly once"); + expect(Number(summary.querySelector( + '[data-acs-summary-status="fail"] .acs-progress-count').textContent) === + failureRows.reduce(function (total, row) { + return total + Number(row.getAttribute("data-acs-failed")); + }, 0), "BSA suite failures must equal the suite-reported Failed total"); expect(document.getElementById("nested-container").getAttribute("data-acs-row-status") === null && document.getElementById("gic-nested-fail").getAttribute("data-acs-row-status") === "fail", - "Nested BSA leaf failures must count once without counting their container row"); - expect(failureRows[0].querySelector(".acs-progress-track").getAttribute("aria-valuenow") === "4" && - failureRows[0].querySelector(".acs-progress-track").getAttribute("aria-valuemax") === "5" && - failureRows[0].querySelector(".acs-failure-fill").style.width === "60%" && - failureRows[0].querySelector(".acs-failure-fill-waiver").style.width === "20%" && + "Nested BSA failures must remain filterable without entering the suite breakdown"); + expect(failureRows[0].querySelector(".acs-progress-track").getAttribute("aria-valuenow") === "3" && + failureRows[0].querySelector(".acs-progress-track").getAttribute("aria-valuemax") === "4" && + failureRows[0].querySelector(".acs-failure-fill").style.width === "50%" && + failureRows[0].querySelector(".acs-failure-fill-waiver").style.width === "25%" && failureRows[1].getAttribute("data-acs-zero") === "true", "Failure tracks must use per-suite outcome ratios and preserve waived failures separately"); expect(document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1, @@ -645,6 +703,55 @@ def _run_page( window.setTimeout(function () { var failures = []; function expect(condition, message) { if (!condition) { failures.push(message); } } + var compliancePanel = document.querySelector(".acs-detail-compliance"); + var complianceTable = compliancePanel && compliancePanel.querySelector( + ".acs-compliance-table"); + var complianceRows = complianceTable ? Array.prototype.slice.call( + complianceTable.tBodies[0].rows + ) : []; + var complianceValues = complianceRows.map(function (row) { + return Array.prototype.map.call(row.cells, function (cell) { + return cell.textContent.trim(); + }).join(" | "); + }); + expect(compliancePanel && + compliancePanel.querySelector(".acs-compliance-tab h2").textContent === + "Compliance results" && + Array.prototype.map.call(complianceTable.tHead.rows[0].cells, function (cell) { + return cell.textContent.trim(); + }).join(" | ") === "Test case | Requirement | Compliance", + "Standalone compliance must use the same table with a Test case column"); + expect(complianceValues.join(" || ") === + "DT-KSELFTEST | Recommended | Compliant || " + + "DT-VALIDATE | Mandatory | Not Compliant || " + + "ETHTOOL-TEST | Mandatory | Compliant || " + + "READ-WRITE-CHECK-BLK-DEVICES | Mandatory | Compliant || " + + "CAPSULE-UPDATE | Mandatory | Not Compliant (Not Run) || " + + "PSCI | Recommended | Compliant || SMBIOS | Recommended | Compliant || " + + "Future component | Future requirement | " + + "Partner review pending: ", + "Standalone must add only missing Mandatory components to its compliance table"); + expect(!complianceValues.some(function (value) { + return value.indexOf("NETWORK-BOOT") >= 0; + }), + "A missing Recommended component must remain in the SRS summary only"); + expect(complianceRows.every(function (row) { + return !row.hasAttribute("data-acs-row-status"); + }) && document.documentElement.scrollWidth <= + document.documentElement.clientWidth + 1, + "Standalone compliance rows must remain outside filters without overflow"); + expect(window.getComputedStyle(compliancePanel).borderLeftColor === + window.getComputedStyle(compliancePanel).borderTopColor && + window.getComputedStyle(complianceRows[0].cells[0]).boxShadow.indexOf( + "22, 163, 74") >= 0 && + window.getComputedStyle(complianceRows[1].cells[0]).boxShadow.indexOf( + "220, 38, 38") >= 0 && + window.getComputedStyle(complianceRows[4].cells[0]).boxShadow.indexOf( + "220, 38, 38") >= 0 && + window.getComputedStyle(complianceRows[7].cells[0]).boxShadow.indexOf( + "100, 116, 139") >= 0, + "Standalone must use red rails for failed and mandatory-not-run results, " + + "with a neutral future-status fallback"); var select = document.querySelector(".acs-control select"); expect(select && select.previousElementSibling.textContent === "Jump to test case" && select.options[0].textContent === "Choose a test case…", @@ -1240,6 +1347,7 @@ def main() -> int: """Generate synthetic reports and execute their interactions in Chromium.""" browser = _chromium_binary() enhance_html_report = _load_enhancer() + inject_detail_compliance = _load_compliance_injector() image_chart = ( 'Chart stripping' '
' @@ -1267,6 +1375,110 @@ def main() -> int: card_summary = enhance_html_report(CARD_SUMMARY_PAGE, suite_type="sbmr") summary = enhance_html_report(SUMMARY_PAGE, page_type="acs-summary") band_mismatch = enhance_html_report(BAND_MISMATCH_PAGE, page_type="acs-summary") + os_detail = enhance_html_report(DETAIL_PAGE, suite_type="os") + + compliance_data = { + "Suite_Name: acs_info": { + "ACS Results Summary": { + "Suite_Name: Mandatory : BSA_compliance": + "Not Compliant: Failed 2", + "Suite_Name: Recommended : DT_KSELFTEST_compliance": + "Compliant", + "Suite_Name: Mandatory : DT_VALIDATE_compliance": + "Not Compliant: Failed 8", + "Suite_Name: Mandatory : ETHTOOL_TEST_compliance": + "Compliant", + "Suite_Name: Mandatory : READ_WRITE_CHECK_BLK_DEVICES_compliance": + "Compliant", + "Suite_Name: Mandatory : Capsule Update_compliance": + "Not Compliant: not run", + "Suite_Name: Recommended : NETWORK_BOOT_compliance": + "Not Run", + "Suite_Name: Recommended : PSCI_compliance": "Compliant", + "Suite_Name: Recommended : SMBIOS_compliance": "Compliant", + "Suite_Name: Future requirement : Future component_compliance": + "Partner review pending: ", + "Suite_Name: Mandatory : OS_linux-alpha_compliance": + "Compliant with waivers: Waivers 1", + "Suite_Name: Mandatory : OS_linux-beta_compliance": "Not Run", + "Suite_Name: Future requirement : OS_linux-future_compliance": + "Partner review pending: ", + } + }, + "Suite_Name: Standalone": [ + {"Test_case": "dt_kselftest", "Test_suite": "Peripherals"}, + {"Test_case": "dt_validate", "Test_suite": "DTValidation"}, + {"Test_case": "ethtool_test", "Test_suite": "Network"}, + { + "Test_case": "read_write_check_blk_devices", + "Test_suite": "Boot sources", + }, + {"Test_case": "psci_check", "Test_suite": "PSCI"}, + {"Test_case": "SmbiosTable", "Test_suite": "SMBIOS"}, + {"Test_case": "Future component", "Test_suite": "Future suite"}, + ], + } + compliance_files = { + "bsa_detailed.html": bsa_detail, + "standalone_tests_detailed.html": case_navigation, + "os_tests_detailed.html": os_detail, + } + for filename, content in compliance_files.items(): + (directory / filename).write_text(content, encoding="utf-8") + merged_path = directory / "merged_results.json" + merged_path.write_text(json.dumps(compliance_data), encoding="utf-8") + + missing_only_directory = directory / "missing_only" + missing_only_directory.mkdir() + missing_only_detail = ( + missing_only_directory / "standalone_tests_detailed.html" + ) + missing_only_detail.write_text(case_navigation, encoding="utf-8") + missing_only_merged = missing_only_directory / "merged_results.json" + missing_only_merged.write_text(json.dumps({ + "Suite_Name: acs_info": { + "ACS Results Summary": { + "Suite_Name: Mandatory : Capsule Update_compliance": + "Not Compliant: not run", + } + } + }), encoding="utf-8") + inject_detail_compliance( + str(missing_only_merged), str(missing_only_directory) + ) + missing_only_content = missing_only_detail.read_text(encoding="utf-8") + if 'data-acs-detail-compliance="true"' in missing_only_content: + raise RuntimeError( + "A missing-only Standalone run must not gain an empty detail table" + ) + + inject_detail_compliance(str(merged_path), str(directory)) + first_injection = { + filename: (directory / filename).read_bytes() + for filename in compliance_files + } + inject_detail_compliance(str(merged_path), str(directory)) + bsa_detail = (directory / "bsa_detailed.html").read_text(encoding="utf-8") + case_navigation = ( + directory / "standalone_tests_detailed.html" + ).read_text(encoding="utf-8") + os_detail = (directory / "os_tests_detailed.html").read_text(encoding="utf-8") + if bsa_detail.count('data-acs-detail-compliance="true"') != 1 or \ + case_navigation.count('data-acs-detail-compliance="true"') != 1: + raise RuntimeError("Compliance injection must be idempotent") + if any( + (directory / filename).read_bytes() != first_injection[filename] + for filename in compliance_files): + raise RuntimeError( + "Repeated compliance injection must be byte-identical" + ) + if not all(value in os_detail for value in ( + "OS-linux-alpha", "Compliant with waivers", "OS-linux-beta", + "Not Run", "OS-linux-future", + "Partner review pending: <keep this detail>")): + raise RuntimeError( + "Dynamic OS compliance values were omitted or not escaped" + ) _run_page(browser, directory, "sct_detail", sct_detail) _run_page(browser, directory, "fwts_detail", fwts_detail) _run_page(browser, directory, "bsa_detail", bsa_detail) diff --git a/common/log_parser/generate_acs_summary.py b/common/log_parser/generate_acs_summary.py index 03edc846..56550a7e 100644 --- a/common/log_parser/generate_acs_summary.py +++ b/common/log_parser/generate_acs_summary.py @@ -28,12 +28,41 @@ from jinja2 import Template from report_ui import enhance_html_report +from suite_registry import get_suite, load_registry, normalize_suite_name YOCTO_FLAG_PATH = "/mnt/yocto_image.flag" LEGACY_SUITE_KEYS = {"Suite_Name: FWTS", "Suite_Name: SCT"} OBSOLETE_DT_SUITE_KEYS = {"Suite_Name: BBR-FWTS", "Suite_Name: BBR-SCT"} +COMPLIANCE_KEY_PATTERN = re.compile( + r"^Suite_Name:\s*([^:]+?)\s*:\s*(.+?)_compliance\s*$", + re.IGNORECASE, +) + +DETAIL_COMPLIANCE_TARGETS = ( + ("bsa_detailed.html", ("BSA",), "BSA"), + ("sbsa_detailed.html", ("SBSA",), "SBSA"), + ( + "fwts_detailed.html", + ("SBBR-FWTS", "EBBR-FWTS", "FWTS"), + "FWTS", + ), + ( + "sct_detailed.html", + ("SBBR-SCT", "EBBR-SCT", "SCT"), + "SCT", + ), + ("bbsr_fwts_detailed.html", ("BBSR-FWTS",), "BBSR-FWTS"), + ("bbsr_sct_detailed.html", ("BBSR-SCT",), "BBSR-SCT"), + ("bbsr_tpm_detailed.html", ("BBSR-TPM",), "BBSR-TPM"), + ("pfdi_detailed.html", ("PFDI",), "PFDI"), + ("post_script_detailed.html", ("POST_SCRIPT",), "POST-SCRIPT"), + ("scmi_detailed.html", ("SCMI",), "SCMI"), + ("sbmr_ib_detailed.html", ("SBMR-IB",), "SBMR-IB"), + ("sbmr_oob_detailed.html", ("SBMR-OOB",), "SBMR-OOB"), +) + def _prefix_from_band(band): """Map an ACS band label to its specification prefix, if recognized.""" @@ -412,6 +441,449 @@ def fmt_info(info): with open(file_path, "w", encoding="utf-8") as file: file.write(updated) + +def _compliance_identity(value): + """Return a stable identifier for matching merged compliance entries.""" + return re.sub(r"[^A-Z0-9]+", "", str(value or "").upper()) + + +def _compliance_display(value): + """Return the user-facing compliance value without its count/reason.""" + raw_value = " ".join(str(value or "Unknown").split()) or "Unknown" + normalized = raw_value.lower() + if normalized.startswith("not compliant"): + return "Not Compliant", "fail" + if normalized.startswith("compliant with waiver"): + return "Compliant with waivers", "pass" + if normalized.startswith("compliant"): + return "Compliant", "pass" + if normalized.startswith("not run"): + return "Not Run", "not-run" + if normalized.startswith("unknown"): + return "Unknown", "unknown" + return raw_value, "unknown" + + +def _requirement_display(value): + """Normalize known requirement labels while preserving future values.""" + raw_value = " ".join(str(value or "Unknown").split()) + normalized = raw_value.lower().replace("-", " ") + labels = { + "mandatory": "Mandatory", + "recommended": "Recommended", + "conditional mandatory": "Conditional-Mandatory", + "extension": "Extension", + } + return labels.get(normalized, raw_value or "Unknown") + + +def _compliance_slug(value): + """Return a CSS-safe slug for a requirement or status label.""" + slug = re.sub(r"[^a-z0-9]+", "-", str(value or "").lower()).strip("-") + return slug or "unknown" + + +def _compliance_records(merged_data): + """Read run-specific suite compliance records from merged ACS data.""" + if not isinstance(merged_data, dict): + return [] + acs_info = merged_data.get("Suite_Name: acs_info", {}) + if not isinstance(acs_info, dict): + return [] + results = acs_info.get("ACS Results Summary", {}) + if not isinstance(results, dict): + return [] + + records = [] + for key, value in results.items(): + match = COMPLIANCE_KEY_PATTERN.match(str(key)) + if not match: + continue + requirement = _requirement_display(match.group(1)) + component = " ".join(match.group(2).split()) + compliance, tone = _compliance_display(value) + records.append({ + "component": component, + "identity": _compliance_identity(component), + "requirement": requirement, + "requirement_slug": _compliance_slug(requirement), + "compliance": compliance, + "tone": tone, + "not_run": bool(re.search( + r"\bnot[\s_-]*run\b", str(value or "").lower() + )), + }) + return records + + +def _merged_entries(value): + """Return merged suite entries without changing their source order.""" + if isinstance(value, list): + return value + if isinstance(value, dict) and isinstance(value.get("test_results"), list): + return value["test_results"] + if isinstance(value, dict): + return [value] + return [] + + +def _summary_compliance(rows): + """Summarize existing decisions for a card, without evaluating raw tests.""" + if not rows: + return {"requirement": "Unknown", "compliance": "Unknown", "tone": "unknown"} + mandatory = [row for row in rows if row["requirement"] in ( + "Mandatory", "Conditional-Mandatory" + )] + relevant = mandatory or rows + requirements = {row["requirement"] for row in relevant} + requirement = "Mandatory" if "Mandatory" in requirements else " / ".join(sorted(requirements)) + if all(row.get("not_run") for row in rows): + status, tone = "Not Run", "fail" if "Mandatory" in requirements else "not-run" + elif any(row["tone"] == "fail" or + (row.get("not_run") and row["requirement"] == "Mandatory") for row in relevant): + status, tone = "Not Compliant", "fail" + elif any(row["tone"] == "unknown" for row in relevant): + status, tone = "Unknown", "unknown" + elif any("waiver" in row["compliance"].lower() for row in relevant): + status, tone = "Compliant with waivers", "pass" + else: + status, tone = "Compliant", "pass" + return {"requirement": requirement, "compliance": status, "tone": tone} + + +def _summary_cards(merged_data, sources, output_dir): + """Decorate collected summaries and show missing applicable suites only.""" + records = _compliance_records(merged_data) + by_identity = {row["identity"]: row for row in records} + registry = load_registry() + standalone_keys = { + _compliance_identity(item.get("requirement_key", item["canonical"])) + for item in registry + if item["canonical"] in get_suite("STANDALONE", registry)["included_suites"] + } + cards = [] + for section_id, label, content, detail, candidates in sources: + if section_id == "standalone_summary": + rows = [row for row in records if row["identity"] in standalone_keys] + elif section_id == "OS_tests_summary": + rows = [row for row in records if row["identity"].startswith("OS")] + else: + rows = [by_identity[key] for key in map(_compliance_identity, candidates) + if key in by_identity] + if not content and not rows: + continue + not_run = bool(rows) and all(row.get("not_run") for row in rows) + state = _summary_compliance(rows) + if not_run or not content: + message = "Not Run" if not_run else "Summary unavailable" + explanation = ("No results were collected for this suite." if not_run else + "The summary report was not provided for this suite.") + content = ( + f'

{html.escape(label)} Test Summary

' + '
' + f'{message}

{explanation}

' + ) + badge_text = f'{state["requirement"]} ({state["compliance"]})' + context = "; ".join( + f'{row["component"]}: {row["requirement"]} ' + f'({row["compliance"]}{"; Not Run" if row.get("not_run") else ""})' + for row in rows + ) or "Run-specific compliance information was not provided." + badge = ( + f'{html.escape(badge_text)}' + ) + content = re.sub( + r"(]*>.*?)", + lambda match: '
' + match.group(1) + badge + '
', + content, count=1, flags=re.IGNORECASE | re.DOTALL, + ) + cards.append({"id": section_id, "label": label, "content": content, + "detail": detail if not not_run and + os.path.isfile(os.path.join(output_dir, detail)) else ""}) + return cards + + +def _standalone_rows(merged_data, records): + """Return one compliance row for each Standalone component in this run.""" + registry = load_registry() + standalone = get_suite("STANDALONE", registry) or {} + included_order = standalone.get("included_suites", []) + included = set(included_order) + registry_by_name = { + item.get("canonical"): item + for item in registry + if item.get("canonical") in included + } + records_by_identity = {record["identity"]: record for record in records} + special_cases = { + _compliance_identity("Runtime device mapping conflict test"): + "RUNTIME-DEV-MAP", + _compliance_identity("SmbiosTable"): "SMBIOS", + } + + rows = [] + seen = set() + standalone_data = merged_data.get("Suite_Name: Standalone", {}) + for entry in _merged_entries(standalone_data): + if not isinstance(entry, dict) or not entry: + continue + candidates = [ + entry.get("Test_case"), + entry.get("Test_suite"), + entry.get("Test_suite_name"), + ] + canonical = "" + for candidate in candidates: + normalized = normalize_suite_name(str(candidate or ""), registry) + if normalized in included: + canonical = normalized + break + special = special_cases.get(_compliance_identity(candidate)) + if special: + canonical = special + break + + record = None + display_name = "" + identity = "" + if canonical: + registry_entry = registry_by_name.get(canonical, {}) + requirement_key = registry_entry.get("requirement_key", canonical) + identity = _compliance_identity(requirement_key) + record = records_by_identity.get(identity) + display_name = canonical + else: + for candidate in candidates: + candidate_identity = _compliance_identity(candidate) + if candidate_identity in records_by_identity: + identity = candidate_identity + record = records_by_identity[candidate_identity] + break + display_name = ( + record["component"] if record else + next((str(value).strip() for value in candidates if value), + "Unknown component") + ) + identity = identity or _compliance_identity(display_name) + + if identity in seen: + continue + seen.add(identity) + if record: + rows.append({**record, "component": display_name}) + else: + rows.append({ + "component": display_name, + "identity": identity, + "requirement": "Unknown", + "requirement_slug": "unknown", + "compliance": "Unknown", + "tone": "unknown", + }) + + if not rows: + return rows + + # Keep a missing Mandatory component visible in the existing Standalone + # compliance table without creating an empty result report for it. + for canonical in included_order: + registry_entry = registry_by_name.get(canonical, {}) + requirement_key = registry_entry.get("requirement_key", canonical) + identity = _compliance_identity(requirement_key) + record = records_by_identity.get(identity) + if identity in seen or not record or not record.get("not_run"): + continue + if ( + record["requirement"] != "Mandatory" or + record["compliance"] != "Not Compliant" + ): + continue + missing_record = {**record, "component": canonical} + if missing_record["compliance"] == "Not Compliant": + missing_record["compliance"] = "Not Compliant (Not Run)" + rows.append(missing_record) + seen.add(identity) + + registry_position = { + _compliance_identity( + registry_by_name.get(canonical, {}).get( + "requirement_key", canonical + ) + ): index + for index, canonical in enumerate(included_order) + } + rows.sort(key=lambda row: registry_position.get( + row["identity"], len(registry_position) + )) + return rows + + +def _detail_compliance_markup(rows, unit_label): + """Build the shared semantic compliance table for a detailed report.""" + body_rows = [] + for row in rows: + component = html.escape(str(row["component"]), quote=True) + requirement = html.escape(str(row["requirement"]), quote=True) + compliance = html.escape(str(row["compliance"]), quote=True) + tone = html.escape(str(row["tone"]), quote=True) + requirement_slug = html.escape( + str(row["requirement_slug"]), quote=True + ) + body_rows.append( + f'
' + f'' + '' + '' + '' + ) + return ( + '
' + '
' + '

Compliance results

' + '
SubtestDescriptionResult
B_GIC_04.1Nested GIC failureFAILED
{component}{requirement}{compliance}
' + '' + '' + '' + '' + f'' + '' + '' + f'{"".join(body_rows)}
Run-specific requirement and ' + f'compliance for each {html.escape(unit_label.lower())} in this ' + 'detailed report
{html.escape(unit_label)}RequirementCompliance
' + ) + + +def inject_detail_compliance(merged_json_path, output_dir): + """Inject run-specific requirement/compliance tables into detail pages.""" + if not merged_json_path or not os.path.isfile(merged_json_path): + return + try: + with open(merged_json_path, "r", encoding="utf-8") as json_file: + merged_data = json.load(json_file) + except (OSError, ValueError, TypeError): + return + + records = _compliance_records(merged_data) + records_by_identity = {record["identity"]: record for record in records} + targets = [] + for filename, candidates, fallback_name in DETAIL_COMPLIANCE_TARGETS: + record = next( + ( + records_by_identity.get(_compliance_identity(candidate)) + for candidate in candidates + if records_by_identity.get(_compliance_identity(candidate)) + ), + None, + ) + display_name = ( + record["component"] + if record and fallback_name in ("FWTS", "SCT") + else fallback_name + ) + row = {**record, "component": display_name} if record else { + "component": display_name, + "identity": _compliance_identity(display_name), + "requirement": "Unknown", + "requirement_slug": "unknown", + "compliance": "Unknown", + "tone": "unknown", + } + targets.append((filename, [row], "Test suite")) + + # Include applicable Not Run interfaces in the existing compliance table, + # without creating empty result sections or detailed pages. + sbmr_rows = [] + for label in ("SBMR-IB", "SBMR-OOB"): + record = records_by_identity.get(_compliance_identity(label)) + if not _merged_entries(merged_data.get(f"Suite_Name: {label}")) and not ( + record and record.get("not_run") + ): + continue + row = {**record, "component": label} if record else { + "component": label, "requirement": "Unknown", "requirement_slug": "unknown", + "compliance": "Unknown", "tone": "unknown", + } + if row.get("not_run") and row["compliance"] != "Not Run": + row["compliance"] += " (Not Run)" + sbmr_rows.append(row) + if sbmr_rows: + targets.append(("sbmr_detailed.html", sbmr_rows, "Test suite")) + + standalone_rows = _standalone_rows(merged_data, records) + if standalone_rows: + targets.append(( + "standalone_tests_detailed.html", standalone_rows, "Test case" + )) + + dynamic_os_rows = [ + {**record, "component": record["component"].replace("_", "-")} + for record in records + if record["identity"].startswith("OS") and + record["identity"] != _compliance_identity("OS_TEST") + ] + if dynamic_os_rows: + targets.append(("os_tests_detailed.html", dynamic_os_rows, "Test case")) + else: + os_record = records_by_identity.get(_compliance_identity("OS_TEST")) + os_row = {**os_record, "component": "OS-TESTS"} if os_record else { + "component": "OS-TESTS", + "identity": _compliance_identity("OS_TEST"), + "requirement": "Unknown", + "requirement_slug": "unknown", + "compliance": "Unknown", + "tone": "unknown", + } + targets.append(( + "os_tests_detailed.html", [os_row], "Test case" + )) + + existing_table = re.compile( + r"]*\bdata-acs-detail-compliance\s*=\s*" + r"([\"'])true\1[^>]*>.*?", + re.IGNORECASE | re.DOTALL, + ) + for filename, rows, unit_label in targets: + file_path = os.path.join(output_dir, filename) + if not os.path.isfile(file_path): + continue + try: + with open(file_path, "r", encoding="utf-8") as detail_file: + content = detail_file.read() + except OSError: + continue + markup = _detail_compliance_markup(rows, unit_label) + if existing_table.search(content): + updated, replacements = existing_table.subn( + markup, content, count=1 + ) + else: + updated, replacements = re.subn( + r"()", + lambda match: match.group(1) + "\n" + markup, + content, + count=1, + flags=re.IGNORECASE, + ) + if not replacements: + updated, replacements = re.subn( + r"(]*class\s*=\s*([\"'])[^\"']*" + r"\bdetailed-(?:summary|container)\b[^\"']*\2[^>]*>)", + lambda match: markup + "\n" + match.group(1), + content, + count=1, + flags=re.IGNORECASE, + ) + if replacements and updated != content: + with open(file_path, "w", encoding="utf-8") as detail_file: + detail_file.write(updated) + def adjust_bbsr_headings(content, suite_name): """Replace a generic BBSR heading with the selected suite name.""" if content: @@ -580,7 +1052,8 @@ def generate_html(system_info, acs_results_summary, bbsr_fwts_summary_path, bbsr_sct_summary_path, bbsr_tpm_summary_path, pfdi_summary_path, post_script_summary_path, standalone_summary_path, os_tests_summary_path, - output_html_path, suite_prefix="SBBR"): + output_html_path, suite_prefix="SBBR", sbmr_combined_summary_path="", + merged_json_path=""): """Generate the consolidated ACS summary from all suite summaries.""" fwts_suite_name = f"{suite_prefix}-FWTS" @@ -593,6 +1066,11 @@ def generate_html(system_info, acs_results_summary, sct_summary_content = read_html_content(sct_summary_path) sbmr_ib_summary_content = read_html_content(sbmr_ib_summary_path) sbmr_oob_summary_content = read_html_content(sbmr_oob_summary_path) + sbmr_summary_content = read_html_content(sbmr_combined_summary_path) + if sbmr_combined_summary_path and not sbmr_summary_content: + raise ValueError("The requested combined SBMR summary was not generated") + if sbmr_summary_content: + sbmr_ib_summary_content = sbmr_oob_summary_content = None scmi_summary_content = read_html_content(scmi_summary_path) bbsr_fwts_summary_content = read_html_content(bbsr_fwts_summary_path) bbsr_sct_summary_content = read_html_content(bbsr_sct_summary_path) @@ -618,6 +1096,35 @@ def generate_html(system_info, acs_results_summary, ) standalone_summary_content = adjust_bbsr_headings(standalone_summary_content, 'Standalone') + merged_data = {} + if merged_json_path and os.path.isfile(merged_json_path): + with open(merged_json_path, encoding="utf-8") as merged_file: + merged_data = json.load(merged_file) + sources = [ + ("bsa_summary", "BSA", bsa_summary_content, "bsa_detailed.html", ("BSA",)), + ("sbsa_summary", "SBSA", sbsa_summary_content, "sbsa_detailed.html", ("SBSA",)), + ("fwts_summary", fwts_suite_name, fwts_summary_content, "fwts_detailed.html", (fwts_suite_name, "FWTS")), + ("sct_summary", sct_suite_name, sct_summary_content, "sct_detailed.html", (sct_suite_name, "SCT")), + ("scmi_summary", "SCMI", scmi_summary_content, "scmi_detailed.html", ("SCMI",)), + ] + if sbmr_summary_content or not (sbmr_ib_summary_content or sbmr_oob_summary_content): + sources.append(("sbmr_summary", "SBMR", sbmr_summary_content, "sbmr_detailed.html", ("SBMR-IB", "SBMR-OOB"))) + else: + sources.extend([ + ("sbmr_ib_summary", "SBMR-IB", sbmr_ib_summary_content, "sbmr_ib_detailed.html", ("SBMR-IB",)), + ("sbmr_oob_summary", "SBMR-OOB", sbmr_oob_summary_content, "sbmr_oob_detailed.html", ("SBMR-OOB",)), + ]) + sources.extend([ + ("post_script_summary", "POST-SCRIPT", post_script_summary_content, "post_script_detailed.html", ("POST_SCRIPT",)), + ("standalone_summary", "Standalone", standalone_summary_content, "standalone_tests_detailed.html", ()), + ("bbsr_fwts_summary", "BBSR-FWTS", bbsr_fwts_summary_content, "bbsr_fwts_detailed.html", ("BBSR-FWTS",)), + ("bbsr_sct_summary", "BBSR-SCT", bbsr_sct_summary_content, "bbsr_sct_detailed.html", ("BBSR-SCT",)), + ("bbsr_tpm_summary", "BBSR-TPM", bbsr_tpm_summary_content, "bbsr_tpm_detailed.html", ("BBSR-TPM",)), + ("pfdi_summary", "PFDI", pfdi_summary_content, "pfdi_detailed.html", ("PFDI",)), + ("OS_tests_summary", "OS Tests", os_tests_summary_content, "os_tests_detailed.html", ("OS_TEST",)), + ]) + summary_cards = _summary_cards(merged_data, sources, os.path.dirname(output_html_path)) + # Jinja2 template for the final HTML page html_template = ''' @@ -943,166 +1450,23 @@ def generate_html(system_info, acs_results_summary,

Test Summaries

- {% if bsa_summary_content %} -
- {{ bsa_summary_content | safe }} - -
- {% endif %} - {% if sbsa_summary_content %} -
- {{ sbsa_summary_content | safe }} - -
- {% endif %} - {% if fwts_summary_content %} -
- {{ fwts_summary_content | safe }} - -
- {% endif %} - {% if sct_summary_content %} -
- {{ sct_summary_content | safe }} + {% for card in summary_cards %} +
+ {{ card.content | safe }} + {% if card.detail %} -
- {% endif %} - {% if scmi_summary_content %} -
- {{ scmi_summary_content | safe }} - -
- {% endif %} - {% if sbmr_ib_summary_content %} -
- {{ sbmr_ib_summary_content | safe }} - -
- {% endif %} - {% if sbmr_oob_summary_content %} -
- {{ sbmr_oob_summary_content | safe }} - -
- {% endif %} - {% if post_script_summary_content %} -
- {{ post_script_summary_content | safe }} - -
- {% endif %} - {% if standalone_summary_content %} -
- {{ standalone_summary_content | safe }} - -
- {% endif %} - {% if bbsr_fwts_summary_content %} -
- {{ bbsr_fwts_summary_content | safe }} - -
- {% endif %} - {% if bbsr_sct_summary_content %} -
- {{ bbsr_sct_summary_content | safe }} - -
- {% endif %} - {% if bbsr_tpm_summary_content %} -
- {{ bbsr_tpm_summary_content | safe }} - -
- {% endif %} - {% if pfdi_summary_content %} -
- {{ pfdi_summary_content | safe }} - -
- {% endif %} - {% if OS_tests_summary_content %} -
- {{ OS_tests_summary_content | safe }} - + {% endif %}
- {% endif %} + {% endfor %}
@@ -1113,22 +1477,7 @@ def generate_html(system_info, acs_results_summary, html_output = template.render( system_info=system_info, acs_results_summary=acs_results_summary, - bsa_summary_content=bsa_summary_content, - sbsa_summary_content=sbsa_summary_content, - fwts_summary_content=fwts_summary_content, - sct_summary_content=sct_summary_content, - scmi_summary_content=scmi_summary_content, - sbmr_ib_summary_content=sbmr_ib_summary_content, - sbmr_oob_summary_content=sbmr_oob_summary_content, - bbsr_fwts_summary_content=bbsr_fwts_summary_content, - bbsr_sct_summary_content=bbsr_sct_summary_content, - bbsr_tpm_summary_content=bbsr_tpm_summary_content, - pfdi_summary_content=pfdi_summary_content, - post_script_summary_content=post_script_summary_content, - standalone_summary_content=standalone_summary_content, - OS_tests_summary_content=os_tests_summary_content, - fwts_suite_name=fwts_suite_name, - sct_suite_name=sct_suite_name + summary_cards=summary_cards, ) html_output = enhance_html_report(html_output, page_type="acs-summary") @@ -1176,6 +1525,7 @@ def generate_html(system_info, acs_results_summary, if __name__ == "__main__": parser = argparse.ArgumentParser(description="Generate ACS Summary HTML page") parser.add_argument("--merged_json", default="", help="Path to merged_results.json if you want to pull final compliance from there") + parser.add_argument("--sbmr-combined-summary", default="", help="Combined SBMR summary retaining per-interface counts") parser.add_argument("bsa_summary_path", help="Path to the BSA summary HTML file") parser.add_argument("sbsa_summary_path", help="Path to the SBSA summary HTML file") parser.add_argument("fwts_summary_path", help="Path to the FWTS summary HTML file") @@ -1290,8 +1640,12 @@ def generate_html(system_info, acs_results_summary, args.standalone_summary_path, args.OS_tests_summary_path, args.output_html_path, - suite_prefix + suite_prefix, + args.sbmr_combined_summary, + args.merged_json, ) # Inject Test_suite_info into detailed HTMLs (no change to suite parsers) - inject_test_suite_info(args.merged_json, os.path.dirname(args.output_html_path)) + detail_output_dir = os.path.dirname(args.output_html_path) + inject_test_suite_info(args.merged_json, detail_output_dir) + inject_detail_compliance(args.merged_json, detail_output_dir) diff --git a/common/log_parser/main_log_parser.sh b/common/log_parser/main_log_parser.sh index 0c1e4a03..7728786f 100755 --- a/common/log_parser/main_log_parser.sh +++ b/common/log_parser/main_log_parser.sh @@ -471,6 +471,20 @@ if [ "$YOCTO_FLAG_PRESENT" -eq 0 ]; then "$LOGS_PATH/sbmr/sbmr_out_of_band_logs/report.html" fi + # Keep legacy per-interface files available; the ACS page uses one SBMR view. + SBMR_COMBINED_ARGS=() + if [ $SBMR_IB_PROCESSED -eq 1 ]; then + SBMR_COMBINED_ARGS+=(--ib-json "$SBMR_IB_JSON" --ib-report "$LOGS_PATH/sbmr/sbmr_in_band_logs/report.html") + fi + if [ $SBMR_OOB_PROCESSED -eq 1 ]; then + SBMR_COMBINED_ARGS+=(--oob-json "$SBMR_OOB_JSON" --oob-report "$LOGS_PATH/sbmr/sbmr_out_of_band_logs/report.html") + fi + if [ ${#SBMR_COMBINED_ARGS[@]} -gt 0 ]; then + python3 "$SCRIPTS_PATH/sbmr/json_to_html.py" --combine \ + "${SBMR_COMBINED_ARGS[@]}" \ + --detailed "$HTMLS_DIR/sbmr_detailed.html" --summary "$HTMLS_DIR/sbmr_summary.html" || exit 1 + fi + fi ################################################################################ # POST-SCRIPT LOG PARSING @@ -1012,6 +1026,9 @@ if [ -f "$MERGED_JSON" ]; then fi # Finally, call generate_acs_summary.py exactly ONCE at the end +if [ $SBMR_IB_PROCESSED -eq 1 ] || [ $SBMR_OOB_PROCESSED -eq 1 ]; then + GENERATE_ACS_SUMMARY_CMD+=" --sbmr-combined-summary \"$HTMLS_DIR/sbmr_summary.html\"" +fi eval "$GENERATE_ACS_SUMMARY_CMD" print_path=0 # For debug only diff --git a/common/log_parser/merge_jsons.py b/common/log_parser/merge_jsons.py index 9bdff197..6dd95fd8 100755 --- a/common/log_parser/merge_jsons.py +++ b/common/log_parser/merge_jsons.py @@ -146,10 +146,11 @@ def is_recommended_test_case(suite_entry): srs_scope = str(suite_entry.get("SRS scope", "")).strip().lower() return srs_scope == "recommended" -def count_fails_in_json(data): +def count_fails_in_json(data, skip_recommended=False): """ Inspect JSON data and count how many tests are 'FAILED' vs 'FAILED_WITH_WAIVER'. Returns (failed, failed_with_waiver). + Recommended entries are excluded only when requested for SR OS compliance. Expects a structure with top-level 'test_results' => [ {subtests: [...]} ] or a top-level list for subtests. If not recognized, returns (0,0). @@ -169,7 +170,7 @@ def count_fails_in_json(data): return (0, 0) for suite_entry in test_results: - if is_recommended_test_case(suite_entry): + if skip_recommended and is_recommended_test_case(suite_entry): continue # If testcases exist, count only testcase-level results to avoid double counting. testcases = suite_entry.get("testcases", []) @@ -536,7 +537,9 @@ def merge_json_files(json_files, output_file): ts_dict.update(temp) merged_results[section_name] = data - f, fw = count_fails_in_json(data) + f, fw = count_fails_in_json( + data, skip_recommended=(DT_OR_SR_MODE == "SR" and suite_key == "OS_TEST") + ) if suite_key in suite_fail_data: suite_fail_data[suite_key]["Failed"] += f suite_fail_data[suite_key]["Failed_with_Waiver"] += fw diff --git a/common/log_parser/report_ui.py b/common/log_parser/report_ui.py index a3adb319..214b25f7 100644 --- a/common/log_parser/report_ui.py +++ b/common/log_parser/report_ui.py @@ -662,6 +662,155 @@ def center_matplotlib_plot(axis): } body.acs-report-ui tr:last-child > td { border-bottom: 0 !important; } body.acs-report-ui tbody tr:hover > td { background-color: #f8fbff; } +.acs-visually-hidden { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} +.acs-detail-compliance { + min-width: 0; + margin: 0 0 14px; + overflow: hidden; + background: var(--acs-panel); + border: 1px solid var(--acs-line); + border-radius: 14px; + box-shadow: var(--acs-shadow); +} +.acs-compliance-tab { + display: flex; + align-items: center; + min-height: 34px; + padding: 7px 13px; + background: var(--acs-panel-muted); + border-bottom: 1px solid var(--acs-line); +} +body.acs-report-ui .acs-compliance-tab h2 { + margin: 0 !important; + padding: 0 !important; + color: var(--acs-heading) !important; + font-size: 13px !important; + font-weight: 850; + line-height: 1.25; +} +body.acs-report-ui table.acs-compliance-table { + display: table !important; + width: 100% !important; + margin: 0 !important; + overflow: visible; + white-space: normal; + background: var(--acs-panel); + border: 0 !important; + border-radius: 0; + border-collapse: collapse !important; + table-layout: fixed; +} +.acs-compliance-component-column { width: 40%; } +.acs-compliance-requirement-column { width: 20%; } +.acs-compliance-status-column { width: 40%; } +body.acs-report-ui .acs-compliance-table th, +body.acs-report-ui .acs-compliance-table td { + padding: 8px 14px !important; + white-space: normal !important; + vertical-align: middle; + overflow-wrap: anywhere; +} +body.acs-report-ui .acs-compliance-table thead th { + color: var(--acs-muted) !important; + background: #f1f6fc !important; + border-bottom: 1px solid var(--acs-line) !important; + font-size: 10px !important; + font-weight: 850 !important; + letter-spacing: .07em; + text-transform: uppercase; +} +body.acs-report-ui .acs-compliance-table thead th:nth-child(2), +body.acs-report-ui .acs-compliance-table thead th:nth-child(3), +body.acs-report-ui .acs-compliance-table tbody td { + text-align: center !important; +} +body.acs-report-ui .acs-compliance-table tbody th, +body.acs-report-ui .acs-compliance-table tbody td { + color: var(--acs-text) !important; + background: #fff !important; + border-bottom: 1px solid var(--acs-line) !important; +} +body.acs-report-ui .acs-compliance-table tbody tr:nth-child(even) th, +body.acs-report-ui .acs-compliance-table tbody tr:nth-child(even) td { + background: #fbfcfe !important; +} +body.acs-report-ui .acs-compliance-table tbody tr:last-child > th, +body.acs-report-ui .acs-compliance-table tbody tr:last-child > td { + border-bottom: 0 !important; +} +body.acs-report-ui .acs-compliance-table tbody th { + font-size: 11.5px !important; + font-weight: 850 !important; + text-transform: uppercase; +} +.acs-compliance-table tbody tr[data-acs-compliance-tone="pass"] > th { + box-shadow: inset 3px 0 #16a34a; +} +.acs-compliance-table tbody tr[data-acs-compliance-tone="fail"] > th { + box-shadow: inset 3px 0 #dc2626; +} +.acs-compliance-table tbody tr[data-acs-compliance-tone="not-run"] > th, +.acs-compliance-table tbody tr[data-acs-compliance-tone="unknown"] > th { + box-shadow: inset 3px 0 #64748b; +} +.acs-requirement-badge, +.acs-compliance-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 23px; + max-width: 100%; + padding: 3px 8px; + border-radius: 999px; + font-size: 9.5px; + font-weight: 850; + line-height: 1.25; + text-align: center; + overflow-wrap: anywhere; +} +.acs-requirement-mandatory { + color: #174f91; + background: #e7f0ff; +} +.acs-requirement-recommended { + color: #6840bd; + background: #f2ecff; +} +.acs-requirement-conditional-mandatory { + color: #92400e; + background: #ffedd5; +} +.acs-requirement-extension { + color: #0f766e; + background: #ccfbf1; +} +.acs-requirement-unknown { + color: var(--acs-neutral-text); + background: var(--acs-neutral-bg); +} +.acs-compliance-pass { + color: var(--acs-pass-text); + background: var(--acs-pass-bg); +} +.acs-compliance-fail { + color: var(--acs-fail-text); + background: var(--acs-fail-bg); +} +.acs-compliance-not-run, +.acs-compliance-unknown { + color: var(--acs-neutral-text); + background: var(--acs-neutral-bg); +} body.acs-report-ui .subtest-table { width: calc(100% - 20px) !important; margin: 8px 10px 12px !important; @@ -853,6 +1002,8 @@ def center_matplotlib_plot(axis): box-shadow: var(--acs-shadow) !important; } body[data-acs-suite="sbmr"] .case-header { padding-top: 5px; border-top: 1px solid var(--acs-line); } +.acs-sbmr-sources { margin: 8px 0; } +.acs-sbmr-source { display: inline-block; font-size: 13px; margin: 0 18px 8px 0; } .acs-toolbar { position: sticky; @@ -1150,6 +1301,43 @@ def center_matplotlib_plot(axis): .acs-report-ui[data-acs-report-kind="acs-summary"] .summary > .details-link { margin-top: auto; } +.acs-suite-heading { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px 12px; + margin-bottom: 4px; + break-inside: avoid; + break-after: avoid; +} +body.acs-report-ui .acs-suite-heading > :is(h1, h2, h3) { + margin: 0 !important; + min-width: 0; +} +.acs-suite-compliance { + display: inline-block; + margin-left: auto; + max-width: 100%; + padding: 6px 10px; + border: 1px solid currentColor; + border-radius: 8px; + font-size: 12px; + font-weight: 750; + line-height: 1.4; + text-align: right; + overflow-wrap: anywhere; +} +.acs-suite-empty { + flex: 1; + margin-top: 14px; + padding: 24px 16px; + border: 1px dashed var(--acs-line); + border-radius: 12px; + color: var(--acs-muted); + text-align: center; +} +.acs-suite-empty p { margin: 6px 0 0; font-size: 13px; } .acs-report-ui[data-acs-report-kind="acs-summary"] .summary h1 { font-size: 20px !important; } .acs-report-ui[data-acs-report-kind="acs-summary"] .summary .acs-summary-card { min-height: 62px; padding: 9px; } .acs-report-ui[data-acs-report-kind="acs-summary"] .summary .acs-summary-value { font-size: 20px; } @@ -1270,6 +1458,19 @@ def center_matplotlib_plot(axis): } @media (max-width: 560px) { + .acs-compliance-component-column { width: 45%; } + .acs-compliance-requirement-column { width: 26%; } + .acs-compliance-status-column { width: 29%; } + body.acs-report-ui .acs-compliance-table th, + body.acs-report-ui .acs-compliance-table td { padding: 7px 8px !important; } + body.acs-report-ui .acs-compliance-table thead th { font-size: 8.5px !important; } + body.acs-report-ui .acs-compliance-table tbody th { font-size: 10px !important; } + .acs-requirement-badge, + .acs-compliance-badge { + padding: 3px 6px; + font-size: 8.5px; + white-space: normal; + } .result-summary.acs-compact-summary, .summary-container.acs-compact-summary, .card.acs-compact-summary { padding: 15px !important; } @@ -1322,6 +1523,19 @@ def center_matplotlib_plot(axis): .system-info, .acs-results-summary, .summary { box-shadow: none !important; break-inside: avoid; } + .acs-detail-compliance { + box-shadow: none !important; + break-inside: auto; + } + .acs-detail-compliance[data-acs-compliance-row-count="1"] { + break-inside: avoid; + page-break-inside: avoid; + } + .acs-compliance-table thead { display: table-header-group; } + .acs-compliance-table tr { + break-inside: avoid; + page-break-inside: avoid; + } .acs-summary-overview > .acs-information-overview, .acs-overview-results > .acs-overview-result-card { padding: 12px !important; @@ -1983,6 +2197,9 @@ def center_matplotlib_plot(axis): /^(?:Suite )?Description:\s*/i ); var overview = caseOverview(suiteName, description, "Test suite", ""); + if (suiteHeader.hasAttribute("data-acs-sbmr-channel")) { + group.setAttribute("data-acs-sbmr-channel", suiteHeader.getAttribute("data-acs-sbmr-channel")); + } overview.setAttribute("data-acs-test-suite", suiteName); [ "data-acs-summary-outcomes", @@ -2130,7 +2347,9 @@ def center_matplotlib_plot(axis): function upgradeTableColumns() { var suiteType = document.body.getAttribute("data-acs-suite"); var collapseReasons = suiteType === "sct" || suiteType === "fwts"; - Array.prototype.forEach.call(document.querySelectorAll("table:not(.summary-table)"), function (table) { + Array.prototype.forEach.call(document.querySelectorAll( + "table:not(.summary-table):not(.acs-compliance-table)" + ), function (table) { if (!table.tHead || !table.tHead.rows.length) { return; } @@ -2239,7 +2458,9 @@ def center_matplotlib_plot(axis): } function upgradeStatusCells() { - Array.prototype.forEach.call(document.querySelectorAll("table:not(.summary-table) tbody > tr"), function (row) { + Array.prototype.forEach.call(document.querySelectorAll( + "table:not(.summary-table):not(.acs-compliance-table) tbody > tr" + ), function (row) { if (row.querySelector("table") || row.closest(".system-info, .acs-results-summary")) { return; } @@ -2383,7 +2604,9 @@ def center_matplotlib_plot(axis): function statusRows() { var records = []; - Array.prototype.forEach.call(document.querySelectorAll("table:not(.summary-table) tbody > tr"), function (row) { + Array.prototype.forEach.call(document.querySelectorAll( + "table:not(.summary-table):not(.acs-compliance-table) tbody > tr" + ), function (row) { var table = row.closest("table"); if (!table || table.closest(".system-info") || table.closest(".acs-results-summary") || row.querySelector("table")) { @@ -2571,7 +2794,13 @@ def center_matplotlib_plot(axis): Array.prototype.forEach.call( group.querySelectorAll("table tbody > tr"), function (row) { - if (row.closest(".acs-result-group") !== group || row.querySelector("table")) { + // BSA/SBSA suite summaries count testcase rules. Keep their + // nested subtests available to search and filters, but do + // not count those rows again in the per-suite breakdown. + if (row.closest(".acs-result-group") !== group || + row.querySelector("table") || + (suiteType === "bsa" && + row.closest("table.subtest-table"))) { return; } leafRowCount += 1; @@ -2747,6 +2976,32 @@ def center_matplotlib_plot(axis): searchControl.appendChild(search); toolbar.appendChild(searchControl); + var channelSelect = null; + if (document.body.hasAttribute("data-acs-sbmr-combined") && + ["SBMR-IB", "SBMR-OOB"].every(function (channel) { + return resultGroups.some(function (group) { + return group.getAttribute("data-acs-sbmr-channel") === channel; + }); + })) { + var channelControl = document.createElement("label"); + channelControl.className = "acs-control"; + var channelLabel = document.createElement("span"); + channelLabel.className = "acs-control-label"; + channelLabel.textContent = "Interface"; + channelSelect = document.createElement("select"); + channelSelect.setAttribute("aria-label", "Filter SBMR interface"); + [["", "All interfaces"], ["SBMR-IB", "SBMR-IB · In-band"], ["SBMR-OOB", "SBMR-OOB · Out-of-band"]].forEach(function (item) { + var option = document.createElement("option"); + option.value = item[0]; + option.textContent = item[1]; + channelSelect.appendChild(option); + }); + channelSelect.addEventListener("change", applyFilters); + channelControl.appendChild(channelLabel); + channelControl.appendChild(channelSelect); + toolbar.appendChild(channelControl); + } + var navigation = navigationTargets(); if (navigation.targets.length > 1) { var suiteControl = document.createElement("label"); @@ -2900,6 +3155,7 @@ def center_matplotlib_plot(axis): var visible = 0; records.forEach(function (record) { record.match = (!activeStatus || record.status === activeStatus) && + (!channelSelect || !channelSelect.value || (record.group && record.group.getAttribute("data-acs-sbmr-channel") === channelSelect.value)) && (!query || record.text.indexOf(query) >= 0); if (record.row) { record.row.classList.toggle("acs-filter-hidden", !record.match); @@ -2979,6 +3235,7 @@ def center_matplotlib_plot(axis): reset.addEventListener("click", function () { search.value = ""; activeStatus = ""; + if (channelSelect) { channelSelect.value = ""; } applyFilters(); search.focus(); }); @@ -3428,6 +3685,7 @@ def center_matplotlib_plot(axis): "scmi_summary": "SCMI", "sbmr_ib_summary": "SBMR-IB", "sbmr_oob_summary": "SBMR-OOB", + "sbmr_summary": "SBMR", "post_script_summary": "POST-SCRIPT", "standalone_summary": "Standalone", "bbsr_fwts_summary": "BBSR-FWTS", diff --git a/common/log_parser/sbmr/json_to_html.py b/common/log_parser/sbmr/json_to_html.py index f193bab3..97392458 100644 --- a/common/log_parser/sbmr/json_to_html.py +++ b/common/log_parser/sbmr/json_to_html.py @@ -17,6 +17,7 @@ """Render SBMR JSON results as detailed and summary HTML reports.""" import base64 +import argparse import importlib import json import os @@ -162,7 +163,7 @@ def generate_bar_chart(suite_summary): - {{ page_title }} Test Details + {{ page_title }} {% if ds.bands %}Detailed Results{% else %}Test Details{% endif %} - -

{{ page_title }} Test Details

+ +

{{ page_title }} {% if ds.bands %}Detailed Results{% else %}Test Details{% endif %}

Test Results Distribution @@ -264,13 +265,23 @@ def generate_bar_chart(suite_summary):
Aborted{{ ds.summary.total_aborted }}
Skipped{{ ds.summary.total_skipped }}
Warnings{{ ds.summary.total_warnings }}
Ignored{{ ds.summary.total_ignored }}
+ {% if ds.bands %}
+ {% for band in ds.bands %}{% if band.report_link %} + {{ band.label }} original Robot report + {% endif %}{% endfor %}
{% endif %}
{% for suite in ds.suites %} -
Test Suite: {{ suite.Test_suite }}
+
Test Suite: {{ suite.report_label | default(suite.Test_suite) }}
+ {% if suite.report_channel and suite.Test_suite_info %} +
Test suite info: + {% if suite.Test_suite_info is string %}{{ suite.Test_suite_info }}{% else %} + {% endif %}
+ {% endif %} {% if suite.Test_cases is defined and suite.Test_cases %} {% for case in suite.Test_cases %} @@ -339,7 +350,7 @@ def generate_bar_chart(suite_summary):
-""") +""", autoescape=True) SUMMARY_TEMPLATE = Template(""" @@ -378,12 +389,14 @@ def generate_bar_chart(suite_summary): Aborted{{ total_aborted }} Skipped{{ total_skipped }} Warnings{{ total_warnings }} + {% if total_ignored %}Ignored{{ total_ignored }}{% endif %} -""") +""", autoescape=True) + # ---------------------------- # Rendering @@ -409,6 +422,7 @@ def render_summary_html(combined_summary, output_html_path, page_title): + combined_summary.get("total_skipped", 0) + combined_summary.get("total_warnings", 0) + combined_summary.get("total_failed_with_waiver", 0) + + combined_summary.get("total_ignored", 0) ) html = SUMMARY_TEMPLATE.render( page_title=page_title.upper(), @@ -419,6 +433,7 @@ def render_summary_html(combined_summary, output_html_path, page_title): total_aborted=combined_summary.get("total_aborted", 0), total_skipped=combined_summary.get("total_skipped", 0), total_warnings=combined_summary.get("total_warnings", 0), + total_ignored=combined_summary.get("total_ignored", 0), ) with open(output_html_path, "w", encoding="utf-8") as file_handle: file_handle.write(enhance_html_report(html, suite_type="sbmr")) @@ -440,8 +455,68 @@ def uid_from_label(label): # Safe id for HTML element ids return "".join(ch for ch in label if ch.isalnum()).lower() + +def render_combined_reports(inputs, detailed_html_file, summary_html_file): + """Combine explicitly selected IB/OOB inputs for display, never for JSON.""" + bands = [] + suites = [] + aggregate = compute_suite_summary([]) + seen = set() + for label, input_path, report_path in inputs: + if label not in {"SBMR-IB", "SBMR-OOB"} or label in seen: + raise ValueError(f"Invalid or duplicate SBMR interface: {label}") + seen.add(label) + with open(input_path, encoding="utf-8") as handle: + data = json.load(handle) + entries = data.get("test_results", []) + if not entries: + continue + summary = data.get("suite_summary") or compute_suite_summary(entries) + summary = {key: summary.get(key, 0) for key in aggregate} + for key, value in summary.items(): + aggregate[key] += value + report_link = None + if report_path and os.path.isfile(report_path): + report_link = os.path.relpath( + os.path.abspath(report_path), os.path.dirname(os.path.abspath(detailed_html_file)) + ).replace(os.sep, "/") + bands.append({"label": label, "report_link": report_link}) + # Display-only metadata disambiguates identical suite/case names without + # modifying IDs, source names, statuses, reasons, waiver data or JSON. + suites.extend({**entry, "report_channel": label, + "report_label": f"{label} · {entry.get('Test_suite', '')}"} for entry in entries) + if not bands: + return False + dataset = {"label": "SBMR", "bands": bands, "suites": suites, "summary": aggregate, + "total_tests": sum(aggregate.values()), "chart_data": generate_bar_chart(aggregate)} + render_detail_html(dataset, detailed_html_file, "SBMR") + render_summary_html(aggregate, summary_html_file, "SBMR") + return True + + +def combined_main(argv): + """Keep the legacy one-interface CLI while adding an explicit combined mode.""" + parser = argparse.ArgumentParser(description="Render one SBMR view with distinct IB/OOB results") + parser.add_argument("--ib-json") + parser.add_argument("--oob-json") + parser.add_argument("--ib-report") + parser.add_argument("--oob-report") + parser.add_argument("--detailed", required=True) + parser.add_argument("--summary", required=True) + args = parser.parse_args(argv) + inputs = [(label, path, report) for label, path, report in ( + ("SBMR-IB", args.ib_json, args.ib_report), ("SBMR-OOB", args.oob_json, args.oob_report) + ) if path] + if not inputs: + parser.error("at least one of --ib-json or --oob-json is required") + if not render_combined_reports(inputs, args.detailed, args.summary): + parser.error("no collected SBMR results to render") + def main(): """Load SBMR JSON and write detailed and summary HTML reports.""" + if sys.argv[1:2] == ["--combine"]: + combined_main(sys.argv[2:]) + return if len(sys.argv) < 4: print( "Usage: python json_to_html.py " @@ -465,6 +540,7 @@ def main(): + suite_summary.get("total_skipped", 0) + suite_summary.get("total_warnings", 0) + suite_summary.get("total_failed_with_waiver", 0) + + suite_summary.get("total_ignored", 0) ) chart_data = generate_bar_chart(suite_summary) diff --git a/common/log_parser/standalone_runner.py b/common/log_parser/standalone_runner.py index a26bbbb9..c7a3eea6 100755 --- a/common/log_parser/standalone_runner.py +++ b/common/log_parser/standalone_runner.py @@ -730,6 +730,19 @@ def render_reports(results, html_dir): result.detailed_html = detailed result.summary_html = summary + sbmr_results = [result for result in regular_results if result.execution.get("handler") == "sbmr"] + if sbmr_results: + command = [sys.executable, BASE_DIR / "sbmr" / "json_to_html.py", "--combine", + "--detailed", html_dir / "sbmr_detailed.html", + "--summary", html_dir / "sbmr_summary.html"] + for result in sbmr_results: + channel = "ib" if result.canonical == "SBMR-IB" else "oob" + command.extend([f"--{channel}-json", result.json_files[0]]) + report = result.inputs.get("report") + if report and report.exists: + command.extend([f"--{channel}-report", report.path]) + run_command("[SBMR] Generating combined IB/OOB HTML", command, EXIT_REPORT) + if standalone_results: registry = load_registry() group = get_suite("STANDALONE", registry) @@ -849,6 +862,8 @@ def generate_combined_summary(results, roots, args, html_dir, merged_json, acs_i "--acs_info_json", acs_info, "--use-acs-info-system-info", ] + if any(result.canonical in {"SBMR-IB", "SBMR-OOB"} for result in results): + command.extend(["--sbmr-combined-summary", html_dir / "sbmr_summary.html"]) if args.acs_config: command.extend(["--acs_config_path", args.acs_config]) if args.system_config: diff --git a/docs/log_parser_guide.md b/docs/log_parser_guide.md index ee14a0db..b9f667f3 100644 --- a/docs/log_parser_guide.md +++ b/docs/log_parser_guide.md @@ -932,6 +932,54 @@ merged. ## Output Files +### Combined SBMR HTML view + +The consolidated summary displays each suite's run-specific requirement and +compliance in the top-right of its card. These labels use the existing merged +compliance records, not raw failure counts. A missing applicable suite has a +`Mandatory (Not Run)` or `Recommended (Not Run)` placeholder, without a chart, +invented results, empty detailed page, or Details link. Only suites represented +in the selected run's compliance records are added as missing cards. + +For grouped cards (SBMR or Standalone), applicable Mandatory decisions control +the badge when present; Recommended results do not override them. For example, +compliant Mandatory SBMR-IB plus missing Mandatory SBMR-OOB displays +`Mandatory (Not Compliant)`. Hovering over the badge shows the individual +decisions. The raw and merged JSON and SRS decisions are unchanged. Without +merged compliance context, the badge displays Unknown. + +SR reports present collected SBMR In-band and Out-of-band results in one +`SBMR` summary card linking to `sbmr_detailed.html`. Each detailed group is labelled +`SBMR-IB` or `SBMR-OOB`, including when both interfaces use identical suite +or testcase names. When both interfaces have results, an interface selector +works together with search and status filters. Print/PDF includes both +interfaces even when one is filtered or collapsed on screen. + +The detailed compliance table includes collected interfaces and applicable +interfaces recorded as Not Run in the merged results. A missing mandatory +interface is shown as `Mandatory` / `Not Compliant (Not Run)`. No combined +compliance record is added to JSON: existing merged decisions remain +authoritative. Missing interfaces add no test-result rows or counts and do not +create empty detailed pages. A run selecting only one interface does not add +the unselected interface to the compliance table. + +`sbmr_ib.json`, `sbmr_oob.json`, their merged JSON keys, original suite/case +identifiers, counts, reasons, and waiver handling remain separate and +unchanged. Legacy per-interface HTML files remain available for existing +links. `--suite SBMR`, `--suite SBMR-IB`, and `--suite SBMR-OOB` retain their +existing selection meanings. The original single-JSON renderer CLI is also +unchanged; explicit multi-interface rendering is available with: + +```bash +python3 common/log_parser/sbmr/json_to_html.py --combine \ + --ib-json /path/to/sbmr_ib.json --oob-json /path/to/sbmr_oob.json \ + --detailed /path/to/sbmr_detailed.html --summary /path/to/sbmr_summary.html +``` + +Either JSON option may be omitted. Compliance context is added by the full +summary-producing flow; raw JSON-to-HTML rendering alone does not infer a +run's requirement classification. + ### Normal Output ```text