120 lines
4.8 KiB
Python
120 lines
4.8 KiB
Python
# Copyright 2026 Umbra Tools (umbratools.com)
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
"""Guards on the speaker edit form's submit behaviour.
|
|
|
|
Regression: the voice-queue work emptied ``queueVoiceRegenOnSave`` but left
|
|
``onsubmit="return queueVoiceRegenOnSave(...)"`` in the edit form. A handler
|
|
that falls off the end returns ``undefined``, which is falsy, so the browser
|
|
cancelled the submit and every speaker edit was silently discarded - the save
|
|
never reached the server, so nothing looked broken and nothing errored.
|
|
|
|
An ``onsubmit`` that returns a value must return a boolean deliberately.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
TEMPLATES = Path(__file__).resolve().parents[1] / "umbraaudio" / "web" / "templates"
|
|
|
|
# onsubmit="return someFn(...)" - the cancelling shape
|
|
ONSUBMIT_RETURN = re.compile(r'onsubmit="return\s+([A-Za-z_$][\w$]*)\s*\(')
|
|
FUNC_DEF = re.compile(
|
|
r"function\s+([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{", re.MULTILINE
|
|
)
|
|
# Comments quote these patterns when documenting the hazard, so strip them
|
|
# first or the scanner reports the documentation as if it were live markup.
|
|
COMMENTS = re.compile(r"\{#.*?#\}|<!--.*?-->", re.DOTALL)
|
|
|
|
|
|
def _strip_comments(text: str) -> str:
|
|
return COMMENTS.sub("", text)
|
|
|
|
|
|
def _all_template_text() -> str:
|
|
return _strip_comments(
|
|
"\n".join(p.read_text() for p in TEMPLATES.rglob("*.html"))
|
|
)
|
|
|
|
|
|
def _function_bodies(text: str) -> dict[str, str]:
|
|
"""Map function name -> body text, brace-matched."""
|
|
bodies: dict[str, str] = {}
|
|
for m in FUNC_DEF.finditer(text):
|
|
start = m.end()
|
|
depth = 1
|
|
i = start
|
|
while i < len(text) and depth:
|
|
if text[i] == "{":
|
|
depth += 1
|
|
elif text[i] == "}":
|
|
depth -= 1
|
|
i += 1
|
|
bodies[m.group(1)] = text[start : i - 1]
|
|
return bodies
|
|
|
|
|
|
def test_no_onsubmit_handler_can_cancel_the_speaker_edit() -> None:
|
|
"""The edit form must never gate its own submission on a JS return value."""
|
|
row = _strip_comments((TEMPLATES / "_speaker_row.html").read_text())
|
|
assert 'onsubmit="return' not in row, (
|
|
"_speaker_row.html uses onsubmit=\"return ...\"; if that handler ever "
|
|
"returns a falsy value the browser drops the edit without any error"
|
|
)
|
|
|
|
|
|
def test_every_onsubmit_return_handler_returns_a_boolean() -> None:
|
|
"""Repo-wide: any onsubmit returning a value must decide explicitly."""
|
|
text = _all_template_text()
|
|
bodies = _function_bodies(text)
|
|
for name in sorted(set(ONSUBMIT_RETURN.findall(text))):
|
|
assert name in bodies, f"onsubmit returns {name}() but it is not defined"
|
|
body = bodies[name]
|
|
returns_bool = re.search(r"return\s+(true|false)\b", body)
|
|
assert returns_bool, (
|
|
f"onsubmit=\"return {name}(...)\" but {name}() never returns an "
|
|
"explicit true/false - falling off the end yields undefined, which "
|
|
"cancels the submit and silently discards the user's edit"
|
|
)
|
|
|
|
|
|
def test_voice_regen_on_save_is_not_empty() -> None:
|
|
"""The specific handler that was gutted in the voice-queue commit."""
|
|
bodies = _function_bodies((TEMPLATES / "speakers.html").read_text())
|
|
assert "onSpeakerSaved" in bodies, "handler is missing entirely"
|
|
body = bodies["onSpeakerSaved"].strip()
|
|
assert body, "onSpeakerSaved has an empty body"
|
|
assert "enqueueVoice" in body, "it must actually queue a regeneration"
|
|
assert "notify" in body, "a save must tell the reviewer it happened"
|
|
|
|
|
|
def test_edit_form_carries_the_regen_marker() -> None:
|
|
row = _strip_comments((TEMPLATES / "_speaker_row.html").read_text())
|
|
assert "data-regen-on-save" in row, (
|
|
"the form must be identifiable so a save can trigger a re-voice"
|
|
)
|
|
assert "hx-on::after-request" not in row, (
|
|
"htmx dispatches afterRequest on the enclosing TBODY for a row swap, "
|
|
"so a listener bound to the form never fires - verified in a real browser"
|
|
)
|
|
|
|
|
|
def test_regen_trigger_uses_the_event_that_carries_the_form() -> None:
|
|
"""detail.elt is the FORM at beforeRequest but the TBODY at afterRequest."""
|
|
js = _strip_comments((TEMPLATES / "speakers.html").read_text())
|
|
assert "htmx:beforeRequest" in js, (
|
|
"the index must be captured at beforeRequest, where detail.elt is still "
|
|
"the form; reading it at afterRequest yields a TBODY and finds nothing"
|
|
)
|
|
assert "htmx:afterRequest" in js
|
|
assert "onSpeakerSaved" in js
|
|
|
|
|
|
def test_saving_tells_the_reviewer() -> None:
|
|
"""A save with no visible response reads as a broken button."""
|
|
bodies = _function_bodies(_strip_comments((TEMPLATES / "speakers.html").read_text()))
|
|
body = bodies["onSpeakerSaved"]
|
|
assert "enqueueVoice" in body, "it must actually queue a regeneration"
|
|
assert "notify" in body, "it must confirm to the reviewer"
|