From 213071b6cebe01aac72261298a74c835f7f11dd3 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Mon, 28 Sep 2026 17:49:01 -0700 Subject: [PATCH] =?UTF-8?q?fix(embed,mutation):=20SPYRJA=20fold=20?= =?UTF-8?q?=E2=80=94=20report=20input=20on=20every=20path;=20an=20instrume?= =?UTF-8?q?nt=20that=20cannot=20certify=20what=20it=20did=20not=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 shows its first option selected while + no option is defaultSelected, a range or color input has a value and no + value attribute, and a host script may fill fields at load — all of them + read "typed into" by the defaults before anyone touched them (SPYRJA H7, + heid V1). A contenteditable region is input too (H2, V2). */ + var HOST = "input, textarea, select, [contenteditable]:not([contenteditable='false'])"; + + function hostControls(forms) { + var out = [], els = document.querySelectorAll(HOST); for (var i = 0; i < els.length; i++) { var el = els[i]; - if (el.form && forms.indexOf(el.form) >= 0) continue; + if (el.form && forms.indexOf(el.form) >= 0) continue; // ours + if (/^(hidden|submit|button|reset|image)$/.test((el.type || "").toLowerCase())) continue; + out.push(el); + } + return out; + } + + function hostState(el) { + if (el.isContentEditable && !("value" in el)) return el.innerHTML; + var type = (el.type || "").toLowerCase(); + if (type === "radio" || type === "checkbox") return String(el.checked); + if (el.tagName === "SELECT") { + var on = []; + for (var j = 0; j < el.options.length; j++) if (el.options[j].selected) on.push(j); + return on.join(","); + } + return el.value; + } + + function baselineHost() { + var els = hostControls(ourForms()); + for (var i = 0; i < els.length; i++) els[i].__bkHost = hostState(els[i]); + } + + function hostDirty(forms) { + var els = hostControls(forms); + for (var i = 0; i < els.length; i++) { + var el = els[i]; + if (el.__bkHost !== undefined) { + if (hostState(el) !== el.__bkHost) return true; + continue; + } + /* Added after we mounted: no baseline, so the defaults it is — with a + select's implicit first option counted as its default. */ + if (el.isContentEditable && !("value" in el)) continue; // nothing to compare against var type = (el.type || "").toLowerCase(); - if (/^(hidden|submit|button|reset|image)$/.test(type)) continue; if (type === "radio" || type === "checkbox") { if (el.checked !== el.defaultChecked) return true; } else if (el.tagName === "SELECT") { - for (var j = 0; j < el.options.length; j++) { - if (el.options[j].selected !== el.options[j].defaultSelected) return true; + var any = false; + for (var j = 0; j < el.options.length; j++) if (el.options[j].defaultSelected) any = true; + for (var k = 0; k < el.options.length; k++) { + var dflt = any ? el.options[k].defaultSelected : k === 0; + if (el.options[k].selected !== dflt) return true; } } else if (el.value !== el.defaultValue) { return true; @@ -512,7 +555,10 @@ would otherwise fall through to the browser, a native POST racing the batch (heid bug-hunt, hulda). */ if (sending) { ev.preventDefault(); return; } - var others = forms.some(function (f) { return f !== form && dirty(f); }); + /* Unsaved text in the REPORT also keeps a lone answer off the browser's + own POST: its 303 is a navigation, and it took that text with it + (SPYRJA H1). */ + var others = forms.some(function (f) { return f !== form && dirty(f); }) || hostDirty(forms); if (!others) { nativeSent = form; setTimeout(function () { if (ev.defaultPrevented && nativeSent === form) nativeSent = null; }, 0); @@ -521,6 +567,12 @@ ev.preventDefault(); sending = true; var batch = forms.filter(dirty); + if (!batch.length) { // report input only: nothing of ours to send + sending = false; + var none = form.parentNode && form.parentNode.querySelector(".bk-ask-status"); + if (none) { none.textContent = "Nothing new to save in this answer."; none.hidden = false; } + return; + } // every form's fields read NOW, at the press var bodies = batch.map(function (f) { return new URLSearchParams(new FormData(f)); }); var failed = [], chain = Promise.resolve(); @@ -579,6 +631,7 @@ bkTheme(); reassociate(); asksChip(data.open || [], mounted); + baselineHost(); document.dispatchEvent(new CustomEvent("booth:mounted", { detail: { booth: name } })); }) .catch(function () { /* the report is the operator's; a failed fetch costs diff --git a/scripts/mutation_check.py b/scripts/mutation_check.py index 2f3335f..39c8d83 100755 --- a/scripts/mutation_check.py +++ b/scripts/mutation_check.py @@ -39,6 +39,7 @@ RUNNING IT. Neither is obvious and both cost real time: from __future__ import annotations +import fcntl import os import shutil import subprocess @@ -54,18 +55,41 @@ TABLES = REPO / "tests" / "mutations" INFLIGHT = REPO / ".mutation-inflight" -def run(test: str, repo: Path = REPO) -> int: - """Exit code of one test, with the bytecode cache defeated. See defect 2.""" +#: pytest's exit code for "the tests ran and at least one FAILED" — the only +#: result that proves a falsifier. Every other non-zero (2 interrupted or a +#: collection error, 3 internal, 4 usage, 5 nothing collected) means the test +#: never got to judge the mutation (SPYRJA H5, 2026-09-28). +TESTS_FAILED = 1 + +#: Seconds before a test run is killed. A mutation that loops forever held +#: the checker, and the mutated file, until someone noticed (SPYRJA H6). +DEFAULT_TIMEOUT = 300 + + +def run(test: str, repo: Path = REPO, timeout: float = DEFAULT_TIMEOUT) -> int: + """Exit code of one test, with the bytecode cache defeated. See defect 2. + Raises subprocess.TimeoutExpired past `timeout`, with the child killed.""" for cache in repo.rglob("__pycache__"): shutil.rmtree(cache, ignore_errors=True) return subprocess.run( [sys.executable, "-m", "pytest", test, "-q", "--no-header", "-p", "no:warnings"], - cwd=repo, capture_output=True, text=True, + cwd=repo, capture_output=True, text=True, timeout=timeout, env=dict(os.environ, PYTHONDONTWRITEBYTECODE="1"), ).returncode -def check(mutation: dict, repo: Path = REPO) -> tuple[bool, str]: +def matches(src: bytes, old: bytes) -> int: + """How many places `old` STARTS in `src`, overlapping ones included. + `bytes.count` is non-overlapping — `b"aaa".count(b"aa") == 1` — and an + anchor that starts twice is as ambiguous as one that appears twice.""" + n, i = 0, src.find(old) + while i != -1: + n, i = n + 1, src.find(old, i + 1) + return n + + +def check(mutation: dict, repo: Path = REPO, + timeout: float = DEFAULT_TIMEOUT) -> tuple[bool, str]: """(proved, note) for one mutation. Never leaves the source mutated. `repo` is a parameter so the harness can be pointed at a throwaway tree and @@ -76,11 +100,18 @@ def check(mutation: dict, repo: Path = REPO) -> tuple[bool, str]: test = mutation["test"] path = repo / mutation["file"] - if run(test, repo) != 0: + try: + base = run(test, repo, timeout) + except subprocess.TimeoutExpired: + return False, f"BASELINE TIMED OUT — {test} ran past {timeout}s unmutated" + if base != 0: return False, f"BASELINE RED — {test} fails BEFORE the mutation" - src = path.read_text() - hits = src.count(mutation["old"]) + # BYTES, in and out: text-mode I/O read CRLF as LF, wrote LF back, then + # compared LF with LF and called it restored (SPYRJA H3). + src = path.read_bytes() + old, new = mutation["old"].encode("utf-8"), mutation["new"].encode("utf-8") + hits = matches(src, old) if hits == 0: return False, f"anchor not found in {mutation['file']} — the table has drifted" if hits > 1: @@ -89,16 +120,20 @@ def check(mutation: dict, repo: Path = REPO) -> tuple[bool, str]: # row names. Two r2b rows proved that way until 2026-09-28. return False, f"anchor is ambiguous — it matches {hits} places in {mutation['file']}" - INFLIGHT.write_text(f"{path}\n") + inflight = repo / INFLIGHT.name # the marker lives with the tree it guards + inflight.write_text(f"{path}\n") stat = path.stat() # mtime included; see the restore below try: - path.write_text(src.replace(mutation["old"], mutation["new"], 1)) - red = run(test, repo) != 0 + path.write_bytes(src.replace(old, new, 1)) + try: + rc = run(test, repo, timeout) + except subprocess.TimeoutExpired: + return False, f"TIMED OUT — the mutated run passed {timeout}s; not a proof" finally: - path.write_text(src) + path.write_bytes(src) # Verified, not assumed: a restore that silently failed would leave a # mutation in a tracked file and the next run would measure it. - assert path.read_text() == src, f"RESTORE FAILED for {path} — fix by hand" + assert path.read_bytes() == src, f"RESTORE FAILED for {path} — fix by hand" # ⚠ AND THE MTIME, which matters more here than it would elsewhere. # This repo IS its own deployment root and nothing takes effect until # the service restarts, so "is :8090 stale?" is answered by comparing @@ -106,12 +141,26 @@ def check(mutation: dict, repo: Path = REPO) -> tuple[bool, str]: # those mtimes without changing a byte makes that check lie — it # reported the live service 16 minutes stale when it was current. os.utime(path, ns=(stat.st_atime_ns, stat.st_mtime_ns)) - INFLIGHT.unlink(missing_ok=True) + inflight.unlink(missing_ok=True) - return red, "" if red else "VACUOUS — stayed green under the change it forbids" + if rc == 0: + return False, "VACUOUS — stayed green under the change it forbids" + if rc != TESTS_FAILED: + return False, (f"the test did not run under the mutation (pytest exit {rc}: " + "a collection, usage or internal error) — not a proof") + return True, "" def main(argv: list[str]) -> int: + # ONE RUN PER TREE. Two checkers interleaving can each restore the file + # they read, and the second "restore" writes the first one's mutation back + # in (SPYRJA H4). Held for the whole run; released when the process exits. + lock = open(REPO / ".mutation-lock", "a") + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + print(f"refusing to run: another mutation_check holds {REPO / '.mutation-lock'}.") + return 2 if INFLIGHT.exists(): print(f"refusing to run: {INFLIGHT} exists, so a previous run died mid-mutation.") print(f"check `git diff {INFLIGHT.read_text().strip()}`, restore it, then delete the marker.") diff --git a/tests/mutations/u3_submit_all.toml b/tests/mutations/u3_submit_all.toml index bddd237..6b37719 100644 --- a/tests/mutations/u3_submit_all.toml +++ b/tests/mutations/u3_submit_all.toml @@ -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";''' diff --git a/tests/test_embed_browser.py b/tests/test_embed_browser.py index 65a2d39..9f0fab8 100644 --- a/tests/test_embed_browser.py +++ b/tests/test_embed_browser.py @@ -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 = ("r" + '' + f"{SEAM}") + 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 " + '' + f"{SEAM}") + 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 = ("r" + '
first
' + f"{SEAM}") + 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) diff --git a/tests/test_mutation_check.py b/tests/test_mutation_check.py index 646642c..5546585 100644 --- a/tests/test_mutation_check.py +++ b/tests/test_mutation_check.py @@ -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