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:
@@ -9,3 +9,4 @@ graphify-out/
|
|||||||
|
|
||||||
# scripts/mutation_check.py crash marker — never committed
|
# scripts/mutation_check.py crash marker — never committed
|
||||||
.mutation-inflight
|
.mutation-inflight
|
||||||
|
.mutation-lock
|
||||||
|
|||||||
+62
-9
@@ -460,20 +460,63 @@
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hostDirty(forms) {
|
/* THE REPORT'S OWN INPUTS, which a reload or a navigation of ours would
|
||||||
/* Any control NOT owned by one of our forms that differs from its default:
|
clear. Measured against how each stood WHEN WE MOUNTED, never against its
|
||||||
the report's own inputs, which a reload would clear. */
|
default attributes: a bare <select> shows its first option selected while
|
||||||
var els = document.querySelectorAll("input, textarea, select");
|
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++) {
|
for (var i = 0; i < els.length; i++) {
|
||||||
var el = els[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();
|
var type = (el.type || "").toLowerCase();
|
||||||
if (/^(hidden|submit|button|reset|image)$/.test(type)) continue;
|
|
||||||
if (type === "radio" || type === "checkbox") {
|
if (type === "radio" || type === "checkbox") {
|
||||||
if (el.checked !== el.defaultChecked) return true;
|
if (el.checked !== el.defaultChecked) return true;
|
||||||
} else if (el.tagName === "SELECT") {
|
} else if (el.tagName === "SELECT") {
|
||||||
for (var j = 0; j < el.options.length; j++) {
|
var any = false;
|
||||||
if (el.options[j].selected !== el.options[j].defaultSelected) return true;
|
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) {
|
} else if (el.value !== el.defaultValue) {
|
||||||
return true;
|
return true;
|
||||||
@@ -512,7 +555,10 @@
|
|||||||
would otherwise fall through to the browser, a native POST racing the
|
would otherwise fall through to the browser, a native POST racing the
|
||||||
batch (heid bug-hunt, hulda). */
|
batch (heid bug-hunt, hulda). */
|
||||||
if (sending) { ev.preventDefault(); return; }
|
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) {
|
if (!others) {
|
||||||
nativeSent = form;
|
nativeSent = form;
|
||||||
setTimeout(function () { if (ev.defaultPrevented && nativeSent === form) nativeSent = null; }, 0);
|
setTimeout(function () { if (ev.defaultPrevented && nativeSent === form) nativeSent = null; }, 0);
|
||||||
@@ -521,6 +567,12 @@
|
|||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
sending = true;
|
sending = true;
|
||||||
var batch = forms.filter(dirty);
|
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
|
// every form's fields read NOW, at the press
|
||||||
var bodies = batch.map(function (f) { return new URLSearchParams(new FormData(f)); });
|
var bodies = batch.map(function (f) { return new URLSearchParams(new FormData(f)); });
|
||||||
var failed = [], chain = Promise.resolve();
|
var failed = [], chain = Promise.resolve();
|
||||||
@@ -579,6 +631,7 @@
|
|||||||
bkTheme();
|
bkTheme();
|
||||||
reassociate();
|
reassociate();
|
||||||
asksChip(data.open || [], mounted);
|
asksChip(data.open || [], mounted);
|
||||||
|
baselineHost();
|
||||||
document.dispatchEvent(new CustomEvent("booth:mounted", { detail: { booth: name } }));
|
document.dispatchEvent(new CustomEvent("booth:mounted", { detail: { booth: name } }));
|
||||||
})
|
})
|
||||||
.catch(function () { /* the report is the operator's; a failed fetch costs
|
.catch(function () { /* the report is the operator's; a failed fetch costs
|
||||||
|
|||||||
+63
-14
@@ -39,6 +39,7 @@ RUNNING IT. Neither is obvious and both cost real time:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fcntl
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -54,18 +55,41 @@ TABLES = REPO / "tests" / "mutations"
|
|||||||
INFLIGHT = REPO / ".mutation-inflight"
|
INFLIGHT = REPO / ".mutation-inflight"
|
||||||
|
|
||||||
|
|
||||||
def run(test: str, repo: Path = REPO) -> int:
|
#: pytest's exit code for "the tests ran and at least one FAILED" — the only
|
||||||
"""Exit code of one test, with the bytecode cache defeated. See defect 2."""
|
#: 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__"):
|
for cache in repo.rglob("__pycache__"):
|
||||||
shutil.rmtree(cache, ignore_errors=True)
|
shutil.rmtree(cache, ignore_errors=True)
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
[sys.executable, "-m", "pytest", test, "-q", "--no-header", "-p", "no:warnings"],
|
[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"),
|
env=dict(os.environ, PYTHONDONTWRITEBYTECODE="1"),
|
||||||
).returncode
|
).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.
|
"""(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
|
`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"]
|
test = mutation["test"]
|
||||||
path = repo / mutation["file"]
|
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"
|
return False, f"BASELINE RED — {test} fails BEFORE the mutation"
|
||||||
|
|
||||||
src = path.read_text()
|
# BYTES, in and out: text-mode I/O read CRLF as LF, wrote LF back, then
|
||||||
hits = src.count(mutation["old"])
|
# 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:
|
if hits == 0:
|
||||||
return False, f"anchor not found in {mutation['file']} — the table has drifted"
|
return False, f"anchor not found in {mutation['file']} — the table has drifted"
|
||||||
if hits > 1:
|
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.
|
# 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']}"
|
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
|
stat = path.stat() # mtime included; see the restore below
|
||||||
try:
|
try:
|
||||||
path.write_text(src.replace(mutation["old"], mutation["new"], 1))
|
path.write_bytes(src.replace(old, new, 1))
|
||||||
red = run(test, repo) != 0
|
try:
|
||||||
|
rc = run(test, repo, timeout)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return False, f"TIMED OUT — the mutated run passed {timeout}s; not a proof"
|
||||||
finally:
|
finally:
|
||||||
path.write_text(src)
|
path.write_bytes(src)
|
||||||
# Verified, not assumed: a restore that silently failed would leave a
|
# Verified, not assumed: a restore that silently failed would leave a
|
||||||
# mutation in a tracked file and the next run would measure it.
|
# 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.
|
# ⚠ AND THE MTIME, which matters more here than it would elsewhere.
|
||||||
# This repo IS its own deployment root and nothing takes effect until
|
# This repo IS its own deployment root and nothing takes effect until
|
||||||
# the service restarts, so "is :8090 stale?" is answered by comparing
|
# 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
|
# those mtimes without changing a byte makes that check lie — it
|
||||||
# reported the live service 16 minutes stale when it was current.
|
# reported the live service 16 minutes stale when it was current.
|
||||||
os.utime(path, ns=(stat.st_atime_ns, stat.st_mtime_ns))
|
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:
|
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():
|
if INFLIGHT.exists():
|
||||||
print(f"refusing to run: {INFLIGHT} exists, so a previous run died mid-mutation.")
|
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.")
|
print(f"check `git diff {INFLIGHT.read_text().strip()}`, restore it, then delete the marker.")
|
||||||
|
|||||||
@@ -148,3 +148,29 @@ old = '''
|
|||||||
if (!failed.length && !changed && !host) { location.reload(); return; }'''
|
if (!failed.length && !changed && !host) { location.reload(); return; }'''
|
||||||
new = '''
|
new = '''
|
||||||
if (!failed.length && !changed) { location.reload(); return; }'''
|
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 same and kept == "my own working", (same, kept)
|
||||||
assert "reload" in said.lower(), said
|
assert "reload" in said.lower(), said
|
||||||
assert {k: v["choice"] for k, v in _answers(b).items()} == {"a1": "yes", "a2": "no"}
|
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)
|
"old": " x = 2\n", "new": " x = 3\n"}, repo=repo)
|
||||||
assert not proved
|
assert not proved
|
||||||
assert "ambiguous" in note
|
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