|
| 1 | +"""Integration tests for browser hot module replacement.""" |
| 2 | + |
| 3 | +import json |
| 4 | +import time |
| 5 | +from collections.abc import Generator |
| 6 | +from pathlib import Path |
| 7 | + |
| 8 | +import pytest |
| 9 | +from playwright.sync_api import Page, WebSocket, expect |
| 10 | + |
| 11 | +from reflex.testing import AppHarness |
| 12 | + |
| 13 | + |
| 14 | +def HmrApp(): |
| 15 | + """Create an app with a route that remains unloaded in the browser.""" |
| 16 | + import reflex as rx |
| 17 | + |
| 18 | + def index(): |
| 19 | + return rx.text("index-v0", id="version") |
| 20 | + |
| 21 | + def unloaded(): |
| 22 | + return rx.text("unloaded-v0", id="unloaded-version") |
| 23 | + |
| 24 | + app = rx.App() |
| 25 | + app.add_page(index) |
| 26 | + app.add_page(unloaded, route="/unloaded") |
| 27 | + |
| 28 | + |
| 29 | +@pytest.fixture(scope="module") |
| 30 | +def hmr_app(tmp_path_factory) -> Generator[AppHarness, None, None]: |
| 31 | + """Run the HMR test app in development mode. |
| 32 | +
|
| 33 | + Args: |
| 34 | + tmp_path_factory: Pytest temporary path factory. |
| 35 | +
|
| 36 | + Yields: |
| 37 | + The running application harness. |
| 38 | + """ |
| 39 | + with AppHarness.create( |
| 40 | + root=tmp_path_factory.mktemp("hmr_app"), |
| 41 | + app_source=HmrApp, |
| 42 | + ) as harness: |
| 43 | + yield harness |
| 44 | + |
| 45 | + |
| 46 | +def _find_route(route_dir: Path, marker: str) -> Path: |
| 47 | + """Locate the generated route module containing a marker string. |
| 48 | +
|
| 49 | + Args: |
| 50 | + route_dir: Directory of generated route modules. |
| 51 | + marker: Source text unique to one route. |
| 52 | +
|
| 53 | + Returns: |
| 54 | + The route module containing the marker. |
| 55 | + """ |
| 56 | + matches = [path for path in route_dir.glob("*.jsx") if marker in path.read_text()] |
| 57 | + assert len(matches) == 1, f"expected one route containing {marker!r}, got {matches}" |
| 58 | + return matches[0] |
| 59 | + |
| 60 | + |
| 61 | +def _replace_once(path: Path, old: str, new: str) -> None: |
| 62 | + """Replace one occurrence in a generated route module. |
| 63 | +
|
| 64 | + Args: |
| 65 | + path: Generated route module to edit. |
| 66 | + old: Existing source text. |
| 67 | + new: Replacement source text. |
| 68 | + """ |
| 69 | + source = path.read_text() |
| 70 | + assert source.count(old) == 1 |
| 71 | + path.write_text(source.replace(old, new)) |
| 72 | + |
| 73 | + |
| 74 | +def _wait_for_hmr_manifest( |
| 75 | + page: Page, frames: list[str], route_id: str, start_index: int |
| 76 | +) -> None: |
| 77 | + """Wait until React Router receives a manifest update for a route. |
| 78 | +
|
| 79 | + Args: |
| 80 | + page: Playwright page driving the application. |
| 81 | + frames: Captured Vite websocket frames. |
| 82 | + route_id: React Router route identifier expected in the update. |
| 83 | + start_index: Ignore frames captured before this index. |
| 84 | +
|
| 85 | + Raises: |
| 86 | + AssertionError: If the expected update is not received. |
| 87 | + """ |
| 88 | + deadline = time.monotonic() + 10 |
| 89 | + scan_pos = start_index |
| 90 | + while time.monotonic() < deadline: |
| 91 | + page.wait_for_timeout(100) |
| 92 | + while scan_pos < len(frames): |
| 93 | + frame = frames[scan_pos] |
| 94 | + scan_pos += 1 |
| 95 | + if "react-router:hmr" not in frame: |
| 96 | + continue |
| 97 | + payload = json.loads(frame) |
| 98 | + if payload.get("data", {}).get("route", {}).get("id") == route_id: |
| 99 | + return |
| 100 | + msg = f"no HMR manifest update received for {route_id}" |
| 101 | + raise AssertionError(msg) |
| 102 | + |
| 103 | + |
| 104 | +def test_unloaded_route_update_does_not_wedge_hmr( |
| 105 | + hmr_app: AppHarness, page: Page |
| 106 | +) -> None: |
| 107 | + """An unopened route edit must not block a later visible route update. |
| 108 | +
|
| 109 | + Args: |
| 110 | + hmr_app: Running application harness. |
| 111 | + page: Playwright page driving the application. |
| 112 | + """ |
| 113 | + assert hmr_app.frontend_url is not None |
| 114 | + route_dir = hmr_app.app_path / ".web" / "app" / "routes" |
| 115 | + index_route = _find_route(route_dir, "index-v0") |
| 116 | + unloaded_route = _find_route(route_dir, "unloaded-v0") |
| 117 | + # React Router route ids are the app-relative module path without extension. |
| 118 | + unloaded_route_id = f"routes/{unloaded_route.stem}" |
| 119 | + frames: list[str] = [] |
| 120 | + |
| 121 | + def capture_websocket(websocket: WebSocket) -> None: |
| 122 | + def capture_frame(frame: bytes | str) -> None: |
| 123 | + frames.append(frame.decode() if isinstance(frame, bytes) else frame) |
| 124 | + |
| 125 | + websocket.on("framereceived", capture_frame) |
| 126 | + |
| 127 | + page.on("websocket", capture_websocket) |
| 128 | + page.goto(hmr_app.frontend_url) |
| 129 | + expect(page.locator("#version")).to_have_text("index-v0") |
| 130 | + |
| 131 | + frame_index = len(frames) |
| 132 | + _replace_once(unloaded_route, "unloaded-v0", "unloaded-v1") |
| 133 | + _wait_for_hmr_manifest(page, frames, unloaded_route_id, frame_index) |
| 134 | + |
| 135 | + _replace_once(index_route, "index-v0", "index-v1") |
| 136 | + expect(page.locator("#version")).to_have_text("index-v1", timeout=10_000) |
| 137 | + |
| 138 | + page.goto(f"{hmr_app.frontend_url.rstrip('/')}/unloaded") |
| 139 | + expect(page.locator("#unloaded-version")).to_have_text("unloaded-v1") |
0 commit comments