Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 22 additions & 12 deletions .github/workflows/test_and_release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,22 +31,21 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10"]
python-version: ["3.13"]
config:
- { name: "Linux", os: ubuntu-latest }
# - {
# name: "MacOSX",
# os: macos-latest
# }
# - {
# name: "Windows",
# os: windows-latest
# }
- { name: "MacOSX", os: macos-latest }
- { name: "Windows", os: windows-latest }

defaults:
run:
shell: bash

# Pin uv to the matrix interpreter; without this uv picks its own default
# and the vtk dev wheel is only published for the interpreters listed above.
env:
UV_PYTHON: ${{ matrix.python-version }}

steps:
- name: Checkout
uses: actions/checkout@v6
Expand Down Expand Up @@ -77,12 +76,23 @@ jobs:
sudo apt update
sudo apt-get install -y libosmesa6-dev

- name: Install OSMesa for Windows
if: matrix.config.os == 'windows-latest'
# 25.0.7 is the last mesa-dist-win release that ships osmesa.dll
# (removed in 25.1.0). PATH is searched by LoadLibrary, which is how
# VTK's vtkOSOpenGLRenderWindow locates osmesa.dll at runtime.
run: |
curl -sSL -o mesa.7z https://github.com/pal1000/mesa-dist-win/releases/download/25.0.7/mesa3d-25.0.7-release-msvc.7z
7z x mesa.7z -omesa
cygpath -w "$PWD/mesa/x64" >> "$GITHUB_PATH"

- name: Install dependencies
run: |
uv sync --all-extras --dev
source .venv/bin/activate
playwright install
pytest -s ./tests --cov=src --cov-report=xml
uv run playwright install

- name: Run tests
run: uv run pytest -s ./tests --cov=src --cov-report=xml

- name: Upload Coverage to Codecov
uses: codecov/codecov-action@v3
Expand Down
11 changes: 11 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,20 @@ dev = [
"coverage",
"pytest-playwright",
"trame-client[test]",
# Dev wheel from wheels.vtk.org (see [tool.uv] below)
"vtk==9.7.20260712.dev0",
]

# Resolve the pinned vtk dev build from the VTK nightly wheel index instead of
# PyPI. `explicit = true` keeps every other dependency on PyPI; only packages
# mapped in [tool.uv.sources] use this index.
[[tool.uv.index]]
name = "vtk-wheels"
url = "https://wheels.vtk.org"
explicit = true

[tool.uv.sources]
vtk = { index = "vtk-wheels" }


[build-system]
Expand Down
Binary file modified tests/assets/cone/00_startup.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed tests/assets/cone/00_startup_github.png
Binary file not shown.
Binary file modified tests/assets/cone/01_update_resolution.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed tests/assets/cone/01_update_resolution_github.png
Binary file not shown.
Binary file modified tests/assets/cone/02_unmount.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed tests/assets/cone/02_unmount_github.png
Binary file not shown.
Binary file modified tests/assets/cone/03_remount.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed tests/assets/cone/03_remount_github.png
Binary file not shown.
Binary file modified tests/assets/multi_view/00_startup.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed tests/assets/multi_view/00_startup_github.png
Binary file not shown.
Binary file modified tests/assets/volume/00_startup.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed tests/assets/volume/00_startup_github.png
Binary file not shown.
Binary file modified tests/assets/volume/01_update_pwf.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed tests/assets/volume/01_update_pwf_github.png
Binary file not shown.
Binary file removed tests/assets/volume/01_update_pwf_gpu.png
Binary file not shown.
Binary file modified tests/assets/volume/03_remount.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed tests/assets/volume/03_remount_github.png
Binary file not shown.
Binary file removed tests/assets/volume/03_remount_gpu.png
Binary file not shown.
136 changes: 119 additions & 17 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import sys
from pathlib import Path

import pytest
import vtk
from PIL import Image
from pixelmatch.contrib.PIL import pixelmatch
from vtkmodules.test import Testing as vtk_testing
from trame.app import TrameApp
from trame.decorators import change
from trame.ui.html import DivLayout
Expand All @@ -16,26 +16,127 @@
HELPER = FixtureHelper(ROOT_PATH)


def webgpu_args():
"""Chromium flags needed to obtain a working WebGPU adapter, per platform.

Playwright's bundled Chromium exposes no WebGPU adapter by default, and
--enable-unsafe-webgpu alone only yields a SwiftShader adapter that renders a
blank frame on macOS. Selecting the platform's native ANGLE backend gives a
real adapter VTK can draw with. Apply only for webgpu configs, since the
angle backend override also shifts webgl pixels.

On Windows, Dawn's D3D12 backend fails to create a device because the
Chromium build ships a dxil.dll it cannot load (EnsureDXCLibraries ->
"DynamicLib.Open: dxil.dll Windows Error: 87"). Disabling the use_dxc Dawn
feature falls back to the FXC shader compiler, which needs no external DLL.
"""
backend = {"darwin": "metal", "win32": "d3d11"}.get(sys.platform, "vulkan")
args = [f"--use-angle={backend}", "--enable-unsafe-webgpu"]
if sys.platform == "win32":
args.append("--disable-dawn-features=use_dxc")
return args


