fix(embed,mutation): SPYRJA fold — report input on every path; an instrument that cannot certify what it did not run
The heid bug-hunt (hulda, with heid's second voice) on 377e652 found eight
issues. Every fixed one has a red-first test.
embed.js:
- H1: the report-input guard covered only the batch path. A lone changed
answer went out as a native POST, whose 303 navigation took the report's
typed text with it. Unsaved report input now routes even a lone answer
in place. With nothing of ours to send, the submit block says so.
- H7 / V1: a bare <select>, and a range or color input with no value
attribute, read as typed-into by their default attributes, so every clean
batch refused its reload with a false message. Report controls are now
measured against how they stood when the Booth mounted. A control added
later falls back to its defaults, counting a select's first option as its
default.
- H2 / V2: a contenteditable region counts as report input.
scripts/mutation_check.py:
- H5: any non-zero exit counted as proof, including a collection error
where the test never ran. Only pytest's "tests failed" (1) proves now.
- H6: the test run has a timeout (300 s). A hang reports "timed out" and
the source is still restored.
- H3: source is read and restored as bytes, so a CRLF file comes back
byte-exact.
- H4: one run per tree, enforced by a lock. The in-flight marker lives with
the tree it guards.
- H8: anchors are counted with overlaps. The check is `matches()`, not
str.count.
Tool controls +5 (tests/test_mutation_check.py). u3_submit_all +3 rows.
This commit is contained in:
@@ -148,3 +148,29 @@ old = '''
|
||||
if (!failed.length && !changed && !host) { location.reload(); return; }'''
|
||||
new = '''
|
||||
if (!failed.length && !changed) { location.reload(); return; }'''
|
||||
|
||||
[[mutation]]
|
||||
label = "a lone answer ignores report input and takes the browser's navigation (SPYRJA H1)"
|
||||
file = "booth/static/embed.js"
|
||||
test = "tests/test_embed_browser.py::test_a_single_answer_does_not_navigate_over_text_typed_into_the_report"
|
||||
old = '''
|
||||
var others = forms.some(function (f) { return f !== form && dirty(f); }) || hostDirty(forms);'''
|
||||
new = '''
|
||||
var others = forms.some(function (f) { return f !== form && dirty(f); });'''
|
||||
|
||||
[[mutation]]
|
||||
label = "report controls are measured against their defaults, not the mount (a bare select holds every reload)"
|
||||
file = "booth/static/embed.js"
|
||||
test = "tests/test_embed_browser.py::test_untouched_report_controls_do_not_hold_the_reload"
|
||||
old = '''
|
||||
for (var i = 0; i < els.length; i++) els[i].__bkHost = hostState(els[i]);'''
|
||||
new = ''''''
|
||||
|
||||
[[mutation]]
|
||||
label = "a contenteditable region is not counted as report input"
|
||||
file = "booth/static/embed.js"
|
||||
test = "tests/test_embed_browser.py::test_a_clean_batch_does_not_reload_over_an_edited_contenteditable"
|
||||
old = '''
|
||||
var HOST = "input, textarea, select, [contenteditable]:not([contenteditable='false'])";'''
|
||||
new = '''
|
||||
var HOST = "input, textarea, select";'''
|
||||
|
||||
@@ -1058,3 +1058,76 @@ def test_a_clean_batch_does_not_reload_over_text_typed_into_the_report(browser,
|
||||
assert same and kept == "my own working", (same, kept)
|
||||
assert "reload" in said.lower(), said
|
||||
assert {k: v["choice"] for k, v in _answers(b).items()} == {"a1": "yes", "a2": "no"}
|
||||
|
||||
|
||||
def test_a_single_answer_does_not_navigate_over_text_typed_into_the_report(browser, live):
|
||||
"""SPYRJA H1 (hulda): the report-input guard covered only the batch path.
|
||||
One changed ask went to the browser's own POST and its 303 — a navigation
|
||||
that took the report's typed text with it. Unsaved report input now sends
|
||||
even a lone answer the in-place way."""
|
||||
base, data = live
|
||||
b = _asks(data / "b", ("a1", "a2"))
|
||||
html = ("<!doctype html><title>r</title><body>"
|
||||
'<textarea id="scratch"></textarea>'
|
||||
f"{SEAM}</body>")
|
||||
page = _open(browser, base, "b", html, b)
|
||||
page.wait_for_selector("#bk-ask-a2-submit")
|
||||
page.evaluate("window.__same = 1")
|
||||
posts = _watch_posts(page)
|
||||
page.fill("#scratch", "my own working")
|
||||
_choice(page, "a1", "yes")
|
||||
_press(page, "a1")
|
||||
page.wait_for_selector("#bk-ask-a1-submit .bk-ask-status:not([hidden])", timeout=10000)
|
||||
same = page.evaluate("window.__same === 1")
|
||||
kept = page.input_value("#scratch")
|
||||
page.close()
|
||||
assert posts == [("fetch", "a1")], posts
|
||||
assert same and kept == "my own working", (same, kept)
|
||||
assert _answers(b)["a1"]["choice"] == "yes"
|
||||
|
||||
|
||||
def test_untouched_report_controls_do_not_hold_the_reload(browser, live):
|
||||
"""SPYRJA H7 / heid V1: a bare <select> shows its first option selected
|
||||
while no option is defaultSelected, and a range or color input has a value
|
||||
and no value attribute — so they read as typed-into before anyone touched
|
||||
them, and every clean batch refused to reload with a false message. The
|
||||
report's controls are measured against how they stood when the Booth
|
||||
mounted."""
|
||||
base, data = live
|
||||
b = _asks(data / "b", ("a1", "a2"))
|
||||
html = ("<!doctype html><title>r</title><body>"
|
||||
"<select id=s><option>a</option><option>b</option></select>"
|
||||
'<input type="range" id="r"><input type="color" id="c">'
|
||||
f"{SEAM}</body>")
|
||||
page = _open(browser, base, "b", html, b)
|
||||
page.wait_for_selector("#bk-ask-a2-submit")
|
||||
_choice(page, "a1", "yes")
|
||||
_choice(page, "a2", "no")
|
||||
with page.expect_navigation(timeout=10000):
|
||||
_press(page, "a1")
|
||||
page.close()
|
||||
assert {k: v["choice"] for k, v in _answers(b).items()} == {"a1": "yes", "a2": "no"}
|
||||
|
||||
|
||||
def test_a_clean_batch_does_not_reload_over_an_edited_contenteditable(browser, live):
|
||||
"""SPYRJA H2 / heid V2: text typed into a report's contenteditable region
|
||||
is input too, and the reload took it."""
|
||||
base, data = live
|
||||
b = _asks(data / "b", ("a1", "a2"))
|
||||
html = ("<!doctype html><title>r</title><body>"
|
||||
'<div id="ed" contenteditable="true">first</div>'
|
||||
f"{SEAM}</body>")
|
||||
page = _open(browser, base, "b", html, b)
|
||||
page.wait_for_selector("#bk-ask-a2-submit")
|
||||
page.evaluate("window.__same = 1")
|
||||
page.click("#ed")
|
||||
page.keyboard.press("End")
|
||||
page.keyboard.type(" and more")
|
||||
_choice(page, "a1", "yes")
|
||||
_choice(page, "a2", "no")
|
||||
_press(page, "a1")
|
||||
page.wait_for_selector("#bk-ask-a1-submit .bk-ask-status:not([hidden])", timeout=10000)
|
||||
same = page.evaluate("window.__same === 1")
|
||||
text = page.inner_text("#ed")
|
||||
page.close()
|
||||
assert same and text == "first and more", (same, text)
|
||||
|
||||
@@ -142,3 +142,73 @@ def test_an_ambiguous_anchor_is_reported_not_guessed(tmp_path):
|
||||
"old": " x = 2\n", "new": " x = 3\n"}, repo=repo)
|
||||
assert not proved
|
||||
assert "ambiguous" in note
|
||||
|
||||
|
||||
# ---- SPYRJA (heid bug-hunt, hulda, 2026-09-28): the instrument's own edges --
|
||||
|
||||
|
||||
def test_a_mutation_that_stops_the_test_running_is_not_a_proof(tmp_path):
|
||||
"""A mutation that breaks collection (a syntax error) exits non-zero
|
||||
without the assertion ever running. `rc != 0` certified that as PROVED:
|
||||
the tool's one claim, that the test caught the change, was never tested."""
|
||||
repo = _tree(tmp_path, "def f():\n return 2\n",
|
||||
"def test_f():\n assert f() == 2\n")
|
||||
proved, note = check(
|
||||
{"label": "syntax", "file": "mod.py", "test": "test_probe.py::test_f",
|
||||
"old": "return 2", "new": "return ("}, repo=repo)
|
||||
assert not proved
|
||||
assert "did not run" in note
|
||||
|
||||
|
||||
def test_an_overlapping_anchor_is_ambiguous_too(tmp_path):
|
||||
"""`str.count` counts NON-overlapping matches: `"aaa".count("aa") == 1`
|
||||
while `aa` starts at two places. The ambiguity guard must see both."""
|
||||
repo = _tree(tmp_path, 'def f():\n return len("aaa")\n',
|
||||
"def test_f():\n assert f() == 3\n")
|
||||
proved, note = check(
|
||||
{"label": "overlap", "file": "mod.py", "test": "test_probe.py::test_f",
|
||||
"old": "aa", "new": "b"}, repo=repo)
|
||||
assert not proved
|
||||
assert "ambiguous" in note
|
||||
|
||||
|
||||
def test_a_crlf_source_is_restored_byte_for_byte(tmp_path):
|
||||
"""Text-mode I/O read CRLF as LF and wrote LF back, then compared LF with
|
||||
LF and called it restored — and reset the mtime so nothing looked touched."""
|
||||
repo = _tree(tmp_path, "def f():\n return 2\n",
|
||||
"def test_f():\n assert f() == 2\n")
|
||||
mod = repo / "mod.py"
|
||||
mod.write_bytes(b"def f():\r\n return 2\r\n")
|
||||
before = mod.read_bytes()
|
||||
check({"label": "flip", "file": "mod.py", "test": "test_probe.py::test_f",
|
||||
"old": "return 2", "new": "return 3"}, repo=repo)
|
||||
assert mod.read_bytes() == before
|
||||
|
||||
|
||||
def test_a_mutation_that_hangs_is_stopped_and_the_source_restored(tmp_path):
|
||||
"""No timeout meant a mutation that loops forever held the checker — and
|
||||
the mutated file — until someone killed it by hand."""
|
||||
repo = _tree(tmp_path, "def f():\n return 2\n",
|
||||
"def test_f():\n assert f() == 2\n")
|
||||
before = (repo / "mod.py").read_text()
|
||||
proved, note = check(
|
||||
{"label": "hang", "file": "mod.py", "test": "test_probe.py::test_f",
|
||||
"old": "return 2", "new": "while True: pass"}, repo=repo, timeout=10)
|
||||
assert not proved
|
||||
assert "timed out" in note.lower()
|
||||
assert (repo / "mod.py").read_text() == before
|
||||
|
||||
|
||||
def test_a_second_run_is_refused_while_one_holds_the_repo(tmp_path, monkeypatch):
|
||||
"""Two checkers interleaving on one tree can restore each other's
|
||||
mutation back in. One run at a time, by lock."""
|
||||
import fcntl
|
||||
import mutation_check as mc
|
||||
|
||||
monkeypatch.setattr(mc, "REPO", tmp_path)
|
||||
monkeypatch.setattr(mc, "INFLIGHT", tmp_path / ".mutation-inflight")
|
||||
monkeypatch.setattr(mc, "TABLES", tmp_path / "tables")
|
||||
(tmp_path / "tables").mkdir()
|
||||
with open(tmp_path / ".mutation-lock", "w") as held:
|
||||
fcntl.flock(held, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
assert mc.main(["mutation_check.py"]) == 2
|
||||
|
||||
Reference in New Issue
Block a user