Improved self-cleaning tests.

This commit is contained in:
Storm Dragon committed 2026-10-15 13:12:15 -04:00
1 parent 77db0b83ab
commit e3de71e594
14 files changed
+487 -39

No files matched your search

+1
View File
@@ -36,6 +36,7 @@ Testing/
*.src.tar.*
# Editor backups and desktop metadata
__pycache__/
*~
*.swp
*.swo
+14 -10
View File
@@ -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()
+41
View File
@@ -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
`<build-dir>/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
+7 -4
View File
@@ -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",
+7 -4
View File
@@ -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",
+7 -4
View File
@@ -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",
+8 -1
View File
@@ -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:
+7 -4
View File
@@ -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")
+7 -4
View File
@@ -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",
+7 -4
View File
@@ -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()
+7 -4
View File
@@ -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')
+22
View File
@@ -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))
+209
View File
@@ -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
+143
View File
@@ -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()