Files
booth/tests/test_embed_browser.py
T
vh 50bfc7b4ec fix(asks): one submit saves every ask on the page
Operator report (via infra-ops): on a page with several asks, a submit
saved only the pressed one and the reload wiped the rest. Confirmed on
auk-audition: one POST at 15:02:23 saved the last ask on the page, then a
400 from the submit of an ask the reload had just blanked.

Client-side on both surfaces; /answer is unchanged. A submit on a pick
form, while another pick form on the page holds unsent input, sends every
changed ("dirty") pick form: one POST each, to its own action, with
Accept: application/json, in document order. A refusal stops nothing, and
untouched forms are never re-sent. With no other dirty form, a submit is
exactly what it was.

- embed.js (verbatim reports): reloads only when nothing was refused and
  nothing of ours is dirty. Otherwise a server-rendered status line in the
  submit block says what did not save, and input stays. A form the server
  took gets a new baseline. A press during the flight is ignored.
- base.html (marks page, lightbox, review rail): one refresh in place. A
  batch never reloads. Only forms the server took count as sent. In-flight
  state and "just sent" are keyed by form identity (formKey) plus the fields
  at the press, not the DOM node.

Two heid bug-hunt rounds: a four-arm panel on the first cut, then Hulda
alone on the fold. Ten findings reproduced red in a browser before their
fixes. Contracts: U3 "Submitting several asks at once" + INV-8, R2 C3
steps 2, 3 and 3a. Mutation tables u3_submit_all (15) and r2_submit_all
(11), all proved. Suite 928 -> 951.
2026-09-27 17:05:11 -07:00

1032 lines
44 KiB
Python

