"""Exercise Now Playing on a private bus and nested desktop. FERESE_TEST_MEDIA=1 FERESE_TEST_BINARY=target/release/ferese \ FERESE_TEST_CTL=target/release/feresectl python3 scripts/tests/test_media_isolated.py Requires a Wayland host, PyGObject and dbus-run-session. """ import json import os from pathlib import Path from session_socket import ipc_environment, ipc_socket import signal import socket import struct import subprocess import sys import tempfile import threading import time import unittest REPO = Path(__file__).resolve().parents[2] PLAYER = "org.mpris.MediaPlayer2.Player" ROOT = "org.mpris.MediaPlayer2" PATH = "/org/mpris/MediaPlayer2" @unittest.skipUnless(os.environ.get("FERESE_TEST_MEDIA") == "1", "requires a Wayland host") class Media(unittest.TestCase): def test_private_players_and_actions(self): result = subprocess.run(["dbus-run-session", "--", sys.executable, __file__, "--private"], capture_output=True, text=True, timeout=60) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) def private_checks(): import gi gi.require_version("Gio", "2.0") from gi.repository import Gio, GLib loop = GLib.MainLoop() threading.Thread(target=loop.run, daemon=True).start() class FakePlayer: def __init__(self, name, status="Paused"): self.name = ROOT + "." + name self.status = status self.title = name + " track" self.position = 1_000_000 self.volume = 0.5 self.calls = [] self.reads = 0 self.can_next = True self.connection = Gio.DBusConnection.new_for_address_sync( os.environ["DBUS_SESSION_BUS_ADDRESS"], Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION, None, None) properties = [('PlaybackStatus', 's'), ('Metadata', 'a{sv}'), ('Position', 'x'), ('Rate', 'd'), ('Volume', 'd'), ('CanControl', 'b'), ('CanPlay', 'b'), ('CanPause', 'b'), ('CanGoNext', 'b'), ('CanGoPrevious', 'b'), ('CanSeek', 'b')] xml = f'' + ''.join( f'' for key, kind in [('Identity', 's'), ('DesktopEntry', 's'), ('CanRaise', 'b')]) xml += '' + f'' xml += ''.join(f'' for key, kind in properties) xml += ''' ''' node = Gio.DBusNodeInfo.new_for_xml(xml) self.registrations = [self.connection.register_object(PATH, interface, self.method, self.property, self.set_property) for interface in node.interfaces] self.connection.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "RequestName", GLib.Variant("(su)", (self.name, 0)), None, Gio.DBusCallFlags.NONE, 2000, None) def property(self, connection, sender, path, interface, name): self.reads += 1 if name == 'Metadata': return GLib.Variant('a{sv}', {'xesam:title': GLib.Variant('s', self.title), 'xesam:artist': GLib.Variant('as', ['Artist']), 'mpris:length': GLib.Variant('x', 120_000_000), 'mpris:trackid': GLib.Variant('o', '/track/one')}) if name == 'PlaybackStatus': return GLib.Variant('s', self.status) if name in ('Identity', 'DesktopEntry'): return GLib.Variant('s', self.name.rsplit('.', 1)[-1]) if name == 'Position': return GLib.Variant('x', self.position) if name in ('Rate', 'Volume'): return GLib.Variant('d', 1.0 if name == 'Rate' else self.volume) return GLib.Variant('b', self.can_next if name == 'CanGoNext' else True) def set_property(self, connection, sender, path, interface, name, value): if interface == PLAYER and name == 'Volume': self.volume = value.unpack() self.changed(['Volume']) return True return False def method(self, connection, sender, path, interface, name, parameters, invocation): self.calls.append((name, parameters.unpack())) if name == 'PlayPause': self.set('Paused' if self.status == 'Playing' else 'Playing') elif name == 'SetPosition': self.position = parameters.unpack()[1] self.connection.emit_signal(None, PATH, PLAYER, 'Seeked', GLib.Variant('(x)', (self.position,))) invocation.return_value(GLib.Variant('()', ())) def changed(self, fields): self.connection.emit_signal(None, PATH, 'org.freedesktop.DBus.Properties', 'PropertiesChanged', GLib.Variant('(sa{sv}as)', (PLAYER, {}, fields))) self.connection.flush_sync(None) def set(self, status): self.status = status self.changed(['PlaybackStatus']) def close(self): self.connection.close_sync(None) players = [FakePlayer('first')] child = None shell = None try: with tempfile.TemporaryDirectory(prefix='ferese-media-') as directory: root = Path(directory) runtime = root / 'runtime' runtime.mkdir(mode=0o700) config = root / 'config/ferese' config.mkdir(parents=True) (config / 'config.kdl').write_text('animations { reduced-motion #true; }\n') display = Path(os.environ['WAYLAND_DISPLAY']) if not display.is_absolute(): display = Path(os.environ['XDG_RUNTIME_DIR']) / display env = dict(os.environ, WAYLAND_DISPLAY=str(display), XDG_RUNTIME_DIR=str(runtime), XDG_CONFIG_HOME=str(root / 'config'), XDG_STATE_HOME=str(root / 'state')) for variable in ['WAYLAND_SOCKET', 'FERESE_SOCKET', 'FERESE_SHELL_CONTROL_SOCKET']: env.pop(variable, None) binary = REPO / os.environ.get('FERESE_TEST_BINARY', 'target/debug/ferese') ctl = REPO / os.environ.get('FERESE_TEST_CTL', 'target/debug/feresectl') with (root / 'compositor.log').open('w') as log: child = subprocess.Popen([str(binary), '--backend=nested'], env=ipc_environment(env), stdout=log, stderr=log, start_new_session=True) def wait(check): deadline = time.monotonic() + 8 while time.monotonic() < deadline: if child.poll() is not None: raise AssertionError((root / 'compositor.log').read_text()) if check(): return time.sleep(.02) raise AssertionError('Timed out\n' + (root / 'compositor.log').read_text()) wait(lambda: (ipc_socket(runtime)).exists()) def call(*args): return json.loads(subprocess.check_output([str(ctl), "-j", *args], env=ipc_environment(env), timeout=5)) def request(command, args): payload = json.dumps({'version': 1, 'id': 17, 'type': 'command', 'command': command, 'args': args}).encode() connection = socket.socket(socket.AF_UNIX) connection.settimeout(3) connection.connect(str(ipc_socket(runtime))) connection.sendall(struct.pack('!I', len(payload)) + payload) return connection def response(connection): def read(size): data = b'' while len(data) < size: chunk = connection.recv(size - len(data)) assert chunk, "IPC closed before the response completed" data += chunk return data result = json.loads(read(struct.unpack('!I', read(4))[0])) connection.close() assert result['id'] == 17 return result def selected(name): wait(lambda: call('media')['selected'] and call('media')['selected']['name'] == name) env['WAYLAND_DISPLAY'] = str(next(path for path in runtime.glob('wayland-*') if not path.name.endswith('.lock'))) shell_binary = os.environ.get('FERESE_TEST_SHELL') if shell_binary: shell = subprocess.Popen([str(REPO / shell_binary)], env=ipc_environment(env), stdout=log, stderr=log, start_new_session=True) first = players[0] selected(first.name) initial = call('media') watcher = request('media-watch', {'since': initial['revision']}) first.set('Playing') update = response(watcher)['result'] assert update['selected']['status'] == 'playing' assert update['selected']['title'] == 'first track' assert update['selected']['artist'] == 'Artist' # No timer queries while the player is idle and unchanged. time.sleep(.1) reads = first.reads time.sleep(1.2) assert first.reads == reads, (first.reads, reads) second = FakePlayer('second', 'Playing') players.append(second) selected(second.name) first.title = 'A different first track' first.changed(['Metadata']) time.sleep(.1) assert call('media')['selected']['name'] == second.name call('media', 'pin', first.name) selected(first.name) call('media', 'next') assert first.calls[-1][0] == 'Next' call('media', 'previous') assert first.calls[-1][0] == 'Previous' call('media', 'raise') assert first.calls[-1][0] == 'Raise' assert 'error' not in response(request('media-action', {'action': 'volume', 'delta': 0.1})) wait(lambda: abs(call('media')['selected']['volume'] - 0.6) < 1e-9) assert abs(first.volume - 0.6) < 1e-9 assert 'error' in response(request('media-action', {'action': 'volume', 'delta': 2.0})) stale = response(request('media-action', {'action': 'play-pause', 'player': first.name, 'owner': ':999999'})) assert 'error' in stale before = len(first.calls) first.can_next = False first.changed(['CanGoNext']) wait(lambda: not call('media')['selected']['can_next']) assert 'error' in response(request('media-action', {'action': 'next'})) assert len(first.calls) == before assert 'error' in response(request('media-action', {'action': 'seek', 'track_id': '/track/stale', 'position_us': 10_000_000})) assert 'error' not in response(request('media-action', {'action': 'seek', 'track_id': '/track/one', 'position_us': 10_000_000})) wait(lambda: call('media')['selected']['position_us'] == 10_000_000) call('media', 'ignore', first.name) selected(second.name) call('media', 'unignore', first.name) call('media', 'pin', first.name) first.close() players.remove(first) selected(second.name) assert call('media')['pinned'] is None replacement = FakePlayer('first', 'Paused') players.append(replacement) wait(lambda: len(call('media')['players']) == 2) assert call('media')['selected']['name'] == second.name call('media', 'play-pause') wait(lambda: second.status == 'Paused') second.set('Stopped') selected(replacement.name) replacement.set('Stopped') wait(lambda: call('media')['selected'] is None) assert child.poll() is None if shell: time.sleep(.3) assert shell.poll() is None, (root / "compositor.log").read_text() print('MPRIS discovery, selection, watches, capabilities, stale actions, seeking and owner replacement passed.') finally: for player in players: player.close() for process in [shell, child]: if process and process.poll() is None: os.killpg(process.pid, signal.SIGTERM) try: process.wait(timeout=5) except subprocess.TimeoutExpired: os.killpg(process.pid, signal.SIGKILL) process.wait() loop.quit() if __name__ == '__main__': if sys.argv[1:] == ['--private']: private_checks() else: unittest.main()