import os import subprocess from pathlib import Path import pytest from openpilot.starpilot.soloist import backend, client, credentials, preferences, supervisor from openpilot.starpilot.soloist.paths import Layout @pytest.fixture def layout(tmp_path): return Layout(tmp_path / 'soloist') def test_credentials_are_private_and_never_echoed(layout): assert not credentials.present(layout) with pytest.raises(credentials.InvalidKey): credentials.store('short', layout) with pytest.raises(credentials.InvalidKey): credentials.store('abc def ghi jkl mno pqr', layout) credentials.store(' sk_live_0123456789abcdef ', layout) assert credentials.load(layout) == 'sk_live_0123456789abcdef' assert layout.api_key.stat().st_mode & 0o777 == 0o600 assert layout.api_key.parent.stat().st_mode & 0o777 == 0o700 credentials.remove(layout) assert not credentials.present(layout) def test_preferences_validate_and_round_trip(layout): assert preferences.load(layout) == preferences.Preferences() saved = preferences.save(preferences.Preferences(device_name=' Civic ', output_address='aa:bb:cc:dd:ee:ff', output_name='Car'), layout) assert saved.device_name == 'Civic' and saved.output_address == 'AA:BB:CC:DD:EE:FF' assert preferences.load(layout) == saved assert preferences.validated({**preferences.Preferences().__dict__, 'output': 'starport'}).output == 'starport' for bad in ({'device_name': ''}, {'cache_mb': 50}, {'initial_volume': 101}, {'output': 'speaker'}, {'output_address': 'nope'}): with pytest.raises(ValueError): preferences.validated({**preferences.Preferences().__dict__, **bad}) layout.config.write_text('{"device_name": 7}') assert preferences.load(layout) == preferences.Preferences() def test_enabled_flag(layout): from openpilot.starpilot.soloist.paths import enabled assert not enabled(layout) preferences.set_enabled(True, layout) assert enabled(layout) preferences.set_enabled(False, layout) assert not enabled(layout) def test_alsa_config_is_process_local_bluealsa_pcm(): text = backend.alsa_config('AA:BB:CC:DD:EE:FF', Path('/usr/share/alsa/alsa.conf')) assert text.startswith('') assert 'type bluealsa' in text and 'device "AA:BB:CC:DD:EE:FF"' in text and 'profile "a2dp"' in text with pytest.raises(backend.BackendUnavailable): backend.alsa_config('AA:BB:CC:DD:EE:FF"\n}', Path('/usr/share/alsa/alsa.conf')) def test_launch_commands_isolate_the_server_and_player(layout): prefs = preferences.Preferences(output_address='AA:BB:CC:DD:EE:FF', initial_volume=60) server = backend.server_launch(layout, prefs, alsa_base=Path('/usr/share/alsa/alsa.conf')) assert '-n' in server.argv and '--system=no' in server.argv assert any(arg.startswith('module-alsa-sink device=starpilot_spotify') for arg in server.argv) assert server.env['ALSA_CONFIG_PATH'].startswith(str(layout.runtime)) assert (layout.runtime / 'alsa.conf').stat().st_mode & 0o777 == 0o600 test = backend.server_launch(layout, preferences.Preferences(output='test')) assert any(arg.startswith('module-null-sink') for arg in test.argv) and 'ALSA_CONFIG_PATH' not in test.env silent = backend.server_launch(layout, prefs, silent=True, alsa_base=Path('/usr/share/alsa/alsa.conf')) assert 'module-null-sink sink_name=spotify_silent' in silent.argv and 'ALSA_CONFIG_PATH' in silent.env player = backend.player_launch(layout, prefs, 'sk_live_0123456789abcdef') assert 'PULSE_SINK' not in player.env # follows the server's default sink, so outputs can switch live assert player.argv[player.argv.index('--ws') + 1] == '127.0.0.1:0' assert player.argv[player.argv.index('--cache-size') + 1] == '256' assert player.argv[player.argv.index('--initial-volume') + 1] == '60' assert player.env['PULSE_SERVER'] == f'unix:{layout.runtime}/pulse/native' def test_vendored_server_matches_its_checksums(): import hashlib root = backend.SERVER_ROOT assert backend.bundle_ready() sums = [line.split() for line in (root / 'SHA256SUMS').read_text().splitlines() if line.strip()] assert {name for _, name in sums} >= {'bin/pulseaudio', 'lib/libpulsecore-16.1.so', 'modules/module-pipe-sink.so'} for digest, name in sums: assert hashlib.sha256((root / name).read_bytes()).hexdigest() == digest, name for name in ('pulseaudio', 'libltdl7', 'liborc-0.4-0t64', 'libsoxr0', 'libspeexdsp1', 'libtdb1'): assert (root / 'licenses' / f'{name}.copyright').is_file() def test_starport_output_is_a_real_time_pipe(layout): launch = backend.server_launch(layout, preferences.Preferences(output='starport')) sink = next(arg for arg in launch.argv if arg.startswith('module-pipe-sink')) assert f'file={backend.starport_pipe(layout)}' in sink assert 'rate=48000' in sink and 'channels=2' in sink and 'use_system_clock_for_timing=yes' in sink assert launch.env['LD_LIBRARY_PATH'] == str(backend.SERVER_ROOT / 'lib') ENTITY = {'uri': 'spotify:track:1', 'entity_type': 'track', 'decorations': { 'identity': {'name': 'My Song\x07'}, 'visual_identity': {'cover': [{'url': 'http://i.scdn.co/a', 'size': 'default'}, {'url': 'https://i.scdn.co/image/x', 'size': 'large'}, {'url': 'https://evil.example/x', 'size': 'default'}]}, 'parent': {'entity': {'decorations': {'identity': {'name': 'Album'}}}}, 'creators': [{'entity': {'decorations': {'identity': {'name': 'A'}}}}, {'entity': {'decorations': {'identity': {'name': 'B'}}}}], 'playback': {'duration_ms': 210000}}} def test_player_state_normalizes_untrusted_events(): state = client.PlayerState() state.apply({'type': 'auth_state', 'logged_in': True, 'is_active': False, 'device_name': 'StarPilot'}) state.apply({'type': 'playback_state', 'status': 'playing', 'item': ENTITY, 'volume': 65, 'is_active': True, 'position': {'position_ms': 45000, 'timestamp_ms': 1747654321000, 'speed': 1.0}, 'options': {'shuffle': True, 'repeat': 'context'}, 'available_actions': {'pause': {}, 'skip_next': {}, 'seek_forward': {'step_ms': 15000}, 'bogus': {}}}) value = state.value assert value['track'] == {'title': 'My Song', 'artists': 'A, B', 'album': 'Album', 'artwork': 'https://i.scdn.co/image/x', 'kind': 'track', 'durationMs': 210000} assert value['actions'] == ['pause', 'skip_next'] and value['shuffle'] and value['repeat'] == 'context' state.apply({'type': 'volume_changed', 'volume': 900}) assert value['volume'] == 65 state.apply({'type': 'playback_changed', 'status': 'paused'}) assert value['status'] == 'paused' state.apply({'type': 'auth_state', 'logged_in': False}) assert value['track'] is None and value['actions'] == [] def test_controls_map_to_documented_commands(): assert client.commands('next', {}) == [{'type': 'command', 'command': 'skip_next'}] assert client.commands('volume', {'volume': 40}) == [{'type': 'command', 'command': 'set_volume', 'volume': 40}] assert [c['command'] for c in client.commands('repeat', {'mode': 'track'})] == ['set_repeat_context', 'set_repeat_track'] assert [(c['command'], c['enabled']) for c in client.commands('repeat', {'mode': 'off'})] == \ [('set_repeat_track', False), ('set_repeat_context', False)] for action, args in (('volume', {'volume': 101}), ('seek', {'positionMs': -1}), ('play', {'uri': 'x'}), ('shell', {}), ('shuffle', {'enabled': 1})): with pytest.raises(ValueError): client.commands(action, args) def test_endpoint_only_accepts_loopback(tmp_path): assert client.endpoint(tmp_path) is None (tmp_path / 'ws.addr').write_text('127.0.0.1\n') (tmp_path / 'ws.port').write_text('41103\n') assert client.endpoint(tmp_path) == 'ws://127.0.0.1:41103' (tmp_path / 'ws.addr').write_text('0.0.0.0') assert client.endpoint(tmp_path) is None class FakeProcess: def __init__(self, argv, **kwargs): import io self.argv, self.kwargs = argv, kwargs self.pid = 99999999 self.code = None self.stdout = io.BytesIO(b'I client expires in 42 days\nkey ' + b'sk_live_0123456789abcdef\n' if 'soloist' in argv[0] else b'') def poll(self): return self.code def wait(self, timeout=None): return self.code class FakeClient: logged_in = True def __init__(self, _data): self.on_change = lambda: None self.started = self.stopped = False self.sent = [] def start(self): self.started = True def stop(self): self.stopped = True def snapshot(self): return {'connected': True, 'loggedIn': self.logged_in, 'status': 'idle', 'active': False} def send(self, action, args): self.sent.append((action, args)) def ready_layout(layout, monkeypatch, *, expired=False): monkeypatch.setattr(supervisor.phone_audio, 'profile', lambda action: None) monkeypatch.setattr(supervisor.package, 'release_status', lambda _l: {'installed': True, 'expired': expired}) monkeypatch.setattr(supervisor.backend, 'bundle_ready', lambda: True) layout.root.mkdir(parents=True, exist_ok=True) credentials.store('sk_live_0123456789abcdef', layout) preferences.save(preferences.Preferences(output='test'), layout) def make_supervisor(layout, spawned, logged_in=True): def spawn(argv, **kwargs): process = FakeProcess(argv, **kwargs) spawned.append(process) if 'pulseaudio' in argv[0]: backend.runtime_paths(layout)['socket'].touch() return process now = [100.0] make_client = type('Client', (FakeClient,), {'logged_in': logged_in}) sup = supervisor.Supervisor(layout, spawn=spawn, clock=lambda: now[0], make_client=make_client, network=lambda: True) # ty: ignore[invalid-argument-type] sup.run_async = lambda fn: fn() return sup, now def test_supervisor_reports_missing_setup_steps(layout, monkeypatch): sup, _ = make_supervisor(layout, []) sup.tick() assert sup.status()['state'] == 'missing_package' monkeypatch.setattr(supervisor.package, 'release_status', lambda _l: {'installed': True}) sup.tick() assert sup.status()['state'] == 'missing_key' credentials.store('sk_live_0123456789abcdef', layout) sup.tick() assert sup.status()['state'] == 'no_output' def test_supervisor_starts_server_then_player_and_redacts_key(layout, monkeypatch): ready_layout(layout, monkeypatch) spawned = [] sup, _ = make_supervisor(layout, spawned, logged_in=False) sup.tick() assert [Path(p.argv[0]).name for p in spawned] == ['pulseaudio', 'soloist'] assert all(p.kwargs['start_new_session'] for p in spawned) assert sup.status()['state'] == 'waiting' import time time.sleep(0.1) assert sup.days_left == 42 assert all('sk_live' not in line for line in sup.log) assert sup.handle({'command': 'control', 'action': 'pause', 'args': {}}) == {'ok': True} assert sup.client.sent == [('pause', {})] assert sup.handle({'command': 'control', 'action': 'play_pause', 'args': {}}) == {'ok': True} assert sup.client.sent[-1] == ('play', {}) sup.client.snapshot = lambda: {'status': 'playing'} sup.handle({'command': 'control', 'action': 'play_pause', 'args': {}}) assert sup.client.sent[-1] == ('pause', {}) def test_supervisor_stops_retrying_an_expired_build(layout, monkeypatch): ready_layout(layout, monkeypatch) spawned = [] sup, now = make_supervisor(layout, spawned) monkeypatch.setattr(supervisor, '_kill_group', lambda _p: None) sup.tick() sup.player.code = 10 now[0] += 1 sup.tick() assert sup.status()['state'] == 'expired' now[0] += 1000 sup.tick() assert len(spawned) == 2 def test_supervisor_backs_off_and_rolls_back_a_failing_build(layout, monkeypatch): ready_layout(layout, monkeypatch) rolled = [] monkeypatch.setattr(supervisor.package, 'rollback', lambda _l: rolled.append(True) or True) monkeypatch.setattr(supervisor, '_kill_group', lambda _p: None) spawned = [] sup, now = make_supervisor(layout, spawned) for _ in range(supervisor.MAX_FAST_FAILURES): sup.tick() sup.player.code = 1 now[0] += 1 sup.tick() assert sup.status()['state'] == 'retrying' now[0] += 100 assert rolled == [True] def _speaker_supervisor(layout, monkeypatch, *, can_switch): ready_layout(layout, monkeypatch) preferences.save(preferences.Preferences(output_address='AA:BB:CC:DD:EE:FF', output_name='Car'), layout) class Output: connected = False def ready(self, address): return self.connected switched = [] monkeypatch.setattr(supervisor.backend, 'can_switch', lambda: can_switch) monkeypatch.setattr(supervisor.backend, 'switch_output', lambda _layout, speaker: switched.append(speaker)) monkeypatch.setattr(supervisor, '_kill_group', lambda _p: None) spawned = [] sup, now = make_supervisor(layout, spawned) sup.output = Output() sup.tick() assert sup.status()['state'] == 'ready' and sup.status()['outputWaiting'] == 'Car' assert len(spawned) == 2 and sup.client.started assert 'module-null-sink sink_name=spotify_silent' in spawned[0].argv sup.output.connected = True for _ in range(2): # one check is not enough; the speaker has to stay connected now[0] += supervisor.OUTPUT_RETRY_SECONDS + 1 sup.tick() assert len(spawned) == 2 and switched == [] sup.tick() return sup, now, spawned, switched def test_speaker_returning_switches_audio_without_restarting_soloist(layout, monkeypatch): sup, now, spawned, switched = _speaker_supervisor(layout, monkeypatch, can_switch=True) client = sup.client assert switched == [True] and len(spawned) == 2 and sup.client is client and not client.stopped assert sup.status()['outputWaiting'] == '' sup.output.connected = False for _ in range(3): now[0] += supervisor.OUTPUT_RETRY_SECONDS + 1 sup.tick() assert switched == [True, False] and len(spawned) == 2 assert sup.status()['outputWaiting'] == 'Car' def test_speaker_switch_falls_back_to_a_restart(layout, monkeypatch): sup, _now, spawned, switched = _speaker_supervisor(layout, monkeypatch, can_switch=False) (layout.state / 'saved-session').write_text('retained') sup.tick() assert switched == [] and len(spawned) == 4 assert any(arg.startswith('module-alsa-sink') for arg in spawned[2].argv) assert (layout.state / 'saved-session').read_text() == 'retained' assert sup.status()['outputWaiting'] == '' def test_switch_output_moves_streams_and_drops_the_old_sink(layout): calls = [] outputs = {('list', 'short', 'sink-inputs'): '7\t1\t3\tprotocol-native.c\ts16le 2ch 44100Hz\n', ('list', 'short', 'modules'): ('0\tmodule-native-protocol-unix\tsocket=/x\n' + '1\tmodule-null-sink\tsink_name=spotify_silent\n' + '2\tmodule-alsa-sink\tdevice=starpilot_spotify sink_name=spotify channels=2 tsched=0\n')} def run(argv, **kwargs): calls.append(tuple(argv[1:])) assert kwargs['env']['PULSE_SERVER'].startswith('unix:') and kwargs['timeout'] and kwargs['check'] return subprocess.CompletedProcess(argv, 0, outputs.get(tuple(argv[1:]), '')) backend.switch_output(layout, True, run=run) assert calls == [('load-module', 'module-alsa-sink', 'device=starpilot_spotify', 'sink_name=spotify', 'channels=2', 'tsched=0'), ('set-default-sink', 'spotify'), ('list', 'short', 'sink-inputs'), ('move-sink-input', '7', 'spotify'), ('list', 'short', 'modules'), ('unload-module', '1')] calls.clear() backend.switch_output(layout, False, run=run) assert calls[0] == ('load-module', 'module-null-sink', 'sink_name=spotify_silent') and calls[-1] == ('unload-module', '2') def test_control_socket_round_trip(layout, tmp_path): import threading from openpilot.starpilot.galaxy.spotify_setup import SpotifyClient sup, _ = make_supervisor(layout, []) import tempfile folder = tempfile.mkdtemp(prefix='sp-', dir='/tmp') # AF_UNIX paths are short on macOS path = folder + '/s.sock' stop = threading.Event() thread = threading.Thread(target=supervisor.serve, args=(sup, path, stop), daemon=True) thread.start() try: import time client = SpotifyClient(path) result = None for attempt in range(50): if not Path(path).exists(): time.sleep(0.02) continue try: result = client.call('status') break except (FileNotFoundError, ConnectionRefusedError): if attempt == 49: raise time.sleep(0.02) assert result is not None assert result['status']['state'] == 'starting' with pytest.raises(RuntimeError): client.call('control', action='pause', args={}) finally: stop.set() thread.join(timeout=2) Path(path).unlink(missing_ok=True) os.rmdir(folder) assert not thread.is_alive() def test_reap_orphans_only_targets_feature_binaries(layout, tmp_path, monkeypatch): proc = tmp_path / 'proc' (proc / '100').mkdir(parents=True) (proc / '200').mkdir() binary = layout.root / 'releases' / 'x' / 'soloist' binary.parent.mkdir(parents=True) binary.write_text('') (proc / '100' / 'exe').symlink_to(binary) (proc / '200' / 'exe').symlink_to('/usr/bin/bluealsa') killed = [] monkeypatch.setattr(supervisor.os, 'kill', lambda pid, sig: killed.append(pid)) assert supervisor.reap_orphans(layout, proc) == 1 and killed == [100] def test_status_publishes_the_spotify_track_for_the_car_screen(layout): sup, _ = make_supervisor(layout, []) sup.client = type('Client', (), {'snapshot': lambda self: { 'connected': True, 'loggedIn': True, 'active': True, 'status': 'playing', 'position': None, 'track': {'title': 'Song', 'artists': 'Band', 'album': '', 'durationMs': 1000}}})() assert sup.status()['nowPlaying']['title'] == 'Song' sup.client = None assert sup.status()['nowPlaying'] is None def test_status_publishes_the_phone_track_only_while_phone_audio_runs(layout): sup, _ = make_supervisor(layout, []) sup.phone_prefs = object() sup.phone_track = {'Status': 'playing', 'Track': {'Title': 'Phone Song', 'Artist': 'Band'}} assert sup.status()['nowPlaying'] is None sup.player = object() found = sup.status()['nowPlaying'] assert found['source'] == 'phone' and found['title'] == 'Phone Song' def test_supervisor_waits_for_network_before_starting(layout, monkeypatch): ready_layout(layout, monkeypatch) spawned = [] sup, _ = make_supervisor(layout, spawned) up = [False] sup.network = lambda: up[0] sup.tick() assert spawned == [] assert sup.status()['state'] == 'waiting_network' assert sup.fast_failures == 0 up[0] = True sup.tick() assert [Path(p.argv[0]).name for p in spawned] == ['pulseaudio', 'soloist'] def test_changing_mute_values_never_restarts_the_player(layout, monkeypatch): from dataclasses import replace ready_layout(layout, monkeypatch) spawned = [] sup, now = make_supervisor(layout, spawned) sup.tick() assert len(spawned) == 2 preferences.save(replace(preferences.load(layout), pause_mute=1.2, skip_mute=0.0), layout) assert preferences.load(layout).pause_mute == 1.2 now[0] += 1 sup.tick() assert len(spawned) == 2 and not sup.client.stopped and all(p.poll() is None for p in spawned) preferences.save(replace(preferences.load(layout), cache_mb=512), layout) # a real change still restarts sup.tick() assert len(spawned) == 4 def test_changing_mute_values_keeps_phone_audio_running(layout, monkeypatch): from dataclasses import replace ready_layout(layout, monkeypatch) preferences.save(preferences.Preferences(output='test', source='phone', phone_address='AA:BB:CC:DD:EE:FF'), layout) spawned = [] sup, now = make_supervisor(layout, spawned) sup.phone.powered = lambda: True sup.phone.ready = lambda *_a: True sup.phone.track = lambda _a: None monkeypatch.setattr(supervisor.phone_audio, 'profile', lambda *_a: None) sup.tick() assert [Path(p.argv[0]).name for p in spawned][0] == 'pulseaudio' and len(spawned) == 2 preferences.save(replace(preferences.load(layout), pause_mute=1.5, skip_mute=0.05), layout) now[0] += 1 sup.tick() assert len(spawned) == 2 and all(p.poll() is None for p in spawned) preferences.save(replace(preferences.load(layout), cache_mb=512), layout) sup.tick() assert len(spawned) == 4 @pytest.mark.parametrize(('ipv4', 'ipv6', 'expected'), [ ('', '', False), ('Iface Destination Gateway Flags', '', False), ('wlan0 00000000 0100000A 0003', '', True), ('', '0' * 32 + ' 00 ' + '0' * 32 + ' 00 ' + '0' * 32 + ' 00000001 00000000 00000000 00000003 wlan0', True), ('', '0' * 32 + ' 00 ' + '0' * 32 + ' 00 ' + '0' * 32 + ' 00000001 00000000 00000000 00200200 lo', False), ('wlan0 00000000 0100000A 0201', '', False), ('lo 00000000 00000000 0001', '', False), ]) def test_network_ready_accepts_either_address_family(monkeypatch, ipv4, ipv6, expected): from io import StringIO monkeypatch.setattr('builtins.open', lambda path: StringIO(ipv4 if path.endswith('/route') else ipv6)) assert supervisor.network_ready() is expected