FluxIP/tests/test_static_frontend.py

187 lines
5.4 KiB
Python

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