Repair the official desktop console without replacing existing work
This commit is contained in:
1 parent
7c9371b76a
commit
e98d0d70e6
7 files changed
+447
-19
No files matched your search
@@ -33,6 +33,26 @@ class ConsoleTests(unittest.TestCase):
|
||||
mock.patch.object(Path, "read_text", return_value="tty7\n"):
|
||||
self.assertTrue(console._active_graphical_display({"DISPLAY": ":0", "XDG_SESSION_ID": "19"}))
|
||||
|
||||
def test_desktop_launcher_without_session_environment_uses_verified_own_display(self):
|
||||
with mock.patch.object(console.subprocess, "run", side_effect=[
|
||||
SimpleNamespace(returncode=0, stdout="19\n"), self.desktop()]) as run, \
|
||||
mock.patch.object(Path, "read_text", return_value="tty7\n"):
|
||||
self.assertTrue(console._active_graphical_display({"DISPLAY": ":0"}))
|
||||
self.assertEqual(run.call_args_list[0].args[0], [
|
||||
"loginctl", "show-user", str(os.getuid()), "--property=Display", "--value"])
|
||||
self.assertIn("19", run.call_args_list[1].args[0])
|
||||
|
||||
def test_missing_primary_session_does_not_guess_a_display_session(self):
|
||||
with mock.patch.object(console.subprocess, "run", return_value=
|
||||
SimpleNamespace(returncode=0, stdout="\n")) as run:
|
||||
self.assertFalse(console._active_graphical_display({"DISPLAY": ":0"}))
|
||||
run.assert_called_once()
|
||||
|
||||
def test_invalid_supplied_session_is_not_replaced(self):
|
||||
with mock.patch.object(console.subprocess, "run") as run:
|
||||
self.assertFalse(console._active_graphical_display({"DISPLAY": ":0", "XDG_SESSION_ID": "../bad"}))
|
||||
run.assert_not_called()
|
||||
|
||||
def test_private_virtual_display_is_not_chosen_as_user_desktop(self):
|
||||
with mock.patch.object(console.subprocess, "run", return_value=self.desktop()), \
|
||||
mock.patch.object(Path, "read_text", return_value="tty7\n"):
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import shlex
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from framebridge_expanded import console_desktop
|
||||
from framebridge_expanded.console_ui import framebridge_console as ui
|
||||
|
||||
|
||||
@unittest.skipIf(os.getuid() == 0, "desktop migration requires a normal user")
|
||||
class DesktopMigrationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
root = Path(self.temp.name)
|
||||
self.home = root / "home"
|
||||
self.home.mkdir(mode=0o700)
|
||||
self.state = self.home / "profile"
|
||||
self.runtime = root / "runtime"
|
||||
self.runtime.mkdir(mode=0o700)
|
||||
parent = self.runtime / "testhost-tty1"
|
||||
parent.mkdir(mode=0o700)
|
||||
self.sock = socket.socket(socket.AF_UNIX)
|
||||
self.sock.bind(str(parent / "console.sock"))
|
||||
os.chmod(parent / "console.sock", 0o600)
|
||||
self.addCleanup(self.sock.close)
|
||||
self.desktop = self.home / ".local/share/applications/framebridge-console.desktop"
|
||||
self.desktop.parent.mkdir(parents=True, mode=0o700)
|
||||
self.desktop.write_bytes(console_desktop._legacy_entry(self.home))
|
||||
self.wrapper = self.home / ".local/bin/framebridge-console"
|
||||
self.wrapper.parent.mkdir(parents=True, mode=0o700)
|
||||
self.wrapper.write_bytes(b"#!/usr/bin/python3\npass\n")
|
||||
self.old_hash = hashlib.sha256(self.wrapper.read_bytes()).hexdigest()
|
||||
|
||||
def migrate(self, windows="@0|Console\n@1|FB-piloto\n@3|FB-Muffin\n"):
|
||||
def result(args, **kwargs):
|
||||
text = windows if "list-windows" in args else "bash|0|shell\npython3|0|monitor\n"
|
||||
return SimpleNamespace(returncode=0, stdout=text)
|
||||
with mock.patch.object(console_desktop, "LEGACY_WRAPPER_SHA256", self.old_hash), \
|
||||
mock.patch.object(console_desktop.subprocess, "run", side_effect=result) as run:
|
||||
value = console_desktop.migrate_legacy(
|
||||
self.state, home=self.home, node="TestHost", runtime=self.runtime)
|
||||
return value, run
|
||||
|
||||
def test_known_legacy_icon_keeps_existing_socket_and_session(self):
|
||||
before = self.desktop.read_bytes()
|
||||
value, run = self.migrate()
|
||||
self.assertTrue(value["changed"])
|
||||
self.assertFalse(value["runtime_changed"])
|
||||
self.assertEqual((self.state / "console-desktop-legacy.backup").read_bytes(), before)
|
||||
self.assertEqual(self.desktop.read_bytes(), console_desktop._new_entry(self.state))
|
||||
context = console_desktop.legacy_context(self.state, "TestHost", self.runtime)
|
||||
self.assertEqual(context["session"], "TESTHOST-FBTERM")
|
||||
self.assertEqual(context["socket"], self.runtime / "testhost-tty1/console.sock")
|
||||
self.assertEqual(run.call_count, 2)
|
||||
self.assertIn("list-windows", run.call_args_list[0].args[0])
|
||||
self.assertIn("list-panes", run.call_args_list[1].args[0])
|
||||
second, run = self.migrate()
|
||||
self.assertFalse(second["changed"])
|
||||
run.assert_not_called()
|
||||
|
||||
def test_custom_desktop_entry_is_preserved(self):
|
||||
self.desktop.write_text("[Desktop Entry]\nExec=my-custom-console\n")
|
||||
before = self.desktop.read_bytes()
|
||||
value, run = self.migrate()
|
||||
self.assertEqual(value["reason"], "CUSTOM_DESKTOP_ENTRY_PRESERVED")
|
||||
self.assertEqual(self.desktop.read_bytes(), before)
|
||||
self.assertFalse(self.state.exists())
|
||||
run.assert_not_called()
|
||||
|
||||
def test_changed_legacy_wrapper_does_not_get_replaced(self):
|
||||
self.wrapper.write_text("custom owner command\n")
|
||||
before = self.desktop.read_bytes()
|
||||
with self.assertRaisesRegex(ValueError, "LEGACY_WRAPPER_CHANGED"):
|
||||
self.migrate()
|
||||
self.assertEqual(self.desktop.read_bytes(), before)
|
||||
self.assertFalse(self.state.exists())
|
||||
|
||||
def test_ambiguous_console_window_prevents_migration(self):
|
||||
before = self.desktop.read_bytes()
|
||||
with self.assertRaisesRegex(ValueError, "LEGACY_WINDOW_UNVERIFIED"):
|
||||
self.migrate("@0|Console\n@1|Console\n")
|
||||
self.assertEqual(self.desktop.read_bytes(), before)
|
||||
self.assertFalse(self.state.exists())
|
||||
|
||||
def test_symlinked_entry_is_refused_without_touching_target(self):
|
||||
self.desktop.unlink()
|
||||
target = self.home / "owner-file"
|
||||
target.write_text("keep\n")
|
||||
self.desktop.symlink_to(target)
|
||||
with self.assertRaisesRegex(ValueError, "SYMLINK_REFUSED"):
|
||||
self.migrate()
|
||||
self.assertEqual(target.read_text(), "keep\n")
|
||||
|
||||
def test_foreign_profile_cannot_reuse_the_binding(self):
|
||||
self.migrate()
|
||||
record = self.state / console_desktop.RECORD
|
||||
raw = record.read_text().replace(str(self.state), str(self.state) + "-other")
|
||||
record.write_text(raw)
|
||||
with self.assertRaisesRegex(ValueError, "RECORD_INVALID"):
|
||||
console_desktop.legacy_context(self.state, "TestHost", self.runtime)
|
||||
|
||||
|
||||
class ConsoleWindowSelectionTests(unittest.TestCase):
|
||||
def console(self, windows):
|
||||
console = ui.Console(SimpleNamespace(socket="/no-real-socket", session="NODE-FBTERM"))
|
||||
def tmux(*args, **kwargs):
|
||||
if args[0] == "list-sessions":
|
||||
return "NODE-FBTERM|$0"
|
||||
if args[0] == "list-windows":
|
||||
return windows
|
||||
raise AssertionError(args)
|
||||
with mock.patch.object(ui.subprocess, "run", return_value=SimpleNamespace(returncode=0)), \
|
||||
mock.patch.object(console, "tmux", side_effect=tmux):
|
||||
value = console.exists()
|
||||
return console, value
|
||||
|
||||
def test_monitor_receives_profile_jobs_root_as_an_explicit_argument(self):
|
||||
args = SimpleNamespace(socket="/owned/socket", session="NODE-FBTERM", node="NODE",
|
||||
jobs_root=Path("/profile with space/jobs"))
|
||||
command = shlex.split(ui.Console(args).monitor_command())
|
||||
self.assertEqual(command[-2:], ["--jobs-root", "/profile with space/jobs"])
|
||||
|
||||
def test_desktop_entry_escapes_percent_field_codes(self):
|
||||
entry = console_desktop._new_entry(Path("/home/user/100%complete/profile"))
|
||||
self.assertIn(b"100%%complete", entry)
|
||||
|
||||
def test_desktop_entry_refuses_newline_in_profile_path(self):
|
||||
with self.assertRaisesRegex(ValueError, "STATE_PATH_INVALID"):
|
||||
console_desktop._new_entry(Path("/home/user/profile\nExec=bad"))
|
||||
|
||||
def test_extra_owner_windows_do_not_prevent_console_attachment(self):
|
||||
console, value = self.console("@0|Console\n@1|FB-piloto\n@3|FB-Muffin")
|
||||
self.assertTrue(value)
|
||||
self.assertEqual(console.window, "@0")
|
||||
self.assertEqual(console.session_target, "$0")
|
||||
|
||||
def test_ambiguous_console_window_is_not_guessed(self):
|
||||
with self.assertRaisesRegex(RuntimeError, "absent or ambiguous"):
|
||||
self.console("@0|Console\n@1|Console")
|
||||
|
||||
def test_multiwindow_session_without_console_is_preserved(self):
|
||||
with self.assertRaisesRegex(RuntimeError, "absent or ambiguous"):
|
||||
self.console("@0|Owner work\n@1|Other")
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("tmux"), "tmux is required")
|
||||
class RealConsolePreservationTests(unittest.TestCase):
|
||||
def test_apply_preserves_shell_and_other_windows_in_existing_session(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
sock = str(Path(directory) / "console.sock")
|
||||
def tmux(*args):
|
||||
return subprocess.check_output(["/usr/bin/tmux", "-S", sock, *args], text=True).strip()
|
||||
try:
|
||||
tmux("new-session", "-d", "-s", "TEST-FBTERM", "-n", "Console", "/bin/bash --noprofile --norc")
|
||||
shell = tmux("display-message", "-p", "-t", "=TEST-FBTERM:Console", "#{pane_pid}")
|
||||
tmux("new-window", "-d", "-t", "=TEST-FBTERM", "-n", "FB-piloto", "/bin/bash --noprofile --norc")
|
||||
tmux("new-window", "-d", "-t", "=TEST-FBTERM", "-n", "FB-Muffin", "/bin/bash --noprofile --norc")
|
||||
fmt = "#{window_name}|#{pane_id}|#{pane_pid}"
|
||||
other = tmux("list-panes", "-a", "-F", fmt).splitlines()[1:]
|
||||
jobs = Path(directory) / "profile-jobs"
|
||||
args = SimpleNamespace(socket=sock, session="TEST-FBTERM", node="Test", jobs_root=jobs,
|
||||
layout="horizontal", reflow=False, refresh_monitor=False)
|
||||
ui.Console(args).apply()
|
||||
panes = tmux("list-panes", "-t", "=TEST-FBTERM:Console", "-F", "#{pane_pid}|#{@framebridge_role}")
|
||||
self.assertIn(shell + "|shell", panes)
|
||||
start_commands = tmux("list-panes", "-t", "=TEST-FBTERM:Console", "-F", "#{pane_start_command}")
|
||||
self.assertIn("--jobs-root", start_commands)
|
||||
self.assertIn(str(jobs), start_commands)
|
||||
after = tmux("list-panes", "-a", "-F", fmt).splitlines()
|
||||
for row in other:
|
||||
self.assertIn(row, after)
|
||||
self.assertEqual(tmux("list-windows", "-t", "=TEST-FBTERM", "-F", "#{window_name}"),
|
||||
"Console\nFB-piloto\nFB-Muffin")
|
||||
finally:
|
||||
subprocess.run(["/usr/bin/tmux", "-S", sock, "kill-server"], capture_output=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user