Files
mail/scripts/test-clean-exit.py
T

205 lines
7.1 KiB
Python

#!/usr/bin/env python3
"""Close the app from its window and require a clean exit.
Usage: python scripts/test-clean-exit.py target/package/windows/flectar-mail.exe
Starts the app with the current user's profile (a fresh one on CI), waits
until its local data is ready, posts WM_CLOSE to its window the way the title
bar's close button does, and fails unless the process then exits with code 0.
Only a close from the window runs the full shutdown: ending the event loop
while the window is still shown keeps the window's state alive. A profile
that this run created is removed afterwards, so later checks, such as the
Store package test, start from a clean profile. Windows only.
"""
import ctypes
import json
import os
import queue
import shutil
import subprocess
import sys
import threading
import time
from pathlib import Path
METRIC_PREFIX = "FLECTAR_STARTUP_METRIC "
READY_EVENT = "core_ready_frame"
READY_SECONDS = 180
SETTLE_SECONDS = 2
EXIT_SECONDS = 30
WM_CLOSE = 0x0010
GW_OWNER = 4
GWL_EXSTYLE = -20
WS_EX_TOOLWINDOW = 0x00000080
def read_lines(stream, lines):
for line in iter(stream.readline, b""):
lines.put(line.decode("utf-8", errors="replace").rstrip())
lines.put(None)
def wait_until_ready(process, lines, output):
deadline = time.monotonic() + READY_SECONDS
while time.monotonic() < deadline:
try:
line = lines.get(timeout=0.5)
except queue.Empty:
continue
if line is None:
process.wait()
fail(f"the app exited with {exit_code(process.returncode)} before it was ready", output)
output.append(line)
if line.startswith(METRIC_PREFIX):
try:
event = json.loads(line[len(METRIC_PREFIX):]).get("event")
except ValueError:
continue
if event == READY_EVENT:
return
fail(f"the app was not ready within {READY_SECONDS} s", output)
def drain(lines, output, seconds):
deadline = time.monotonic() + seconds
while (remaining := deadline - time.monotonic()) > 0:
try:
line = lines.get(timeout=remaining)
except queue.Empty:
return
if line is None:
return
output.append(line)
def app_windows(pid):
"""The process's windows that the taskbar lists, which its close button
and Task Manager's End task close.
Helper windows are left alone. winit keeps a visible tool window for its
own messages, and closing it would destroy it along with the event loop's
wakeups, so the app could never quit.
"""
from ctypes import wintypes
user32 = ctypes.WinDLL("user32", use_last_error=True)
enum_proc = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
user32.EnumWindows.argtypes = [enum_proc, wintypes.LPARAM]
user32.EnumWindows.restype = wintypes.BOOL
user32.GetWindowThreadProcessId.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.DWORD)]
user32.GetWindowThreadProcessId.restype = wintypes.DWORD
user32.IsWindowVisible.argtypes = [wintypes.HWND]
user32.IsWindowVisible.restype = wintypes.BOOL
user32.GetWindow.argtypes = [wintypes.HWND, wintypes.UINT]
user32.GetWindow.restype = wintypes.HWND
user32.GetWindowLongW.argtypes = [wintypes.HWND, ctypes.c_int]
user32.GetWindowLongW.restype = wintypes.LONG
windows = []
def collect(hwnd, _):
process = wintypes.DWORD()
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(process))
if (
process.value == pid
and user32.IsWindowVisible(hwnd)
and not user32.GetWindow(hwnd, GW_OWNER)
and not user32.GetWindowLongW(hwnd, GWL_EXSTYLE) & WS_EX_TOOLWINDOW
):
windows.append(hwnd)
return True
if not user32.EnumWindows(enum_proc(collect), 0):
raise ctypes.WinError(ctypes.get_last_error())
return windows
def close_windows(pid):
from ctypes import wintypes
user32 = ctypes.WinDLL("user32", use_last_error=True)
user32.PostMessageW.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM]
user32.PostMessageW.restype = wintypes.BOOL
windows = app_windows(pid)
for hwnd in windows:
if not user32.PostMessageW(hwnd, WM_CLOSE, 0, 0):
raise ctypes.WinError(ctypes.get_last_error())
return len(windows)
def profile_dirs():
"""The desktop profile: data under %APPDATA%, caches under %LOCALAPPDATA%."""
return [Path(os.environ[name]) / "flectar-mail" for name in ("APPDATA", "LOCALAPPDATA")]
def remove(paths):
for path in paths:
if path.exists():
try:
shutil.rmtree(path)
except OSError as error:
print(f"warning: could not remove {path}: {error}", file=sys.stderr)
def exit_code(code):
return f"{code & 0xFFFFFFFF:#010x}" if code < 0 or code > 255 else str(code)
def fail(message, output):
print(f"FAIL: {message}", file=sys.stderr)
print("\n".join(output[-40:]), file=sys.stderr)
sys.exit(1)
def main(binary):
if os.name != "nt":
sys.exit("test-clean-exit.py closes a native Windows window; run it on Windows")
environment = dict(os.environ, FLECTAR_STARTUP_METRICS="1")
# Only a profile this run creates is removed, never one that was there.
created = [path for path in profile_dirs() if not path.exists()]
process = subprocess.Popen(
[binary],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
env=environment,
)
lines = queue.Queue()
threading.Thread(target=read_lines, args=(process.stderr, lines), daemon=True).start()
output = []
try:
started = time.monotonic()
wait_until_ready(process, lines, output)
ready = time.monotonic() - started
time.sleep(SETTLE_SECONDS)
if process.poll() is not None:
drain(lines, output, 5)
fail(f"the app exited with {exit_code(process.returncode)} before it was closed", output)
if close_windows(process.pid) == 0:
fail("the app showed no window to close", output)
closed = time.monotonic()
try:
process.wait(timeout=EXIT_SECONDS)
except subprocess.TimeoutExpired:
drain(lines, output, 1)
window = "its window is still open" if app_windows(process.pid) else "its window closed"
fail(f"the app did not exit within {EXIT_SECONDS} s of WM_CLOSE ({window})", output)
exited = time.monotonic() - closed
drain(lines, output, 5)
if process.returncode != 0:
fail(f"the app exited with {exit_code(process.returncode)} after WM_CLOSE", output)
if any("' panicked at " in line for line in output):
fail("the app panicked while closing", output)
print(f"PASS: ready after {ready:.1f} s, exited cleanly {exited:.1f} s after WM_CLOSE")
finally:
if process.poll() is None:
process.kill()
process.wait()
remove(created)
if __name__ == "__main__":
if len(sys.argv) != 2:
sys.exit(__doc__)
main(sys.argv[1])