In-place client (base.html): - Saves are serialized: POST, re-fetch and swap complete before the next save starts, so an older snapshot can no longer land after a newer one. - A form already queued or in flight ignores another submit; a double-click writes one note. - Dirty controls (drafts, unsent radio choices) and disclosures carry by identity (form action + hidden ask/target/mark/f + name), not position. - Any non-tile structural difference, or a page with no region to swap, reloads instead of patching. Server and templates: - .seen is a JSON array read without following links or blocking, regular files of at most 1 MiB only; malformed, nested-too-deep or planted markers read as nothing seen. - landed_at reads symlinks by lstat and skips one unreadable entry instead of pinning the booth in "new". - The Desk counts flags on current items only; orphan flags are listed under the tray with an unmark form. - Agent-written bench and bookmark URLs link only when http(s). - Audio and video tiles carry a review link. - A rel the filesystem cannot represent is a 404, not a 500. - A non-finite Accept q-value fails to parse. - The standalone marks page has regions and updates in place. - The review's next arrow sits at the edge at phone width. Contract amended for each, plus an accepted-risks section (unlocked .seen read-modify-write, a planted .viewed symlink, Item.ordinal with no default). 741 passed. Each new browser test was mutation-checked against its fix; the serialization test forces the race with a held first refresh, since localhost alone never lost it.
377 lines
16 KiB
Python
377 lines
16 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 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"
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def browser():
|
|
with playwright_api.sync_playwright() as pw:
|
|
try:
|
|
b = pw.chromium.launch()
|
|
except Exception as exc: # noqa: BLE001 - any launch failure is a skip
|
|
pytest.skip(f"no usable chromium: {exc}")
|
|
yield b
|
|
b.close()
|
|
|
|
|
|
@pytest.fixture
|
|
def live(tmp_path):
|
|
import uvicorn
|
|
|
|
sock = socket.socket()
|
|
sock.bind(("127.0.0.1", 0))
|
|
port = sock.getsockname()[1]
|
|
sock.close()
|
|
app = create_app(tmp_path, 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=server.run, daemon=True)
|
|
thread.start()
|
|
deadline = time.time() + 10
|
|
while not server.started and time.time() < deadline:
|
|
time.sleep(0.02)
|
|
if not server.started:
|
|
pytest.skip("uvicorn did not come up")
|
|
try:
|
|
yield f"http://127.0.0.1:{port}", tmp_path
|
|
finally:
|
|
server.should_exit = True
|
|
thread.join(timeout=10)
|
|
|
|
|
|
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"}
|