Skip to content

Commit bc937db

Browse files
fix(e2e): async wait_for_function predicates never waited — poll from Python
page.wait_for_function does not await a returned Promise: the Promise object itself is truthy, so every 'async () => ...' predicate passed on its first poll no matter what it would have resolved to. Verified empirically — wait_for_function('async () => false') returns instantly. That silent no-op is how phase 5 of the body-double journey went red on main (run 33859082268): its 'wait until the server holds both notes' guard never waited, and the active_total read raced the second POST on a loaded runner. Six call sites carried the same defect — one had already failed, five were latent. Add _env.await_async_predicate, which drives the predicate through page.evaluate (which DOES await) on a Python-side deadline, and convert all six sites. Positive control per the campaign's trap #1: an always-false predicate times out, an eventually-true one passes at the flip. Sync predicates keep using wait_for_function, which is cheaper. Verified: test_body_double_journey 13 passed; test_session_recovery_journey + test_ghostty_dev_terminal 40 passed, 2 skipped.
1 parent 092936c commit bc937db

4 files changed

Lines changed: 84 additions & 16 deletions

File tree

packages/studyloop/tests/e2e/_env.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,3 +468,35 @@ def goto_view(page: Page, view: str) -> None:
468468
page.wait_for_function("() => !!window.Alpine && !!window.Alpine.store('nav')", timeout=15000)
469469
page.evaluate("(v) => window.Alpine.store('nav').go(v)", view)
470470
page.wait_for_timeout(250)
471+
472+
473+
def await_async_predicate(
474+
page: Page,
475+
js: str,
476+
*,
477+
arg: object = None,
478+
timeout: float = 15.0,
479+
what: str = "async page predicate",
480+
poll_ms: int = 200,
481+
) -> None:
482+
"""Poll an ``async`` JS predicate until truthy, from Python.
483+
484+
Exists because ``page.wait_for_function`` does NOT await a returned
485+
Promise: the Promise object itself is truthy, so an ``async () => …``
486+
predicate passes on its first poll no matter what it would resolve to —
487+
the wait is silently a no-op. (Verified:
488+
``wait_for_function("async () => false")`` returns instantly.) That
489+
no-op is exactly how phase 5 of the body-double journey went red on CI
490+
twice on 2026-09-04: its "wait until the server holds both notes" guard
491+
never waited, and the read raced the second POST.
492+
493+
``page.evaluate`` DOES await, so this loop drives it from Python with a
494+
deadline. Sync predicates should keep using ``wait_for_function``, which
495+
polls in-page and is cheaper.
496+
"""
497+
deadline = time.monotonic() + timeout
498+
while time.monotonic() < deadline:
499+
if page.evaluate(js, arg):
500+
return
501+
page.wait_for_timeout(poll_ms)
502+
raise AssertionError(f"timed out after {timeout:.0f}s waiting for {what}")

packages/studyloop/tests/e2e/test_body_double_journey.py

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,12 @@
5151
sys.path.insert(0, _tests_dir)
5252

5353
from _playwright_paths import PLAYWRIGHT_ARTIFACTS as RESULTS # noqa: E402
54-
from e2e._env import RunningServer, build_test_world, start_server # noqa: E402
54+
from e2e._env import ( # noqa: E402
55+
RunningServer,
56+
await_async_predicate,
57+
build_test_world,
58+
start_server,
59+
)
5560

5661
if TYPE_CHECKING:
5762
from collections.abc import Generator
@@ -293,16 +298,19 @@ def _park(page: Page, question: str, notes: str = "") -> None:
293298

