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