"""Run selected regression modules with isolated state and no external effects.""" from __future__ import annotations import argparse import contextlib import io import json import os import socket import sys import tempfile import time import unittest from pathlib import Path from unittest import mock parser = argparse.ArgumentParser() parser.add_argument("project") parser.add_argument("output") parser.add_argument("modules", nargs="*") args = parser.parse_args() project = Path(args.project).resolve() output = Path(args.output).resolve() output.parent.mkdir(parents=True, exist_ok=True) sys.path.insert(0, str(project)) sys.dont_write_bytecode = True os.environ["QT_QPA_PLATFORM"] = "offscreen" modules = args.modules or [ "test_visual_pipeline", "test_vision_fallback", "test_message_navigation", "test_self_reply_loop", "test_vision", "test_session_name", "test_unread_cross_machine", "test_reply_safety", ] attempts = [] def blocked(name): def reject(*a, **kw): attempts.append(name) raise RuntimeError("Audit blocks real external action: " + name) return reject original_connect = socket.socket.connect original_connect_ex = socket.socket.connect_ex def loopback_connect(self, address): if isinstance(address, tuple) and address[0] in ("127.0.0.1", "localhost", "::1"): return original_connect(self, address) return blocked("socket.connect")() def loopback_connect_ex(self, address): if isinstance(address, tuple) and address[0] in ("127.0.0.1", "localhost", "::1"): return original_connect_ex(self, address) return blocked("socket.connect_ex")() with tempfile.TemporaryDirectory(prefix="wechat-review-audit-", ignore_cleanup_errors=True) as temporary, contextlib.ExitStack() as stack: os.chdir(temporary) import runtime_paths stack.enter_context(mock.patch.object(runtime_paths, "application_data_dir", return_value=Path(temporary))) stack.enter_context(mock.patch.object(socket.socket, "connect", loopback_connect)) stack.enter_context(mock.patch.object(socket.socket, "connect_ex", loopback_connect_ex)) import requests stack.enter_context(mock.patch.object(requests.sessions.Session, "request", side_effect=blocked("HTTP request"))) import pyautogui for name in ("click", "doubleClick", "rightClick", "moveTo", "moveRel", "dragTo", "dragRel", "scroll", "hscroll", "vscroll", "press", "hotkey", "write", "typewrite", "keyDown", "keyUp", "mouseDown", "mouseUp", "screenshot"): if hasattr(pyautogui, name): stack.enter_context(mock.patch.object(pyautogui, name, side_effect=blocked("pyautogui." + name))) import pyperclip for name in ("copy", "paste"): stack.enter_context(mock.patch.object(pyperclip, name, side_effect=blocked("pyperclip." + name))) import win32gui for name in ("SetForegroundWindow", "SetWindowPos", "MoveWindow", "ShowWindow", "PostMessage", "SendMessage"): if hasattr(win32gui, name): stack.enter_context(mock.patch.object(win32gui, name, side_effect=blocked("win32gui." + name))) from PIL import ImageGrab stack.enter_context(mock.patch.object(ImageGrab, "grab", side_effect=blocked("ImageGrab.grab"))) # Guards stay below per-test mocks, so expected synthetic operations remain testable. import wechat_bot stack.enter_context(mock.patch.object(wechat_bot.ctypes.windll.user32, "PrintWindow", side_effect=blocked("PrintWindow"))) import send_lock stack.enter_context(mock.patch.object(send_lock, "_MUTEX_NAME", "Local\\VisualAuditSendLock-" + str(os.getpid()))) log = io.StringIO() started = time.monotonic() suite = unittest.defaultTestLoader.loadTestsFromNames(modules) with contextlib.redirect_stdout(log), contextlib.redirect_stderr(log): result = unittest.TextTestRunner(stream=log, verbosity=2).run(suite) report = { "project": str(project), "modules": modules, "tests_run": result.testsRun, "failures": len(result.failures), "errors": len(result.errors), "skipped": len(result.skipped), "elapsed_seconds": round(time.monotonic() - started, 3), "blocked_external_actions": attempts, "failure_details": [{"test": str(test), "trace": trace} for test, trace in result.failures + result.errors], "skip_details": [{"test": str(test), "reason": reason} for test, reason in result.skipped], } # Dispose native widgets and log handlers before removing isolated temp data. import logging try: from PySide6.QtCore import QCoreApplication, QEvent from PySide6.QtWidgets import QApplication app = QApplication.instance() if app: for widget in app.topLevelWidgets(): widget.close() widget.deleteLater() QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete) app.processEvents() finally: logging.shutdown() 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: value for key, value in report.items() if key not in ("failure_details", "skip_details")}, ensure_ascii=False)) for item in report["failure_details"]: print(item["test"] + "\n" + item["trace"]) exit_code = 0 if result.wasSuccessful() else 1 os.chdir(project) raise SystemExit(exit_code)