FluxIP/tests/test_static_frontend.py

314 lines
10 KiB
Python

from __future__ import annotations
import os
import re
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"
STYLES_CSS = PROJECT_ROOT / "app" / "static" / "styles.css"
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]) == 10
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)) == 10
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
for field_name in ("aws_account_id", "aws_region", "cloudflare_account_id"):
assert len([node for node in hidden_inputs if node.attrs.get("name") == field_name]) == 1
def test_account_view_and_region_catalog_are_wired_into_the_app() -> None:
nodes = descendants(parse_index())
account_nav_items = [
node
for node in nodes
if node.attrs.get("data-view") == "accounts" and has_class(node, "nav-item")
]
account_panels = [node for node in nodes if node.attrs.get("data-view-panel") == "accounts"]
source = APP_JS.read_text(encoding="utf-8")
assert len(account_nav_items) == 2
assert len(account_panels) == 1
assert 'api("/accounts")' in source
assert 'api("/regions")' in source
assert 'aws_account_id: value("aws_account_id")' in source
assert 'cloudflare_account_id: value("cloudflare_account_id")' in source
assert "symbol: region.icon" in source
def test_instance_editor_exposes_proxy_url_and_cloudflare_mode_controls() -> None:
nodes = descendants(parse_index())
source = APP_JS.read_text(encoding="utf-8")
named_inputs = [node for node in nodes if node.tag == "input" and node.attrs.get("name")]
by_name = {node.attrs["name"]: node for node in named_inputs}
assert by_name["proxy_url"].attrs.get("type") == "password"
assert by_name["proxy_url"].attrs.get("autocomplete") == "off"
assert by_name["proxy_protocol"].attrs.get("type") == "hidden"
assert by_name["clear_proxy_credentials"].attrs.get("type") == "checkbox"
assert by_name["cloudflare_proxied"].attrs.get("type") == "checkbox"
assert "proxy_auth_configured" in source
assert "if (proxyUrl) payload.proxy_url = proxyUrl" in source
assert "payload.clear_proxy_credentials" in source
assert "cloudflare_proxied: form.elements.cloudflare_proxied.checked" in source
assert "DNS-only · TTL 60" in source
assert "Cloudflare Spectrum" in source
assert "route-mode-proxied" in source
assert 'input.dataset.baselineProtocol || "socks5"' in source
assert "代理链接主机必须与 A 记录完整域名一致" in source
def test_proxy_url_parser_accepts_supported_paste_formats() -> None:
node = shutil.which("node")
assert node is not None, "Node.js is required to validate proxy URL parsing"
script = r"""
const fs = require("fs");
const vm = require("vm");
const context = { document: { addEventListener() {} } };
vm.createContext(context);
vm.runInContext(fs.readFileSync(process.env.FLUXIP_APP_JS, "utf8"), context);
const cases = [
[
"socks5://proxy-user:proxy-password@proxy.example.com:10808",
"socks5", "proxy.example.com", 10808,
"proxy-user", "proxy-password", false,
],
[
"http://proxy-user:proxy-password@proxy.example.com:10808",
"http", "proxy.example.com", 10808,
"proxy-user", "proxy-password", false,
],
[
"socks5://proxy.example.com:10808@proxy-user:proxy-password",
"socks5", "proxy.example.com", 10808,
"proxy-user", "proxy-password", true,
],
[
"socks5://user:p%40ss@proxy.example.com:1080",
"socks5", "proxy.example.com", 1080, "user", "p@ss", false,
],
[
"socks5://1.2.3.4:1234@host.example.com:5678",
"socks5", "1.2.3.4", 1234, "host.example.com", "5678", true,
],
];
for (const [value, protocol, host, port, username, password, reversed] of cases) {
const parsed = context.parseProxyUrl(value);
const mismatch = parsed.protocol !== protocol
|| parsed.host !== host
|| parsed.port !== port
|| parsed.username !== username
|| parsed.password !== password
|| parsed.reversed !== reversed;
if (mismatch) {
throw new Error(`Unexpected parse result for ${value}: ${JSON.stringify(parsed)}`);
}
}
const invalidValues = [
"socks://user:pass@host:1080",
"socks5://user@host:1080",
"socks5://host:not-a-port",
"socks5://user:p%0Ass@host:1080",
"socks5://user:pass/word@host.example.com:1080",
"socks5://user:pass?word@host.example.com:1080",
"socks5://user:pass#word@host.example.com:1080",
];
for (const value of invalidValues) {
let rejected = false;
try { context.parseProxyUrl(value); } catch { rejected = true; }
if (!rejected) throw new Error(`Expected parser to reject ${value}`);
}
"""
env = os.environ.copy()
env["FLUXIP_APP_JS"] = str(APP_JS)
result = subprocess.run(
[node, "-e", script],
capture_output=True,
text=True,
timeout=30,
check=False,
env=env,
)
assert result.returncode == 0, result.stderr or result.stdout
def test_visible_typography_has_a_twelve_pixel_floor() -> None:
styles = STYLES_CSS.read_text(encoding="utf-8")
pixel_sizes = [
float(value) for value in re.findall(r"font-size:\s*([0-9]+(?:\.[0-9]+)?)px", styles)
]
assert pixel_sizes
assert min(pixel_sizes) >= 12
assert '"Microsoft YaHei UI"' in styles
assert "font-synthesis: none" not in styles
assert "scale(0.985)" not in styles
assert "scale(0.99)" not in styles
assert ".instances-table td.route-cell > .route-meta" in styles
assert "width: 44px;" in styles
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