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
591 lines
17 KiB
Python
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
|