Skip to content

Commit 35cf8cb

Browse files
apiadclaude
andcommitted
test(e2e): first Playwright slice — real browser asserts no Pyodide errors
The bug surfaced in commit 0923863 (NameError: name 'store' is not defined) only manifested when the bundle actually ran in Pyodide in a real browser. Compile-time tests passed; the failure was invisible to the existing suite. This slice fixes that. New: tests/test_examples_e2e.py - One @pytest.mark.e2e test that boots example 03 on a real uvicorn server, drives a headless Chromium via Playwright, waits for the violetear-cloak element to be removed (hydration-complete signal), and asserts no console.error / pageerror fired during the load. - A cheap sanity check that the SSR-rendered meters value is present after hydration. New: tests/conftest.py - Session-scoped autouse fixture that pre-warms the Pyodide local cache (~14MB download once if missing — no-op after). - example_server fixture that boots an example via uvicorn-in-thread on a free port and returns the base URL. - browser_type_launch_args override that points executable_path at the full chromium binary on disk instead of the headless_shell variant pytest-playwright defaults to — works around a chronic version-mismatch where `playwright install chromium` doesn't always fetch the matching headless_shell. Opt out via env var if needed. New: pytest config - e2e marker registered in pyproject.toml. - `addopts = "-m 'not e2e'"` keeps the default fast gate skipping them. - Run e2e with `make e2e` or `uv run pytest -m e2e -v`. CI: - New `e2e` job in tests.yaml that runs after the fast `test` job. - Caches ~/.cache/ms-playwright and ~/.cache/violetear so the per-run cost is one-time per cache miss. - Uses `playwright install --with-deps chromium` (sudo OK on the ubuntu runner; not on local dev — that's why we override executable_path locally). 43 passed (fast gate, 1 deselected) + 1 e2e passed locally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0923863 commit 35cf8cb

6 files changed

Lines changed: 509 additions & 1 deletion

File tree

.github/workflows/tests.yaml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,40 @@ jobs:
3030

3131
- name: Run tests with coverage
3232
run: uv run pytest --cov=violetear --cov-report=xml --cov-report=term
33+
34+
e2e:
35+
runs-on: ubuntu-latest
36+
needs: test
37+
38+
steps:
39+
- name: Checkout repository
40+
uses: actions/checkout@v4
41+
42+
- name: Set up Python
43+
uses: actions/setup-python@v5
44+
with:
45+
python-version: "3.13"
46+
47+
- name: Install uv
48+
run: curl -LsSf https://astral.sh/uv/install.sh | sh
49+
50+
- name: Install dependencies
51+
run: uv sync --all-extras --all-groups --no-editable
52+
53+
- name: Cache Playwright browsers
54+
uses: actions/cache@v4
55+
with:
56+
path: ~/.cache/ms-playwright
57+
key: playwright-${{ runner.os }}-${{ hashFiles('uv.lock') }}
58+
59+
- name: Cache violetear Pyodide assets
60+
uses: actions/cache@v4
61+
with:
62+
path: ~/.cache/violetear
63+
key: violetear-pyodide-0.29.0
64+
65+
- name: Install Playwright Chromium
66+
run: uv run playwright install --with-deps chromium
67+
68+
- name: Run e2e tests
69+
run: uv run pytest -m e2e -v

makefile

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,21 @@ format-check:
2020
type-check:
2121
mypy
2222

23-
.PHONY: test-unit test-all
23+
.PHONY: test-unit test-all e2e
2424

2525
test-unit: format-check
2626
uv run pytest tests --cov=violetear
2727

2828
test-all: format-check
2929
uv run pytest --cov=violetear
3030

31+
# End-to-end browser tests via Playwright. Slow (~5s/test for Pyodide load),
32+
# so kept out of the default `make` gate. Run when you change anything that
33+
# affects the bundle, hydration, reactive bindings, or Pyodide hosting.
34+
e2e:
35+
uv run playwright install chromium
36+
uv run pytest -m e2e -v
37+
3138
.PHONY: docker-build
3239
docker-build:
3340
docker build -t violetear:latest -f dockerfile .

pyproject.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,15 @@ dev = [
3030
"ruff>=0.13.0",
3131
"pytest>=9.0.2",
3232
"pytest-cov>=7.0.0",
33+
"pytest-playwright>=0.5",
3334
]
3435

3536
[tool.setuptools.package-data]
3637
violetear = ["*.png"]
38+
39+
[tool.pytest.ini_options]
40+
markers = [
41+
"e2e: end-to-end browser tests via Playwright (slow; run separately from the fast unit gate)",
42+
]
43+
# Default pytest run excludes e2e — invoke `pytest -m e2e` or `make e2e` to run them.
44+
addopts = "-m 'not e2e'"

