"""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"]*\bsrc=)[^>]*>(.*?)", 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