Add terminal VM menu and focused session handoff

This commit is contained in:
Codex
2026-10-13 15:18:03 +03:00
parent b547d6c535
commit c6099103b2
5 changed files with 675 additions and 65 deletions
+168
View File
@@ -0,0 +1,168 @@
import contextlib
import importlib
import importlib.util
import io
import json
import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from hydra.profiles import Profile
from hydra.runtime import Runtime, RuntimeError
class MenuTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix='hm-')
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.manager = Runtime(self.root / 'config', self.root / 'state', self.root / 'run')
def menu(self):
self.assertIsNotNone(importlib.util.find_spec('hydra.menu'), 'Terminal menu is missing')
return importlib.import_module('hydra.menu')
def run_choices(self, choices, **kwargs):
output = io.StringIO()
with patch('builtins.input', side_effect=choices), contextlib.redirect_stdout(output):
code = self.menu().run_menu(self.manager, **kwargs)
self.assertEqual(code, 0)
return output.getvalue()
def test_display_is_readable_and_never_starts_a_vm(self):
self.manager.add(Profile('alpha', memory=512))
with patch.object(Runtime, 'start', side_effect=AssertionError('display started a VM')):
output = self.run_choices([':quit'])
for label in ('Hydra OS', 'alpha', 'stopped', '512 MiB', ':run', ':network', ':shell'):
self.assertIn(label, output)
self.assertNotIn('"status":', output)
self.assertFalse(self.manager.paths('alpha').disk.exists())
def test_number_selection_persists_and_enter_reuses_it(self):
self.manager.add(Profile('alpha'))
self.manager.add(Profile('beta'))
module = self.menu()
selected = []
def open_vm(runtime, name, **kwargs):
selected.append(name)
return 0
with patch.object(module, 'open_vm', side_effect=open_vm):
self.run_choices(['2', ':quit'])
self.run_choices(['', ':quit'])
self.assertEqual(selected, ['beta', 'beta'])
self.assertEqual(json.loads((self.manager.state / 'menu.json').read_text()), {'last_selected': 'beta'})
self.assertEqual((self.manager.state / 'menu.json').stat().st_mode & 0o777, 0o600)
def test_names_s_and_q_remain_vm_selections(self):
for name in ('s', 'q'):
self.manager.add(Profile(name))
selected = []
with patch.object(self.menu(), 'open_vm', side_effect=lambda runtime, name, **kw: selected.append(name) or 0):
self.run_choices(['s', 'q', ':quit'])
self.assertEqual(selected, ['s', 'q'])
def test_invalid_profile_and_unknown_identity_leave_other_vms_accessible(self):
self.manager.add(Profile('alpha'))
self.manager.add(Profile('beta'))
(self.manager.config / 'bad.toml').write_text('memory = "invalid"')
self.manager.paths('alpha').spice.parent.mkdir(parents=True)
self.manager.paths('alpha').spice.touch()
module = self.menu()
with patch.object(module, 'open_vm', side_effect=RuntimeError('alpha: unknown identity; inspect status')):
output = self.run_choices(['alpha', ':help', ':quit'])
self.assertIn('bad.toml', output)
self.assertIn('unknown', output)
self.assertIn('beta', output)
self.assertFalse((self.manager.state / 'menu.json').exists())
def test_empty_or_removed_selection_does_not_launch_and_shell_can_repair_profiles(self):
module = self.menu()
def repair(argv):
self.manager.add(Profile('fresh'))
return type('Result', (), {'returncode': 0})()
with patch.object(module.subprocess, 'run', side_effect=repair), patch.object(module, 'open_vm', return_value=0) as launch:
output = self.run_choices(['', ':shell', '', ':quit'])
self.assertIn('No VM profiles', output)
self.assertEqual([call.args[1] for call in launch.call_args_list], ['fresh'])
def test_missing_network_tool_and_cancelled_action_return_to_menu(self):
module = self.menu()
with patch.object(module.shutil, 'which', return_value=None):
output = self.run_choices([':network', ':run', '', ':quit'])
self.assertIn('nmtui', output)
self.assertIn('cancel', output.lower())
def test_refresh_and_successful_session_selection_return_to_compositor(self):
self.manager.add(Profile('beta'))
manifest = self.root / 'manifest.json'
module = self.menu()
with patch.object(module, 'open_vm', return_value=0) as launch:
self.run_choices([''], manifest=manifest)
self.assertEqual(launch.call_args.args[1], 'beta')
self.assertEqual(launch.call_args.kwargs['manifest'], manifest)
with patch.object(module, 'request_refresh', return_value=0) as refresh:
self.run_choices([':refresh'], manifest=manifest)
self.assertEqual(refresh.call_args.args, (manifest,))
def test_orderly_shutdown_and_detach_are_explicit_and_do_not_force_stop(self):
iso = self.root / 'install.iso'; iso.touch()
self.manager.add(Profile('alpha', iso))
module = self.menu()
with patch.object(Runtime, 'stop', side_effect=AssertionError('must not force stop')), patch.object(Runtime, 'shutdown', return_value={'status': 'shutdown-requested'}) as shutdown:
self.run_choices([':shutdown alpha', ':detach alpha', ':quit'])
self.assertEqual(shutdown.call_args.args, ('alpha',))
self.assertIsNone(self.manager.profile('alpha').iso)
def test_download_renders_safe_preview_and_transfer_progress(self):
module = self.menu()
iso = self.root / 'saved.iso'
def transfer(url, *, sha256=None, progress=None):
self.assertEqual(url, 'https://example.test/live.iso?token=private-token')
self.assertIsNotNone(progress, 'Download menu needs progress feedback')
progress(1048576, 2097152)
iso.write_bytes(b'complete fixture')
progress(2097152, 2097152)
return iso
with patch.object(module, 'download_iso', side_effect=transfer):
output = self.run_choices([':download https://example.test/live.iso?token=private-token', '', ':quit'])
self.assertEqual(iso.read_bytes(), b'complete fixture')
self.assertIn('2.0 / 2.0 MiB', output)
self.assertIn(str(iso), output)
self.assertNotIn('private-token', output)
def test_guided_run_applies_allocations_and_returns_after_selected_handoff(self):
module = self.menu()
def launch(runtime, source, **kwargs):
self.assertEqual(source, '/tmp/example.iso')
self.assertEqual(kwargs['disk_size'], '8G')
self.assertEqual(kwargs.get('defaults'), Path('/etc/hydra/defaults.toml'))
runtime.add(Profile(kwargs['name'], memory=kwargs['memory'], cpus=kwargs['cpus']))
return 0
with patch.object(module, 'run_source', side_effect=launch):
self.run_choices([':run /tmp/example.iso', 'new-vm', '512', '1', '8G', ''], manifest=self.root / 'manifest.json')
self.assertEqual(self.manager.profile('new-vm').memory, 512)
self.assertEqual(self.manager.profile('new-vm').cpus, 1)
self.assertEqual(json.loads((self.manager.state / 'menu.json').read_text()), {'last_selected': 'new-vm'})
def test_duplicate_guided_run_does_not_download_or_replace_profile(self):
self.manager.add(Profile('alpha', memory=512))
before = (self.manager.config / 'alpha.toml').read_bytes()
with patch.object(self.menu(), 'run_source', side_effect=AssertionError('existing name must not launch')):
output = self.run_choices([':run https://example.test/live.iso', 'alpha', ':quit'])
self.assertIn('hydra open alpha', output)
self.assertEqual((self.manager.config / 'alpha.toml').read_bytes(), before)
def test_corrupt_saved_selection_and_removed_vm_do_not_block_menu(self):
self.manager.state.mkdir()
(self.manager.state / 'menu.json').write_text('null')
self.run_choices(['', ':quit'])
(self.manager.state / 'menu.json').write_text('{"last_selected": "removed"}')
with patch.object(self.menu(), 'open_vm', side_effect=AssertionError('removed VM must not open')):
self.run_choices(['', ':quit'])
if __name__ == '__main__':
unittest.main()
+136 -18
View File
@@ -4,6 +4,7 @@ import io
import json
import os
import shlex
import shutil
import socket
import subprocess
import tempfile
@@ -13,7 +14,7 @@ from unittest.mock import patch
from xml.etree import ElementTree as ET
from hydra.profiles import Profile
from hydra.runtime import Runtime
from hydra.runtime import Runtime, RuntimeError as VMError
from hydra import session
from hydra.cli import main, parser
@@ -34,7 +35,7 @@ class SessionTests(unittest.TestCase):
self.assertEqual([n.text for n in tree.findall('./desktops/names/name')], ['alpha', 'beta'])
self.assertEqual(tree.findtext('./desktops/popupTime'), '0')
rules = tree.findall('./windowRules/windowRule')
self.assertEqual([r.get('title') for r in rules], ['HydraOS: alpha', 'HydraOS: beta'])
self.assertEqual([r.get('title') for r in rules], ['Hydra OS: alpha', 'Hydra OS: beta'])
for index, rule in enumerate(rules, 1):
self.assertEqual(rule.get('serverDecoration'), 'no')
self.assertEqual(rule.find("./action[@name='SendToDesktop']").get('to'), str(index))
@@ -214,7 +215,7 @@ sys.exit(0)
tree = ET.fromstring(seen[1]['xml'])
self.assertEqual(tree.find('desktops').get('number'), '2')
self.assertEqual([rule.get('title') for rule in tree.findall('./windowRules/windowRule')],
['HydraOS: first', 'HydraOS: second'])
['Hydra OS: first', 'Hydra OS: second'])
self.assertFalse(manager.paths('first').disk.exists())
def test_no_running_vms_opens_recovery_without_start_or_stop(self):
@@ -226,7 +227,7 @@ sys.exit(0)
manifest.write_text(json.dumps({'config': str(self.manager.config), 'state': str(self.manager.state), 'runtime': str(self.manager.runtime), 'names': ['alpha', 'beta'], 'terminal': str(terminal), 'directory': str(self.root)}))
with patch.dict(os.environ, {'CAPTURE': str(capture)}), patch.object(Runtime, 'start', side_effect=AssertionError('must not start')), patch.object(Runtime, 'stop', side_effect=AssertionError('must not stop')):
self.assertEqual(session.session_helper('clients', manifest), 0)
self.assertEqual(json.loads(capture.read_text())[0], '--title=HydraOS-recovery')
self.assertEqual(json.loads(capture.read_text())[0], '--title=Hydra OS recovery')
self.assertEqual(self.manager.status('alpha')['status'], 'stopped')
self.assertFalse(self.manager.paths('alpha').disk.exists())
@@ -265,27 +266,22 @@ else:
return manifest, {'WAYLAND_DISPLAY': 'unused', 'HYDRA_VIEWER': str(viewer), 'RECOVERY_CAPTURE': str(self.root), 'SHELL': str(shell)}
def _check_recovery_vm_name(self, name):
import importlib.util
self.assertIsNotNone(importlib.util.find_spec('hydra.menu'), 'Terminal menu is missing')
from hydra import menu
manifest, env = self._recovery_fixture()
processes = []
original_popen = subprocess.Popen
def launch(*args, **kwargs):
process = original_popen(*args, **kwargs)
processes.append(process)
return process
with patch.dict(os.environ, env), patch('builtins.input', side_effect=[name, ':quit', EOFError()]), patch.object(subprocess, 'Popen', launch), contextlib.redirect_stdout(io.StringIO()):
with patch.dict(os.environ, env), patch('builtins.input', side_effect=[name, ':quit']), \
patch.object(menu, 'open_vm', return_value=0) as launch, contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(session.session_helper('recover', manifest), 0)
for process in processes:
self.assertEqual(process.wait(timeout=5), 0)
connected = self.root / (name + '.connected')
self.assertTrue(connected.exists(), f'VM {name} was not reconnected')
self.assertEqual(connected.read_text(), name)
self.assertEqual(launch.call_args.args[1], name)
self.assertEqual(launch.call_args.kwargs['manifest'], manifest)
self.assertFalse((self.root / 'shell.opened').exists())
self.assertFalse(self.manager.paths(name).disk.exists())
def test_recovery_reconnects_vm_named_s(self):
def test_recovery_selects_vm_named_s(self):
self._check_recovery_vm_name('s')
def test_recovery_reconnects_vm_named_q(self):
def test_recovery_selects_vm_named_q(self):
self._check_recovery_vm_name('q')
def test_recovery_colon_commands_open_shell_and_close_terminal(self):
@@ -297,5 +293,127 @@ else:
self.assertFalse((self.root / 'q.connected').exists())
def test_refresh_payload_selects_fresh_profile_and_legacy_empty_request_is_accepted(self):
manifest = self.root / 'session.json'
manifest.write_text(json.dumps({'config': str(self.manager.config), 'state': str(self.manager.state),
'runtime': str(self.manager.runtime), 'names': ['alpha'], 'terminal': '/bin/sh'}))
self.assertTrue(hasattr(session, 'request_refresh'), 'Selected refresh API is missing')
self.assertEqual(session.request_refresh(manifest, selected='beta'), 0)
request = self.root / 'refresh.request'
self.assertEqual(json.loads(request.read_text()), {'selected': 'beta'})
self.assertEqual(request.stat().st_mode & 0o777, 0o600)
request.unlink()
self.assertEqual(session.request_refresh(manifest), 0)
self.assertEqual(request.read_bytes(), b'')
def test_refresh_rejects_invalid_or_removed_selection_without_publishing(self):
manifest = self.root / 'session.json'
manifest.write_text(json.dumps({'config': str(self.manager.config), 'state': str(self.manager.state),
'runtime': str(self.manager.runtime), 'names': ['alpha'], 'terminal': '/bin/sh'}))
self.assertTrue(hasattr(session, 'request_refresh'), 'Selected refresh API is missing')
for name in ('missing', '../alpha', 'bad*'):
with self.assertRaises((ValueError, OSError)):
session.request_refresh(manifest, selected=name)
self.assertFalse((self.root / 'refresh.request').exists())
def test_selected_second_workspace_survives_compositor_refresh_without_starting_guests(self):
capture = self.root / 'selected-capture.json'
compositor = self.root / 'selected-labwc'
compositor.write_text('#!' + os.sys.executable + '\n' + r'''import json, os, sys
from pathlib import Path
if '--version' in sys.argv:
print('labwc 0.20.2')
sys.exit(0)
config = Path(sys.argv[sys.argv.index('-C') + 1])
manifest = config.parent / 'session.json'
capture = Path(os.environ['CAPTURE'])
seen = json.loads(capture.read_text()) if capture.exists() else []
seen.append({'xml': (config / 'rc.xml').read_text(), 'manifest_env': os.environ.get('HYDRA_SESSION_MANIFEST'),
'manifest_path': str(manifest)})
capture.write_text(json.dumps(seen))
if len(seen) == 1:
(manifest.parent / 'refresh.request').write_text('{"selected": "beta"}')
''')
compositor.chmod(0o700)
with patch.dict(os.environ, {'HYDRA_LABWC': str(compositor), 'HYDRA_TERMINAL': str(compositor),
'HYDRA_BRIGHTNESS': str(compositor), 'CAPTURE': str(capture)}), \
patch.object(Runtime, 'start', side_effect=AssertionError('session must never start guests')):
self.assertEqual(session.launch_session(self.manager), 0)
seen = json.loads(capture.read_text())
self.assertEqual(len(seen), 2)
self.assertEqual(ET.fromstring(seen[1]['xml']).findtext('./desktops/initial'), '2')
self.assertTrue(all(s['manifest_env'] == s['manifest_path'] for s in seen))
self.assertFalse(self.manager.paths('alpha').disk.exists())
self.assertFalse(self.manager.paths('beta').disk.exists())
def test_recovery_failure_leaves_emergency_shell_accessible(self):
manifest, env = self._recovery_fixture()
import importlib.util
self.assertIsNotNone(importlib.util.find_spec('hydra.menu'), 'Terminal menu is missing')
from hydra import menu
with patch.dict(os.environ, env), patch.object(menu, 'run_menu', side_effect=TypeError('broken menu')), \
patch('builtins.input', side_effect=[':shell', ':quit']), contextlib.redirect_stdout(io.StringIO()) as output:
try:
code = session.session_helper('recover', manifest)
except TypeError as exc:
self.fail(f'Menu failure escaped emergency recovery: {exc}')
self.assertEqual(code, 0)
self.assertIn('emergency', output.getvalue().lower())
self.assertTrue((self.root / 'shell.opened').exists())
def test_runtime_status_error_opens_recovery_without_starting_guests(self):
manifest = self.root / 'session.json'
manifest.write_text(json.dumps({'config': str(self.manager.config), 'state': str(self.manager.state),
'runtime': str(self.manager.runtime), 'names': ['alpha'], 'terminal': shutil.which('true')}))
with patch.object(Runtime, 'status', side_effect=VMError('private runtime permission problem')), \
patch.object(Runtime, 'start', side_effect=AssertionError('must not start')), \
contextlib.redirect_stderr(io.StringIO()) as error:
try:
code = session.session_helper('clients', manifest)
except VMError as exc:
self.fail(f'Status failure escaped session recovery: {exc}')
self.assertEqual(code, 0)
self.assertIn('private runtime permission problem', error.getvalue())
def test_invalid_profile_is_excluded_from_compositor_but_valid_profiles_remain(self):
(self.manager.config / 'bad.toml').write_text('memory = "broken"')
capture = self.root / 'invalid-capture.json'
compositor = self.root / 'invalid-labwc'
compositor.write_text('#!' + os.sys.executable + '\n' + r'''import json, os, sys
from pathlib import Path
if '--version' in sys.argv:
print('labwc 0.20.2')
else:
config = Path(sys.argv[sys.argv.index('-C') + 1])
Path(os.environ['CAPTURE']).write_text((config.parent / 'session.json').read_text())
''')
compositor.chmod(0o700)
with patch.dict(os.environ, {'HYDRA_LABWC': str(compositor), 'HYDRA_TERMINAL': str(compositor),
'HYDRA_BRIGHTNESS': str(compositor), 'CAPTURE': str(capture)}), \
contextlib.redirect_stderr(io.StringIO()) as error:
self.assertEqual(session.launch_session(self.manager), 0)
self.assertEqual(json.loads(capture.read_text())['names'], ['alpha', 'beta'])
self.assertIn('bad.toml', error.getvalue())
def test_paused_guests_reconnect_viewers_without_start_or_resume(self):
manifest, env = self._recovery_fixture()
terminal = self.root / 'exit-terminal'
terminal.write_text('#!' + os.sys.executable + '\n')
terminal.chmod(0o700)
data = json.loads(manifest.read_text())
data['terminal'] = str(terminal)
manifest.write_text(json.dumps(data))
with patch.dict(os.environ, env), patch.object(Runtime, 'status', return_value={'status': 'paused'}), \
patch.object(Runtime, 'start', side_effect=AssertionError('must not start/resume')), \
patch.object(Runtime, 'stop', side_effect=AssertionError('must not stop')), \
contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(session.session_helper('clients', manifest), 0)
for name in ('s', 'q'):
self.assertTrue((self.root / (name + '.connected')).exists(), f'Paused {name} was not reconnected')
self.assertFalse(self.manager.paths(name).disk.exists())
if __name__ == '__main__':
unittest.main()