async def webgpu_hardware_available(page):
"""Return True only if the page has a non-software WebGPU adapter.

Playwright's headless Chromium always exposes *an* adapter once
--enable-unsafe-webgpu is set, but on a GPU-less runner (GitHub's
windows-latest and ubuntu-latest) it is a software fallback -- WARP on
Windows, SwiftShader/llvmpipe on Linux -- which VTK's WebGPU backend
cannot present with, so the canvas stays blank. macOS runners have a real
Metal GPU and render correctly. Detect the fallback case so webgpu configs
can skip where no real GPU exists instead of failing on a blank frame.
"""
return await page.evaluate(
"""async () => {
if (!navigator.gpu) return false;
const a = await navigator.gpu.requestAdapter();
if (!a) return false;
if (a.isFallbackAdapter) return false;
const i = a.info || {};
const desc = [i.vendor, i.architecture, i.device, i.description]
.join(' ')
.toLowerCase();
return !/swiftshader|llvmpipe|basic render|warp|software|microsoft basic/.test(
desc
);
}"""
)


class Utils:
@staticmethod
async def compare_screenshot(page, baseline_image, result_directory, threshold=0.1):
async def wait_for_render(page):
"""Wait until every LocalView canvas is sized and painted.

Two timing hazards make a bare screenshot unreliable:

* The `updated` event fires when the client finishes applying a state
update, but WebGPU presents the new frame on a *later* animation
frame, so an immediate capture grabs a blank pre-present buffer.
* On (re)mount the canvas starts at its 300x150 HTML default and only
reaches the container size after the 100ms debounced ResizeObserver
calls setSizeAsync, which does not bump `updated`. Capturing before
then yields a wrong-sized frame.

Poll on requestAnimationFrame until every canvas drawing buffer matches
its layout size (same floor(size * dpr + 0.5) formula the component
uses) and has stayed stable for two consecutive frames, which also
gives the compositor time to present. A frame cap keeps it from hanging
if a canvas never settles.
"""
await page.evaluate(
"""() => new Promise((resolve) => {
let stable = 0;
let frames = 0;
const settled = () => {
const canvases = [...document.querySelectorAll('canvas')];
if (!canvases.length) return false;
const dpr = window.devicePixelRatio;
return canvases.every((c) => {
const r = c.getBoundingClientRect();
const w = Math.floor(r.width * dpr + 0.5);
const h = Math.floor(r.height * dpr + 0.5);
return w > 0 && h > 0 && c.width === w && c.height === h;
});
};
const tick = () => {
stable = settled() ? stable + 1 : 0;
if (stable >= 2 || ++frames > 180) resolve();
else requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
})"""
)

@staticmethod
async def compare_screenshot(
page, baseline_image, result_directory, threshold=0.05
):
test_image = result_directory / baseline_image.with_suffix(".png").name
await page.screenshot(path=test_image)

img_test = Image.open(test_image)
img_diff = Image.new("RGBA", img_test.size)
mismatches = [999]

for ref_file in baseline_image.parent.glob(f"{baseline_image.name}*.png"):
img_ref = Image.open(ref_file)

file_diff = (test_image.parent / ref_file.name).with_suffix(".diff.png")
mismatch = pixelmatch(img_ref, img_test, img_diff, threshold=threshold)
img_diff.save(file_diff)
file_diff.with_suffix(".txt").write_text(f"{mismatch}")
mismatches.append(mismatch)

return min(mismatches) < threshold
reader = vtk.vtkPNGReader(file_name=str(test_image))
reader.Update()

# vtkTesting writes the .diff/.valid/error images into VTK_TEMP_DIR
vtk_testing.VTK_TEMP_DIR = str(result_directory)

try:
# src_img must be a vtkAlgorithm (image source), not vtkImageData.
# Use a posix-style path: vtkTesting derives the .diff/.valid output
# names by splitting the baseline path on '/' only, so a Windows
# backslash path makes it append the whole absolute path to tmpDir.
vtk_testing.compareImageWithSavedImage(
reader,
baseline_image.with_suffix(".png").as_posix(),
threshold=threshold,
)
return True
except RuntimeError as e:
print(e)
return False


