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(
+
| Subtest | Description | Result |
| B_GIC_04.1 | Nested GIC failure | FAILED |
@@ -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'| {component} | '
+ '{requirement} | '
+ '{compliance} | '
+ ' '
+ )
+ return (
+ ''
+ ''
+ ' Compliance results'
+ ''
+ 'Run-specific requirement and '
+ f'compliance for each {html.escape(unit_label.lower())} in this '
+ 'detailed report'
+ ''
+ ''
+ ''
+ f'| {html.escape(unit_label)} | '
+ 'Requirement | '
+ 'Compliance | '
+ f'{"".join(body_rows)} '
+ )
+
+
+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 %}

@@ -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 }} |
+ {% if ds.summary.total_ignored %} | Ignored | {{ ds.summary.total_ignored }} | {% endif %}
+ {% if ds.bands %}{% endif %}
{% for suite in ds.suites %}
-
+
+ {% if suite.report_channel and suite.Test_suite_info %}
+ Test suite info:
+ {% if suite.Test_suite_info is string %}{{ suite.Test_suite_info }}{% else %}
+ {% for info in suite.Test_suite_info %}- {{ info }}
{% endfor %} {% 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
|