Files
framebridge/source/tests/test_cli_community_contract.py
T

233 lines
13 KiB
Python

"""Command contract tests: mocked interfaces only, no external connections."""
import contextlib
import io
import json
from pathlib import Path
import tempfile
from types import SimpleNamespace
import unittest
from unittest import mock
from framebridge_expanded import cli, __version__
from framebridge_expanded.config import default_config, save_config
class CommunityCliContractTests(unittest.TestCase):
def call(self, args, modules=None):
stdout, stderr = io.StringIO(), io.StringIO()
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
if modules is None:
code = cli.main(args)
else:
with mock.patch.object(cli, "_module", side_effect=lambda name: modules[name]):
code = cli.main(args)
return code, stdout.getvalue(), stderr.getvalue()
def aggregate(self):
return {"ok": True, "runtime":{"role":"standalone", "ready":True, "running":True},
"connection":{"ok":True,"verified":False,"state":"closed"},
"boot":{"configured":True,"enabled":True,"linger":True,"encrypted_credential_present":True,
"service_active":True,"service_state":"active"},
"desired_connection":"disconnected"}
def test_short_and_legacy_connect_share_flags(self):
for args in (["connect", "--replace"], ["connect", "chatgpt", "--replace"]):
value=cli.build_parser().parse_args(args)
self.assertTrue(value.replace)
self.assertFalse(value.foreground)
def test_common_json_and_profile_options_survive_nested_parsers(self):
for args in (["--json","--state-dir","/qa/custom","service","boot","disable"],
["service","boot","disable","--json","--state-dir","/qa/custom"]):
value=cli.build_parser().parse_args(args)
self.assertTrue(value.json)
self.assertEqual(value.state_dir,"/qa/custom")
self.assertEqual(value.boot_action,"disable")
def test_plain_status_separates_live_connection_and_boot(self):
life=SimpleNamespace(status=mock.Mock(return_value=self.aggregate()))
code,text,_=self.call(["status"],{"lifecycle":life})
self.assertEqual(code,0)
self.assertIn("Local tools: ready",text)
self.assertIn("ChatGPT connection: closed",text)
self.assertIn("Automatic startup: enabled",text)
self.assertNotIn("connected (verified)",text)
def test_unavailable_status_is_not_presented_as_stopped_or_disabled(self):
value={"ok":True,"runtime":{"status_unavailable":True},"connection":{"state":"unavailable"},"boot":{},"desired_connection":"unavailable"}
life=SimpleNamespace(status=mock.Mock(return_value=value))
code,text,_=self.call(["status"],{"lifecycle":life})
self.assertEqual(code,0)
self.assertIn("Local tools: unavailable",text)
self.assertIn("Automatic startup: unavailable",text)
self.assertNotIn("Local tools: stopped",text)
def test_json_status_is_one_versioned_object(self):
life=SimpleNamespace(status=mock.Mock(return_value=self.aggregate()))
code,text,_=self.call(["status","--json"],{"lifecycle":life})
value=json.loads(text)
self.assertEqual(code,0)
self.assertEqual(value["schema_version"],1)
self.assertEqual(value["version"], __version__)
self.assertEqual(value["result"]["desired_connection"],"disconnected")
self.assertFalse(value["result"]["connection"]["verified"])
def test_scoped_status_uses_same_model_and_includes_intent(self):
life=SimpleNamespace(status=mock.Mock(return_value=self.aggregate()))
code,text,_=self.call(["--json","status","connection"],{"lifecycle":life})
data=json.loads(text)["result"]
self.assertEqual(code,0)
self.assertEqual(data["state"],"closed")
self.assertEqual(data["desired_connection"],"disconnected")
self.assertNotIn("runtime",data)
def test_doctor_is_distinct_and_does_not_use_status(self):
runtime=SimpleNamespace(doctor=mock.Mock(return_value={"ok":False,"compatible_debian_13_amd64":True,
"display":"text tools","missing_commands":["tmux"],"issues":[]}))
code,text,_=self.call(["doctor"],{"runtime":runtime})
self.assertEqual(code,1)
self.assertIn("Missing command: tmux",text)
self.assertIn("use fb status",text)
def test_json_new_connect_never_prompts_or_starts_services(self):
with tempfile.TemporaryDirectory() as td, mock.patch("builtins.input") as ask, \
mock.patch.object(cli,"connect_chatgpt") as connect, mock.patch.object(cli,"_module") as modules:
code,text,_=self.call(["--state-dir",td,"connect","--json"])
self.assertEqual(code,2)
self.assertEqual(json.loads(text)["result"]["error"],"INTERACTION_REQUIRED")
ask.assert_not_called();connect.assert_not_called();modules.assert_not_called()
def test_structured_foreground_next_action_preserves_temporary_mode(self):
with tempfile.TemporaryDirectory() as td,mock.patch.object(cli,"connect_chatgpt") as connect:
code,text,_=self.call(["--state-dir",td,"connect","--foreground","--json"])
self.assertEqual(code,2)
self.assertTrue(json.loads(text)["result"]["next_action"].endswith(" connect --foreground"))
connect.assert_not_called()
def test_menu_cancel_returns_cleanly_without_dispatch(self):
with mock.patch.object(cli.sys.stdin,"isatty",return_value=True),mock.patch("builtins.input",side_effect=KeyboardInterrupt):
code,text,errors=self.call([])
self.assertEqual(code,130)
self.assertIn("Cancelled.",errors)
def test_json_setup_reports_interaction_without_reading_prompts(self):
with mock.patch("builtins.input") as ask,mock.patch.object(cli,"setup") as setup:
code,text,_=self.call(["setup","--json"])
self.assertEqual(code,2)
self.assertEqual(json.loads(text)["result"]["error"],"INTERACTION_REQUIRED")
ask.assert_not_called();setup.assert_not_called()
def test_saved_json_connect_keeps_human_progress_off_stdout(self):
life=SimpleNamespace(status=mock.Mock(return_value=self.aggregate()))
with tempfile.TemporaryDirectory() as td:
state=Path(td);(state/"autostart").mkdir();(state/"autostart/installation.json").write_text("{}")
def connect(*args,**kwargs):
print("Checking the saved connection.")
return 0
with mock.patch.object(cli,"connect_chatgpt",side_effect=connect):
code,text,errors=self.call(["--state-dir",td,"connect","--json"],{"lifecycle":life})
self.assertEqual(code,0)
self.assertEqual(json.loads(text)["result"]["runtime"]["ready"],True)
self.assertIn("Checking the saved connection.",errors)
self.assertNotIn("Checking",text)
def test_canonical_disconnect_and_legacy_have_explicit_different_routes(self):
canonical=SimpleNamespace(disconnect=mock.Mock(return_value={"ok":True}))
legacy=SimpleNamespace(disconnect=mock.Mock(return_value={"ok":True}))
modules={"lifecycle":canonical,"connection_background":legacy}
self.assertEqual(self.call(["disconnect"],modules)[0],0)
canonical.disconnect.assert_called_once();legacy.disconnect.assert_not_called()
self.assertEqual(self.call(["disconnect","chatgpt"],modules)[0],0)
legacy.disconnect.assert_called_once()
def test_noninteractive_service_start_does_not_invent_authentication_gate(self):
life=SimpleNamespace(service_start=mock.Mock(return_value={"ok":True,"state":"started","scope":"local_tools"}))
code,text,_=self.call(["service","start","--json"],{"lifecycle":life})
self.assertEqual(code,0)
self.assertEqual(json.loads(text)["result"]["scope"],"local_tools")
life.service_start.assert_called_once()
def test_first_node_service_start_reports_interaction_before_provisioning(self):
life=SimpleNamespace(status=mock.Mock(return_value={"boot":{"configured":False}}),service_start=mock.Mock())
with tempfile.TemporaryDirectory() as td:
state=Path(td);config=default_config();config["role"]="node";save_config(state,config)
code,text,_=self.call(["--state-dir",td,"service","start","--json"],{"lifecycle":life})
self.assertEqual(code,2)
self.assertEqual(json.loads(text)["result"]["error"],"INTERACTION_REQUIRED")
life.service_start.assert_not_called()
def test_configured_node_service_start_remains_noninteractive(self):
life=SimpleNamespace(status=mock.Mock(return_value={"boot":{"configured":True}}),service_start=mock.Mock(return_value={"ok":True,"state":"start_requested"}))
with tempfile.TemporaryDirectory() as td:
state=Path(td);config=default_config();config["role"]="node";save_config(state,config)
code,text,_=self.call(["--state-dir",td,"service","start","--json"],{"lifecycle":life})
self.assertEqual(code,0)
self.assertEqual(json.loads(text)["result"]["state"],"start_requested")
life.service_start.assert_called_once()
def test_boot_disable_and_legacy_autostart_disable_are_not_retargeted(self):
life=SimpleNamespace(boot_disable=mock.Mock(return_value={"ok":True,"state":"boot-disabled"}))
auto=SimpleNamespace(disable=mock.Mock(return_value={"ok":True,"state":"disabled"}),
status=mock.Mock(return_value={"service_state":"inactive"}))
modules={"lifecycle":life,"autostart":auto}
self.call(["service","boot","disable"],modules)
life.boot_disable.assert_called_once();auto.disable.assert_not_called()
self.call(["autostart","disable"],modules)
auto.disable.assert_called_once()
def test_forget_requires_explicit_confirmation_without_calling_destructive_api(self):
life=SimpleNamespace(forget_credentials=mock.Mock())
code,text,_=self.call(["service","credentials","forget","--json"],{"lifecycle":life})
self.assertEqual(code,2)
self.assertEqual(json.loads(text)["result"]["error"],"CONFIRMATION_REQUIRED")
life.forget_credentials.assert_not_called()
def test_forget_confirmation_instruction_preserves_custom_profile(self):
life=SimpleNamespace(forget_credentials=mock.Mock())
code,text,_=self.call(["--state-dir","/qa/profile with spaces","service","credentials","forget","--json"],{"lifecycle":life})
self.assertEqual(code,2)
self.assertEqual(json.loads(text)["result"]["next_action"],"fb --state-dir '/qa/profile with spaces' service credentials forget --confirm")
life.forget_credentials.assert_not_called()
def test_explicit_confirm_forget_calls_one_narrow_api(self):
life=SimpleNamespace(forget_credentials=mock.Mock(return_value={"ok":True,"state":"forgotten"}))
self.assertEqual(self.call(["service","credentials","forget","--confirm","--json"],{"lifecycle":life})[0],0)
life.forget_credentials.assert_called_once()
def test_console_login_subcommand_calls_only_login_policy(self):
startup=SimpleNamespace(status=mock.Mock(return_value={"ok":True,"enabled":False}))
code,text,_=self.call(["console","login","status","--json"],{"console_startup":startup})
self.assertEqual(code,0)
self.assertFalse(json.loads(text)["result"]["enabled"])
startup.status.assert_called_once()
def test_terminal_service_group_routes_same_explicit_target(self):
args=cli.build_parser().parse_args(["tty","service","status","--node","N2"])
with mock.patch.object(cli,"_target_tty",return_value={"ok":True}) as target,contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(cli.tty_command(args,Path("/qa/profile")),0)
target.assert_called_once_with(Path("/qa/profile"),"N2",service="status")
def test_noninteractive_typed_text_without_text_never_prompts(self):
with mock.patch("builtins.input") as ask,mock.patch.object(cli,"tty_command") as tty:
code,text,_=self.call(["tty","type","a"*32,"--json"])
self.assertEqual(code,2)
self.assertEqual(json.loads(text)["result"]["error"],"INTERACTION_REQUIRED")
ask.assert_not_called();tty.assert_not_called()
def test_help_uses_same_versioned_guide_without_runtime(self):
guide=SimpleNamespace(get_guide=mock.Mock(return_value={"ok":True,"topic":"commands","text":"Command guide"}))
code,text,_=self.call(["help","commands","--json"],{"guide":guide})
self.assertEqual(code,0)
self.assertEqual(json.loads(text)["result"]["topic"],"commands")
guide.get_guide.assert_called_once_with("commands")
def test_failed_pause_does_not_claim_success(self):
life=SimpleNamespace(disconnect=mock.Mock(return_value={"ok":False,"error":"TEST_FAILURE"}))
code,text,errors=self.call(["disconnect"],{"lifecycle":life})
self.assertEqual(code,1)
self.assertNotIn("paused until",text)
self.assertIn("TEST_FAILURE",errors)
if __name__=="__main__":
unittest.main()