Files
allhaileris afb81b8278
Some checks failed
Docker. / Ubuntu (push) Has been cancelled
User-agent updater. / User-agent (push) Failing after 15s
Lock Threads / lock (push) Failing after 10s
Waiting for answer. / waiting-for-answer (push) Failing after 22s
Needs user action. / needs-user-action (push) Failing after 8s
Can't reproduce. / cant-reproduce (push) Failing after 8s
Close stale issues and PRs / stale (push) Has been cancelled
init
2026-02-16 15:50:16 +03:00

591 lines
17 KiB
Python

# SPDX-License-Identifier: LGPL-2.1-or-later
#
# This file is formatted with Python Black
from typing import Any, Dict, Iterator, Optional
from types import ModuleType
import pytest
import dbus
import dbusmock
import os
import sys
import tempfile
import subprocess
import time
import signal
from pathlib import Path
from contextlib import chdir
import gi
gi.require_version("UMockdev", "1.0")
from gi.repository import UMockdev # noqa E402
def pytest_configure() -> None:
ensure_environment_set()
ensure_umockdev_loaded()
def pytest_sessionfinish(session, exitstatus):
# Meson and ginsttest-runner expect tests to exit with status 77 if all
# tests were skipped
if exitstatus == pytest.ExitCode.NO_TESTS_COLLECTED:
session.exitstatus = 77
def ensure_environment_set() -> None:
env_vars = [
"XDG_DESKTOP_PORTAL_PATH",
"XDG_PERMISSION_STORE_PATH",
"XDG_DOCUMENT_PORTAL_PATH",
]
if not os.getenv("XDP_VALIDATE_AUTO"):
env_vars += [
"XDP_VALIDATE_ICON",
"XDP_VALIDATE_SOUND",
]
else:
os.environ.pop("XDP_VALIDATE_ICON", None)
os.environ.pop("XDP_VALIDATE_SOUND", None)
for env_var in env_vars:
if not os.getenv(env_var):
raise Exception(f"{env_var} must be set")
def ensure_umockdev_loaded() -> None:
umockdev_preload = "libumockdev-preload.so"
preload = os.environ.get("LD_PRELOAD", "")
if umockdev_preload not in preload:
os.environ["LD_PRELOAD"] = f"{umockdev_preload}:{preload}"
os.execv(sys.executable, [sys.executable] + sys.argv)
def test_dir() -> Path:
return Path(__file__).resolve().parent
@pytest.fixture
def xdg_desktop_portal_path() -> Path:
return Path(os.environ["XDG_DESKTOP_PORTAL_PATH"])
@pytest.fixture
def xdg_permission_store_path() -> Path:
return Path(os.environ["XDG_PERMISSION_STORE_PATH"])
@pytest.fixture
def xdg_document_portal_path() -> Path:
return Path(os.environ["XDG_DOCUMENT_PORTAL_PATH"])
@pytest.fixture(autouse=True)
def create_test_dirs(umockdev: Optional[UMockdev.Testbed]) -> Iterator[None]:
# The umockdev argument is to make sure the testbed
# is created before we create the tmpdir
env_dirs = [
"HOME",
"TMPDIR",
"XDG_CACHE_HOME",
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
"XDG_RUNTIME_DIR",
"XDG_DESKTOP_PORTAL_DIR",
]
test_root = tempfile.TemporaryDirectory(
prefix="xdp-testroot-", ignore_cleanup_errors=True
)
for env_dir in env_dirs:
directory = Path(test_root.name) / env_dir.lower()
directory.mkdir(mode=0o700, parents=True)
os.environ[env_dir] = directory.absolute().as_posix()
yield
test_root.cleanup()
@pytest.fixture
def xdg_data_home_files() -> Dict[str, bytes]:
"""
Default fixture which can be used to create files in the temporary
XDG_DATA_HOME directory of the test.
"""
return {}
@pytest.fixture(autouse=True)
def ensure_xdg_data_home(
create_test_dirs: Any, xdg_data_home_files: Dict[str, bytes]
) -> None:
files = xdg_data_home_files
for name, content in files.items():
file_path = Path(os.environ["XDG_DATA_HOME"]) / name
file_path.parent.mkdir(parents=True, exist_ok=True)
with open(file_path.absolute().as_posix(), "wb") as f:
f.write(content)
@pytest.fixture
def xdg_desktop_portal_dir_files() -> Dict[str, bytes]:
"""
Default fixture which can be used to create files in the temporary
XDG_DESKTOP_PORTAL_DIR directory of the test.
"""
return {}
@pytest.fixture
def xdg_desktop_portal_dir_default_files() -> Dict[str, bytes]:
files = {}
portals = [
"org.freedesktop.impl.portal.Access",
"org.freedesktop.impl.portal.Account",
"org.freedesktop.impl.portal.AppChooser",
"org.freedesktop.impl.portal.Background",
"org.freedesktop.impl.portal.Clipboard",
"org.freedesktop.impl.portal.DynamicLauncher",
"org.freedesktop.impl.portal.Email",
"org.freedesktop.impl.portal.FileChooser",
"org.freedesktop.impl.portal.GlobalShortcuts",
"org.freedesktop.impl.portal.Inhibit",
"org.freedesktop.impl.portal.InputCapture",
"org.freedesktop.impl.portal.Lockdown",
"org.freedesktop.impl.portal.Notification",
"org.freedesktop.impl.portal.Print",
"org.freedesktop.impl.portal.RemoteDesktop",
"org.freedesktop.impl.portal.Screenshot",
"org.freedesktop.impl.portal.Settings",
"org.freedesktop.impl.portal.Usb",
"org.freedesktop.impl.portal.Wallpaper",
]
files["test-portals.conf"] = b"""
[preferred]
default=test;
"""
files["test.portal"] = """
[portal]
DBusName=org.freedesktop.impl.portal.Test
Interfaces={}
""".format(";".join(portals)).encode("utf-8")
return files
@pytest.fixture(autouse=True)
def ensure_xdg_desktop_portal_dir(
create_test_dirs: Any,
xdg_desktop_portal_dir_files: Dict[str, bytes],
xdg_desktop_portal_dir_default_files: Dict[str, bytes],
) -> None:
files = xdg_desktop_portal_dir_default_files | xdg_desktop_portal_dir_files
for name, content in files.items():
file_path = Path(os.environ["XDG_DESKTOP_PORTAL_DIR"]) / name
file_path.parent.mkdir(parents=True, exist_ok=True)
with open(file_path.absolute().as_posix(), "wb") as f:
f.write(content)
@pytest.fixture(autouse=True)
def create_test_dbus() -> Iterator[dbusmock.DBusTestCase]:
bus = dbusmock.DBusTestCase()
bus.setUp()
bus.start_session_bus()
bus.start_system_bus()
yield bus
bus.tearDown()
bus.tearDownClass()
@pytest.fixture(autouse=True)
def create_dbus_monitor(create_test_dbus) -> Iterator[Optional[subprocess.Popen]]:
if not os.getenv("XDP_DBUS_MONITOR"):
yield None
return
dbus_monitor = subprocess.Popen(["dbus-monitor", "--session"])
yield dbus_monitor
dbus_monitor.terminate()
dbus_monitor.wait()
def _get_server_for_module(
busses: dict[dbusmock.BusType, dict[str, dbusmock.SpawnedMock]],
module: ModuleType,
bustype: dbusmock.BusType,
) -> dbusmock.SpawnedMock:
assert bustype in dbusmock.BusType
try:
return busses[bustype][module.BUS_NAME]
except KeyError:
server = dbusmock.SpawnedMock.spawn_for_name(
module.BUS_NAME,
"/dbusmock",
dbusmock.OBJECT_MANAGER_IFACE,
bustype,
stdout=None,
stderr=None,
)
busses[bustype][module.BUS_NAME] = server
return server
def _get_main_obj_for_module(
server: dbusmock.SpawnedMock, module: ModuleType, bustype: dbusmock.BusType
) -> dbusmock.DBusMockObject:
try:
server.obj.AddObject(
module.MAIN_OBJ,
"com.example.EmptyInterface",
{},
[],
dbus_interface=dbusmock.MOCK_IFACE,
)
except Exception:
pass
bustype.wait_for_bus_object(module.BUS_NAME, module.MAIN_OBJ)
bus = bustype.get_connection()
return bus.get_object(module.BUS_NAME, module.MAIN_OBJ)
def _terminate_mock_p(process: subprocess.Popen) -> None:
process.terminate()
process.wait()
def _terminate_servers(
busses: dict[dbusmock.BusType, dict[str, dbusmock.SpawnedMock]],
) -> None:
for server in busses[dbusmock.BusType.SYSTEM].values():
_terminate_mock_p(server.process)
for server in busses[dbusmock.BusType.SESSION].values():
_terminate_mock_p(server.process)
def _start_template(
busses: dict[dbusmock.BusType, dict[str, dbusmock.SpawnedMock]],
template: str,
bus_name: Optional[str],
params: Dict[str, Any] = {},
) -> None:
"""
Start the template and potentially start a server for it
"""
module_path_dir = (test_dir()).parent.absolute().as_posix()
template_path = test_dir() / f"templates/{template.lower()}.py"
template = template_path.absolute().as_posix()
# we cd to the parent dir of the test_dir so that the module search path for
# the templates is the same as opening the modules from here
with chdir(module_path_dir):
module = dbusmock.mockobject.load_module(template)
bustype = (
dbusmock.BusType.SYSTEM if module.SYSTEM_BUS else dbusmock.BusType.SESSION
)
if bus_name:
module.BUS_NAME = bus_name
server = _get_server_for_module(busses, module, bustype)
main_obj = _get_main_obj_for_module(server, module, bustype)
main_obj.AddTemplate(
template,
dbus.Dictionary(params, signature="sv"),
dbus_interface=dbusmock.MOCK_IFACE,
)
@pytest.fixture
def template_params() -> dict[str, dict[str, Any]]:
"""
Default fixture for overriding the parameters which should be passed to the
mocking templates. Use required_templates to specify the default parameters
and override it for specific test cases via
@pytest.mark.parametrize("template_params", ({"Template": {"foo": "bar"}},))
"""
return {}
@pytest.fixture
def required_templates() -> dict[str, dict[str, Any]]:
"""
Default fixture for enumerating the mocking templates the test case requires
to be started. This is a map from a template spec to the parameters which
should be passed to the template. The template spec is the name of a
template in the templates directory and an optional dbus bus name to start
the template at, separated by a colon.
This starts the `settings` and `email` templates on the bus names specified
with BUS_NAME in the templates and passes parameters to settings.
{
"settings": {
"some_param": true,
},
"email": {},
}
This starts two instances of the settings template with their own parameters
once on the bus name specified in BUS_NAME in the template, and once on the
bus name `org.freedesktop.impl.portal.OtherImpl`.
{
"settings": {
"some_param": true,
},
"settings:org.freedesktop.impl.portal.OtherImpl": {
"some_param": false,
},
}
"""
return {}
@pytest.fixture
def templates(
required_templates: dict[str, dict[str, Any]],
template_params: dict[str, dict[str, Any]],
) -> Iterator[None]:
"""
Fixture which starts the required templates with their parameters. Usually
the `portals` fixture is what you're looking for because it also starts
the portal frontend and the permission store.
"""
busses: dict[dbusmock.BusType, dict[str, dbusmock.SpawnedMock]] = {
dbusmock.BusType.SYSTEM: {},
dbusmock.BusType.SESSION: {},
}
for template_data, params in required_templates.items():
template, bus_name = (template_data.split(":") + [None])[:2]
assert template
params = template_params.get(template, params)
_start_template(busses, template, bus_name, params)
yield
_terminate_servers(busses)
@pytest.fixture
def xdp_overwrite_env() -> dict[str, str]:
"""
Default fixture which can be used to override the environment that gets
passed to xdg-desktop-portal, xdg-document-portal and xdg-permission-store.
"""
return {}
@pytest.fixture
def app_id() -> str:
"""
Default fixture which can be used to override the app id that the portal
frontend will discover for incoming connections.
"""
return "org.example.Test"
@pytest.fixture
def xdp_env(
xdp_overwrite_env: dict[str, str],
app_id: str,
usb_queries: Optional[str],
umockdev: Optional[UMockdev.Testbed],
) -> dict[str, str]:
env = os.environ.copy()
env["G_DEBUG"] = "fatal-criticals"
env["XDG_CURRENT_DESKTOP"] = "test"
if app_id:
env["XDG_DESKTOP_PORTAL_TEST_APP_ID"] = app_id
if usb_queries:
env["XDG_DESKTOP_PORTAL_TEST_USB_QUERIES"] = usb_queries
if umockdev:
env["UMOCKDEV_DIR"] = umockdev.get_root_dir()
asan_suppression = test_dir() / "asan.suppression"
if not asan_suppression.exists():
raise FileNotFoundError(f"{asan_suppression} does not exist")
env["LSAN_OPTIONS"] = f"suppressions={asan_suppression}"
for key, val in xdp_overwrite_env.items():
env[key] = val
return env
def _maybe_add_asan_preload(executable: Path, env: dict[str, str]) -> None:
# ASAN really wants to be the first library to get loaded but we also
# LD_PRELOAD umockdev and LD_PRELOAD gets loaded before any "normally"
# linked libraries. This uses ldd to find the version of libasan.so that
# should be loaded and puts it in front of LD_PRELOAD.
# This way, LD_PRELOAD and ASAN can be used at the same time.
ldd = subprocess.check_output(["ldd", executable]).decode("utf-8")
libs = [line.split()[0] for line in ldd.splitlines()]
try:
libasan = next(filter(lambda lib: lib.startswith("libasan"), libs))
except StopIteration:
return
preload = env.get("LD_PRELOAD", "")
env["LD_PRELOAD"] = f"{libasan}:{preload}"
@pytest.fixture
def xdg_desktop_portal(
dbus_con: dbus.Bus, xdg_desktop_portal_path: Path, xdp_env: dict[str, str]
) -> Iterator[subprocess.Popen]:
"""
Fixture which starts and eventually stops xdg-desktop-portal
"""
if not xdg_desktop_portal_path.exists():
raise FileNotFoundError(f"{xdg_desktop_portal_path} does not exist")
env = xdp_env.copy()
_maybe_add_asan_preload(xdg_desktop_portal_path, env)
xdg_desktop_portal = subprocess.Popen([xdg_desktop_portal_path], env=env)
while not dbus_con.name_has_owner("org.freedesktop.portal.Desktop"):
returncode = xdg_desktop_portal.poll()
if returncode is not None:
raise subprocess.SubprocessError(
f"xdg-desktop-portal exited with {returncode}"
)
time.sleep(0.1)
yield xdg_desktop_portal
xdg_desktop_portal.send_signal(signal.SIGHUP)
returncode = xdg_desktop_portal.wait()
assert returncode == 0
@pytest.fixture
def xdg_permission_store(
dbus_con: dbus.Bus, xdg_permission_store_path: Path, xdp_env: dict[str, str]
) -> Iterator[subprocess.Popen]:
"""
Fixture which starts and eventually stops xdg-permission-store
"""
if not xdg_permission_store_path.exists():
raise FileNotFoundError(f"{xdg_permission_store_path} does not exist")
env = xdp_env.copy()
_maybe_add_asan_preload(xdg_permission_store_path, env)
permission_store = subprocess.Popen([xdg_permission_store_path], env=env)
while not dbus_con.name_has_owner("org.freedesktop.impl.portal.PermissionStore"):
returncode = permission_store.poll()
if returncode is not None:
raise subprocess.SubprocessError(
f"xdg-permission-store exited with {returncode}"
)
time.sleep(0.1)
yield permission_store
permission_store.send_signal(signal.SIGHUP)
permission_store.wait()
# The permission store does not shut down cleanly currently
# returncode = permission_store.wait()
# assert returncode == 0
@pytest.fixture
def xdg_document_portal(
dbus_con: dbus.Bus, xdg_document_portal_path: Path, xdp_env: dict[str, str]
) -> Iterator[subprocess.Popen]:
"""
Fixture which starts and eventually stops xdg-document-portal
"""
if not xdg_document_portal_path.exists():
raise FileNotFoundError(f"{xdg_document_portal_path} does not exist")
# FUSE and LD_PRELOAD don't like each other. Not sure what exactly is going
# wrong but it usually just results in a weird hang that needs SIGKILL
env = xdp_env.copy()
env.pop("LD_PRELOAD", None)
document_portal = subprocess.Popen([xdg_document_portal_path], env=env)
while not dbus_con.name_has_owner("org.freedesktop.portal.Documents"):
returncode = document_portal.poll()
if returncode is not None:
raise subprocess.SubprocessError(
f"xdg-document-portal exited with {returncode}"
)
time.sleep(0.1)
yield document_portal
document_portal.send_signal(signal.SIGHUP)
returncode = document_portal.wait()
assert returncode == 0
@pytest.fixture
def portals(templates: Any, xdg_desktop_portal: Any, xdg_permission_store: Any) -> None:
"""
Fixture which starts the required templates, xdg-desktop-portal,
xdg-document-portal and xdg-permission-store. Most tests require this.
"""
return None
@pytest.fixture
def usb_queries() -> Optional[str]:
"""
Default fixture providing the usb queries the connecting process can
enumerate
"""
return None
@pytest.fixture
def umockdev() -> Optional[UMockdev.Testbed]:
"""
Default fixture providing a umockdev testbed
"""
return None
@pytest.fixture
def dbus_con(create_test_dbus: dbusmock.DBusTestCase) -> dbus.Bus:
"""
Default fixture which provides the python-dbus session bus of the test.
"""
con = create_test_dbus.get_dbus(system_bus=False)
assert con
return con
@pytest.fixture
def dbus_con_sys(create_test_dbus: dbusmock.DBusTestCase) -> dbus.Bus:
"""
Default fixture which provides the python-dbus system bus of the test.
"""
con_sys = create_test_dbus.get_dbus(system_bus=True)
assert con_sys
return con_sys