187 lines
5.4 KiB
Python
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
|