from __future__ import annotations import shutil import subprocess from dataclasses import dataclass, field from html.parser import HTMLParser from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[1] INDEX_HTML = PROJECT_ROOT / "app" / "static" / "index.html" APP_JS = PROJECT_ROOT / "app" / "static" / "app.js" FILTER_INPUT_IDS = { "instance-group-filter", "instance-status-filter", "group-status-filter", "history-instance-filter", "history-group-filter", "history-status-filter", } VOID_ELEMENTS = { "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr", } @dataclass class HtmlNode: tag: str attrs: dict[str, str | None] children: list[HtmlNode] = field(default_factory=list) class HtmlTreeParser(HTMLParser): def __init__(self) -> None: super().__init__(convert_charrefs=True) self.root = HtmlNode("document", {}) self._stack = [self.root] def handle_starttag( self, tag: str, attrs: list[tuple[str, str | None]] ) -> None: self._append_node(tag, attrs, push=tag not in VOID_ELEMENTS) def handle_startendtag( self, tag: str, attrs: list[tuple[str, str | None]] ) -> None: self._append_node(tag, attrs, push=False) def handle_endtag(self, tag: str) -> None: for index in range(len(self._stack) - 1, 0, -1): if self._stack[index].tag == tag: del self._stack[index:] return def _append_node( self, tag: str, attrs: list[tuple[str, str | None]], *, push: bool, ) -> None: node = HtmlNode(tag, dict(attrs)) self._stack[-1].children.append(node) if push: self._stack.append(node) def parse_index() -> HtmlNode: parser = HtmlTreeParser() parser.feed(INDEX_HTML.read_text(encoding="utf-8")) parser.close() return parser.root def descendants(node: HtmlNode) -> list[HtmlNode]: result: list[HtmlNode] = [] pending = list(reversed(node.children)) while pending: current = pending.pop() result.append(current) pending.extend(reversed(current.children)) return result def has_class(node: HtmlNode, class_name: str) -> bool: return class_name in (node.attrs.get("class") or "").split() def test_all_native_selects_are_replaced_by_custom_selects() -> None: nodes = descendants(parse_index()) assert not [node for node in nodes if node.tag == "select"] assert len([node for node in nodes if "data-custom-select" in node.attrs]) == 7 def test_every_custom_select_has_scoped_hidden_input_and_valid_aria() -> None: nodes = descendants(parse_index()) roots = [node for node in nodes if "data-custom-select" in node.attrs] controlled_ids: list[str] = [] hidden_inputs: list[HtmlNode] = [] for index, root in enumerate(roots, start=1): scoped_nodes = descendants(root) scoped_hidden = [ node for node in scoped_nodes if node.tag == "input" and (node.attrs.get("type") or "").lower() == "hidden" ] triggers = [ node for node in scoped_nodes if node.tag == "button" and has_class(node, "custom-select-trigger") ] listboxes = [ node for node in scoped_nodes if node.attrs.get("role") == "listbox" ] description = root.attrs.get("id") or f"custom select #{index}" assert len(scoped_hidden) == 1, description assert len(triggers) == 1, description assert len(listboxes) == 1, description hidden_inputs.extend(scoped_hidden) trigger = triggers[0] listbox = listboxes[0] controls = trigger.attrs.get("aria-controls") listbox_id = listbox.attrs.get("id") assert trigger.attrs.get("aria-haspopup") == "listbox", description assert trigger.attrs.get("aria-expanded") in {"true", "false"}, description assert controls, description assert controls == listbox_id, description assert "data-custom-select-menu" in listbox.attrs, description controlled_ids.append(controls) assert len(controlled_ids) == len(set(controlled_ids)) == 7 assert { node.attrs["id"] for node in hidden_inputs if node.attrs.get("id") } == FILTER_INPUT_IDS assert len( [node for node in hidden_inputs if node.attrs.get("name") == "group_id"] ) == 1 def test_shared_option_template_keeps_listbox_option_aria_contract() -> None: source = APP_JS.read_text(encoding="utf-8") marker = "function customSelectOptionMarkup(" start = source.index(marker) end = source.index("\nfunction ", start + len(marker)) function_source = source[start:end] assert source.count(marker) == 1 assert source.count("customSelectOptionMarkup(") >= 2 assert 'role="option"' in function_source assert 'aria-selected="${selected}"' in function_source assert 'tabindex="-1"' in function_source def test_app_javascript_has_valid_syntax() -> None: node = shutil.which("node") assert node is not None, "Node.js is required to validate app/static/app.js" result = subprocess.run( [node, "--check", str(APP_JS)], capture_output=True, text=True, timeout=30, check=False, ) assert result.returncode == 0, result.stderr or result.stdout