tests/conftest.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"""
2+
Shared test fixtures for violetear.
3+
4+
Most of the suite runs against `TestClient` (sync, in-process). The e2e tests
5+
(marked `@pytest.mark.e2e`) need a real port-bound server because Playwright
6+
drives a real Chromium that fetches HTTP/WS resources from a URL.
7+
"""
8+
9+
import importlib.util
10+
import os
11+
import socket
12+
import sys
13+
import threading
14+
import time
15+
from pathlib import Path
16+
from typing import Callable
17+
18+
import httpx
19+
import pytest
20+
import uvicorn
21+
22+
REPO_ROOT = Path(__file__).resolve().parent.parent
23+
EXAMPLES_DIR = REPO_ROOT / "examples"
24+
25+
26+
# ---- Playwright browser-launch override ------------------------------------
27+
# pytest-playwright defaults to the `chromium_headless_shell-<rev>` binary for
28+
# headless mode. On systems where that variant isn't installed (e.g. the full
29+
# `chromium-<rev>` was installed but the headless-shell wasn't), we fall back
30+
# to launching the full chromium binary in headless mode. This avoids the
31+
# common "Executable doesn't exist at .../chrome-headless-shell" error when
32+
# CI or dev has only one of the two variants on disk.
33+
34+
35+
def _find_full_chromium() -> str | None:
36+
"""Locate a usable full chromium binary under PLAYWRIGHT_BROWSERS_PATH."""
37+
root = Path(
38+
os.environ.get("PLAYWRIGHT_BROWSERS_PATH")
39+
or (Path.home() / ".cache" / "ms-playwright")
40+
)
41+
if not root.exists():
42+
return None
43+
# Pick the newest chromium-<rev>/chrome-linux64/chrome on disk.
44+
candidates = sorted(root.glob("chromium-*/chrome-linux64/chrome"), reverse=True)
45+
return str(candidates[0]) if candidates else None
46+
47+
48+
@pytest.fixture(scope="session")
49+
def browser_type_launch_args(browser_type_launch_args):
50+
"""Override pytest-playwright's launch args to use the full chromium
51+
binary instead of the headless_shell variant.
52+
53+
pytest-playwright defaults to a `chromium_headless_shell-<rev>` binary
54+
that often isn't installed (the standard `playwright install chromium`
55+
sometimes only fetches the full chromium). Pointing executable_path at
56+
the full chromium with headless launch produces equivalent behavior
57+
without the version-mismatch error.
58+
59+
Set VIOLETEAR_PLAYWRIGHT_USE_HEADLESS_SHELL=1 to opt out of the override.
60+
"""
61+
args = dict(browser_type_launch_args)
62+
if os.environ.get("VIOLETEAR_PLAYWRIGHT_USE_HEADLESS_SHELL"):
63+
return args
64+
chromium = _find_full_chromium()
65+
if chromium:
66+
args["executable_path"] = chromium
67+
return args
68+
69+
70+
def _free_port() -> int:
71+
"""Bind to port 0, get the OS-assigned port, release. Standard race-tolerant
72+
pattern — there's a tiny window between release and uvicorn binding but
73+
uvicorn errors loudly if it can't bind."""
74+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
75+
s.bind(("127.0.0.1", 0))
76+
return s.getsockname()[1]
77+
78+
79+
def _load_example(filename: str):
80+
"""Import an example module by filename, registering in sys.modules first
81+
so violetear's bundle generator can call inspect.getsource on its state
82+
classes (see issues/7.5)."""
83+
name = filename.removesuffix(".py").replace(".", "_")
84+
spec = importlib.util.spec_from_file_location(name, EXAMPLES_DIR / filename)
85+
module = importlib.util.module_from_spec(spec)
86+
sys.modules[name] = module
87+
spec.loader.exec_module(module)
88+
return module
89+
90+
91+
@pytest.fixture(scope="session", autouse=True)
92+
def _pyodide_cache_prewarm():
93+
"""Pre-warm the Pyodide local cache once per test session.
94+
95+
Without this, the first e2e test that loads a real page triggers a ~14MB
96+
download from jsDelivr — fine in dev but flaky in CI and slow on a cold
97+
runner. Calling _ensure_pyodide_cached() here populates the cache early
98+
(or no-ops if it already exists from a prior run / a CI cache restore).
99+
100+
Marked autouse so any e2e test inherits a warm cache without opting in.
101+
"""
102+
from violetear.app import _ensure_pyodide_cached
103+
104+
_ensure_pyodide_cached()
105+
106+
107+
@pytest.fixture
108+
def example_server() -> Callable[[str], str]:
109+
"""Boot one of the canonical examples on a free port via uvicorn-in-thread,
110+
yield a factory that returns the base URL. The server tears down at end of
111+
test."""
112+
servers: list[tuple[uvicorn.Server, threading.Thread]] = []
113+
114+
def _start(filename: str) -> str:
115+
port = _free_port()
116+
module = _load_example(filename)
117+
118+
config = uvicorn.Config(
119+
module.app.api,
120+
host="127.0.0.1",
121+
port=port,
122+
log_level="warning",
123+
lifespan="on",
124+
)
125+
server = uvicorn.Server(config)
126+
thread = threading.Thread(target=server.run, daemon=True)
127+
thread.start()
128+
servers.append((server, thread))
129+
130+
# Wait for the server to actually accept connections.
131+
base = f"http://127.0.0.1:{port}"
132+
deadline = time.monotonic() + 10.0
133+
while time.monotonic() < deadline:
134+
try:
135+
httpx.get(base + "/", timeout=0.4)
136+
return base
137+
except httpx.HTTPError:
138+
time.sleep(0.05)
139+
raise RuntimeError(f"Example server {filename} did not start within 10s")
140+
141+
yield _start
142+
143+
# Teardown — request graceful exit, give it a moment, then drop the threads.
144+
for server, _thread in servers:
145+
server.should_exit = True
146+
for _server, thread in servers:
147+
thread.join(timeout=3.0)

