diff --git a/.github/workflows/test_and_release.yml b/.github/workflows/test_and_release.yml index 1a89505..f5d99a4 100644 --- a/.github/workflows/test_and_release.yml +++ b/.github/workflows/test_and_release.yml @@ -2,25 +2,28 @@ name: Test and Release on: push: - branches: [ master ] + branches: [master] pull_request: - branches: [ master ] + branches: [master] jobs: pre-commit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 + - uses: actions/checkout@v6 + - name: Install uv + uses: astral-sh/setup-uv@v6 with: - python-version: "3.9" + version: "0.11.24" + enable-cache: true + - name: Install the project + run: uv sync --all-extras --dev # Install and run pre-commit - run: | - pip install pre-commit - pre-commit install - pre-commit run --all-files + uv run pre-commit install + uv run pre-commit install --hook-type commit-msg + uv run pre-commit run --all-files pytest: name: Pytest ${{ matrix.config.name }} @@ -30,10 +33,7 @@ jobs: matrix: python-version: ["3.10"] config: - - { - name: "Linux", - os: ubuntu-latest - } + - { name: "Linux", os: ubuntu-latest } # - { # name: "MacOSX", # os: macos-latest @@ -48,57 +48,65 @@ jobs: shell: bash steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Install OSMesa for Linux - if: matrix.config.os == 'ubuntu-latest' - run: | - sudo apt update - sudo apt-get install -y libosmesa6-dev - - - name: Install dependencies - run: | - pip install .[vtk] - pip install -r tests/requirements.txt - pip install coverage - playwright install - - - name: Run Tests - run: | - # Run the tests with coverage so we get a coverage report too - coverage run --source . -m pytest ./tests - # Print the coverage report - coverage report -m - - - name: Upload Coverage to Codecov - uses: codecov/codecov-action@v3 - - - name: Upload pytest test results - uses: actions/upload-artifact@v4 - if: always() - with: - name: pytest-results-${{ matrix.config.name }} - path: | - tests/refs/*.yml - tests/refs/**/*.png - assets/** - retention-days: 1 + - name: Checkout + uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + version: "0.11.24" + enable-cache: true + + - name: Set Up Node + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Build Vue Components + run: | + cd vue-components + npm i + npm run build + + - name: Install the project + run: uv sync --all-extras --dev + + - name: Install OSMesa for Linux + if: matrix.config.os == 'ubuntu-latest' + run: | + sudo apt update + sudo apt-get install -y libosmesa6-dev + + - name: Install dependencies + run: | + uv sync --all-extras --dev + source .venv/bin/activate + playwright install + pytest -s ./tests --cov=src --cov-report=xml + + - name: Upload Coverage to Codecov + uses: codecov/codecov-action@v3 + + - name: Upload pytest test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: trame-vtklocal-tests-${{ matrix.config.name }} + path: | + tests/results/**/*.png + tests/results/**/*.txt + + retention-days: 1 test-npm-build: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set Up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 24 @@ -109,11 +117,7 @@ jobs: npm run build release: - needs: [ - pre-commit, - pytest, - test-npm-build, - ] + needs: [pre-commit, pytest, test-npm-build] runs-on: ubuntu-latest if: github.event_name == 'push' environment: @@ -125,12 +129,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: Set Up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 24 diff --git a/pyproject.toml b/pyproject.toml index afb2498..fd6a579 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,9 @@ dev = [ "ruff", "pytest", "pytest-asyncio", + "pytest-cov >=3", + "coverage", + "pytest-playwright", "trame-client[test]", ] diff --git a/tests/.gitignore b/tests/.gitignore new file mode 100644 index 0000000..1a06816 --- /dev/null +++ b/tests/.gitignore @@ -0,0 +1 @@ +results diff --git a/tests/assets/cone/00_startup.png b/tests/assets/cone/00_startup.png new file mode 100644 index 0000000..9619157 Binary files /dev/null and b/tests/assets/cone/00_startup.png differ diff --git a/tests/assets/cone/00_startup_github.png b/tests/assets/cone/00_startup_github.png new file mode 100644 index 0000000..e528a9e Binary files /dev/null and b/tests/assets/cone/00_startup_github.png differ diff --git a/tests/assets/cone/01_update_resolution.png b/tests/assets/cone/01_update_resolution.png new file mode 100644 index 0000000..9717210 Binary files /dev/null and b/tests/assets/cone/01_update_resolution.png differ diff --git a/tests/assets/cone/01_update_resolution_github.png b/tests/assets/cone/01_update_resolution_github.png new file mode 100644 index 0000000..84b926b Binary files /dev/null and b/tests/assets/cone/01_update_resolution_github.png differ diff --git a/tests/assets/cone/02_unmount.png b/tests/assets/cone/02_unmount.png new file mode 100644 index 0000000..04d28c7 Binary files /dev/null and b/tests/assets/cone/02_unmount.png differ diff --git a/tests/assets/cone/02_unmount_github.png b/tests/assets/cone/02_unmount_github.png new file mode 100644 index 0000000..6fe6c63 Binary files /dev/null and b/tests/assets/cone/02_unmount_github.png differ diff --git a/tests/assets/cone/03_remount.png b/tests/assets/cone/03_remount.png new file mode 100644 index 0000000..b92b1c7 Binary files /dev/null and b/tests/assets/cone/03_remount.png differ diff --git a/tests/assets/cone/03_remount_github.png b/tests/assets/cone/03_remount_github.png new file mode 100644 index 0000000..0605078 Binary files /dev/null and b/tests/assets/cone/03_remount_github.png differ diff --git a/tests/conftest.py b/tests/conftest.py index b74bfa9..96437e8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,116 @@ -import pytest from pathlib import Path -from trame_client.utils.testing import FixtureHelper + +import pytest +import vtk +from PIL import Image +from pixelmatch.contrib.PIL import pixelmatch +from trame.app import TrameApp +from trame.decorators import change +from trame.ui.html import DivLayout +from trame_client.utils.testing import FixtureHelper, enable_testing + +from trame.widgets import client, html, vtklocal +from trame_vtklocal.utils import ui ROOT_PATH = Path(__file__).parent.parent.absolute() HELPER = FixtureHelper(ROOT_PATH) +class Utils: + @staticmethod + async def compare_screenshot(page, baseline_image, result_directory, threshold=0.1): + 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 = [] + + 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 + + +def create_vtk_pipeline(): + renderer = vtk.vtkRenderer() + rw = vtk.vtkRenderWindow() + rw.AddRenderer(renderer) + rwi = vtk.vtkRenderWindowInteractor(render_window=rw) + rwi.interactor_style.SetCurrentStyleToTrackballCamera() + + cone = vtk.vtkConeSource() + + mapper = vtk.vtkPolyDataMapper(input_connection=cone.output_port) + actor = vtk.vtkActor(mapper=mapper) + + renderer.AddActor(actor) + renderer.background = (0.1, 0.2, 0.4) + renderer.ResetCamera() + + return rw, cone + + +class Cone(TrameApp): + def __init__(self, server=None, mode="wasm32", exec="sync", rendering="webgl"): + super().__init__(server) + self.state.wasm_conf = { + "mode": mode, + "exec": exec, + "rendering": rendering, + } + enable_testing(self.server, "local_rendering_ready") + self.render_window, self.cone = create_vtk_pipeline() + self._build_ui() + + @property + def resolution(self): + return self.state.resolution + + @resolution.setter + def resolution(self, v): + with self.state: + self.state.resolution = int(v) + + @change("resolution") + def _on_resolution(self, resolution, **_): + self.cone.resolution = int(resolution) + self.ctx.view.update() + + @property + def mounted(self): + return self.state.mounted + + @mounted.setter + def mounted(self, v): + with self.state: + self.state.mounted = bool(v) + + def _build_ui(self): + self.state.local_rendering_ready = 0 + with DivLayout(self.server) as self.ui: + html.Div("{{ local_rendering_ready }}", classes="readyCount") + client.Style( + "body { margin: 0; } .readyCount { z-index: 10; position: absolute; left: 0; top: 0; }" + ) + with html.Div( + style=ui.FULL_SCREEN, + v_if=("mounted", True), + ): + vtklocal.LocalView( + self.render_window, + ctx_name="view", + config=["wasm_conf"], + updated="local_rendering_ready++", + ) + + @pytest.fixture def ref_dir() -> Path: return Path(__file__).parent / "refs" @@ -21,3 +126,13 @@ def server(xprocess, server_path): # clean up whole process tree afterwards xprocess.getinfo(name).terminate() + + +@pytest.fixture +def utils(): + return Utils + + +@pytest.fixture +def ConeApp(): + return Cone diff --git a/tests/requirements.txt b/tests/requirements.txt deleted file mode 100644 index d6ef582..0000000 --- a/tests/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -pytest -pytest-asyncio -pytest-playwright -trame-client[test] diff --git a/tests/test_cone.py b/tests/test_cone.py new file mode 100644 index 0000000..4e380b2 --- /dev/null +++ b/tests/test_cone.py @@ -0,0 +1,83 @@ +import asyncio +from pathlib import Path + +import pytest +from playwright.async_api import async_playwright, expect + +BASELINES = [ + Path(__file__).with_name("assets") / "cone" / name + for name in [ + "00_startup", + "01_update_resolution", + "02_unmount", + "03_remount", + ] +] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "config", + [ + ("wasm32", "sync", "webgl"), + ("wasm32", "async", "webgl"), + ("wasm64", "sync", "webgl"), + ("wasm64", "async", "webgl"), + ("wasm32", "async", "webgpu"), + ("wasm64", "async", "webgpu"), + ], +) +async def test_cone(ConeApp, utils, config): + wasm_mode, wasm_exec, wasm_rendering = config + conf_key = f"{wasm_mode}-{wasm_exec}-{wasm_rendering}" + app = ConeApp(f"cone-{conf_key}", wasm_mode, wasm_exec, wasm_rendering) + task = app.server.start(exec_mode="task", port=0) + await app.server.ready + RESULT_BASE = Path(__file__).with_name("results") / "cone" / conf_key + valid_image_comparisons = [] + + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True) + page = await browser.new_page() + await page.set_viewport_size({"width": 300, "height": 300}) + + await page.goto(f"http://localhost:{app.server.port}/") + await expect(page.locator(".readyCount")).to_have_text("1") + valid_image_comparisons.append( + await utils.compare_screenshot( + page, BASELINES[0], RESULT_BASE, threshold=0.1 + ) + ) + + app.resolution = 60 + await expect(page.locator(".readyCount")).to_have_text("2") + valid_image_comparisons.append( + await utils.compare_screenshot( + page, BASELINES[1], RESULT_BASE, threshold=0.1 + ) + ) + + app.mounted = False + app.resolution = 4 + valid_image_comparisons.append( + await utils.compare_screenshot( + page, BASELINES[2], RESULT_BASE, threshold=0.1 + ) + ) + + app.mounted = True + await asyncio.sleep(0.1) # Debounced resize needs complete + 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 all(valid_image_comparisons), "Some images don't match" + + # Clean up resource + await browser.close() + + await app.server.stop() + await task