Fix pipe desync: async drain on _send, robust Playwright tests
Root cause: OcamlBridge._send() used write() without drain(). asyncio.StreamWriter buffers writes — without drain(), multiple commands accumulate and flush as a batch. The kernel processes them sequentially, sending responses, but Python only reads one response per command → pipe desync → "unexpected response" errors. Fix: _send() is now async, calls drain() after every write. All 14 callers updated to await. Playwright tests rewritten: - test_home_has_header: verifies #logo-opacity visible (was only checking for "sx" text — never caught missing header) - test_home_has_nav_children: Geography link must be visible - test_home_has_main_panel: #main-panel must have child elements - TestDirectPageLoad: fresh browser.new_context() per test to avoid stale component hash in localStorage - _setup_error_capture + _check_no_fatal_errors helpers _render_to_sx uses aser_slot (not aser) — layout wrappers contain re-parsed content that needs full expansion capability. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -216,18 +216,8 @@ async def eval_sx_url(raw_path: str) -> Any:
|
||||
Keyword("content"), wrapped_ast,
|
||||
]
|
||||
full_text = serialize(full_ast)
|
||||
has_nl = chr(10) in full_text
|
||||
if has_nl:
|
||||
logger.error("NEWLINE in aser_slot input at char %d!",
|
||||
full_text.index(chr(10)))
|
||||
import time as _time
|
||||
_t0 = _time.monotonic()
|
||||
body_sx = SxExpr(await bridge.aser_slot(
|
||||
full_text, ctx=ocaml_ctx))
|
||||
_elapsed = _time.monotonic() - _t0
|
||||
logger.info("aser_slot: %.1fs, input=%d chars, output=%d chars, starts=%s",
|
||||
_elapsed, len(full_text), len(body_sx),
|
||||
str(body_sx)[:100])
|
||||
tctx = await get_template_context()
|
||||
return await make_response(
|
||||
await sx_page(tctx, body_sx), 200)
|
||||
|
||||
@@ -506,61 +506,95 @@ class TestSpecExplorer:
|
||||
# Key doc pages (smoke tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _check_no_fatal_errors(errors):
|
||||
"""Assert no fatal JS errors were collected."""
|
||||
fatal = [e for e in errors
|
||||
if any(s in e for s in ("Not callable", "Undefined symbol",
|
||||
"SES_UNCAUGHT", "UNCAUGHT"))]
|
||||
assert not fatal, f"JS errors: {fatal}"
|
||||
|
||||
|
||||
def _setup_error_capture(page):
|
||||
"""Attach error listeners, return the errors list."""
|
||||
errors = []
|
||||
page.on("pageerror", lambda err: errors.append(f"UNCAUGHT: {err.message}"))
|
||||
page.on("console", lambda msg: errors.append(msg.text) if msg.type == "error" else None)
|
||||
return errors
|
||||
|
||||
|
||||
class TestHomePage:
|
||||
def test_home_loads(self, page: Page):
|
||||
def test_home_has_header(self, page: Page):
|
||||
"""Home page MUST have the (<sx>) header with logo."""
|
||||
errors = _setup_error_capture(page)
|
||||
nav(page, "")
|
||||
expect(page.locator("#main-panel")).to_contain_text("sx", timeout=10000)
|
||||
# Header logo — the (<sx>) text
|
||||
expect(page.locator("#logo-opacity")).to_be_visible(timeout=15000)
|
||||
_check_no_fatal_errors(errors)
|
||||
|
||||
def test_home_has_nav_children(self, page: Page):
|
||||
"""Home page MUST have nav children (Geography, Language, etc.)."""
|
||||
nav(page, "")
|
||||
expect(page.locator("a[sx-push-url]:has-text('Geography')")).to_be_visible(timeout=15000)
|
||||
|
||||
def test_home_has_main_panel(self, page: Page):
|
||||
"""Home page MUST render #main-panel with content inside it."""
|
||||
nav(page, "")
|
||||
expect(page.locator("#main-panel")).to_be_visible(timeout=15000)
|
||||
# Must have actual content (divs/headings), not be empty
|
||||
page.wait_for_selector("#main-panel div, #main-panel h2, #main-panel p", timeout=15000)
|
||||
|
||||
def test_no_console_errors(self, page: Page):
|
||||
"""Home page should have no JS errors (console or uncaught)."""
|
||||
errors = []
|
||||
page.on("console", lambda msg: errors.append(msg.text) if msg.type == "error" else None)
|
||||
page.on("pageerror", lambda err: errors.append(f"UNCAUGHT: {err.message}"))
|
||||
errors = _setup_error_capture(page)
|
||||
page.goto(f"{BASE}/sx/", wait_until="networkidle")
|
||||
page.wait_for_timeout(3000)
|
||||
fatal = [e for e in errors if "Not callable" in e or "Undefined symbol" in e or "SES_UNCAUGHT" in e or "UNCAUGHT" in e]
|
||||
assert not fatal, f"JS errors on home page: {fatal}"
|
||||
_check_no_fatal_errors(errors)
|
||||
|
||||
def test_navigate_from_home_to_geography(self, page: Page):
|
||||
"""Click Geography nav link from home — content must render."""
|
||||
errors = []
|
||||
page.on("pageerror", lambda err: errors.append(f"UNCAUGHT: {err.message}"))
|
||||
errors = _setup_error_capture(page)
|
||||
nav(page, "")
|
||||
# Click the Geography link in the nav children
|
||||
geo_link = page.locator("a[sx-push-url]:has-text('Geography')").first
|
||||
expect(geo_link).to_be_visible(timeout=10000)
|
||||
geo_link.click()
|
||||
page.wait_for_timeout(5000)
|
||||
# Content must still be visible after navigation
|
||||
expect(page.locator("#main-panel")).to_contain_text("Geography", timeout=10000)
|
||||
fatal = [e for e in errors if "Not callable" in e or "Undefined symbol" in e or "UNCAUGHT" in e]
|
||||
assert not fatal, f"JS errors after navigation: {fatal}"
|
||||
|
||||
_check_no_fatal_errors(errors)
|
||||
|
||||
def test_navigate_geography_to_reactive(self, page: Page):
|
||||
"""Click Reactive Islands from Geography — content must render."""
|
||||
errors = []
|
||||
page.on("pageerror", lambda err: errors.append(f"UNCAUGHT: {err.message}"))
|
||||
errors = _setup_error_capture(page)
|
||||
nav(page, "(geography)")
|
||||
ri_link = page.locator("a[sx-push-url]:has-text('Reactive Islands')").first
|
||||
expect(ri_link).to_be_visible(timeout=10000)
|
||||
ri_link.click()
|
||||
page.wait_for_timeout(5000)
|
||||
expect(page.locator("#main-panel")).to_contain_text("Reactive Islands", timeout=10000)
|
||||
fatal = [e for e in errors if "Not callable" in e or "Undefined symbol" in e or "UNCAUGHT" in e]
|
||||
assert not fatal, f"JS errors after navigation: {fatal}"
|
||||
_check_no_fatal_errors(errors)
|
||||
|
||||
def test_direct_load_reactive_page(self, page: Page):
|
||||
"""Direct load of reactive islands page — no errors, content renders."""
|
||||
errors = []
|
||||
page.on("pageerror", lambda err: errors.append(f"UNCAUGHT: {err.message}"))
|
||||
page.on("console", lambda msg: errors.append(msg.text) if msg.type == "error" else None)
|
||||
page.goto(f"{BASE}/sx/(geography.(reactive))", wait_until="networkidle")
|
||||
# aser_slot may take several seconds on first load — wait for render
|
||||
page.wait_for_selector("#main-panel", timeout=30000)
|
||||
expect(page.locator("#main-panel")).to_contain_text("Reactive Islands", timeout=10000)
|
||||
fatal = [e for e in errors if "Not callable" in e or "Undefined symbol" in e or "SES_UNCAUGHT" in e or "UNCAUGHT" in e]
|
||||
assert not fatal, f"JS errors on reactive page: {fatal}"
|
||||
|
||||
class TestDirectPageLoad:
|
||||
"""Direct page loads (not HTMX) — each uses a FRESH browser context
|
||||
to avoid stale component hash in localStorage from prior tests."""
|
||||
|
||||
@pytest.mark.parametrize("path,expected_text", [
|
||||
("(geography.(reactive))", "Reactive Islands"),
|
||||
("(etc)", "Etc"),
|
||||
("(geography)", "Geography"),
|
||||
])
|
||||
def test_page_renders_with_header(self, browser, path: str, expected_text: str):
|
||||
"""Page must have header, #main-panel, and expected content."""
|
||||
ctx = browser.new_context(ignore_https_errors=True)
|
||||
page = ctx.new_page()
|
||||
try:
|
||||
errors = _setup_error_capture(page)
|
||||
page.goto(f"{BASE}/sx/{path}", wait_until="networkidle")
|
||||
page.wait_for_selector("#main-panel", timeout=30000)
|
||||
expect(page.locator("#main-panel")).to_contain_text(expected_text, timeout=10000)
|
||||
expect(page.locator("#sx-nav")).to_be_visible(timeout=5000)
|
||||
_check_no_fatal_errors(errors)
|
||||
finally:
|
||||
ctx.close()
|
||||
|
||||
|
||||
class TestDocPages:
|
||||
|
||||
Reference in New Issue
Block a user