tests/test_examples_e2e.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""
2+
End-to-end tests — drive a real headless Chromium against a real uvicorn
3+
server. Each test boots one canonical example, navigates to it, and asserts on
4+
the browser-observable state after Pyodide finishes hydrating.
5+
6+
Marked `e2e` so the fast unit gate skips them. Run with:
7+
8+
uv run pytest -m e2e
9+
# or: make e2e
10+
11+
These tests catch the class of bug that compile-time bundle checks miss —
12+
e.g. a NameError when the bundle exec actually runs in Pyodide. See
13+
issues/7.6 for the motivating incident.
14+
"""
15+
16+
import pytest
17+
18+
19+
# Maximum wait for Pyodide load + bundle eval + hydration. ~5s typical on a
20+
# warm cache, more on first run. Generous to absorb CI variance.
21+
HYDRATION_TIMEOUT_MS = 45_000
22+
23+
24+
def _collect_browser_errors(page):
25+
"""Attach listeners that record any console.error / pageerror events.
26+
27+
Returns a list that the test reads after navigation. We capture both:
28+
- `console.error` (where Pyodide's PythonError lands after exec failure),
29+
- `pageerror` (uncaught JS / promise rejections).
30+
"""
31+
errors: list[str] = []
32+
page.on(
33+
"console",
34+
lambda msg: (
35+
errors.append(f"[console.{msg.type}] {msg.text}")
36+
if msg.type == "error"
37+
else None
38+
),
39+
)
40+
page.on("pageerror", lambda exc: errors.append(f"[pageerror] {exc}"))
41+
return errors
42+
43+
44+
@pytest.mark.e2e
45+
def test_03_interactive_loads_with_no_pyodide_errors(example_server, page):
46+
"""The smoke test: example 03 boots, Pyodide loads, the bundle executes,
47+
hydration completes — and no console.error / pageerror fires along the way.
48+
49+
This is the test that would have caught the `NameError: name 'store' is
50+
not defined` bug surfaced in the browser (issues/7.6) before it shipped.
51+
"""
52+
base = example_server("03_interactive.py")
53+
errors = _collect_browser_errors(page)
54+
55+
page.goto(base + "/")
56+
57+
# Hydration is complete when the bootstrap script removes the "cloak"
58+
# <style> element (see app.py:_inject_client_side). If the bundle exec
59+
# threw, this signal never arrives and we time out.
60+
page.wait_for_function(
61+
"() => document.getElementById('violetear-cloak') === null",
62+
timeout=HYDRATION_TIMEOUT_MS,
63+
)
64+
65+
# If anything blew up during init (top-level `await restore()` etc.),
66+
# PyodideError → console.error before the cloak fires. We assert silence.
67+
assert errors == [], "Browser errors after hydration:\n " + "\n ".join(errors)
68+
69+
# Cheap sanity check that the SSR-rendered values are present after
70+
# hydration. The meters input was server-rendered with value="1.0".
71+
meters_value = page.input_value('input[data-bind-value="UiState.meters"]')
72+
assert meters_value == "1.0"

0 commit comments

Comments
 (0)