"""R2 C3/C5/C6 in a real DOM: in-place judgment. A TestClient can prove what the server answers. It cannot prove that a flag made in the page lands without a reload, that the regions come back fresh, or that a failure never re-POSTs. So: a real uvicorn, a real Chromium — the same harness as test_embed_browser.py, and like it this SKIPS, never fails, when no browser is available. """ import contextlib import pathlib import socket import sys import threading import time import pytest sys.path.insert(0, str(pathlib.Path(__file__).parent.parent)) from booth.app import create_app # noqa: E402 playwright_api = pytest.importorskip("playwright.sync_api", reason="playwright is not installed") PNG = b"\x89PNG\r\n\x1a\n" # 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() @contextlib.contextmanager def _serving(data_dir: pathlib.Path): """A real uvicorn on a free port over `data_dir`. A context manager rather than only a fixture, so a test can serve from a data dir of its own shape (a long install path, say).""" 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(data_dir, ttl_hours=24, start_sweeper=False) server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")) 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}" finally: server.should_exit = True thread.join(timeout=10) @pytest.fixture def live(tmp_path): with _serving(tmp_path) as base: yield base, tmp_path def _set(root: pathlib.Path, n: int = 30) -> pathlib.Path: b = root / "g" b.mkdir() for i in range(1, n + 1): (b / f"{i:02d}.png").write_bytes(PNG) return b def test_a_flag_lands_in_place_and_every_region_catches_up(browser, live): """Click a tile's flag far down the page: no reload, the scroll position holds, and the tile, the tray and the rail's count all show the server's new state — the three places one flag appears.""" base, root = live _set(root) posts = [] page = browser.new_page(viewport={"width": 1400, "height": 800}) page.on("request", lambda r: posts.append(r.url) if r.method == "POST" else None) page.goto(f"{base}/b/g/", wait_until="networkidle") page.evaluate("window.__noReload = 1") tile = page.locator('figure.item[data-item="24.png"]') tile.scroll_into_view_if_needed() y = page.evaluate("window.scrollY") tile.locator(".flagtoggle button").click() page.wait_for_selector('figure.item.is-flagged[data-item="24.png"]', timeout=10000) state = page.evaluate("""() => ({ reload: window.__noReload !== 1, y: window.scrollY, tray: [...document.querySelectorAll('.tray .tray-ord')].map(e => e.textContent), count: document.querySelector('.rail-f[data-filter="flagged"] b').textContent, })""") page.close() assert not state["reload"] assert abs(state["y"] - y) < 4, "the page must not jump" assert state["tray"] == ["#24"] assert state["count"] == "1" assert len(posts) == 1 def test_a_failed_save_says_so_reloads_and_never_re_posts(browser, live): """The server refuses the write (the marks file went bad under the page). The script must not retry — a retry after a lost response would duplicate the judgment — it says so and reloads to show the server's truth.""" base, root = live b = _set(root, 3) posts = [] page = browser.new_page() page.on("request", lambda r: posts.append(r.url) if r.method == "POST" else None) page.goto(f"{base}/b/g/", wait_until="networkidle") (b / ".marks.json").write_text("{damaged") page.locator('figure.item[data-item="01.png"] .flagtoggle button').click() # the reader is TOLD before the page goes (Wren, hulda: nothing asserted it) page.wait_for_selector('[data-region="status"]:not([hidden])', timeout=5000) said = page.locator('[data-region="status"]').inner_text() page.wait_for_load_state("networkidle") page.wait_for_timeout(1500) # past the reload page.close() assert "Could not save in place" in said assert len(posts) == 1, f"re-POSTed: {posts}" def test_a_lost_response_after_a_landed_write_is_never_retried(browser, live): """The case the never-re-POST rule exists for: the server WROTE the note, then the response was lost. A retry would write it twice. The route lets the request reach the server and then drops the reply.""" from booth.marks import marks_for base, root = live b = _set(root, 2) page = browser.new_page(viewport={"width": 1400, "height": 900}) page.goto(f"{base}/b/g/", wait_until="networkidle") def drop_after_write(route): route.fetch() # the write lands route.abort() # ...and the browser never hears back page.route("**/b/g/note", drop_after_write) page.locator(".verdict .mark-add textarea").fill("exactly once") page.locator(".verdict .mark-add button").click() page.wait_for_selector('[data-region="status"]:not([hidden])', timeout=5000) page.wait_for_timeout(1500) page.close() notes = [m for m in marks_for(b) if m.shape == "note" and m.text == "exactly once"] assert len(notes) == 1, f"written {len(notes)} times" def test_a_tile_the_fresh_page_no_longer_has_stays_put_marked_stale(browser, live): """Un-flag under ?filter=flagged: the fresh page has no such tile. It is left where it is (nothing shifts under the reader) and marked stale.""" from booth.marks import set_flag base, root = live b = _set(root, 3) set_flag(b, "01.png", True) set_flag(b, "02.png", True) page = browser.new_page(viewport={"width": 1400, "height": 900}) page.goto(f"{base}/b/g/?filter=flagged", wait_until="networkidle") page.locator('figure.item[data-item="01.png"] .flagtoggle button').click() page.wait_for_selector('figure.item.is-stale[data-item="01.png"]', timeout=10000) tiles = page.eval_on_selector_all("figure.item", "els => els.map(e => e.dataset.item)") page.close() assert tiles == ["01.png", "02.png"] def test_the_review_keys_judge_in_place_and_stay_out_of_the_note(browser, live): """At full size: F typed into the note is a letter; F outside it flags IN PLACE (the filmstrip underline and the tape catch up, no reload); Space moves on; Esc goes back to the grid at the tile you were on.""" base, root = live _set(root, 4) page = browser.new_page(viewport={"width": 1400, "height": 900}) page.goto(f"{base}/b/g/view?f=02.png", wait_until="networkidle") page.evaluate("window.__noReload = 1") page.locator("#vnote-text").click() page.keyboard.type("fff") assert page.locator(".vflag-btn.is-flagged").count() == 0 assert page.locator("#vnote-text").input_value() == "fff" page.locator(".vr-where").click() # focus back on the page, not a field page.keyboard.press("f") page.wait_for_selector(".vflag-btn.is-flagged", timeout=10000) state = page.evaluate("""() => ({ reload: window.__noReload !== 1, film: [...document.querySelectorAll('.film-f.is-flagged .film-ord')].map(e => e.textContent), draft: document.getElementById('vnote-text').value, })""") assert not state["reload"] assert state["film"] == ["#2"] assert state["draft"] == "fff", "an unsaved note must survive a swap it was not part of" page.keyboard.press("n") focused = page.evaluate("document.activeElement.id") assert focused == "vnote-text", "N focuses the note" page.locator(".vr-where").click() with page.expect_navigation(): page.keyboard.press(" ") assert page.url.endswith("/b/g/view?f=03.png") with page.expect_navigation(): page.keyboard.press("ArrowRight") assert page.url.endswith("/b/g/view?f=04.png") with page.expect_navigation(): page.keyboard.press("ArrowLeft") assert page.url.endswith("/b/g/view?f=03.png") with page.expect_navigation(): page.keyboard.press("Escape") assert page.url.endswith("/b/g/#item-03.png") page.close() def test_the_stage_survives_an_in_place_save_and_a_focused_radio_keeps_f(browser, live): """The stage is never a region: the same node must still be on the page after a flag lands (a playing track would restart otherwise). And F while a radio — an — has focus is not a flag.""" from booth.marks import declare_pick base, root = live b = _set(root, 2) declare_pick(b, "q", {"prompt": "Sharp?", "options": ["yes", "no"]}, target="01.png") page = browser.new_page(viewport={"width": 1400, "height": 900}) page.goto(f"{base}/b/g/view?f=01.png", wait_until="networkidle") page.evaluate("document.getElementById('vstage').__mark = 1") page.locator('.vrail input[type=radio]').first.focus() page.keyboard.press("f") page.wait_for_timeout(600) assert page.locator(".vflag-btn.is-flagged").count() == 0, "F in a radio is not a flag" page.locator(".vr-where").click() page.keyboard.press("f") page.wait_for_selector(".vflag-btn.is-flagged", timeout=10000) same = page.evaluate("document.getElementById('vstage').__mark === 1") page.close() assert same, "the stage was replaced by the swap" @pytest.mark.parametrize("js", [True, False]) def test_on_a_narrow_screen_flags_and_notes_fold_and_on_a_wide_one_they_show(browser, live, js): """C5 (Wren W1, 4/4): under 1000px the verdict stacks ABOVE the set, and its flags and notes fold into
so the question is not buried — with no script. Wide, they are simply shown. Checked with JS on and off.""" from booth.marks import set_flag, write_note base, root = live b = _set(root, 3) set_flag(b, "02.png", True) write_note(b, None, "a booth note") for width, shown in ((390, False), (1400, True)): ctx = browser.new_context(viewport={"width": width, "height": 900}, java_script_enabled=js) page = ctx.new_page() page.goto(f"{base}/b/g/", wait_until="networkidle") tray = page.locator(".verdict .tray-item").first note = page.locator(".verdict .mark-text", has_text="a booth note") assert tray.is_visible() is shown, (width, js, "tray") assert note.is_visible() is shown, (width, js, "note") if not shown: # folded, but one tap away page.locator(".verdict summary.v-fold-head").first.click() assert tray.is_visible() ctx.close() # ---- fixups from the heid bug-hunt panel (round "Nyx") ------------------------ def test_the_link_board_still_confirms_before_removing_a_row(browser, live): """Nyx N1 (2/4): the R2 rewrite of booth.html's scripts deleted the board's multi-select + confirmation script along with the handlers it replaced. Removing a row is destructive; the confirm naming it must still stand in front of the POST, and select-all must still select.""" base, root = live board = root / "links" board.mkdir() (board / "links.md").write_text( "- [one](http://x/1) · a · 2026-09-01 10:00\n" "- [two](http://x/2) · a · 2026-09-01 10:01\n") page = browser.new_page() page.goto(f"{base}/b/links/", wait_until="networkidle") dialogs = [] page.on("dialog", lambda d: (dialogs.append(d.message), d.dismiss())) page.locator(".board-rm-btn").first.click() page.wait_for_timeout(300) page.locator("#board-selall").check() ticked = page.eval_on_selector_all(".board-check", "els => els.filter(e => e.checked).length") page.close() assert dialogs and "Remove this link?" in dialogs[0] assert ticked == 2 assert (board / "links.md").read_text().count("- [") == 2, "a dismissed confirm removed nothing" def test_an_unsaved_choice_survives_a_save_elsewhere_and_a_double_click_writes_once(browser, live): """Nyx N5 (hulda, regin, groa): a picked-but-unsent radio was reset by any other in-place save, drafts were matched by POSITION, and a double-click on Add note wrote two notes. Now: dirty controls carry by identity, and a form already in flight ignores a second submit.""" from booth.marks import declare_pick, marks_for base, root = live b = _set(root, 3) declare_pick(b, "q", {"prompt": "Which?", "options": ["x", "y"]}) page = browser.new_page(viewport={"width": 1400, "height": 900}) page.goto(f"{base}/b/g/", wait_until="networkidle") page.locator('.verdict input[type=radio][value="y"]').check() page.locator('figure.item[data-item="02.png"] .flagtoggle button').click() page.wait_for_selector('figure.item.is-flagged[data-item="02.png"]', timeout=10000) assert page.locator('.verdict input[type=radio][value="y"]').is_checked() page.locator(".verdict .mark-add textarea").fill("once") page.locator(".verdict .mark-add button").dblclick() page.wait_for_timeout(1500) page.close() assert [m.text for m in marks_for(b) if m.shape == "note"] == ["once"] # The first page refresh after a save is held back 1s in the CLIENT: the server # renders it at once (so it carries only the first flag) and the browser sees # it late. Localhost alone never loses that race, so without the hold the test # passed with sequencing deleted — it has to be forced to be a control. _HOLD_FIRST_REFRESH = """ (function () { var real = window.fetch, n = 0; window.fetch = function (u, o) { var p = real.apply(this, arguments); if ((!o || !o.method || o.method === 'GET') && n++ === 0) { return p.then(function (r) { return new Promise(function (res) { setTimeout(function () { res(r); }, 1000); }); }); } return p; }; })(); """ def test_quick_successive_flags_all_show(browser, live): """Nyx (hulda): with no sequencing, an older refresh landing after a newer one showed the newer flag as gone. Saves are serialized.""" base, root = live _set(root, 4) page = browser.new_page(viewport={"width": 1400, "height": 900}) page.add_init_script(_HOLD_FIRST_REFRESH) page.goto(f"{base}/b/g/", wait_until="networkidle") for rel in ("01.png", "02.png", "03.png"): page.locator(f'figure.item[data-item="{rel}"] .flagtoggle button').click() page.wait_for_timeout(150) page.wait_for_function( "document.querySelectorAll('figure.item.is-flagged').length === 3", timeout=10000) page.wait_for_timeout(1500) n = page.locator("figure.item.is-flagged").count() page.close() assert n == 3 def test_the_standalone_marks_page_updates_in_place(browser, live): """Nyx (kimi): the marks page's forms are in-place, but the page had no region, so an answer saved and the page never showed it.""" from booth.marks import declare_pick base, root = live b = _set(root, 1) declare_pick(b, "q", {"prompt": "Which?", "options": ["x", "y"]}) page = browser.new_page() page.goto(f"{base}/b/g/marks", wait_until="networkidle") page.evaluate("window.__same_page = 1") page.locator('input[type=radio][value="x"]').check() page.locator(".mark-submit").click() page.wait_for_selector(".mark.is-answered", timeout=10000) # In place, not the reload fallback: the page's own window survived. same = page.evaluate("window.__same_page === 1") page.close() assert same def test_the_next_arrow_clears_the_rail_only_beside_it(browser, live): """Nyx: view.html's bare `.vnext{right:360px}` came later in the page than base.html's narrow override and won it, parking the arrow 360px in from the edge of a phone. Wide: it clears the rail. Narrow: it sits at the edge.""" base, root = live _set(root, 3) rights = {} for w in (1400, 390): page = browser.new_page(viewport={"width": w, "height": 900}) page.goto(f"{base}/b/g/view?f=01.png", wait_until="networkidle") rights[w] = page.evaluate( "getComputedStyle(document.querySelector('.vnav.vnext')).right") page.close() assert rights == {1400: "360px", 390: "0px"} def test_the_desk_never_scrolls_sideways_at_any_width(browser, tmp_path): """The row-controls change made a Desk row a flex container, and a long provenance line (nowrap, ellipsised) then set the Desk column's MINIMUM width: at 390px the page scrolled sideways to 1029px. The column is capped at the space it has; the ellipsis does the rest. Slate (heid bug-hunt) widened it three ways: the invariant is PAGE-level, so a long install path — printed in the footer and the empty state — must wrap too (groa); the widths between the two first tested (700-1000px, a non-wrapping flex line) are swept with the heaviest row the Desk draws (regin); and the page is compared with its OWN client width, never the viewport's, which a vertical scrollbar would make a false failure (kimi).""" from booth.manifest import write_manifest from booth.marks import declare_pick, set_flag # ONE unbreakable run: a hyphen or a slash is a line-break opportunity, and # the first draft of this fixture ("a-very-long-install-path-" * 9) wrapped # all by itself — the test passed with the bug present. root = tmp_path / ("averylonginstallpath" * 11) root.mkdir() over = {} with _serving(root) as base: for w in (390, 1400): # the empty Desk first page = browser.new_page(viewport={"width": w, "height": 844}) page.goto(f"{base}/", wait_until="networkidle") over[("empty", w)] = page.evaluate( "document.documentElement.scrollWidth - document.documentElement.clientWidth") page.close() d = root / ("heavy-" + "x" * 60) d.mkdir() for i in range(12): (d / f"{i:02d}.png").write_bytes(PNG) set_flag(d, f"{i:02d}.png", True) declare_pick(d, "q", {"prompt": "Which?", "options": ["x", "y"]}) (d / ".uploaded").write_bytes(b"") write_manifest(d, "design-dev", title="A set with a long title " * 4, why="a reason long enough to overflow any phone " * 6) for w in (390, 720, 850, 1000, 1400): page = browser.new_page(viewport={"width": w, "height": 844}) page.goto(f"{base}/", wait_until="networkidle") over[("heavy", w)] = page.evaluate( "document.documentElement.scrollWidth - document.documentElement.clientWidth") page.close() assert all(v <= 0 for v in over.values()), over def test_the_wipe_dialog_shows_what_is_being_wiped_and_never_fails_open(browser, live): """Slate T4 (groa): the booth name travels as data, never into a script — but the confirm TEXT showed it raw, so a name carrying a bidi override (U+202E) or a newline rewrote what the operator reads before approving a wipe. Controls and bidi formatting show as U+FFFD instead. And (seat S1) a `data-confirm` word the page does not know submitted with NO prompt; an unknown word now asks generically — fail closed, never open.""" import os base, root = live name = "safe‮gnp.xe\nline2" d = root / name d.mkdir() (d / "a.png").write_bytes(PNG) past = time.time() - 10_000 os.utime(d / "a.png", (past, past)) (d / ".viewed").write_bytes(b"") page = browser.new_page(viewport={"width": 1400, "height": 900}) said = [] page.on("dialog", lambda dlg: (said.append(dlg.message), dlg.dismiss())) page.goto(f"{base}/", wait_until="networkidle") # r2b D1: the row's controls appear on hover (operator ruling), so hover first page.locator(".desk-row").first.hover() page.locator("form.wipe button").first.click() page.wait_for_timeout(300) page.evaluate("""() => { const f = document.createElement('form'); f.method = 'post'; f.action = '/nowhere'; f.setAttribute('data-confirm', 'typo'); f.setAttribute('data-booth', 'x'); f.innerHTML = ''; document.body.appendChild(f); f.querySelector('button').click(); }""") page.wait_for_timeout(300) page.close() assert d.exists(), "a dismissed confirm must not wipe" assert len(said) == 2, said shown = said[0].split("\n\n")[0] assert not any(c in shown for c in "‮\n"), repr(shown) assert "safe�gnp.xe�line2" in shown, repr(shown) _FILTER = "sel => { const e = document.querySelector(sel); return e ? getComputedStyle(e).filter : 'MISSING'; }" def _blurred_set(root: pathlib.Path, name: str = "g", blur=("a.png",), flag=("a.png",)) -> pathlib.Path: from booth.app import set_blurred from booth.marks import set_flag b = root / name b.mkdir() for rel in ("a.png", "b.png", "c.png"): (b / rel).write_bytes(PNG) for rel in blur: set_blurred(b, rel, True) for rel in flag: set_flag(b, rel, True) return b def _settle(page) -> None: page.wait_for_timeout(400) # the filter transition (--dur-2) def test_reveal_all_reveals_every_blurred_surface_and_survives_the_next_page(browser, live): """r2b D2 (blur ruling A): one click lifts the blur on the tile, the tray, the review stage and the filmstrip, and it holds on the next page of the same tab. A fresh tab is blurred again — per tab, never persisted.""" base, root = live _blurred_set(root) ctx = browser.new_context(viewport={"width": 1400, "height": 900}) page = ctx.new_page() page.goto(f"{base}/b/g/", wait_until="networkidle") tile, tray = 'figure.item[data-item="a.png"] img', ".tray-item.is-blurred img" before = [page.evaluate(_FILTER, tile), page.evaluate(_FILTER, tray)] page.locator("[data-reveal-all]").click() _settle(page) after = [page.evaluate(_FILTER, tile), page.evaluate(_FILTER, tray)] page.goto(f"{base}/b/g/view?f=a.png", wait_until="networkidle") _settle(page) review = [page.evaluate(_FILTER, "#vimg"), page.evaluate(_FILTER, ".film-f.is-blurred img"), page.locator("[data-reveal-all]").inner_text()] fresh = ctx.new_page() # a new tab: sessionStorage is per tab fresh.goto(f"{base}/b/g/view?f=a.png", wait_until="networkidle") _settle(fresh) again = fresh.evaluate(_FILTER, "#vimg") ctx.close() assert all("blur" in f for f in before), before assert after == ["none", "none"], after assert review[:2] == ["none", "none"] and "blur again" in review[2], review assert "blur" in again, again def test_reveal_all_on_booth_a_does_not_reveal_booth_b_or_the_desk(browser, live): """r2b D2 + INV-4: the reveal is scoped to the booth you are in. Booth A's cannot follow you into booth B, and nothing on the index is revealed.""" base, root = live _blurred_set(root, "ga") _blurred_set(root, "gb") page = browser.new_page(viewport={"width": 1400, "height": 900}) page.goto(f"{base}/b/ga/", wait_until="networkidle") page.locator("[data-reveal-all]").click() page.goto(f"{base}/b/gb/", wait_until="networkidle") _settle(page) b_tile = page.evaluate(_FILTER, 'figure.item[data-item="a.png"] img') page.goto(f"{base}/", wait_until="networkidle") desk = page.evaluate("""() => [...document.querySelectorAll('.desk-strip img.blurred-thumb')] .map(i => getComputedStyle(i).filter)""") page.close() assert b_tile == "blur(22px)", b_tile assert desk and all(f == "blur(16px)" for f in desk), desk # exact: blur(0px) is not blurred def test_reveal_all_survives_an_in_place_save(browser, live): """r2b D2: after an in-place save the blur is still lifted — the swapped-in tile included — the control still says "blur again" and still works, and the per-tile reveal buttons are still stood down.""" base, root = live _blurred_set(root, blur=("a.png", "b.png"), flag=()) page = browser.new_page(viewport={"width": 1400, "height": 900}) page.goto(f"{base}/b/g/", wait_until="networkidle") page.evaluate("window.__same_page = 1") page.locator("[data-reveal-all]").click() page.locator('figure.item[data-item="b.png"] .flagtoggle button').click() page.wait_for_selector('figure.item.is-flagged[data-item="b.png"]', timeout=10000) _settle(page) got = {"same": page.evaluate("window.__same_page === 1"), "b": page.evaluate(_FILTER, 'figure.item[data-item="b.png"] img'), "label": page.locator("[data-reveal-all]").inner_text(), "tile_btn": page.evaluate("""() => getComputedStyle( document.querySelector('figure.item[data-item="b.png"] .reveal')).display""")} page.locator("[data-reveal-all]").click() _settle(page) got["back"] = page.evaluate(_FILTER, 'figure.item[data-item="b.png"] img') page.close() assert got["same"] and got["b"] == "none" and "blur again" in got["label"], got assert got["tile_btn"] == "none" and "blur" in got["back"], got def test_blur_again_restores_each_items_own_reveal(browser, live): """r2b D2: Reveal all never touches an item's own reveal, so "blur again" returns each item exactly as it was — one revealed on its own stays so.""" base, root = live _blurred_set(root, blur=("a.png", "b.png"), flag=()) page = browser.new_page(viewport={"width": 1400, "height": 900}) page.goto(f"{base}/b/g/", wait_until="networkidle") page.locator('figure.item[data-item="a.png"] .reveal').click() page.locator("[data-reveal-all]").click() page.locator("[data-reveal-all]").click() _settle(page) got = [page.evaluate(_FILTER, f'figure.item[data-item="{r}"] img') for r in ("a.png", "b.png")] page.close() assert got[0] == "none" and "blur" in got[1], got def test_reveal_all_never_shows_without_js_and_a_storage_failure_still_applies_the_click(browser, live): """r2b D2: without JS the control is in the markup but never shown. With sessionStorage throwing on write (a private window), the click still applies to the page — only the memory is lost.""" base, root = live _blurred_set(root) ctx = browser.new_context(java_script_enabled=False) page = ctx.new_page() page.goto(f"{base}/b/g/", wait_until="networkidle") nojs = page.locator("[data-reveal-all]").is_visible() ctx.close() page = browser.new_page(viewport={"width": 1400, "height": 900}) errors = [] page.on("pageerror", lambda e: errors.append(str(e))) # READS and WRITES both throw: the pre-paint read must degrade to "not # revealed" without raising, and the click must still apply. page.add_init_script("""Storage.prototype.setItem = function () { throw new Error('quota'); }; Storage.prototype.getItem = function () { throw new Error('denied'); };""") page.goto(f"{base}/b/g/", wait_until="networkidle") _settle(page) before = page.evaluate(_FILTER, 'figure.item[data-item="a.png"] img') page.locator("[data-reveal-all]").click() _settle(page) lifted = page.evaluate(_FILTER, 'figure.item[data-item="a.png"] img') page.close() assert not nojs assert before == "blur(22px)" and lifted == "none", (before, lifted) assert errors == [], errors def test_an_items_own_reveal_survives_an_in_place_save(browser, live): """heid code-review (groa, kimi): nothing pinned the swap carrying an item's own `revealed` — deleting it from the carry list left every test green. Reveal one tile, save something else in place: it stays revealed, and its button still says so.""" base, root = live _blurred_set(root, blur=("a.png", "b.png"), flag=()) page = browser.new_page(viewport={"width": 1400, "height": 900}) page.goto(f"{base}/b/g/", wait_until="networkidle") page.locator('figure.item[data-item="b.png"] .reveal').click() page.locator('figure.item[data-item="a.png"] .flagtoggle button').click() page.wait_for_selector('figure.item.is-flagged[data-item="a.png"]', timeout=10000) _settle(page) got = [page.evaluate(_FILTER, 'figure.item[data-item="b.png"] img'), page.locator('figure.item[data-item="b.png"] .reveal').inner_text()] page.close() assert got[0] == "none" and "hide" in got[1], got def test_reveal_all_lifts_a_blurred_docs_own_page(browser, live): """The doc page's blur (heid code-review) obeys the same class, and its own reveal works.""" from booth.app import set_blurred base, root = live b = root / "g" b.mkdir() (b / "n.md").write_text("# secret\n\nbody") set_blurred(b, "n.md", True) page = browser.new_page(viewport={"width": 1200, "height": 800}) page.goto(f"{base}/b/g/view?f=n.md", wait_until="networkidle") _settle(page) at_rest = page.evaluate(_FILTER, "#docbody .markdown-body, #docbody .textview") page.locator("#docreveal").click() _settle(page) own = page.evaluate(_FILTER, "#docbody .markdown-body, #docbody .textview") page.close() assert at_rest == "blur(22px)" and own == "none", (at_rest, own) def test_space_on_a_focused_review_button_presses_it_and_does_not_move_on(browser, live): """heid bug-hunt (hulda): the review's document-level Space handler moved to the next item before a focused button could take the key, so a keyboard user could not press Reveal all or the fog control with Space.""" base, root = live _blurred_set(root, flag=()) page = browser.new_page(viewport={"width": 1400, "height": 900}) page.goto(f"{base}/b/g/view?f=a.png", wait_until="networkidle") page.locator("[data-reveal-all]").focus() page.keyboard.press(" ") _settle(page) got = [page.url, page.evaluate("document.documentElement.classList.contains('reveal-all')")] page.close() assert got[0].endswith("/b/g/view?f=a.png") and got[1], got def test_the_review_and_doc_top_bars_fit_a_phone(browser, live): """heid bug-hunt (hulda, groa; needs-repro): the top bar gained the fog control and Reveal all beside the name, the fit toggle and the download. At 390px no review or doc page scrolls sideways, even with a long name.""" from booth.app import set_blurred base, root = live b = _blurred_set(root, flag=()) long = "a-rather-long-picture-name-" * 3 + ".png" (b / long).write_bytes(PNG) set_blurred(b, long, True) (b / "n.md").write_text("# n") set_blurred(b, "n.md", True) over = {} for url in (f"/b/g/view?f={long}", "/b/g/view?f=n.md"): page = browser.new_page(viewport={"width": 390, "height": 844}) page.goto(f"{base}{url}", wait_until="networkidle") over[url] = page.evaluate( "document.documentElement.scrollWidth - document.documentElement.clientWidth") # and nothing squeezed into a stack: every top-bar control is one line over[url + " tallest"] = page.evaluate("""() => Math.max(...[...document.querySelectorAll( '.vbar button, .vbar .vbtn')].filter(e => e.offsetParent).map(e => e.getBoundingClientRect().height)) - 40""") page.close() assert all(v <= 0 for v in over.values()), over 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 def _two_rows(root: pathlib.Path) -> None: """A kept and an ephemeral booth, both looked at since they landed (so no 'new' badge): the plainest rows the Desk draws.""" import os past = time.time() - 10_000 for name, kept in (("kept1", True), ("loose", False)): d = root / name d.mkdir() (d / "a.png").write_bytes(PNG) os.utime(d / "a.png", (past, past)) (d / ".viewed").write_bytes(b"") if kept: (d / ".forever").write_bytes(b"") _ROW_BOXES = """row => { const box = e => { const r = e.getBoundingClientRect(); return [r.x, r.y, r.width, r.height].map(Math.round); }; const parts = [...row.querySelectorAll('.desk-strip, .desk-strip img, .desk-main, .desk-side, .life')]; return parts.map(box); }""" def test_the_row_controls_take_no_room_where_a_hover_exists(browser, live): """r2b D1 (operator: 'download, keep and release buttons only appear on mouseover'; earlier: 'release and x take up space whether or not they're visible'). Where a real hover exists the cluster floats over the preview strip: at rest invisible AND unclickable; on hover visible AND clickable; and every other box in the row is the same with the cluster removed. It never covers the text or the side column, at any width.""" base, root = live _two_rows(root) over = {} for w in (390, 720, 1000, 1400): page = browser.new_page(viewport={"width": w, "height": 900}) page.goto(f"{base}/", wait_until="networkidle") assert page.evaluate("matchMedia('(hover: hover) and (pointer: fine)').matches") page.mouse.move(0, 0) row = page.locator('.desk-row[data-booth="loose"]') acts = row.locator(".desk-acts") rest = acts.evaluate("a => [getComputedStyle(a).opacity, getComputedStyle(a).pointerEvents]") with_acts = row.evaluate(_ROW_BOXES) row.hover() page.wait_for_timeout(350) shown = acts.evaluate("a => [getComputedStyle(a).opacity, getComputedStyle(a).pointerEvents]") over[w] = row.evaluate("""row => { const a = row.querySelector('.desk-acts').getBoundingClientRect(); return [...row.querySelectorAll('.desk-main, .desk-side')].some(e => { const r = e.getBoundingClientRect(); return a.left < r.right && r.left < a.right && a.top < r.bottom && r.top < a.bottom; }); }""") acts.evaluate("a => a.remove()") without = row.evaluate(_ROW_BOXES) page.close() assert rest == ["0", "none"], (w, rest) assert shown == ["1", "auto"], (w, shown) assert with_acts == without, (w, with_acts, without) assert not any(over.values()), over # and on hover a control is really pressable: keep reaches the server page = browser.new_page(viewport={"width": 1400, "height": 900}) page.goto(f"{base}/", wait_until="networkidle") row = page.locator('.desk-row[data-booth="loose"]') row.hover() page.wait_for_timeout(350) with page.expect_navigation(): row.locator(".desk-acts form.keepit button").click() page.close() assert (root / "loose" / ".forever").exists() def test_on_touch_the_row_controls_are_visible_in_flow_and_at_least_28px(browser, live): """r2b D1: hover-only would mean no controls at all on touch. Without a real hover (here: a coarse, touch primary pointer) the cluster is visible, in flow on its own line, and every control is at least the 28px floor (Slate T2) — wipe clear of its neighbour.""" base, root = live _two_rows(root) ctx = browser.new_context(viewport={"width": 390, "height": 844}, has_touch=True, is_mobile=True) page = ctx.new_page() page.goto(f"{base}/", wait_until="networkidle") coarse = page.evaluate("matchMedia('(pointer: coarse)').matches") got = page.evaluate("""() => [...document.querySelectorAll('.desk-row')].map(row => { const a = row.querySelector('.desk-acts'), cs = getComputedStyle(a); const ctl = [...a.querySelectorAll('button, a')].map(b => b.getBoundingClientRect()); const main = row.querySelector('.desk-main').getBoundingClientRect(); const w = a.querySelector('form.wipe button').getBoundingClientRect(); const prev = ctl[ctl.length - 2]; return {opacity: cs.opacity, position: cs.position, below: a.getBoundingClientRect().top >= main.bottom - 1, small: ctl.filter(r => r.width < 28 || r.height < 28).length, gap: Math.round(Math.max(w.left - prev.right, w.top - prev.bottom))}; })""") ctx.close() assert coarse, "the emulation must present a coarse pointer, or this test measures nothing" for g in got: assert g["opacity"] == "1" and g["position"] == "static" and g["below"], got assert g["small"] == 0 and g["gap"] >= 8, got def test_the_row_controls_run_zip_keep_or_release_then_wipe(browser, live): """r2b D1 (operator: 'the zip download button is in between keep/release and wipe, and looks awkward'). Zip leads; release stays next to x.""" base, root = live _two_rows(root) page = browser.new_page(viewport={"width": 1400, "height": 900}) page.goto(f"{base}/", wait_until="networkidle") order = page.evaluate("""() => Object.fromEntries([...document.querySelectorAll('.desk-row')].map(row => [row.dataset.booth, [...row.querySelectorAll('.desk-acts > *')].map(e => e.className.split(' ')[0])]))""") page.close() assert order == {"kept1": ["dl-link", "release", "wipe"], "loose": ["dl-link", "keepit", "wipe"]}, order _VAR = "n => getComputedStyle(document.documentElement).getPropertyValue(n).trim()" def test_the_theme_toggle_forces_light_and_dark_and_system_follows_the_os_live(browser, live): """r2b D3 (operator: 'light mode toggle at the top (dark, light, system)'). Light and Dark force the theme and survive a reload in the same browser (localStorage). System hands the question back to the OS, and follows it LIVE — an OS flip moves the page with no reload and no listener, because a media query tracks the OS by construction.""" base, root = live _set(root, 1) ctx = browser.new_context(color_scheme="dark", viewport={"width": 1200, "height": 800}) page = ctx.new_page() page.goto(f"{base}/b/g/", wait_until="networkidle") dark = page.evaluate(_VAR, "--surface-base") page.locator('.theme [data-theme-choice="light"]').click() light = page.evaluate(_VAR, "--surface-base") page.reload(wait_until="networkidle") after_reload = page.evaluate(_VAR, "--surface-base") pressed = page.locator('.theme [aria-pressed="true"]').get_attribute("data-theme-choice") page.locator('.theme [data-theme-choice="system"]').click() system_dark = page.evaluate(_VAR, "--surface-base") page.emulate_media(color_scheme="light") system_light = page.evaluate(_VAR, "--surface-base") page.locator('.theme [data-theme-choice="dark"]').click() forced_dark_on_light_os = page.evaluate(_VAR, "--surface-base") ctx.close() assert dark != light assert after_reload == light and pressed == "light" assert system_dark == dark and system_light == light, (system_dark, system_light) assert forced_dark_on_light_os == dark def test_a_forced_theme_follows_high_contrast(browser, live): """r2b D3: under prefers-contrast: more, a FORCED theme gets its own high-contrast variant — forced dark resolves exactly what an OS-dark page resolves, forced light exactly what an OS-light page does, whatever the OS says about the scheme.""" base, root = live _set(root, 1) got = {} for os_scheme in ("dark", "light"): for forced in (None, "dark", "light"): ctx = browser.new_context(color_scheme=os_scheme, reduced_motion="no-preference") page = ctx.new_page() page.emulate_media(color_scheme=os_scheme) page.goto(f"{base}/b/g/", wait_until="networkidle") page.evaluate("f => f ? document.documentElement.setAttribute('data-theme', f) : null", forced) # contrast: more is emulated through CDP; Playwright has no option for it cdp = ctx.new_cdp_session(page) cdp.send("Emulation.setEmulatedMedia", {"features": [ {"name": "prefers-contrast", "value": "more"}, {"name": "prefers-color-scheme", "value": os_scheme}]}) # tokens that DIFFER between a theme and its high-contrast variant # (--surface-card does not, so reading it saw nothing — heid, 2/4) got[(os_scheme, forced)] = (page.evaluate(_VAR, "--text-faint"), page.evaluate(_VAR, "--border-default")) if forced is None and os_scheme == "dark": cdp.send("Emulation.setEmulatedMedia", {"features": [ {"name": "prefers-contrast", "value": "no-preference"}, {"name": "prefers-color-scheme", "value": os_scheme}]}) got["dark, no contrast"] = (page.evaluate(_VAR, "--text-faint"), page.evaluate(_VAR, "--border-default")) ctx.close() assert got[("light", "dark")] == got[("dark", None)] == got[("dark", "dark")] assert got[("dark", "light")] == got[("light", None)] == got[("light", "light")] assert got[("dark", None)] != got[("light", None)] assert got[("dark", None)] != got["dark, no contrast"] # high contrast really applied def test_the_theme_toggle_never_shows_without_js_and_a_storage_failure_still_applies(browser, live): """r2b D3: in the markup with `hidden`, never shown without JS (the page follows the OS). With localStorage throwing, a click still applies to the page; only the memory is lost.""" base, root = live _set(root, 1) ctx = browser.new_context(java_script_enabled=False) page = ctx.new_page() page.goto(f"{base}/", wait_until="networkidle") nojs = page.locator(".theme").is_visible() ctx.close() ctx = browser.new_context(color_scheme="dark") page = ctx.new_page() errors = [] page.on("pageerror", lambda e: errors.append(str(e))) page.add_init_script("""Storage.prototype.setItem = function () { throw new Error('quota'); }; Storage.prototype.getItem = function () { throw new Error('denied'); };""") page.goto(f"{base}/", wait_until="networkidle") before = page.evaluate(_VAR, "--surface-base") page.locator('.theme [data-theme-choice="light"]').click() after = page.evaluate(_VAR, "--surface-base") ctx.close() assert not nojs assert before != after and errors == [], (before, after, errors) def test_a_forced_theme_reaches_the_ask_chrome_inside_a_verbatim_page(browser, live): """r2b D3 (operator: 'theme toggle reaches inside'): the `.bk-ask` chrome embed.js mounts in an author's page follows the stored choice, and a change in another tab (the `storage` event) moves it live. The host page's own is never touched.""" from booth.marks import declare_pick base, root = live b = root / "rep" b.mkdir() declare_pick(b, "winner", {"prompt": "Which?", "options": ["A", "B"]}) (b / "index.html").write_text('r

