Files
Python-Profiler-Visualizer/engine/tests/test_runner.py
2026-09-12 14:19:56 +08:00

158 lines
6.9 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
import os
import subprocess
import sys
FX = os.path.join(os.path.dirname(__file__), "fixtures")
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
def _run(script, *extra):
# 默认关掉 hide_internal这些测试是结构 / 状态 / 形状的契约测试,
# 要看到 fixture 里所有 frame 才能断言。hide_internal 是 UI 默认开的产品行为,
# 不是引擎协议的一部分 —— 测协议就别测产品默认。
p = subprocess.run(
[sys.executable, "-m", "engine.runner", "--script", script,
"--no-hide-internal", *extra],
capture_output=True, text=True, cwd=ROOT,
)
assert p.stdout.strip(), f"no stdout; stderr={p.stderr}"
return json.loads(p.stdout.strip().splitlines()[-1])
def test_ok_run():
data = _run(os.path.join(FX, "nested_calls.py"))
assert data["status"] == "ok"
assert data["wallTime"]["seconds"] > 0
assert data["wallTime"]["unit"] == "s"
assert len(data["functions"]) >= 3
def test_syntax_error(tmp_path):
bad = tmp_path / "syntax_error.py"
bad.write_text("def f(:\n pass\n", encoding="utf-8")
data = _run(str(bad))
assert data["status"] == "syntax_error"
assert data["error"]["type"]
def test_runtime_error(tmp_path):
bad = tmp_path / "runtime_error.py"
bad.write_text("raise ValueError('boom')\n", encoding="utf-8")
data = _run(str(bad))
assert data["status"] == "runtime_error"
assert "boom" in data["error"]["message"]
def test_sys_exit_becomes_runtime_error(tmp_path):
"""sys.exit() 抛 SystemExitBaseException 子类),之前 except Exception 抓不到,
子进程裸退出无 JSON → UI 显示 "引擎无输出"。现在应被显式捕获并归为 runtime_error。"""
bad = tmp_path / "exit.py"
bad.write_text("import sys\nsys.exit(7)\n", encoding="utf-8")
data = _run(str(bad))
assert data["status"] == "runtime_error"
assert "SystemExit" in data["error"]["type"]
assert "7" in data["error"]["message"]
def test_io_error_returns_runtime_error(tmp_path):
"""脚本文件不存在 / 不可读时,不要让子进程裸崩 — 应该回 runtime_error 给出有用信息。"""
missing = tmp_path / "does_not_exist.py"
data = _run(str(missing))
assert data["status"] == "runtime_error"
assert "FileNotFoundError" in data["error"]["type"] or "无法读取" in data["error"]["message"]
def test_bom_script_runs_ok(tmp_path):
"""带 BOM 的 UTF-8 脚本Windows 记事本默认保存格式)必须能正常跑。
回归测试runner 曾用 encoding="utf-8" 读源码BOM 的  前缀残留下来,
语法预检的 compile() 直接抛 SyntaxError: invalid character用户看到的是
"语法错误"而不是正常结果 —— 而 harness.py 早就用 utf-8-sig 处理对了,
两处不一致导致这个 bug 只在预检阶段出现。
"""
script = tmp_path / "bom.py"
script.write_text("x = sum(range(1000))\n", encoding="utf-8-sig")
# 确认 fixture 真的带 BOM否则这个测试会静默失效
assert script.read_bytes().startswith(b"\xef\xbb\xbf")
data = _run(str(script))
assert data["status"] == "ok", f"BOM 脚本被误判: {data.get('error')}"
def test_binary_script_returns_runtime_error(tmp_path):
"""非 UTF-8 / 二进制文件应回结构化 runtime_error而不是让引擎裸崩。
回归测试UnicodeDecodeError 不是 OSError 的子类,之前只 except OSError
的那层保护抓不到它,子进程崩溃且无 JSON 输出 → UI 显示"引擎无输出"
"""
script = tmp_path / "binary.py"
script.write_bytes(b"\x00\x01\x02\xff\xfe\xfd binary garbage \x80\x81")
data = _run(str(script))
assert data["status"] in ("runtime_error", "syntax_error")
assert data["error"]["type"]
def test_scope_default_is_user():
"""默认 --scope=user —— 不给 scope 参数时只看到用户脚本里的函数。"""
script = os.path.join(FX, "with_imports.py")
data = _run(script)
assert data["status"] == "ok"
files = {f["file"] for f in data["functions"]}
# user 模式:不应出现 json 模块的内部帧(虽然 fixture 里 import 了 json
json_internal = {f for f in files if "json" in f and "decoder" in f or "json" in f and "encoder" in f}
assert not json_internal, f"默认 scope 应为 user但出现了 json 内部帧: {json_internal}"
def test_scope_all_includes_library_frames():
"""--scope=all让耗时可以下钻到 import 的包里 —— json 模块的函数也应进入结果。"""
script = os.path.join(FX, "with_imports.py")
data = _run(script, "--scope", "all")
assert data["status"] == "ok"
files = {f["file"] for f in data["functions"]}
# scope=all 时至少有一个 json 相关的文件路径进来 —— 说明 import 的包被归因了
has_json = any("json" in f for f in files)
assert has_json, f"scope=all 应包含 json 模块帧,实际 files: {files}"
def test_user_side_effects_run_once(tmp_path):
"""v4 单跑架构核心回归:用户脚本里的副作用只执行一次。
v3 时期 runner 跑两次(裸跑测 wall_time + cProfile 跑),
用户脚本里 plot()、print()、写文件等副作用也跟着跑两次 —— 经典"plot 弹两个窗口"
bug 的根因。v4 改成单次 exec(code) under cProfile + 校准系数折算 wall-time 后,
副作用应只发生 1 次。
检测手段:让用户脚本 append 一行到文件exec 一次就一行,跑两次就两行。
读 marker 文件的行数即可判断 exec 次数。
"""
marker = tmp_path / "marker.txt"
script = tmp_path / "s.py"
# 用 raw 字符串避 Windows 反斜杠麻烦append 模式保证两次跑不会被覆盖
script.write_text(
"with open(r'" + str(marker) + "', 'a', encoding='utf-8') as f:\n"
" f.write('X\\n')\n",
encoding="utf-8",
)
# 直接 spawn runner 子进程(不走 _run helper —— 那个 helper 是从 stdout 解析 JSON
# 的,我们这次主要观察 marker 文件)
out_path = tmp_path / "result.json"
p = subprocess.run(
[sys.executable, "-m", "engine.runner",
"--script", str(script), "--out", str(out_path)],
capture_output=True, text=True, cwd=ROOT,
)
assert p.returncode == 0, f"runner 失败:{p.stderr}"
assert marker.exists(), "用户脚本副作用根本没执行"
lines = marker.read_text(encoding="utf-8").splitlines()
assert len(lines) == 1, (
f"副作用被多次执行(找到 {len(lines)}v4 单跑架构回归:{lines}"
)
# 同时验证 result.json 里 calibration 字段在 + wallTime 被校准
result = json.loads(out_path.read_text(encoding="utf-8"))
assert result["status"] == "ok"
assert "calibration" in result and result["calibration"] is not None
assert result["calibration"]["ratio"] >= 1.0
assert result["wallTime"]["seconds"] > 0