from __future__ import annotations import argparse import hashlib import os import shutil import subprocess import sys from pathlib import Path from typing import Sequence HARNESS_DIR = Path(__file__).resolve().parent WORKSPACE_ROOT = HARNESS_DIR.parents[2] SOURCE_DIR = WORKSPACE_ROOT / "核桃派软件源代码" VENV_DIR = HARNESS_DIR / ".venv" WORK_DIR = HARNESS_DIR / "work" DATA_DIR = HARNESS_DIR / "data" VENV_PYTHON = VENV_DIR / ("Scripts/python.exe" if os.name == "nt" else "bin/python") REQUIREMENTS_STAMP = VENV_DIR / ".requirements.sha256" REQUIREMENTS_FILES = ( SOURCE_DIR / "requirements.txt", SOURCE_DIR / "requirements-dev.txt", ) PYTEST_TARGETS = { "config": ( "tests/test_config_store.py", "tests/test_wifi.py", "tests/test_templates.py", "tests/test_animations.py", "tests/test_animation_preview_fixture.py", "tests/test_migrate_state_root.py", ), "display": ("tests/test_display_service.py",), "api": ("tests/test_api.py", "tests/test_fonts.py", "tests/test_ui_copy_editor.py"), "python": (), } SUITES = (*PYTEST_TARGETS, "frontend", "all") class HarnessError(RuntimeError): """A user-actionable local test environment error.""" def _display_command(command: Sequence[str]) -> str: return subprocess.list2cmdline([str(part) for part in command]) def _run( command: Sequence[str | Path], *, cwd: Path | None = None, env: dict[str, str] | None = None, ) -> None: normalized = [str(part) for part in command] print(f"> {_display_command(normalized)}", flush=True) subprocess.run(normalized, cwd=cwd, env=env, check=True) def _validate_layout() -> None: missing = [path for path in (*REQUIREMENTS_FILES, SOURCE_DIR / "app") if not path.exists()] if missing: joined = "、".join(str(path) for path in missing) raise HarnessError(f"无法定位项目文件:{joined}") def _requirements_fingerprint() -> str: digest = hashlib.sha256() for path in REQUIREMENTS_FILES: digest.update(path.name.encode("utf-8")) digest.update(b"\0") digest.update(path.read_bytes()) digest.update(b"\0") return digest.hexdigest() def setup_environment() -> None: _validate_layout() HARNESS_DIR.mkdir(parents=True, exist_ok=True) if VENV_DIR.exists() and not VENV_PYTHON.is_file(): raise HarnessError( f"持久环境存在但 Python 不可用:{VENV_PYTHON}。" "请先把损坏的 .venv 改名留作检查,再手动删除或移走它,然后重新运行 setup;" "工具不会自动删除现有环境。" ) if not VENV_PYTHON.is_file(): print(f"创建持久测试环境:{VENV_DIR}", flush=True) _run((sys.executable, "-m", "venv", VENV_DIR)) fingerprint = _requirements_fingerprint() recorded = ( REQUIREMENTS_STAMP.read_text(encoding="ascii").strip() if REQUIREMENTS_STAMP.is_file() else "" ) if recorded != fingerprint: print("依赖首次安装或 requirements 已变化,正在从官方 PyPI 同步。", flush=True) _run( ( VENV_PYTHON, "-m", "pip", "install", "--disable-pip-version-check", "--upgrade", "--index-url", "https://pypi.org/simple", "-r", REQUIREMENTS_FILES[-1], ) ) _run((VENV_PYTHON, "-m", "pip", "check")) REQUIREMENTS_STAMP.write_text(f"{fingerprint}\n", encoding="ascii") print("依赖同步完成,已记录 requirements 哈希。", flush=True) else: print("requirements 未变化,跳过依赖安装。", flush=True) _run((VENV_PYTHON, "-m", "pip", "check")) def _base_environment() -> dict[str, str]: WORK_DIR.mkdir(parents=True, exist_ok=True) env = os.environ.copy() env["MATRIX_DRIVER"] = "mock" env["PYTHONPYCACHEPREFIX"] = str(WORK_DIR / "pycache") return env def _test_environment() -> dict[str, str]: env = _base_environment() # Tests that construct an explicit project root remain isolated per case. # The process-level paths below protect module-level application imports # from ever reading or migrating either source-tree or manual mock data. test_data_dir = WORK_DIR / "test-app-data" env["MATRIX_TEST_ROOT"] = str(test_data_dir) env["MATRIX_DATA_DIR"] = str(test_data_dir) env["MATRIX_RUNTIME_DIR"] = str(test_data_dir / "runtime") for name in ("STATE_DIRECTORY", "RUNTIME_DIRECTORY"): env.pop(name, None) return env def _serve_environment() -> dict[str, str]: DATA_DIR.mkdir(parents=True, exist_ok=True) env = _base_environment() env["MATRIX_DATA_DIR"] = str(DATA_DIR) env["MATRIX_RUNTIME_DIR"] = str(DATA_DIR / "runtime") return env def _remove_path(path: Path) -> bool: if not path.exists() and not path.is_symlink(): return False if path.is_dir() and not path.is_symlink(): shutil.rmtree(path) else: path.unlink() print(f"已清理:{path}", flush=True) return True def _assert_within(path: Path, root: Path) -> None: resolved_path = path.resolve(strict=False) resolved_root = root.resolve(strict=False) try: resolved_path.relative_to(resolved_root) except ValueError as exc: raise HarnessError(f"拒绝处理工作区外路径:{resolved_path}") from exc def _prepare_pytest_temp() -> Path: pytest_temp = WORK_DIR / "pytest" _assert_within(pytest_temp, HARNESS_DIR) _remove_path(pytest_temp) pytest_temp.mkdir(parents=True, exist_ok=True) return pytest_temp def _run_pytest(suite: str, env: dict[str, str]) -> None: pytest_temp = _prepare_pytest_temp() command: list[str | Path] = [ VENV_PYTHON, "-m", "pytest", "-q", "-p", "no:cacheprovider", f"--basetemp={pytest_temp}", ] command.extend(PYTEST_TARGETS[suite]) _run(command, cwd=SOURCE_DIR, env=env) def _run_frontend_tests(env: dict[str, str]) -> None: node = shutil.which("node") if not node: raise HarnessError("未找到 Node.js;frontend/all 套件需要可执行的 node。") _run( ( node, "--test", "tests/test_animation_ui.mjs", "tests/test_canvas_tools.mjs", "tests/test_color_math.mjs", "tests/test_current_display_playback_ui.mjs", "tests/test_device_ui.mjs", "tests/test_font_picker.mjs", "tests/test_frp_ui.mjs", "tests/test_interaction_feedback.mjs", "tests/test_scene_model.mjs", "tests/test_scene_layers.mjs", "tests/test_settings_ui.mjs", "tests/test_template_management_ui.mjs", "tests/test_ui_copy_editor.mjs", "tests/test_wifi_ui.mjs", "tests/test_workspace_order_ui.mjs", ), cwd=SOURCE_DIR, env=env, ) javascript_files = sorted((SOURCE_DIR / "app" / "static").rglob("*.js")) if not javascript_files: raise HarnessError("未找到 app/static 下的 JavaScript 文件。") for path in javascript_files: _run((node, "--check", path), cwd=SOURCE_DIR, env=env) _run((node, "--check", "app/ui_copy_editor/static/editor.js"), cwd=SOURCE_DIR, env=env) _run((VENV_PYTHON, "scripts/build_ui_copy_catalog.py", "--check"), cwd=SOURCE_DIR, env=env) def run_tests(suite: str) -> None: setup_environment() env = _test_environment() if suite == "all": _run_pytest("python", env) _run_frontend_tests(env) elif suite == "frontend": _run_frontend_tests(env) else: _run_pytest(suite, env) print(f"本机测试套件通过:{suite}", flush=True) def serve(port: int) -> None: setup_environment() env = _serve_environment() print(f"本机 mock 页面:http://127.0.0.1:{port}", flush=True) try: _run( ( VENV_PYTHON, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", str(port), ), cwd=SOURCE_DIR, env=env, ) except KeyboardInterrupt: print("本机 mock 服务已停止。", flush=True) def clean() -> None: _validate_layout() fixed_targets = [ WORK_DIR, DATA_DIR / "runtime", SOURCE_DIR / ".pytest_cache", SOURCE_DIR / ".coverage", SOURCE_DIR / "coverage.xml", WORKSPACE_ROOT / ".playwright-cli", WORKSPACE_ROOT / "output" / "playwright", HARNESS_DIR / "__pycache__", ] for target in fixed_targets: _assert_within(target, WORKSPACE_ROOT) cache_dirs: list[Path] = [] for root in (SOURCE_DIR / "app", SOURCE_DIR / "scripts", SOURCE_DIR / "tests"): if root.is_dir(): cache_dirs.extend(root.rglob("__pycache__")) for target in cache_dirs: _assert_within(target, SOURCE_DIR) removed = 0 for target in sorted(cache_dirs, key=lambda item: len(item.parts), reverse=True): removed += int(_remove_path(target)) for target in fixed_targets: removed += int(_remove_path(target)) if not VENV_PYTHON.is_file(): raise HarnessError("清理后未找到持久 .venv Python;清理验收失败。") if not REQUIREMENTS_STAMP.is_file(): raise HarnessError("清理后依赖哈希不存在;清理验收失败。") if (DATA_DIR / "config.json").exists(): print("已保留:data/config.json", flush=True) print(f"清理完成,共移除 {removed} 个可再生目标;.venv 已保留。", flush=True) def _port(value: str) -> int: port = int(value) if not 1 <= port <= 65535: raise argparse.ArgumentTypeError("端口必须在 1..65535。") return port def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="奇妙小屏幕控制器本机持久测试工具") subparsers = parser.add_subparsers(dest="command", required=True) subparsers.add_parser("setup", help="创建或更新持久 .venv") test_parser = subparsers.add_parser("test", help="运行所需的本机测试套件") test_parser.add_argument("--suite", choices=SUITES, default="all") serve_parser = subparsers.add_parser("serve", help="以前台方式启动 mock 网页") serve_parser.add_argument("--port", type=_port, default=8765) subparsers.add_parser("clean", help="清理可再生产物,保留 .venv 和本机配置") return parser def main(argv: Sequence[str] | None = None) -> int: args = _parser().parse_args(argv) try: if args.command == "setup": setup_environment() elif args.command == "test": run_tests(args.suite) elif args.command == "serve": serve(args.port) elif args.command == "clean": clean() else: raise HarnessError(f"未知命令:{args.command}") except (HarnessError, OSError, subprocess.CalledProcessError) as exc: print(f"本机测试工具失败:{exc}", file=sys.stderr, flush=True) return 1 return 0 if __name__ == "__main__": raise SystemExit(main())