"""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"
)
# NO INTERNET for the test browser. Every Booth page asks fonts.googleapis.com
# for its faces, and "networkidle" waits for that request — so a stalled request
# to Google hung the page until goto's 30s timeout, the failure mode of the
# full-suite flake (Page.goto timeouts in tests far apart in one run; a stalled
# font request reproduces it exactly). Whether that was THE cause is unproven;
# a test that depends on Google being reachable is wrong regardless. Every
# hostname but 127.0.0.1 now fails DNS at once, and the pages fall back to the
# system stacks the tokens declare. Positive control: test_*_has_no_internet.
OFFLINE = ["--host-resolver-rules=MAP * ~NOTFOUND , EXCLUDE 127.0.0.1"]
@pytest.fixture(scope="module")
def browser():
with playwright_api.sync_playwright() as pw:
try:
b = pw.chromium.launch(args=OFFLINE)
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 = '<script src="/_booth/embed.js" defer></script>'
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"<!doctype html><title>r</title><body><h1>R</h1>{SEAM}</body>", b)
page.wait_for_selector(".booth-nav-home")
assert page.locator("h1").inner_text() == "R" # the report is intact
assert page.locator(".booth-nav-home").get_attribute("href").endswith("/")
# the favicon question, asked of a parsed document instead of raw text
assert page.locator('link[rel="icon"]').count() == 1
page.close()
def test_a_page_that_never_declared_the_seam_still_mounts(browser, live):
"""The appended path: every verbatim booth that predates U3 keeps working
without its author touching it."""
base, data = live
b = _single(data / "b")
page = _open(browser, base, "b", "<!doctype html><body><h1>OLD</h1></body>", b)
page.wait_for_selector(".bk-ask")
assert page.locator("h1").inner_text() == "OLD"
assert page.locator(".booth-nav-home").count() == 1
page.close()
def test_a_page_that_declares_its_own_icon_keeps_it(browser, live):
base, data = live
b = _single(data / "b")
page = _open(
browser, base, "b",
f'<!doctype html><head><link rel="icon" href="data:image/png;base64,AAAA">'
f"</head><body>x{SEAM}</body>", b)
page.wait_for_selector(".booth-nav-home")
icons = page.locator('link[rel="icon"]')
assert icons.count() == 1
assert icons.get_attribute("href").startswith("data:image/png")
page.close()
# ---- placement ---------------------------------------------------------------
REPORT = f"""<!doctype html><title>audition</title><body>
<h1>Three voices</h1>
<section id="lawson"><audio src="a.wav"></audio>
<div data-booth-ask="batch:r1"></div></section>
<section id="jo"><audio src="b.wav"></audio>
<div data-booth-mark="batch:r2"></div></section>
<div data-booth-ask-submit="batch"></div>
{SEAM}
</body>"""
def test_each_question_lands_where_the_author_put_it(browser, live):
"""The 2026-09-09 ruling, enforced in the DOM: the question for a voice sits
under that voice, not on another page and not in a pile at the end. Both
attribute spellings, because live reports use the older one."""
base, data = live
b = _multi(data / "b")
page = _open(browser, base, "b", REPORT, b)
page.wait_for_selector("#lawson .bk-ask")
assert page.locator('#lawson input[name="choice.r1"]').count() == 2
assert page.locator('#jo input[name="choice.r2"]').count() == 2
# nothing spilled to the end of the body: every piece had an anchor
assert page.locator("body > .bk-ask").count() == 0
assert page.locator("form#bk-ask-form-batch").count() == 1
page.close()
def test_the_authors_wrapper_and_its_contents_survive_the_mount(browser, live):
"""The live `dfa-concepts` shape — a non-empty styled wrapper carrying the
anchor attribute. The regex this replaced matched the opening tag and
SUBSTITUTED it, eating the class and orphaning the heading. beforeend keeps
both and puts the radios under the heading, which is what the markup says."""
base, data = live
b = _multi(data / "b")
page = _open(browser, base, "b", (
'<!doctype html><body><div class="ask" data-booth-ask="batch:r1">'
f"<h3>The one asset that must survive</h3></div>{SEAM}</body>"), b)
page.wait_for_selector(".ask .bk-ask")
assert page.locator("div.ask").count() == 1 # class kept
assert page.locator(".ask h3").inner_text() == "The one asset that must survive"
assert page.locator('.ask input[name="choice.r1"]').count() == 2 # radios inside
page.close()
def test_an_unplaced_question_is_appended_and_so_is_its_submit(browser, live):
"""INV-7. A multi-question pick needs EVERY question on submit or the POST is
unanswerable: a question that never reaches the page cannot be picked, and
a submission with nothing picked at all is refused outright."""
base, data = live
b = _multi(data / "b")
page = _open(browser, base, "b",
f'<!doctype html><body><div data-booth-mark="batch:r1"></div>{SEAM}</body>', b)
# attached, not visible: the shared <form> is deliberately empty and so has
# no box — the controls that bind to it are what the operator sees.
page.wait_for_selector("form#bk-ask-form-batch", state="attached")
# BOTH halves. Asserting only the appended r2 let a mutation that silently
# swallowed the anchored r1 — while still recording it as placed — pass.
assert page.locator('input[name="choice.r1"]').count() == 2 # anchored, mounted
assert page.locator('input[name="choice.r2"]').count() == 2 # unplaced, appended
assert page.locator("form#bk-ask-form-batch").count() == 1 # submittable
page.close()
def test_a_page_with_no_anchors_gets_the_whole_ask(browser, live):
base, data = live
b = _multi(data / "b")
page = _open(browser, base, "b", f"<!doctype html><body><p>x</p>{SEAM}</body>", b)
page.wait_for_selector(".bk-ask")
assert page.locator('input[name="choice.r1"]').count() == 2
assert page.locator('input[name="choice.r2"]').count() == 2
page.close()
def test_an_anchor_naming_no_mark_is_left_alone(browser, live):
"""A typo'd id stays visible as the author's own empty element rather than
being blanked — and the real ask is still never lost."""
base, data = live
b = _multi(data / "b")
page = _open(browser, base, "b",
f'<!doctype html><body><div id="t" data-booth-mark="typo"></div>{SEAM}</body>', b)
page.wait_for_selector(".bk-ask")
assert page.locator("#t").inner_html().strip() == ""
assert page.locator('input[name="choice.r1"]').count() == 2
page.close()
def test_the_tail_follows_payload_order(browser, live):
"""INV-6. Creation order and alphabetical order DISAGREE here on purpose:
`winner` is created first, `batch` second, so payload order is
winner-then-batch while an id sort would give the reverse. The first version
of this fixture made the two identical, so sorting the tail alphabetically
in JavaScript passed it — caught by a panel reading the fixture."""
base, data = live
b = _single(data / "b")
_multi(b)
raw = json.loads((b / ".marks.json").read_text())
stamps = {"winner": "2026-09-22T10:00:00.000000-07:00", # first
"batch": "2026-09-22T11:00:00.000000-07:00"} # second
for e in raw["marks"]:
e["created"] = stamps[e["id"]]
(b / ".marks.json").write_text(json.dumps(raw))
page = _open(browser, base, "b", f"<!doctype html><body>{SEAM}</body>", b)
page.wait_for_selector(".bk-ask")
ids = page.eval_on_selector_all("[id^='bk-ask-']", "els => els.map(e => e.id)")
batch = [i for i, v in enumerate(ids) if "batch" in v]
winner = [i for i, v in enumerate(ids) if "winner" in v]
assert batch and winner, ids
# winner (created first) ahead of batch (created second) — the OPPOSITE of
# alphabetical, so an id sort cannot pass this.
assert max(winner) < min(batch), ids
page.close()
# ---- the one that actually matters ------------------------------------------
def test_a_form_scattered_down_the_report_submits_every_question(browser, live):
"""THE load-bearing browser test.
Four radio groups under four different artifacts, one <form> somewhere else
entirely, bound only by the HTML5 `form=` attribute — and now inserted into
a live document in visual order, which means a control can land before the
form it points at. If form-owner resolution does not survive that, the
operator fills the whole thing in and the button saves nothing.
It was true by construction before U3 (static HTML, resolved at parse). It
is true by measurement now. That is the trade this test pays for.
"""
base, data = live
b = _multi(data / "b")
page = _open(browser, base, "b", REPORT, b)
page.wait_for_selector("#lawson .bk-ask")
page.check('#lawson input[name="choice.r1"][value="keep"]')
page.check('#jo input[name="choice.r2"][value="cut"]')
with page.expect_navigation():
page.click("button.bk-ask-go")
ans = _answer(b)
assert ans is not None, "the scattered form submitted nothing"
assert ans["answers"]["r1"]["choice"] == "keep"
assert ans["answers"]["r2"]["choice"] == "cut", \
"a question bound by form= did not reach the POST"
assert ans["complete"] is True
page.close()
def test_the_chip_jumps_to_the_first_fragment_of_the_open_ask(browser, live):
base, data = live
b = _multi(data / "b")
page = _open(browser, base, "b", REPORT, b)
page.wait_for_selector(".booth-nav-asks")
chip = page.locator(".booth-nav-asks")
assert chip.inner_text() == "? 1 open ask"
target = chip.get_attribute("href")
# the EARLIEST match in document order, not merely a match: the report
# anchors r1 above r2 above the submit block, so any later one is wrong.
ids = page.eval_on_selector_all("[id^='bk-ask-batch']", "els => els.map(e => e.id)")
assert ids, "no batch fragment mounted"
assert target == "#" + ids[0], (target, ids)
page.close()
def test_an_anchor_named_like_an_object_property_does_not_kill_the_page(browser, live):
"""`toString` is a legal mark id (`asks.valid_stem`) and therefore a legal
thing for an author to typo into an anchor. Against a plain `{}` lookup it
came back as Object.prototype.toString — truthy, so it sailed past the
unknown-mark guard and threw on `.questions.length`, aborting placement
before the tail and costing the page EVERY ask. One typo, no chrome, no
error the operator would see. Found by a cross-frontier panel."""
base, data = live
b = _multi(data / "b")
page = _open(browser, base, "b", (
'<!doctype html><body><div id="t" data-booth-mark="toString"></div>'
f'<div id="v" data-booth-mark="valueOf:r1"></div>{SEAM}</body>'), b)
page.wait_for_selector(".bk-ask")
assert page.locator("#t").inner_html().strip() == "" # left alone
assert page.locator("#v").inner_html().strip() == "" # left alone
# and the real ask still mounted, which is what the bug destroyed
assert page.locator('input[name="choice.r1"]').count() == 2
assert page.locator('input[name="choice.r2"]').count() == 2
assert page.locator("form#bk-ask-form-batch").count() == 1
page.close()
def test_a_question_keyed_like_an_object_property_is_not_swallowed(browser, live):
"""The mirror of the same bug, on the `placed` set. `constructor` matches
`asks._KEY_RE`, and against a plain object an inherited `got.constructor`
read as ALREADY PLACED — so a question the author did not anchor was
silently dropped from the tail, which is INV-7's whole subject."""
from booth.marks import declare_pick
base, data = live
b = data / "b"
b.mkdir(parents=True, exist_ok=True)
declare_pick(b, "batch", {"title": "Round one", "questions": [
{"key": "r1", "prompt": "First?", "options": ["keep", "cut"]},
{"key": "constructor", "prompt": "Second?", "options": ["keep", "cut"]},
]})
page = _open(browser, base, "b",
f'<!doctype html><body><div data-booth-mark="batch:r1"></div>{SEAM}</body>', b)
page.wait_for_selector("form#bk-ask-form-batch", state="attached")
assert page.locator('input[name="choice.r1"]').count() == 2
assert page.locator('input[name="choice.constructor"]').count() == 2, \
"an unplaced question was swallowed by an inherited property"
page.close()
def test_a_submit_anchor_inside_the_authors_own_form_still_submits(browser, live):
"""A submit anchor placed inside the author's own `<form>` loses ours: the
HTML parser drops a nested form element outright. The controls' `form=`
then points at nothing, the button does nothing, and the operator finds out
by filling the whole thing in. Found by a cross-frontier bug-hunt panel; the
fix is to count the anchor submitted only if the form actually survived, so
the tail supplies one at body level where no form encloses it."""
base, data = live
b = _multi(data / "b")
page = _open(browser, base, "b", (
'<!doctype html><body>'
'<div data-booth-mark="batch:r1"></div>'
'<div data-booth-mark="batch:r2"></div>'
'<form id="mine" action="/elsewhere">'
'<div data-booth-ask-submit="batch"></div></form>'
f"{SEAM}</body>"), b)
page.wait_for_selector("form#bk-ask-form-batch", state="attached")
assert page.locator("form#bk-ask-form-batch").count() == 1
page.check('input[name="choice.r1"][value="keep"]')
page.check('input[name="choice.r2"][value="cut"]')
with page.expect_navigation():
page.click("button.bk-ask-go")
ans = _answer(b)
assert ans is not None, "the button reached no form"
assert ans["answers"]["r1"]["choice"] == "keep"
assert ans["answers"]["r2"]["choice"] == "cut"
page.close()
def test_a_broken_pick_shows_its_diagnostic_even_from_a_submit_anchor(browser, live):
"""A broken pick has no submit block — its `submit` is empty and the
diagnostic lives in `whole`. Mounting that empty string and then recording
the pick as placed made the tail skip it, so the 'broken ask' box never
rendered at the one surface built to show it. A question the session
believes it posted has to be visible."""
base, data = live
b = _single(data / "b")
raw = json.loads((b / ".marks.json").read_text())
raw["marks"][0]["declaration"] = {"prompt": "p"} # no options -> AskError
(b / ".marks.json").write_text(json.dumps(raw))
page = _open(browser, base, "b",
f'<!doctype html><body><div id="s" data-booth-ask-submit="winner">'
f"</div>{SEAM}</body>", b)
page.wait_for_selector(".bk-ask")
assert page.locator("#s").inner_html().strip() == "" # anchor left alone
# inner_text() is the RENDERED text, and `.bk-ask-tag` is uppercased by CSS —
# so assert the diagnostic itself, which is the part that has to reach him.
shown = page.locator("body").inner_text()
assert "this question could not be read" in shown, shown
assert "options" in shown # the actual reason
page.close()
def test_an_author_element_cannot_hijack_the_chip(browser, live):
"""`<section id="bk-ask-winner-background">` satisfies any id-prefix rule,
hyphen boundary included. The chip therefore searches only the elements THIS
SCRIPT MOUNTED — the identity the deleted `bk-ask-<id>-top` anchor used to
guarantee — and takes the earliest of those in document order."""
base, data = live
b = _single(data / "b")
page = _open(browser, base, "b", (
'<!doctype html><body><section id="bk-ask-winner-background">notes</section>'
f"<p>report</p>{SEAM}</body>"), 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", (
'<!doctype html><body><div data-booth-mark="batch2"></div>'
f"<p>report</p>{SEAM}</body>"), 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", (
'<!doctype html><body><div id="a" data-booth-mark="batch:r2" '
f'data-booth-ask="batch:r1"></div>{SEAM}</body>'), 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"<!doctype html><body>{SEAM}</body>", 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"
def test_the_test_browser_has_no_internet(browser, live):
"""Positive control for OFFLINE (booth-dev's ask: see the fix in force,
don't assume it). An external host fails at once, and a Booth page — whose
fonts are external — still goes idle in well under the goto timeout."""
base, root = live
(root / "g").mkdir()
page = browser.new_page()
t = time.time()
with pytest.raises(Exception) as err:
page.goto("https://fonts.googleapis.com/css2?family=IBM+Plex+Sans", timeout=10000)
external = time.time() - t
page.close()
page = browser.new_page()
t = time.time()
page.goto(f"{base}/b/g/", wait_until="networkidle")
local = time.time() - t
page.close()
assert "ERR_NAME_NOT_RESOLVED" in str(err.value) and external < 3, (str(err.value)[:80], external)
assert local < 10, local
# ---- one submit saves every ask on the page (2026-09-27) ---------------------
#
# The operator's report, relayed by infra-ops: "submitting a question should go
# through and submit ALL answers. As it is, I go through, submit a question and
# it only submits the last one and clears out the top ones." Confirmed against
# the live `auk-audition` booth before any code: three single-question asks,
# each its own <form>; the access log shows ONE POST at 15:02:23 saving the
# LAST ask on the page, its 303 reload wiping the other two, then a 400 when a
# now-blank ask was submitted. Contract: u3_declared_embed_seam, "Submitting
# several asks at once".
def _asks(booth, ids=("a1", "a2", "a3")):
"""Single-question asks, declared in `ids` order, which is also their id
order, so `(created, id)` cannot disagree with the order written here."""
from booth.marks import declare_pick
booth.mkdir(parents=True, exist_ok=True)
for mid in ids:
declare_pick(booth, mid, {"prompt": f"About {mid}?", "options": ["yes", "no"]})
return booth
def _answers(booth):
raw = json.loads((booth / ".marks.json").read_text())
return {m["id"]: m.get("answer") for m in raw["marks"]}
def _choice(page, mid, value):
page.check(f'input[name="choice"][form="bk-ask-form-{mid}"][value="{value}"]')
def _press(page, mid):
page.click(f"#bk-ask-{mid}-submit button.bk-ask-go")
def _watch_posts(page):
"""Every POST the page makes, as (resource type, ask id), in send order. A
native form submission is a `document` request; a script's is a `fetch`."""
from urllib.parse import parse_qs
posts = []
def seen(r):
if r.method == "POST":
ask = parse_qs(r.post_data or "").get("ask", [None])[0]
posts.append((r.resource_type, ask))
page.on("request", seen)
return posts
PLAIN = f"<!doctype html><title>r</title><body><h1>R</h1>{SEAM}</body>"
@pytest.mark.parametrize("pressed", ["a3", "a1"])
def test_one_submit_saves_every_answered_ask_on_the_page(browser, live, pressed):
"""The operator's exact sequence: answer top to bottom, press the LAST
submit. And the same from the FIRST button, because "press any one" is the
acceptance. Every ask he answered is recorded, and after the page comes
back every pick he made is still showing."""
base, data = live
b = _asks(data / "b")
page = _open(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a3-submit")
_choice(page, "a1", "yes")
_choice(page, "a2", "no")
_choice(page, "a3", "yes")
with page.expect_navigation():
_press(page, pressed)
got = _answers(b)
assert {k: (v or {}).get("choice") for k, v in got.items()} == \
{"a1": "yes", "a2": "no", "a3": "yes"}, got
page.wait_for_selector("#bk-ask-a3-submit")
showing = page.evaluate("""() => ['a1', 'a2', 'a3'].map(id => {
var c = document.querySelector('input[name="choice"][form="bk-ask-form-' + id + '"]:checked');
return c ? c.value : null; })""")
page.close()
assert showing == ["yes", "no", "yes"], f"a pick disappeared on reload: {showing}"
def test_a_blank_ask_is_skipped_never_refused(browser, live):
"""Pressing the submit of an ask he left blank used to be a 400 page
("nothing to record"): the live log has one, four seconds after the reload
that wiped his picks. Blanks stay legal: the ask is skipped, never sent, and
the others are saved."""
base, data = live
b = _asks(data / "b")
page = _open(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a3-submit")
posts = _watch_posts(page)
_choice(page, "a1", "no")
_choice(page, "a3", "yes")
with page.expect_navigation():
_press(page, "a2")
page.close()
got = _answers(b)
assert got["a1"]["choice"] == "no" and got["a3"]["choice"] == "yes", got
assert got["a2"] is None, "a blank ask was recorded"
assert [a for _, a in posts] == ["a1", "a3"], posts
def test_an_ask_nobody_touched_is_not_re_sent(browser, live):
"""Re-sending an answer re-dates it, and a reading session sees a fresh
answer that nobody gave. Only what changed since the page loaded is sent —
not the recorded answer sitting under the button that was pressed."""
from booth.marks import answer_pick
base, data = live
b = _asks(data / "b", ("a1", "a2"))
answer_pick(b, "a1", "yes")
page = _open(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a2-submit")
posts = _watch_posts(page)
_choice(page, "a2", "no")
with page.expect_navigation():
_press(page, "a1") # the "Update answer" button
page.close()
assert [a for _, a in posts] == ["a2"], posts
assert _answers(b)["a2"]["choice"] == "no"
def test_one_changed_ask_still_submits_as_a_plain_form(browser, live):
"""With nothing else on the page to save, a submit is exactly what it was:
the browser's own form POST and its 303. The batch is an addition that
engages only when another ask holds unsent input."""
base, data = live
b = _asks(data / "b")
page = _open(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a3-submit")
posts = _watch_posts(page)
_choice(page, "a2", "yes")
with page.expect_navigation():
_press(page, "a2")
page.close()
assert posts == [("document", "a2")], posts
assert _answers(b)["a2"]["choice"] == "yes"
def test_the_asks_are_sent_in_document_order(browser, live):
"""INV-6: the batch is an ordered collection, and its rule is the order the
forms sit in the document — here the REVERSE of the payload's, because the
author anchored a2 above a1."""
base, data = live
b = _asks(data / "b", ("a1", "a2"))
html = ("<!doctype html><title>r</title><body>"
'<div data-booth-mark="a2"></div><div data-booth-mark="a1"></div>'
f"{SEAM}</body>")
page = _open(browser, base, "b", html, b)
page.wait_for_selector("#bk-ask-a1-submit")
posts = _watch_posts(page)
_choice(page, "a1", "yes")
_choice(page, "a2", "yes")
with page.expect_navigation():
_press(page, "a1")
page.close()
assert [a for _, a in posts] == ["a2", "a1"], posts
def test_a_refused_ask_costs_only_itself_and_clears_nothing(browser, live):
"""The session withdrew a2 while the operator was answering. a1 and a3 are
still saved — one stale ask must not cost the rest — and the page does NOT
reload, so the pick he made for a2 is still on screen, and the page says
which one did not save."""
from booth.marks import delete_mark
base, data = live
b = _asks(data / "b")
page = _open(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a3-submit")
page.evaluate("window.__same = 1")
_choice(page, "a1", "yes")
_choice(page, "a2", "yes")
_choice(page, "a3", "no")
delete_mark(b, "a2")
_press(page, "a3")
page.wait_for_selector("#bk-ask-a3-submit .bk-ask-status:not([hidden])", timeout=10000)
said = page.locator("#bk-ask-a3-submit .bk-ask-status").inner_text()
kept = page.is_checked('input[name="choice"][form="bk-ask-form-a2"][value="yes"]')
same = page.evaluate("window.__same === 1")
page.close()
got = _answers(b)
assert got["a1"]["choice"] == "yes" and got["a3"]["choice"] == "no", got
assert "a2" not in got
assert same, "a failed batch reloaded the page and cleared what was entered"
assert kept, "the refused ask's pick was cleared"
assert "a2" in said and "2 of 3" in said, said
def test_an_authors_own_form_is_never_taken_over(browser, live):
"""The batch listens to the forms the SCRIPT mounted. An author's own form
on the same report — a search box, say — submits as the author wrote it,
even while the operator has unsent picks, and nothing of ours is sent."""
base, data = live
b = _asks(data / "b", ("a1", "a2"))
html = ("<!doctype html><title>r</title><body>"
# an id with OUR prefix, so the mounted-root check is what excludes
# it, not the selector (kimi, hulda: the prefix alone hid the guard)
'<form id="bk-ask-form-theirs" action="/b/b/" method="get"><input name="q" value="x">'
'<button id="go">go</button></form>'
f"{SEAM}</body>")
page = _open(browser, base, "b", html, b)
page.wait_for_selector("#bk-ask-a2-submit")
posts = _watch_posts(page)
_choice(page, "a1", "yes")
_choice(page, "a2", "no")
with page.expect_navigation():
page.click("#go")
url = page.url
page.close()
assert posts == [], posts
assert url.endswith("?q=x"), url
assert _answers(b) == {"a1": None, "a2": None}
# ---- the flight window (heid bug-hunt, 2026-09-27, 4 of 4 arms) --------------
#
# The batch reads every form at the press, but the page stays live for the
# whole flight. Every guard that protects that window survived its mutation,
# because no test pressed or edited inside one. These do: each POST's reply is
# held 700ms in the CLIENT, so the window is wide enough to act in on purpose.
_HOLD_POSTS = """
(function () {
var real = window.fetch;
window.fetch = function (u, o) {
var p = real.apply(this, arguments);
if (o && o.method === 'POST') {
return p.then(function (r) {
return new Promise(function (res) { setTimeout(function () { res(r); }, 700); });
});
}
return p;
};
})();
"""
def _open_held(browser, base, name, html, booth):
(booth / "index.html").write_text(html, encoding="utf-8")
page = browser.new_page()
page.add_init_script(_HOLD_POSTS)
page.goto(f"{base}/b/{name}/", wait_until="networkidle")
return page
def test_a_press_inside_the_flight_never_fires_a_native_post(browser, live):
"""hulda: press a blank ask's button (a batch of the OTHER one starts), then
that other ask's own button. No other form is dirty any more from its point
of view, so it used to fall through to the browser — a native POST of an
answer already in flight, and a navigation racing the batch."""
base, data = live
b = _asks(data / "b", ("a1", "a2"))
page = _open_held(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a2-submit")
posts = _watch_posts(page)
_choice(page, "a2", "yes")
with page.expect_navigation(timeout=10000):
_press(page, "a1")
page.wait_for_timeout(150)
_press(page, "a2")
page.close()
assert posts == [("fetch", "a2")], posts
def test_input_made_during_the_flight_is_kept_and_saved_on_the_next_press(browser, live):
"""All four arms: a successful batch reloaded with no carry, so a pick made
while it was in flight vanished. Now the page stays when anything changed
after the press, says so, and the next press sends ONLY that — the saved
answers are not sent again (their baseline moved to what the server took)."""
base, data = live
b = _asks(data / "b")
page = _open_held(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a3-submit")
page.evaluate("window.__same = 1")
posts = _watch_posts(page)
_choice(page, "a1", "yes")
_choice(page, "a2", "no")
_press(page, "a1")
page.wait_for_timeout(150)
_choice(page, "a3", "no") # mid-flight
page.wait_for_selector("#bk-ask-a1-submit .bk-ask-status:not([hidden])", timeout=10000)
said = page.locator("#bk-ask-a1-submit .bk-ask-status").inner_text()
kept = page.is_checked('input[name="choice"][form="bk-ask-form-a3"][value="no"]')
same = page.evaluate("window.__same === 1")
with page.expect_navigation(timeout=10000):
_press(page, "a3")
page.close()
assert same and kept, (same, kept)
assert "not saved yet" in said, said
assert [a for _, a in posts] == ["a1", "a2", "a3"], posts
assert {k: v["choice"] for k, v in _answers(b).items()} == {"a1": "yes", "a2": "no", "a3": "no"}
def test_a_retry_after_a_refusal_sends_only_what_did_not_save(browser, live):
"""groa, hulda: after a partial failure the saved forms still read as dirty,
so the retry re-POSTed them and re-dated answers nobody changed."""
from booth.marks import delete_mark
base, data = live
b = _asks(data / "b")
page = _open(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a3-submit")
for mid in ("a1", "a2", "a3"):
_choice(page, mid, "yes")
delete_mark(b, "a2")
posts = _watch_posts(page)
_press(page, "a3")
page.wait_for_selector("#bk-ask-a3-submit .bk-ask-status:not([hidden])", timeout=10000)
page.evaluate("document.querySelector('#bk-ask-a3-submit .bk-ask-status').hidden = true")
_press(page, "a3")
page.wait_for_selector("#bk-ask-a3-submit .bk-ask-status:not([hidden])", timeout=10000)
page.close()
assert [a for _, a in posts] == ["a1", "a2", "a3", "a2"], posts
def test_the_empty_status_line_stays_hidden_under_host_css(browser, live):
"""groa, regin: `p{display:block}` in the author's sheet outranks the UA's
own [hidden]; the embed restates it at class specificity."""
base, data = live
b = _asks(data / "b", ("a1",))
html = ("<!doctype html><title>r</title><style>p{display:block}</style>"
f"<body><h1>R</h1>{SEAM}</body>")
page = _open(browser, base, "b", html, b)
page.wait_for_selector("#bk-ask-a1-submit")
shown = page.evaluate(
"getComputedStyle(document.querySelector('#bk-ask-a1-submit .bk-ask-status')).display")
page.close()
assert shown == "none", shown
def test_a_refusal_blocks_the_reload_even_when_nothing_reads_dirty(browser, live):
"""hulda: the reload waited only on "nothing dirty". A refused form the
operator then set back to its first value reads clean — so the page
reloaded over a failure and never said it. A refusal blocks the reload on
its own."""
from booth.marks import answer_pick, delete_mark
base, data = live
b = _asks(data / "b", ("a1", "a2"))
answer_pick(b, "a1", "yes")
answer_pick(b, "a2", "yes")
page = _open_held(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a2-submit")
page.evaluate("window.__same = 1")
_choice(page, "a1", "no")
_choice(page, "a2", "no")
delete_mark(b, "a1")
_press(page, "a2")
page.wait_for_timeout(150)
_choice(page, "a1", "yes") # back to its first value, mid-flight
page.wait_for_timeout(2500)
same = page.evaluate("window.__same === 1")
shown = page.evaluate(
"!document.querySelector('#bk-ask-a2-submit .bk-ask-status').hidden")
page.close()
assert same, "the page reloaded over a refused POST"
assert shown, "the refusal was never said"