fix(desk): the heid bug-hunt panel on the row controls (round "Slate", 4/4)
- Touch: on a coarse pointer every row control is at least 28px square again (32px), and wipe stands clear of the zip link. The move onto the facts line had dropped the deliberate 28px floor to ~21px, 4-6px from zip; with scripts off no confirm fires, so a mis-tap on wipe is the delete. The zip link no longer breaks between its glyph and its word, and each separator is glued to the item after it. - The wipe dialog shows the name as it should be read: control and bidi formatting characters in an agent-made name show as U+FFFD, so U+202E or a newline cannot rewrite what the operator approves. An unknown data-confirm word now prompts generically instead of submitting unguarded (fail closed). - No page scrolls sideways: `code` wraps anywhere, so a long unbreakable install path in the footer or the empty Desk no longer widens every page. The overflow test now sweeps 390/720/850/1000/1400 with the heaviest row the Desk draws, and compares scrollWidth with the page's own clientWidth. Its first fixture used a hyphenated path, which wrapped by itself; the test passed with the bug present until the path became one unbreakable run. r2_flow.toml: 27/27 proved. 749 passed.
This commit is contained in:
@@ -51,8 +51,11 @@
|
|||||||
:focus-visible{outline:2px solid var(--border-focus);outline-offset:2px}
|
:focus-visible{outline:2px solid var(--border-focus);outline-offset:2px}
|
||||||
a{color:var(--text-link);text-decoration:none}
|
a{color:var(--text-link);text-decoration:none}
|
||||||
a:hover{color:var(--text-link-hover);text-decoration:underline}
|
a:hover{color:var(--text-link-hover);text-decoration:underline}
|
||||||
|
/* overflow-wrap: a path is one unbreakable run to the line breaker, and the
|
||||||
|
footer and the empty Desk print the install path — a long one scrolled
|
||||||
|
every page sideways. Inside <pre> nothing wraps anyway (white-space). */
|
||||||
code{font-family:var(--font-mono);font-size:.9em;background:var(--surface-sunken);color:var(--text-body);
|
code{font-family:var(--font-mono);font-size:.9em;background:var(--surface-sunken);color:var(--text-body);
|
||||||
padding:1px 6px;border-radius:var(--radius-sm);border:1px solid var(--border-subtle)}
|
padding:1px 6px;border-radius:var(--radius-sm);border:1px solid var(--border-subtle);overflow-wrap:anywhere}
|
||||||
|
|
||||||
/* ---- base controls ----------------------------------------------------
|
/* ---- base controls ----------------------------------------------------
|
||||||
A bare <button>, <textarea> or text <input> in the chrome is a quiet SVOS
|
A bare <button>, <textarea> or text <input> in the chrome is a quiet SVOS
|
||||||
@@ -203,6 +206,7 @@
|
|||||||
but always there and always tappable; wipe turns danger only under the
|
but always there and always tappable; wipe turns danger only under the
|
||||||
pointer or focus. */
|
pointer or focus. */
|
||||||
.desk-facts form{display:inline;margin:0}
|
.desk-facts form{display:inline;margin:0}
|
||||||
|
.desk-facts .dl-link{white-space:nowrap}
|
||||||
.desk-facts form button{display:inline-block;margin-left:4px;padding:1px 6px;height:auto;min-width:0;
|
.desk-facts form button{display:inline-block;margin-left:4px;padding:1px 6px;height:auto;min-width:0;
|
||||||
font:inherit;line-height:1.4;color:var(--text-muted);background:none;
|
font:inherit;line-height:1.4;color:var(--text-muted);background:none;
|
||||||
border:1px solid var(--border-default);border-radius:var(--radius-sm);
|
border:1px solid var(--border-default);border-radius:var(--radius-sm);
|
||||||
@@ -212,6 +216,15 @@
|
|||||||
border-color:var(--border-strong);background:var(--surface-overlay)}
|
border-color:var(--border-strong);background:var(--surface-overlay)}
|
||||||
.desk-facts .wipe button:hover,.desk-facts .wipe button:focus-visible{background:var(--danger);
|
.desk-facts .wipe button:hover,.desk-facts .wipe button:focus-visible{background:var(--danger);
|
||||||
border-color:var(--danger);color:var(--danger-contrast)}
|
border-color:var(--danger);color:var(--danger-contrast)}
|
||||||
|
/* A finger is not a cursor. The compact line is for a fine pointer; on a
|
||||||
|
coarse one every control gets back the 28px floor it had as a column
|
||||||
|
(32px here, with room), and wipe stands clear of the zip link — with
|
||||||
|
scripts off no confirm fires, so a mis-tap on wipe IS the delete. */
|
||||||
|
@media (pointer:coarse){
|
||||||
|
.desk-facts{line-height:2.4}
|
||||||
|
.desk-facts form button{min-height:32px;min-width:32px;padding:0 10px;margin-left:6px;vertical-align:middle}
|
||||||
|
.desk-facts .wipe button{margin-left:10px}
|
||||||
|
}
|
||||||
@media (max-width:700px){
|
@media (max-width:700px){
|
||||||
.desk-row{flex-wrap:wrap}
|
.desk-row{flex-wrap:wrap}
|
||||||
.desk-strip{flex-basis:100%}
|
.desk-strip{flex-basis:100%}
|
||||||
|
|||||||
@@ -43,7 +43,9 @@
|
|||||||
whether or not they're visible") — and hover never existed on touch.
|
whether or not they're visible") — and hover never existed on touch.
|
||||||
The confirmation text is DATA-DRIVEN: the booth name travels as a data
|
The confirmation text is DATA-DRIVEN: the booth name travels as a data
|
||||||
attribute and never reaches a JS string (see the script below).
|
attribute and never reaches a JS string (see the script below).
|
||||||
Release is reversible, so it has no prompt beyond the wording. #}
|
Release is reversible, so it has no prompt beyond the wording.
|
||||||
|
`· ` glues each separator to the item after it, so a wrapped
|
||||||
|
line never ends on a dangling dot. #}
|
||||||
<div class="desk-facts">
|
<div class="desk-facts">
|
||||||
{{ b.count }} item{{ '' if b.count == 1 else 's' }}
|
{{ b.count }} item{{ '' if b.count == 1 else 's' }}
|
||||||
{% if b.flags %} · <span class="desk-flags">{{ b.flags }} flagged</span>{% endif %}
|
{% if b.flags %} · <span class="desk-flags">{{ b.flags }} flagged</span>{% endif %}
|
||||||
@@ -54,12 +56,12 @@
|
|||||||
{%- else %}
|
{%- else %}
|
||||||
<form class="keepit" method="post" action="/b/{{ b.name_url }}/keep"><button title="keep — exempt from the {{ ttl_hours }}h sweep" aria-label="keep booth">★ keep</button></form>
|
<form class="keepit" method="post" action="/b/{{ b.name_url }}/keep"><button title="keep — exempt from the {{ ttl_hours }}h sweep" aria-label="keep booth">★ keep</button></form>
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
· <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>
|
· <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>
|
||||||
{%- if b.kept %}
|
{%- if b.kept %}
|
||||||
· <form class="wipe wipe-kept" method="post" action="/b/{{ b.name_url }}/delete"
|
· <form class="wipe wipe-kept" method="post" action="/b/{{ b.name_url }}/delete"
|
||||||
data-booth="{{ b.name }}" data-confirm="wipe-kept"><button title="wipe this KEPT booth now" aria-label="wipe kept booth">× wipe</button></form>
|
data-booth="{{ b.name }}" data-confirm="wipe-kept"><button title="wipe this KEPT booth now" aria-label="wipe kept booth">× wipe</button></form>
|
||||||
{%- else %}
|
{%- else %}
|
||||||
· <form class="wipe" method="post" action="/b/{{ b.name_url }}/delete"
|
· <form class="wipe" method="post" action="/b/{{ b.name_url }}/delete"
|
||||||
data-booth="{{ b.name }}" data-confirm="wipe"><button title="wipe now" aria-label="wipe booth">× wipe</button></form>
|
data-booth="{{ b.name }}" data-confirm="wipe"><button title="wipe now" aria-label="wipe booth">× wipe</button></form>
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -204,11 +206,21 @@
|
|||||||
},
|
},
|
||||||
wipe: function (n) { return 'Wipe booth “' + n + '”?'; }
|
wipe: function (n) { return 'Wipe booth “' + n + '”?'; }
|
||||||
};
|
};
|
||||||
|
/* A word the page does not know still ASKS: fail closed. Keying the
|
||||||
|
prompt on `word &&` meant a typo'd data-confirm submitted unguarded. */
|
||||||
|
var ASK = function (n) { return 'Really do this to “' + n + '”?'; };
|
||||||
|
/* The name as the operator should READ it. Escaping keeps it out of any
|
||||||
|
script, but a bidi override (U+202E) or a newline in an agent-made name
|
||||||
|
still rewrote the dialog's text — what is read before approving a wipe.
|
||||||
|
Controls and bidi formatting show as U+FFFD, visibly, never silently. */
|
||||||
|
function shown(n) {
|
||||||
|
return n.replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, '\ufffd');
|
||||||
|
}
|
||||||
document.addEventListener('submit', function (ev) {
|
document.addEventListener('submit', function (ev) {
|
||||||
var form = ev.target.closest ? ev.target.closest('form[data-confirm]') : null;
|
var form = ev.target.closest ? ev.target.closest('form[data-confirm]') : null;
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
var word = WORDS[form.getAttribute('data-confirm')];
|
var word = WORDS[form.getAttribute('data-confirm')] || ASK;
|
||||||
if (word && !confirm(word(form.getAttribute('data-booth') || ''))) ev.preventDefault();
|
if (!confirm(word(shown(form.getAttribute('data-booth') || '')))) ev.preventDefault();
|
||||||
}, true);
|
}, true);
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -337,6 +337,18 @@ no hover. The side column renders only when the row has a badge, so a row
|
|||||||
without one reserves nothing. The forms, POST targets and `data-confirm`
|
without one reserves nothing. The forms, POST targets and `data-confirm`
|
||||||
wording are unchanged.
|
wording are unchanged.
|
||||||
|
|
||||||
|
- **On a coarse pointer every row control is at least 28px square**, the floor
|
||||||
|
it had as a column, and wipe stands clear of the zip link. With scripts off
|
||||||
|
no confirm fires, so a mis-tap on wipe is the delete. A fine pointer keeps
|
||||||
|
the compact line.
|
||||||
|
- **The confirm dialog shows the name as it should be READ.** Control and bidi
|
||||||
|
formatting characters in an agent-made name show as U+FFFD, so a U+202E or a
|
||||||
|
newline cannot rewrite what the operator approves. A `data-confirm` word the
|
||||||
|
page does not know still asks, generically: the prompt fails closed.
|
||||||
|
- **No page scrolls sideways at any width**, including an install path with no
|
||||||
|
break opportunity in the footer or the empty Desk (`code` wraps anywhere).
|
||||||
|
Tested at 390, 720, 850, 1000 and 1400px with the heaviest row the Desk draws.
|
||||||
|
|
||||||
### C5 — the lightbox (booth.html, booth_view)
|
### C5 — the lightbox (booth.html, booth_view)
|
||||||
|
|
||||||
- **Layout.** Two panes on a gallery booth: the set on the left, the
|
- **Layout.** Two panes on a gallery booth: the set on the left, the
|
||||||
|
|||||||
@@ -182,8 +182,64 @@ new = '''
|
|||||||
[[mutation]]
|
[[mutation]]
|
||||||
label = "the stacked Desk column is a bare 1fr (content sets its minimum)"
|
label = "the stacked Desk column is a bare 1fr (content sets its minimum)"
|
||||||
file = "booth/templates/base.html"
|
file = "booth/templates/base.html"
|
||||||
test = "tests/test_flow_browser.py::test_the_desk_never_scrolls_sideways_on_a_phone"
|
test = "tests/test_flow_browser.py::test_the_desk_never_scrolls_sideways_at_any_width"
|
||||||
old = '''
|
old = '''
|
||||||
@media (max-width:1000px){.desk{grid-template-columns:minmax(0,1fr)}}'''
|
@media (max-width:1000px){.desk{grid-template-columns:minmax(0,1fr)}}'''
|
||||||
new = '''
|
new = '''
|
||||||
@media (max-width:1000px){.desk{grid-template-columns:1fr}}'''
|
@media (max-width:1000px){.desk{grid-template-columns:1fr}}'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "a long unbreakable install path in <code> scrolls the page sideways"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_the_desk_never_scrolls_sideways_at_any_width"
|
||||||
|
old = '''
|
||||||
|
padding:1px 6px;border-radius:var(--radius-sm);border:1px solid var(--border-subtle);overflow-wrap:anywhere}'''
|
||||||
|
new = '''
|
||||||
|
padding:1px 6px;border-radius:var(--radius-sm);border:1px solid var(--border-subtle)}'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "the row's text column cannot shrink (the 700-1000px window overflows)"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_the_desk_never_scrolls_sideways_at_any_width"
|
||||||
|
old = '''
|
||||||
|
.desk-main{flex:1 1 auto;min-width:0}'''
|
||||||
|
new = '''
|
||||||
|
.desk-main{flex:1 1 auto}'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "a coarse pointer gets the compact ~21px controls"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_on_a_touch_screen_the_row_controls_keep_their_tap_floor"
|
||||||
|
old = '''
|
||||||
|
.desk-facts form button{min-height:32px;min-width:32px;padding:0 10px;margin-left:6px;vertical-align:middle}'''
|
||||||
|
new = '''
|
||||||
|
.desk-facts form button{padding:0 10px;margin-left:6px;vertical-align:middle}'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "the zip link may break between its glyph and its word"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_on_a_touch_screen_the_row_controls_keep_their_tap_floor"
|
||||||
|
old = '''
|
||||||
|
.desk-facts .dl-link{white-space:nowrap}'''
|
||||||
|
new = '''
|
||||||
|
.desk-facts .dl-link{}'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "the wipe dialog shows the agent-made name raw (bidi, newline)"
|
||||||
|
file = "booth/templates/index.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_the_wipe_dialog_shows_what_is_being_wiped_and_never_fails_open"
|
||||||
|
old = '''
|
||||||
|
if (!confirm(word(shown(form.getAttribute('data-booth') || '')))) ev.preventDefault();'''
|
||||||
|
new = '''
|
||||||
|
if (!confirm(word(form.getAttribute('data-booth') || ''))) ev.preventDefault();'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "an unknown data-confirm word submits with no prompt (fail open)"
|
||||||
|
file = "booth/templates/index.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_the_wipe_dialog_shows_what_is_being_wiped_and_never_fails_open"
|
||||||
|
old = '''
|
||||||
|
var word = WORDS[form.getAttribute('data-confirm')] || ASK;
|
||||||
|
if (!confirm('''
|
||||||
|
new = '''
|
||||||
|
var word = WORDS[form.getAttribute('data-confirm')];
|
||||||
|
if (word && !confirm('''
|
||||||
|
|||||||
+121
-16
@@ -6,6 +6,7 @@ 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
|
harness as test_embed_browser.py, and like it this SKIPS, never fails, when no
|
||||||
browser is available.
|
browser is available.
|
||||||
"""
|
"""
|
||||||
|
import contextlib
|
||||||
import pathlib
|
import pathlib
|
||||||
import socket
|
import socket
|
||||||
import sys
|
import sys
|
||||||
@@ -34,15 +35,18 @@ def browser():
|
|||||||
b.close()
|
b.close()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@contextlib.contextmanager
|
||||||
def live(tmp_path):
|
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
|
import uvicorn
|
||||||
|
|
||||||
sock = socket.socket()
|
sock = socket.socket()
|
||||||
sock.bind(("127.0.0.1", 0))
|
sock.bind(("127.0.0.1", 0))
|
||||||
port = sock.getsockname()[1]
|
port = sock.getsockname()[1]
|
||||||
sock.close()
|
sock.close()
|
||||||
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
|
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"))
|
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 = threading.Thread(target=server.run, daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
@@ -52,12 +56,18 @@ def live(tmp_path):
|
|||||||
if not server.started:
|
if not server.started:
|
||||||
pytest.skip("uvicorn did not come up")
|
pytest.skip("uvicorn did not come up")
|
||||||
try:
|
try:
|
||||||
yield f"http://127.0.0.1:{port}", tmp_path
|
yield f"http://127.0.0.1:{port}"
|
||||||
finally:
|
finally:
|
||||||
server.should_exit = True
|
server.should_exit = True
|
||||||
thread.join(timeout=10)
|
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:
|
def _set(root: pathlib.Path, n: int = 30) -> pathlib.Path:
|
||||||
b = root / "g"
|
b = root / "g"
|
||||||
b.mkdir()
|
b.mkdir()
|
||||||
@@ -413,22 +423,117 @@ def test_a_rows_keep_release_and_wipe_take_no_room_of_their_own(browser, live):
|
|||||||
assert gaps and max(gaps) <= 14, gaps
|
assert gaps and max(gaps) <= 14, gaps
|
||||||
|
|
||||||
|
|
||||||
def test_the_desk_never_scrolls_sideways_on_a_phone(browser, live):
|
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
|
"""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
|
provenance line (nowrap, ellipsised) then set the Desk column's MINIMUM
|
||||||
width: at 390px the page scrolled sideways to 1029px. The column is capped
|
width: at 390px the page scrolled sideways to 1029px. The column is capped
|
||||||
at the space it has; the ellipsis does the rest."""
|
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.manifest import write_manifest
|
||||||
base, root = live
|
from booth.marks import declare_pick, set_flag
|
||||||
d = root / "longwhy"
|
# ONE unbreakable run: a hyphen or a slash is a line-break opportunity, and
|
||||||
d.mkdir()
|
# the first draft of this fixture ("a-very-long-install-path-" * 9) wrapped
|
||||||
(d / "a.png").write_bytes(PNG)
|
# all by itself — the test passed with the bug present.
|
||||||
write_manifest(d, "design-dev", title="A set with a long reason",
|
root = tmp_path / ("averylonginstallpath" * 11)
|
||||||
why="a reason long enough to overflow any phone " * 6)
|
root.mkdir()
|
||||||
widths = {}
|
over = {}
|
||||||
for w in (390, 1400):
|
with _serving(root) as base:
|
||||||
|
for w in (390, 1400): # the empty Desk first
|
||||||
page = browser.new_page(viewport={"width": w, "height": 844})
|
page = browser.new_page(viewport={"width": w, "height": 844})
|
||||||
page.goto(f"{base}/", wait_until="networkidle")
|
page.goto(f"{base}/", wait_until="networkidle")
|
||||||
widths[w] = page.evaluate("document.documentElement.scrollWidth")
|
over[("empty", w)] = page.evaluate(
|
||||||
|
"document.documentElement.scrollWidth - document.documentElement.clientWidth")
|
||||||
page.close()
|
page.close()
|
||||||
assert widths == {390: 390, 1400: 1400}, widths
|
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_on_a_touch_screen_the_row_controls_keep_their_tap_floor(browser, live):
|
||||||
|
"""Slate T2 (kimi, groa): moving keep/release/wipe onto the facts line
|
||||||
|
dropped the deliberate 28px tap target (`height:28px;min-width:28px`) to
|
||||||
|
about 19px, 4-6px from the zip link. With scripts off no confirm fires, so
|
||||||
|
a mis-tap on wipe POSTs the delete. On a coarse pointer every row control
|
||||||
|
is at least 28px square again and wipe stands clear of the zip link; a
|
||||||
|
fine pointer keeps the compact line."""
|
||||||
|
import os
|
||||||
|
base, root = live
|
||||||
|
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"")
|
||||||
|
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")
|
||||||
|
boxes = page.evaluate("""() => [...document.querySelectorAll('.desk-facts form button')].map(b => {
|
||||||
|
const r = b.getBoundingClientRect(); return [Math.round(r.width), Math.round(r.height)]; })""")
|
||||||
|
gaps = page.evaluate("""() => [...document.querySelectorAll('.desk-row')].map(row => {
|
||||||
|
const z = row.querySelector('.dl-link').getBoundingClientRect(),
|
||||||
|
w = row.querySelector('form.wipe button').getBoundingClientRect();
|
||||||
|
// the clearance between the two boxes on whichever axis separates them:
|
||||||
|
// beside each other on one line, or wipe wrapped onto the next
|
||||||
|
return Math.round(Math.max(w.left - z.right, z.left - w.right, w.top - z.bottom, z.top - w.bottom)); })""")
|
||||||
|
ctx.close()
|
||||||
|
assert coarse, "the emulation must present a coarse pointer, or this test measures nothing"
|
||||||
|
assert len(boxes) == 4 and all(w >= 28 and h >= 28 for w, h in boxes), boxes
|
||||||
|
assert all(g >= 8 for g in gaps), gaps
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user