"""Verify that inline (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"",
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 .
#
# 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(?:x-[a-zA-Z][a-zA-Z0-9:-]*|@[a-zA-Z][a-zA-Z0-9.:-]*|:[a-zA-Z][a-zA-Z0-9.:-]*))="(?P[^"]*)\"""",
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: " for ").
value = (
value.replace(""", '"')
.replace(""", '"')
.replace("'", "'")
.replace("&", "&")
)
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