mirror of
https://github.com/flectar/mail.git
synced 2026-11-11 17:57:54 +00:00
205 lines
7.1 KiB
Python
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])
|