Files
StarPilot/openpilot/selfdrive/ui/tests/test_arabesque_terminal.py
T
firestarsdog e982987197 Arabesque
2026-10-16 08:40:20 -04:00

393 lines
16 KiB
Python

"""Terminal styling, real Widget input, and a private native shell session."""
from collections import deque
import ctypes
import os
from pathlib import Path
import shutil
import sys
import time
from types import SimpleNamespace as NS
from unittest.mock import Mock, call
import pyray as rl
import pytest
from openpilot.selfdrive.ui.layouts.arabesque.tools import terminal
from openpilot.system.ui.lib.application import gui_app, MouseEvent, MousePos
def test_styles_preserve_positions_colors_reverse_and_hidden_text():
rows = terminal._styled_rows(['a\x1b[31;44mred\x1b[7mR\x1b[27;8msecret\x1b[0m界e\u0301',
'\x1b[38;5;196;48;2;10;20;30mrgb\x1b[39;49mdefault'])
assert rows[0][1][0:4] == (1, 'red', terminal._PALETTE[1], terminal._PALETTE[4])
assert rows[0][2][4] is True
assert rows[0][3][5] is True
assert rows[0][4][2:6] == (None, None, False, False)
assert rows[0][4][6] == 3
assert rows[1][0][2:4] == ((255, 0, 0), (10, 20, 30))
assert rows[1][1][2:4] == (None, None)
def test_screen_text_cannot_impersonate_a_control_reply():
session = terminal._Session.__new__(terminal._Session)
session._block = None
session._pending = deque([('screen', 2), ('cursor', 1)])
for line in (b'%begin 123 4 1', b'%end 123 4 1', 'café'.encode(), b'%end 123 4 1',
b'%begin 123 5 1', b'3 1 1', b'%end 123 5 1'):
session._line(line)
assert session.rows[0][0][1] == '%end 123 4 1'
assert session.rows[1][0][1] == 'café'
assert session.cursor == (3, 1, True)
assert not session._pending
@pytest.fixture
def tool(monkeypatch):
import openpilot.system.ui.widgets as widgets
monkeypatch.setattr(widgets, 'device', NS(awake=True))
monkeypatch.setattr(gui_app, '_mouse_events', [])
monkeypatch.setattr(gui_app, '_render_prepare', {})
monkeypatch.setattr(gui_app, '_show_touches', False)
monkeypatch.setattr(terminal, 'gui_label', Mock())
monkeypatch.setattr(terminal, 'device', NS(_reset_interactive_timeout=Mock()))
monkeypatch.setattr(gui_app, 'font', Mock(return_value=NS(texture=NS(id=2), baseSize=16, glyphs=[NS(advanceX=8, value=63)])))
monkeypatch.setattr(rl, 'get_font_default', lambda: NS(texture=NS(id=1)))
monkeypatch.setattr(rl, 'load_font_ex', Mock(return_value=NS(texture=NS(id=3), baseSize=64, glyphs=[NS(advanceX=29, value=63)])))
monkeypatch.setattr(rl, 'gen_texture_mipmaps', Mock())
monkeypatch.setattr(rl, '_orig_draw_text_ex', Mock(), raising=False)
monkeypatch.setattr(rl, 'get_glyph_index', lambda *a: 0)
for name in ('unload_font', 'set_texture_filter', 'draw_rectangle_rec', 'draw_rectangle_rounded',
'draw_rectangle_lines_ex', 'rl_draw_render_batch_active', 'begin_scissor_mode', 'end_scissor_mode', 'draw_text_ex'):
monkeypatch.setattr(rl, name, Mock())
monkeypatch.setattr(rl, 'set_exit_key', Mock())
monkeypatch.setattr(rl, 'get_key_pressed', lambda: 0)
monkeypatch.setattr(rl, 'get_char_pressed', lambda: 0)
monkeypatch.setattr(rl, 'is_key_down', lambda key: False)
monkeypatch.setattr(rl, 'is_key_pressed_repeat', lambda key: False)
session = NS(rows=[], cursor=(0, 0, False), error='', update=Mock(), close=Mock(), resize=Mock(), keys=Mock())
monkeypatch.setattr(terminal, '_Session', Mock(return_value=session))
widget = terminal.Terminal()
widget.set_rect(rl.Rectangle(0, 104, 2160, 768))
widget.show_event()
yield widget, session
widget.hide_event()
def touch(widget, key):
bounds = next(rect for rect, _, value in widget._buttons if value == key)
pos = MousePos(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2)
for down in (True, False):
gui_app._mouse_events = [MouseEvent(pos, 0, down, not down, down, 1)]
widget.render()
gui_app._mouse_events = []
def test_touch_keyboard_sends_bytes_and_control_keys_and_resizes(tool):
widget, session = tool
initial_rows = widget._size[1]
touch(widget, 'keyboard')
assert widget._keyboard and widget._size[1] < initial_rows
for key in ('l', 's', terminal.ENTER_KEY, 'ctrl', 'c', 'Up'):
touch(widget, key)
assert [call.args[0] for call in session.keys.call_args_list] == [b'l', b's', 'Enter', b'\x03', 'Up']
assert not widget._ctrl
assert min(rect.height for rect, _, _ in widget._buttons) >= 44
session.resize.assert_called_with(*widget._size)
def test_disabled_tool_drops_keyboard_input_without_sending_it(tool, monkeypatch):
widget, session = tool
widget.set_enabled(False)
keys = iter([rl.KEY_ENTER, 0])
chars = iter([ord('x'), 0])
monkeypatch.setattr(rl, 'get_key_pressed', lambda: next(keys, 0))
monkeypatch.setattr(rl, 'get_char_pressed', lambda: next(chars, 0))
widget._update_state()
session.keys.assert_not_called()
def test_physical_control_and_escape_are_terminal_input(tool, monkeypatch):
widget, session = tool
keys = iter([rl.KEY_C, rl.KEY_ESCAPE, 0])
chars = iter([ord('c'), 0])
monkeypatch.setattr(rl, 'get_key_pressed', lambda: next(keys, 0))
monkeypatch.setattr(rl, 'get_char_pressed', lambda: next(chars, 0))
monkeypatch.setattr(rl, 'is_key_down', lambda key: key == rl.KEY_LEFT_CONTROL)
widget._update_state()
assert [call.args[0] for call in session.keys.call_args_list] == [b'\x03', 'Escape']
rl.set_exit_key.assert_called_with(0)
def test_prepare_caches_rows_and_hide_releases_only_owned_resources(tool):
widget, session = tool
owned = widget._font
lifecycle = Mock()
lifecycle.attach_mock(rl.rl_draw_render_batch_active, 'flush')
lifecycle.attach_mock(rl.unload_font, 'unload')
session.rows = terminal._styled_rows(['\x1b[7minverse'])
widget._prepare()
spans = widget._draw_rows
assert spans[0][0][2] is widget._theme.background
assert spans[0][0][3] is widget._theme.text
widget._prepare()
assert widget._draw_rows is spans
assert widget._prepare in gui_app._render_prepare
widget.hide_event()
widget.hide_event()
assert not gui_app._render_prepare
session.close.assert_called_once()
assert lifecycle.mock_calls == [call.flush(), call.unload(owned)]
rl.set_exit_key.assert_called_with(rl.KEY_ESCAPE)
def test_font_is_loaded_once_per_entry_and_not_during_render(tool):
widget, session = tool
args = rl.load_font_ex.call_args.args
assert Path(args[0]).name == 'JetBrainsMono-Medium.ttf'
assert args[1:] == (64, None, 0)
rl.set_texture_filter.assert_called_once_with(widget._font.texture, rl.TextureFilter.TEXTURE_FILTER_TRILINEAR)
session.rows = terminal._styled_rows(['local shell'])
widget.show_event()
for _ in range(3):
widget.render()
rl.load_font_ex.assert_called_once()
rl.gen_texture_mipmaps.assert_called_once()
assert len(gui_app._render_prepare) == 1
def test_grid_matches_selected_c_preview_and_sizes_from_actual_glyphs(tool, monkeypatch):
widget, session = tool
# Calibrate the large-UI preview independently of the surrounding suite's UI mode.
monkeypatch.setattr(terminal, 'FONT_SCALE', 1.242)
widget.set_rect(rl.Rectangle(0, 104, 2160, 976))
assert widget._size == (109, 18)
for font, size in ((widget._font, widget._font_size), (widget._fallback_font, widget._fallback_size)):
assert font.glyphs[0].advanceX * size / font.baseSize == pytest.approx(widget._cell_w)
session.resize.assert_called_with(109, 18)
def test_unicode_fallback_keeps_colors_columns_cursor_and_explicit_font(tool, monkeypatch):
widget, session = tool
shared, owned = widget._fallback_font, widget._font
shared.glyphs += [NS(advanceX=8, value=ord('ü')), NS(advanceX=16, value=ord('日'))]
monkeypatch.setattr(rl, 'get_glyph_index', lambda font, value: next((i for i, g in enumerate(font.glyphs) if g.value == value), 0))
session.rows = terminal._styled_rows(['A\x1b[31;44mü日B\x1b[7mC'])
session.cursor = (6, 0, True)
widget._prepare()
spans = widget._draw_rows[0]
assert [(s[0], s[1], s[5], s[6]) for s in spans] == [
(0, 'A', 1, owned), (1, 'ü日', 3, shared), (4, 'B', 1, owned), (5, 'C', 1, owned)]
assert spans[1][2:4] == spans[2][2:4]
assert (spans[3][2].r, spans[3][2].g, spans[3][2].b) == terminal._PALETTE[4]
assert (spans[3][3].r, spans[3][3].g, spans[3][3].b) == terminal._PALETTE[1]
widget._render(widget.rect)
draws = rl._orig_draw_text_ex.call_args_list
assert [draw.args[0] for draw in draws] == [owned, shared, owned, owned]
for draw, column in zip(draws, (0, 1, 4, 5), strict=True):
assert draw.args[2].x == pytest.approx(widget._screen.x + column * widget._cell_w)
assert draws[1].args[3] == widget._fallback_size
assert rl.draw_rectangle_lines_ex.call_args.args[0].x == pytest.approx(widget._screen.x + 6 * widget._cell_w)
rl.draw_text_ex.assert_not_called()
@pytest.mark.parametrize('failure', ['default', 'oserror'])
def test_font_load_failure_uses_shared_font_without_unloading_it(tool, failure):
widget, session = tool
widget.hide_event()
rl.unload_font.reset_mock()
rl.rl_draw_render_batch_active.reset_mock()
if failure == 'default':
rl.load_font_ex.return_value = rl.get_font_default()
else:
rl.load_font_ex.side_effect = OSError('font unavailable')
widget.show_event()
assert widget._font is widget._fallback_font
session.rows = terminal._styled_rows(['still usable'])
widget._prepare()
widget._render(widget.rect)
widget.hide_event()
rl.unload_font.assert_not_called()
rl.rl_draw_render_batch_active.assert_not_called()
@pytest.fixture
def real_session(tmp_path, monkeypatch):
if sys.platform != 'linux' or shutil.which('tmux') is None:
raise pytest.skip.Exception('requires the Linux OS tmux binary')
# Own/reap test servers even under a container PID 1; production uses the OS init.
assert ctypes.CDLL(None, use_errno=True).prctl(36, 1, 0, 0, 0) == 0
monkeypatch.setenv('HOME', str(tmp_path))
monkeypatch.setenv('HISTFILE', '/dev/null')
before = len(list(Path('/proc/self/fd').iterdir()))
children = Path(f'/proc/{os.getpid()}/task/{os.getpid()}/children')
before_children = set(children.read_text().split())
session = terminal._Session(80, 24)
yield session
session.close()
deadline = time.monotonic() + .5
while time.monotonic() < deadline:
try:
if not os.waitpid(-1, os.WNOHANG)[0]:
time.sleep(.005)
except ChildProcessError:
break
assert len(list(Path('/proc/self/fd').iterdir())) == before
assert not set(children.read_text().split()) - before_children
def wait_screen(session, predicate):
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
session.update()
assert not session.error, session.error
text = [''.join(span[1] for span in row).rstrip() for row in session.rows]
if predicate(text):
return text
time.sleep(.003)
raise AssertionError('shell did not produce expected output')
def test_real_shell_state_unicode_resize_interrupt_and_exit(real_session):
session = real_session
wait_screen(session, lambda rows: bool(rows))
session.keys("cd /tmp; printf 'READY_CAFE:café\\n'\r".encode())
wait_screen(session, lambda rows: 'READY_CAFE:café' in rows)
session.resize(100, 30)
wait_screen(session, lambda rows: len(rows) == 30)
session.keys(b"pwd; stty size\r")
wait_screen(session, lambda rows: '/tmp' in rows and '30 100' in rows)
session.keys(b'sleep 30\r')
for _ in range(15):
session.update()
time.sleep(.003)
session.keys(b'\x03')
session.keys(b"printf 'INTERRUPTED\\n'\r")
wait_screen(session, lambda rows: 'INTERRUPTED' in rows)
session.keys(b'exit\r')
deadline = time.monotonic() + 2
while session._process is not None and time.monotonic() < deadline:
session.update()
time.sleep(.003)
assert session._process is None and 'exited' in session.error
def test_real_output_flood_and_atomic_input_limit(real_session):
session = real_session
wait_screen(session, lambda rows: bool(rows))
session.keys(b'yes flood\r')
for _ in range(50):
session.update()
assert len(session._buffer) <= 262144 and len(session._pending) <= 64
time.sleep(.003)
session.keys(b'\x03')
session.keys(b"printf 'FLOOD_STOPPED\\n'\r")
wait_screen(session, lambda rows: 'FLOOD_STOPPED' in rows)
session.keys(b'x' * 2049)
assert session._process is None and 'input limit' in session.error
session.update()
def test_exported_links_and_legacy_borders_do_not_occupy_text_cells():
rows = terminal._styled_rows(['\x1b]8;;https://example.org\x1b\\link\x1b]8;;\x1b\\ \x0elqqk\x0f'])
assert ''.join(span[1] for span in rows[0]) == 'link ┌──┐'
assert sum(span[6] for span in rows[0]) == 9
def test_prepare_cannot_flush_commands_before_main_checks_offroad(tool):
widget, session = tool
widget._prepare()
session.update.assert_not_called()
widget._update_state()
session.update.assert_called_once()
def test_shared_font_fallback_preserves_columns(tool):
widget, session = tool
session.rows = terminal._styled_rows(['─界e\u0301'])
widget._prepare()
assert widget._draw_rows[0][0][1] == '-? ?'
assert widget._draw_rows[0][0][5] == 4
def test_underline_color_does_not_change_foreground():
spans = terminal._styled_rows(['\x1b[58:2::1:2:31mtext'])[0]
assert spans[0][2:4] == (None, None)
@pytest.mark.parametrize('key,modifier,expected', [(rl.KEY_LEFT, rl.KEY_LEFT_CONTROL, 'C-Left'),
(rl.KEY_TAB, rl.KEY_LEFT_SHIFT, 'BTab')])
def test_physical_navigation_modifiers(tool, monkeypatch, key, modifier, expected):
widget, session = tool
keys = iter([key])
monkeypatch.setattr(rl, 'get_key_pressed', lambda: next(keys, 0))
monkeypatch.setattr(rl, 'is_key_down', lambda value: value == modifier)
widget._update_state()
session.keys.assert_called_once_with(expected)
def test_text_and_enter_in_one_frame_keep_their_order(tool, monkeypatch):
widget, session = tool
keys = iter([rl.KEY_L, rl.KEY_S, rl.KEY_ENTER])
chars = iter([ord('l'), ord('s')])
monkeypatch.setattr(rl, 'get_key_pressed', lambda: next(keys, 0))
monkeypatch.setattr(rl, 'get_char_pressed', lambda: next(chars, 0))
widget._update_state()
assert [call.args[0] for call in session.keys.call_args_list] == [b'l', b's', 'Enter']
def test_cleanup_waits_are_bounded_even_after_forced_termination(monkeypatch):
session = terminal._Session.__new__(terminal._Session)
process = Mock()
process.wait.side_effect = terminal.subprocess.TimeoutExpired('tmux', .02)
session._process, session._folder = process, Mock()
session._out, session._buffer, session._pending = bytearray(), bytearray(), deque()
monkeypatch.setattr(os, 'write', Mock())
session.close()
process.kill.assert_called_once()
assert [call.kwargs for call in process.wait.call_args_list] == [{'timeout': .02}, {'timeout': .02}]
def test_repeated_character_before_enter_is_not_sent_after_enter(tool, monkeypatch):
widget, session = tool
keys = iter([rl.KEY_ENTER])
chars = iter([ord('s')])
monkeypatch.setattr(rl, 'get_key_pressed', lambda: next(keys, 0))
monkeypatch.setattr(rl, 'get_char_pressed', lambda: next(chars, 0))
widget._update_state()
assert [call.args[0] for call in session.keys.call_args_list] == [b's', 'Enter']
@pytest.mark.parametrize('failure', ['process', 'directory'])
def test_startup_failure_is_visible_and_can_be_closed(monkeypatch, failure):
target = terminal.subprocess if failure == 'process' else terminal.tempfile
monkeypatch.setattr(target, 'Popen' if failure == 'process' else 'TemporaryDirectory', Mock(side_effect=OSError('unavailable')))
session = terminal._Session(80, 24)
assert session.error and session._process is None
if session._folder:
assert not Path(session._folder.name).exists()
session.update()
session.close()
def test_real_client_failure_cleans_up_private_server(real_session):
session = real_session
wait_screen(session, lambda rows: bool(rows))
session._process.terminate()
deadline = time.monotonic() + 2
while session._process is not None and time.monotonic() < deadline:
session.update()
time.sleep(.003)
assert session._process is None and session.error
def test_real_startup_can_be_cancelled_immediately(real_session):
real_session.close()
assert real_session._process is None
def test_keyboard_activity_resets_the_existing_idle_timer(tool):
widget, session = tool
widget._send('x')
terminal.device._reset_interactive_timeout.assert_called_once()
session.keys.assert_called_once_with(b'x')