MAPPERS = {
Expand Down Expand Up @@ -211,6 +312,7 @@ def _build_ui(self):
):
vtklocal.LocalView(
self.render_window,
ref="cone_view",
ctx_name="view",
config=["wasm_conf"],
updated="local_rendering_ready++",
Expand Down
26 changes: 22 additions & 4 deletions tests/test_cone.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import asyncio
from pathlib import Path
from trame_vtklocal.module.wasm import wasm_downloaded

import pytest
from playwright.async_api import async_playwright, expect

from conftest import webgpu_args, webgpu_hardware_available

BASELINES = [
Path(__file__).with_name("assets") / "cone" / name
for name in [
Expand All @@ -14,6 +15,10 @@
"03_remount",
]
]
EXPECTED_WINDOW_CLASSNAMES = {
"webgl": "vtkWebAssemblyOpenGLRenderWindow",
"webgpu": "vtkWebGPURenderWindow",
}


@pytest.mark.asyncio
Expand All @@ -40,12 +45,16 @@ async def test_cone(ConeApp, utils, config):
valid_image_comparisons = []

async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
args = webgpu_args() if wasm_rendering == "webgpu" else []
browser = await p.chromium.launch(headless=True, args=args)
page = await browser.new_page()
await page.set_viewport_size({"width": 300, "height": 300})

await page.goto(f"http://localhost:{app.server.port}/")
await asyncio.sleep(0.1) # wait for page load
if wasm_rendering == "webgpu" and not await webgpu_hardware_available(page):
await browser.close()
pytest.skip("No hardware WebGPU adapter; software fallback renders blank")
await utils.wait_for_render(page) # wait for page load
await expect(page.locator(".readyCount")).to_have_text("1")
valid_image_comparisons.append(
await utils.compare_screenshot(
Expand All @@ -55,6 +64,7 @@ async def test_cone(ConeApp, utils, config):

app.resolution = 60
await expect(page.locator(".readyCount")).to_have_text("2")
await utils.wait_for_render(page)
valid_image_comparisons.append(
await utils.compare_screenshot(
page, BASELINES[1], RESULT_BASE, threshold=0.1
Expand All @@ -70,14 +80,22 @@ async def test_cone(ConeApp, utils, config):
)

app.mounted = True
await asyncio.sleep(0.1) # Debounced resize needs complete
await utils.wait_for_render(page)
await expect(page.locator(".readyCount")).to_have_text("3")
valid_image_comparisons.append(
await utils.compare_screenshot(
page, BASELINES[3], RESULT_BASE, threshold=0.1
)
)

# Assert the active rendering backend last: getVtkObject() runs a
# client-side serialize that currently corrupts the WebGPU render window
# (VTK webgpu bug; harmless on webgl), so keep it after every screenshot.
result = await page.evaluate(
"window.trame.refs.cone_view.getVtkObject(1).state.className"
)
assert result == EXPECTED_WINDOW_CLASSNAMES[wasm_rendering]

assert all(valid_image_comparisons), "Some images don't match"

# Clean up resource
Expand Down
3 changes: 1 addition & 2 deletions tests/test_multi_view.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import asyncio
from pathlib import Path
from trame_vtklocal.module.wasm import wasm_downloaded

Expand Down Expand Up @@ -36,7 +35,7 @@ async def test_multi_view(MultiViewApp, utils):
await page.set_viewport_size({"width": 600, "height": 300})

await page.goto(f"http://localhost:{app.server.port}/")
await asyncio.sleep(0.1) # wait for page load
await utils.wait_for_render(page)
# Both views increment the counter once they have rendered.
await expect(page.locator(".readyCount")).to_have_text("2")
valid_image_comparisons.append(
Expand Down
26 changes: 20 additions & 6 deletions tests/test_volume_rendering.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import asyncio
from pathlib import Path

import pytest
from playwright.async_api import async_playwright, expect

from trame_vtklocal.module.wasm import wasm_downloaded

from conftest import webgpu_args

BASELINES = [
Path(__file__).with_name("assets") / "volume" / name
for name in [
Expand Down Expand Up @@ -34,8 +35,20 @@
("wasm32", "async", "webgl"),
("wasm64", "sync", "webgl"),
("wasm64", "async", "webgl"),
("wasm32", "async", "webgpu"),
("wasm64", "async", "webgpu"),
pytest.param(
("wasm32", "async", "webgpu"),
marks=pytest.mark.xfail(
reason="Volume rendering is unsupported on the WebGPU backend in VTK",
run=False,
),
),
pytest.param(
("wasm64", "async", "webgpu"),
marks=pytest.mark.xfail(
reason="Volume rendering is unsupported on the WebGPU backend in VTK",
run=False,
),
),
],
)
async def test_volume_rendering(VolumeApp, utils, config, mapper_type):
Expand All @@ -55,12 +68,13 @@ async def test_volume_rendering(VolumeApp, utils, config, mapper_type):
valid_image_comparisons = []

async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
args = webgpu_args() if wasm_rendering == "webgpu" else []
browser = await p.chromium.launch(headless=True, args=args)
page = await browser.new_page()
await page.set_viewport_size({"width": 300, "height": 300})

await page.goto(f"http://localhost:{app.server.port}/")
await asyncio.sleep(0.1) # wait for page load
await utils.wait_for_render(page)
await expect(page.locator(".readyCount")).to_have_text("1")
valid_image_comparisons.append(
await utils.compare_screenshot(
Expand All @@ -85,7 +99,7 @@ async def test_volume_rendering(VolumeApp, utils, config, mapper_type):
)

app.mounted = True
await asyncio.sleep(0.1) # Debounced resize needs complete
await utils.wait_for_render(page)
await expect(page.locator(".readyCount")).to_have_text("3")
valid_image_comparisons.append(
await utils.compare_screenshot(
Expand Down
Loading