Add terminal VM menu and focused session handoff
This commit is contained in:
+136
-18
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user