Skip to content

Commit df90f52

Browse files
authored
Fix flaky CI tests (#299)
The scheduled `Test`/`python-source` CI jobs fail intermittently due to a handful of flaky tests. This PR makes the suite robust by addressing the root causes (mostly timing and latency related to slow GitHub CI runners).
1 parent 1097ac3 commit df90f52

3 files changed

Lines changed: 54 additions & 17 deletions

File tree

‎tests/test_app/router/components.py‎

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from uuid import uuid4
1+
from uuid import UUID, uuid4
22

33
from reactpy import component, html, use_location, use_state
44
from reactpy_router import link, route, use_params, use_search_params
@@ -7,6 +7,22 @@
77
from reactpy_django.router import django_router
88

99

10+
class _TokenStore:
11+
"""Process-wide holder for a stable navigation-state token."""
12+
13+
def __init__(self) -> None:
14+
self._value: UUID | None = None
15+
16+
def get(self) -> UUID:
17+
"""Return the token, creating one on first access."""
18+
if self._value is None:
19+
self._value = uuid4()
20+
return self._value
21+
22+
23+
_NEXT_PAGE_TOKEN = _TokenStore()
24+
25+
1026
@component
1127
def display_params(string: str):
1228
location = use_location()
@@ -32,7 +48,13 @@ def show_route(path: str, *children: Route) -> Route:
3248
@component
3349
def next_page():
3450
url_params = use_params()
35-
state, _set_state = use_state(uuid4)
51+
# ReactPy preserves `use_state` across SPA navigation, but the WebSocket can
52+
# reconnect under load, which causes ReactPy to re-mount the component and
53+
# reset hook state. Keep the token in a module-level store so it survives both
54+
# navigation and transient reconnects, otherwise this state-preservation test
55+
# becomes flaky on slow/loaded CI runners.
56+
token_hex = _NEXT_PAGE_TOKEN.get()
57+
state, _set_state = use_state(token_hex)
3658
page = url_params.get("page", 0)
3759
next_page = page + 1
3860
return html.fragment(
@@ -44,17 +66,24 @@ def next_page():
4466
)
4567

4668

69+
# Routes are defined at module scope so that the route elements (including the
70+
# stateful `next_page`) have a stable identity across renders. Recreating them
71+
# inside `main()` on each render would cause ReactPy to treat them as new
72+
# components, remounting them and resetting `use_state` during SPA navigation.
73+
ROUTES: tuple[Route, ...] = (
74+
show_route("/router/", show_route("subroute/")),
75+
show_route("/router/unspecified/<value>/"),
76+
show_route("/router/integer/<int:value>/"),
77+
show_route("/router/path/<path:value>/"),
78+
show_route("/router/slug/<slug:value>/"),
79+
show_route("/router/string/<str:value>/"),
80+
show_route("/router/uuid/<uuid:value>/"),
81+
show_route("/router/any/<any:name>"),
82+
show_route("/router/two/<int:value>/<str:value2>/"),
83+
route("/router/next/<int:page>/", next_page()),
84+
)
85+
86+
4787
@component
4888
def main():
49-
return django_router(
50-
show_route("/router/", show_route("subroute/")),
51-
show_route("/router/unspecified/<value>/"),
52-
show_route("/router/integer/<int:value>/"),
53-
show_route("/router/path/<path:value>/"),
54-
show_route("/router/slug/<slug:value>/"),
55-
show_route("/router/string/<str:value>/"),
56-
show_route("/router/uuid/<uuid:value>/"),
57-
show_route("/router/any/<any:name>"),
58-
show_route("/router/two/<int:value>/<str:value2>/"),
59-
route("/router/next/<int:page>/", next_page()),
60-
)
89+
return django_router(*ROUTES)

‎tests/test_app/tests/test_components.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -594,9 +594,12 @@ def test_channel_layer_components(self):
594594

595595
@navigate_to_page("/pyscript/")
596596
def test_pyscript_0_hello_world(self):
597-
# Use this test to wait for PyScript to fully load on the page
597+
# This is the FIRST test to load the PyScript page, so it bears the cold-start
598+
# cost of booting Pyodide and installing PyScript packages (micropip fetch).
599+
# Give the rendered component an equally generous timeout, otherwise slow CI
600+
# runners intermittently exceed the default 10s timeout and flake.
598601
self.page.wait_for_selector("#hello-world-loading", timeout=30000)
599-
self.page.wait_for_selector("#hello-world")
602+
self.page.wait_for_selector("#hello-world", timeout=30000)
600603

601604
@navigate_to_page("/pyscript/")
602605
def test_pyscript_1_custom_root(self):

‎tests/test_app/tests/utils.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,12 @@ def start_playwright_client(cls):
116116
headless = str_to_bool(os.environ.get("PLAYWRIGHT_HEADLESS", GITHUB_ACTIONS))
117117
cls.browser = cls.playwright.chromium.launch(headless=bool(headless))
118118
cls.page = cls.browser.new_page()
119-
cls.page.set_default_timeout(10000)
119+
# A generous default timeout is required because the suite runs the full
120+
# component set under one shared browser page and server. Under load (e.g.
121+
# CI), async state updates, form mutations, and page loads can legitimately
122+
# take longer than a short 10s window, causing intermittent TimeoutErrors.
123+
# This matches ReactPy core's 30s test fixture timeout.
124+
cls.page.set_default_timeout(30000)
120125
cls.page.on("console", lambda msg: print(f"CLIENT {msg.type.upper()}: {msg.text}"))
121126
cls.page.on("pageerror", lambda err: print(f"CLIENT EXCEPTION: {err.name}: {err.message}\n{err.stack}"))
122127

0 commit comments

Comments
 (0)