Skip to content

Commit f44acf4

Browse files
committed
Add compatibility for test in --parallel mode
1 parent b5fd55a commit f44acf4

File tree

14 files changed

+159
-100
lines changed

14 files changed

+159
-100
lines changed

.github/copilot-instructions.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,10 @@ pip install flask sanic tornado
5050

5151
**Run Python Tests:**
5252

53-
- `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.**
54-
- `hatch test --cover` -- run tests with coverage reporting (used in CI)
55-
- `hatch test -k test_name` -- run specific tests
56-
- `hatch test tests/test_config.py` -- run specific test files
53+
- `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.**
54+
- `hatch test --parallel --cover` -- run tests with coverage reporting (used in CI)
55+
- `hatch test --parallel -k test_name` -- run specific tests
56+
- `hatch test --parallel tests/test_config.py` -- run specific test files
5757

5858
**Run Python Linting and Formatting:**
5959

@@ -152,7 +152,7 @@ print(f"✓ Hook-based component: {type(counter)}")
152152
- `hatch run javascript:check` -- Ensure JavaScript passes linting (never expected to fail)
153153
- Test basic component creation and rendering as shown above
154154
- Test server creation if working on server-related features
155-
- Run relevant tests with `hatch test` -- **All tests must always pass - failures are never expected or allowed**
155+
- Run relevant tests with `hatch test --parallel` -- **All tests must always pass - failures are never expected or allowed**
156156

157157
**Integration Testing:**
158158

@@ -263,9 +263,9 @@ The following are key commands for daily development:
263263
### Development Commands
264264

