Skip to content

Commit 4bf6d75

Browse files
authored
TST add comprehensive headless WebGL rendering tests (#606)
* Add .worktrees to .gitignore * TST add comprehensive headless WebGL rendering tests Add test_webgl_headless.py with 32 parametrized tests covering: - All 6 data types (Volume, Vertex, VolumeRGB, VertexRGB, Volume2D, Vertex2D) - All 13 predefined camera angles - All 5 surface morph states (fiducial to flatmap) - All 5 predefined panel layouts - _capture_view roundtrip verification - Overlay visibility comparison (with/without ROIs) - addData dataset switching (xfail: closure not available in headless path) Uses class-scoped fixtures to share browser sessions across angle and surface tests, reducing total runtime from ~15 min to ~6 min. * Fix flaky tests: wait for nonzero file size, not just existence The file is created (touched) by the POST handler before image data is written. In CI with slow software rendering, _wait_for_file was returning too early on a 0-byte file.
1 parent f6783a3 commit 4bf6d75

File tree

2 files changed

+290
-0
lines changed

2 files changed

+290
-0
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,6 @@ docs/colormaps.rst
6666

6767
# Python virtual environment
6868
.venv
69+
70+
# Git worktrees
71+
.worktrees
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
"""Tests for headless WebGL rendering across data types, angles, surfaces, and panels.
2+
3+
These tests exercise the WebGL viewer through the headless Chromium browser,
4+
verifying that all supported data types, camera angles, surface morphing
5+
states, and predefined panel layouts render correctly without a display server.
6+
7+
All tests are skipped if playwright is not installed.
8+
"""
9+
10+
import os
11+
import time
12+
13+
import numpy as np
14+
import pytest
15+
16+
import cortex
17+
import cortex.export
18+
from cortex.export.save_views import (
19+
angle_view_params,
20+
default_view_params,
21+
unfold_view_params,
22+
)
23+
from cortex.tests.testing_utils import has_playwright
24+
25+
pytestmark = pytest.mark.skipif(
26+
not has_playwright, reason="playwright and chromium are required"
27+
)
28+
29+
subj = "S1"
30+
xfmname = "fullhead"
31+
nverts = 304380
32+
volshape = (31, 100, 100)
33+
34+
ALL_PANEL_PRESETS = {
35+
name: getattr(cortex.export, name)
36+
for name in sorted(dir(cortex.export))
37+
if name.startswith("params_")
38+
}
39+
40+
41+
def make_dataview(dtype_name):
42+
"""Return a Dataview instance for the given type name."""
43+
np.random.seed(0)
44+
if dtype_name == "Volume":
45+
return cortex.Volume(np.random.randn(*volshape), subj, xfmname)
46+
elif dtype_name == "Vertex":
47+
return cortex.Vertex(np.random.randn(nverts), subj)
48+
elif dtype_name == "VolumeRGB":
49+
r, g, b = [np.random.randn(*volshape) for _ in range(3)]
50+
return cortex.VolumeRGB(r, g, b, subj, xfmname)
51+
elif dtype_name == "VertexRGB":
52+
r, g, b = [np.random.randn(nverts) for _ in range(3)]
53+
return cortex.VertexRGB(r, g, b, subj)
54+
elif dtype_name == "Volume2D":
55+
a1, a2 = np.random.randn(*volshape), np.random.randn(*volshape)
56+
return cortex.Volume2D(a1, a2, subject=subj, xfmname=xfmname)
57+
elif dtype_name == "Vertex2D":
58+
a1, a2 = np.random.randn(nverts), np.random.randn(nverts)
59+
return cortex.Vertex2D(a1, a2, subject=subj)
60+
else:
61+
raise ValueError(f"Unknown dtype_name: {dtype_name}")
62+
63+
64+
def _wait_for_file(path, timeout=30):
65+
"""Poll until file exists and has nonzero size, raise after timeout."""
66+
for _ in range(int(timeout / 0.1)):
67+
if os.path.exists(path) and os.path.getsize(path) > 0:
68+
return
69+
time.sleep(0.1)
70+
raise RuntimeError(f"File {path!r} not written within {timeout}s")
71+
72+
73+
# ---------------------------------------------------------------------------
74+
# Group 1: Data type smoke tests
75+
# ---------------------------------------------------------------------------
76+
77+
78+
@pytest.mark.parametrize(
79+
"dtype_name",
80+
["Volume", "Vertex", "VolumeRGB", "VertexRGB", "Volume2D", "Vertex2D"],
81+
)
82+
def test_datatype_renders(dtype_name, tmp_path):
83+
"""Each data type should render in the headless viewer without errors."""
84+
vol = make_dataview(dtype_name)
85+
with cortex.export.headless_viewer(vol, viewer_params={}) as handle:
86+
time.sleep(10)
87+
outfile = str(tmp_path / "test.png")
88+
handle.getImage(outfile, (512, 384))
89+
_wait_for_file(outfile)
90+
assert os.path.isfile(outfile)
91+
assert os.path.getsize(outfile) > 0
92+
# No uncaught JS errors
93+
pageerrors = [e for e in handle._pw_thread.browser_errors if "[pageerror]" in e]
94+
assert len(pageerrors) == 0, f"JS errors: {pageerrors}"
95+
96+
97+
# ---------------------------------------------------------------------------
98+
# Group 2: All predefined camera angles
99+
# ---------------------------------------------------------------------------
100+
101+
102+
class TestAllAngles:
103+
"""Test all predefined camera angles render correctly.
104+
105+
Uses a single headless browser session for all angles.
106+
"""
107+
108+
@pytest.fixture(autouse=True, scope="class")
109+
def _setup_viewer(self, tmp_path_factory):
110+
vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
111+
cls = type(self)
112+
cls.tmp_dir = tmp_path_factory.mktemp("angles")
113+
with cortex.export.headless_viewer(vol, viewer_params={}) as handle:
114+
time.sleep(10)
115+
cls.handle = handle
116+
yield
117+
118+
@pytest.mark.parametrize("angle_name", list(angle_view_params.keys()))
119+
def test_angle(self, angle_name):
120+
handle = type(self).handle
121+
view_params = {**default_view_params, **angle_view_params[angle_name]}
122+
if angle_name == "flatmap":
123+
view_params.update(unfold_view_params["flatmap"])
124+
else:
125+
view_params.update(unfold_view_params["inflated"])
126+
handle._set_view(**view_params)
127+
time.sleep(1)
128+
outfile = str(type(self).tmp_dir / f"{angle_name}.png")
129+
handle.getImage(outfile, (512, 384))
130+
_wait_for_file(outfile)
131+
assert os.path.isfile(outfile)
132+
assert os.path.getsize(outfile) > 1000, "Image too small — may be blank"
133+
134+
135+
# ---------------------------------------------------------------------------
136+
# Group 3: All surface types
137+
# ---------------------------------------------------------------------------
138+
139+
140+
class TestAllSurfaces:
141+
"""Test all surface morph states render correctly.
142+
143+
Uses a single headless browser session for all surfaces.
144+
"""
145+
146+
@pytest.fixture(autouse=True, scope="class")
147+
def _setup_viewer(self, tmp_path_factory):
148+
vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
149+
cls = type(self)
150+
cls.tmp_dir = tmp_path_factory.mktemp("surfaces")
151+
with cortex.export.headless_viewer(vol, viewer_params={}) as handle:
152+
time.sleep(10)
153+
cls.handle = handle
154+
yield
155+
156+
@pytest.mark.parametrize("surface_name", list(unfold_view_params.keys()))
157+
def test_surface(self, surface_name):
158+
handle = type(self).handle
159+
view_params = {
160+
**default_view_params,
161+
**angle_view_params["lateral_pivot"],
162+
**unfold_view_params[surface_name],
163+
}
164+
handle._set_view(**view_params)
165+
time.sleep(1)
166+
outfile = str(type(self).tmp_dir / f"{surface_name}.png")
167+
handle.getImage(outfile, (512, 384))
168+
_wait_for_file(outfile)
169+
assert os.path.isfile(outfile)
170+
assert os.path.getsize(outfile) > 1000, "Image too small — may be blank"
171+
172+
173+
# ---------------------------------------------------------------------------
174+
# Group 4: Predefined panel layouts
175+
# ---------------------------------------------------------------------------
176+
177+
178+
@pytest.mark.parametrize("preset_name", list(ALL_PANEL_PRESETS.keys()))
179+
def test_panel_preset(preset_name, tmp_path):
180+
"""Each predefined panel layout should render without errors."""
181+
import matplotlib.pyplot as plt
182+
183+
preset = ALL_PANEL_PRESETS[preset_name]
184+
vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
185+
save_name = str(tmp_path / f"{preset_name}.png")
186+
fig = cortex.export.plot_panels(
187+
vol,
188+
panels=preset["panels"],
189+
figsize=preset.get("figsize", (16, 9)),
190+
windowsize=(1024, 768),
191+
save_name=save_name,
192+
sleep=10,
193+
viewer_params={},
194+
headless=True,
195+
)
196+
assert fig is not None
197+
assert os.path.isfile(save_name)
198+
assert os.path.getsize(save_name) > 0
199+
plt.close(fig)
200+
201+
202+
# ---------------------------------------------------------------------------
203+
# Group 5: _capture_view roundtrip
204+
# ---------------------------------------------------------------------------
205+
206+
207+
def test_capture_view_roundtrip():
208+
"""Setting view parameters and capturing them back should match."""
209+
vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
210+
with cortex.export.headless_viewer(vol, viewer_params={}) as handle:
211+
time.sleep(10)
212+
target_params = {
213+
"camera.azimuth": 90,
214+
"camera.altitude": 90,
215+
}
216+
handle._set_view(**target_params)
217+
time.sleep(2)
218+
captured = handle._capture_view()
219+
for key, expected in target_params.items():
220+
assert captured[key] == pytest.approx(
221+
expected, abs=1.0
222+
), f"{key}: expected {expected}, got {captured[key]}"
223+
224+
225+
# ---------------------------------------------------------------------------
226+
# Group 6: Overlay visibility
227+
# ---------------------------------------------------------------------------
228+
229+
230+
def test_overlay_visibility_changes_image(tmp_path):
231+
"""Rendering with and without overlays should produce different images."""
232+
from PIL import Image
233+
234+
vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
235+
view = {
236+
**default_view_params,
237+
**angle_view_params["lateral_pivot"],
238+
**unfold_view_params["inflated"],
239+
}
240+
241+
# Render WITH overlays
242+
f1 = str(tmp_path / "with_overlay.png")
243+
with cortex.export.headless_viewer(
244+
vol, viewer_params=dict(overlays_visible=["rois"])
245+
) as handle:
246+
time.sleep(10)
247+
handle._set_view(**view)
248+
time.sleep(1)
249+
handle.getImage(f1, (512, 384))
250+
_wait_for_file(f1)
251+
252+
# Render WITHOUT overlays
253+
f2 = str(tmp_path / "without_overlay.png")
254+
with cortex.export.headless_viewer(
255+
vol, viewer_params=dict(overlays_visible=[])
256+
) as handle:
257+
time.sleep(10)
258+
handle._set_view(**view)
259+
time.sleep(1)
260+
handle.getImage(f2, (512, 384))
261+
_wait_for_file(f2)
262+
263+
img1 = np.array(Image.open(f1))
264+
img2 = np.array(Image.open(f2))
265+
assert not np.array_equal(img1, img2), "Images with/without overlays should differ"
266+
267+
268+
# ---------------------------------------------------------------------------
269+
# Group 7: addData dataset switching
270+
# ---------------------------------------------------------------------------
271+
272+
273+
@pytest.mark.xfail(
274+
reason="addData relies on _convert_dataset closure from show(), "
275+
"which is not available in the headless code path",
276+
raises=NameError,
277+
)
278+
def test_addData_no_crash():
279+
"""Adding a second dataset to an open viewer should not crash."""
280+
vol1 = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
281+
vol2 = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
282+
with cortex.export.headless_viewer(vol1, viewer_params={}) as handle:
283+
time.sleep(10)
284+
handle.addData(second=vol2)
285+
time.sleep(2)
286+
pageerrors = [e for e in handle._pw_thread.browser_errors if "[pageerror]" in e]
287+
assert len(pageerrors) == 0, f"JS errors after addData: {pageerrors}"

0 commit comments

Comments
 (0)