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:
2026-03-19 16:11:55 +00:00
parent f819fda587
commit d3b3b4b720
4 changed files with 102 additions and 74 deletions

View File

@@ -49,12 +49,12 @@ class TestHelperInjection(unittest.IsolatedAsyncioTestCase):
path = os.path.join(spec_dir, f)
if os.path.isfile(path):
async with self.bridge._lock:
self.bridge._send(f'(load "{_escape(path)}")')
await self.bridge._send(f'(load "{_escape(path)}")')
await self.bridge._read_until_ok(ctx=None)
adapter = os.path.join(web_dir, "adapter-sx.sx")
if os.path.isfile(adapter):
async with self.bridge._lock:
self.bridge._send(f'(load "{_escape(adapter)}")')
await self.bridge._send(f'(load "{_escape(adapter)}")')
await self.bridge._read_until_ok(ctx=None)
async def asyncTearDown(self):
@@ -66,7 +66,7 @@ class TestHelperInjection(unittest.IsolatedAsyncioTestCase):
arg_list = " ".join(chr(97 + i) for i in range(nargs))
sx_def = f'(define {name} (fn ({param_names}) (helper "{name}" {arg_list})))'
async with self.bridge._lock:
self.bridge._send(f'(load-source "{_escape(sx_def)}")')
await self.bridge._send(f'(load-source "{_escape(sx_def)}")')
await self.bridge._read_until_ok(ctx=None)
async def test_helper_call_returns_value(self):
@@ -87,7 +87,7 @@ class TestHelperInjection(unittest.IsolatedAsyncioTestCase):
# Define a 2-arg test helper via the generic helper binding
sx_def = '(define test-two-args (fn (a b) (helper "json-encode" (str a ":" b))))'
async with self.bridge._lock:
self.bridge._send(f'(load-source "{_escape(sx_def)}")')
await self.bridge._send(f'(load-source "{_escape(sx_def)}")')
await self.bridge._read_until_ok(ctx=None)
result = await self.bridge.eval(
@@ -121,7 +121,7 @@ class TestHelperInjection(unittest.IsolatedAsyncioTestCase):
# Define a component that calls the helper
async with self.bridge._lock:
self.bridge._send(
await self.bridge._send(
'(load-source "(defcomp ~test/code-display (&key code) '
'(pre (code code)))")'
)
@@ -155,12 +155,12 @@ class TestHelperIOPerformance(unittest.IsolatedAsyncioTestCase):
path = os.path.join(spec_dir, f)
if os.path.isfile(path):
async with self.bridge._lock:
self.bridge._send(f'(load "{_escape(path)}")')
await self.bridge._send(f'(load "{_escape(path)}")')
await self.bridge._read_until_ok(ctx=None)
adapter = os.path.join(web_dir, "adapter-sx.sx")
if os.path.isfile(adapter):
async with self.bridge._lock:
self.bridge._send(f'(load "{_escape(adapter)}")')
await self.bridge._send(f'(load "{_escape(adapter)}")')
await self.bridge._read_until_ok(ctx=None)
async def asyncTearDown(self):
@@ -172,7 +172,7 @@ class TestHelperIOPerformance(unittest.IsolatedAsyncioTestCase):
param_names = "a"
sx_def = '(define json-encode (fn (a) (helper "json-encode" a)))'
async with self.bridge._lock:
self.bridge._send(f'(load-source "{_escape(sx_def)}")')
await self.bridge._send(f'(load-source "{_escape(sx_def)}")')
await self.bridge._read_until_ok(ctx=None)
# Time 20 sequential calls (simulating a page with 20 highlight calls)
@@ -192,7 +192,7 @@ class TestHelperIOPerformance(unittest.IsolatedAsyncioTestCase):
"""aser_slot with multiple helper calls completes in reasonable time."""
sx_def = '(define json-encode (fn (a) (helper "json-encode" a)))'
async with self.bridge._lock:
self.bridge._send(f'(load-source "{_escape(sx_def)}")')
await self.bridge._send(f'(load-source "{_escape(sx_def)}")')
await self.bridge._read_until_ok(ctx=None)
# Define a component with multiple helper calls
@@ -206,7 +206,7 @@ class TestHelperIOPerformance(unittest.IsolatedAsyncioTestCase):
' (p (json-encode "e"))))'
)
async with self.bridge._lock:
self.bridge._send(f'(load-source "{_escape(comp_def)}")')
await self.bridge._send(f'(load-source "{_escape(comp_def)}")')
await self.bridge._read_until_ok(ctx=None)
start = time.monotonic()
@@ -237,12 +237,12 @@ class TestAserSlotClientAffinity(unittest.IsolatedAsyncioTestCase):
path = os.path.join(spec_dir, f)
if os.path.isfile(path):
async with self.bridge._lock:
self.bridge._send(f'(load "{_escape(path)}")')
await self.bridge._send(f'(load "{_escape(path)}")')
await self.bridge._read_until_ok(ctx=None)
adapter = os.path.join(web_dir, "adapter-sx.sx")
if os.path.isfile(adapter):
async with self.bridge._lock:
self.bridge._send(f'(load "{_escape(adapter)}")')
await self.bridge._send(f'(load "{_escape(adapter)}")')
await self.bridge._read_until_ok(ctx=None)
async def asyncTearDown(self):
@@ -255,7 +255,7 @@ class TestAserSlotClientAffinity(unittest.IsolatedAsyncioTestCase):
' (div "browser-only-content"))'
)
async with self.bridge._lock:
self.bridge._send(f'(load-source "{_escape(comp_def)}")')
await self.bridge._send(f'(load-source "{_escape(comp_def)}")')
await self.bridge._read_until_ok(ctx=None)
result = await self.bridge.aser_slot('(~test/client-only)')
@@ -270,7 +270,7 @@ class TestAserSlotClientAffinity(unittest.IsolatedAsyncioTestCase):
' (div :class "server" label))'
)
async with self.bridge._lock:
self.bridge._send(f'(load-source "{_escape(comp_def)}")')
await self.bridge._send(f'(load-source "{_escape(comp_def)}")')
await self.bridge._read_until_ok(ctx=None)
result = await self.bridge.aser(
@@ -287,7 +287,7 @@ class TestAserSlotClientAffinity(unittest.IsolatedAsyncioTestCase):
' (div "auto-content" label))'
)
async with self.bridge._lock:
self.bridge._send(f'(load-source "{_escape(comp_def)}")')
await self.bridge._send(f'(load-source "{_escape(comp_def)}")')
await self.bridge._read_until_ok(ctx=None)
result = await self.bridge.aser(
@@ -302,7 +302,7 @@ class TestAserSlotClientAffinity(unittest.IsolatedAsyncioTestCase):
' (div (deref (signal 0)) label))'
)
async with self.bridge._lock:
self.bridge._send(f'(load-source "{_escape(island_def)}")')
await self.bridge._send(f'(load-source "{_escape(island_def)}")')
await self.bridge._read_until_ok(ctx=None)
result = await self.bridge.aser_slot(
@@ -321,7 +321,7 @@ class TestAserSlotClientAffinity(unittest.IsolatedAsyncioTestCase):
' (div (h1 title) (~test/inner-isle :v 42)))'
)
async with self.bridge._lock:
self.bridge._send(f'(load-source "{_escape(src)}")')
await self.bridge._send(f'(load-source "{_escape(src)}")')
await self.bridge._read_until_ok(ctx=None)
result = await self.bridge.aser_slot(
@@ -340,7 +340,7 @@ class TestAserSlotClientAffinity(unittest.IsolatedAsyncioTestCase):
' (div "expanded" label))'
)
async with self.bridge._lock:
self.bridge._send(f'(load-source "{_escape(comp_def)}")')
await self.bridge._send(f'(load-source "{_escape(comp_def)}")')
await self.bridge._read_until_ok(ctx=None)
result = await self.bridge.aser_slot(