From e3de71e59457c056d30a4d09da83ed309bdba314 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Thu, 15 Oct 2026 13:12:15 -0400 Subject: [PATCH] Improved self-cleaning tests. --- .gitignore | 1 + CMakeLists.txt | 24 ++-- tests/README.md | 41 ++++++ tests/controller_integration.py | 11 +- tests/diagnostics_test.py | 11 +- tests/event_stall_integration.py | 11 +- tests/foreground_focus_test.py | 9 +- tests/menu_integration.py | 11 +- tests/notification_integration.py | 11 +- tests/reader_stall_integration.py | 11 +- tests/review_integration.py | 11 +- tests/run_wine_test.py | 22 +++ tests/wine_test_environment.py | 209 ++++++++++++++++++++++++++++ tests/wine_test_environment_test.py | 143 +++++++++++++++++++ 14 files changed, 487 insertions(+), 39 deletions(-) create mode 100644 tests/run_wine_test.py create mode 100644 tests/wine_test_environment.py create mode 100644 tests/wine_test_environment_test.py diff --git a/.gitignore b/.gitignore index f97c14f..e26bcd3 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ Testing/ *.src.tar.* # Editor backups and desktop metadata +__pycache__/ *~ *.swp *.swo diff --git a/CMakeLists.txt b/CMakeLists.txt index bf0d8a4..39a241e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -70,6 +70,16 @@ target_compile_options(wine-access PRIVATE include(CTest) if(BUILD_TESTING) + function(add_wine_test name timeout) + add_test(NAME "${name}" COMMAND python3 + "${CMAKE_CURRENT_SOURCE_DIR}/tests/run_wine_test.py" + --build-dir "${CMAKE_CURRENT_BINARY_DIR}" --name "${name}" + --timeout "${timeout}" -- ${ARGN}) + math(EXPR ctestTimeout "${timeout} + 15") + # Fixtures share the private display. Serialize them even with ctest -j, + # and allow the runner to finish teardown before CTest kills it. + set_tests_properties("${name}" PROPERTIES TIMEOUT "${ctestTimeout}" RUN_SERIAL TRUE) + endfunction() set(foreground_fixture "${CMAKE_CURRENT_BINARY_DIR}/foreground-fixture.exe") add_custom_command(OUTPUT "${foreground_fixture}" COMMAND "${MINGW_C_COMPILER}" -std=c11 -Wall -Wextra -mwindows @@ -81,16 +91,12 @@ if(BUILD_TESTING) target_compile_features(foreground-focus-test PRIVATE cxx_std_20) target_compile_options(foreground-focus-test PRIVATE -Wall -Wextra) target_link_libraries(foreground-focus-test PRIVATE user32 PkgConfig::XCB) - add_test(NAME foreground-focus COMMAND python3 + add_wine_test(foreground-focus 90 python3 "${CMAKE_CURRENT_SOURCE_DIR}/tests/foreground_focus_test.py" "${CMAKE_CURRENT_BINARY_DIR}") - set_tests_properties(foreground-focus PROPERTIES TIMEOUT 90 ENVIRONMENT - "WINEPREFIX=${CMAKE_CURRENT_BINARY_DIR}/focus-prefix;CTHULHU_WINE_ACCESS_MANAGED=1;WINEDEBUG=-all;WINEDLLOVERRIDES=mscoree,mshtml=") add_executable(msaa-states-test tests/msaa_states_test.cpp) target_compile_features(msaa-states-test PRIVATE cxx_std_20) target_compile_options(msaa-states-test PRIVATE -Wall -Wextra) - add_test(NAME msaa-states COMMAND "${CMAKE_CURRENT_BINARY_DIR}/msaa-states-test.exe") - set_tests_properties(msaa-states PROPERTIES ENVIRONMENT - "WINEPREFIX=${CMAKE_CURRENT_BINARY_DIR}/test-prefix;CTHULHU_WINE_ACCESS_MANAGED=1;WINEDEBUG=-all;WINEDLLOVERRIDES=mscoree,mshtml=") + add_wine_test(msaa-states 35 "${CMAKE_CURRENT_BINARY_DIR}/msaa-states-test.exe") add_executable(review-capture-test tests/review_capture_test.cpp) target_compile_features(review-capture-test PRIVATE cxx_std_20) target_compile_options(review-capture-test PRIVATE -Wall -Wextra) @@ -111,10 +117,8 @@ if(BUILD_TESTING) DEPENDS tests/review_fixture.c VERBATIM) add_custom_target(review-fixture${arch} ALL DEPENDS "${review_fixture}") add_dependencies(review-capture-test review-fixture${arch}) - add_test(NAME review-capture${arch} - COMMAND "${CMAKE_CURRENT_BINARY_DIR}/review-capture-test.exe" --fixture "${review_fixture}") - set_tests_properties(review-capture${arch} PROPERTIES TIMEOUT 35 ENVIRONMENT - "WINEPREFIX=${CMAKE_CURRENT_BINARY_DIR}/review-prefix${arch};CTHULHU_WINE_ACCESS_MANAGED=1;WINEDEBUG=-all;WINEDLLOVERRIDES=mscoree,mshtml=") + add_wine_test(review-capture${arch} 35 + "${CMAKE_CURRENT_BINARY_DIR}/review-capture-test.exe" --fixture "${review_fixture}") endforeach() endif() diff --git a/tests/README.md b/tests/README.md index 6a94ae3..02c5b22 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,5 +1,46 @@ # Wine control integration +## Temporary files and failure inspection + +Wine CTests and the integration scripts below use fresh prefixes under the +temporary directory (`/tmp` by default). Successful runs stop their Wine +servers and remove their temporary files. **Failures, timeouts and ordinary +interruptions retain the original files by default**, with the directory path +printed before the test starts and again at exit. This preserves a one-off +failure for inspection without requiring a second reproduction. Retention +keeps files and registry state; test processes are still stopped. + +Console output and fixture `.log` files are saved under +`/Testing/WineAccessLogs/`, outside the disposable prefix. These logs +remain even when temporary files are removed. CTest also keeps its usual +`Testing/Temporary/LastTest.log` report. Saved logs need occasional maintenance; +review them for private text before sharing. + +To retain successful runs too, or explicitly clean a failed run: + +```sh +xvfb-run -a python3 tests/menu_integration.py --build-dir build --keep +xvfb-run -a python3 tests/menu_integration.py --build-dir build --clean-on-failure +WINE_ACCESS_TEST_KEEP=1 xvfb-run -a ctest --test-dir build -V +WINE_ACCESS_TEST_KEEP_ON_FAILURE=0 xvfb-run -a ctest --test-dir build --output-on-failure +``` + +Each run requires at least 1024 MiB free on the temporary filesystem before +starting. If retained failures accumulate, the next run stops with an explicit +space error; it never deletes earlier evidence automatically. Remove retained +directories manually after inspecting them. `TMPDIR` can select another +filesystem, and `WINE_ACCESS_TEST_MIN_FREE_MIB` can adjust the space guard for +larger fixtures. CTests run serially because their fixtures share one display. +A teardown or log-preservation failure always retains the temporary files. +SIGKILL, machine shutdown and an outer test-runner kill cannot run Python +cleanup; occasional `/tmp` cleanup remains useful. + +Check the runner without Wine: + +```sh +python3 tests/wine_test_environment_test.py +``` + ## UIA notifications UIA notification routing uses the actual native application hooks and a private diff --git a/tests/controller_integration.py b/tests/controller_integration.py index 8c71c60..2ea922b 100644 --- a/tests/controller_integration.py +++ b/tests/controller_integration.py @@ -4,23 +4,26 @@ import os from pathlib import Path import subprocess import sys -import tempfile import time from gi.repository import Gio, GLib +from wine_test_environment import add_test_options, run_wine_test, wine_test_directory + parser = argparse.ArgumentParser() parser.add_argument("--build-dir", default="build") parser.add_argument("--reader", choices=("cthulhu", "orca")) parser.add_argument("--extension-endpoint", action="store_true") parser.add_argument("--session", action="store_true") +add_test_options(parser) args = parser.parse_args() build = Path(args.build_dir).resolve() if not args.session: - sys.exit(subprocess.call(["dbus-run-session", "--", sys.executable, __file__, + sys.exit(run_wine_test(["dbus-run-session", "--", sys.executable, __file__, "--session", "--build-dir", str(build), *(["--extension-endpoint"] if args.extension_endpoint else []), - *(["--reader", args.reader] if args.reader else [])])) + *(["--reader", args.reader] if args.reader else [])], build, "controller_integration", + keep=args.keep, keepOnFailure=args.keepOnFailure)) busName = "org.gnome.Orca1.Service" if args.reader == "orca" else "org.stormux.Cthulhu1.Service" interface = busName.rsplit(".", 1)[0] + ".WineAccess" if args.extension_endpoint else busName @@ -70,7 +73,7 @@ def wait_for_client(client, expected=0): assert client.poll() == expected, f"client failed or timed out: {client.poll()}" -with tempfile.TemporaryDirectory(prefix="wine-access-controller-") as temporary: +with wine_test_directory(prefix="wine-access-controller-") as temporary: tempPath = Path(temporary) env = os.environ.copy() env.update(WINEPREFIX=str(tempPath / "prefix"), WINEDEBUG="-all", diff --git a/tests/diagnostics_test.py b/tests/diagnostics_test.py index 2085be3..40afeab 100644 --- a/tests/diagnostics_test.py +++ b/tests/diagnostics_test.py @@ -9,20 +9,23 @@ from pathlib import Path import shutil import subprocess import sys -import tempfile import time from gi.repository import Gio, GLib +from wine_test_environment import add_test_options, run_wine_test, wine_test_directory + parser = argparse.ArgumentParser() parser.add_argument("--build-dir", type=Path, default=Path("build")) parser.add_argument("--session", action="store_true") +add_test_options(parser) args = parser.parse_args() buildDir = args.build_dir.resolve() if not args.session: - sys.exit(subprocess.call(["dbus-run-session", "--", sys.executable, __file__, - "--session", "--build-dir", str(buildDir)])) + sys.exit(run_wine_test(["dbus-run-session", "--", sys.executable, __file__, + "--session", "--build-dir", str(buildDir)], buildDir, "diagnostics_test", + keep=args.keep, keepOnFailure=args.keepOnFailure)) busName = "org.stormux.Cthulhu1.Service" objectPath = "/org/stormux/Cthulhu1/Service" @@ -39,7 +42,7 @@ def record(connection, sender, path, interface, method, parameters, invocation): invocation.return_value(GLib.Variant("(b)", (True,))) -with tempfile.TemporaryDirectory(prefix="wine-access-diagnostics-") as temporary: +with wine_test_directory(prefix="wine-access-diagnostics-") as temporary: testDir = Path(temporary) env = os.environ.copy() env.update(WINEPREFIX=str(testDir / "prefix"), WINEDEBUG="-all", diff --git a/tests/event_stall_integration.py b/tests/event_stall_integration.py index a3e09a1..ffb2d6f 100644 --- a/tests/event_stall_integration.py +++ b/tests/event_stall_integration.py @@ -8,11 +8,12 @@ import os from pathlib import Path import subprocess import sys -import tempfile import time from gi.repository import Gio, GLib +from wine_test_environment import add_test_options, run_wine_test, wine_test_directory + parser = argparse.ArgumentParser() parser.add_argument("--build-dir", default="build") parser.add_argument("--32-bit", dest="is32Bit", action="store_true") @@ -21,15 +22,17 @@ parser.add_argument("--session", action="store_true") parser.add_argument("--getter", action="store_true") parser.add_argument("--background", action="store_true") parser.add_argument("--popup", action="store_true") +add_test_options(parser) args = parser.parse_args() build = Path(args.build_dir).resolve() if not args.session: - sys.exit(subprocess.call(["dbus-run-session", "--", sys.executable, __file__, + sys.exit(run_wine_test(["dbus-run-session", "--", sys.executable, __file__, "--session", "--build-dir", str(build), "--reader", args.reader, *(["--32-bit"] if args.is32Bit else []), *(["--getter"] if args.getter else []), *(["--background"] if args.background else []), - *(["--popup"] if args.popup else [])])) + *(["--popup"] if args.popup else [])], build, "event_stall_integration", + keep=args.keep, keepOnFailure=args.keepOnFailure)) interface = "org.gnome.Orca1.Service" if args.reader == "orca" else "org.stormux.Cthulhu1.Service" objectPath = "/" + interface.replace(".", "/") @@ -67,7 +70,7 @@ def pump_until(predicate, seconds): raise AssertionError(f"condition timed out; events: {events}") -with tempfile.TemporaryDirectory(prefix="wine-event-stall-") as temporary: +with wine_test_directory(prefix="wine-event-stall-") as temporary: root = Path(temporary) env = os.environ.copy() env.update(WINEPREFIX=str(root / "prefix"), WINEDEBUG="-all", diff --git a/tests/foreground_focus_test.py b/tests/foreground_focus_test.py index ce26de9..d13dccc 100644 --- a/tests/foreground_focus_test.py +++ b/tests/foreground_focus_test.py @@ -4,9 +4,16 @@ from pathlib import Path import subprocess import sys +from wine_test_environment import run_wine_test + buildDir = Path(sys.argv[1]).resolve() +if "WINE_ACCESS_TEST_ROOT" not in os.environ: + sys.exit(run_wine_test([sys.executable, __file__, str(buildDir)], buildDir, + "foreground-focus", timeout=90, + keep=os.environ.get("WINE_ACCESS_TEST_KEEP") == "1", + keepOnFailure=os.environ.get("WINE_ACCESS_TEST_KEEP_ON_FAILURE", "1") != "0")) env = os.environ.copy() -env.update(WINEPREFIX=str(buildDir / "focus-prefix"), +env.update(WINEPREFIX=os.environ["WINEPREFIX"], CTHULHU_WINE_ACCESS_MANAGED="1", WINEDEBUG="-all", WINEDLLOVERRIDES="mscoree,mshtml=") try: diff --git a/tests/menu_integration.py b/tests/menu_integration.py index 6d1e72f..7bd230a 100644 --- a/tests/menu_integration.py +++ b/tests/menu_integration.py @@ -9,11 +9,12 @@ from pathlib import Path import subprocess import shutil import sys -import tempfile import time from gi.repository import Gio, GLib +from wine_test_environment import add_test_options, run_wine_test, wine_test_directory + parser = argparse.ArgumentParser() parser.add_argument("--build-dir", default="build") parser.add_argument("--session", action="store_true") @@ -25,6 +26,7 @@ parser.add_argument("--goldwave-exe", type=Path) parser.add_argument("--32-bit", dest="is32Bit", action="store_true") parser.add_argument("--late-helper", action="store_true") parser.add_argument("--restart-helper", action="store_true") +add_test_options(parser) args = parser.parse_args() if args.is32Bit and args.fixture not in ("menu", "edit", "winforms", "list", "listbox", "delphi", "controls"): parser.error("32-bit checks currently require --fixture menu, edit, winforms, list, listbox, delphi or controls") @@ -36,7 +38,7 @@ if args.fixture == "goldwave" and (not args.goldwave_exe or not args.goldwave_ex parser.error("--fixture goldwave requires --goldwave-exe pointing to GoldWave 5.70") build = Path(args.build_dir).resolve() if not args.session: - sys.exit(subprocess.call(["dbus-run-session", "--", sys.executable, + sys.exit(run_wine_test(["dbus-run-session", "--", sys.executable, __file__, "--session", "--build-dir", str(build), "--fixture", args.fixture, *(["--extension-endpoint"] if args.extension_endpoint else []), @@ -45,7 +47,8 @@ if not args.session: *(["--goldwave-exe", str(args.goldwave_exe.resolve())] if args.goldwave_exe else []), *(["--32-bit"] if args.is32Bit else []), *(["--late-helper"] if args.late_helper else []), - *(["--restart-helper"] if args.restart_helper else [])])) + *(["--restart-helper"] if args.restart_helper else [])], build, "menu_integration", + keep=args.keep, keepOnFailure=args.keepOnFailure)) busName = "org.gnome.Orca1.Service" if args.reader == "orca" else "org.stormux.Cthulhu1.Service" interface = busName.rsplit(".", 1)[0] + ".WineAccess" if args.extension_endpoint else busName @@ -75,7 +78,7 @@ connection.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", GLib.Variant("(su)", (busName, 0)), None, Gio.DBusCallFlags.NONE, 1000, None) -with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary: +with wine_test_directory(prefix="wine-access-menus-") as temporary: env = os.environ.copy() env.update(WINEPREFIX=str(Path(temporary) / "prefix"), WINEDEBUG=os.environ.get("WINE_ACCESS_TEST_DEBUG", "-all"), WINEDLLOVERRIDES="mscoree,mshtml=", CTHULHU_WINE_ACCESS_MANAGED="1") diff --git a/tests/notification_integration.py b/tests/notification_integration.py index c696ff1..3cd66ab 100644 --- a/tests/notification_integration.py +++ b/tests/notification_integration.py @@ -4,11 +4,12 @@ import os from pathlib import Path import subprocess import sys -import tempfile import time from gi.repository import Gio, GLib +from wine_test_environment import add_test_options, run_wine_test, wine_test_directory + parser = argparse.ArgumentParser() parser.add_argument("--build-dir", default="build") parser.add_argument("--reader", choices=("cthulhu", "orca"), default="cthulhu") @@ -16,17 +17,19 @@ parser.add_argument("--32-bit", dest="is32Bit", action="store_true") parser.add_argument("--session", action="store_true") parser.add_argument("--prism-dll", type=Path) parser.add_argument("--prism-include", type=Path) +add_test_options(parser) args = parser.parse_args() if bool(args.prism_dll) != bool(args.prism_include) or (args.prism_dll and args.is32Bit): parser.error("Prism checks require both --prism-dll and --prism-include, and a 64-bit DLL") build = Path(args.build_dir).resolve() if not args.session: - sys.exit(subprocess.call(["dbus-run-session", "--", sys.executable, __file__, + sys.exit(run_wine_test(["dbus-run-session", "--", sys.executable, __file__, "--session", "--build-dir", str(build), "--reader", args.reader, *(["--prism-dll", str(args.prism_dll.resolve()), "--prism-include", str(args.prism_include.resolve())] if args.prism_dll else []), - *(["--32-bit"] if args.is32Bit else [])])) + *(["--32-bit"] if args.is32Bit else [])], build, "notification_integration", + keep=args.keep, keepOnFailure=args.keepOnFailure)) interface = "org.gnome.Orca1.Service" if args.reader == "orca" else "org.stormux.Cthulhu1.Service" objectPath = "/" + interface.replace(".", "/") @@ -52,7 +55,7 @@ connection.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freed "RequestName", GLib.Variant("(su)", (interface, 0)), None, Gio.DBusCallFlags.NONE, 1000, None) -with tempfile.TemporaryDirectory(prefix="wine-access-notifications-") as temporary: +with wine_test_directory(prefix="wine-access-notifications-") as temporary: tempPath = Path(temporary) env = os.environ.copy() env.update(WINEPREFIX=str(tempPath / "prefix"), WINEDEBUG="-all", diff --git a/tests/reader_stall_integration.py b/tests/reader_stall_integration.py index 66543b7..a9b1064 100644 --- a/tests/reader_stall_integration.py +++ b/tests/reader_stall_integration.py @@ -7,25 +7,28 @@ import os from pathlib import Path import subprocess import sys -import tempfile import time from gi.repository import Gio, GLib +from wine_test_environment import add_test_options, run_wine_test, wine_test_directory + parser = argparse.ArgumentParser() parser.add_argument("--build-dir", default="build") parser.add_argument("--32-bit", dest="is32Bit", action="store_true") parser.add_argument("--timeout", action="store_true") parser.add_argument("--focus-error", action="store_true") parser.add_argument("--session", action="store_true") +add_test_options(parser) args = parser.parse_args() build = Path(args.build_dir).resolve() if not args.session: - sys.exit(subprocess.call(["dbus-run-session", "--", sys.executable, __file__, + sys.exit(run_wine_test(["dbus-run-session", "--", sys.executable, __file__, "--session", "--build-dir", str(build), *(["--32-bit"] if args.is32Bit else []), *(["--timeout"] if args.timeout else []), - *(["--focus-error"] if args.focus_error else [])])) + *(["--focus-error"] if args.focus_error else [])], build, "reader_stall_integration", + keep=args.keep, keepOnFailure=args.keepOnFailure)) interface = "org.stormux.Cthulhu1.Service" object_path = "/org/stormux/Cthulhu1/Service" @@ -51,7 +54,7 @@ def pump_until(predicate, seconds): raise AssertionError(f"condition timed out; events: {events}") -with tempfile.TemporaryDirectory(prefix="wine-reader-stall-") as temporary: +with wine_test_directory(prefix="wine-reader-stall-") as temporary: root = Path(temporary) waiting, typed, resume = (root / name for name in ("waiting", "typed", "resume")) env = os.environ.copy() diff --git a/tests/review_integration.py b/tests/review_integration.py index 59488b7..6c21773 100644 --- a/tests/review_integration.py +++ b/tests/review_integration.py @@ -4,24 +4,27 @@ import os from pathlib import Path import subprocess import sys -import tempfile import time from gi.repository import Gio, GLib +from wine_test_environment import add_test_options, run_wine_test, wine_test_directory + parser = argparse.ArgumentParser() parser.add_argument('--build-dir', default='build') parser.add_argument('--32-bit', dest='is32', action='store_true') parser.add_argument('--session', action='store_true') parser.add_argument('--reader', choices=('cthulhu', 'orca')) parser.add_argument('--extension-endpoint', action='store_true') +add_test_options(parser) args = parser.parse_args() build = Path(args.build_dir).resolve() if not args.session: - sys.exit(subprocess.call(['dbus-run-session', '--', sys.executable, __file__, + sys.exit(run_wine_test(['dbus-run-session', '--', sys.executable, __file__, '--session', '--build-dir', str(build), *(['--extension-endpoint'] if args.extension_endpoint else []), *(['--reader', args.reader] if args.reader else []), - *(['--32-bit'] if args.is32 else [])])) + *(['--32-bit'] if args.is32 else [])], build, "review_integration", + keep=args.keep, keepOnFailure=args.keepOnFailure)) busName = 'org.gnome.Orca1.Service' if args.reader == 'orca' else 'org.stormux.Cthulhu1.Service' iface = busName.rsplit('.', 1)[0] + '.WineAccess' if args.extension_endpoint else busName path = '/' + busName.replace('.', '/') + ('/WineAccess' if args.extension_endpoint else '') @@ -75,7 +78,7 @@ def request(conn, sender, focus): GLib.VariantType.new('(a{sv})'),Gio.DBusCallFlags.NONE,1500,None,finished) return result -with tempfile.TemporaryDirectory(prefix='wine-review-wire-') as temporary: +with wine_test_directory(prefix='wine-review-wire-') as temporary: env = os.environ.copy() env.update(WINEPREFIX=str(Path(temporary)/'prefix'),WINEDEBUG=os.environ.get('WINE_ACCESS_TEST_DEBUG','-all'),WINEDLLOVERRIDES='mscoree,mshtml=',CTHULHU_WINE_ACCESS_MANAGED='1') fixture = build/('review-fixture32.exe' if args.is32 else 'review-fixture64.exe') diff --git a/tests/run_wine_test.py b/tests/run_wine_test.py new file mode 100644 index 0000000..608321d --- /dev/null +++ b/tests/run_wine_test.py @@ -0,0 +1,22 @@ +"""Run a native Wine fixture with managed temporary files and persistent logs.""" +import argparse +from pathlib import Path +import sys + +from wine_test_environment import add_test_options, run_wine_test + + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("--build-dir", type=Path, required=True) +parser.add_argument("--name", required=True) +parser.add_argument("--timeout", type=float, default=120) +parser.add_argument("--min-free-mib", type=int) +add_test_options(parser) +parser.add_argument("command", nargs=argparse.REMAINDER) +args = parser.parse_args() +command = args.command[1:] if args.command[:1] == ["--"] else args.command +if not command: + parser.error("a test command is required after --") +sys.exit(run_wine_test(command, args.build_dir, args.name, timeout=args.timeout, + keep=args.keep, keepOnFailure=args.keepOnFailure, + minFreeMiB=args.min_free_mib)) diff --git a/tests/wine_test_environment.py b/tests/wine_test_environment.py new file mode 100644 index 0000000..b772573 --- /dev/null +++ b/tests/wine_test_environment.py @@ -0,0 +1,209 @@ +"""Own disposable Wine test files, saved output and prefix teardown.""" +from contextlib import contextmanager +from datetime import datetime, timezone +import os +from pathlib import Path +import selectors +import shutil +import signal +import subprocess +import sys +import tempfile +import time + + +def add_test_options(parser): + parser.add_argument("--keep", action="store_true", + default=os.environ.get("WINE_ACCESS_TEST_KEEP") == "1", + help="retain temporary files even after success") + parser.add_argument("--clean-on-failure", dest="keepOnFailure", action="store_false", + default=os.environ.get("WINE_ACCESS_TEST_KEEP_ON_FAILURE", "1") != "0", + help="remove temporary files after failure too; logs remain") + + +@contextmanager +def wine_test_directory(prefix): + # The outer runner owns deletion, after all child processes have stopped. + # In particular, an inner finally/TemporaryDirectory must not erase the + # failed fixture before the runner can retain it for inspection. + yield tempfile.mkdtemp(prefix=prefix, dir=os.environ["WINE_ACCESS_TEST_ROOT"]) + + +@contextmanager +def test_signals(): + def interrupted(signum, frame): + raise KeyboardInterrupt(signum) + + handlers = {signum: signal.signal(signum, interrupted) + for signum in (signal.SIGINT, signal.SIGTERM)} + try: + yield + finally: + for signum, handler in handlers.items(): + signal.signal(signum, handler) + + +def stop_wine_prefixes(testRoot, env, log): + prefixes = set() + if (testRoot / "prefix").exists(): + prefixes.add(testRoot / "prefix") + for current, dirs, files in os.walk(testRoot, followlinks=False): + if "system.reg" in files or "drive_c" in dirs: + prefixes.add(Path(current)) + dirs.clear() + stopped = True + for prefix in sorted(prefixes): + # These paths originate in this run's private directory. Never stop a + # server selected by the caller's WINEPREFIX or a symlink outside it. + if not prefix.resolve().is_relative_to(testRoot): + stopped = False + continue + prefixEnv = dict(env, WINEPREFIX=str(prefix)) + try: + # -k also returns 1 when the fixture already stopped its server. + # -w confirms the final state without treating that as a failure. + subprocess.run(["wineserver", "-k"], env=prefixEnv, check=False, + timeout=5, stdout=log, stderr=subprocess.STDOUT) + subprocess.run(["wineserver", "-w"], env=prefixEnv, check=True, + timeout=5, stdout=log, stderr=subprocess.STDOUT) + except (OSError, subprocess.SubprocessError) as error: + log.write(f"Wine prefix teardown failed: {prefix}: {error}\n".encode()) + stopped = False + return stopped + + +def run_wine_test(command, buildDir, name, *, timeout=120, keep=False, + keepOnFailure=True, minFreeMiB=None): + if not name or any(char not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-" for char in name): + raise ValueError("test name must contain only letters, digits, underscores or hyphens") + if timeout <= 0: + raise ValueError("test timeout must be positive") + tempBase = Path(tempfile.gettempdir()) + if minFreeMiB is None: + minFreeMiB = int(os.environ.get("WINE_ACCESS_TEST_MIN_FREE_MIB", "1024")) + if minFreeMiB < 0: + raise ValueError("minimum free space must not be negative") + freeMiB = shutil.disk_usage(tempBase).free // (1024 * 1024) + if freeMiB < minFreeMiB: + print(f"Wine test {name}: {tempBase} has {freeMiB} MiB free; " + f"at least {minFreeMiB} MiB is required. Clean up retained test directories first.", + file=sys.stderr, flush=True) + return 2 + + logDir = Path(buildDir).resolve() / "Testing" / "WineAccessLogs" + logDir.mkdir(parents=True, exist_ok=True) + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ") + logPath = logDir / f"{name}-{stamp}-{os.getpid()}.log" + testRoot = Path(tempfile.mkdtemp(prefix=f"wine-access-{name}-", dir=tempBase)).resolve() + env = os.environ.copy() + env.update(WINE_ACCESS_TEST_ROOT=str(testRoot), TMPDIR=str(testRoot), + WINEPREFIX=str(testRoot / "prefix"), CTHULHU_WINE_ACCESS_MANAGED="1", + WINEDEBUG=env.get("WINE_ACCESS_TEST_DEBUG", "-all"), + WINEDLLOVERRIDES="mscoree,mshtml=", PYTHONUNBUFFERED="1") + process = None + returnCode = 1 + + with logPath.open("wb", buffering=0) as log, test_signals(): + def report(message): + print(message, flush=True) + log.write((message + "\n").encode()) + + report(f"Wine test directory: {testRoot}") + report(f"Wine test log: {logPath}") + try: + process = subprocess.Popen(command, env=env, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, start_new_session=True) + deadline = time.monotonic() + timeout + exitedAt = None + with selectors.DefaultSelector() as selector: + selector.register(process.stdout, selectors.EVENT_READ) + while selector.get_map(): + now = time.monotonic() + if process.poll() is None and now >= deadline: + raise subprocess.TimeoutExpired(command, timeout) + if process.poll() is not None: + if exitedAt is None: + exitedAt = now + # Detached Wine services may retain the output pipe. + # Prefix teardown below closes them; do not wait forever. + if now - exitedAt >= 1: + break + for key, _ in selector.select(.1): + data = os.read(key.fd, 32768) + if not data: + selector.unregister(key.fileobj) + continue + log.write(data) + if hasattr(sys.stdout, "buffer"): + sys.stdout.buffer.write(data) + sys.stdout.buffer.flush() + else: + sys.stdout.write(data.decode(errors="replace")) + returnCode = process.wait(timeout=max(.1, deadline - time.monotonic())) + if returnCode < 0: + returnCode = 128 - returnCode + except subprocess.TimeoutExpired: + returnCode = 124 + report(f"Wine test timed out after {timeout} seconds") + except KeyboardInterrupt as error: + returnCode = 128 + (error.args[0] if error.args else signal.SIGINT) + report("Wine test interrupted") + except OSError as error: + report(f"Wine test could not run: {error}") + finally: + # Ignore repeated interrupts during the bounded teardown, then + # restore the caller's handlers. Never signal another test's group. + for signum in (signal.SIGINT, signal.SIGTERM): + signal.signal(signum, signal.SIG_IGN) + if process is not None: + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + process.wait(timeout=1) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait(timeout=2) + stopped = stop_wine_prefixes(testRoot, env, log) + if process is not None: + # Save any last diagnostics from services stopped above. + os.set_blocking(process.stdout.fileno(), False) + while True: + try: + data = os.read(process.stdout.fileno(), 32768) + except BlockingIOError: + break + if not data: + break + log.write(data) + process.stdout.close() + if not stopped: + returnCode = returnCode or 1 + report("Wine teardown did not finish; retaining temporary files") + evidenceSaved = True + try: + for current, _, files in os.walk(testRoot, followlinks=False): + for name in files: + source = Path(current) / name + if source.suffix != ".log" or source.is_symlink(): + continue + destination = logDir / logPath.stem / source.relative_to(testRoot) + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + except OSError as error: + evidenceSaved = False + returnCode = returnCode or 1 + report(f"Could not save fixture logs; retaining temporary files: {error}") + retain = keep or (returnCode != 0 and keepOnFailure) or not stopped or not evidenceSaved + if retain: + report(f"Retained Wine test directory: {testRoot}") + else: + try: + shutil.rmtree(testRoot) + report(f"Removed Wine test directory: {testRoot}") + except OSError as error: + returnCode = returnCode or 1 + report(f"Could not remove Wine test directory: {testRoot}: {error}") + report(f"Wine test exit status: {returnCode}; log saved: {logPath}") + return returnCode diff --git a/tests/wine_test_environment_test.py b/tests/wine_test_environment_test.py new file mode 100644 index 0000000..3407e94 --- /dev/null +++ b/tests/wine_test_environment_test.py @@ -0,0 +1,143 @@ +"""Check test-file retention, saved evidence, space guards and cancellation.""" +from contextlib import redirect_stderr, redirect_stdout +import io +import os +from pathlib import Path +import signal +import subprocess +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from wine_test_environment import run_wine_test, stop_wine_prefixes + + +class WineTestEnvironmentTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="wine-test-runner-check-") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.buildDir = self.root / "build" + + def run_fixture(self, source, **options): + output = io.StringIO() + with patch("wine_test_environment.tempfile.gettempdir", return_value=str(self.root)), \ + redirect_stdout(output), redirect_stderr(output): + code = run_wine_test([sys.executable, "-c", source], self.buildDir, + "runner-check", minFreeMiB=0, **options) + lines = output.getvalue().splitlines() + testRoot = Path(next(line.removeprefix("Wine test directory: ") + for line in lines if line.startswith("Wine test directory: "))) + logPath = Path(next(line.removeprefix("Wine test log: ") + for line in lines if line.startswith("Wine test log: "))) + return code, testRoot, logPath.read_text() + + def test_success_removes_files_but_preserves_log(self): + code, testRoot, log = self.run_fixture("print('fixture diagnostic')") + self.assertEqual(code, 0) + self.assertFalse(testRoot.exists()) + self.assertIn("fixture diagnostic", log) + self.assertIn("Removed Wine test directory:", log) + + def test_failure_retains_original_fixture_and_log_by_default(self): + source = "import os, pathlib; pathlib.Path(os.environ['WINE_ACCESS_TEST_ROOT'], 'evidence').write_text('fixture'); print('failure detail'); raise SystemExit(7)" + code, testRoot, log = self.run_fixture(source) + self.assertEqual(code, 7) + self.assertEqual((testRoot / "evidence").read_text(), "fixture") + self.assertIn("failure detail", log) + self.assertIn("Retained Wine test directory:", log) + + def test_fixture_log_survives_successful_cleanup(self): + source = "import os, pathlib; pathlib.Path(os.environ['WINE_ACCESS_TEST_ROOT'], 'helper.log').write_text('saved helper detail')" + code, testRoot, _ = self.run_fixture(source) + self.assertEqual(code, 0) + self.assertFalse(testRoot.exists()) + saved = list((self.buildDir / "Testing/WineAccessLogs").rglob("helper.log")) + self.assertEqual(len(saved), 1) + self.assertEqual(saved[0].read_text(), "saved helper detail") + + def test_keep_preserves_success_and_clean_option_preserves_failure_status(self): + code, testRoot, _ = self.run_fixture("pass", keep=True) + self.assertEqual(code, 0) + self.assertTrue(testRoot.exists()) + code, testRoot, log = self.run_fixture("raise SystemExit(9)", keepOnFailure=False) + self.assertEqual(code, 9) + self.assertFalse(testRoot.exists()) + self.assertIn("exit status: 9", log) + + def test_cli_retention_switches(self): + runner = Path(__file__).with_name("run_wine_test.py") + env = dict(os.environ, TMPDIR=str(self.root), WINE_ACCESS_TEST_KEEP="0", + WINE_ACCESS_TEST_KEEP_ON_FAILURE="1") + for flags, exitCode, retained in (([], 7, True), (["--clean-on-failure"], 7, False), + (["--keep"], 0, True)): + result = subprocess.run([sys.executable, str(runner), "--build-dir", str(self.buildDir), + "--name", "cli-check", "--min-free-mib", "0", *flags, + "--", sys.executable, "-c", f"raise SystemExit({exitCode})"], + env=env, capture_output=True, text=True, timeout=5) + self.assertEqual(result.returncode, exitCode, result.stdout + result.stderr) + root = Path(next(line.removeprefix("Wine test directory: ") + for line in result.stdout.splitlines() if line.startswith("Wine test directory: "))) + self.assertEqual(root.exists(), retained, result.stdout) + + def test_timeout_stops_fixture_and_retains_its_log(self): + code, testRoot, log = self.run_fixture("import time; print('before stall', flush=True); time.sleep(60)", timeout=.2) + self.assertEqual(code, 124) + self.assertTrue(testRoot.exists()) + self.assertIn("before stall", log) + self.assertIn("timed out", log) + + def test_sigterm_retains_files_and_restores_signal_handlers(self): + before = signal.getsignal(signal.SIGTERM) + code, testRoot, log = self.run_fixture("import os, signal; os.kill(os.getppid(), signal.SIGTERM)") + self.assertEqual(code, 143) + self.assertTrue(testRoot.exists()) + self.assertIn("interrupted", log) + self.assertEqual(signal.getsignal(signal.SIGTERM), before) + + def test_descendant_holding_output_does_not_hang_runner(self): + code, testRoot, log = self.run_fixture("import subprocess; subprocess.Popen(['sleep', '60']); print('parent finished')") + self.assertEqual(code, 0) + self.assertFalse(testRoot.exists()) + self.assertIn("parent finished", log) + + def test_failed_teardown_overrides_cleanup_and_success(self): + with patch("wine_test_environment.stop_wine_prefixes", return_value=False): + code, testRoot, log = self.run_fixture("pass", keepOnFailure=False) + self.assertEqual(code, 1) + self.assertTrue(testRoot.exists()) + self.assertIn("teardown did not finish", log) + + def test_low_space_stops_before_starting_or_creating_files(self): + output = io.StringIO() + with patch("wine_test_environment.shutil.disk_usage", return_value=SimpleNamespace(free=0)), \ + patch("wine_test_environment.subprocess.Popen") as launch, \ + redirect_stderr(output): + code = run_wine_test(["unused"], self.buildDir, "low-space", minFreeMiB=1024) + self.assertEqual(code, 2) + launch.assert_not_called() + self.assertEqual(list(self.root.iterdir()), []) + self.assertIn("at least 1024 MiB", output.getvalue()) + + def test_teardown_is_limited_to_owned_prefixes(self): + testRoot = self.root / "test" + testRoot.mkdir() + ownPrefix = testRoot / "fixture" / "prefix" + ownPrefix.mkdir(parents=True) + (ownPrefix / "system.reg").touch() + with patch("wine_test_environment.subprocess.run") as run: + self.assertTrue(stop_wine_prefixes(testRoot, {"WINEPREFIX": "/unrelated-prefix"}, io.BytesIO())) + self.assertEqual(run.call_count, 2) + for call in run.call_args_list: + self.assertEqual(call.kwargs["env"]["WINEPREFIX"], str(ownPrefix)) + (testRoot / "prefix").symlink_to(self.root, target_is_directory=True) + with patch("wine_test_environment.subprocess.run") as run: + self.assertFalse(stop_wine_prefixes(testRoot, {}, io.BytesIO())) + self.assertTrue(all(call.kwargs["env"]["WINEPREFIX"] == str(ownPrefix) + for call in run.call_args_list)) + + +if __name__ == "__main__": + unittest.main()