diff --git a/tests/integration/tests_playwright/conftest.py b/tests/integration/tests_playwright/conftest.py new file mode 100644 index 00000000000..985a38a2eea --- /dev/null +++ b/tests/integration/tests_playwright/conftest.py @@ -0,0 +1,70 @@ +"""Conftest for Playwright integration tests. + +Records browser-side activity (console messages, page errors, websocket +frames) for each test's page and attaches it to the test report on failure, +so CI logs contain enough context to diagnose flaky frontend behavior +post-mortem. +""" + +import time + +import pytest + +# Cap recorded entries per test so a chatty page can't bloat memory or logs. +_MAX_DIAGNOSTIC_ENTRIES = 500 +_MAX_ENTRY_LENGTH = 300 + + +@pytest.fixture(autouse=True) +def _page_diagnostics(request): + """Capture console/pageerror/websocket activity from the ``page`` fixture. + + Args: + request: The pytest fixture request object. + + Yields: + Control to the test function. + """ + if "page" not in request.fixturenames: + yield + return + page = request.getfixturevalue("page") + log: list[str] = [] + t0 = time.monotonic() + + def stamp(message: str) -> None: + if len(log) < _MAX_DIAGNOSTIC_ENTRIES: + log.append(f"+{time.monotonic() - t0:.3f}s {message[:_MAX_ENTRY_LENGTH]}") + + page.on("console", lambda msg: stamp(f"console.{msg.type}: {msg.text}")) + page.on("pageerror", lambda exc: stamp(f"pageerror: {exc}")) + + def _on_websocket(ws) -> None: + stamp(f"ws open: {ws.url}") + ws.on("framesent", lambda frame: stamp(f"ws sent: {frame}")) + ws.on("framereceived", lambda frame: stamp(f"ws recv: {frame}")) + ws.on("close", lambda _ws: stamp("ws close")) + + page.on("websocket", _on_websocket) + request.node._page_diagnostics = log + yield + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_makereport(item, call): + """Attach recorded page diagnostics to failed (or rerun) call reports. + + Args: + item: The test item being reported on. + call: The call info for the current test phase. + + Yields: + Control to other report hooks. + """ + outcome = yield + report = outcome.get_result() + if report.when != "call" or not (report.failed or report.outcome == "rerun"): + return + log = getattr(item, "_page_diagnostics", None) + if log: + report.sections.append((f"page diagnostics ({report.outcome})", "\n".join(log))) diff --git a/tests/integration/tests_playwright/test_memo.py b/tests/integration/tests_playwright/test_memo.py index 0d43ef526e5..32853d35974 100644 --- a/tests/integration/tests_playwright/test_memo.py +++ b/tests/integration/tests_playwright/test_memo.py @@ -83,6 +83,11 @@ def keyed_row(label: rx.Var[str]) -> rx.Component: def index() -> rx.Component: return rx.vstack( + rx.input( + value=MemoState.router.session.client_token, + read_only=True, + id="token", + ), rx.text(MemoState.last_value, id="memo-last-value"), my_memoed_component( some_value="memod_some_value", event=MemoState.set_last_value @@ -125,6 +130,23 @@ def memo_app( yield harness +def load_page(memo_app: AppHarness, page: Page) -> None: + """Navigate to the app and wait for hydration before interacting. + + The ``#token`` input is bound to router state, so it only gets a value + once the websocket is connected and the hydrate round trip has finished. + Interacting before that point races the event-loop startup (the typed + input event can be lost), which made these tests flaky in CI. + + Args: + memo_app: Running app harness. + page: Playwright page. + """ + assert memo_app.frontend_url is not None + page.goto(memo_app.frontend_url) + expect(page.locator("#token")).not_to_have_value("") + + def test_memo_event_handler_partial_application( memo_app: AppHarness, page: Page ) -> None: @@ -134,8 +156,7 @@ def test_memo_event_handler_partial_application( memo_app: Running app harness. page: Playwright page. """ - assert memo_app.frontend_url is not None - page.goto(memo_app.frontend_url) + load_page(memo_app, page) expect(page.locator("#memo-last-value")).to_have_text("") page.click("#memo-button") @@ -149,8 +170,7 @@ def test_memo_event_handler_raw_pass_through(memo_app: AppHarness, page: Page) - memo_app: Running app harness. page: Playwright page. """ - assert memo_app.frontend_url is not None - page.goto(memo_app.frontend_url) + load_page(memo_app, page) page.locator("#memo-input").fill("typed_value") expect(page.locator("#memo-last-value")).to_have_text("typed_value") @@ -163,8 +183,7 @@ def test_memo_recursive_tree_render(memo_app: AppHarness, page: Page) -> None: memo_app: Running app harness. page: Playwright page. """ - assert memo_app.frontend_url is not None - page.goto(memo_app.frontend_url) + load_page(memo_app, page) tree_root = page.locator("#tree-root") node_names = tree_root.locator(".tree-node-name") @@ -179,8 +198,7 @@ def test_memo_recursive_tree_reacts_to_state(memo_app: AppHarness, page: Page) - memo_app: Running app harness. page: Playwright page. """ - assert memo_app.frontend_url is not None - page.goto(memo_app.frontend_url) + load_page(memo_app, page) node_names = page.locator("#tree-root .tree-node-name") expect(node_names).to_have_count(4) @@ -206,8 +224,7 @@ def test_memo_key_preserves_identity_across_reorder( memo_app: Running app harness. page: Playwright page. """ - assert memo_app.frontend_url is not None - page.goto(memo_app.frontend_url) + load_page(memo_app, page) rows = page.locator("#keyed-rows input") expect(rows).to_have_count(3)