# -*- coding: utf-8 -*- r'''在**隐藏的 Windows 桌面**上跑命令,用户屏幕不受打扰。 == 为什么需要 == 用户原话:「把全量测试改为在后台静默运行不要打开任何窗口或锁屏」。 有几个测试必须开**真实的窗口**才测得出东西,不能改成假的: test_image_modes 投图窗口的三种显示方式(含**全屏**) test_indicator_visible 屏幕共享提示条 + Win32 窗口枚举 fuzz_features 功能模糊测试 test_first_run_setup 首次设置窗口 问题是它们跑的时候会在用户屏幕上一通闪,**全屏那个还会把屏幕锁住**, 用户根本没法用电脑。 == 解法 == Windows 支持多个"桌面"(Desktop)—— 同一个登录会话里可以有若干个, 但**只有一个是当前显示给用户的**(叫 Default)。 我们新建一个桌面,把测试进程放上去: CreateDesktopW("ykxmz-test", ...) 建一个不显示的桌面 CreateProcessW(..., lpDesktop=那个名字) 把子进程放上去 子进程里所有窗口都创建在那个桌面上 -> 用户完全看不到 跑完 CloseDesktop 这样窗口是**真的**(窗口句柄、几何、可见性、Win32 枚举全都真实), 只是不在用户眼前 —— 测试有效性一点没损失。 == 为什么不用别的方法 == * "把窗口设成透明/最小化" —— 那就测不出全屏了,而且还是会闪 * "跳过这些测试" —— 投图和提示条是核心功能,不能因为烦就不测 * "用无头模式" —— 那测的就不是真实窗口了 == 用法 == 命令行: python _tests/hidden_desktop.py -- <要跑的命令...> 代码里: from hidden_desktop import run_hidden code, out = run_hidden(["python", "test_image_modes.py"], timeout=300) ''' from __future__ import annotations import ctypes import ctypes.wintypes as wt import os import subprocess import sys import tempfile import time import uuid from pathlib import Path IS_WINDOWS = os.name == "nt" # ---- Win32 常量和结构 ---- # 桌面权限:给足,否则 CreateProcess 放上去之后子进程建不了窗口 DESKTOP_ALL_ACCESS = 0x000F01FF GENERIC_ALL = 0x10000000 CREATE_NEW_CONSOLE = 0x00000010 CREATE_UNICODE_ENVIRONMENT = 0x00000400 WAIT_TIMEOUT = 0x00000102 INFINITE = 0xFFFFFFFF class STARTUPINFOW(ctypes.Structure): _fields_ = [ ("cb", wt.DWORD), ("lpReserved", wt.LPWSTR), ("lpDesktop", wt.LPWSTR), ("lpTitle", wt.LPWSTR), ("dwX", wt.DWORD), ("dwY", wt.DWORD), ("dwXSize", wt.DWORD), ("dwYSize", wt.DWORD), ("dwXCountChars", wt.DWORD), ("dwYCountChars", wt.DWORD), ("dwFillAttribute", wt.DWORD), ("dwFlags", wt.DWORD), ("wShowWindow", wt.WORD), ("cbReserved2", wt.WORD), ("lpReserved2", ctypes.POINTER(ctypes.c_byte)), ("hStdInput", wt.HANDLE), ("hStdOutput", wt.HANDLE), ("hStdError", wt.HANDLE), ] class PROCESS_INFORMATION(ctypes.Structure): _fields_ = [ ("hProcess", wt.HANDLE), ("hThread", wt.HANDLE), ("dwProcessId", wt.DWORD), ("dwThreadId", wt.DWORD), ] def _api(): user32 = ctypes.WinDLL("user32", use_last_error=True) kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) user32.CreateDesktopW.argtypes = [ wt.LPCWSTR, wt.LPCWSTR, ctypes.c_void_p, wt.DWORD, wt.DWORD, ctypes.c_void_p] user32.CreateDesktopW.restype = wt.HANDLE user32.CloseDesktop.argtypes = [wt.HANDLE] user32.CloseDesktop.restype = wt.BOOL kernel32.CreateProcessW.argtypes = [ wt.LPCWSTR, wt.LPWSTR, ctypes.c_void_p, ctypes.c_void_p, wt.BOOL, wt.DWORD, ctypes.c_void_p, wt.LPCWSTR, ctypes.POINTER(STARTUPINFOW), ctypes.POINTER(PROCESS_INFORMATION)] kernel32.CreateProcessW.restype = wt.BOOL kernel32.WaitForSingleObject.argtypes = [wt.HANDLE, wt.DWORD] kernel32.WaitForSingleObject.restype = wt.DWORD kernel32.GetExitCodeProcess.argtypes = [wt.HANDLE, ctypes.POINTER(wt.DWORD)] kernel32.GetExitCodeProcess.restype = wt.BOOL kernel32.TerminateProcess.argtypes = [wt.HANDLE, wt.UINT] kernel32.CloseHandle.argtypes = [wt.HANDLE] return user32, kernel32 def run_hidden(command, timeout: float = 600.0, cwd: str | None = None, log_path: Path | None = None) -> tuple: """在隐藏桌面上跑命令。 返回 (退出码, 输出文本)。退出码 -1 表示建桌面失败(会自动退回 直接在普通桌面跑,并说明原因 —— 宁可闪一下也不能让测试跑不了)。 """ if not IS_WINDOWS: # 非 Windows 直接跑 r = subprocess.run(command, cwd=cwd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout) return r.returncode, (r.stdout or "") + (r.stderr or "") if log_path is None: log_path = Path(tempfile.gettempdir()) / ( "ykxmz_hidden_%s.log" % uuid.uuid4().hex[:8]) user32, kernel32 = _api() name = "ykxmz-test-%s" % uuid.uuid4().hex[:8] desk = user32.CreateDesktopW(name, None, None, 0, DESKTOP_ALL_ACCESS | GENERIC_ALL, None) if not desk: err = ctypes.get_last_error() # 建不了桌面(少见,通常是权限)—— 退回普通方式,但要说清楚 r = subprocess.run(command, cwd=cwd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout) msg = (" [警告] 建隐藏桌面失败(Win32 错误 %d)," "这次只能在普通桌面上跑 —— 屏幕上可能会闪窗口。\n" % err) return r.returncode, msg + (r.stdout or "") + (r.stderr or "") try: # 让子进程自己把输出重定向到文件 —— 走 CreateProcess 时 # 管道不好接管,重定向到文件最简单可靠。 # 用 cmd /c 是为了让 > 生效。 quoted = subprocess.list2cmdline([str(c) for c in command]) full = 'cmd.exe /c %s > "%s" 2>&1' % (quoted, log_path) # cmd /c 后面整串要作为一个参数传 cmdline = ctypes.create_unicode_buffer(full) si = STARTUPINFOW() si.cb = ctypes.sizeof(STARTUPINFOW) si.lpDesktop = name # <<< 关键:把子进程放到隐藏桌面 pi = PROCESS_INFORMATION() ok = kernel32.CreateProcessW( None, cmdline, None, None, False, CREATE_NEW_CONSOLE | CREATE_UNICODE_ENVIRONMENT, None, cwd, ctypes.byref(si), ctypes.byref(pi)) if not ok: err = ctypes.get_last_error() r = subprocess.run(command, cwd=cwd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout) return r.returncode, (" [警告] 在隐藏桌面上启动失败" "(Win32 错误 %d)\n" % err ) + (r.stdout or "") + (r.stderr or "") try: waited = kernel32.WaitForSingleObject(pi.hProcess, int(timeout * 1000)) if waited == WAIT_TIMEOUT: kernel32.TerminateProcess(pi.hProcess, 124) code = 124 else: code = wt.DWORD() kernel32.GetExitCodeProcess(pi.hProcess, ctypes.byref(code)) code = code.value finally: kernel32.CloseHandle(pi.hThread) kernel32.CloseHandle(pi.hProcess) # 给文件一点时间落盘 for _ in range(40): if log_path.is_file() and log_path.stat().st_size > 0: break time.sleep(0.05) try: out = log_path.read_text(encoding="utf-8", errors="replace") except OSError: out = "" return code, out finally: user32.CloseDesktop(desk) try: log_path.unlink() except OSError: pass def main() -> int: if not IS_WINDOWS: print(" 这个工具只在 Windows 上有意义") return 1 args = sys.argv[1:] if args and args[0] == "--": args = args[1:] if not args: print(__doc__) return 1 print(" 在隐藏桌面上运行:%s" % " ".join(args)) started = time.time() code, out = run_hidden(args, timeout=900) took = time.time() - started # 原样把输出打出来(套件靠它判断) sys.stdout.write(out) if not out.endswith("\n"): sys.stdout.write("\n") # ⚠ 总结这行**只在终端里打**。 # 套件是用 `*> 日志文件` 接输出的,再 `Select-Object -Last 2/3` # 取摘要 —— 如果这里多打一行,就会把真正的摘要顶掉, # 汇总表上显示的是"隐藏桌面运行结束"而不是测试结果。 if sys.stdout.isatty(): print(" —— 隐藏桌面运行结束:退出码 %d,用时 %.1f 秒" % (code, took)) return code if __name__ == "__main__": sys.exit(main())