Skip to content

Commit e67df2f

Browse files
authored
Merge pull request #389 from uriahf/jules-quarto-summary-report-acceptance-14654400758726331705
test: add Quarto browser summary report acceptance proof
2 parents d3ecbd0 + 83482d1 commit e67df2f

2 files changed

Lines changed: 290 additions & 0 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
name: Quarto Summary Report Acceptance
2+
3+
on:
4+
push:
5+
branches: ["main"]
6+
paths:
7+
- "src/rtichoke/summary_report/**"
8+
- "src/rtichoke/_report_browser.py"
9+
- "src/rtichoke/_report_spec.py"
10+
- "src/rtichoke/_vendor/rtichoke_viz/**"
11+
- "tests/test_quarto_summary_report_browser.py"
12+
- "tests/test_summary_report_browser.py"
13+
- ".github/workflows/quarto-acceptance.yml"
14+
pull_request:
15+
branches: ["main"]
16+
paths:
17+
- "src/rtichoke/summary_report/**"
18+
- "src/rtichoke/_report_browser.py"
19+
- "src/rtichoke/_report_spec.py"
20+
- "src/rtichoke/_vendor/rtichoke_viz/**"
21+
- "tests/test_quarto_summary_report_browser.py"
22+
- "tests/test_summary_report_browser.py"
23+
- ".github/workflows/quarto-acceptance.yml"
24+
workflow_dispatch:
25+
26+
jobs:
27+
quarto-acceptance:
28+
runs-on: ubuntu-latest
29+
permissions:
30+
contents: read
31+
32+
steps:
33+
- uses: actions/checkout@v4
34+
35+
- name: Install uv and set Python version
36+
uses: astral-sh/setup-uv@v5
37+
with:
38+
python-version: "3.12"
39+
40+
- name: Install Quarto CLI
41+
uses: quarto-dev/quarto-actions/setup@v2
42+
with:
43+
version: "1.4.555"
44+
45+
- name: Install project dependencies
46+
run: uv sync --all-extras --dev
47+
48+
- name: Install Playwright Chromium for browser acceptance
49+
run: uv run --with playwright playwright install chromium
50+
51+
- name: Run Quarto browser report acceptance tests
52+
run: uv run --with playwright pytest tests/test_quarto_summary_report_browser.py
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import shutil
2+
import subprocess
3+
from contextlib import contextmanager
4+
from functools import partial
5+
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
6+
from pathlib import Path
7+
from threading import Thread
8+
from typing import Iterator
9+
10+
import numpy as np
11+
import pytest
12+
13+
from rtichoke.summary_report.summary_report import create_summary_report
14+
15+
16+
def _quarto_executable() -> str:
17+
executable = shutil.which("quarto")
18+
if executable is None:
19+
pytest.skip("quarto executable is not available")
20+
return executable
21+
22+
23+
def _chrome_executable() -> str:
24+
for candidate in (
25+
"google-chrome",
26+
"google-chrome-stable",
27+
"chromium",
28+
"chromium-browser",
29+
):
30+
executable = shutil.which(candidate)
31+
if executable is not None:
32+
return executable
33+
pytest.skip("headless Chrome/Chromium is not available")
34+
35+
36+
@contextmanager
37+
def _serve(directory: Path) -> Iterator[str]:
38+
handler = partial(SimpleHTTPRequestHandler, directory=str(directory))
39+
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
40+
thread = Thread(target=server.serve_forever, daemon=True)
41+
thread.start()
42+
try:
43+
yield f"http://127.0.0.1:{server.server_port}"
44+
finally:
45+
server.shutdown()
46+
thread.join()
47+
server.server_close()
48+
49+
50+
def _dump_dom(url: str) -> subprocess.CompletedProcess[str]:
51+
return subprocess.run(
52+
[
53+
_chrome_executable(),
54+
"--headless=new",
55+
"--no-sandbox",
56+
"--disable-gpu",
57+
"--enable-logging=stderr",
58+
"--log-level=0",
59+
"--dump-dom",
60+
url,
61+
],
62+
check=False,
63+
capture_output=True,
64+
text=True,
65+
timeout=30,
66+
)
67+
68+
69+
def test_quarto_single_browser_summary_report(tmp_path):
70+
quarto_bin = _quarto_executable()
71+
72+
probs = {
73+
"Model A": np.array(
74+
[0.03, 0.08, 0.12, 0.18, 0.25, 0.32, 0.40, 0.50, 0.62, 0.75, 0.88, 0.96]
75+
)
76+
}
77+
reals = np.array([0, 0, 0, 0, 0, 1, 0, 1, 1, 1, 1, 1])
78+
79+
report_path = tmp_path / "report.html"
80+
create_summary_report(probs, reals, renderer="browser", output_file=report_path)
81+
82+
qmd = tmp_path / "doc.qmd"
83+
qmd.write_text(
84+
"""---
85+
title: "Single Report Host"
86+
format: html
87+
---
88+
89+
<iframe src="report.html" style="width: 100%; height: 1200px; border: 0;"></iframe>
90+
""",
91+
encoding="utf-8",
92+
)
93+
94+
res = subprocess.run(
95+
[quarto_bin, "render", str(qmd), "--to", "html"], capture_output=True, text=True
96+
)
97+
assert res.returncode == 0, res.stderr
98+
99+
rendered_html = tmp_path / "doc.html"
100+
assert rendered_html.exists()
101+
102+
try:
103+
from playwright.sync_api import sync_playwright # type: ignore[import-untyped]
104+
except ImportError:
105+
with _serve(tmp_path) as base_url:
106+
browser = _dump_dom(f"{base_url}/doc.html")
107+
assert browser.returncode == 0, browser.stderr
108+
assert "INFO:CONSOLE" not in browser.stderr, browser.stderr
109+
assert '<iframe src="report.html"' in browser.stdout
110+
else:
111+
with _serve(tmp_path) as base_url:
112+
with sync_playwright() as p:
113+
browser = p.chromium.launch(headless=True)
114+
page = browser.new_page()
115+
errors: list[str] = []
116+
page.on(
117+
"console",
118+
lambda msg: errors.append(msg.text)
119+
if msg.type in ["error", "warning"]
120+
else None,
121+
)
122+
page.on("pageerror", lambda err: errors.append(str(err)))
123+
124+
page.goto(f"{base_url}/doc.html")
125+
page.wait_for_selector("iframe")
126+
127+
frame_el = page.query_selector("iframe")
128+
assert frame_el is not None
129+
frame = frame_el.content_frame()
130+
assert frame is not None
131+
132+
frame.wait_for_selector("#rtichoke-report")
133+
frame.wait_for_selector("table")
134+
frame.wait_for_selector("svg")
135+
136+
assert "Performance" in frame.content()
137+
assert "ROC" in frame.content()
138+
assert "Calibration" in frame.content()
139+
140+
tbl_text = frame.locator("table").inner_text()
141+
assert "Model" in tbl_text or "True Positives" in tbl_text
142+
assert frame.locator("svg").count() >= 2
143+
assert len(errors) == 0, f"Console errors found: {errors}"
144+
browser.close()
145+
146+
147+
def test_quarto_two_browser_summary_reports(tmp_path):
148+
quarto_bin = _quarto_executable()
149+
150+
probs1 = {"Model A": np.array([0.03, 0.12, 0.25, 0.40, 0.62, 0.75, 0.88, 0.96])}
151+
reals1 = np.array([0, 0, 0, 1, 0, 1, 1, 1])
152+
153+
probs2 = {
154+
"Model X": np.array([0.1, 0.3, 0.5, 0.7, 0.9]),
155+
"Model Y": np.array([0.2, 0.4, 0.6, 0.8, 0.95]),
156+
}
157+
reals2 = np.array([0, 0, 1, 1, 1])
158+
159+
create_summary_report(
160+
probs1, reals1, renderer="browser", output_file=tmp_path / "report1.html"
161+
)
162+
create_summary_report(
163+
probs2, reals2, renderer="browser", output_file=tmp_path / "report2.html"
164+
)
165+
166+
qmd = tmp_path / "multi_doc.qmd"
167+
qmd.write_text(
168+
"""---
169+
title: "Two Reports Host"
170+
format: html
171+
---
172+
173+
## First Report
174+
175+
<iframe src="report1.html" style="width: 100%; height: 1200px; border: 0;"></iframe>
176+
177+
## Second Report
178+
179+
<iframe src="report2.html" style="width: 100%; height: 1200px; border: 0;"></iframe>
180+
""",
181+
encoding="utf-8",
182+
)
183+
184+
res = subprocess.run(
185+
[quarto_bin, "render", str(qmd), "--to", "html"], capture_output=True, text=True
186+
)
187+
assert res.returncode == 0, res.stderr
188+
189+
rendered_html = tmp_path / "multi_doc.html"
190+
assert rendered_html.exists()
191+
192+
try:
193+
from playwright.sync_api import sync_playwright # type: ignore[import-untyped]
194+
except ImportError:
195+
with _serve(tmp_path) as base_url:
196+
browser = _dump_dom(f"{base_url}/multi_doc.html")
197+
assert browser.returncode == 0, browser.stderr
198+
assert "INFO:CONSOLE" not in browser.stderr, browser.stderr
199+
assert '<iframe src="report1.html"' in browser.stdout
200+
assert '<iframe src="report2.html"' in browser.stdout
201+
else:
202+
with _serve(tmp_path) as base_url:
203+
with sync_playwright() as p:
204+
browser = p.chromium.launch(headless=True)
205+
page = browser.new_page()
206+
errors: list[str] = []
207+
page.on(
208+
"console",
209+
lambda msg: errors.append(msg.text)
210+
if msg.type in ["error", "warning"]
211+
else None,
212+
)
213+
page.on("pageerror", lambda err: errors.append(str(err)))
214+
215+
page.goto(f"{base_url}/multi_doc.html")
216+
page.wait_for_selector("iframe")
217+
218+
iframes = page.query_selector_all("iframe")
219+
assert len(iframes) == 2
220+
221+
for iframe_el in iframes:
222+
frame = iframe_el.content_frame()
223+
assert frame is not None
224+
225+
frame.wait_for_selector("#rtichoke-report")
226+
frame.wait_for_selector("table")
227+
frame.wait_for_selector("svg")
228+
229+
assert "Performance" in frame.content()
230+
assert "ROC" in frame.content()
231+
assert "Calibration" in frame.content()
232+
233+
tbl_text = frame.locator("table").inner_text()
234+
assert "Model" in tbl_text or "True Positives" in tbl_text
235+
assert frame.locator("svg").count() >= 2
236+
237+
assert len(errors) == 0, f"Console errors found: {errors}"
238+
browser.close()

0 commit comments

Comments
 (0)