265265
```bash
266-
hatch test # Run all tests (**All tests must always pass**)
267-
hatch test --cover # Run tests with coverage (used in CI)
268-
hatch test -k test_name # Run specific tests
266+
hatch test --parallel # Run all tests (**All tests must always pass**)
267+
hatch test --parallel --cover # Run tests with coverage (used in CI)
268+
hatch test --parallel -k test_name # Run specific tests
269269
hatch fmt # Format code with all formatters
270270
hatch fmt --check # Check formatting without changes
271271
hatch run python:type_check # Run Python type checker
@@ -303,7 +303,7 @@ Follow this step-by-step process for effective development:
303303
3. **Run formatting**: `hatch fmt` to format code (~1 second)
304304
4. **Run type checking**: `hatch run python:type_check` for type checking (~10 seconds)
305305
5. **Run JavaScript linting** (if JavaScript was modified): `hatch run javascript:check` (~10 seconds)
306-
6. **Run relevant tests**: `hatch test` with specific test selection if needed. **All tests must always pass - failures are never expected or allowed.**
306+
6. **Run relevant tests**: `hatch test --parallel` with specific test selection if needed. **All tests must always pass - failures are never expected or allowed.**
307307
7. **Validate component functionality** manually using validation tests above
308308
8. **Build JavaScript** (if modified): `hatch run javascript:build` (~15 seconds)
309309
9. **Update documentation** when making changes to Python source code (required)
@@ -365,7 +365,7 @@ Modern dependency management via pyproject.toml:
365365

366366
The repository uses GitHub Actions with these key jobs:
367367

368-
- `test-python-coverage` -- Python test coverage with `hatch test --cover`
368+
- `test-python-coverage` -- Python test coverage with `hatch test --parallel --cover`
369369
- `lint-python` -- Python linting and type checking via `hatch fmt --check` and `hatch run python:type_check`
370370
- `test-python` -- Cross-platform Python testing across Python 3.10-3.13 and Ubuntu/macOS/Windows
371371
- `lint-javascript` -- JavaScript linting and type checking

.github/workflows/check.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ jobs:
1515
uses: ./.github/workflows/.hatch-run.yml
1616
with:
1717
job-name: "python-{0}"
18-
run-cmd: "hatch test --cover"
18+
run-cmd: "hatch test --parallel --cover"
1919
lint-python:
2020
uses: ./.github/workflows/.hatch-run.yml
2121
with:
@@ -25,7 +25,7 @@ jobs:
2525
uses: ./.github/workflows/.hatch-run.yml
2626
with:
2727
job-name: "python-{0} {1}"
28-
run-cmd: "hatch test"
28+
run-cmd: "hatch test --parallel"
2929
runs-on: '["ubuntu-latest", "macos-latest", "windows-latest"]'
3030
python-version: '["3.11", "3.12", "3.13", "3.14"]'
3131
test-documentation:

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ extra-dependencies = [
9898
"exceptiongroup",
9999
"jsonpointer",
100100
"starlette",
101+
"filelock",
101102
]
102103
features = ["all"]
103104

@@ -119,7 +120,7 @@ asyncio_mode = "auto"
119120
asyncio_default_fixture_loop_scope = "session"
120121
asyncio_default_test_loop_scope = "session"
121122
log_cli_level = "INFO"
122-
timeout = 30
123+
timeout = 60
123124

124125
#######################################
125126
# >>> Hatch Documentation Scripts <<< #

src/reactpy/reactjs/module.py

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
module_name_suffix,
1515
resolve_from_module_file,
1616
resolve_from_module_url,
17+
simple_file_lock,
1718
)
1819
from reactpy.types import ImportSourceDict, JavaScriptModule, VdomConstructor
1920

@@ -54,19 +55,20 @@ def file_to_module(
5455

5556
source_file = Path(file).resolve()
5657
target_file = get_module_path(name)
57-
if not source_file.exists():
58-
msg = f"Source file does not exist: {source_file}"
59-
raise FileNotFoundError(msg)
6058

61-
if not target_file.exists():
62-
copy_file(target_file, source_file, symlink)
63-
elif not are_files_identical(source_file, target_file):
64-
logger.info(
65-
f"Existing web module {name!r} will "
66-
f"be replaced with {target_file.resolve()}"
67-
)
68-
target_file.unlink()
69-
copy_file(target_file, source_file, symlink)
59+
with simple_file_lock(target_file.with_name(target_file.name + ".lock")):
60+
if not source_file.exists():
61+
msg = f"Source file does not exist: {source_file}"
62+
raise FileNotFoundError(msg)
63+
64+
if not target_file.exists():
65+
copy_file(target_file, source_file, symlink)
66+
elif not are_files_identical(source_file, target_file):
67+
logger.info(
68+
f"Existing web module {name!r} will "
69+
f"be replaced with {target_file.resolve()}"
70+
)
71+
copy_file(target_file, source_file, symlink)
7072

7173
return JavaScriptModule(
7274
source=name,

src/reactpy/reactjs/utils.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import filecmp
22
import logging
3+
import os
34
import re
45
import shutil
6+
import time
7+
from contextlib import contextmanager, suppress
58
from pathlib import Path, PurePosixPath
69
from urllib.parse import urlparse, urlunparse
710

@@ -167,9 +170,26 @@ def are_files_identical(f1: Path, f2: Path) -> bool:
167170
def copy_file(target: Path, source: Path, symlink: bool) -> None:
168171
target.parent.mkdir(parents=True, exist_ok=True)
169172
if symlink:
173+
if target.exists():
174+
target.unlink()
170175
target.symlink_to(source)
171176
else:
172-
shutil.copy(source, target)
177+
temp_target = target.with_suffix(target.suffix + ".tmp")
178+
shutil.copy(source, temp_target)
179+
try:
180+
temp_target.replace(target)
181+
except OSError:
182+
# On Windows, replace might fail if the file is open
183+
# Retry once after a short delay
184+
time.sleep(0.1)
185+
try:
186+
temp_target.replace(target)
187+
except OSError:
188+
# If it still fails, try to unlink and rename
189+
# This is not atomic, but it's a fallback
190+
if target.exists():
191+
target.unlink()
192+
temp_target.rename(target)
173193

174194

175195
_JS_DEFAULT_EXPORT_PATTERN = re.compile(
@@ -181,3 +201,22 @@ def copy_file(target: Path, source: Path, symlink: bool) -> None:
181201
_JS_GENERAL_EXPORT_PATTERN = re.compile(
182202
r"(?:^|;|})\s*export(?=\s+|{)(.*?)(?=;|$)", re.MULTILINE
183203
)
204+
205+
206+
@contextmanager
207+
def simple_file_lock(lock_file: Path, timeout: float = 10.0):
208+
start_time = time.time()
209+
while True:
210+
try:
211+
fd = os.open(lock_file, os.O_CREAT | os.O_EXCL | os.O_RDWR)
212+
os.close(fd)
213+
break
214+
except OSError as e:
215+
if time.time() - start_time > timeout:
216+
raise TimeoutError(f"Could not acquire lock {lock_file}") from e
217+
time.sleep(0.1)
218+
try:
219+
yield
220+
finally:
221+
with suppress(OSError):
222+
os.unlink(lock_file)

src/reactpy/testing/backend.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import asyncio
44
import logging
5+
import socket
56
from collections.abc import Callable
67
from contextlib import AsyncExitStack
78
from types import TracebackType
@@ -21,7 +22,6 @@
2122
capture_reactpy_logs,
2223
list_logged_exceptions,
2324
)
24-
from reactpy.testing.utils import find_available_port
2525
from reactpy.types import ComponentConstructor
2626
from reactpy.utils import Ref
2727

@@ -51,7 +51,7 @@ def __init__(
5151
**reactpy_config: Any,
5252
) -> None:
5353
self.host = host
54-
self.port = port or find_available_port(host)
54+
self.port = port or 0
5555
self.mount = mount_to_hotswap
5656
self.timeout = (
5757
REACTPY_TESTS_DEFAULT_TIMEOUT.current if timeout is None else timeout
@@ -122,7 +122,24 @@ async def __aenter__(self) -> BackendFixture:
122122
# Wait for the server to start
123123
self.webserver.config.get_loop_factory()
124124
self.webserver_task = asyncio.create_task(self.webserver.serve())
125-
await asyncio.sleep(1)
125+
for _ in range(100):
126+
if self.webserver.started and self.webserver.servers:
127+
break
128+
await asyncio.sleep(0.1)
129+
else:
130+
msg = "Server failed to start"
131+
raise RuntimeError(msg)
132+
133+
# Determine the port if it was set to 0 (auto-select port)
134+
if self.port == 0:
135+
for server in self.webserver.servers:
136+
for sock in server.sockets:
137+
if sock.family == socket.AF_INET:
138+
self.port = sock.getsockname()[1]
139+
self.webserver.config.port = self.port
140+
break
141+
if self.port != 0:
142+
break
126143

127144
return self
128145

src/reactpy/testing/display.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import os
34
from contextlib import AsyncExitStack
45
from types import TracebackType
56
from typing import Any
@@ -23,6 +24,7 @@ def __init__(
2324
self,
2425
backend: BackendFixture | None = None,
2526
browser: Browser | None = None,
27+
headless: bool = False,
2628
) -> None:
2729
if backend:
2830
self.backend_is_external = True
@@ -32,6 +34,8 @@ def __init__(
3234
self.browser_is_external = True
3335
self.browser = browser
3436

37+
self.headless = headless
38+
3539
async def show(
3640
self,
3741
component: RootComponentConstructor,
@@ -49,7 +53,11 @@ async def __aenter__(self) -> DisplayFixture:
4953

5054
if not hasattr(self, "browser"):
5155
pw = await self.browser_exit_stack.enter_async_context(async_playwright())
52-
self.browser = await pw.chromium.launch(headless=GITHUB_ACTIONS)
56+
self.browser = await pw.chromium.launch(
57+
headless=self.headless
58+
or os.environ.get("PLAYWRIGHT_HEADLESS") == "1"
59+
or GITHUB_ACTIONS
60+
)
5361
await self.configure_page()
5462

5563
if not hasattr(self, "backend"): # nocov

src/reactpy/testing/utils.py

Lines changed: 0 additions & 27 deletions
This file was deleted.

tests/conftest.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
import contextlib
44
import os
55
import subprocess
6+
import sys
7+
from time import sleep
68

79
import pytest
810
from _pytest.config.argparsing import Parser
11+
from filelock import FileLock
912

1013
from reactpy.config import (
1114
REACTPY_ASYNC_RENDERING,
@@ -32,6 +35,17 @@ def pytest_addoption(parser: Parser) -> None:
3235
)
3336

3437

38+
def headless_environ(pytestconfig: pytest.Config):
39+
if (
40+
pytestconfig.getoption("headless")
41+
or os.environ.get("PLAYWRIGHT_HEADLESS") == "1"
42+
or GITHUB_ACTIONS
43+
):
44+
os.environ["PLAYWRIGHT_HEADLESS"] = "1"
45+
return True
46+
return False
47+
48+
3549
@pytest.fixture(autouse=True, scope="session")
3650
def install_playwright():
3751
subprocess.run(["playwright", "install", "chromium"], check=True) # noqa: S607
@@ -41,15 +55,24 @@ def install_playwright():
4155

4256

4357
@pytest.fixture(autouse=True, scope="session")
44-
def rebuild():
58+
def rebuild(tmp_path_factory, worker_id):
4559
# When running inside `hatch test`, the `HATCH_ENV_ACTIVE` environment variable
4660
# is set. If we try to run `hatch build` with this variable set, Hatch will
4761
# complain that the current environment is not a builder environment.
4862
# To fix this, we remove `HATCH_ENV_ACTIVE` from the environment variables
4963
# passed to the subprocess.
5064
env = os.environ.copy()
5165
env.pop("HATCH_ENV_ACTIVE", None)
52-
subprocess.run(["hatch", "build", "-t", "wheel"], check=True, env=env) # noqa: S607
66+
67+
root_tmp_dir = tmp_path_factory.getbasetemp().parent
68+
fn = root_tmp_dir / "build.lock"
69+
flag = root_tmp_dir / "build.done"
70+
71+
# Whoever gets the lock first performs the build.
72+
with FileLock(str(fn)):
73+
if not flag.exists():
74+
subprocess.run(["hatch", "build", "-t", "wheel"], check=True, env=env) # noqa: S607
75+
flag.touch()
5376

5477

5578
@pytest.fixture(scope="session")
@@ -69,9 +92,7 @@ async def browser(pytestconfig: pytest.Config):
6992
from playwright.async_api import async_playwright
7093

7194
async with async_playwright() as pw:
72-
yield await pw.chromium.launch(
73-
headless=bool(pytestconfig.option.headless) or GITHUB_ACTIONS
74-
)
95+
yield await pw.chromium.launch(headless=headless_environ(pytestconfig))
7596

7697

7798
@pytest.fixture(autouse=True)

0 commit comments

Comments
 (0)