"""
WebUI Tests - 测试Ascend Deployer WebUI的语法和功能
包括:JavaScript语法检查、Python语法检查、配置文件验证、Flask路由测试
"""
import os
import json
import yaml
import subprocess
import importlib.util
import shutil
from pathlib import Path
from typing import Dict, List
import pytest
PROJECT_ROOT = Path(__file__).parent.parent.parent
WEBUI_DIR = PROJECT_ROOT / "kadt" / "webui"
KEY_PROFILES = "profiles"
KEY_WORKFLOW = "workflow"
KEY_NAME = "name"
KEY_PARAMS = "params"
KEY_TYPE = "type"
KEY_TEMPLATES = "templates"
KEY_NODE = "node"
KEY_FALSE = "false"
KEY_TRUE = "true"
PROFILE_VLLM_ASCEND = "vllm-ascend"
STEP_LLM = "llm"
PROFILE_OPENCODE = "opencode"
STEP_DOWNLOAD = "download"
def load_module_from_file(module_name: str, file_path: Path):
"""动态加载模块,避免使用sys.path.insert"""
spec = importlib.util.spec_from_file_location(module_name, file_path)
if spec and spec.loader:
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
return None
class TestJavaScriptSyntax:
"""JavaScript语法检查"""
@staticmethod
def test_js_files_exist(js_files: List[Path]) -> None:
"""测试JS文件存在"""
assert len(js_files) > 0, "未找到JavaScript文件"
@pytest.fixture
def js_files(self) -> List[Path]:
"""获取所有JS文件"""
js_files = []
static_dir = WEBUI_DIR / "static"
if static_dir.exists():
js_files = list(static_dir.glob("*.js"))
return js_files
@pytest.mark.parametrize("js_file", [
WEBUI_DIR / "static" / "app.js",
])
def test_js_file_syntax(self, js_file: Path) -> None:
"""测试JavaScript语法"""
if not js_file.exists():
pytest.skip(f"{js_file} 不存在")
content = js_file.read_text()
basic_checks = [
("括号匹配", lambda c: c.count("{") == c.count("}")),
("圆括号匹配", lambda c: c.count("(") == c.count(")")),
("方括号匹配", lambda c: c.count("[") == c.count("]")),
]
for check_name, check_func in basic_checks:
assert check_func(content), f"{js_file.name}: {check_name}失败"
node_path = shutil.which(KEY_NODE)
if node_path:
result = subprocess.run(
[node_path, "--check", str(js_file)],
capture_output=True,
text=True
)
assert result.returncode == 0, f"Node.js语法检查失败: {result.stderr}"
class TestTemplatesSyntax:
"""Jinja2模板语法检查"""
@staticmethod
def test_templates_exist(template_files: List[Path]) -> None:
"""测试模板文件存在"""
assert len(template_files) > 0, "未找到模板文件"
@staticmethod
def test_template_in_script_block() -> None:
"""测试step.html中的script block语法"""
step_html = WEBUI_DIR / KEY_TEMPLATES / "step.html"
if not step_html.exists():
pytest.skip("step.html不存在")
content = step_html.read_text()
script_start = content.find("{% block scripts %}")
script_end = content.find("{% endblock %}")
if script_start == -1 or script_end == -1:
pytest.fail("step.html缺少{% block scripts %}或{% endblock %}")
script_content = content[script_start:script_end]
js_checks = [
("括号匹配", lambda c: c.count("{") == c.count("}")),
("圆括号匹配", lambda c: c.count("(") == c.count(")")),
("方括号匹配", lambda c: c.count("[") == c.count("]")),
]
for check_name, check_func in js_checks:
assert check_func(script_content), f"step.html script block: {check_name}失败"
@pytest.fixture
def template_files(self) -> List[Path]:
"""获取所有模板文件"""
template_dir = WEBUI_DIR / KEY_TEMPLATES
if template_dir.exists():
return list(template_dir.glob("*.html"))
return []
@pytest.mark.parametrize("template_file", [
WEBUI_DIR / KEY_TEMPLATES / "base.html",
WEBUI_DIR / KEY_TEMPLATES / "index.html",
WEBUI_DIR / KEY_TEMPLATES / "workflow.html",
WEBUI_DIR / KEY_TEMPLATES / "step.html",
WEBUI_DIR / KEY_TEMPLATES / "logs.html",
WEBUI_DIR / KEY_TEMPLATES / "tasks.html",
])
def test_template_syntax(self, template_file: Path) -> None:
"""测试Jinja2模板语法"""
if not template_file.exists():
pytest.skip(f"{template_file} 不存在")
content = template_file.read_text()
block_checks = [
("{% block %}匹配", lambda c: c.count("{% block") == c.count("{% endblock")),
("{% for %}匹配", lambda c: c.count("{% for") == c.count("{% endfor")),
("{% if %}匹配", lambda c: c.count("{% if") >= c.count("{% endif")),
]
for check_name, check_func in block_checks:
assert check_func(content), f"{template_file.name}: {check_name}失败"
template_errors = []
STR_JINJA_VAR_END = "}}"
for i, line in enumerate(content.split("\n"), 1):
line_stripped = line.strip()
if "{{ " in line and not line.endswith(STR_JINJA_VAR_END) and STR_JINJA_VAR_END not in line:
if not any(x in line for x in ["{%", "|", "and", "or", "else", "endif", "endfor", "endblock"]):
if line_stripped.count("{{") != line_stripped.count(STR_JINJA_VAR_END):
template_errors.append(f"行{i}: 双花括号可能未闭合")
if "{% " in line and " %}" in line:
if "}%" in line and "%}" not in line:
template_errors.append(f"行{i}: Jinja2语法错误,可能有错误闭合符号")
assert len(template_errors) == 0, f"{template_file.name}模板语法错误:\n" + "\n".join(template_errors)
class TestPythonSyntax:
"""Python语法检查"""
@staticmethod
def test_python_files_exist(python_files: List[Path]) -> None:
"""测试Python文件存在"""
assert len(python_files) > 0, "未找到Python文件"
@staticmethod
def test_import_app_module() -> None:
"""测试导入app模块"""
module = load_module_from_file("app", WEBUI_DIR / "app.py")
if module is None:
pytest.fail("无法加载app.py")
@staticmethod
def test_import_script_runner_module() -> None:
"""测试导入script_runner模块"""
module = load_module_from_file("script_runner", WEBUI_DIR / "script_runner.py")
if module is None:
pytest.fail("无法加载script_runner.py")
@pytest.mark.parametrize("py_file", [
WEBUI_DIR / "app.py",
WEBUI_DIR / "script_runner.py",
])
def test_python_syntax_import(self, py_file: Path) -> None:
"""测试Python语法(通过导入)"""
if not py_file.exists():
pytest.skip(f"{py_file} 不存在")
import sys
result = subprocess.run(
[sys.executable, "-m", "py_compile", str(py_file)],
capture_output=True,
text=True
)
assert result.returncode == 0, f"Python语法错误: {result.stderr}"
@pytest.fixture
def python_files(self) -> List[Path]:
"""获取所有Python文件"""
py_files = []
if WEBUI_DIR.exists():
py_files = list(WEBUI_DIR.glob("*.py"))
return py_files
class TestConfigFile:
"""配置文件验证"""
@staticmethod
def test_config_file_exists() -> None:
"""测试配置文件存在"""
config_file = WEBUI_DIR / "scripts_config.yaml"
assert config_file.exists(), f"配置文件不存在: {config_file}"
@staticmethod
def test_config_yaml_syntax() -> None:
"""测试YAML语法"""
config_file = WEBUI_DIR / "scripts_config.yaml"
if not config_file.exists():
pytest.skip("配置文件不存在")
try:
with open(config_file, "r", encoding="utf-8") as f:
config = yaml.safe_load(f)
assert config is not None, "配置文件为空"
except yaml.YAMLError as e:
pytest.fail(f"YAML语法错误: {e}")
@staticmethod
def test_config_structure() -> None:
"""测试配置文件结构"""
config_file = WEBUI_DIR / "scripts_config.yaml"
if not config_file.exists():
pytest.skip("配置文件不存在")
with open(config_file, "r", encoding="utf-8") as f:
config = yaml.safe_load(f)
assert KEY_PROFILES in config, "配置缺少profiles字段"
assert isinstance(config[KEY_PROFILES], dict), "profiles应该是字典"
for profile_name, profile_data in config[KEY_PROFILES].items():
assert "display_name" in profile_data, f"{profile_name}缺少display_name"
assert KEY_WORKFLOW in profile_data, f"{profile_name}缺少workflow"
assert isinstance(profile_data[KEY_WORKFLOW], list), f"{profile_name}的workflow应该是列表"
for step in profile_data[KEY_WORKFLOW]:
required_fields = [KEY_NAME, "script", "title", "description"]
for field in required_fields:
assert field in step, f"{profile_name}的步骤缺少{field}"
if KEY_PARAMS in step:
assert isinstance(step[KEY_PARAMS], list), f"{profile_name}/{step[KEY_NAME]}的params应该是列表"
for param in step[KEY_PARAMS]:
if param.get(KEY_TYPE) == "help_panel":
param_required = [KEY_NAME, KEY_TYPE, "title"]
else:
param_required = [KEY_NAME, KEY_TYPE, "label"]
for field in param_required:
assert field in param, f"{profile_name}/{step[KEY_NAME]}的参数缺少{field}"
@staticmethod
def test_config_scripts_exist() -> None:
"""测试配置中的脚本文件存在"""
config_file = WEBUI_DIR / "scripts_config.yaml"
if not config_file.exists():
pytest.skip("配置文件不存在")
with open(config_file, "r", encoding="utf-8") as f:
config = yaml.safe_load(f)
for _, profile_data in config[KEY_PROFILES].items():
for step in profile_data[KEY_WORKFLOW]:
script_path = PROJECT_ROOT / "kadt" / step["script"]
assert script_path.exists(), f"脚本不存在: {step['script']}"
class TestFlaskRoutes:
"""Flask路由测试"""
@staticmethod
def test_index_route(client) -> None:
"""测试首页路由"""
response = client.get("/")
assert response.status_code == 200
assert "text/html" in response.content_type
@staticmethod
def test_tasks_route(client) -> None:
"""测试任务列表路由"""
response = client.get("/tasks")
assert response.status_code == 200
@staticmethod
def test_profile_route(client) -> None:
"""测试profile详情路由"""
profiles = [PROFILE_OPENCODE, "openclaw", PROFILE_VLLM_ASCEND]
for profile in profiles:
response = client.get(f"/profile/{profile}")
assert response.status_code == 200, f"profile/{profile}返回{response.status_code}"
@staticmethod
def test_step_route(client) -> None:
"""测试步骤页面路由"""
test_cases = [
(PROFILE_OPENCODE, STEP_DOWNLOAD),
(PROFILE_VLLM_ASCEND, STEP_LLM),
]
for profile, step in test_cases:
response = client.get(f"/step/{profile}/{step}")
assert response.status_code == 200, f"/step/{profile}/{step}返回{response.status_code}"
@staticmethod
def test_api_profiles(client) -> None:
"""测试API: 获取profiles"""
response = client.get("/api/profiles")
assert response.status_code == 200
data = json.loads(response.data)
assert isinstance(data, list)
@staticmethod
def test_api_workflow(client) -> None:
"""测试API: 获取workflow"""
response = client.get("/api/workflow/opencode")
assert response.status_code == 200
data = json.loads(response.data)
assert isinstance(data, list)
@staticmethod
def test_api_health(client) -> None:
"""测试API: 健康检查"""
response = client.get("/api/health")
assert response.status_code == 200
data = json.loads(response.data)
assert data.get("status") == "ok"
@pytest.fixture
def client(self):
"""获取Flask测试客户端"""
app_module = load_module_from_file("app", WEBUI_DIR / "app.py")
if app_module is None:
pytest.skip("无法加载Flask应用")
flask_app = app_module.app
flask_app.testing = True
yield flask_app.test_client()
class TestScriptRunner:
"""ScriptRunner功能测试"""
@staticmethod
def test_get_profiles(runner) -> None:
"""测试获取profiles"""
profiles = runner.get_profiles()
assert isinstance(profiles, list)
assert len(profiles) > 0
for profile in profiles:
assert KEY_NAME in profile
assert "display_name" in profile
@staticmethod
def test_get_workflow(runner) -> None:
"""测试获取workflow"""
workflow = runner.get_workflow(PROFILE_OPENCODE)
assert isinstance(workflow, list)
assert len(workflow) > 0
for step in workflow:
assert KEY_NAME in step
assert "script" in step
@staticmethod
def test_get_step(runner) -> None:
"""测试获取单个步骤"""
step = runner.get_step(PROFILE_OPENCODE, STEP_DOWNLOAD)
assert step is not None
assert step.get(KEY_NAME) == STEP_DOWNLOAD
@staticmethod
def test_build_command(runner) -> None:
"""测试构建命令"""
params = {"model": "Qwen3.5-27B-w8a8-mtp", "os": "openEuler22.03"}
cmd = runner.build_command(PROFILE_OPENCODE, STEP_DOWNLOAD, params)
assert isinstance(cmd, list)
assert cmd[0] == "bash"
assert "--os" in cmd
@staticmethod
def test_checkbox_command(runner) -> None:
"""测试布尔select参数构建(bool_flag区分)"""
params = {"model": "Qwen3.5-27B-w8a8-mtp", "port": "8000", "api-key": "test123"}
params["dual-node"] = KEY_FALSE
cmd = runner.build_command(PROFILE_VLLM_ASCEND, STEP_LLM, params)
assert "--dual-node" not in cmd, "dual-node为false时不应该添加参数"
params["dual-node"] = KEY_TRUE
cmd = runner.build_command(PROFILE_VLLM_ASCEND, STEP_LLM, params)
assert "--dual-node" in cmd, "dual-node为true时应该添加参数"
assert KEY_TRUE not in cmd, "bool_flag参数不应该添加值"
params["privileged"] = KEY_FALSE
cmd = runner.build_command(PROFILE_VLLM_ASCEND, STEP_LLM, params)
assert "--privileged" in cmd, "privileged参数应该添加"
assert KEY_FALSE in cmd, "非bool_flag参数应该添加值"
params["privileged"] = KEY_TRUE
cmd = runner.build_command(PROFILE_VLLM_ASCEND, STEP_LLM, params)
assert "--privileged" in cmd
assert KEY_TRUE in cmd, "privileged=true时应该传值true"
@staticmethod
def test_inventory_update(runner) -> None:
"""测试inventory更新"""
inventory_file = PROJECT_ROOT / "ascend_deployer" / "inventory_file"
if not inventory_file.exists():
pytest.skip("inventory_file不存在")
original_content = inventory_file.read_text()
try:
servers = [
{"ip": "10.10.10.1", "pass": "test123"},
{"ip": "10.10.10.2", "pass": "test456"},
]
result = runner.update_inventory(servers)
assert result is True
new_content = inventory_file.read_text()
assert "10.10.10.1" in new_content
assert "10.10.10.2" in new_content
assert "ansible_ssh_user=\"root\"" in new_content
assert "ansible_ssh_pass=\"test123\"" in new_content
result_empty = runner.update_inventory([])
assert result_empty is True
default_content = inventory_file.read_text()
assert "localhost ansible_connection='local'" in default_content
finally:
inventory_file.write_text(original_content)
@pytest.fixture
def runner(self):
"""获取ScriptRunner实例"""
script_runner_module = load_module_from_file("script_runner", WEBUI_DIR / "script_runner.py")
if script_runner_module is None:
pytest.skip("无法加载ScriptRunner")
ScriptRunner = script_runner_module.ScriptRunner
config_path = WEBUI_DIR / "scripts_config.yaml"
runner = ScriptRunner(str(config_path), base_dir=str(PROJECT_ROOT))
yield runner
class TestStaticFiles:
"""静态文件测试"""
@staticmethod
def test_css_file_exists() -> None:
"""测试CSS文件存在"""
css_file = WEBUI_DIR / "static" / "style.css"
assert css_file.exists(), "style.css不存在"
@staticmethod
def test_css_syntax() -> None:
"""测试CSS基本语法"""
css_file = WEBUI_DIR / "static" / "style.css"
if not css_file.exists():
pytest.skip("style.css不存在")
content = css_file.read_text()
assert content.count("{") == content.count("}"), "CSS括号不匹配"
class TestHTMLRendering:
"""HTML渲染测试 - 验证关键元素正确渲染"""
@staticmethod
def test_json_data_attributes_quote_format(app_client) -> None:
"""测试JSON数据属性使用单引号包裹(避免引号冲突)"""
resp = app_client.get(f'/step/{PROFILE_OPENCODE}/{STEP_LLM}')
html = resp.data.decode('utf-8')
assert "data-filter-depends='[" in html, \
"data-filter-depends应该使用单引号包裹JSON数组"
assert "data-filter-rules='{" in html, \
"data-filter-rules应该使用单引号包裹JSON对象"
assert "data-filter-depends=\"[" not in html, \
"data-filter-depends不应该使用双引号(会与JSON内部引号冲突)"
assert "data-filter-rules=\"{" not in html, \
"data-filter-rules不应该使用双引号(会与JSON内部引号冲突)"
@staticmethod
def test_help_panel_rendering(app_client) -> None:
"""测试help_panel正确渲染"""
resp = app_client.get(f'/step/{PROFILE_OPENCODE}/{STEP_LLM}')
html = resp.data.decode('utf-8')
assert 'help-panel-title' in html, "help_panel应该有标题元素"
assert '模型兼容性说明' in html, "help_panel标题内容应该正确"
assert 'help-panel-content' in html, "help_panel应该有内容区域"
assert 'help-section-title' in html, "help_panel应该有分节标题"
assert 'help-model-tag' in html, "help_panel应该显示模型标签"
@staticmethod
def test_dynamic_filter_attributes(app_client) -> None:
"""测试dynamic_filter参数正确渲染所有必需属性"""
resp = app_client.get(f'/step/{PROFILE_OPENCODE}/{STEP_LLM}')
html = resp.data.decode('utf-8')
assert 'data-dynamic-filter="true"' in html, \
"model select应该有data-dynamic-filter属性"
assert '"server-type"' in html, "filter_depends应该包含server-type"
assert '"dual-node"' in html, "filter_depends应该包含dual-node"
assert 'a2_single' in html, "filter_rules应该包含a2_single键"
assert 'a2_multi' in html, "filter_rules应该包含a2_multi键"
assert 'a3_single' in html, "filter_rules应该包含a3_single键"
assert 'a3_multi' in html, "filter_rules应该包含a3_multi键"
@staticmethod
def test_default_values_rendering(app_client) -> None:
"""测试参数默认值正确渲染"""
resp = app_client.get(f'/step/{PROFILE_OPENCODE}/{STEP_LLM}')
html = resp.data.decode('utf-8')
assert 'data-default="a2"' in html, \
"server-type应该有data-default='a2'"
assert 'data-default="false"' in html, \
"dual-node应该有data-default='false'"
assert '<option value="a2"' in html and 'selected' in html, \
"server-type的a2选项应该默认选中"
assert '<option value="false"' in html and 'selected' in html, \
"dual-node的false选项应该默认选中"
@staticmethod
def test_json_parseable_in_html(app_client) -> None:
"""测试HTML中的JSON可以被正确解析"""
resp = app_client.get(f'/step/{PROFILE_OPENCODE}/{STEP_LLM}')
html = resp.data.decode('utf-8')
import re
deps_pattern = r"data-filter-depends='([^']+)'"
deps_match = re.search(deps_pattern, html)
if deps_match:
deps_json = deps_match.group(1)
try:
deps = json.loads(deps_json)
assert isinstance(deps, list), "filter_depends应该是列表"
assert "server-type" in deps, "filter_depends应该包含server-type"
assert "dual-node" in deps, "filter_depends应该包含dual-node"
except json.JSONDecodeError as e:
pytest.fail(f"data-filter-depends的JSON无法解析: {e}")
rules_pattern = r"data-filter-rules='([^']+)'"
rules_match = re.search(rules_pattern, html)
if rules_match:
rules_json = rules_match.group(1)
try:
rules = json.loads(rules_json)
assert isinstance(rules, dict), "filter_rules应该是字典"
assert "a2_single" in rules, "filter_rules应该包含a2_single"
assert "a2_multi" in rules, "filter_rules应该包含a2_multi"
except json.JSONDecodeError as e:
pytest.fail(f"data-filter-rules的JSON无法解析: {e}")
@pytest.fixture
def app_client(self):
"""创建Flask测试客户端"""
app_module = load_module_from_file("app", WEBUI_DIR / "app.py")
if app_module is None:
pytest.skip("无法加载Flask应用")
app = app_module.app
app.testing = True
with app.test_client() as client:
yield client
if __name__ == "__main__":
pytest.main([__file__, "-v"])