R

' '') ctx = browser.new_context(color_scheme="dark") page = ctx.new_page() page.goto(f"{base}/b/rep/", wait_until="networkidle") page.wait_for_selector(".bk-ask") accent = lambda: page.evaluate("getComputedStyle(document.querySelector('.bk-ask')).getPropertyValue('--bk-accent').trim()") os_dark = accent() other = ctx.new_page() # the toggle, pressed in another tab other.goto(f"{base}/", wait_until="networkidle") other.locator('.theme [data-theme-choice="light"]').click() page.wait_for_timeout(300) forced_light = accent() host_html = page.evaluate("document.documentElement.getAttribute('data-theme')") page.reload(wait_until="networkidle") page.wait_for_selector(".bk-ask") after_reload = accent() ctx.close() assert os_dark == "#b2cd12" and forced_light == "#586519" and after_reload == "#586519", (os_dark, forced_light, after_reload) assert host_html is None def test_a_rows_booth_name_comes_before_its_controls_in_tab_order(browser, live): """heid bug-hunt (groa): the cluster sat before the text in the markup, so the first tab stop on a row was zip, then keep, then WIPE — before the booth it acts on — and with scripts off, wipe submits with no confirm. The name is first; the controls follow, wipe last.""" base, root = live _two_rows(root) ctx = browser.new_context(viewport={"width": 390, "height": 844}, has_touch=True, is_mobile=True) page = ctx.new_page() page.goto(f"{base}/", wait_until="networkidle") order = page.evaluate("""() => { const row = document.querySelector('.desk-row[data-booth="loose"]'); return [...row.querySelectorAll('a[href], button')].filter(e => e.tabIndex >= 0) .map(e => e.classList.contains('desk-title') ? 'title' : e.closest('form') ? e.closest('form').className.split(' ')[0] : e.className.split(' ')[0]); }""") ctx.close() assert order == ["title", "dl-link", "keepit", "wipe"], order def test_a_theme_chosen_in_one_tab_moves_the_others(browser, live): """heid bug-hunt (groa): the ask chrome followed a choice made in another tab, but the Booth's own open pages did not until reloaded.""" base, root = live _set(root, 1) ctx = browser.new_context(color_scheme="dark") a, b = ctx.new_page(), ctx.new_page() a.goto(f"{base}/", wait_until="networkidle") b.goto(f"{base}/b/g/", wait_until="networkidle") a.locator('.theme [data-theme-choice="light"]').click() b.wait_for_function("document.documentElement.getAttribute('data-theme') === 'light'", timeout=5000) pressed = b.locator('.theme [aria-pressed="true"]').get_attribute("data-theme-choice") a.locator('.theme [data-theme-choice="system"]').click() b.wait_for_function("!document.documentElement.hasAttribute('data-theme')", timeout=5000) ctx.close() assert pressed == "light" def test_the_theme_marks_only_the_ask_fragments_we_mounted(browser, live): """heid bug-hunt (regin): the theme mark went on every `.bk-ask` in the page, the author's own included. Only fragments the embed mounted.""" from booth.marks import declare_pick base, root = live b = root / "rep" b.mkdir() declare_pick(b, "winner", {"prompt": "Which?", "options": ["A", "B"]}) (b / "index.html").write_text('r
mine
' '') ctx = browser.new_context() page = ctx.new_page() page.add_init_script("try { localStorage.setItem('booth.theme', 'light'); } catch (e) {}") page.goto(f"{base}/b/rep/", wait_until="networkidle") page.wait_for_selector(".bk-ask:not(#authors)") got = page.evaluate("""() => [document.getElementById('authors').getAttribute('data-bk-theme'), [...document.querySelectorAll('.bk-ask:not(#authors)')].map(e => e.getAttribute('data-bk-theme'))]""") ctx.close() assert got[0] is None and got[1] and all(t == "light" for t in got[1]), got def test_the_pill_shows_at_rest_and_focus_reveals_the_controls(browser, live): """heid code-review: the pill's "visible with no hover" was never asserted (an opacity-0 box keeps its geometry), and nothing pinned KEYBOARD focus revealing the controls — `:focus-within` could go alone.""" base, root = live _two_rows(root) page = browser.new_page(viewport={"width": 1400, "height": 900}) page.goto(f"{base}/", wait_until="networkidle") page.mouse.move(0, 0) row = page.locator('.desk-row[data-booth="loose"]') pill = row.locator(".life").evaluate("e => [getComputedStyle(e).opacity, getComputedStyle(e).visibility, e.offsetWidth > 0]") row.locator(".desk-acts .dl-link").focus() page.wait_for_timeout(350) focused = row.locator(".desk-acts").evaluate("a => [getComputedStyle(a).opacity, getComputedStyle(a).pointerEvents]") page.close() assert pill == ["1", "visible", True], pill assert focused == ["1", "auto"], focused def test_with_scripts_off_a_rows_controls_still_act(browser, live): """r2b INV-2, never exercised with JS actually off (heid code-review): the hover reveal is CSS, and keep is a plain form.""" base, root = live _two_rows(root) ctx = browser.new_context(java_script_enabled=False, viewport={"width": 1400, "height": 900}) page = ctx.new_page() page.goto(f"{base}/", wait_until="networkidle") row = page.locator('.desk-row[data-booth="loose"]') row.hover() page.wait_for_timeout(350) with page.expect_navigation(): row.locator(".desk-acts form.keepit button").click() ctx.close() assert (root / "loose" / ".forever").exists() def test_reveal_all_lifts_the_doc_page_it_reaches(browser, live): """heid code-review (regin, seat-settled): the doc page carries the rules, but no test turned Reveal all on and looked there.""" from booth.app import set_blurred base, root = live b = root / "g" b.mkdir() (b / "a.png").write_bytes(PNG) (b / "n.md").write_text("# secret\n\nbody") set_blurred(b, "a.png", True) set_blurred(b, "n.md", True) page = browser.new_page(viewport={"width": 1200, "height": 800}) page.goto(f"{base}/b/g/", wait_until="networkidle") page.locator("[data-reveal-all]").click() page.goto(f"{base}/b/g/view?f=n.md", wait_until="networkidle") _settle(page) got = [page.evaluate(_FILTER, "#docbody .markdown-body, #docbody .textview"), page.evaluate("getComputedStyle(document.getElementById('docreveal')).display")] page.close() assert got == ["none", "none"], got