Reported: editing a speaker, then "Save & Re-voice", did nothing.
This is a regression from e810b49 (the voice-queue commit), which deleted
the body of queueVoiceRegenOnSave but left the edit form calling it as
onsubmit="return queueVoiceRegenOnSave(...)". A JS handler that falls off
the end returns undefined, which is falsy, so the browser cancelled the
submit. The PUT never fired: no request, no error, no change. It affected
every field on the speaker edit form - name, gender, traits, accent - not
just re-voicing.
- Re-voicing is now hooked to hx-on::after-request, so the new profile is
on disk before the voice is rebuilt from it, and a failed save does not
queue work. The handler re-reads the name from the swapped row, so a
rename regenerates under the new name instead of the old one.
- The backend was never at fault: a full round-trip through
PUT /api/speakers/0 with the record's own values leaves every field
byte-identical.
- New tests/test_speaker_edit_submit.py guards the class of bug, not just
this instance: no onsubmit="return ..." in the row template, and any
onsubmit that returns a value anywhere must return a literal true/false.
Comments are stripped before scanning, since the hazard is documented in
one. Verified by reintroducing the bug - 3 of the 4 tests fail.
Suite: 688 passed, 8 pre-existing.
96 lines
3.7 KiB
Python
96 lines
3.7 KiB
Python
"""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 function that was gutted."""
|
|
bodies = _function_bodies((TEMPLATES / "speakers.html").read_text())
|
|
assert "queueVoiceRegenOnSave" in bodies, "handler is missing entirely"
|
|
body = bodies["queueVoiceRegenOnSave"].strip()
|
|
assert body, "queueVoiceRegenOnSave has an empty body"
|
|
assert "enqueueVoice" in body, "it must actually queue a regeneration"
|
|
|
|
|
|
def test_edit_form_queues_regen_after_a_successful_save() -> None:
|
|
"""Re-voice must run after the new profile is persisted, not before."""
|
|
row = _strip_comments((TEMPLATES / "_speaker_row.html").read_text())
|
|
assert "queueVoiceRegenOnSave" in row
|
|
assert "hx-on::after-request" in row, (
|
|
"regeneration should be hooked to after-request so the saved profile "
|
|
"is on disk before the voice is rebuilt from it"
|
|
)
|