From 2a4ed923b915be865996ca6a29efa017d39447e0 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 11 Dec 2025 21:27:44 -0800 Subject: [PATCH 01/12] Improve test runner speed --- pyproject.toml | 3 +- src/reactpy/testing/display.py | 66 +-- tests/conftest.py | 24 +- tests/test_asgi/test_middleware.py | 16 +- tests/test_asgi/test_pyscript.py | 18 +- tests/test_asgi/test_standalone.py | 6 +- tests/test_client.py | 20 +- tests/test_pyscript/test_components.py | 6 +- tests/test_reactjs/__init__.py | 91 ---- tests/test_reactjs/test_modules.py | 140 ++--- tests/test_reactjs/test_modules_from_npm.py | 565 ++++++++++---------- tests/test_testing.py | 4 +- tests/test_web/test_module.py | 4 +- 13 files changed, 418 insertions(+), 545 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5ce61e84a..debe05472 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -115,7 +115,8 @@ filterwarnings = """ testpaths = "tests" xfail_strict = true asyncio_mode = "auto" -asyncio_default_fixture_loop_scope = "function" +asyncio_default_fixture_loop_scope = "session" +asyncio_default_test_loop_scope = "session" log_cli_level = "INFO" ####################################### diff --git a/src/reactpy/testing/display.py b/src/reactpy/testing/display.py index a06258783..f1f2a33d4 100644 --- a/src/reactpy/testing/display.py +++ b/src/reactpy/testing/display.py @@ -4,35 +4,33 @@ from types import TracebackType from typing import Any -from playwright.async_api import ( - Browser, - BrowserContext, - Page, - async_playwright, -) +from playwright.async_api import Browser, Page, async_playwright from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT from reactpy.testing.backend import BackendFixture +from reactpy.testing.common import GITHUB_ACTIONS from reactpy.types import RootComponentConstructor class DisplayFixture: """A fixture for running web-based tests using ``playwright``""" - _exit_stack: AsyncExitStack + page: Page + browser_is_external: bool = False + backend_is_external: bool = False def __init__( self, backend: BackendFixture | None = None, - driver: Browser | BrowserContext | Page | None = None, + browser: Browser | None = None, ) -> None: - if backend is not None: + if backend: + self.backend_is_external = True self.backend = backend - if driver is not None: - if isinstance(driver, Page): - self.page = driver - else: - self._browser = driver + + if browser: + self.browser_is_external = True + self.browser = browser async def show( self, @@ -42,29 +40,32 @@ async def show( await self.goto("/") async def goto(self, path: str, query: Any | None = None) -> None: + await self.configure_page() await self.page.goto(self.backend.url(path, query)) async def __aenter__(self) -> DisplayFixture: - es = self._exit_stack = AsyncExitStack() - - browser: Browser | BrowserContext - if not hasattr(self, "page"): - if not hasattr(self, "_browser"): - pw = await es.enter_async_context(async_playwright()) - browser = await pw.chromium.launch() - else: - browser = self._browser - self.page = await browser.new_page() - - self.page.set_default_timeout(REACTPY_TESTS_DEFAULT_TIMEOUT.current * 1000) - self.page.on("console", lambda msg: print(f"BROWSER CONSOLE: {msg.text}")) # noqa: T201 - self.page.on("pageerror", lambda exc: print(f"BROWSER ERROR: {exc}")) # noqa: T201 + self.browser_exit_stack = AsyncExitStack() + self.backend_exit_stack = AsyncExitStack() + + if not hasattr(self, "browser"): + pw = await self.browser_exit_stack.enter_async_context(async_playwright()) + self.browser = await pw.chromium.launch(headless=GITHUB_ACTIONS) + await self.configure_page() + if not hasattr(self, "backend"): # nocov self.backend = BackendFixture() - await es.enter_async_context(self.backend) + await self.backend_exit_stack.enter_async_context(self.backend) return self + async def configure_page(self) -> None: + """Hook for configuring the page before use.""" + if getattr(self, "page", None) is None: + self.page = await self.browser.new_page() + self.page.set_default_timeout(REACTPY_TESTS_DEFAULT_TIMEOUT.current * 1000) + self.page.on("console", lambda msg: print(f"BROWSER CONSOLE: {msg.text}")) # noqa: T201 + self.page.on("pageerror", lambda exc: print(f"BROWSER ERROR: {exc}")) # noqa: T201 + async def __aexit__( self, exc_type: type[BaseException] | None, @@ -72,4 +73,9 @@ async def __aexit__( traceback: TracebackType | None, ) -> None: self.backend.mount(None) - await self._exit_stack.aclose() + if getattr(self, "page", None) is not None: + await self.page.close() + if not self.browser_is_external: + await self.browser_exit_stack.aclose() + if not self.backend_is_external: + await self.backend_exit_stack.aclose() diff --git a/tests/conftest.py b/tests/conftest.py index 8531f9874..8827a7593 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,13 +5,11 @@ import subprocess import pytest -from _pytest.config import Config from _pytest.config.argparsing import Parser from reactpy.config import ( REACTPY_ASYNC_RENDERING, REACTPY_DEBUG, - REACTPY_TESTS_DEFAULT_TIMEOUT, ) from reactpy.testing import ( BackendFixture, @@ -54,30 +52,20 @@ def rebuild(): subprocess.run(["hatch", "build", "-t", "wheel"], check=True, env=env) # noqa: S607 -@pytest.fixture -async def display(server, page): - async with DisplayFixture(server, page) as display: +@pytest.fixture(scope="session") +async def display(server, browser): + async with DisplayFixture(backend=server, browser=browser) as display: yield display -@pytest.fixture +@pytest.fixture(scope="session") async def server(): async with BackendFixture() as server: yield server -@pytest.fixture -async def page(browser): - pg = await browser.new_page() - pg.set_default_timeout(REACTPY_TESTS_DEFAULT_TIMEOUT.current * 1000) - try: - yield pg - finally: - await pg.close() - - -@pytest.fixture -async def browser(pytestconfig: Config): +@pytest.fixture(scope="session") +async def browser(pytestconfig: pytest.Config): from playwright.async_api import async_playwright async with async_playwright() as pw: diff --git a/tests/test_asgi/test_middleware.py b/tests/test_asgi/test_middleware.py index 476187072..81d7dab88 100644 --- a/tests/test_asgi/test_middleware.py +++ b/tests/test_asgi/test_middleware.py @@ -16,8 +16,8 @@ from reactpy.testing import BackendFixture, DisplayFixture -@pytest.fixture() -async def display(page): +@pytest.fixture(scope="module") +async def display(browser): """Override for the display fixture that uses ReactPyMiddleware.""" templates = Jinja2Templates( env=JinjaEnvironment( @@ -32,7 +32,7 @@ async def homepage(request): app = Starlette(routes=[Route("/", homepage)]) async with BackendFixture(app) as server: - async with DisplayFixture(backend=server, driver=page) as new_display: + async with DisplayFixture(backend=server, browser=browser) as new_display: yield new_display @@ -56,7 +56,7 @@ async def app(scope, receive, send): ReactPyMiddleware(app, root_components=["abc"], web_modules_dir=Path("invalid")) -async def test_unregistered_root_component(): +async def test_unregistered_root_component(browser): templates = Jinja2Templates( env=JinjaEnvironment( loader=JinjaFileSystemLoader("tests/templates"), @@ -75,7 +75,7 @@ def Stub(): app = ReactPyMiddleware(app, root_components=["tests.sample.SampleApp"]) async with BackendFixture(app) as server: - async with DisplayFixture(backend=server) as new_display: + async with DisplayFixture(backend=server, browser=browser) as new_display: await new_display.show(Stub) # Wait for the log record to be populated @@ -106,7 +106,7 @@ def Hello(): await display.page.wait_for_selector("#hello") -async def test_static_file_not_found(page): +async def test_static_file_not_found(): async def app(scope, receive, send): ... app = ReactPyMiddleware(app, []) @@ -119,7 +119,7 @@ async def app(scope, receive, send): ... assert response.status_code == 404 -async def test_templatetag_bad_kwargs(page, caplog): +async def test_templatetag_bad_kwargs(caplog, browser): """Override for the display fixture that uses ReactPyMiddleware.""" templates = Jinja2Templates( env=JinjaEnvironment( @@ -134,7 +134,7 @@ async def homepage(request): app = Starlette(routes=[Route("/", homepage)]) async with BackendFixture(app) as server: - async with DisplayFixture(backend=server, driver=page) as new_display: + async with DisplayFixture(backend=server, browser=browser) as new_display: await new_display.goto("/") # This test could be improved by actually checking if `bad kwargs` error message is shown in diff --git a/tests/test_asgi/test_pyscript.py b/tests/test_asgi/test_pyscript.py index 0ddd05485..3c0a7e0f7 100644 --- a/tests/test_asgi/test_pyscript.py +++ b/tests/test_asgi/test_pyscript.py @@ -13,8 +13,8 @@ from reactpy.testing import BackendFixture, DisplayFixture -@pytest.fixture() -async def display(page): +@pytest.fixture(scope="module") +async def display(browser): """Override for the display fixture that uses ReactPyMiddleware.""" app = ReactPyCsr( Path(__file__).parent / "pyscript_components" / "root.py", @@ -22,12 +22,12 @@ async def display(page): ) async with BackendFixture(app) as server: - async with DisplayFixture(backend=server, driver=page) as new_display: + async with DisplayFixture(backend=server, browser=browser) as new_display: yield new_display -@pytest.fixture() -async def multi_file_display(page): +@pytest.fixture(scope="module") +async def multi_file_display(browser): """Override for the display fixture that uses ReactPyMiddleware.""" app = ReactPyCsr( Path(__file__).parent / "pyscript_components" / "load_first.py", @@ -36,12 +36,12 @@ async def multi_file_display(page): ) async with BackendFixture(app) as server: - async with DisplayFixture(backend=server, driver=page) as new_display: + async with DisplayFixture(backend=server, browser=browser) as new_display: yield new_display -@pytest.fixture() -async def jinja_display(page): +@pytest.fixture(scope="module") +async def jinja_display(browser): """Override for the display fixture that uses ReactPyMiddleware.""" templates = Jinja2Templates( env=JinjaEnvironment( @@ -56,7 +56,7 @@ async def homepage(request): app = Starlette(routes=[Route("/", homepage)]) async with BackendFixture(app) as server: - async with DisplayFixture(backend=server, driver=page) as new_display: + async with DisplayFixture(backend=server, browser=browser) as new_display: yield new_display diff --git a/tests/test_asgi/test_standalone.py b/tests/test_asgi/test_standalone.py index c4a42dcf3..2d4baa544 100644 --- a/tests/test_asgi/test_standalone.py +++ b/tests/test_asgi/test_standalone.py @@ -123,7 +123,7 @@ def ShowRoute(): assert hook_val.current is not None -async def test_customized_head(page): +async def test_customized_head(browser): custom_title = "Custom Title for ReactPy" @reactpy.component @@ -133,12 +133,12 @@ def sample(): app = ReactPy(sample, html_head=html.head(html.title(custom_title))) async with BackendFixture(app) as server: - async with DisplayFixture(backend=server, driver=page) as new_display: + async with DisplayFixture(backend=server, browser=browser) as new_display: await new_display.show(sample) assert (await new_display.page.title()) == custom_title -async def test_head_request(page): +async def test_head_request(): @reactpy.component def sample(): return html.h1("Hello World") diff --git a/tests/test_client.py b/tests/test_client.py index afe577e38..e05286f74 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,8 +1,6 @@ import asyncio from pathlib import Path -from playwright.async_api import Page - import reactpy from reactpy.testing import BackendFixture, DisplayFixture, poll from tests.tooling.common import DEFAULT_TYPE_DELAY @@ -11,9 +9,7 @@ JS_DIR = Path(__file__).parent / "js" -async def test_automatic_reconnect( - display: DisplayFixture, page: Page, server: BackendFixture -): +async def test_automatic_reconnect(display: DisplayFixture, server: BackendFixture): @reactpy.component def SomeComponent(): count, incr_count = use_counter(0) @@ -26,35 +22,35 @@ def SomeComponent(): async def get_count(): # need to refetch element because may unmount on reconnect - count = await page.wait_for_selector("#count") + count = await display.page.wait_for_selector("#count") return await count.get_attribute("data-count") await display.show(SomeComponent) await poll(get_count).until_equals("0") - incr = await page.wait_for_selector("#incr") + incr = await display.page.wait_for_selector("#incr") await incr.click() await poll(get_count).until_equals("1") - incr = await page.wait_for_selector("#incr") + incr = await display.page.wait_for_selector("#incr") await incr.click() await poll(get_count).until_equals("2") - incr = await page.wait_for_selector("#incr") + incr = await display.page.wait_for_selector("#incr") await incr.click() await server.restart() await poll(get_count).until_equals("0") - incr = await page.wait_for_selector("#incr") + incr = await display.page.wait_for_selector("#incr") await incr.click() await poll(get_count).until_equals("1") - incr = await page.wait_for_selector("#incr") + incr = await display.page.wait_for_selector("#incr") await incr.click() await poll(get_count).until_equals("2") - incr = await page.wait_for_selector("#incr") + incr = await display.page.wait_for_selector("#incr") await incr.click() diff --git a/tests/test_pyscript/test_components.py b/tests/test_pyscript/test_components.py index 51fe59f50..6d1080a57 100644 --- a/tests/test_pyscript/test_components.py +++ b/tests/test_pyscript/test_components.py @@ -9,13 +9,13 @@ from reactpy.testing.backend import root_hotswap_component -@pytest.fixture() -async def display(page): +@pytest.fixture(scope="module") +async def display(browser): """Override for the display fixture that uses ReactPyMiddleware.""" app = ReactPy(root_hotswap_component, pyscript_setup=True) async with BackendFixture(app) as server: - async with DisplayFixture(backend=server, driver=page) as new_display: + async with DisplayFixture(backend=server, browser=browser) as new_display: yield new_display diff --git a/tests/test_reactjs/__init__.py b/tests/test_reactjs/__init__.py index ad1f106fd..e69de29bb 100644 --- a/tests/test_reactjs/__init__.py +++ b/tests/test_reactjs/__init__.py @@ -1,91 +0,0 @@ -import pytest - -import reactpy -from reactpy import html -from reactpy.reactjs import component_from_string, import_reactjs -from reactpy.testing import BackendFixture, DisplayFixture - - -@pytest.mark.anyio -async def test_nested_client_side_components(): - async with BackendFixture(html_head=html.head(import_reactjs())) as backend: - async with DisplayFixture(backend=backend) as display: - # Module A - ComponentA = component_from_string( - """ - import React from "react"; - export function ComponentA({ children }) { - return React.createElement("div", { id: "component-a" }, children); - } - """, - "ComponentA", - name="module-a", - ) - - # Module B - ComponentB = component_from_string( - """ - import React from "react"; - export function ComponentB({ children }) { - return React.createElement("div", { id: "component-b" }, children); - } - """, - "ComponentB", - name="module-b", - ) - - @reactpy.component - def App(): - return ComponentA( - ComponentB( - reactpy.html.div({"id": "server-side"}, "Server Side Content") - ) - ) - - await display.show(App) - - # Check that all components are rendered - await display.page.wait_for_selector("#component-a") - await display.page.wait_for_selector("#component-b") - await display.page.wait_for_selector("#server-side") - - -@pytest.mark.anyio -async def test_interleaved_client_server_components(): - async with BackendFixture(html_head=html.head(import_reactjs())) as backend: - async with DisplayFixture(backend=backend) as display: - # Module C - ComponentC = component_from_string( - """ - import React from "react"; - export function ComponentC({ children }) { - return React.createElement("div", { id: "component-c", className: "component-c" }, children); - } - """, - "ComponentC", - name="module-c", - ) - - @reactpy.component - def App(): - return reactpy.html.div( - {"id": "root-server"}, - ComponentC( - reactpy.html.div( - {"id": "nested-server"}, - ComponentC( - reactpy.html.span({"id": "deep-server"}, "Deep Content") - ), - ) - ), - ) - - await display.show(App) - - await display.page.wait_for_selector("#root-server") - await display.page.wait_for_selector(".component-c") - await display.page.wait_for_selector("#nested-server") - # We need to check that there are two component-c elements - elements = await display.page.query_selector_all(".component-c") - assert len(elements) == 2 - await display.page.wait_for_selector("#deep-server") diff --git a/tests/test_reactjs/test_modules.py b/tests/test_reactjs/test_modules.py index ad1f106fd..5cccfded9 100644 --- a/tests/test_reactjs/test_modules.py +++ b/tests/test_reactjs/test_modules.py @@ -6,86 +6,88 @@ from reactpy.testing import BackendFixture, DisplayFixture -@pytest.mark.anyio -async def test_nested_client_side_components(): +@pytest.fixture(scope="module") +async def display(browser): + """Override for the display fixture that includes ReactJS.""" async with BackendFixture(html_head=html.head(import_reactjs())) as backend: - async with DisplayFixture(backend=backend) as display: - # Module A - ComponentA = component_from_string( - """ - import React from "react"; - export function ComponentA({ children }) { - return React.createElement("div", { id: "component-a" }, children); - } - """, - "ComponentA", - name="module-a", - ) + async with DisplayFixture(backend=backend, browser=browser) as new_display: + yield new_display - # Module B - ComponentB = component_from_string( - """ - import React from "react"; - export function ComponentB({ children }) { - return React.createElement("div", { id: "component-b" }, children); - } - """, - "ComponentB", - name="module-b", - ) - @reactpy.component - def App(): - return ComponentA( - ComponentB( - reactpy.html.div({"id": "server-side"}, "Server Side Content") - ) - ) +@pytest.mark.anyio +async def test_nested_client_side_components(display: DisplayFixture): + # Module A + ComponentA = component_from_string( + """ + import React from "react"; + export function ComponentA({ children }) { + return React.createElement("div", { id: "component-a" }, children); + } + """, + "ComponentA", + name="module-a", + ) - await display.show(App) + # Module B + ComponentB = component_from_string( + """ + import React from "react"; + export function ComponentB({ children }) { + return React.createElement("div", { id: "component-b" }, children); + } + """, + "ComponentB", + name="module-b", + ) - # Check that all components are rendered - await display.page.wait_for_selector("#component-a") - await display.page.wait_for_selector("#component-b") - await display.page.wait_for_selector("#server-side") + @reactpy.component + def App(): + return ComponentA( + ComponentB(reactpy.html.div({"id": "server-side"}, "Server Side Content")) + ) + + await display.show(App) + + # Check that all components are rendered + await display.page.wait_for_selector("#component-a") + await display.page.wait_for_selector("#component-b") + await display.page.wait_for_selector("#server-side") @pytest.mark.anyio -async def test_interleaved_client_server_components(): - async with BackendFixture(html_head=html.head(import_reactjs())) as backend: - async with DisplayFixture(backend=backend) as display: - # Module C - ComponentC = component_from_string( - """ - import React from "react"; - export function ComponentC({ children }) { - return React.createElement("div", { id: "component-c", className: "component-c" }, children); - } - """, - "ComponentC", - name="module-c", - ) +async def test_interleaved_client_server_components(display: DisplayFixture): + # Module C + ComponentC = component_from_string( + """ + import React from "react"; + export function ComponentC({ children }) { + return React.createElement("div", { id: "component-c", className: "component-c" }, children); + } + """, + "ComponentC", + name="module-c", + ) - @reactpy.component - def App(): - return reactpy.html.div( - {"id": "root-server"}, + @reactpy.component + def App(): + return reactpy.html.div( + {"id": "root-server"}, + ComponentC( + reactpy.html.div( + {"id": "nested-server"}, ComponentC( - reactpy.html.div( - {"id": "nested-server"}, - ComponentC( - reactpy.html.span({"id": "deep-server"}, "Deep Content") - ), - ) + reactpy.html.span({"id": "deep-server"}, "Deep Content") ), ) + ), + ) - await display.show(App) + await display.show(App) - await display.page.wait_for_selector("#root-server") - await display.page.wait_for_selector(".component-c") - await display.page.wait_for_selector("#nested-server") - # We need to check that there are two component-c elements - elements = await display.page.query_selector_all(".component-c") - assert len(elements) == 2 - await display.page.wait_for_selector("#deep-server") + await display.page.wait_for_selector("#root-server") + await display.page.wait_for_selector(".component-c") + await display.page.wait_for_selector("#nested-server") + # We need to check that there are two component-c elements + elements = await display.page.query_selector_all(".component-c") + assert len(elements) == 2 + await display.page.wait_for_selector("#deep-server") diff --git a/tests/test_reactjs/test_modules_from_npm.py b/tests/test_reactjs/test_modules_from_npm.py index 61c51f515..9bd4da575 100644 --- a/tests/test_reactjs/test_modules_from_npm.py +++ b/tests/test_reactjs/test_modules_from_npm.py @@ -6,387 +6,358 @@ from reactpy.testing import BackendFixture, DisplayFixture -@pytest.mark.anyio -@pytest.mark.flaky(reruns=5, reruns_delay=2) -async def test_component_from_npm_react_bootstrap(): +@pytest.fixture(scope="module") +async def display(browser): + """Override for the display fixture that includes ReactJS imports.""" async with BackendFixture(html_head=html.head(import_reactjs())) as backend: - async with DisplayFixture(backend=backend) as display: - Button = component_from_npm("react-bootstrap", "Button", version="2.10.2") + async with DisplayFixture(backend=backend, browser=browser) as new_display: + yield new_display + - @reactpy.component - def App(): - return Button({"variant": "primary", "id": "test-button"}, "Click me") +@pytest.mark.flaky(reruns=5, reruns_delay=2) +async def test_component_from_npm_react_bootstrap(display: DisplayFixture): + Button = component_from_npm("react-bootstrap", "Button", version="2.10.2") + + @reactpy.component + def App(): + return Button({"variant": "primary", "id": "test-button"}, "Click me") - await display.show(App) + await display.show(App) - button = await display.page.wait_for_selector("#test-button") - assert await button.inner_text() == "Click me" + button = await display.page.wait_for_selector("#test-button") + assert await button.inner_text() == "Click me" - # Check if it has the correct class for primary variant - # React Bootstrap buttons usually have 'btn' and 'btn-primary' classes - classes = await button.get_attribute("class") - assert "btn" in classes - assert "btn-primary" in classes + # Check if it has the correct class for primary variant + # React Bootstrap buttons usually have 'btn' and 'btn-primary' classes + classes = await button.get_attribute("class") + assert "btn" in classes + assert "btn-primary" in classes @pytest.mark.anyio @pytest.mark.flaky(reruns=5, reruns_delay=2) -async def test_component_from_npm_material_ui(): - async with BackendFixture(html_head=html.head(import_reactjs())) as backend: - async with DisplayFixture(backend=backend) as display: - Button = component_from_npm("@mui/material", "Button") +async def test_component_from_npm_material_ui(display: DisplayFixture): + Button = component_from_npm("@mui/material", "Button") - @reactpy.component - def App(): - return Button({"variant": "contained", "id": "test-button"}, "Click me") + @reactpy.component + def App(): + return Button({"variant": "contained", "id": "test-button"}, "Click me") - await display.show(App) - button = await display.page.wait_for_selector("#test-button") - # Material UI transforms text to uppercase by default - assert await button.inner_text() == "CLICK ME" - classes = await button.get_attribute("class") - assert "MuiButton-root" in classes + await display.show(App) + button = await display.page.wait_for_selector("#test-button") + # Material UI transforms text to uppercase by default + assert await button.inner_text() == "CLICK ME" + classes = await button.get_attribute("class") + assert "MuiButton-root" in classes @pytest.mark.anyio @pytest.mark.flaky(reruns=5, reruns_delay=2) -async def test_component_from_npm_antd(): - async with BackendFixture(html_head=html.head(import_reactjs())) as backend: - async with DisplayFixture(backend=backend) as display: - # Try antd v4 which might be more stable with esm.sh - Button = component_from_npm("antd", "Button", version="4.24.15") +async def test_component_from_npm_antd(display: DisplayFixture): + # Try antd v4 which might be more stable with esm.sh + Button = component_from_npm("antd", "Button", version="4.24.15") - @reactpy.component - def App(): - return Button({"type": "primary", "id": "test-button"}, "Click me") + @reactpy.component + def App(): + return Button({"type": "primary", "id": "test-button"}, "Click me") - await display.show(App) - button = await display.page.wait_for_selector("#test-button") - assert "Click me" in await button.inner_text() - classes = await button.get_attribute("class") - assert "ant-btn" in classes + await display.show(App) + button = await display.page.wait_for_selector("#test-button") + assert "Click me" in await button.inner_text() + classes = await button.get_attribute("class") + assert "ant-btn" in classes @pytest.mark.anyio @pytest.mark.flaky(reruns=5, reruns_delay=2) -async def test_component_from_npm_chakra_ui(): - async with BackendFixture(html_head=html.head(import_reactjs())) as backend: - async with DisplayFixture(backend=backend) as display: - ChakraProvider, Button = component_from_npm( - "@chakra-ui/react", ["ChakraProvider", "Button"], version="2.8.2" - ) +async def test_component_from_npm_chakra_ui(display: DisplayFixture): + ChakraProvider, Button = component_from_npm( + "@chakra-ui/react", ["ChakraProvider", "Button"], version="2.8.2" + ) - @reactpy.component - def App(): - return ChakraProvider( - Button({"colorScheme": "blue", "id": "test-button"}, "Click me") - ) + @reactpy.component + def App(): + return ChakraProvider( + Button({"colorScheme": "blue", "id": "test-button"}, "Click me") + ) - await display.show(App) - button = await display.page.wait_for_selector("#test-button") - assert await button.inner_text() == "Click me" - classes = await button.get_attribute("class") - assert "chakra-button" in classes + await display.show(App) + button = await display.page.wait_for_selector("#test-button") + assert await button.inner_text() == "Click me" + classes = await button.get_attribute("class") + assert "chakra-button" in classes @pytest.mark.anyio @pytest.mark.flaky(reruns=5, reruns_delay=2) -async def test_component_from_npm_semantic_ui_react(): - async with BackendFixture(html_head=html.head(import_reactjs())) as backend: - async with DisplayFixture(backend=backend) as display: - Button = component_from_npm("semantic-ui-react", "Button") +async def test_component_from_npm_semantic_ui_react(display: DisplayFixture): + Button = component_from_npm("semantic-ui-react", "Button") - @reactpy.component - def App(): - return Button({"primary": True, "id": "test-button"}, "Click me") + @reactpy.component + def App(): + return Button({"primary": True, "id": "test-button"}, "Click me") - await display.show(App) - button = await display.page.wait_for_selector("#test-button") - assert await button.inner_text() == "Click me" - classes = await button.get_attribute("class") - assert "ui" in classes - assert "button" in classes + await display.show(App) + button = await display.page.wait_for_selector("#test-button") + assert await button.inner_text() == "Click me" + classes = await button.get_attribute("class") + assert "ui" in classes + assert "button" in classes @pytest.mark.anyio @pytest.mark.flaky(reruns=5, reruns_delay=2) -async def test_component_from_npm_mantine(): - async with BackendFixture(html_head=html.head(import_reactjs())) as backend: - async with DisplayFixture(backend=backend) as display: - MantineProvider, Button = component_from_npm( - "@mantine/core", ["MantineProvider", "Button"], version="7.3.0" - ) +async def test_component_from_npm_mantine(display: DisplayFixture): + MantineProvider, Button = component_from_npm( + "@mantine/core", ["MantineProvider", "Button"], version="7.3.0" + ) - @reactpy.component - def App(): - return MantineProvider(Button({"id": "test-button"}, "Click me")) + @reactpy.component + def App(): + return MantineProvider(Button({"id": "test-button"}, "Click me")) - await display.show(App) - button = await display.page.wait_for_selector("#test-button") - assert await button.inner_text() == "Click me" - classes = await button.get_attribute("class") - assert "mantine-Button-root" in classes + await display.show(App) + button = await display.page.wait_for_selector("#test-button") + assert await button.inner_text() == "Click me" + classes = await button.get_attribute("class") + assert "mantine-Button-root" in classes @pytest.mark.anyio @pytest.mark.flaky(reruns=5, reruns_delay=2) -async def test_component_from_npm_fluent_ui(): - async with BackendFixture(html_head=html.head(import_reactjs())) as backend: - async with DisplayFixture(backend=backend) as display: - PrimaryButton = component_from_npm("@fluentui/react", "PrimaryButton") +async def test_component_from_npm_fluent_ui(display: DisplayFixture): + PrimaryButton = component_from_npm("@fluentui/react", "PrimaryButton") - @reactpy.component - def App(): - return PrimaryButton({"id": "test-button"}, "Click me") + @reactpy.component + def App(): + return PrimaryButton({"id": "test-button"}, "Click me") - await display.show(App) - button = await display.page.wait_for_selector("#test-button") - assert await button.inner_text() == "Click me" - classes = await button.get_attribute("class") - assert "ms-Button" in classes + await display.show(App) + button = await display.page.wait_for_selector("#test-button") + assert await button.inner_text() == "Click me" + classes = await button.get_attribute("class") + assert "ms-Button" in classes @pytest.mark.anyio @pytest.mark.flaky(reruns=5, reruns_delay=2) -async def test_component_from_npm_blueprint(): - async with BackendFixture(html_head=html.head(import_reactjs())) as backend: - async with DisplayFixture(backend=backend) as display: - Button = component_from_npm("@blueprintjs/core", "Button") +async def test_component_from_npm_blueprint(display: DisplayFixture): + Button = component_from_npm("@blueprintjs/core", "Button") - @reactpy.component - def App(): - return Button({"intent": "primary", "id": "test-button"}, "Click me") + @reactpy.component + def App(): + return Button({"intent": "primary", "id": "test-button"}, "Click me") - await display.show(App) - button = await display.page.wait_for_selector("#test-button") - assert await button.inner_text() == "Click me" - classes = await button.get_attribute("class") - assert any(c.startswith("bp") and "button" in c for c in classes.split()) + await display.show(App) + button = await display.page.wait_for_selector("#test-button") + assert await button.inner_text() == "Click me" + classes = await button.get_attribute("class") + assert any(c.startswith("bp") and "button" in c for c in classes.split()) @pytest.mark.anyio @pytest.mark.flaky(reruns=5, reruns_delay=2) -async def test_component_from_npm_grommet(): - async with BackendFixture(html_head=html.head(import_reactjs())) as backend: - async with DisplayFixture(backend=backend) as display: - Grommet, Button = component_from_npm("grommet", ["Grommet", "Button"]) +async def test_component_from_npm_grommet(display: DisplayFixture): + Grommet, Button = component_from_npm("grommet", ["Grommet", "Button"]) - @reactpy.component - def App(): - return Grommet( - Button({"primary": True, "label": "Click me", "id": "test-button"}) - ) + @reactpy.component + def App(): + return Grommet( + Button({"primary": True, "label": "Click me", "id": "test-button"}) + ) - await display.show(App) - button = await display.page.wait_for_selector("#test-button") - assert await button.inner_text() == "Click me" + await display.show(App) + button = await display.page.wait_for_selector("#test-button") + assert await button.inner_text() == "Click me" @pytest.mark.anyio @pytest.mark.flaky(reruns=5, reruns_delay=2) -async def test_component_from_npm_evergreen(): - async with BackendFixture(html_head=html.head(import_reactjs())) as backend: - async with DisplayFixture(backend=backend) as display: - Button = component_from_npm("evergreen-ui", "Button") +async def test_component_from_npm_evergreen(display: DisplayFixture): + Button = component_from_npm("evergreen-ui", "Button") - @reactpy.component - def App(): - return Button( - {"appearance": "primary", "id": "test-button"}, "Click me" - ) + @reactpy.component + def App(): + return Button({"appearance": "primary", "id": "test-button"}, "Click me") - await display.show(App) - button = await display.page.wait_for_selector("#test-button") - assert await button.inner_text() == "Click me" + await display.show(App) + button = await display.page.wait_for_selector("#test-button") + assert await button.inner_text() == "Click me" @pytest.mark.anyio @pytest.mark.flaky(reruns=5, reruns_delay=2) -async def test_component_from_npm_react_spinners(): - async with BackendFixture(html_head=html.head(import_reactjs())) as backend: - async with DisplayFixture(backend=backend) as display: - ClipLoader = component_from_npm("react-spinners", "ClipLoader") - - @reactpy.component - def App(): - return ClipLoader( - { - "color": "red", - "loading": True, - "size": 150, - "data-testid": "loader", - } - ) - - await display.show(App) - # react-spinners renders a span with the loader - # We can check if it exists. It might not have an ID we can easily set on the root if it doesn't forward props well, - # but let's try wrapping it. - loader = await display.page.wait_for_selector("span[data-testid='loader']") - assert await loader.is_visible() +async def test_component_from_npm_react_spinners(display: DisplayFixture): + ClipLoader = component_from_npm("react-spinners", "ClipLoader") + + @reactpy.component + def App(): + return ClipLoader( + { + "color": "red", + "loading": True, + "size": 150, + "data-testid": "loader", + } + ) + + await display.show(App) + # react-spinners renders a span with the loader + # We can check if it exists. It might not have an ID we can easily set on the root if it doesn't forward props well, + # but let's try wrapping it. + loader = await display.page.wait_for_selector("span[data-testid='loader']") + assert await loader.is_visible() @pytest.mark.anyio @pytest.mark.flaky(reruns=5, reruns_delay=2) -async def test_nested_npm_components(): - async with BackendFixture(html_head=html.head(import_reactjs())) as backend: - async with DisplayFixture(backend=backend) as display: - # Use Chakra UI Provider and Box, and nest a React Bootstrap Button inside - ChakraProvider, Box = component_from_npm( - "@chakra-ui/react", ["ChakraProvider", "Box"], version="2.8.2" - ) - BootstrapButton = component_from_npm( - "react-bootstrap", "Button", version="2.10.2" +async def test_nested_npm_components(display: DisplayFixture): + # Use Chakra UI Provider and Box, and nest a React Bootstrap Button inside + ChakraProvider, Box = component_from_npm( + "@chakra-ui/react", ["ChakraProvider", "Box"], version="2.8.2" + ) + BootstrapButton = component_from_npm("react-bootstrap", "Button", version="2.10.2") + + @reactpy.component + def App(): + return ChakraProvider( + Box( + { + "id": "chakra-box", + "p": 4, + "color": "white", + "bg": "blue.500", + }, + BootstrapButton( + {"variant": "light", "id": "bootstrap-button"}, + "Nested Button", + ), ) + ) - @reactpy.component - def App(): - return ChakraProvider( - Box( - { - "id": "chakra-box", - "p": 4, - "color": "white", - "bg": "blue.500", - }, - BootstrapButton( - {"variant": "light", "id": "bootstrap-button"}, - "Nested Button", - ), - ) - ) + await display.show(App) - await display.show(App) + box = await display.page.wait_for_selector("#chakra-box") + assert await box.is_visible() - box = await display.page.wait_for_selector("#chakra-box") - assert await box.is_visible() - - button = await display.page.wait_for_selector("#bootstrap-button") - assert await button.inner_text() == "Nested Button" - classes = await button.get_attribute("class") - assert "btn" in classes + button = await display.page.wait_for_selector("#bootstrap-button") + assert await button.inner_text() == "Nested Button" + classes = await button.get_attribute("class") + assert "btn" in classes @pytest.mark.anyio @pytest.mark.flaky(reruns=5, reruns_delay=2) -async def test_interleaved_npm_and_server_components(): - async with BackendFixture(html_head=html.head(import_reactjs())) as backend: - async with DisplayFixture(backend=backend) as display: - Card = component_from_npm("antd", "Card", version="4.24.15") - Button = component_from_npm("@mui/material", "Button") - - @reactpy.component - def App(): - return Card( - {"title": "Antd Card", "id": "antd-card"}, - html.div( - { - "id": "server-div", - "style": {"padding": "10px", "border": "1px solid red"}, - }, - "Server Side Div", - Button( - {"variant": "contained", "id": "mui-button"}, "MUI Button" - ), - ), - ) +async def test_interleaved_npm_and_server_components(display: DisplayFixture): + Card = component_from_npm("antd", "Card", version="4.24.15") + Button = component_from_npm("@mui/material", "Button") + + @reactpy.component + def App(): + return Card( + {"title": "Antd Card", "id": "antd-card"}, + html.div( + { + "id": "server-div", + "style": {"padding": "10px", "border": "1px solid red"}, + }, + "Server Side Div", + Button({"variant": "contained", "id": "mui-button"}, "MUI Button"), + ), + ) - await display.show(App) + await display.show(App) - card = await display.page.wait_for_selector("#antd-card") - assert await card.is_visible() + card = await display.page.wait_for_selector("#antd-card") + assert await card.is_visible() - server_div = await display.page.wait_for_selector("#server-div") - assert await server_div.is_visible() - assert "Server Side Div" in await server_div.inner_text() + server_div = await display.page.wait_for_selector("#server-div") + assert await server_div.is_visible() + assert "Server Side Div" in await server_div.inner_text() - button = await display.page.wait_for_selector("#mui-button") - assert "MUI BUTTON" in await button.inner_text() # MUI capitalizes + button = await display.page.wait_for_selector("#mui-button") + assert "MUI BUTTON" in await button.inner_text() # MUI capitalizes @pytest.mark.anyio @pytest.mark.flaky(reruns=5, reruns_delay=2) -async def test_complex_nested_material_ui(): - async with BackendFixture(html_head=html.head(import_reactjs())) as backend: - async with DisplayFixture(backend=backend) as display: - # Import multiple components from @mui/material - # Note: component_from_npm can take a list of names - mui_components = component_from_npm( - "@mui/material", - ["Button", "Card", "CardContent", "Typography", "Box", "Stack"], - ) - Button, Card, CardContent, Typography, Box, Stack = mui_components - - @reactpy.component - def App(): - return Box( - { - "sx": { - "padding": "20px", - "backgroundColor": "#f5f5f5", - "height": "100vh", - } - }, - Stack( - {"spacing": 2, "direction": "column", "alignItems": "center"}, +async def test_complex_nested_material_ui(display: DisplayFixture): + # Import multiple components from @mui/material + # Note: component_from_npm can take a list of names + mui_components = component_from_npm( + "@mui/material", + ["Button", "Card", "CardContent", "Typography", "Box", "Stack"], + ) + Button, Card, CardContent, Typography, Box, Stack = mui_components + + @reactpy.component + def App(): + return Box( + { + "sx": { + "padding": "20px", + "backgroundColor": "#f5f5f5", + "height": "100vh", + } + }, + Stack( + {"spacing": 2, "direction": "column", "alignItems": "center"}, + Typography( + {"variant": "h4", "component": "h1", "gutterBottom": True}, + "Complex Nested UI Test", + ), + Card( + {"sx": {"minWidth": 300, "maxWidth": 500}}, + CardContent( + Typography( + { + "sx": {"fontSize": 14}, + "color": "text.secondary", + "gutterBottom": True, + }, + "Word of the Day", + ), Typography( - {"variant": "h4", "component": "h1", "gutterBottom": True}, - "Complex Nested UI Test", + {"variant": "h5", "component": "div"}, + "be-nev-o-lent", ), - Card( - {"sx": {"minWidth": 300, "maxWidth": 500}}, - CardContent( - Typography( - { - "sx": {"fontSize": 14}, - "color": "text.secondary", - "gutterBottom": True, - }, - "Word of the Day", - ), - Typography( - {"variant": "h5", "component": "div"}, - "be-nev-o-lent", - ), - Typography( - {"sx": {"mb": 1.5}, "color": "text.secondary"}, - "adjective", - ), - Typography( - {"variant": "body2"}, "well meaning and kindly." - ), - ), - Box( - { - "sx": { - "padding": "10px", - "display": "flex", - "justifyContent": "flex-end", - } - }, - Button( - { - "size": "small", - "variant": "contained", - "id": "learn-more-btn", - }, - "Learn More", - ), - ), + Typography( + {"sx": {"mb": 1.5}, "color": "text.secondary"}, + "adjective", ), + Typography({"variant": "body2"}, "well meaning and kindly."), ), - ) - - await display.show(App) - - # Check if the button is visible and has correct text - btn = await display.page.wait_for_selector("#learn-more-btn") - assert await btn.is_visible() - # Material UI transforms text to uppercase by default - assert "LEARN MORE" in await btn.inner_text() - - # Check if Card is rendered (it usually has MuiCard-root class) - # We can't easily select by ID as we didn't put one on Card, but we can check structure if needed. - # But let's just check if the text "be-nev-o-lent" is visible - text = await display.page.wait_for_selector("text=be-nev-o-lent") - assert await text.is_visible() + Box( + { + "sx": { + "padding": "10px", + "display": "flex", + "justifyContent": "flex-end", + } + }, + Button( + { + "size": "small", + "variant": "contained", + "id": "learn-more-btn", + }, + "Learn More", + ), + ), + ), + ), + ) + + await display.show(App) + + # Check if the button is visible and has correct text + btn = await display.page.wait_for_selector("#learn-more-btn") + assert await btn.is_visible() + # Material UI transforms text to uppercase by default + assert "LEARN MORE" in await btn.inner_text() + + # Check if Card is rendered (it usually has MuiCard-root class) + # We can't easily select by ID as we didn't put one on Card, but we can check structure if needed. + # But let's just check if the text "be-nev-o-lent" is visible + text = await display.page.wait_for_selector("text=be-nev-o-lent") + assert await text.is_visible() diff --git a/tests/test_testing.py b/tests/test_testing.py index 3318bb2c4..dfb706599 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -135,10 +135,10 @@ def test_assert_reactpy_did_not_log(): ROOT_LOGGER.exception("something") -async def test_simple_display_fixture(): +async def test_simple_display_fixture(browser): if os.name == "nt": pytest.skip("Browser tests not supported on Windows") - async with testing.DisplayFixture() as display: + async with testing.DisplayFixture(browser=browser) as display: await display.show(SampleApp) await display.page.wait_for_selector("#sample") diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index ae751b69e..579d4469a 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -70,7 +70,7 @@ def ShowSimpleButton(): app = ServeStaticASGI(app, JS_FIXTURES_DIR, "/static/") async with BackendFixture(app) as server: - async with DisplayFixture(server, browser) as display: + async with DisplayFixture(server, browser=browser) as display: await display.show(ShowSimpleButton) await display.page.wait_for_selector("#my-button") @@ -440,7 +440,7 @@ def ShowSimpleButton(): app = ServeStaticASGI(app, JS_FIXTURES_DIR, "/static/") async with BackendFixture(app) as server: - async with DisplayFixture(server, browser) as display: + async with DisplayFixture(server, browser=browser) as display: await display.show(ShowSimpleButton) await display.page.wait_for_selector("#my-button") From b5fd55a16fb51d57b1358e25b8916be017eda835 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 11 Dec 2025 21:45:01 -0800 Subject: [PATCH 02/12] Add pytest-timeout --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index debe05472..b1b94a669 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,6 +93,7 @@ artifacts = [] extra-dependencies = [ "pytest-sugar", "pytest-asyncio", + "pytest-timeout", "responses", "exceptiongroup", "jsonpointer", @@ -118,6 +119,7 @@ asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "session" asyncio_default_test_loop_scope = "session" log_cli_level = "INFO" +timeout = 30 ####################################### # >>> Hatch Documentation Scripts <<< # From f44acf4037d795bcefb205a9844603dccfad02df Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 11 Dec 2025 23:34:49 -0800 Subject: [PATCH 03/12] Add compatibility for test in `--parallel` mode --- .github/copilot-instructions.md | 20 ++++----- .github/workflows/check.yml | 4 +- pyproject.toml | 3 +- src/reactpy/reactjs/module.py | 26 ++++++------ src/reactpy/reactjs/utils.py | 41 ++++++++++++++++++- src/reactpy/testing/backend.py | 23 +++++++++-- src/reactpy/testing/display.py | 10 ++++- src/reactpy/testing/utils.py | 27 ------------ tests/conftest.py | 31 +++++++++++--- .../js_fixtures/subcomponent-notation.js | 26 +++++------- tests/test_testing.py | 4 +- tests/test_utils.py | 7 +++- .../js_fixtures/subcomponent-notation.js | 26 +++++------- tests/test_web/test_module.py | 11 ++++- 14 files changed, 159 insertions(+), 100 deletions(-) delete mode 100644 src/reactpy/testing/utils.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0a93205e0..bd4173691 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -50,10 +50,10 @@ pip install flask sanic tornado **Run Python Tests:** -- `hatch test` -- takes 10-30 seconds for basic tests. NEVER CANCEL. Set timeout to 60+ minutes for full test suite. **All tests must always pass - failures are never expected or allowed.** -- `hatch test --cover` -- run tests with coverage reporting (used in CI) -- `hatch test -k test_name` -- run specific tests -- `hatch test tests/test_config.py` -- run specific test files +- `hatch test --parallel` -- takes 10-30 seconds for basic tests. NEVER CANCEL. Set timeout to 2 minutes for full test suite. **All tests must always pass - failures are never expected or allowed.** +- `hatch test --parallel --cover` -- run tests with coverage reporting (used in CI) +- `hatch test --parallel -k test_name` -- run specific tests +- `hatch test --parallel tests/test_config.py` -- run specific test files **Run Python Linting and Formatting:** @@ -152,7 +152,7 @@ print(f"✓ Hook-based component: {type(counter)}") - `hatch run javascript:check` -- Ensure JavaScript passes linting (never expected to fail) - Test basic component creation and rendering as shown above - Test server creation if working on server-related features -- Run relevant tests with `hatch test` -- **All tests must always pass - failures are never expected or allowed** +- Run relevant tests with `hatch test --parallel` -- **All tests must always pass - failures are never expected or allowed** **Integration Testing:** @@ -263,9 +263,9 @@ The following are key commands for daily development: ### Development Commands ```bash -hatch test # Run all tests (**All tests must always pass**) -hatch test --cover # Run tests with coverage (used in CI) -hatch test -k test_name # Run specific tests +hatch test --parallel # Run all tests (**All tests must always pass**) +hatch test --parallel --cover # Run tests with coverage (used in CI) +hatch test --parallel -k test_name # Run specific tests hatch fmt # Format code with all formatters hatch fmt --check # Check formatting without changes hatch run python:type_check # Run Python type checker @@ -303,7 +303,7 @@ Follow this step-by-step process for effective development: 3. **Run formatting**: `hatch fmt` to format code (~1 second) 4. **Run type checking**: `hatch run python:type_check` for type checking (~10 seconds) 5. **Run JavaScript linting** (if JavaScript was modified): `hatch run javascript:check` (~10 seconds) -6. **Run relevant tests**: `hatch test` with specific test selection if needed. **All tests must always pass - failures are never expected or allowed.** +6. **Run relevant tests**: `hatch test --parallel` with specific test selection if needed. **All tests must always pass - failures are never expected or allowed.** 7. **Validate component functionality** manually using validation tests above 8. **Build JavaScript** (if modified): `hatch run javascript:build` (~15 seconds) 9. **Update documentation** when making changes to Python source code (required) @@ -365,7 +365,7 @@ Modern dependency management via pyproject.toml: The repository uses GitHub Actions with these key jobs: -- `test-python-coverage` -- Python test coverage with `hatch test --cover` +- `test-python-coverage` -- Python test coverage with `hatch test --parallel --cover` - `lint-python` -- Python linting and type checking via `hatch fmt --check` and `hatch run python:type_check` - `test-python` -- Cross-platform Python testing across Python 3.10-3.13 and Ubuntu/macOS/Windows - `lint-javascript` -- JavaScript linting and type checking diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 957284d1c..f4e35fea6 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -15,7 +15,7 @@ jobs: uses: ./.github/workflows/.hatch-run.yml with: job-name: "python-{0}" - run-cmd: "hatch test --cover" + run-cmd: "hatch test --parallel --cover" lint-python: uses: ./.github/workflows/.hatch-run.yml with: @@ -25,7 +25,7 @@ jobs: uses: ./.github/workflows/.hatch-run.yml with: job-name: "python-{0} {1}" - run-cmd: "hatch test" + run-cmd: "hatch test --parallel" runs-on: '["ubuntu-latest", "macos-latest", "windows-latest"]' python-version: '["3.11", "3.12", "3.13", "3.14"]' test-documentation: diff --git a/pyproject.toml b/pyproject.toml index b1b94a669..3ba12424e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,6 +98,7 @@ extra-dependencies = [ "exceptiongroup", "jsonpointer", "starlette", + "filelock", ] features = ["all"] @@ -119,7 +120,7 @@ asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "session" asyncio_default_test_loop_scope = "session" log_cli_level = "INFO" -timeout = 30 +timeout = 60 ####################################### # >>> Hatch Documentation Scripts <<< # diff --git a/src/reactpy/reactjs/module.py b/src/reactpy/reactjs/module.py index bbedf6fed..5718b7a89 100644 --- a/src/reactpy/reactjs/module.py +++ b/src/reactpy/reactjs/module.py @@ -14,6 +14,7 @@ module_name_suffix, resolve_from_module_file, resolve_from_module_url, + simple_file_lock, ) from reactpy.types import ImportSourceDict, JavaScriptModule, VdomConstructor @@ -54,19 +55,20 @@ def file_to_module( source_file = Path(file).resolve() target_file = get_module_path(name) - if not source_file.exists(): - msg = f"Source file does not exist: {source_file}" - raise FileNotFoundError(msg) - if not target_file.exists(): - copy_file(target_file, source_file, symlink) - elif not are_files_identical(source_file, target_file): - logger.info( - f"Existing web module {name!r} will " - f"be replaced with {target_file.resolve()}" - ) - target_file.unlink() - copy_file(target_file, source_file, symlink) + with simple_file_lock(target_file.with_name(target_file.name + ".lock")): + if not source_file.exists(): + msg = f"Source file does not exist: {source_file}" + raise FileNotFoundError(msg) + + if not target_file.exists(): + copy_file(target_file, source_file, symlink) + elif not are_files_identical(source_file, target_file): + logger.info( + f"Existing web module {name!r} will " + f"be replaced with {target_file.resolve()}" + ) + copy_file(target_file, source_file, symlink) return JavaScriptModule( source=name, diff --git a/src/reactpy/reactjs/utils.py b/src/reactpy/reactjs/utils.py index cec40ab32..b7c42211c 100644 --- a/src/reactpy/reactjs/utils.py +++ b/src/reactpy/reactjs/utils.py @@ -1,7 +1,10 @@ import filecmp import logging +import os import re import shutil +import time +from contextlib import contextmanager, suppress from pathlib import Path, PurePosixPath from urllib.parse import urlparse, urlunparse @@ -167,9 +170,26 @@ def are_files_identical(f1: Path, f2: Path) -> bool: def copy_file(target: Path, source: Path, symlink: bool) -> None: target.parent.mkdir(parents=True, exist_ok=True) if symlink: + if target.exists(): + target.unlink() target.symlink_to(source) else: - shutil.copy(source, target) + temp_target = target.with_suffix(target.suffix + ".tmp") + shutil.copy(source, temp_target) + try: + temp_target.replace(target) + except OSError: + # On Windows, replace might fail if the file is open + # Retry once after a short delay + time.sleep(0.1) + try: + temp_target.replace(target) + except OSError: + # If it still fails, try to unlink and rename + # This is not atomic, but it's a fallback + if target.exists(): + target.unlink() + temp_target.rename(target) _JS_DEFAULT_EXPORT_PATTERN = re.compile( @@ -181,3 +201,22 @@ def copy_file(target: Path, source: Path, symlink: bool) -> None: _JS_GENERAL_EXPORT_PATTERN = re.compile( r"(?:^|;|})\s*export(?=\s+|{)(.*?)(?=;|$)", re.MULTILINE ) + + +@contextmanager +def simple_file_lock(lock_file: Path, timeout: float = 10.0): + start_time = time.time() + while True: + try: + fd = os.open(lock_file, os.O_CREAT | os.O_EXCL | os.O_RDWR) + os.close(fd) + break + except OSError as e: + if time.time() - start_time > timeout: + raise TimeoutError(f"Could not acquire lock {lock_file}") from e + time.sleep(0.1) + try: + yield + finally: + with suppress(OSError): + os.unlink(lock_file) diff --git a/src/reactpy/testing/backend.py b/src/reactpy/testing/backend.py index 129d1658b..1e82dad0e 100644 --- a/src/reactpy/testing/backend.py +++ b/src/reactpy/testing/backend.py @@ -2,6 +2,7 @@ import asyncio import logging +import socket from collections.abc import Callable from contextlib import AsyncExitStack from types import TracebackType @@ -21,7 +22,6 @@ capture_reactpy_logs, list_logged_exceptions, ) -from reactpy.testing.utils import find_available_port from reactpy.types import ComponentConstructor from reactpy.utils import Ref @@ -51,7 +51,7 @@ def __init__( **reactpy_config: Any, ) -> None: self.host = host - self.port = port or find_available_port(host) + self.port = port or 0 self.mount = mount_to_hotswap self.timeout = ( REACTPY_TESTS_DEFAULT_TIMEOUT.current if timeout is None else timeout @@ -122,7 +122,24 @@ async def __aenter__(self) -> BackendFixture: # Wait for the server to start self.webserver.config.get_loop_factory() self.webserver_task = asyncio.create_task(self.webserver.serve()) - await asyncio.sleep(1) + for _ in range(100): + if self.webserver.started and self.webserver.servers: + break + await asyncio.sleep(0.1) + else: + msg = "Server failed to start" + raise RuntimeError(msg) + + # Determine the port if it was set to 0 (auto-select port) + if self.port == 0: + for server in self.webserver.servers: + for sock in server.sockets: + if sock.family == socket.AF_INET: + self.port = sock.getsockname()[1] + self.webserver.config.port = self.port + break + if self.port != 0: + break return self diff --git a/src/reactpy/testing/display.py b/src/reactpy/testing/display.py index f1f2a33d4..c4cf41bf0 100644 --- a/src/reactpy/testing/display.py +++ b/src/reactpy/testing/display.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os from contextlib import AsyncExitStack from types import TracebackType from typing import Any @@ -23,6 +24,7 @@ def __init__( self, backend: BackendFixture | None = None, browser: Browser | None = None, + headless: bool = False, ) -> None: if backend: self.backend_is_external = True @@ -32,6 +34,8 @@ def __init__( self.browser_is_external = True self.browser = browser + self.headless = headless + async def show( self, component: RootComponentConstructor, @@ -49,7 +53,11 @@ async def __aenter__(self) -> DisplayFixture: if not hasattr(self, "browser"): pw = await self.browser_exit_stack.enter_async_context(async_playwright()) - self.browser = await pw.chromium.launch(headless=GITHUB_ACTIONS) + self.browser = await pw.chromium.launch( + headless=self.headless + or os.environ.get("PLAYWRIGHT_HEADLESS") == "1" + or GITHUB_ACTIONS + ) await self.configure_page() if not hasattr(self, "backend"): # nocov diff --git a/src/reactpy/testing/utils.py b/src/reactpy/testing/utils.py deleted file mode 100644 index 6a48516ed..000000000 --- a/src/reactpy/testing/utils.py +++ /dev/null @@ -1,27 +0,0 @@ -from __future__ import annotations - -import socket -import sys -from contextlib import closing - - -def find_available_port( - host: str, port_min: int = 8000, port_max: int = 9000 -) -> int: # nocov - """Get a port that's available for the given host and port range""" - for port in range(port_min, port_max): - with closing(socket.socket()) as sock: - try: - if sys.platform in ("linux", "darwin"): - # Fixes bug on Unix-like systems where every time you restart the - # server you'll get a different port on Linux. This cannot be set - # on Windows otherwise address will always be reused. - # Ref: https://stackoverflow.com/a/19247688/3159288 - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - sock.bind((host, port)) - except OSError: - pass - else: - return port - msg = f"Host {host!r} has no available port in range {port_max}-{port_max}" - raise RuntimeError(msg) diff --git a/tests/conftest.py b/tests/conftest.py index 8827a7593..67d184c4b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,9 +3,12 @@ import contextlib import os import subprocess +import sys +from time import sleep import pytest from _pytest.config.argparsing import Parser +from filelock import FileLock from reactpy.config import ( REACTPY_ASYNC_RENDERING, @@ -32,6 +35,17 @@ def pytest_addoption(parser: Parser) -> None: ) +def headless_environ(pytestconfig: pytest.Config): + if ( + pytestconfig.getoption("headless") + or os.environ.get("PLAYWRIGHT_HEADLESS") == "1" + or GITHUB_ACTIONS + ): + os.environ["PLAYWRIGHT_HEADLESS"] = "1" + return True + return False + + @pytest.fixture(autouse=True, scope="session") def install_playwright(): subprocess.run(["playwright", "install", "chromium"], check=True) # noqa: S607 @@ -41,7 +55,7 @@ def install_playwright(): @pytest.fixture(autouse=True, scope="session") -def rebuild(): +def rebuild(tmp_path_factory, worker_id): # When running inside `hatch test`, the `HATCH_ENV_ACTIVE` environment variable # is set. If we try to run `hatch build` with this variable set, Hatch will # complain that the current environment is not a builder environment. @@ -49,7 +63,16 @@ def rebuild(): # passed to the subprocess. env = os.environ.copy() env.pop("HATCH_ENV_ACTIVE", None) - subprocess.run(["hatch", "build", "-t", "wheel"], check=True, env=env) # noqa: S607 + + root_tmp_dir = tmp_path_factory.getbasetemp().parent + fn = root_tmp_dir / "build.lock" + flag = root_tmp_dir / "build.done" + + # Whoever gets the lock first performs the build. + with FileLock(str(fn)): + if not flag.exists(): + subprocess.run(["hatch", "build", "-t", "wheel"], check=True, env=env) # noqa: S607 + flag.touch() @pytest.fixture(scope="session") @@ -69,9 +92,7 @@ async def browser(pytestconfig: pytest.Config): from playwright.async_api import async_playwright async with async_playwright() as pw: - yield await pw.chromium.launch( - headless=bool(pytestconfig.option.headless) or GITHUB_ACTIONS - ) + yield await pw.chromium.launch(headless=headless_environ(pytestconfig)) @pytest.fixture(autouse=True) diff --git a/tests/test_reactjs/js_fixtures/subcomponent-notation.js b/tests/test_reactjs/js_fixtures/subcomponent-notation.js index 12542786e..4bb2f89b6 100644 --- a/tests/test_reactjs/js_fixtures/subcomponent-notation.js +++ b/tests/test_reactjs/js_fixtures/subcomponent-notation.js @@ -1,17 +1,11 @@ -import React from "https://esm.sh/v135/react@19.0" -import ReactDOM from "https://esm.sh/v135/react-dom@19.0/client" -// Explicitly import react-is to ensure it's loaded before react-bootstrap -// This prevents race conditions where react-bootstrap tries to use React context before deps are ready -import * as ReactIs from "https://esm.sh/v135/react-is@19.0" -import {InputGroup, Form} from "https://esm.sh/v135/react-bootstrap@2.10.2?deps=react@19.0,react-dom@19.0,react-is@19.0&exports=InputGroup,Form"; -export {InputGroup, Form}; +import React from "react"; +import ReactDOM from "react-dom"; -export function bind(node, config) { - const root = ReactDOM.createRoot(node); - return { - create: (type, props, children) => - React.createElement(type, props, ...children), - render: (element) => root.render(element), - unmount: () => root.unmount() - }; -} \ No newline at end of file +const InputGroup = ({ children }) => React.createElement("div", { className: "input-group" }, children); +InputGroup.Text = ({ children, ...props }) => React.createElement("span", { className: "input-group-text", ...props }, children); + +const Form = ({ children }) => React.createElement("form", {}, children); +Form.Control = ({ children, ...props }) => React.createElement("input", { className: "form-control", ...props }, children); +Form.Label = ({ children, ...props }) => React.createElement("label", { className: "form-label", ...props }, children); + +export { InputGroup, Form }; diff --git a/tests/test_testing.py b/tests/test_testing.py index dfb706599..01f323a3f 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -136,14 +136,12 @@ def test_assert_reactpy_did_not_log(): async def test_simple_display_fixture(browser): - if os.name == "nt": - pytest.skip("Browser tests not supported on Windows") async with testing.DisplayFixture(browser=browser) as display: await display.show(SampleApp) await display.page.wait_for_selector("#sample") -def test_list_logged_excptions(): +def test_list_logged_exceptions(): the_error = None with testing.capture_reactpy_logs() as records: ROOT_LOGGER.info("A non-error log message") diff --git a/tests/test_utils.py b/tests/test_utils.py index d98adab6b..52cea0f6a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -275,7 +275,12 @@ def test_non_html_tag_behavior(): utils.string_to_reactpy(source, strict=True) -SOME_OBJECT = object() +class StableReprObject: + def __repr__(self): + return "StableReprObject" + + +SOME_OBJECT = StableReprObject() @component diff --git a/tests/test_web/js_fixtures/subcomponent-notation.js b/tests/test_web/js_fixtures/subcomponent-notation.js index 12542786e..4bb2f89b6 100644 --- a/tests/test_web/js_fixtures/subcomponent-notation.js +++ b/tests/test_web/js_fixtures/subcomponent-notation.js @@ -1,17 +1,11 @@ -import React from "https://esm.sh/v135/react@19.0" -import ReactDOM from "https://esm.sh/v135/react-dom@19.0/client" -// Explicitly import react-is to ensure it's loaded before react-bootstrap -// This prevents race conditions where react-bootstrap tries to use React context before deps are ready -import * as ReactIs from "https://esm.sh/v135/react-is@19.0" -import {InputGroup, Form} from "https://esm.sh/v135/react-bootstrap@2.10.2?deps=react@19.0,react-dom@19.0,react-is@19.0&exports=InputGroup,Form"; -export {InputGroup, Form}; +import React from "react"; +import ReactDOM from "react-dom"; -export function bind(node, config) { - const root = ReactDOM.createRoot(node); - return { - create: (type, props, children) => - React.createElement(type, props, ...children), - render: (element) => root.render(element), - unmount: () => root.unmount() - }; -} \ No newline at end of file +const InputGroup = ({ children }) => React.createElement("div", { className: "input-group" }, children); +InputGroup.Text = ({ children, ...props }) => React.createElement("span", { className: "input-group-text", ...props }, children); + +const Form = ({ children }) => React.createElement("form", {}, children); +Form.Control = ({ children, ...props }) => React.createElement("input", { className: "form-control", ...props }, children); +Form.Label = ({ children, ...props }) => React.createElement("label", { className: "form-label", ...props }, children); + +export { InputGroup, Form }; diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index 579d4469a..da7a88d71 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -8,7 +8,7 @@ import reactpy import reactpy.reactjs from reactpy.executors.asgi.standalone import ReactPy -from reactpy.reactjs import NAME_SOURCE, JavaScriptModule +from reactpy.reactjs import NAME_SOURCE, JavaScriptModule, import_reactjs from reactpy.testing import ( BackendFixture, DisplayFixture, @@ -21,6 +21,14 @@ JS_FIXTURES_DIR = Path(__file__).parent / "js_fixtures" +@pytest.fixture(scope="module") +async def display(browser): + """Override for the display fixture that includes ReactJS.""" + async with BackendFixture(html_head=reactpy.html.head(import_reactjs())) as backend: + async with DisplayFixture(backend=backend, browser=browser) as new_display: + yield new_display + + async def test_that_js_module_unmount_is_called(display: DisplayFixture): SomeComponent = reactpy.reactjs.module_to_vdom( reactpy.reactjs.file_to_module( @@ -325,7 +333,6 @@ async def test_subcomponent_notation_as_str_attrs(display: DisplayFixture): ) await display.show(lambda: content) - await display.page.wait_for_selector("#basic-addon3", state="attached") parent = await display.page.wait_for_selector("#the-parent", state="attached") input_group_text = await parent.query_selector_all(".input-group-text") From b5ec6ade4d156ef40f23286dfe5de2c34ff66e95 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 11 Dec 2025 23:46:24 -0800 Subject: [PATCH 04/12] fix coverage --- tests/conftest.py | 2 - tests/test_reactjs/test_utils.py | 35 ++++++++++++++ tests/test_testing.py | 80 +++++++++++++++++++++++++++++++- 3 files changed, 114 insertions(+), 3 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 67d184c4b..b3d8f95a0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,8 +3,6 @@ import contextlib import os import subprocess -import sys -from time import sleep import pytest from _pytest.config.argparsing import Parser diff --git a/tests/test_reactjs/test_utils.py b/tests/test_reactjs/test_utils.py index c22ad80a0..8f29114e5 100644 --- a/tests/test_reactjs/test_utils.py +++ b/tests/test_reactjs/test_utils.py @@ -1,14 +1,17 @@ from pathlib import Path +from unittest.mock import patch import pytest import responses from reactpy.reactjs.utils import ( + copy_file, module_name_suffix, normalize_url_path, resolve_from_module_file, resolve_from_module_source, resolve_from_module_url, + simple_file_lock, ) from reactpy.testing import assert_reactpy_did_log @@ -163,3 +166,35 @@ def test_resolve_relative_url(): == "https://some.url/path/to/another.js" ) assert normalize_url_path("/some/path", "to/another.js") == "to/another.js" + + +def test_copy_file_fallback(tmp_path): + source = tmp_path / "source.txt" + source.write_text("content") + target = tmp_path / "target.txt" + + path_cls = type(target) + + with patch("shutil.copy"): + with patch.object( + path_cls, "replace", side_effect=[OSError, OSError] + ) as mock_replace: + with patch.object(path_cls, "rename") as mock_rename: + with patch.object(path_cls, "exists", return_value=True): + with patch.object(path_cls, "unlink") as mock_unlink: + with patch("time.sleep"): # Speed up test + copy_file(target, source, symlink=False) + + assert mock_replace.call_count == 2 + mock_unlink.assert_called_once() + mock_rename.assert_called_once() + + +def test_simple_file_lock_timeout(tmp_path): + lock_file = tmp_path / "lock" + + with patch("os.open", side_effect=OSError): + with patch("time.sleep"): # Speed up test + with pytest.raises(TimeoutError, match="Could not acquire lock"): + with simple_file_lock(lock_file, timeout=0.1): + pass diff --git a/tests/test_testing.py b/tests/test_testing.py index 01f323a3f..2e6a73ff8 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -1,11 +1,12 @@ import logging import os +from unittest.mock import AsyncMock, MagicMock, patch import pytest from reactpy import Ref, component, html, testing from reactpy.logging import ROOT_LOGGER -from reactpy.testing.backend import _hotswap +from reactpy.testing.backend import BackendFixture, _hotswap from reactpy.testing.display import DisplayFixture from tests.sample import SampleApp @@ -205,3 +206,80 @@ async def on_click(event): await display.page.wait_for_selector("#hotswap-2") await client_incr_button.click() await display.page.wait_for_selector("#hotswap-3") + + +@pytest.mark.asyncio +async def test_backend_server_failure(): + # We need to mock uvicorn.Server to fail starting + with patch("uvicorn.Server") as mock_server_cls: + mock_server = mock_server_cls.return_value + mock_server.started = False + mock_server.servers = [] + mock_server.config.get_loop_factory = MagicMock() + + # Mock serve to just return (or sleep briefly then return) + mock_server.serve = AsyncMock(return_value=None) + + backend = BackendFixture() + + # We need to speed up the loop + with patch("asyncio.sleep", new_callable=AsyncMock): + with pytest.raises(RuntimeError, match="Server failed to start"): + await backend.__aenter__() + + +@pytest.mark.asyncio +async def test_display_fixture_headless_logic(): + # Mock async_playwright to avoid launching real browser + with patch("reactpy.testing.display.async_playwright") as mock_pw: + mock_context_manager = mock_pw.return_value + mock_playwright_instance = AsyncMock() + mock_context_manager.__aenter__.return_value = mock_playwright_instance + + mock_browser = AsyncMock() + mock_playwright_instance.chromium.launch.return_value = mock_browser + + mock_page = AsyncMock() + # Configure synchronous methods on page + mock_page.set_default_timeout = MagicMock() + mock_page.on = MagicMock() + + mock_browser.new_page.return_value = mock_page + + # Case: headless=False, PLAYWRIGHT_HEADLESS='1' + with patch.dict(os.environ, {"PLAYWRIGHT_HEADLESS": "1"}): + async with DisplayFixture(headless=False): + pass + # Check that launch was called with headless=True + mock_playwright_instance.chromium.launch.assert_called_with(headless=True) + + +@pytest.mark.asyncio +async def test_display_fixture_internal_backend(): + # This covers line 87: await self.backend_exit_stack.aclose() + # when backend is internal (default) + + with patch("reactpy.testing.display.async_playwright") as mock_pw: + mock_context_manager = mock_pw.return_value + mock_playwright_instance = AsyncMock() + mock_context_manager.__aenter__.return_value = mock_playwright_instance + + mock_browser = AsyncMock() + mock_playwright_instance.chromium.launch.return_value = mock_browser + + mock_page = AsyncMock() + mock_page.set_default_timeout = MagicMock() + mock_page.on = MagicMock() + mock_browser.new_page.return_value = mock_page + + # We also need to mock BackendFixture to avoid starting real server + with patch("reactpy.testing.display.BackendFixture") as mock_backend_cls: + mock_backend = AsyncMock() + mock_backend.mount = MagicMock() # mount is synchronous + mock_backend_cls.return_value = mock_backend + + async with DisplayFixture() as display: + assert not display.backend_is_external + + # Verify backend exit stack closed (implied if no error and backend.__aexit__ called) + mock_backend.__aexit__.assert_called() From 02cb5ab880b3be66d2447d589e1164a02345531e Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 12 Dec 2025 00:30:13 -0800 Subject: [PATCH 05/12] Attempt fix for failing tests --- tests/conftest.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index b3d8f95a0..497f9bf7b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -44,12 +44,25 @@ def headless_environ(pytestconfig: pytest.Config): return False +def get_lock_dir(tmp_path_factory, worker_id): + if worker_id == "master": + return tmp_path_factory.getbasetemp() + return tmp_path_factory.getbasetemp().parent + + @pytest.fixture(autouse=True, scope="session") -def install_playwright(): - subprocess.run(["playwright", "install", "chromium"], check=True) # noqa: S607 - # Try to install system deps, but don't fail if already installed or no root access - with contextlib.suppress(subprocess.CalledProcessError): - subprocess.run(["playwright", "install-deps"], check=True) # noqa: S607 +def install_playwright(tmp_path_factory, worker_id): + root_tmp_dir = get_lock_dir(tmp_path_factory, worker_id) + fn = root_tmp_dir / "playwright_install.lock" + flag = root_tmp_dir / "playwright_install.done" + + with FileLock(str(fn)): + if not flag.exists(): + subprocess.run(["playwright", "install", "chromium"], check=True) # noqa: S607 + # Try to install system deps, but don't fail if already installed or no root access + with contextlib.suppress(subprocess.CalledProcessError): + subprocess.run(["playwright", "install-deps"], check=True) # noqa: S607 + flag.touch() @pytest.fixture(autouse=True, scope="session") @@ -62,7 +75,7 @@ def rebuild(tmp_path_factory, worker_id): env = os.environ.copy() env.pop("HATCH_ENV_ACTIVE", None) - root_tmp_dir = tmp_path_factory.getbasetemp().parent + root_tmp_dir = get_lock_dir(tmp_path_factory, worker_id) fn = root_tmp_dir / "build.lock" flag = root_tmp_dir / "build.done" From 0c63629e93eecede40fff23f19dfcf5e7f0923a8 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 12 Dec 2025 15:42:26 -0800 Subject: [PATCH 06/12] Move test dep configuration out of Pytest and into Hatch --- pyproject.toml | 8 +++- src/build_scripts/clean_js_dir.py | 2 + src/reactpy/testing/__init__.py | 8 +--- src/reactpy/testing/common.py | 10 +---- tests/conftest.py | 75 +++++-------------------------- 5 files changed, 23 insertions(+), 80 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3ba12424e..974fa780c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,6 +88,13 @@ artifacts = [] ############################# # >>> Hatch Test Runner <<< # ############################# +[tool.hatch.envs.hatch-test.scripts] +run = [ + "hatch --env default build -t wheel", + "playwright install chromium", + "playwright install-deps", + "pytest{env:HATCH_TEST_ARGS:} {args}", +] [tool.hatch.envs.hatch-test] extra-dependencies = [ @@ -98,7 +105,6 @@ extra-dependencies = [ "exceptiongroup", "jsonpointer", "starlette", - "filelock", ] features = ["all"] diff --git a/src/build_scripts/clean_js_dir.py b/src/build_scripts/clean_js_dir.py index cdb9e276a..f6b2b000f 100644 --- a/src/build_scripts/clean_js_dir.py +++ b/src/build_scripts/clean_js_dir.py @@ -11,6 +11,8 @@ import pathlib import shutil +print("Cleaning JS source directory...") # noqa: T201 + # Get the path to the JS source directory js_src_dir = pathlib.Path(__file__).parent.parent / "js" static_output_dir = pathlib.Path(__file__).parent.parent / "reactpy" / "static" diff --git a/src/reactpy/testing/__init__.py b/src/reactpy/testing/__init__.py index 27247a88f..2829ab36f 100644 --- a/src/reactpy/testing/__init__.py +++ b/src/reactpy/testing/__init__.py @@ -1,10 +1,5 @@ from reactpy.testing.backend import BackendFixture -from reactpy.testing.common import ( - HookCatcher, - StaticEventHandler, - clear_reactpy_web_modules_dir, - poll, -) +from reactpy.testing.common import HookCatcher, StaticEventHandler, poll from reactpy.testing.display import DisplayFixture from reactpy.testing.logs import ( LogAssertionError, @@ -22,6 +17,5 @@ "assert_reactpy_did_log", "assert_reactpy_did_not_log", "capture_reactpy_logs", - "clear_reactpy_web_modules_dir", "poll", ] diff --git a/src/reactpy/testing/common.py b/src/reactpy/testing/common.py index 276d2d4a9..bcfce2ebd 100644 --- a/src/reactpy/testing/common.py +++ b/src/reactpy/testing/common.py @@ -3,7 +3,6 @@ import asyncio import inspect import os -import shutil import time from collections.abc import Awaitable, Callable, Coroutine from functools import wraps @@ -11,18 +10,11 @@ from uuid import uuid4 from weakref import ref -from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT, REACTPY_WEB_MODULES_DIR +from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT from reactpy.core._life_cycle_hook import HOOK_STACK, LifeCycleHook from reactpy.core.events import EventHandler, to_event_handler_function from reactpy.utils import str_to_bool - -def clear_reactpy_web_modules_dir() -> None: - """Clear the directory where ReactPy stores registered web modules""" - for path in REACTPY_WEB_MODULES_DIR.current.iterdir(): - shutil.rmtree(path) if path.is_dir() else path.unlink() - - _P = ParamSpec("_P") _R = TypeVar("_R") diff --git a/tests/conftest.py b/tests/conftest.py index 497f9bf7b..5118f539b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,12 +1,9 @@ from __future__ import annotations -import contextlib import os -import subprocess import pytest from _pytest.config.argparsing import Parser -from filelock import FileLock from reactpy.config import ( REACTPY_ASYNC_RENDERING, @@ -16,7 +13,6 @@ BackendFixture, DisplayFixture, capture_reactpy_logs, - clear_reactpy_web_modules_dir, ) from reactpy.testing.common import GITHUB_ACTIONS @@ -33,59 +29,6 @@ def pytest_addoption(parser: Parser) -> None: ) -def headless_environ(pytestconfig: pytest.Config): - if ( - pytestconfig.getoption("headless") - or os.environ.get("PLAYWRIGHT_HEADLESS") == "1" - or GITHUB_ACTIONS - ): - os.environ["PLAYWRIGHT_HEADLESS"] = "1" - return True - return False - - -def get_lock_dir(tmp_path_factory, worker_id): - if worker_id == "master": - return tmp_path_factory.getbasetemp() - return tmp_path_factory.getbasetemp().parent - - -@pytest.fixture(autouse=True, scope="session") -def install_playwright(tmp_path_factory, worker_id): - root_tmp_dir = get_lock_dir(tmp_path_factory, worker_id) - fn = root_tmp_dir / "playwright_install.lock" - flag = root_tmp_dir / "playwright_install.done" - - with FileLock(str(fn)): - if not flag.exists(): - subprocess.run(["playwright", "install", "chromium"], check=True) # noqa: S607 - # Try to install system deps, but don't fail if already installed or no root access - with contextlib.suppress(subprocess.CalledProcessError): - subprocess.run(["playwright", "install-deps"], check=True) # noqa: S607 - flag.touch() - - -@pytest.fixture(autouse=True, scope="session") -def rebuild(tmp_path_factory, worker_id): - # When running inside `hatch test`, the `HATCH_ENV_ACTIVE` environment variable - # is set. If we try to run `hatch build` with this variable set, Hatch will - # complain that the current environment is not a builder environment. - # To fix this, we remove `HATCH_ENV_ACTIVE` from the environment variables - # passed to the subprocess. - env = os.environ.copy() - env.pop("HATCH_ENV_ACTIVE", None) - - root_tmp_dir = get_lock_dir(tmp_path_factory, worker_id) - fn = root_tmp_dir / "build.lock" - flag = root_tmp_dir / "build.done" - - # Whoever gets the lock first performs the build. - with FileLock(str(fn)): - if not flag.exists(): - subprocess.run(["hatch", "build", "-t", "wheel"], check=True, env=env) # noqa: S607 - flag.touch() - - @pytest.fixture(scope="session") async def display(server, browser): async with DisplayFixture(backend=server, browser=browser) as display: @@ -103,12 +46,7 @@ async def browser(pytestconfig: pytest.Config): from playwright.async_api import async_playwright async with async_playwright() as pw: - yield await pw.chromium.launch(headless=headless_environ(pytestconfig)) - - -@pytest.fixture(autouse=True) -def clear_web_modules_dir_after_test(): - clear_reactpy_web_modules_dir() + yield await pw.chromium.launch(headless=_headless_environ(pytestconfig)) @pytest.fixture(autouse=True) @@ -121,3 +59,14 @@ def assert_no_logged_exceptions(): raise r.exc_info[1] finally: records.clear() + + +def _headless_environ(pytestconfig: pytest.Config): + if ( + pytestconfig.getoption("headless") + or os.environ.get("PLAYWRIGHT_HEADLESS") == "1" + or GITHUB_ACTIONS + ): + os.environ["PLAYWRIGHT_HEADLESS"] = "1" + return True + return False From b7725826af9ea3c2bbbdf851865298bee29121a6 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 12 Dec 2025 15:46:42 -0800 Subject: [PATCH 07/12] self review --- src/reactpy/reactjs/module.py | 4 ++-- src/reactpy/reactjs/utils.py | 2 +- src/reactpy/testing/display.py | 1 - tests/test_reactjs/test_utils.py | 4 ++-- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/reactpy/reactjs/module.py b/src/reactpy/reactjs/module.py index 5718b7a89..125c21609 100644 --- a/src/reactpy/reactjs/module.py +++ b/src/reactpy/reactjs/module.py @@ -11,10 +11,10 @@ from reactpy.reactjs.utils import ( are_files_identical, copy_file, + file_lock, module_name_suffix, resolve_from_module_file, resolve_from_module_url, - simple_file_lock, ) from reactpy.types import ImportSourceDict, JavaScriptModule, VdomConstructor @@ -56,7 +56,7 @@ def file_to_module( source_file = Path(file).resolve() target_file = get_module_path(name) - with simple_file_lock(target_file.with_name(target_file.name + ".lock")): + with file_lock(target_file.with_name(f"{target_file.name}.lock")): if not source_file.exists(): msg = f"Source file does not exist: {source_file}" raise FileNotFoundError(msg) diff --git a/src/reactpy/reactjs/utils.py b/src/reactpy/reactjs/utils.py index b7c42211c..a1bd3891e 100644 --- a/src/reactpy/reactjs/utils.py +++ b/src/reactpy/reactjs/utils.py @@ -204,7 +204,7 @@ def copy_file(target: Path, source: Path, symlink: bool) -> None: @contextmanager -def simple_file_lock(lock_file: Path, timeout: float = 10.0): +def file_lock(lock_file: Path, timeout: float = 10.0): start_time = time.time() while True: try: diff --git a/src/reactpy/testing/display.py b/src/reactpy/testing/display.py index c4cf41bf0..3f07793e3 100644 --- a/src/reactpy/testing/display.py +++ b/src/reactpy/testing/display.py @@ -67,7 +67,6 @@ async def __aenter__(self) -> DisplayFixture: return self async def configure_page(self) -> None: - """Hook for configuring the page before use.""" if getattr(self, "page", None) is None: self.page = await self.browser.new_page() self.page.set_default_timeout(REACTPY_TESTS_DEFAULT_TIMEOUT.current * 1000) diff --git a/tests/test_reactjs/test_utils.py b/tests/test_reactjs/test_utils.py index 8f29114e5..e5a00f550 100644 --- a/tests/test_reactjs/test_utils.py +++ b/tests/test_reactjs/test_utils.py @@ -6,12 +6,12 @@ from reactpy.reactjs.utils import ( copy_file, + file_lock, module_name_suffix, normalize_url_path, resolve_from_module_file, resolve_from_module_source, resolve_from_module_url, - simple_file_lock, ) from reactpy.testing import assert_reactpy_did_log @@ -196,5 +196,5 @@ def test_simple_file_lock_timeout(tmp_path): with patch("os.open", side_effect=OSError): with patch("time.sleep"): # Speed up test with pytest.raises(TimeoutError, match="Could not acquire lock"): - with simple_file_lock(lock_file, timeout=0.1): + with file_lock(lock_file, timeout=0.1): pass From 9dcce14d060539df44c6dd525df1a5f43fa6d1de Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 12 Dec 2025 15:59:32 -0800 Subject: [PATCH 08/12] fix tests --- tests/test_web/test_module.py | 49 +++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index da7a88d71..cddcc86dc 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -111,30 +111,36 @@ def test_module_from_file_source_conflict(tmp_path): first_file = tmp_path / "first.js" with pytest.raises(FileNotFoundError, match=r"does not exist"): - reactpy.reactjs.file_to_module("temp", first_file) + reactpy.reactjs.file_to_module( + "test-module-from-file-source-conflict", first_file + ) first_file.touch() - reactpy.reactjs.file_to_module("temp", first_file) + reactpy.reactjs.file_to_module("test-module-from-file-source-conflict", first_file) second_file = tmp_path / "second.js" second_file.touch() # ok, same content - reactpy.reactjs.file_to_module("temp", second_file) + reactpy.reactjs.file_to_module("test-module-from-file-source-conflict", second_file) third_file = tmp_path / "third.js" third_file.write_text("something-different") with assert_reactpy_did_log(r"Existing web module .* will be replaced with"): - reactpy.reactjs.file_to_module("temp", third_file) + reactpy.reactjs.file_to_module( + "test-module-from-file-source-conflict", third_file + ) def test_web_module_from_file_symlink(tmp_path): file = tmp_path / "temp.js" file.touch() - module = reactpy.reactjs.file_to_module("temp", file, symlink=True) + module = reactpy.reactjs.file_to_module( + "test-web-module-from-file-symlink", file, symlink=True + ) assert module.file.resolve().read_text() == "" @@ -147,29 +153,38 @@ def test_web_module_from_file_symlink_twice(tmp_path): file_1 = tmp_path / "temp_1.js" file_1.touch() - reactpy.reactjs.file_to_module("temp", file_1, symlink=True) - - with assert_reactpy_did_not_log(r"Existing web module .* will be replaced with"): - reactpy.reactjs.file_to_module("temp", file_1, symlink=True) - + reactpy.reactjs.file_to_module( + "test-web-module-from-file-symlink-twice", file_1, symlink=True + ) + with assert_reactpy_did_not_log( + r"Existing web module 'test-web-module-from-file-symlink-twice.js' will be replaced with" + ): + reactpy.reactjs.file_to_module( + "test-web-module-from-file-symlink-twice", file_1, symlink=True + ) file_2 = tmp_path / "temp_2.js" file_2.write_text("something") - - with assert_reactpy_did_log(r"Existing web module .* will be replaced with"): - reactpy.reactjs.file_to_module("temp", file_2, symlink=True) + with assert_reactpy_did_log( + r"Existing web module 'test-web-module-from-file-symlink-twice.js' will be replaced with" + ): + reactpy.reactjs.file_to_module( + "test-web-module-from-file-symlink-twice", file_2, symlink=True + ) def test_web_module_from_file_replace_existing(tmp_path): file1 = tmp_path / "temp1.js" file1.touch() - reactpy.reactjs.file_to_module("temp", file1) + reactpy.reactjs.file_to_module("test-web-module-from-file-replace-existing", file1) file2 = tmp_path / "temp2.js" file2.write_text("something") with assert_reactpy_did_log(r"Existing web module .* will be replaced with"): - reactpy.reactjs.file_to_module("temp", file2) + reactpy.reactjs.file_to_module( + "test-web-module-from-file-replace-existing", file2 + ) def test_module_missing_exports(): @@ -425,12 +440,12 @@ def App(): def test_component_from_string(): reactpy.reactjs.component_from_string( - "old", "Component", resolve_imports=False, name="temp" + "old", "Component", resolve_imports=False, name="test-component-from-string" ) reactpy.reactjs._STRING_JS_MODULE_CACHE.clear() with assert_reactpy_did_log(r"Existing web module .* will be replaced with"): reactpy.reactjs.component_from_string( - "new", "Component", resolve_imports=False, name="temp" + "new", "Component", resolve_imports=False, name="test-component-from-string" ) From b4d5c737566c344861c72e7b64d996500439799b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 12 Dec 2025 16:03:06 -0800 Subject: [PATCH 09/12] fix coverage --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 974fa780c..83701e8f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,6 +95,9 @@ run = [ "playwright install-deps", "pytest{env:HATCH_TEST_ARGS:} {args}", ] +run-cov = "coverage run -m pytest{env:HATCH_TEST_ARGS:} {args}" +cov-combine = "coverage combine" +cov-report = "coverage report" [tool.hatch.envs.hatch-test] extra-dependencies = [ From 9f318fd624411fa33f5c2d2e3c36bc39d60a3c09 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 12 Dec 2025 16:18:09 -0800 Subject: [PATCH 10/12] Extend timeout since GITHUB_ACTIONS windows runners are slow --- src/reactpy/testing/backend.py | 5 ----- src/reactpy/testing/display.py | 6 +++++- tests/test_asgi/test_pyscript.py | 4 +++- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/reactpy/testing/backend.py b/src/reactpy/testing/backend.py index 1e82dad0e..ca311ceed 100644 --- a/src/reactpy/testing/backend.py +++ b/src/reactpy/testing/backend.py @@ -11,7 +11,6 @@ import uvicorn -from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT from reactpy.core.component import component from reactpy.core.hooks import use_callback, use_effect, use_state from reactpy.executors.asgi.middleware import ReactPyMiddleware @@ -47,15 +46,11 @@ def __init__( app: AsgiApp | None = None, host: str = "127.0.0.1", port: int | None = None, - timeout: float | None = None, **reactpy_config: Any, ) -> None: self.host = host self.port = port or 0 self.mount = mount_to_hotswap - self.timeout = ( - REACTPY_TESTS_DEFAULT_TIMEOUT.current if timeout is None else timeout - ) if isinstance(app, (ReactPyMiddleware, ReactPy)): self._app = app elif app: diff --git a/src/reactpy/testing/display.py b/src/reactpy/testing/display.py index 3f07793e3..ff6ecdca8 100644 --- a/src/reactpy/testing/display.py +++ b/src/reactpy/testing/display.py @@ -25,6 +25,7 @@ def __init__( backend: BackendFixture | None = None, browser: Browser | None = None, headless: bool = False, + timeout: float | None = None, ) -> None: if backend: self.backend_is_external = True @@ -34,6 +35,9 @@ def __init__( self.browser_is_external = True self.browser = browser + self.timeout = ( + timeout if timeout is not None else REACTPY_TESTS_DEFAULT_TIMEOUT.current + ) self.headless = headless async def show( @@ -69,7 +73,7 @@ async def __aenter__(self) -> DisplayFixture: async def configure_page(self) -> None: if getattr(self, "page", None) is None: self.page = await self.browser.new_page() - self.page.set_default_timeout(REACTPY_TESTS_DEFAULT_TIMEOUT.current * 1000) + self.page.set_default_timeout(self.timeout * 1000) self.page.on("console", lambda msg: print(f"BROWSER CONSOLE: {msg.text}")) # noqa: T201 self.page.on("pageerror", lambda exc: print(f"BROWSER ERROR: {exc}")) # noqa: T201 diff --git a/tests/test_asgi/test_pyscript.py b/tests/test_asgi/test_pyscript.py index 3c0a7e0f7..9a86a9592 100644 --- a/tests/test_asgi/test_pyscript.py +++ b/tests/test_asgi/test_pyscript.py @@ -22,7 +22,9 @@ async def display(browser): ) async with BackendFixture(app) as server: - async with DisplayFixture(backend=server, browser=browser) as new_display: + async with DisplayFixture( + backend=server, browser=browser, timeout=20 + ) as new_display: yield new_display From 0e60b47b4e8ac55bea7e6b5b3efccbdbf9c7705b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 12 Dec 2025 16:20:38 -0800 Subject: [PATCH 11/12] Remove usage of anyio pytest marker --- tests/test_reactjs/test_modules.py | 2 -- tests/test_reactjs/test_modules_from_npm.py | 13 ------------- 2 files changed, 15 deletions(-) diff --git a/tests/test_reactjs/test_modules.py b/tests/test_reactjs/test_modules.py index 5cccfded9..8b3992ef0 100644 --- a/tests/test_reactjs/test_modules.py +++ b/tests/test_reactjs/test_modules.py @@ -14,7 +14,6 @@ async def display(browser): yield new_display -@pytest.mark.anyio async def test_nested_client_side_components(display: DisplayFixture): # Module A ComponentA = component_from_string( @@ -54,7 +53,6 @@ def App(): await display.page.wait_for_selector("#server-side") -@pytest.mark.anyio async def test_interleaved_client_server_components(display: DisplayFixture): # Module C ComponentC = component_from_string( diff --git a/tests/test_reactjs/test_modules_from_npm.py b/tests/test_reactjs/test_modules_from_npm.py index 9bd4da575..0b5fa468e 100644 --- a/tests/test_reactjs/test_modules_from_npm.py +++ b/tests/test_reactjs/test_modules_from_npm.py @@ -34,7 +34,6 @@ def App(): assert "btn-primary" in classes -@pytest.mark.anyio @pytest.mark.flaky(reruns=5, reruns_delay=2) async def test_component_from_npm_material_ui(display: DisplayFixture): Button = component_from_npm("@mui/material", "Button") @@ -51,7 +50,6 @@ def App(): assert "MuiButton-root" in classes -@pytest.mark.anyio @pytest.mark.flaky(reruns=5, reruns_delay=2) async def test_component_from_npm_antd(display: DisplayFixture): # Try antd v4 which might be more stable with esm.sh @@ -68,7 +66,6 @@ def App(): assert "ant-btn" in classes -@pytest.mark.anyio @pytest.mark.flaky(reruns=5, reruns_delay=2) async def test_component_from_npm_chakra_ui(display: DisplayFixture): ChakraProvider, Button = component_from_npm( @@ -88,7 +85,6 @@ def App(): assert "chakra-button" in classes -@pytest.mark.anyio @pytest.mark.flaky(reruns=5, reruns_delay=2) async def test_component_from_npm_semantic_ui_react(display: DisplayFixture): Button = component_from_npm("semantic-ui-react", "Button") @@ -105,7 +101,6 @@ def App(): assert "button" in classes -@pytest.mark.anyio @pytest.mark.flaky(reruns=5, reruns_delay=2) async def test_component_from_npm_mantine(display: DisplayFixture): MantineProvider, Button = component_from_npm( @@ -123,7 +118,6 @@ def App(): assert "mantine-Button-root" in classes -@pytest.mark.anyio @pytest.mark.flaky(reruns=5, reruns_delay=2) async def test_component_from_npm_fluent_ui(display: DisplayFixture): PrimaryButton = component_from_npm("@fluentui/react", "PrimaryButton") @@ -139,7 +133,6 @@ def App(): assert "ms-Button" in classes -@pytest.mark.anyio @pytest.mark.flaky(reruns=5, reruns_delay=2) async def test_component_from_npm_blueprint(display: DisplayFixture): Button = component_from_npm("@blueprintjs/core", "Button") @@ -155,7 +148,6 @@ def App(): assert any(c.startswith("bp") and "button" in c for c in classes.split()) -@pytest.mark.anyio @pytest.mark.flaky(reruns=5, reruns_delay=2) async def test_component_from_npm_grommet(display: DisplayFixture): Grommet, Button = component_from_npm("grommet", ["Grommet", "Button"]) @@ -171,7 +163,6 @@ def App(): assert await button.inner_text() == "Click me" -@pytest.mark.anyio @pytest.mark.flaky(reruns=5, reruns_delay=2) async def test_component_from_npm_evergreen(display: DisplayFixture): Button = component_from_npm("evergreen-ui", "Button") @@ -185,7 +176,6 @@ def App(): assert await button.inner_text() == "Click me" -@pytest.mark.anyio @pytest.mark.flaky(reruns=5, reruns_delay=2) async def test_component_from_npm_react_spinners(display: DisplayFixture): ClipLoader = component_from_npm("react-spinners", "ClipLoader") @@ -209,7 +199,6 @@ def App(): assert await loader.is_visible() -@pytest.mark.anyio @pytest.mark.flaky(reruns=5, reruns_delay=2) async def test_nested_npm_components(display: DisplayFixture): # Use Chakra UI Provider and Box, and nest a React Bootstrap Button inside @@ -246,7 +235,6 @@ def App(): assert "btn" in classes -@pytest.mark.anyio @pytest.mark.flaky(reruns=5, reruns_delay=2) async def test_interleaved_npm_and_server_components(display: DisplayFixture): Card = component_from_npm("antd", "Card", version="4.24.15") @@ -279,7 +267,6 @@ def App(): assert "MUI BUTTON" in await button.inner_text() # MUI capitalizes -@pytest.mark.anyio @pytest.mark.flaky(reruns=5, reruns_delay=2) async def test_complex_nested_material_ui(display: DisplayFixture): # Import multiple components from @mui/material From 3f927467bd566e792ff3053cdb8e9879087bf4fc Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 12 Dec 2025 16:23:20 -0800 Subject: [PATCH 12/12] fix coverage script sequence --- pyproject.toml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 83701e8f5..4ba2950d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,7 +95,12 @@ run = [ "playwright install-deps", "pytest{env:HATCH_TEST_ARGS:} {args}", ] -run-cov = "coverage run -m pytest{env:HATCH_TEST_ARGS:} {args}" +run-cov = [ + "hatch --env default build -t wheel", + "playwright install chromium", + "playwright install-deps", + "coverage run -m pytest{env:HATCH_TEST_ARGS:} {args}", +] cov-combine = "coverage combine" cov-report = "coverage report"