← Files ClaraARCHIVED FILE
modules/attribute-reporting/scripts/browser_qa.py
14.4 KB · Oct 3, 2026 · 06:30 UTC
#!/usr/bin/env python3
"""Run deterministic desktop/mobile browser QA for an Attribute Report draft."""
from __future__ import annotations
# Direct CLI calls must select dependencies before importing workflow modules.
if __name__ == "__main__":
import runpy as _runpy
from pathlib import Path as _Path
for _parent in _Path(__file__).resolve().parents:
_launcher = _parent / "scripts" / "self_relaunch.py"
if not _launcher.is_file():
_launcher = _parent / "clara" / "scripts" / "self_relaunch.py"
if _launcher.is_file():
_runpy.run_path(str(_launcher))["ensure_running_in_managed_venv"](__file__)
break
else:
# Standalone components retain their host's dependency setup.
if any(
(_p / "components.json").is_file()
for _p in _Path(__file__).resolve().parents
):
raise SystemExit(
"Managed Python launcher is missing; rebuild the plugin package."
)
import argparse
import hashlib
import json
import logging
import os
import uuid
from collections.abc import Mapping, Sequence
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
__all__ = [
"assess_browser_metrics",
"run_browser_qa",
"main",
]
LOGGER = logging.getLogger(__name__)
SCHEMA_VERSION = "attribute_reporting.browser_qa.v1"
VIEWPORTS = (
{"name": "desktop", "width": 1440, "height": 1000},
{"name": "mobile", "width": 390, "height": 844},
)
def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _load_json(path: Path) -> dict[str, Any]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ValueError(f"Unable to read {path}: {exc}") from exc
if not isinstance(payload, dict):
raise ValueError(f"Expected a JSON object in {path}")
return payload
def _write_json(path: Path, payload: Mapping[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
try:
temporary.write_text(
json.dumps(dict(payload), ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
os.chmod(temporary, 0o600)
temporary.replace(path)
finally:
temporary.unlink(missing_ok=True)
def _finding(
code: str,
status: str,
message: str,
*,
details: Any = None,
) -> dict[str, Any]:
return {
"code": code,
"status": status,
"message": message,
"details": details,
}
def assess_browser_metrics(
metrics: Mapping[str, Any],
*,
viewport_name: str,
console_errors: Sequence[str] = (),
page_errors: Sequence[str] = (),
) -> list[dict[str, Any]]:
"""Convert mechanically measurable browser state into QA findings."""
findings: list[dict[str, Any]] = []
prefix = f"browser.{viewport_name}"
horizontal_overflow = bool(metrics.get("horizontalOverflow"))
findings.append(
_finding(
f"{prefix}.horizontal_overflow",
"fail" if horizontal_overflow else "pass",
(
"The report document overflows horizontally."
if horizontal_overflow
else "The report document fits the viewport horizontally."
),
details=metrics.get("document"),
)
)
broken_images = list(metrics.get("brokenImages") or [])
findings.append(
_finding(
f"{prefix}.local_images",
"fail" if broken_images else "pass",
(
f"Found {len(broken_images)} broken local image(s)."
if broken_images
else "Every rendered product image loaded successfully."
),
details=broken_images,
)
)
unsafe_assets = list(metrics.get("unsafeAssets") or [])
findings.append(
_finding(
f"{prefix}.asset_locality",
"fail" if unsafe_assets else "pass",
(
"The report references external or unsafe assets."
if unsafe_assets
else "Report scripts and images are local or embedded."
),
details=unsafe_assets,
)
)
table_failures = list(metrics.get("uncontainedWideTables") or [])
findings.append(
_finding(
f"{prefix}.table_scrolling",
"fail" if table_failures else "pass",
(
"One or more wide tables lack a horizontal scroll container."
if table_failures
else "Wide tables remain inside scrollable containers."
),
details=table_failures,
)
)
missing_elements = list(metrics.get("missingRequiredElements") or [])
findings.append(
_finding(
f"{prefix}.required_elements",
"fail" if missing_elements else "pass",
(
"Required report elements are missing."
if missing_elements
else "Required report structure is present."
),
details=missing_elements,
)
)
unsafe_links = list(metrics.get("unsafeProductLinks") or [])
findings.append(
_finding(
f"{prefix}.product_links",
"fail" if unsafe_links else "pass",
(
"Product links are malformed or missing safe new-tab attributes."
if unsafe_links
else "Product links use safe HTTP(S) new-tab behavior."
),
details=unsafe_links,
)
)
runtime_errors = [*console_errors, *page_errors]
findings.append(
_finding(
f"{prefix}.runtime",
"fail" if runtime_errors else "pass",
(
f"Found {len(runtime_errors)} browser runtime error(s)."
if runtime_errors
else "No browser console or page errors were observed."
),
details=runtime_errors,
)
)
return findings
_METRICS_SCRIPT = r"""
() => {
const absolute = (value) => {
try { return new URL(value, document.baseURI); } catch (_error) { return null; }
};
const brokenImages = Array.from(document.images)
.filter((image) => !image.complete || image.naturalWidth <= 0 || image.naturalHeight <= 0)
.map((image) => image.getAttribute('src') || '');
const unsafeAssets = [
...Array.from(document.images).map((node) => ({kind: 'image', value: node.getAttribute('src') || ''})),
...Array.from(document.scripts).map((node) => ({kind: 'script', value: node.getAttribute('src') || ''})),
].filter((item) => {
if (!item.value) return false;
const parsed = absolute(item.value);
return !parsed || !['file:', 'data:'].includes(parsed.protocol);
});
const uncontainedWideTables = Array.from(document.querySelectorAll('table')).flatMap((table, index) => {
if (table.scrollWidth <= table.clientWidth + 1) return [];
const container = table.closest('.table-scroll');
if (!container) return [{index, reason: 'missing .table-scroll'}];
const style = getComputedStyle(container);
const permitsScroll = ['auto', 'scroll'].includes(style.overflowX) || ['auto', 'scroll'].includes(style.overflow);
return permitsScroll ? [] : [{index, reason: `overflow-x=${style.overflowX}`}];
});
const requiredSelectors = [
'main.report-shell',
'header.hero',
'.verdict-slot',
'.verdict[data-correctness-verdict="pending"]',
'nav.report-nav',
'section[data-section-id="method_and_caveats"]',
];
const missingRequiredElements = requiredSelectors.filter((selector) => !document.querySelector(selector));
const unsafeProductLinks = Array.from(document.querySelectorAll('.product-card h3 a')).flatMap((link) => {
const parsed = absolute(link.getAttribute('href') || '');
const rel = new Set((link.getAttribute('rel') || '').split(/\s+/).filter(Boolean));
if (!parsed || !['http:', 'https:'].includes(parsed.protocol)) return [{href: link.getAttribute('href') || '', reason: 'invalid protocol'}];
if (link.getAttribute('target') !== '_blank' || !rel.has('noreferrer')) return [{href: parsed.href, reason: 'unsafe new-tab attributes'}];
return [];
});
const root = document.documentElement;
return {
document: {clientWidth: root.clientWidth, scrollWidth: root.scrollWidth, clientHeight: root.clientHeight, scrollHeight: root.scrollHeight},
horizontalOverflow: root.scrollWidth > root.clientWidth + 1,
brokenImages,
unsafeAssets,
uncontainedWideTables,
missingRequiredElements,
unsafeProductLinks,
warningCount: document.querySelectorAll('[data-warning-code]').length,
productCardCount: document.querySelectorAll('.product-card').length,
};
}
"""
def run_browser_qa(
output_dir: Path,
*,
viewports: Sequence[Mapping[str, Any]] = VIEWPORTS,
output_path: Path | None = None,
screenshot_dir: Path | None = None,
) -> dict[str, Any]:
"""Open the rendered draft at two viewports and persist QA evidence."""
output = output_dir.expanduser().resolve()
render_manifest = _load_json(output / "render_manifest.json")
catalog = _load_json(output / "evidence_catalog.json")
draft = output / str(render_manifest.get("draft_html") or "report_draft.html")
if not draft.is_file():
raise ValueError(f"Rendered report draft is missing: {draft}")
if _sha256_file(draft) != str(render_manifest.get("draft_html_sha256") or ""):
raise ValueError("Rendered report draft hash differs from render_manifest.json")
target = output_path or output / "browser_qa.json"
screenshots = screenshot_dir or output / "qa" / "screenshots"
report: dict[str, Any] = {
"schema_version": SCHEMA_VERSION,
"checked_at": _utc_now(),
"report_id": catalog.get("report_id"),
"targets": {
"draft_html": str(draft),
"draft_html_sha256": _sha256_file(draft),
"render_manifest_sha256": _sha256_file(output / "render_manifest.json"),
},
"status": "blocked",
"viewports": [],
"findings": [],
"browser_error": "",
}
try:
from playwright.sync_api import Error as PlaywrightError # noqa: PLC0415
from playwright.sync_api import sync_playwright # noqa: PLC0415
except ImportError as exc:
report["browser_error"] = f"Playwright is unavailable: {exc}"
_write_json(target, report)
return report
try:
screenshots.mkdir(parents=True, exist_ok=True)
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
try:
for raw_viewport in viewports:
name = str(raw_viewport["name"])
width = int(raw_viewport["width"])
height = int(raw_viewport["height"])
context = browser.new_context(
viewport={"width": width, "height": height}
)
page = context.new_page()
console_errors: list[str] = []
page_errors: list[str] = []
page.on(
"console",
lambda message, errors=console_errors: (
errors.append(message.text)
if message.type == "error"
else None
),
)
page.on(
"pageerror",
lambda error, errors=page_errors: errors.append(str(error)),
)
page.goto(draft.as_uri(), wait_until="load")
page.wait_for_timeout(100)
metrics = page.evaluate(_METRICS_SCRIPT)
screenshot_path = screenshots / f"report-{name}.png"
page.screenshot(path=str(screenshot_path), full_page=True)
findings = assess_browser_metrics(
metrics,
viewport_name=name,
console_errors=console_errors,
page_errors=page_errors,
)
report["viewports"].append(
{
"name": name,
"width": width,
"height": height,
"screenshot": screenshot_path.relative_to(
output
).as_posix(),
"screenshot_sha256": _sha256_file(screenshot_path),
"metrics": metrics,
"findings": findings,
}
)
report["findings"].extend(findings)
context.close()
finally:
browser.close()
except (OSError, PlaywrightError) as exc:
report["browser_error"] = str(exc)
_write_json(target, report)
return report
report["status"] = (
"fail"
if any(item.get("status") == "fail" for item in report["findings"])
else "pass"
)
_write_json(target, report)
return report
def main() -> int:
"""Run report browser QA from the command line."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("output_dir", type=Path)
parser.add_argument("--output", type=Path)
parser.add_argument("--screenshot-dir", type=Path)
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
try:
report = run_browser_qa(
args.output_dir,
output_path=args.output,
screenshot_dir=args.screenshot_dir,
)
except ValueError as exc:
LOGGER.error("Browser QA failed to start: %s", exc)
return 1
if report["status"] == "blocked":
LOGGER.error("Browser QA is blocked: %s", report["browser_error"])
return 2
LOGGER.info("Browser QA status: %s", report["status"])
return 0 if report["status"] == "pass" else 1
if __name__ == "__main__":
raise SystemExit(main())
SHA-256: 4d57596687f210359858e3a48c5205df63512f18de7a09c60400593d194a9a3b