From 8476246b23752590c467a39e9134b0b096e4a92e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 21:44:02 +0000 Subject: [PATCH] Deflake test_memo: gate on hydration and capture page diagnostics test_memo interacted with the page immediately after goto, racing the websocket connect/hydrate startup; the typed input event could be lost, leaving #memo-last-value empty through every rerun of an affected CI job. Gate all memo tests on the standard #token hydration marker used by the rest of the playwright suite before interacting. Also add a tests_playwright conftest that records console messages, page errors, and websocket frames per test and attaches them to failed test reports, so any future flake of this kind is diagnosable directly from CI job logs instead of reproducing blind. https://claude.ai/code/session_01Xvaqxi4LJeWu2fo4Fk7qMD --- .../integration/tests_playwright/conftest.py | 70 +++++++++++++++++++ .../integration/tests_playwright/test_memo.py | 37 +++++++--- 2 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 tests/integration/tests_playwright/conftest.py 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)