294299
# The invariant the product actually guarantees: one more pending topic
295300
# exists server-side. Polled through the page so it shares the browser's
296-
# origin and auth, exactly as _api does.
297-
page.wait_for_function(
301+
# origin and auth, exactly as _api does. await_async_predicate, not
302+
# wait_for_function: the latter does not await async predicates (_env.py).
303+
await_async_predicate(
304+
page,
298305
"""async (want) => {
299306
const r = await fetch('/api/backlog');
300307
if (!r.ok) return false;
301308
const b = await r.json();
302309
return (b.active_count + b.parking_lot_count) >= want;
303310
}""",
304311
arg=expected,
305-
timeout=15_000,
312+
timeout=15.0,
313+
what=f"backlog to reach {expected} pending topics",
306314
)
307315
# Only now is the form guaranteed quiescent: submitPark() clears the
308316
# question field in its success path, so returning before that lands would
@@ -619,9 +627,16 @@ def test_phase5_notes_are_structured_markdown_and_preview_renders(
619627
# then races the second POST. The write itself is durable once it returns
620628
# -- add_note commits before responding -- so polling the count is the
621629
# honest signal. test_phase8 already polls a count for this reason.
622-
page.wait_for_function(
630+
#
631+
# await_async_predicate, not wait_for_function: the previous de-flake
632+
# used wait_for_function with an async predicate, which never awaits
633+
# the Promise and so never waited at all — red on main twice,
634+
# 2026-09-04. See _env.await_async_predicate.
635+
await_async_predicate(
636+
page,
623637
"async () => (await (await fetch('/api/notes')).json()).active_total === 2",
624-
timeout=10_000,
638+
timeout=10.0,
639+
what="both notes to land server-side",
625640
)
626641

627642
notes = _api(page, "/api/notes")
@@ -1092,16 +1107,19 @@ def test_phase10_park_form_survives_a_draft_typed_while_a_save_is_in_flight(
10921107
page.locator("#bd-park-submit").click()
10931108
page.locator("#bd-park-question").fill("Race second tangent")
10941109

1095-
# Let the first save complete server-side.
1096-
page.wait_for_function(
1110+
# Let the first save complete server-side. await_async_predicate, not
1111+
# wait_for_function, which never awaits an async predicate (_env.py).
1112+
await_async_predicate(
1113+
page,
10971114
"""async (want) => {
10981115
const r = await fetch('/api/backlog');
10991116
if (!r.ok) return false;
11001117
const b = await r.json();
11011118
return (b.active_count + b.parking_lot_count) >= want;
11021119
}""",
11031120
arg=total + 1,
1104-
timeout=15_000,
1121+
timeout=15.0,
1122+
what="the first park to land server-side",
11051123
)
11061124

11071125
# The draft must still be there. Before the fix this was '' and the next

packages/studyloop/tests/e2e/test_ghostty_dev_terminal.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,12 @@
5151
if _tests_dir not in sys.path:
5252
sys.path.insert(0, _tests_dir)
5353

54-
from e2e._env import RunningServer, build_test_world, start_server # noqa: E402
54+
from e2e._env import ( # noqa: E402
55+
RunningServer,
56+
await_async_predicate,
57+
build_test_world,
58+
start_server,
59+
)
5560

5661
if TYPE_CHECKING:
5762
from collections.abc import Generator
@@ -735,15 +740,19 @@ def _await_server_sees_session(page, session_id: str) -> None:
735740
is visible: without this the test asserts on a race and fails with an
736741
opaque KeyError when it loses.
737742
"""
738-
page.wait_for_function(
743+
# await_async_predicate, not wait_for_function, which never awaits an
744+
# async predicate (_env.py).
745+
await_async_predicate(
746+
page,
739747
"""async (id) => {
740748
const res = await fetch('/api/session/state', { cache: 'no-store' });
741749
if (!res.ok) return false;
742750
const state = await res.json();
743751
return state.study_session_id === id;
744752
}""",
745753
arg=session_id,
746-
timeout=15_000,
754+
timeout=15.0,
755+
what=f"the server to report session {session_id}",
747756
)
748757

749758
def test_session_state_survives_reload(self, dev_page) -> None:

packages/studyloop/tests/e2e/test_session_recovery_journey.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
sys.path.insert(0, _tests_dir)
5050

5151
from _playwright_helpers import start_web_server # noqa: E402
52+
from e2e._env import await_async_predicate # noqa: E402
5253

5354
_TEST_AGENT_SCRIPT = Path(_tests_dir) / "_fake_agent.py"
5455

@@ -283,13 +284,17 @@ def test_end_from_the_picker_releases_the_slot(self, clean_session: str, page: P
283284
dialog.wait_for(state="visible", timeout=5_000)
284285
page.locator("[data-testid='study-end-confirm-yes']").click()
285286

286-
page.wait_for_function(
287+
# await_async_predicate, not wait_for_function, which never awaits an
288+
# async predicate (_env.py).
289+
await_async_predicate(
290+
page,
287291
"""async () => {
288292
const res = await fetch('/api/session/state');
289293
const s = await res.json();
290294
return !s.study_session_id;
291295
}""",
292-
timeout=10_000,
296+
timeout=10.0,
297+
what="the session to be released server-side",
293298
)
294299
page.wait_for_function(
295300
"""() => {
@@ -385,13 +390,17 @@ def test_body_double_picker_can_end_a_foreign_session(
385390
page.locator("#bd-conflict-end").wait_for(state="visible", timeout=5_000)
386391
page.locator("#bd-conflict-end").click()
387392

388-
page.wait_for_function(
393+
# await_async_predicate, not wait_for_function, which never awaits an
394+
# async predicate (_env.py).
395+
await_async_predicate(
396+
page,
389397
"""async () => {
390398
const res = await fetch('/api/session/state');
391399
const state = await res.json();
392400
return !state.study_session_id;
393401
}""",
394-
timeout=10_000,
402+
timeout=10.0,
403+
what="the conflicting session to be ended server-side",
395404
)
396405

397406
def test_body_double_picker_can_reattach_its_own_session(

0 commit comments

Comments
 (0)