Files
TieMeasureFlow/src/frontend/flask_app/tests/test_template_js_syntax.py
T

440 lines
16 KiB
Python

"""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
from tests.conftest import api_get_router
# 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,
)
# Match every Alpine.js / event-binding attribute on a tag — the bug class we
# care about (apostrophe in IT translation closing a JS literal) hides equally
# well inside x-text="…" as inside <script>…</script>.
#
# Covers: x-data, x-init, x-text, x-html, x-show, x-if, x-for, x-bind:foo,
# x-on:foo, x-model, x-effect, x-transition:*, plus the @evt and :attr
# shortcuts.
_ALPINE_ATTR_RX = re.compile(
r"""(?P<name>(?:x-[a-zA-Z][a-zA-Z0-9:-]*|@[a-zA-Z][a-zA-Z0-9.:-]*|:[a-zA-Z][a-zA-Z0-9.:-]*))="(?P<value>[^"]*)\"""",
re.DOTALL,
)
# Skip these — they're plain identifiers / object shapes, not JS expressions
# that node --check should reject for things like a stray apostrophe.
_ALPINE_NON_EXPR_NAMES = {
# transition modifiers like x-transition:enter-start get bare class names
"x-transition:enter",
"x-transition:enter-start",
"x-transition:enter-end",
"x-transition:leave",
"x-transition:leave-start",
"x-transition:leave-end",
# x-cloak has no value, x-data is parsed below as an expression but Alpine
# also accepts an object literal.
}
def _alpine_attribute_expressions(html: str):
"""Yield (attr_name, value) tuples for every Alpine expression attribute."""
for m in _ALPINE_ATTR_RX.finditer(html):
name = m.group("name")
if name in _ALPINE_NON_EXPR_NAMES:
continue
value = m.group("value")
if not value.strip():
continue
# HTML entities used to embed quotes inside the attribute value
# (most common: &quot; for ").
value = (
value.replace("&quot;", '"')
.replace("&#34;", '"')
.replace("&#39;", "'")
.replace("&amp;", "&")
)
yield name, value
def _node_syntax_error(source: str) -> str | None:
"""Return node's complaint about `source`, or None if it parses."""
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(source)
result = subprocess.run(
[node, "--check", path],
capture_output=True,
text=True,
timeout=10,
)
finally:
os.unlink(path)
return None if result.returncode == 0 else result.stderr.strip()
def _fail(label: str, source: str, stderr: str) -> None:
snippet = source.strip()
if len(snippet) > 600:
snippet = snippet[:600] + "\n…(truncated)…"
pytest.fail(
f"{label}: node rejected this code as invalid JS.\n"
f"--- node stderr ---\n{stderr}\n"
f"--- source (first 600 chars) ---\n{snippet}"
)
def _node_check(source: str, label: str) -> None:
"""Fail the test if `node --check` rejects `source`."""
if not source.strip():
return
stderr = _node_syntax_error(source)
if stderr is not None:
_fail(label, source, stderr)
def _check_alpine_attributes(html: str, page_label: str) -> None:
"""Validate every Alpine expression attribute on the page.
Alpine accepts an attribute either as a single expression or as a sequence of
statements, so the check accepts a value that parses as either and only fails
when both readings are rejected. Neither form alone is enough:
:class="{ 'dark': $store.theme.dark }" only as an expression
@click="showOverlay = false; confirmed = true" only as statements
An unterminated string literal - the bug this file exists for - is a syntax
error under both, so the guard is unchanged.
"""
for name, value in _alpine_attribute_expressions(html):
as_expression = f"void (\n{value}\n);\n"
as_statements = f"(() => {{\n{value}\n}});\n"
expression_error = _node_syntax_error(as_expression)
if expression_error is None:
continue
if _node_syntax_error(as_statements) is None:
continue
_fail(
f"{page_label} attribute {name}=\"…\" parsed neither as an expression "
"nor as statements",
as_expression,
expression_error,
)
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}]")
_check_alpine_attributes(html, "/admin/stations")
def test_admin_stations_inline_js_is_valid_in_english(
logged_in_client, mock_admin_api, flask_app,
):
"""gitea#37.5: the EN catalog is where this page's apostrophe actually is.
translations/en/LC_MESSAGES/messages.po: msgid "Azzera le ricette della
stazione" -> msgstr "Clear the station's recipes" (admin/stations.html:105).
The IT source string has no apostrophe, so test_admin_stations_inline_js_is_valid
(IT-only) would never have caught this - #37.5's own TEST note calls out
the EN render specifically.
The .mo catalogs are gitignored and compiled only at Docker build time
(compile_translations.py) - not part of this test run - so setting
session["language"] = "en" alone has no effect here: flask_babel falls
back to the (Italian) msgid with no compiled catalog to translate from.
The Jinja "_" global is patched directly instead, standing in for what a
compiled EN catalog would return for this one string; everything else
round-trips unchanged, same as gettext's own fallback.
"""
EN_TRANSLATIONS = {
"Azzera le ricette della stazione": "Clear the station's recipes",
}
def fake_gettext(msgid, **kwargs):
text = EN_TRANSLATIONS.get(msgid, msgid)
return text % kwargs if kwargs else text
flask_app.jinja_env.globals["_"] = fake_gettext
mock_admin_api.get.side_effect = [
[{
"id": 1, "code": "ST-DEFAULT", "name": "Default Station",
"location": None, "notes": None, "active": True,
"created_by": 1, "created_at": "2026-04-25T10:00:00",
}],
[{"id": 1, "code": "DEMO-001", "name": "Demo Recipe", "active": True}],
]
resp = logged_in_client.get("/admin/stations")
assert resp.status_code == 200, resp.data[:300]
html = resp.data.decode("utf-8")
# The fix (#37.5) is exactly why the apostrophe shows up escaped
# (tojson_attr) rather than literal - that is what keeps it from
# breaking the surrounding JS string.
assert "station" in html.lower(), "sanity check: the EN string did render"
assert "station\\u0027s recipes" in html or "station&#39;s recipes" in html, (
"expected the EN string's apostrophe to reach the page only as an "
"escaped token (tojson_attr), not literally"
)
scripts = _INLINE_SCRIPT_RX.findall(html)
for i, body in enumerate(scripts):
_node_check(body, f"/admin/stations (en) script[{i}]")
_check_alpine_attributes(html, "/admin/stations (en)")
def test_admin_users_inline_js_is_valid(logged_in_client, mock_admin_api):
"""Same guard for the Users tab, now inside /admin/settings (#50)."""
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/settings")
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/settings script[{i}]")
_check_alpine_attributes(html, "/admin/settings")
@pytest.fixture
def mock_measure_api():
"""Patch api_client used inside the measure blueprint."""
mock = MagicMock()
with patch("blueprints.measure.api_client", mock):
yield mock
def test_task_execute_inline_js_is_valid(logged_in_client, mock_measure_api, monkeypatch):
"""The measurement screen carries the most inline JS in the app.
It drives the numpad, the caliper burst detection, the supervisor modal and -
since the production run moved server-side - the countdown. A broken literal
here kills every binding on the screen the operator actually works in.
"""
_force_italian(logged_in_client)
# STATION_CODE defaults to unset in the test env, so it has to be forced
# for task_execute()'s station-assignment check (#24).
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
task = {
"id": 11,
"recipe_id": 3,
"version_id": 5,
"title": "Quota d'ingresso",
"description": "Misura l'altezza",
"file_path": None,
"file_type": None,
"annotations_json": None,
"subtasks": [{
"id": 21, "marker_number": 1, "name": "Altezza", "order_index": 0,
"nominal": 10.0, "utl": 10.5, "uwl": 10.2, "lwl": 9.8, "ltl": 9.5,
"unit": "mm",
}],
}
# task_execute() only renders a recipe assigned to this station (#24).
mock_measure_api.get_station_recipes.return_value = [{"id": 3}]
mock_measure_api.get.side_effect = [
task, # /api/tasks/11
[{"id": 11, "order_index": 0}], # /api/recipes/3/tasks
{"id": 3, "measurement_interval_minutes": 30}, # /api/recipes/3
]
resp = logged_in_client.get("/measure/execute/11")
assert resp.status_code == 200
html = resp.get_data(as_text=True)
scripts = _INLINE_SCRIPT_RX.findall(html)
assert scripts, "expected at least one inline <script> on /measure/execute"
for i, body in enumerate(scripts):
_node_check(body, f"/measure/execute script[{i}]")
_check_alpine_attributes(html, "/measure/execute")
def test_select_recipe_inline_js_is_valid(logged_in_client, mock_measure_api, monkeypatch):
"""The selection screen decides whether Avvia starts at all.
Its expressions are inline in x-data, so a broken one there takes the search,
the barcode scanner and the traceability check down with it.
"""
monkeypatch.setenv("STATION_CODE", "ST-TEST")
import importlib
import config
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
_force_italian(logged_in_client)
mock_measure_api.get_station_recipes.return_value = [
{
"id": 1, "code": "REC-001", "name": "Ricetta con l'apostrofo",
"description": "Con l'apostrofo anche qui",
"requires_lot": True, "requires_serial": True,
},
]
resp = logged_in_client.get("/measure/select")
assert resp.status_code == 200
html = resp.get_data(as_text=True)
for i, body in enumerate(_INLINE_SCRIPT_RX.findall(html)):
_node_check(body, f"/measure/select script[{i}]")
_check_alpine_attributes(html, "/measure/select")
def test_task_list_inline_js_is_valid(logged_in_client, mock_measure_api):
"""The task list carries the shared measurement clock.
Its bindings are the ones that turn the countdown red and announce the return
to the measurement; a broken expression here would take the whole page's Alpine
with it, and the operator would simply never be called back.
"""
_force_italian(logged_in_client)
mock_measure_api.get.side_effect = api_get_router({
"/api/recipes/3/tasks": [{
"id": 11, "order_index": 0, "title": "Quota d'ingresso",
"task_type": "measure", "subtasks": [], "file_path": None,
}],
"/api/recipes/3": {
"id": 3, "code": "REC-3", "name": "Ricetta", "description": None,
"current_version": {"id": 5},
},
"/api/production-runs/current": None,
"/api/measurements/task-progress": {
"tasks": [{"task_id": 11, "quotes": 2, "measured": 1, "state": "partial"}],
},
})
resp = logged_in_client.get("/measure/tasks/3")
assert resp.status_code == 200
html = resp.get_data(as_text=True)
for i, body in enumerate(_INLINE_SCRIPT_RX.findall(html)):
_node_check(body, f"/measure/tasks script[{i}]")
_check_alpine_attributes(html, "/measure/tasks")
@pytest.fixture
def mock_maker_api():
"""Patch api_client used inside the maker blueprint."""
mock = MagicMock()
with patch("blueprints.maker.api_client", mock):
yield mock
def test_task_editor_inline_js_is_valid(logged_in_client, mock_maker_api):
"""gitea#4: the version-conflict reload added _reloadForNewVersion() and
new `if (…status === 409)` / `data.task_id !== taskId` checks in every
save handler. Regression guard that the inline script still parses."""
_force_italian(logged_in_client)
mock_maker_api.get.side_effect = api_get_router({
"/api/recipes/3/tasks": [{
"id": 11, "order_index": 0, "title": "Quota d'ingresso",
"task_type": "measure", "file_path": None, "file_type": None,
"annotations_json": None,
"subtasks": [{
"id": 21, "marker_number": 1, "description": "Diametro",
"nominal": 10.0, "utl": 10.5, "uwl": 10.2, "lwl": 9.8, "ltl": 9.5,
"unit": "mm",
}],
}],
"/api/recipes/3": {
"id": 3, "code": "REC-3", "name": "Ricetta con l'apostrofo",
"description": "Con l'apostrofo anche qui",
},
})
resp = logged_in_client.get("/maker/recipes/3/tasks")
assert resp.status_code == 200
html = resp.get_data(as_text=True)
scripts = _INLINE_SCRIPT_RX.findall(html)
assert scripts, "expected at least one inline <script> on /maker/recipes/3/tasks"
for i, body in enumerate(scripts):
_node_check(body, f"/maker/recipes/3/tasks script[{i}]")
_check_alpine_attributes(html, "/maker/recipes/3/tasks")