159 lines
8.8 KiB
Python
159 lines
8.8 KiB
Python
"""Adaptive text/graphical selection; inert CUA stubs never send user input."""
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import tempfile
|
|
from types import SimpleNamespace
|
|
import unittest
|
|
from unittest.mock import patch
|
|
from framebridge_expanded import adapter
|
|
from framebridge_expanded import config
|
|
from framebridge_expanded import display_context as dc
|
|
from framebridge_expanded import runtime
|
|
|
|
|
|
def physical(**changes):
|
|
data=dict(kind='physical-x11',physical=True,session_id='fixture-session',uid=os.getuid(),
|
|
leader=123,active_vt='tty7',seat='seat0',vtnr=7,display=':0',authority='/private/fixture.Xauthority')
|
|
data.update(changes)
|
|
return data
|
|
|
|
|
|
def capture_output(valid=True):
|
|
if not valid: return 'VISUAL_CAPTURE_GATE=PASS\nMIME=image/jpeg\nBASE64=invalid'
|
|
pixels=b'\xff\xd8synthetic-unit-fixture\xff\xd9'
|
|
return '\n'.join(['RESULTADO_FINAL=CUA_VISUAL_OBSERVE_PASS','VISUAL_CAPTURE_GATE=PASS','MIME=image/jpeg','BYTES='+str(len(pixels)),
|
|
'SHA256='+hashlib.sha256(pixels).hexdigest(),'BASE64='+base64.b64encode(pixels).decode()])
|
|
|
|
|
|
class AdaptiveDisplayTests(unittest.TestCase):
|
|
def test_auto_missing_graphical_tools_selects_text_and_clears_inherited_display(self):
|
|
env={'DISPLAY':':99','XAUTHORITY':'/unrelated'}
|
|
with patch.object(dc,'_physical',return_value=physical()), patch.object(dc,'missing_graphical_commands',return_value=['xdotool']):
|
|
dc.configure(env,'auto','/profile')
|
|
self.assertEqual(dc.describe(env)['kind'],'text')
|
|
self.assertNotIn('DISPLAY',env)
|
|
self.assertNotIn('XAUTHORITY',env)
|
|
self.assertFalse(dc.private_display_required(env,'auto',False))
|
|
|
|
def test_text_to_graphical_and_back_preserves_profile_and_requires_new_observation(self):
|
|
env={};dc.bind_text(env,'/profile')
|
|
with patch.object(dc,'_physical',return_value=physical()),patch.object(dc,'missing_graphical_commands',return_value=[]):
|
|
dc.prepare(env)
|
|
self.assertEqual(dc.check(env)['kind'],'physical-x11')
|
|
self.assertTrue(dc.describe(env)['needs_observation'])
|
|
dc.prepare(env)
|
|
self.assertTrue(dc.describe(env)['needs_observation'])
|
|
with patch.object(dc,'_physical',side_effect=dc.DisplayContextError('NO_SESSION')):
|
|
dc.prepare(env)
|
|
self.assertEqual(dc.describe(env)['kind'],'text')
|
|
self.assertEqual(dc.describe(env)['profile'],'/profile')
|
|
with self.assertRaisesRegex(dc.DisplayContextError,'CUA_DISPLAY_UNAVAILABLE'):
|
|
dc.check(env)
|
|
|
|
def stub(self,env,valid=True):
|
|
calls=[]
|
|
engine=SimpleNamespace()
|
|
def native(payload,timeout_s=60):
|
|
calls.append(payload)
|
|
return {'executed':True,'output':capture_output(valid)}
|
|
engine.native_cua_roundtrip=native
|
|
def handle(request,job):
|
|
response=engine.native_cua_roundtrip(request.get('cua',{}))
|
|
return {'ok':response['executed']}
|
|
engine.handle_cua_request=handle
|
|
adapter.install_adapter_guard(engine,env=env)
|
|
return engine,calls
|
|
|
|
def test_transition_blocks_input_until_successful_observation_then_allows_input(self):
|
|
env={};dc.bind_text(env,'/profile');engine,calls=self.stub(env)
|
|
with patch.object(dc,'_physical',return_value=physical()),patch.object(dc,'missing_graphical_commands',return_value=[]):
|
|
blocked=engine.handle_cua_request({'cua':{'action':'CLICK'}},'job1')
|
|
self.assertFalse(blocked['performed']);self.assertEqual(calls,[])
|
|
self.assertEqual(blocked['error'],'CUA_DISPLAY_CHANGED_OBSERVE_REQUIRED')
|
|
observed=engine.handle_cua_request({'cua':{'action':'OBSERVE'}},'job2')
|
|
self.assertTrue(observed['ok']);self.assertFalse(dc.describe(env).get('needs_observation',False))
|
|
self.assertTrue(engine.handle_cua_request({'cua':{'action':'CLICK'}},'job3')['ok'])
|
|
self.assertEqual(len(calls),2)
|
|
|
|
def test_invalid_observation_does_not_unlock_input_after_transition(self):
|
|
env={};dc.bind_text(env,'/profile');engine,calls=self.stub(env,valid=False)
|
|
with patch.object(dc,'_physical',return_value=physical()),patch.object(dc,'missing_graphical_commands',return_value=[]):
|
|
engine.handle_cua_request({'cua':{'action':'OBSERVE'}},'job1')
|
|
self.assertTrue(dc.describe(env)['needs_observation'])
|
|
blocked=engine.handle_cua_request({'cua':{'action':'CLICK'}},'job2')
|
|
self.assertFalse(blocked['performed']);self.assertEqual(len(calls),1)
|
|
|
|
def test_session_changes_during_observation_keep_input_locked(self):
|
|
env={};dc.bind_text(env,'/profile');engine,calls=self.stub(env)
|
|
with patch.object(dc,'_physical',side_effect=[physical(),physical(),physical(session_id='different')]),patch.object(dc,'missing_graphical_commands',return_value=[]):
|
|
result=engine.handle_cua_request({'cua':{'action':'OBSERVE'}},'job1')
|
|
self.assertFalse(result['ok'])
|
|
self.assertEqual(result['error'],'CUA_FOREGROUND_SESSION_CHANGED')
|
|
self.assertTrue(dc.describe(env)['needs_observation'])
|
|
|
|
def test_session_change_at_observation_release_returns_safe_block_and_keeps_input_locked(self):
|
|
env={};dc.bind_text(env,'/profile');engine,calls=self.stub(env)
|
|
with patch.object(dc,'_physical',side_effect=[physical(),physical(),physical(),physical(session_id='different')]),patch.object(dc,'missing_graphical_commands',return_value=[]):
|
|
result=engine.handle_cua_request({'cua':{'action':'OBSERVE'}},'job1')
|
|
self.assertFalse(result['ok'])
|
|
self.assertFalse(result['performed'])
|
|
self.assertFalse(result['input_may_have_been_sent'])
|
|
self.assertEqual(result['error'],'CUA_FOREGROUND_SESSION_CHANGED')
|
|
self.assertTrue(dc.describe(env)['needs_observation'])
|
|
with patch.object(dc,'_physical',return_value=physical(session_id='different')),patch.object(dc,'missing_graphical_commands',return_value=[]):
|
|
blocked=engine.handle_cua_request({'cua':{'action':'CLICK'}},'job2')
|
|
self.assertFalse(blocked['performed'])
|
|
self.assertEqual(blocked['error'],'CUA_DISPLAY_CHANGED_OBSERVE_REQUIRED')
|
|
self.assertEqual(len(calls),1)
|
|
|
|
|
|
class AdaptiveCoreTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp=tempfile.TemporaryDirectory(prefix='fb-adaptive-test-')
|
|
self.state=Path(self.temp.name)
|
|
config.save_config(self.state,config.default_config())
|
|
self.env={'PATH':'/fixture/bin'}
|
|
dc.bind_text(self.env,self.state)
|
|
|
|
def tearDown(self): self.temp.cleanup()
|
|
|
|
def doctor(self,missing):
|
|
def which(name,path=None): return None if name in missing else '/fixture/bin/'+name
|
|
with patch.object(runtime,'environment',return_value=dict(self.env)),patch.object(runtime.shutil,'which',side_effect=which),patch('framebridge_expanded.network.inspect',return_value={}):
|
|
return runtime.doctor(self.state)
|
|
|
|
def test_optional_gui_dependencies_do_not_block_text_tools(self):
|
|
result=self.doctor(set(dc.GRAPHICAL_COMMANDS+dc.VIRTUAL_COMMANDS+('magick','convert','gnome-terminal','xterm')))
|
|
self.assertTrue(result['ok'])
|
|
self.assertEqual(result['missing_commands'],[])
|
|
self.assertTrue(result['capabilities']['fbexec'])
|
|
self.assertTrue(result['capabilities']['fbterm'])
|
|
self.assertTrue(result['capabilities']['cua_tty_module'])
|
|
self.assertFalse(result['capabilities']['graphical_cua'])
|
|
self.assertTrue(result['optional_missing_commands']['graphical_cua'])
|
|
|
|
def test_missing_core_command_still_blocks_startup(self):
|
|
result=self.doctor({'bash'})
|
|
self.assertFalse(result['ok']);self.assertIn('bash',result['missing_commands'])
|
|
|
|
def test_explicit_private_desktop_requires_its_optional_components(self):
|
|
current=config.load_config(self.state);current['headless']='yes';config.save_config(self.state,current)
|
|
result=self.doctor({'Xvfb'})
|
|
self.assertFalse(result['ok']);self.assertIn('Xvfb',result['missing_commands'])
|
|
|
|
def test_text_manager_starts_core_session_without_xvfb_or_window_manager(self):
|
|
spawned=[]
|
|
def spawn(command,env,state,role):
|
|
spawned.append((command,role))
|
|
return SimpleNamespace(poll=lambda:None),{'role':role}
|
|
with patch.object(runtime,'environment',return_value=dict(self.env)),patch.object(runtime,'_display_ok',return_value=False),patch.object(runtime,'_spawn',side_effect=spawn),patch.object(runtime,'_atomic'),patch.object(runtime,'_terminate'),patch.object(runtime.signal,'signal'),patch.object(runtime,'_STOP',True):
|
|
runtime._manager(self.state)
|
|
self.assertEqual([role for _,role in spawned],['private-session'])
|
|
self.assertEqual(spawned[0][0][0],'dbus-run-session')
|
|
|
|
|
|
if __name__=='__main__': unittest.main()
|