- Add EXCLUDE_DIR_NAMES and in-place directory pruning to AST dependency scanner - Restore on-demand folder scanning in PackingThread (respect use_pipreqs_dir) - Filter out invalid modules starting with '-' or '_' during AST import extraction - Prune virtual environments and vendor folders during project entry script discovery - Enforce lowercase keys when updating default package mappings in config - Bump version to 2.8.4
391 lines
13 KiB
Python
391 lines
13 KiB
Python
import os
|
|
import sys
|
|
import time
|
|
import stat
|
|
import shutil
|
|
import tempfile
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from PySide6.QtCore import Qt
|
|
from PySide6.QtWidgets import QApplication
|
|
|
|
# 智能搜索并添加项目路径
|
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, PROJECT_ROOT.as_posix())
|
|
if (PROJECT_ROOT / "src").exists():
|
|
sys.path.insert(0, (PROJECT_ROOT / "src").as_posix())
|
|
if (PROJECT_ROOT / "qpypack").exists():
|
|
sys.path.insert(0, (PROJECT_ROOT / "qpypack").as_posix())
|
|
|
|
try:
|
|
import main
|
|
except ModuleNotFoundError:
|
|
try:
|
|
from qpypack import main
|
|
except ModuleNotFoundError:
|
|
import src.qpypack.main as main
|
|
|
|
|
|
class Colors:
|
|
GREEN = "\033[92m"
|
|
RED = "\033[91m"
|
|
YELLOW = "\033[93m"
|
|
CYAN = "\033[96m"
|
|
BOLD = "\033[1m"
|
|
RESET = "\033[0m"
|
|
|
|
|
|
def log_pass(msg):
|
|
print(f" {Colors.GREEN}✔ [PASS]{Colors.RESET} {msg}")
|
|
|
|
|
|
def log_warn(msg):
|
|
print(f" {Colors.YELLOW}⚠ [WARN]{Colors.RESET} {msg}")
|
|
|
|
|
|
def log_fail(msg):
|
|
print(f" {Colors.RED}✖ [FAIL]{Colors.RESET} {msg}")
|
|
|
|
|
|
def safe_cleanup_dir(path: Path, retries=15, delay=0.5):
|
|
"""Windows 下鲁棒删除临时目录,解决句柄延迟释放导致的 WinError 5 PermissionError"""
|
|
if not path or not path.exists():
|
|
return True
|
|
|
|
def remove_readonly(func, p, exc_info):
|
|
try:
|
|
os.chmod(p, stat.S_IWRITE)
|
|
func(p)
|
|
except Exception:
|
|
pass
|
|
|
|
for attempt in range(retries):
|
|
try:
|
|
shutil.rmtree(path, onerror=remove_readonly)
|
|
if not path.exists():
|
|
return True
|
|
except Exception:
|
|
time.sleep(delay)
|
|
|
|
try:
|
|
shutil.rmtree(path, ignore_errors=True)
|
|
except Exception:
|
|
pass
|
|
return not path.exists()
|
|
|
|
|
|
@pytest.fixture(scope="session", autouse=True)
|
|
def init_qapp():
|
|
app = QApplication.instance()
|
|
if not app:
|
|
app = QApplication(["pytest_qpypack", "-platform", "offscreen"])
|
|
yield app
|
|
|
|
|
|
def test_01_config_and_i18n():
|
|
"""测试 1: 配置加载、保存及多语言系统"""
|
|
print(f"\n{Colors.BOLD}{Colors.CYAN}>>> [测试 1/8] 偏好设置及国际化引擎校验...{Colors.RESET}")
|
|
config = main.load_config()
|
|
assert "Settings" in config
|
|
assert "Mappings" in config
|
|
assert "BackportRules" in config
|
|
|
|
# 验证翻译引擎
|
|
zh_text = main.ZH_CN_DICT.get("Start Build", "开始构建")
|
|
assert zh_text == "开始构建"
|
|
log_pass("配置加载与多语言引擎校验通过")
|
|
|
|
|
|
def test_02_ast_dependency_scanner():
|
|
"""测试 2: AST 依赖深度扫描与本地白名单识别"""
|
|
print(f"\n{Colors.BOLD}{Colors.CYAN}>>> [测试 2/8] AST 依赖引擎与白名单扫描...{Colors.RESET}")
|
|
tmp_dir = Path(tempfile.mkdtemp(prefix="qp_test_ast_"))
|
|
try:
|
|
script = tmp_dir / "sample.py"
|
|
script.write_text("import requests\nfrom PIL import Image\nimport local_dummy\n", encoding="utf-8")
|
|
|
|
local_mod = tmp_dir / "local_dummy.py"
|
|
local_mod.write_text("def hello(): pass\n", encoding="utf-8")
|
|
|
|
discovered = main.extract_project_imports_via_ast(script, scan_dir=False)
|
|
assert "requests" in discovered
|
|
assert "PIL" in discovered
|
|
assert "local_dummy" in discovered
|
|
|
|
local_modules = main.get_all_local_modules(tmp_dir, tmp_dir)
|
|
assert "local_dummy" in local_modules
|
|
|
|
log_pass(f"成功识别 AST 依赖: {discovered},本地模块排除列表: {local_modules}")
|
|
finally:
|
|
safe_cleanup_dir(tmp_dir)
|
|
|
|
|
|
def test_03_backport_version_parsing_safety():
|
|
"""测试 3: 废弃兼容包版本解析安全性 (防御 tuple index out of range)"""
|
|
print(f"\n{Colors.BOLD}{Colors.CYAN}>>> [测试 3/8] 版本解析防护机制 (防止 IndexError)...{Colors.RESET}")
|
|
|
|
test_cases = [
|
|
("3.11", (3, 11)),
|
|
("3.9.13", (3, 9)),
|
|
("3", (3, 0)),
|
|
("", (3, 4)),
|
|
("invalid", (3, 4)),
|
|
((3, 10), (3, 10)),
|
|
([3, 12], (3, 12)),
|
|
]
|
|
|
|
for val, expected in test_cases:
|
|
if isinstance(val, (tuple, list)):
|
|
res = (int(val[0]), int(val[1])) if len(val) >= 2 else (int(val[0]), 0)
|
|
elif isinstance(val, str):
|
|
import re
|
|
parts = [int(x) for x in re.findall(r"\d+", str(val))]
|
|
if len(parts) >= 2:
|
|
res = (parts[0], parts[1])
|
|
elif len(parts) == 1:
|
|
res = (parts[0], 0)
|
|
else:
|
|
res = (3, 4)
|
|
else:
|
|
res = (3, 4)
|
|
|
|
assert res == expected, f"解析 {val} 失败,得到 {res}"
|
|
|
|
log_pass("版本解析异常防御测试完全通过")
|
|
|
|
|
|
def test_04_sanitize_script_pep263_injection():
|
|
"""测试 4: 脚本净化与控制台暂停代码注入 (保护 PEP 263 编码头与 Shebang)"""
|
|
print(f"\n{Colors.BOLD}{Colors.CYAN}>>> [测试 4/8] 脚本安全净化与控制台暂停代码注入...{Colors.RESET}")
|
|
tmp_dir = Path(tempfile.mkdtemp(prefix="qp_test_sanitize_"))
|
|
try:
|
|
script = tmp_dir / "app_with_encoding.py"
|
|
raw_code = (
|
|
"#!/usr/bin/env python\n"
|
|
"# -*- coding: utf-8 -*-\n"
|
|
"from __future__ import annotations\n"
|
|
"print('Hello World')\n"
|
|
)
|
|
script.write_text(raw_code, encoding="utf-8")
|
|
|
|
mock_params = {
|
|
"noconsole": False,
|
|
"script_path": script.as_posix()
|
|
}
|
|
thread = main.PackingThread(mock_params)
|
|
sanitized_path, is_temp, err = thread.sanitize_script(script)
|
|
|
|
assert sanitized_path.exists()
|
|
new_content = sanitized_path.read_text(encoding="utf-8")
|
|
|
|
# 验证前部关键声明全部保留,且注入了 _qpypack_pause
|
|
assert "#!/usr/bin/env python" in new_content
|
|
assert "coding: utf-8" in new_content
|
|
assert "from __future__ import annotations" in new_content
|
|
assert "_qpypack_pause" in new_content
|
|
|
|
# 验证 pause 代码位于头部声明之后
|
|
pos_coding = new_content.find("coding: utf-8")
|
|
pos_pause = new_content.find("_qpypack_pause")
|
|
assert pos_coding < pos_pause, "pause 代码不应插在编码声明之前"
|
|
|
|
if is_temp:
|
|
sanitized_path.unlink(missing_ok=True)
|
|
|
|
log_pass("控制台暂停注入成功保留 Shebang 与 PEP 263 编码头声明")
|
|
finally:
|
|
safe_cleanup_dir(tmp_dir)
|
|
|
|
|
|
def test_05_complex_dependency_spec_parsing():
|
|
"""测试 5: 复合依赖规范字符串解析 (extras, @, 版本符号切分)"""
|
|
print(f"\n{Colors.BOLD}{Colors.CYAN}>>> [测试 5/8] 复合依赖包名正则表达式提取...{Colors.RESET}")
|
|
import re
|
|
|
|
test_specs = [
|
|
"uvicorn[standard]>=0.20.0",
|
|
"fastapi==0.100.0",
|
|
"requests~=2.31.0",
|
|
"my-pkg @ git+https://github.com/org/repo.git",
|
|
"certifi ; python_version >= '3.8'",
|
|
]
|
|
|
|
extracted = []
|
|
for pkg in test_specs:
|
|
cleaned = re.split(r"[=><!~@;\[]", pkg)[0].strip()
|
|
if cleaned:
|
|
extracted.append(cleaned)
|
|
|
|
assert extracted == ["uvicorn", "fastapi", "requests", "my-pkg", "certifi"]
|
|
log_pass(f"复合依赖包名提取完全符合预期: {extracted}")
|
|
|
|
|
|
def test_06_ui_settings_panel(init_qapp):
|
|
"""测试 6: 构建设置面板组件与参数状态双向绑定"""
|
|
print(f"\n{Colors.BOLD}{Colors.CYAN}>>> [测试 6/8] UI 设置面板及交互流转...{Colors.RESET}")
|
|
win = main.MainWindow()
|
|
panel = win.settings_panel
|
|
|
|
panel.rb_lite_mode.setChecked(True)
|
|
assert panel.rb_lite_mode.isChecked()
|
|
|
|
panel.engine_combo.setCurrentText("Nuitka")
|
|
assert panel.engine_combo.currentText() == "Nuitka"
|
|
|
|
panel._add_resource_item("file", "C:/dummy.txt", ".")
|
|
assert panel.add_data_list.count() == 1
|
|
panel.clear_resource()
|
|
assert panel.add_data_list.count() == 0
|
|
|
|
win.close()
|
|
log_pass("UI 核心交互与状态绑定测试通过")
|
|
|
|
|
|
def run_e2e_mock_build(engine_name: str, tmp_dir: Path, is_onefile: bool = True):
|
|
"""辅助函数: 创建模拟项目并执行全自动构建与拉起验证"""
|
|
app_dir = tmp_dir / "mock_app"
|
|
app_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
entry_py = app_dir / "app_main.py"
|
|
entry_py.write_text(
|
|
"import sys, os, time\n"
|
|
"if __name__ == '__main__':\n"
|
|
" print('BOOT_SUCCESS_OK')\n"
|
|
" time.sleep(0.1)\n"
|
|
" sys.exit(0)\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
reqs = app_dir / "requirements.txt"
|
|
reqs.write_text("# simple requirements\n", encoding="utf-8")
|
|
|
|
python_exe = main.get_python_executable()
|
|
assert python_exe and os.path.exists(python_exe), "未检测到可用的测试 Python 解释器"
|
|
|
|
params = {
|
|
"engine": engine_name,
|
|
"python_exe": python_exe,
|
|
"script_path": entry_py.as_posix(),
|
|
"project_folder": app_dir.as_posix(),
|
|
"enable_sign": False,
|
|
"cert_path": "",
|
|
"cert_pass": "",
|
|
"app_name": "app_main",
|
|
"onefile": is_onefile,
|
|
"contents_dir": "_internal",
|
|
"noconsole": True,
|
|
"icon": "",
|
|
"use_reqs": True,
|
|
"use_pipreqs": False,
|
|
"use_pipreqs_dir": False,
|
|
"reqs_file": reqs.as_posix(),
|
|
"hidden_imports": "",
|
|
"add_data_list": [],
|
|
"upx": False,
|
|
"upx_path": "",
|
|
"cpu_cores": 2,
|
|
"exclude_modules": "",
|
|
"out_mode": 0,
|
|
"custom_out_dir": "",
|
|
"temp_sandbox_mode": 0,
|
|
"use_venv": True,
|
|
"keep_venv": False,
|
|
"venv_mode": "isolated",
|
|
"shared_venv_dir": "",
|
|
"clean_all": True,
|
|
"version_file": None,
|
|
"temp_icon_file": None,
|
|
"ver_comp": "Qwesoft",
|
|
"ver_desc": "E2E Test App",
|
|
"ver_ver": "1.0.0",
|
|
"pip_index_url": "https://pypi.org/simple",
|
|
"pip_index_backup": "",
|
|
"concise_log": True,
|
|
"auto_save_log": False,
|
|
"lite_mode": True,
|
|
"pyi_version": "",
|
|
"nuitka_version": "",
|
|
"mappings": main.DEFAULT_MAPPINGS.copy(),
|
|
"enable_backport_shield": True,
|
|
"backport_rules": main.DEFAULT_BACKPORT_RULES.copy(),
|
|
}
|
|
|
|
thread = main.PackingThread(params)
|
|
results = []
|
|
logs = []
|
|
|
|
# 实时捕获日志
|
|
thread.progress.connect(lambda msg: logs.append(msg))
|
|
|
|
def on_finished(success, msg, failed_pkgs):
|
|
results.append((success, msg))
|
|
|
|
thread.build_finished.connect(on_finished)
|
|
thread.run()
|
|
|
|
assert len(results) > 0, "构建线程未返回结果"
|
|
success, msg = results[0]
|
|
|
|
if not success:
|
|
print("\n" + "!" * 30 + " NUITKA ERROR LOGS " + "!" * 30)
|
|
print("\n".join(logs))
|
|
print("!" * 79 + "\n")
|
|
|
|
assert success, f"{engine_name} 构建失败: {msg}"
|
|
|
|
ext = ".exe" if os.name == "nt" else ""
|
|
if is_onefile:
|
|
out_bin = app_dir / f"app_main{ext}"
|
|
else:
|
|
out_bin = app_dir / "app_main" / f"app_main{ext}"
|
|
|
|
assert out_bin.exists(), f"未找到生成的二进制可执行文件: {out_bin}"
|
|
|
|
# 实机运行验证
|
|
kw = {"capture_output": True, "text": True, "timeout": 10, "errors": "ignore"}
|
|
if os.name == "nt":
|
|
kw["creationflags"] = subprocess.CREATE_NO_WINDOW
|
|
|
|
proc = subprocess.run([out_bin.as_posix()], **kw)
|
|
assert proc.returncode == 0, f"构建产物启动异常退出,ReturnCode: {proc.returncode}"
|
|
assert "BOOT_SUCCESS_OK" in proc.stdout, f"产物输出未匹配到预期签名,标准输出: {proc.stdout}"
|
|
|
|
def test_07_pyinstaller_real_build(init_qapp):
|
|
"""测试 7: PyInstaller 真实构建与产物执行测试"""
|
|
print(f"\n{Colors.BOLD}{Colors.CYAN}>>> [测试 7/8] PyInstaller Lite 模式真实端到端打包...{Colors.RESET}")
|
|
tmp_dir = Path(tempfile.mkdtemp(prefix="qp_e2e_pyi_"))
|
|
try:
|
|
start_t = time.time()
|
|
run_e2e_mock_build("PyInstaller", tmp_dir, is_onefile=True)
|
|
log_pass(f"PyInstaller E2E 测试通过,耗时: {time.time() - start_t:.2f}s")
|
|
finally:
|
|
time.sleep(0.5)
|
|
init_qapp.processEvents()
|
|
safe_cleanup_dir(tmp_dir)
|
|
|
|
|
|
def test_08_nuitka_real_build(init_qapp):
|
|
"""测试 8: Nuitka 真实编译与产物执行测试 (防 WinError 5)"""
|
|
print(f"\n{Colors.BOLD}{Colors.CYAN}>>> [测试 8/8] Nuitka Lite 模式原生编译与实机验证...{Colors.RESET}")
|
|
try:
|
|
import nuitka
|
|
except ImportError:
|
|
log_warn("当前环境未安装 Nuitka,跳过实机编译测试")
|
|
return
|
|
|
|
tmp_dir = Path(tempfile.mkdtemp(prefix="qp_e2e_nuitka_"))
|
|
try:
|
|
start_t = time.time()
|
|
# 测试 Nuitka 文件夹独立模式 (Standalone)
|
|
run_e2e_mock_build("Nuitka", tmp_dir, is_onefile=False)
|
|
log_pass(f"Nuitka E2E 完整链路测试通过,耗时: {time.time() - start_t:.2f}s")
|
|
finally:
|
|
time.sleep(0.5)
|
|
init_qapp.processEvents()
|
|
safe_cleanup_dir(tmp_dir)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main(["-s", "-vv", __file__]) |