-
Notifications
You must be signed in to change notification settings - Fork 0
Add browser smoke harness #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,187 @@ | ||||||||||||||||
| #!/usr/bin/env python3 | ||||||||||||||||
| """Browser smoke harness for the homepage, dashboard, and one representative plot. | ||||||||||||||||
|
|
||||||||||||||||
| The script is intentionally stdlib-only so the repo has a non-UI pass/fail signal | ||||||||||||||||
| without depending on Playwright or another browser automation runtime. | ||||||||||||||||
|
|
||||||||||||||||
| It validates the canonical smoke targets, prints a concise terminal report, and | ||||||||||||||||
| can optionally write a self-contained HTML receipt for browser QA. | ||||||||||||||||
| """ | ||||||||||||||||
|
|
||||||||||||||||
| from __future__ import annotations | ||||||||||||||||
|
|
||||||||||||||||
| import argparse | ||||||||||||||||
| import json | ||||||||||||||||
| import sys | ||||||||||||||||
| from html import escape | ||||||||||||||||
| from pathlib import Path | ||||||||||||||||
|
|
||||||||||||||||
| from manifest_utils import ROOT | ||||||||||||||||
|
|
||||||||||||||||
| SMOKE_VIEWPORTS = [ | ||||||||||||||||
| {"name": "desktop", "width": 1440, "height": 1200}, | ||||||||||||||||
| {"name": "mobile", "width": 390, "height": 844}, | ||||||||||||||||
| ] | ||||||||||||||||
|
|
||||||||||||||||
| SMOKE_TARGETS = [ | ||||||||||||||||
| { | ||||||||||||||||
| "name": "homepage", | ||||||||||||||||
| "title": "Plots homepage", | ||||||||||||||||
| "path": "index.html", | ||||||||||||||||
| "checks": [ | ||||||||||||||||
| 'data-page="home"', | ||||||||||||||||
| "plots_manifest.json", | ||||||||||||||||
| "published entries", | ||||||||||||||||
| ], | ||||||||||||||||
| }, | ||||||||||||||||
| { | ||||||||||||||||
| "name": "dashboard", | ||||||||||||||||
| "title": "Unified Dashboard", | ||||||||||||||||
| "path": "dashboard/index.html", | ||||||||||||||||
| "checks": [ | ||||||||||||||||
| "dashboard.css", | ||||||||||||||||
| "dashboard.js", | ||||||||||||||||
| "dashboard-container", | ||||||||||||||||
| ], | ||||||||||||||||
| }, | ||||||||||||||||
| { | ||||||||||||||||
| "name": "representative-plot", | ||||||||||||||||
| "title": "AI Compute Timeline", | ||||||||||||||||
| "path": "ai-compute-timeline/index.html", | ||||||||||||||||
| "checks": [ | ||||||||||||||||
| "output/ai_compute_timeline_interactive.html", | ||||||||||||||||
| "output/ai_compute_timeline_highres.png", | ||||||||||||||||
| "output/ai_compute_timeline.svg", | ||||||||||||||||
| ], | ||||||||||||||||
| }, | ||||||||||||||||
| ] | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| def _relative_url(root: Path, rel_path: str) -> str: | ||||||||||||||||
| return (root / rel_path).as_uri() | ||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Using Useful? React with 👍 / 👎. |
||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| def evaluate_target(root: Path, target: dict) -> dict: | ||||||||||||||||
| path = root / target["path"] | ||||||||||||||||
| text = path.read_text(encoding="utf-8", errors="ignore") if path.is_file() else "" | ||||||||||||||||
| missing = [needle for needle in target["checks"] if needle not in text] | ||||||||||||||||
| result = { | ||||||||||||||||
| "name": target["name"], | ||||||||||||||||
| "title": target["title"], | ||||||||||||||||
| "path": target["path"], | ||||||||||||||||
| "url": _relative_url(root, target["path"]), | ||||||||||||||||
| "file_exists": path.is_file(), | ||||||||||||||||
| "missing_checks": missing, | ||||||||||||||||
| "status": "pass" if path.is_file() and not missing else "fail", | ||||||||||||||||
| "viewports": SMOKE_VIEWPORTS, | ||||||||||||||||
| } | ||||||||||||||||
| return result | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| def run_smoke_checks(root: Path = ROOT) -> list[dict]: | ||||||||||||||||
| return [evaluate_target(root, target) for target in SMOKE_TARGETS] | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| def render_text_report(results: list[dict], root: Path) -> str: | ||||||||||||||||
| lines = [ | ||||||||||||||||
| "Browser smoke harness", | ||||||||||||||||
| f"Root: {root}", | ||||||||||||||||
| "", | ||||||||||||||||
| ] | ||||||||||||||||
| for result in results: | ||||||||||||||||
| lines.append(f"[{result['status'].upper()}] {result['title']} -> {result['path']}") | ||||||||||||||||
| if result["missing_checks"]: | ||||||||||||||||
| for check in result["missing_checks"]: | ||||||||||||||||
| lines.append(f" - missing: {check}") | ||||||||||||||||
| viewport_summary = ", ".join( | ||||||||||||||||
| f"{viewport['name']} {viewport['width']}x{viewport['height']}" | ||||||||||||||||
| for viewport in result["viewports"] | ||||||||||||||||
| ) | ||||||||||||||||
| lines.append(f" - browser viewports: {viewport_summary}") | ||||||||||||||||
| lines.append("") | ||||||||||||||||
| lines.append("Pass/fail: " + ("PASS" if all(result["status"] == "pass" for result in results) else "FAIL")) | ||||||||||||||||
| return "\n".join(lines) | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| def render_html_report(results: list[dict], root: Path) -> str: | ||||||||||||||||
| rows = [] | ||||||||||||||||
| for result in results: | ||||||||||||||||
| missing = ", ".join(result["missing_checks"]) if result["missing_checks"] else "—" | ||||||||||||||||
| viewports = ", ".join( | ||||||||||||||||
| f"{vp['name']} {vp['width']}×{vp['height']}" for vp in result["viewports"] | ||||||||||||||||
| ) | ||||||||||||||||
| rows.append( | ||||||||||||||||
| f"""<tr> | ||||||||||||||||
| <td>{escape(result['title'])}</td> | ||||||||||||||||
| <td><a href=\"{escape(result['url'])}\">{escape(result['path'])}</a></td> | ||||||||||||||||
| <td class=\"status-{escape(result['status'])}\">{escape(result['status'].upper())}</td> | ||||||||||||||||
| <td>{escape(viewports)}</td> | ||||||||||||||||
| <td>{escape(missing)}</td> | ||||||||||||||||
| </tr>""" | ||||||||||||||||
| ) | ||||||||||||||||
|
|
||||||||||||||||
| overall = "PASS" if all(result["status"] == "pass" for result in results) else "FAIL" | ||||||||||||||||
| return f"""<!DOCTYPE html> | ||||||||||||||||
| <html lang=\"en\"> | ||||||||||||||||
| <head> | ||||||||||||||||
| <meta charset=\"UTF-8\"> | ||||||||||||||||
| <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"> | ||||||||||||||||
| <title>Browser Smoke Receipt</title> | ||||||||||||||||
| <style> | ||||||||||||||||
| body {{ background: #0f1117; color: #e8eaf6; font-family: system-ui, sans-serif; padding: 32px; line-height: 1.5; }} | ||||||||||||||||
| a {{ color: #7dd3fc; }} | ||||||||||||||||
| table {{ border-collapse: collapse; width: 100%; margin-top: 20px; }} | ||||||||||||||||
| th, td {{ border-bottom: 1px solid #2d3148; padding: 12px 10px; text-align: left; vertical-align: top; }} | ||||||||||||||||
| th {{ color: #cbd5e1; }} | ||||||||||||||||
| .status-pass {{ color: #4ade80; font-weight: 700; }} | ||||||||||||||||
| .status-fail {{ color: #f87171; font-weight: 700; }} | ||||||||||||||||
| .badge {{ display: inline-block; margin-left: 8px; padding: 2px 8px; border-radius: 999px; background: #1c1f2e; color: #cbd5e1; }} | ||||||||||||||||
| </style> | ||||||||||||||||
| </head> | ||||||||||||||||
| <body> | ||||||||||||||||
| <h1>Browser smoke receipt <span class=\"badge\">{overall}</span></h1> | ||||||||||||||||
| <p>Root: <code>{escape(str(root))}</code></p> | ||||||||||||||||
| <p>This receipt validates the canonical browser-smoke targets before opening them in a real browser session. Capture desktop and mobile screenshots for the three linked pages after this preflight passes.</p> | ||||||||||||||||
| <table> | ||||||||||||||||
| <thead> | ||||||||||||||||
| <tr><th>Target</th><th>Path</th><th>Status</th><th>Viewports</th><th>Missing checks</th></tr> | ||||||||||||||||
| </thead> | ||||||||||||||||
| <tbody> | ||||||||||||||||
| {''.join(rows)} | ||||||||||||||||
| </tbody> | ||||||||||||||||
| </table> | ||||||||||||||||
| </body> | ||||||||||||||||
| </html> | ||||||||||||||||
| """ | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| def main(argv: list[str] | None = None) -> int: | ||||||||||||||||
| parser = argparse.ArgumentParser(description="Validate the browser smoke targets.") | ||||||||||||||||
| parser.add_argument("--root", type=Path, default=ROOT, help="Repository root to validate.") | ||||||||||||||||
| parser.add_argument("--html", type=Path, help="Optional path for a browser-openable HTML receipt.") | ||||||||||||||||
| parser.add_argument("--json", dest="json_path", type=Path, help="Optional path for a JSON receipt.") | ||||||||||||||||
| args = parser.parse_args(argv) | ||||||||||||||||
|
|
||||||||||||||||
| results = run_smoke_checks(args.root) | ||||||||||||||||
|
Comment on lines
+163
to
+165
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Suggested change
|
||||||||||||||||
| ok = all(result["status"] == "pass" for result in results) | ||||||||||||||||
|
|
||||||||||||||||
| print(render_text_report(results, args.root)) | ||||||||||||||||
|
|
||||||||||||||||
| if args.html: | ||||||||||||||||
| args.html.parent.mkdir(parents=True, exist_ok=True) | ||||||||||||||||
| args.html.write_text(render_html_report(results, args.root), encoding="utf-8") | ||||||||||||||||
| print(f"HTML receipt: {args.html}") | ||||||||||||||||
|
|
||||||||||||||||
| if args.json_path: | ||||||||||||||||
| args.json_path.parent.mkdir(parents=True, exist_ok=True) | ||||||||||||||||
| args.json_path.write_text( | ||||||||||||||||
| json.dumps({"root": str(args.root), "results": results}, indent=2), | ||||||||||||||||
| encoding="utf-8", | ||||||||||||||||
| ) | ||||||||||||||||
| print(f"JSON receipt: {args.json_path}") | ||||||||||||||||
|
|
||||||||||||||||
| return 0 if ok else 1 | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| if __name__ == "__main__": | ||||||||||||||||
| raise SystemExit(main()) | ||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| """Tests for the browser smoke harness.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import sys | ||
| from pathlib import Path | ||
|
|
||
| import pytest | ||
|
|
||
|
|
||
| def _load_browser_smoke(): | ||
| sys.path.insert(0, "scripts") | ||
| import browser_smoke | ||
|
|
||
| return browser_smoke | ||
|
|
||
|
|
||
| class TestBrowserSmokeTargets: | ||
| def test_targets_are_canonical(self): | ||
| browser_smoke = _load_browser_smoke() | ||
|
|
||
| names = [target["name"] for target in browser_smoke.SMOKE_TARGETS] | ||
| assert names == ["homepage", "dashboard", "representative-plot"] | ||
|
|
||
| paths = [target["path"] for target in browser_smoke.SMOKE_TARGETS] | ||
| assert paths == [ | ||
| "index.html", | ||
| "dashboard/index.html", | ||
| "ai-compute-timeline/index.html", | ||
| ] | ||
|
|
||
| def test_viewports_are_desktop_and_mobile(self): | ||
| browser_smoke = _load_browser_smoke() | ||
|
|
||
| viewport_names = [viewport["name"] for viewport in browser_smoke.SMOKE_VIEWPORTS] | ||
| assert viewport_names == ["desktop", "mobile"] | ||
|
|
||
| def test_run_smoke_checks_pass_for_repo_root(self, repo_root: Path): | ||
| browser_smoke = _load_browser_smoke() | ||
|
|
||
| results = browser_smoke.run_smoke_checks(repo_root) | ||
| assert len(results) == 3 | ||
| assert all(result["status"] == "pass" for result in results) | ||
| assert all(result["file_exists"] for result in results) | ||
|
|
||
| def test_render_html_report_mentions_targets(self, repo_root: Path): | ||
| browser_smoke = _load_browser_smoke() | ||
|
|
||
| results = browser_smoke.run_smoke_checks(repo_root) | ||
| html = browser_smoke.render_html_report(results, repo_root) | ||
|
|
||
| assert "Browser smoke receipt" in html | ||
| assert "homepage" in html | ||
| assert "dashboard/index.html" in html | ||
| assert "ai-compute-timeline/index.html" in html | ||
| assert "PASS" in html | ||
|
Comment on lines
+46
to
+56
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test is an integration test that depends on the repository being in a 'built' state. If the build hasn't been run, the test will fail on the |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The smoke targets and their associated content checks are currently hardcoded. Since the repository is manifest-driven, consider dynamically generating the targets (especially the
representative-plot) by querying the manifest viamanifest_utils.plot_entries. This would make the harness more resilient to directory renames or plot deletions in the future.