"""U3 — the declared embed seam, in a real DOM. The Python suite can prove what the server OFFERS. It cannot prove where a fragment lands, whether the author's own markup survived the mount, or whether four radio groups scattered down a report still submit as one POST — and that last one is the operator's most important workflow. Before U3 those properties were true by construction, because the server did the placing and the `form=` bindings were static by the time the page was parsed. Now they are true because `/_booth/embed.js` does it in a live document, which is a different kind of claim and needs a different kind of test. So: a real uvicorn on an ephemeral port, a real Chromium. SKIPS, NEVER FAILS, when playwright or the shared browser is unavailable. The box-wide store at /opt/ms-playwright pins specific Chromium revisions and a playwright release that wants a newer one dies with an opaque "Executable doesn't exist" — see pyproject's version bound. A test layer that goes red for an environment reason teaches nothing and trains people to ignore it. """ import json import socket import threading import time import pytest from booth.app import create_app playwright_api = pytest.importorskip( "playwright.sync_api", reason="playwright is not installed" ) @pytest.fixture(scope="module") def browser(): with playwright_api.sync_playwright() as pw: try: b = pw.chromium.launch() except Exception as exc: # noqa: BLE001 - any launch failure is a skip pytest.skip(f"no usable chromium: {exc}") yield b b.close() @pytest.fixture def live(tmp_path): """A real server, because a browser cannot talk to a TestClient.""" import uvicorn sock = socket.socket() sock.bind(("127.0.0.1", 0)) port = sock.getsockname()[1] # ⚠ THE SOCKET IS HANDED TO UVICORN STILL BOUND, never closed and # re-opened by port number. The old form did bind -> getsockname -> CLOSE -> # tell uvicorn the number, which leaves a window where the kernel can give # that port to somebody else — and this suite runs TWO browser files that # each start a server per test, so the other one is right there competing # for it. Passing the live socket removes the window rather than narrowing # it. # # Honest about the evidence: two different browser tests failed once each # across full-suite runs while passing 3/3 and 5/5 on their own, which is # the signature of contention. We cannot prove from two samples that this # race was the cause. It is a real defect either way, and it is the only # one visible in the harness. app = create_app(tmp_path, ttl_hours=24, start_sweeper=False) config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error") server = uvicorn.Server(config) thread = threading.Thread(target=lambda: server.run(sockets=[sock]), daemon=True) thread.start() deadline = time.time() + 10 while not server.started and time.time() < deadline: time.sleep(0.02) if not server.started: pytest.skip("uvicorn did not come up") try: yield f"http://127.0.0.1:{port}", tmp_path finally: server.should_exit = True thread.join(timeout=10) PNG = b"\x89PNG\r\n\x1a\n" SEAM = '' def _multi(booth): from booth.marks import declare_pick booth.mkdir(parents=True, exist_ok=True) declare_pick(booth, "batch", {"title": "Round one", "questions": [ {"key": "r1", "prompt": "First?", "options": ["keep", "cut"]}, {"key": "r2", "prompt": "Second?", "options": ["keep", "cut"]}, ]}) return booth def _single(booth): from booth.marks import declare_pick booth.mkdir(parents=True, exist_ok=True) declare_pick(booth, "winner", {"prompt": "Which render wins?", "options": ["A — baseline", "B — async"]}) return booth def _open(browser, base, name, html, booth): (booth / "index.html").write_text(html, encoding="utf-8") page = browser.new_page() page.goto(f"{base}/b/{name}/", wait_until="networkidle") return page def _answer(booth): raw = json.loads((booth / ".marks.json").read_text()) return raw["marks"][0].get("answer") # ---- the chrome -------------------------------------------------------------- def test_a_declaring_page_gets_its_chrome_mounted(browser, live): base, data = live b = _single(data / "b") page = _open(browser, base, "b", f"
report
{SEAM}"), b) page.wait_for_selector(".booth-nav-asks") target = page.locator(".booth-nav-asks").get_attribute("href") assert target != "#bk-ask-winner-background" landed = page.locator(target) assert landed.count() == 1 assert landed.evaluate("e => e.classList.contains('bk-ask')"), \ "the chip jumped to something the Booth did not mount" page.close() def test_the_chip_does_not_jump_to_a_mark_that_merely_shares_a_prefix(browser, live): """A SIBLING MARK's fragment must not take the chip, even when it is earlier in the document. `batch2`'s id starts with `batch`, so the original id-prefix rule could land on it; the mounted-elements rule cannot, because the candidates are partitioned by mark. ⚠ The first version of this test put the sibling's fragment AFTER the open mark's, so the right answer was also the first answer and pooling every mark's elements passed it. The vacuity pass caught that; the fixture now puts the sibling FIRST, which is the only arrangement that can tell the two implementations apart. """ from booth.marks import declare_pick base, data = live b = _multi(data / "b") # `batch`, created first declare_pick(b, "batch2", {"prompt": "Unrelated?", "options": ["x", "y"]}) # batch2 is anchored at the very top; batch is unanchored and so lands in # the tail, at the END of the body. Document order is therefore batch2's # fragments, then batch's. page = _open(browser, base, "b", ( '' f"report
{SEAM}"), b) page.wait_for_selector(".booth-nav-asks") ids = page.eval_on_selector_all("[id^='bk-ask-']", "els => els.map(e => e.id)") assert any("batch2" in i for i in ids) and any( "batch2" not in i and "batch" in i for i in ids), ids assert ids.index(next(i for i in ids if "batch2" in i)) < \ ids.index(next(i for i in ids if "batch2" not in i and "batch" in i)), \ f"fixture is wrong: the sibling must come FIRST, got {ids}" # `open` is (created, id) -> batch before batch2, so the chip targets batch target = page.locator(".booth-nav-asks").get_attribute("href") assert "batch2" not in target, f"the chip landed on the sibling mark: {target}" assert target.startswith("#bk-ask-batch") assert page.locator(target).count() == 1 page.close() def test_the_canonical_attribute_wins_when_both_are_present(browser, live): """`data-booth-mark` is canonical and `data-booth-ask` is the kept alias. An element carrying both is not a case any live report has, but the precedence has to be decided somewhere rather than by selector order.""" base, data = live b = _multi(data / "b") page = _open(browser, base, "b", ( '{SEAM}'), b) page.wait_for_selector("#a .bk-ask") assert page.locator('#a input[name="choice.r2"]').count() == 2 # canonical assert page.locator('#a input[name="choice.r1"]').count() == 0 # alias ignored # r1 was never placed, so INV-7 still puts it somewhere assert page.locator('input[name="choice.r1"]').count() == 2 page.close() def test_the_chip_count_comes_from_the_server(browser, live): """INV-4. A half-answered multi-question pick is STILL OPEN, and the page does not get to have an opinion about that — `open_marks` decides.""" from booth.marks import answer_pick base, data = live b = _multi(data / "b") answer_pick(b, "batch", {"r1": "keep"}) page = _open(browser, base, "b", REPORT, b) page.wait_for_selector(".bk-ask") assert page.locator(".booth-nav-asks").count() == 1 answer_pick(b, "batch", {"r1": "keep", "r2": "cut"}) page.reload(wait_until="networkidle") page.wait_for_selector(".bk-ask") assert page.locator(".booth-nav-asks").count() == 0 page.close() def test_the_chip_follows_a_payload_that_disagrees_with_the_fragments(browser, live): """INV-4, and the version that actually falsifies it. The test above uses honest fixtures, so a client that INFERRED openness from the rendered fragments would pass it — the fragments and `open` always agree when the server computes both. A panel pointed out that this never creates the disagreement it claims to test. So: intercept the response and make `open` lie. The fragments say fully answered; the payload says two are open. The chip must follow the payload, because the payload is the only thing that decides. """ import json as _json from booth.marks import answer_pick base, data = live b = _multi(data / "b") answer_pick(b, "batch", {"r1": "keep", "r2": "cut"}) # nothing is open (b / "index.html").write_text(f"{SEAM}", encoding="utf-8") def lie(route): body = _json.loads(route.fetch().text()) assert body["open"] == [], "fixture is not answered; the lie would be true" body["open"] = ["batch", "batch"] route.fulfill(status=200, content_type="application/json", body=_json.dumps(body)) page = browser.new_page() page.route("**/embed.json", lie) page.goto(f"{base}/b/b/", wait_until="networkidle") page.wait_for_selector(".booth-nav-asks") assert page.locator(".booth-nav-asks").inner_text() == "? 2 open asks" page.close() # --- the grid keyboard, which is the OTHER thing no string assertion sees ---- # Added 2026-09-22 after the heid bug-hunt panel found a defect whose entire # expression is viewport geometry: a group jump moves the scroll position, the # keyboard cursor does not know, and the next arrow key scrolls back. def _gallery(root, name="g"): """Enough tiles that the grid must scroll, in two groups.""" b = root / name b.mkdir() for i in range(1, 13): (b / f"aa{i:02d}.png").write_bytes(PNG) for i in range(1, 13): (b / f"zz{i:02d}.png").write_bytes(PNG) return b def test_an_arrow_after_a_group_jump_does_not_scroll_back(browser, live): """GRÓA's solo. The jump scrolled the viewport but left the cursor at -1, so the next ArrowRight focused tile 0 and `scrollIntoView` yanked the page back to the top — silently reversing the jump the operator just made. The whole failure is geometry, so it is asserted on geometry: scroll position after the arrow must stay near where the jump landed, not return to the top. Defeating change: `focus(at + 1)` with `at` starting at -1.""" base, root = live _gallery(root) page = browser.new_page() page.set_viewport_size({"width": 900, "height": 600}) page.goto(f"{base}/b/g/", wait_until="networkidle") page.click('.rail-g[data-group="zz"]') page.wait_for_timeout(250) after_jump = page.evaluate("window.scrollY") assert after_jump > 0, "the group jump did not scroll at all" page.keyboard.press("ArrowRight") page.wait_for_timeout(250) after_key = page.evaluate("window.scrollY") page.close() assert after_key > after_jump / 2, ( f"the arrow key undid the jump: scrollY {after_jump} -> {after_key}" ) def test_the_keyboard_flag_actually_submits(browser, live): """HULDA's solo. `f` selected `.flagbtn, [name="target"]`; nothing in this repo emits `.flagbtn`, so it clicked the HIDDEN target input — and clicking a hidden input does not submit its form. The shortcut never worked while still swallowing the keystroke. Asserted end to end: press f, and the flag must come back from the server. R2 C3 (docs/contracts/r2_flow.contract.md, "Assertions that change"): this used to expect a NAVIGATION — the flag form POSTed, 303'd and reloaded. That was the no-JS design working, not a defect, and it still is with scripts off (tests/golden/r2_mark_303.json replays those responses byte for byte). What changed is that WITH JS ON the flag now applies in place. The claim that matters is kept and tightened: the flag must come back from the SERVER (the swapped tile is server-rendered), and a marker set on the window before the keypress must survive, which a reload would wipe.""" base, root = live _gallery(root) page = browser.new_page() page.goto(f"{base}/b/g/", wait_until="networkidle") page.evaluate("window.__noReload = 1") page.keyboard.press("ArrowRight") # ⚠ WAIT FOR THE CURSOR TO LAND BEFORE PRESSING `f`. Firing both keys # back to back assumed the first had finished, and `focus()` does a # `scrollIntoView` — so under full-suite load `f` could arrive with no # cursor set and flag nothing. It failed once in roughly five whole-suite # runs while passing 3/3 on its own, which is the signature of a race # rather than a defect, and a test that goes red one time in five trains # people to ignore red. page.wait_for_selector("figure.item.is-cursor", timeout=10000) page.keyboard.press("f") page.wait_for_selector("figure.item.is-flagged", timeout=10000) flagged = page.locator("figure.item.is-flagged").count() survived = page.evaluate("window.__noReload === 1") page.close() assert flagged == 1, f"the f key flagged {flagged} items, expected 1" assert survived, "the flag reloaded the page; in-place judgment must not"