fix(admin): apostrophe-in-translation broke /admin/stations Alpine bindings
Two error messages on /admin/stations rendered Italian translations
that contain an apostrophe inside a single-quoted JS literal:
alert(result.detail || '{{ _("Errore nell\'eliminazione") }}');
Jinja outputs the apostrophe verbatim, so the JS string closed
prematurely:
alert(result.detail || 'Errore nell'eliminazione');
That syntax error blew up the entire <script> block, which means
stationManagement() was never defined and EVERY Alpine binding on the
page failed silently. Symptom: clicking "Nuova Stazione" did nothing,
DevTools showed "Alpine Expression Error: openCreateModal is not
defined".
Fix: swap outer JS quotes to double quotes and reword the IT string so
the apostrophe disappears anyway ("Errore nella eliminazione",
"Errore nella assegnazione"). Same for the assignment-error path.
Regression guard: src/frontend/flask_app/tests/test_template_js_syntax.py
renders /admin/stations and /admin/users with the IT locale forced,
extracts every inline <script>, and runs `node --check` on each. The
test is skipped if `node` is not on PATH so CI without Node still
passes. Verified the test catches the original bug (revert + run +
fail) before re-applying the fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -477,7 +477,7 @@ function stationManagement(initialStations, initialRecipes) {
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const result = await resp.json().catch(() => ({}));
|
||||
alert(result.detail || '{{ _("Errore nell\'eliminazione") }}');
|
||||
alert(result.detail || "{{ _('Errore nella eliminazione') }}");
|
||||
return;
|
||||
}
|
||||
this.stations = this.stations.filter(s => s.id !== this.deleteTarget.id);
|
||||
@@ -528,7 +528,7 @@ function stationManagement(initialStations, initialRecipes) {
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (!resp.ok) {
|
||||
this.errorMsg = result.detail || '{{ _("Errore nell\'assegnazione") }}';
|
||||
this.errorMsg = result.detail || "{{ _('Errore nella assegnazione') }}";
|
||||
return;
|
||||
}
|
||||
const recipe = this.allRecipes.find(r => r.id === parseInt(this.recipeToAdd, 10));
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Verify that inline <script> blocks in admin templates parse as valid JS.
|
||||
|
||||
We had a regression where an Italian translation containing an apostrophe
|
||||
(e.g. "Errore nell'eliminazione") broke a single-quoted JS string literal,
|
||||
killing every Alpine binding on the page (the "Nuova Stazione" button no
|
||||
longer triggered openCreateModal).
|
||||
|
||||
Strategy: render each admin page through the Flask test client, extract every
|
||||
inline <script> body and feed it to `node --check` for a syntax verdict. We
|
||||
force the IT locale so the worst-case translations (which contain apostrophes)
|
||||
are the ones evaluated.
|
||||
|
||||
The test is skipped if `node` is not on PATH; CI images that exclude Node
|
||||
get a soft skip rather than a false failure.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# Match every inline <script>…</script> (i.e. without `src=`) so we don't try
|
||||
# to syntax-check Alpine.js / Plotly bundles served from a CDN.
|
||||
_INLINE_SCRIPT_RX = re.compile(
|
||||
r"<script(?![^>]*\bsrc=)[^>]*>(.*?)</script>",
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _node_check(script: str, label: str) -> None:
|
||||
"""Fail the test if `node --check` rejects `script`."""
|
||||
if not script.strip():
|
||||
return
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
pytest.skip("node binary not found on PATH; cannot validate JS syntax")
|
||||
|
||||
fd, path = tempfile.mkstemp(suffix=".js")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(script)
|
||||
result = subprocess.run(
|
||||
[node, "--check", path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
if result.returncode != 0:
|
||||
snippet = script.strip()
|
||||
if len(snippet) > 600:
|
||||
snippet = snippet[:600] + "\n…(truncated)…"
|
||||
pytest.fail(
|
||||
f"{label}: node rejected the inline <script> as invalid JS.\n"
|
||||
f"--- node stderr ---\n{result.stderr.strip()}\n"
|
||||
f"--- script (first 600 chars) ---\n{snippet}"
|
||||
)
|
||||
|
||||
|
||||
def _force_italian(client) -> None:
|
||||
"""Make the next request render with the IT locale (worst case for JS)."""
|
||||
with client.session_transaction() as sess:
|
||||
sess["language"] = "it"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_admin_api():
|
||||
"""Patch api_client used inside the admin blueprint."""
|
||||
mock = MagicMock()
|
||||
with patch("blueprints.admin.api_client", mock):
|
||||
yield mock
|
||||
|
||||
|
||||
def test_admin_stations_inline_js_is_valid(logged_in_client, mock_admin_api):
|
||||
"""The /admin/stations page must emit syntactically valid inline JS in IT.
|
||||
|
||||
Regression guard for the apostrophe-in-single-quote bug.
|
||||
"""
|
||||
mock_admin_api.get.side_effect = [
|
||||
# GET /api/stations
|
||||
[{
|
||||
"id": 1, "code": "ST-DEFAULT", "name": "Default Station",
|
||||
"location": None, "notes": None, "active": True,
|
||||
"created_by": 1, "created_at": "2026-04-25T10:00:00",
|
||||
}],
|
||||
# GET /api/recipes
|
||||
[{"id": 1, "code": "DEMO-001", "name": "Demo Recipe", "active": True}],
|
||||
]
|
||||
|
||||
_force_italian(logged_in_client)
|
||||
resp = logged_in_client.get("/admin/stations")
|
||||
assert resp.status_code == 200, resp.data[:300]
|
||||
|
||||
html = resp.data.decode("utf-8")
|
||||
scripts = _INLINE_SCRIPT_RX.findall(html)
|
||||
assert scripts, "expected at least one inline <script> on /admin/stations"
|
||||
|
||||
for i, body in enumerate(scripts):
|
||||
_node_check(body, f"/admin/stations script[{i}]")
|
||||
|
||||
|
||||
def test_admin_users_inline_js_is_valid(logged_in_client, mock_admin_api):
|
||||
"""Same guard for /admin/users (reuses the userManagement Alpine pattern)."""
|
||||
mock_admin_api.get.return_value = [{
|
||||
"id": 1, "username": "admin", "display_name": "Admin",
|
||||
"email": None, "roles": ["Maker"], "is_admin": True,
|
||||
"language_pref": "it", "theme_pref": "light", "active": True,
|
||||
}]
|
||||
|
||||
_force_italian(logged_in_client)
|
||||
resp = logged_in_client.get("/admin/users")
|
||||
assert resp.status_code == 200
|
||||
|
||||
html = resp.data.decode("utf-8")
|
||||
scripts = _INLINE_SCRIPT_RX.findall(html)
|
||||
assert scripts
|
||||
|
||||
for i, body in enumerate(scripts):
|
||||
_node_check(body, f"/admin/users script[{i}]")
|
||||
Reference in New Issue
Block a user