357 lines
19 KiB
Python
357 lines
19 KiB
Python
"""Run 52 synthetic notification regressions against notification modules from EXE PYZ.
|
|
|
|
Both tested modules and their lazy reply_session_policy helper use this same EXE.
|
|
|
|
Run only after the final EXE and both production tests have been synchronized:
|
|
C:\\wechat_rpa\\.build-venv\\Scripts\\python.exe <this-script>
|
|
|
|
No packages are installed. Only pure Python pytest dependencies may fall back to
|
|
Python 3.11 site-packages. All application state stays in a temporary directory;
|
|
unmocked external actions and skipped tests make the verification fail.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import contextlib
|
|
import hashlib
|
|
import importlib
|
|
import importlib.abc
|
|
import importlib.machinery
|
|
import importlib.util
|
|
import io
|
|
import json
|
|
import marshal
|
|
import os
|
|
from pathlib import Path
|
|
import socket
|
|
import sqlite3
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import traceback
|
|
import types
|
|
from unittest import mock
|
|
from urllib.parse import unquote, urlsplit
|
|
|
|
|
|
PROJECT = Path(r"C:\wechat_rpa")
|
|
FALLBACK = Path(r"C:\Users\isard\AppData\Local\Programs\Python\Python311\Lib\site-packages")
|
|
EXE_RELATIVE = "dist/ZhenAI-WeCom-Assistant-v1.4.26/ZhenAI-WeCom-Assistant-v1.4.26.exe"
|
|
FROZEN_MODULES = ("review_assistant_contacts", "review_assistant", "reply_session_policy")
|
|
PURE_FALLBACK = frozenset({"pytest", "_pytest", "pluggy", "iniconfig", "packaging", "pygments", "colorama"})
|
|
EXPECTED_TESTS = 52
|
|
|
|
|
|
def inside(path, root):
|
|
try:
|
|
Path(path).resolve().relative_to(root.resolve())
|
|
return True
|
|
except (TypeError, ValueError, OSError):
|
|
return False
|
|
|
|
|
|
def sha256(path):
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as stream:
|
|
for part in iter(lambda: stream.read(1024 * 1024), b""):
|
|
digest.update(part)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def allowed_fallback(name, origin, root):
|
|
if Path(origin).suffix.lower() not in (".py", ".pyc"):
|
|
return False
|
|
if name.split(".", 1)[0] in PURE_FALLBACK:
|
|
return True
|
|
# pytest 8.4 ships this shim in its own distribution RECORD. Reject the
|
|
# unrelated pylib package (py/__init__.py) if it has higher precedence.
|
|
return (name == "py" and Path(origin).resolve() == root / "py.py") or (
|
|
name in {"py.error", "py.path"} and inside(origin, root / "_pytest" / "_py"))
|
|
|
|
|
|
class PureFallbackOnly(importlib.abc.MetaPathFinder):
|
|
"""Prevent other CPython 3.11 packages/extensions entering a 3.12 process."""
|
|
def __init__(self, root):
|
|
self.root = root
|
|
|
|
def find_spec(self, fullname, path=None, target=None):
|
|
spec = importlib.machinery.PathFinder.find_spec(fullname, path, target)
|
|
if spec is None:
|
|
return None
|
|
locations = [spec.origin] if spec.origin not in (None, "built-in", "frozen") else []
|
|
locations += list(spec.submodule_search_locations or ())
|
|
if any(inside(location, self.root) for location in locations):
|
|
if not spec.origin or not allowed_fallback(fullname, spec.origin, self.root):
|
|
raise ImportError("Forbidden Python 3.11 fallback dependency: " + fullname)
|
|
return None
|
|
|
|
|
|
def fallback_inventory(root):
|
|
result = []
|
|
for name, module in tuple(sys.modules.items()):
|
|
origin = getattr(module, "__file__", None)
|
|
if origin and inside(origin, root):
|
|
if not allowed_fallback(name, origin, root):
|
|
raise RuntimeError("Unexpected Python 3.11 module: " + name)
|
|
result.append({"module": name, "path": str(origin)})
|
|
return sorted(result, key=lambda row: row["module"])
|
|
|
|
|
|
class FrozenCode(importlib.abc.MetaPathFinder, importlib.abc.Loader):
|
|
def __init__(self, code, exe, isolated_root):
|
|
self.code, self.exe, self.isolated_root = code, exe, isolated_root
|
|
|
|
def find_spec(self, fullname, path=None, target=None):
|
|
if fullname in self.code:
|
|
return importlib.util.spec_from_loader(fullname, self, origin=str(self.exe) + "!PYZ.pyz/" + fullname)
|
|
return None
|
|
|
|
def create_module(self, spec):
|
|
return None
|
|
|
|
def exec_module(self, module):
|
|
# Contacts includes __file__.parent in cache search paths. Keep it isolated
|
|
# while retaining the real EXE origin in __spec__ and this loader.
|
|
module.__file__ = str(self.isolated_root / (module.__name__ + ".py"))
|
|
exec(self.code[module.__name__], module.__dict__)
|
|
|
|
|
|
class Outcomes:
|
|
def __init__(self):
|
|
self.collected = []
|
|
self.reports = []
|
|
self.collection_errors = []
|
|
|
|
def pytest_collection_finish(self, session):
|
|
self.collected = [item.nodeid for item in session.items]
|
|
|
|
def pytest_collectreport(self, report):
|
|
if report.failed or report.skipped:
|
|
self.collection_errors.append({"test": report.nodeid, "outcome": report.outcome,
|
|
"detail": str(report.longrepr)})
|
|
|
|
def pytest_runtest_logreport(self, report):
|
|
self.reports.append({"test": report.nodeid, "phase": report.when, "outcome": report.outcome,
|
|
"xfail": str(getattr(report, "wasxfail", "")),
|
|
"detail": str(report.longrepr) if report.failed or report.skipped else ""})
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--project", type=Path, default=PROJECT)
|
|
parser.add_argument("--exe", type=Path)
|
|
parser.add_argument("--fallback", type=Path, default=FALLBACK)
|
|
parser.add_argument("--output", type=Path, default=Path(__file__).with_name("frozen-customer-notifications.json"))
|
|
args = parser.parse_args()
|
|
project, fallback = args.project.resolve(), args.fallback.resolve()
|
|
exe = (args.exe or project / EXE_RELATIVE).resolve()
|
|
output = args.output.resolve()
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
log = io.StringIO()
|
|
started = time.monotonic()
|
|
attempts = []
|
|
report = {"ok": False, "expected_tests": EXPECTED_TESTS, "python": sys.version,
|
|
"executable": sys.executable, "project": str(project), "exe": str(exe),
|
|
"blocked_external_actions": attempts, "tests_run": 0, "passed": 0,
|
|
"failures": 0, "skipped": 0, "plugin_autoload_disabled": True}
|
|
state = {"active": False}
|
|
initial_cwd = Path.cwd()
|
|
|
|
def reject(name):
|
|
attempts.append(name)
|
|
raise RuntimeError("Frozen notification audit blocks external action: " + name)
|
|
|
|
def blocked(name):
|
|
def call(*a, **kw):
|
|
return reject(name)
|
|
return call
|
|
|
|
try:
|
|
with contextlib.redirect_stdout(log), contextlib.redirect_stderr(log):
|
|
if sys.version_info[:2] != (3, 12):
|
|
raise RuntimeError("Use the production Python 3.12 build venv")
|
|
if not inside(sys.executable, project / ".build-venv"):
|
|
raise RuntimeError("Interpreter is not the production build venv")
|
|
tests = [project / "tests" / name for name in
|
|
("test_review_assistant.py", "test_review_customer_notifications.py")]
|
|
for path in [exe, *tests, fallback / "pytest" / "__init__.py"]:
|
|
if not path.is_file():
|
|
raise FileNotFoundError("Required finalized artifact is missing: " + str(path))
|
|
report["test_files"] = [{"path": str(path), "sha256": sha256(path)} for path in tests]
|
|
report["exe_sha256"] = sha256(exe)
|
|
sys.dont_write_bytecode = True
|
|
os.environ["QT_QPA_PLATFORM"] = "offscreen"
|
|
os.environ["PYTEST_DISABLE_PLUGIN_AUTOLOAD"] = "1"
|
|
os.environ.pop("PYTEST_PLUGINS", None)
|
|
os.environ.pop("PYTEST_ADDOPTS", None)
|
|
# Load native Qt BEFORE making any 3.11 dependencies discoverable.
|
|
from PySide6 import QtCore
|
|
import PySide6
|
|
import shiboken6
|
|
qt_modules = (PySide6, QtCore, shiboken6)
|
|
for module in qt_modules:
|
|
if not inside(module.__file__, project / ".build-venv"):
|
|
raise RuntimeError("Qt must come from the Python 3.12 build venv")
|
|
report["qt_dependencies"] = {module.__name__: module.__file__ for module in qt_modules}
|
|
fallback_guard = PureFallbackOnly(fallback)
|
|
sys.meta_path.insert(0, fallback_guard)
|
|
sys.path.append(str(fallback)) # No .pth execution, no installation.
|
|
dependencies = {name: importlib.import_module(name) for name in
|
|
("pytest", "pluggy", "iniconfig", "packaging", "pygments", "colorama")}
|
|
pytest = dependencies["pytest"]
|
|
report["pytest_dependencies"] = {name: {"path": module.__file__,
|
|
"version": str(getattr(module, "__version__", "")),
|
|
"from_311_fallback": inside(module.__file__, fallback)}
|
|
for name, module in dependencies.items()}
|
|
report["pytest_compatibility_shim"] = {"distribution": "pytest", "path": sys.modules["py"].__file__}
|
|
from PyInstaller.archive.readers import CArchiveReader
|
|
pyz = CArchiveReader(str(exe)).open_embedded_archive("PYZ.pyz")
|
|
code = {name: pyz.extract(name) for name in FROZEN_MODULES}
|
|
if any(not isinstance(value, types.CodeType) for value in code.values()):
|
|
raise RuntimeError("Required executable PYZ modules were not found")
|
|
report["frozen_modules"] = {name: {"origin": str(exe) + "!PYZ.pyz/" + name,
|
|
"code_sha256": hashlib.sha256(marshal.dumps(value)).hexdigest()} for name, value in code.items()}
|
|
with tempfile.TemporaryDirectory(prefix="frozen-customer-notify-", ignore_cleanup_errors=True) as temp, contextlib.ExitStack() as stack:
|
|
isolated = Path(temp).resolve()
|
|
os.chdir(isolated)
|
|
report["isolated_state"] = str(isolated)
|
|
stack.enter_context(mock.patch.dict(os.environ, {"LOCALAPPDATA": str(isolated),
|
|
"APPDATA": str(isolated), "TMP": str(isolated), "TEMP": str(isolated)}))
|
|
stack.enter_context(mock.patch.object(tempfile, "tempdir", str(isolated)))
|
|
# Runtime helpers are infrastructure, not either tested module.
|
|
sys.path.insert(0, str(project))
|
|
try:
|
|
runtime = importlib.import_module("runtime_paths")
|
|
finally:
|
|
sys.path.remove(str(project))
|
|
for name in ("application_data_dir", "resource_dir"):
|
|
stack.enter_context(mock.patch.object(runtime, name, return_value=isolated))
|
|
stack.enter_context(mock.patch.object(runtime, "resource_path", side_effect=lambda *parts: isolated.joinpath(*parts)))
|
|
for name in ("connect", "connect_ex", "send", "sendall", "sendto"):
|
|
stack.enter_context(mock.patch.object(socket.socket, name, blocked("socket." + name)))
|
|
stack.enter_context(mock.patch.object(socket, "getaddrinfo", blocked("socket.getaddrinfo")))
|
|
# Native modules are blocked underneath each test's explicit fakes.
|
|
native = types.ModuleType("wecom_native_sender")
|
|
native.NativeSender = blocked("native.NativeSender")
|
|
native.discover = blocked("native.discover")
|
|
native.readonly_build_validation_scope = contextlib.nullcontext
|
|
reply_db = types.ModuleType("reply_database")
|
|
reply_db.LiveReplyDatabase = type("BlockedLiveReplyDatabase", (),
|
|
{"_open_database": staticmethod(blocked("native.LiveReplyDatabase"))})
|
|
send_lock = types.ModuleType("send_lock")
|
|
send_lock.try_acquire = blocked("native.send_lock.acquire")
|
|
send_lock.release = blocked("native.send_lock.release")
|
|
substitutes = {"wecom_native_sender": native, "reply_database": reply_db, "send_lock": send_lock}
|
|
for module_name in ("pyautogui", "pyperclip", "win32gui", "win32api", "PIL.ImageGrab"):
|
|
module = types.ModuleType(module_name)
|
|
def get_attribute(name, prefix=module_name):
|
|
if name.startswith("__"):
|
|
raise AttributeError(name)
|
|
return blocked(prefix + "." + name)
|
|
module.__getattr__ = get_attribute
|
|
substitutes[module_name] = module
|
|
stack.enter_context(mock.patch.dict(sys.modules, substitutes))
|
|
|
|
def database_path(value):
|
|
text = os.fsdecode(value)
|
|
if text == ":memory:":
|
|
return isolated / "memory"
|
|
if text.startswith("file:"):
|
|
parsed = urlsplit(text)
|
|
if parsed.netloc:
|
|
return Path("//" + parsed.netloc + unquote(parsed.path))
|
|
text = unquote(parsed.path)
|
|
if len(text) > 2 and text[0] == "/" and text[2] == ":":
|
|
text = text[1:]
|
|
return Path(text)
|
|
|
|
real_connect = sqlite3.connect
|
|
def safe_connect(database, *a, **kw):
|
|
if not inside(database_path(database), isolated):
|
|
return reject("sqlite outside isolated state")
|
|
return real_connect(database, *a, **kw)
|
|
stack.enter_context(mock.patch.object(sqlite3, "connect", safe_connect))
|
|
private_roots = (project, Path(r"C:\kefu\wechat_rpa"),
|
|
Path(os.environ.get("USERPROFILE", str(isolated))) / "AppData/Local/ZhenYangTangRPA")
|
|
|
|
def audit(event, values):
|
|
if not state["active"]:
|
|
return
|
|
if event in {"subprocess.Popen", "os.system", "os.startfile", "os.startfile/2",
|
|
"ctypes.dlopen", "ctypes.dlsym", "socket.connect", "socket.bind", "socket.sendto"}:
|
|
reject(event)
|
|
if event == "sqlite3.connect" and not inside(database_path(values[0]), isolated):
|
|
reject("sqlite audit outside isolated state")
|
|
if event == "open" and isinstance(values[0], (str, bytes, os.PathLike)):
|
|
path = Path(os.fsdecode(values[0]))
|
|
if inside(path, isolated):
|
|
return
|
|
flags = values[2] if len(values) > 2 and isinstance(values[2], int) else 0
|
|
mode = values[1] if len(values) > 1 and isinstance(values[1], str) else ""
|
|
write = flags & (os.O_WRONLY | os.O_RDWR | os.O_CREAT | os.O_TRUNC | os.O_APPEND) or any(c in mode for c in "wax+")
|
|
if write:
|
|
reject("file write outside isolated state")
|
|
if (any(inside(path, root) for root in private_roots)
|
|
and not inside(path, project / ".build-venv")
|
|
and path.suffix.lower() not in {".py", ".pyc", ".pyd", ".dll"}):
|
|
reject("real application data read")
|
|
if event in {"os.remove", "os.rmdir", "os.mkdir", "os.rename"}:
|
|
paths = values[:2] if event == "os.rename" else values[:1]
|
|
if any(isinstance(path, (str, bytes, os.PathLike)) and not inside(os.fsdecode(path), isolated) for path in paths):
|
|
reject("filesystem mutation outside isolated state")
|
|
|
|
sys.addaudithook(audit)
|
|
state["active"] = True
|
|
frozen_loader = FrozenCode(code, exe, isolated)
|
|
if any(name in sys.modules for name in FROZEN_MODULES):
|
|
raise RuntimeError("Tested module was loaded before the frozen loader")
|
|
sys.meta_path.insert(0, frozen_loader)
|
|
frozen_loaded = {name: importlib.import_module(name) for name in FROZEN_MODULES}
|
|
for name, module in frozen_loaded.items():
|
|
if module.__loader__ is not frozen_loader:
|
|
raise RuntimeError("Source fallback detected: " + name)
|
|
ini = isolated / "pytest.ini"
|
|
ini.write_text("[pytest]\n", encoding="utf-8")
|
|
outcomes = Outcomes()
|
|
pytest_args = ["-q", "--strict-markers", "-p", "no:cacheprovider", "--noconftest",
|
|
"--import-mode=importlib", "--rootdir=" + str(isolated), "-c", str(ini),
|
|
"--basetemp=" + str(isolated / "pytest-tmp"),
|
|
"--log-file=" + str(isolated / "pytest.log"), *map(str, tests)]
|
|
report["pytest_args"] = pytest_args
|
|
report["pytest_exit_code"] = int(pytest.main(pytest_args, plugins=[outcomes]))
|
|
report["tests_run"] = len(outcomes.collected)
|
|
report["passed"] = sum(row["phase"] == "call" and row["outcome"] == "passed" for row in outcomes.reports)
|
|
report["skipped"] = sum(row["outcome"] == "skipped" or bool(row["xfail"]) for row in outcomes.reports)
|
|
report["failures"] = sum(row["outcome"] == "failed" for row in outcomes.reports)
|
|
report["collection_errors"] = outcomes.collection_errors
|
|
report["test_results"] = outcomes.reports
|
|
report["fallback_modules"] = fallback_inventory(fallback)
|
|
for name, module in frozen_loaded.items():
|
|
if sys.modules.get(name) is not module or module.__loader__ is not frozen_loader:
|
|
raise RuntimeError("Tested frozen module was not restored after test mocks: " + name)
|
|
report["ok"] = (report["pytest_exit_code"] == 0 and report["tests_run"] == EXPECTED_TESTS
|
|
and report["passed"] == EXPECTED_TESTS and report["skipped"] == 0
|
|
and report["failures"] == 0 and not outcomes.collection_errors and not attempts)
|
|
state["active"] = False
|
|
os.chdir(initial_cwd)
|
|
except BaseException:
|
|
state["active"] = False
|
|
report["error"] = traceback.format_exc()
|
|
log.write(report["error"])
|
|
report["ok"] = False
|
|
finally:
|
|
state["active"] = False
|
|
os.chdir(initial_cwd)
|
|
report["elapsed_seconds"] = round(time.monotonic() - started, 3)
|
|
output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
output.with_suffix(".log").write_text(log.getvalue(), encoding="utf-8")
|
|
print(json.dumps({key: report[key] for key in
|
|
("ok", "tests_run", "passed", "failures", "skipped", "blocked_external_actions", "elapsed_seconds")}, ensure_ascii=False))
|
|
print("Report: " + str(output))
|
|
print("Log: " + str(output.with_suffix(".log")))
|
|
return 0 if report["ok"] else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|