314 lines
10 KiB
Python
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
|