Operator rulings, 2026-09-23. D1, the Desk row: - Kept vs ephemeral reads at a glance: an always-visible lifetime pill in the right column (sage ★ kept, amber held, ◷ counting down). - The facts line is facts only. - zip / keep|release / wipe are one cluster, with zip out of the middle. Where a real hover exists it floats over the preview strip (covering pictures, never information), appears on hover or keyboard focus, and takes no room. Anywhere else (touch, any coarse pointer) it is the row's last line, visible, with 32px controls. × hides too (the operator answered yes). D1b: "created 12 Sep" (filesystem birth time; nothing when unknown) and "updated 5d ago" (the content clock), as <time> facts on the row and in the booth header, from one macro and one clock per page. D3, the theme toggle: System · Light · Dark in the top bar. - Stored in localStorage and applied in <head> before any stylesheet. - System removes data-theme, so the OS query follows the OS live, with no listener. - The token sheet is re-vendored at the same SVOS SHA with a scoping-only transform (155 declarations, the same set, both directions), so forced themes win over the OS and high contrast follows the theme in effect. - The ask chrome inside verbatim pages follows the choice through data-bk-theme on our own fragments, live across tabs. The host page's <html> is never touched. Declared test changes: - two row tests replaced; - the wipe-dialog test hovers first; - four r2_flow rows retired, with successors in r2b.toml (45/45). 785 passed.
964 lines
45 KiB
Python
964 lines
45 KiB
Python
"""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 <input> — 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 <details> 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) <sub>· a · 2026-09-01 10:00</sub>\n"
|
||
"- [two](http://x/2) <sub>· a · 2026-09-01 10:01</sub>\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 = "safegnp.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 = '<button>go</button>';
|
||
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 <html> 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}]})
|
||
got[(os_scheme, forced)] = page.evaluate(_VAR, "--surface-card")
|
||
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)]
|
||
|
||
|
||
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
|
||
<html> 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('<!doctype html><title>r</title><body><h1>R</h1>'
|
||
'<script src="/_booth/embed.js" defer></script></body>')
|
||
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
|