Files

323 lines
13 KiB
Python
Raw Permalink Normal View History

2026-08-24 08:11:59 -07:00
from __future__ import annotations
import argparse
import io
import json
import os
import unittest
from pathlib import Path
from unittest import mock
import mmo_cli_output
import mmoctl
import scripts.install as install_script
class TTYBuffer(io.StringIO):
def isatty(self) -> bool:
return True
class UnreadableTTY(TTYBuffer):
def read(self, *_args: object, **_kwargs: object) -> str:
raise AssertionError("interactive stdin must not be read implicitly")
def parser_tree(
root: argparse.ArgumentParser,
) -> list[tuple[tuple[str, ...], argparse.ArgumentParser]]:
found: list[tuple[tuple[str, ...], argparse.ArgumentParser]] = []
pending: list[tuple[tuple[str, ...], argparse.ArgumentParser]] = [((), root)]
while pending:
path, parser = pending.pop()
found.append((path, parser))
for action in parser._actions:
if isinstance(action, argparse._SubParsersAction):
pending.extend(((*path, name), child) for name, child in action.choices.items())
return found
class CLIUXTests(unittest.TestCase):
def test_complete_command_surface_has_descriptions_and_option_help(self) -> None:
parsers = parser_tree(mmoctl._parser())
self.assertEqual(len(parsers), 69)
for path, parser in parsers:
with self.subTest(command=" ".join(path) or "root"):
self.assertTrue(parser.description)
help_text = parser.format_help()
self.assertIn("usage:", help_text)
self.assertNotIn("\x1b[", help_text)
for action in parser._actions:
if isinstance(action, (argparse._HelpAction, argparse._SubParsersAction)):
continue
self.assertNotIn(action.help, (None, "", argparse.SUPPRESS))
def test_entrypoint_routing_has_one_source_of_truth(self) -> None:
parser = mmoctl._parser(prog="codex-mmo")
self.assertEqual(mmoctl._route_argv(parser, [], implicit_run=True), ["run"])
self.assertEqual(
mmoctl._route_argv(parser, ["profile", "list"], implicit_run=True),
["profile", "list"],
)
self.assertEqual(
mmoctl._route_argv(parser, ["profile", "list", "--json"], implicit_run=True),
["--json", "profile", "list"],
)
self.assertEqual(
mmoctl._route_argv(parser, ["--quiet", "--", "--search"], implicit_run=True),
["--quiet", "run", "--", "--search"],
)
self.assertEqual(
mmoctl._route_argv(parser, ["run", "--", "--json"], implicit_run=True),
["run", "--", "--json"],
)
def test_usage_errors_are_local_actionable_and_machine_readable(self) -> None:
stdout = io.StringIO()
stderr = io.StringIO()
with mock.patch("sys.stdout", stdout), mock.patch("sys.stderr", stderr):
status = mmoctl.main(["session", "list", "--limt", "1"])
self.assertEqual(status, 2)
self.assertEqual(stdout.getvalue(), "")
self.assertIn("usage: codex-mmoctl session list", stderr.getvalue())
self.assertIn("Did you mean '--limit'?", stderr.getvalue())
stdout = io.StringIO()
stderr = io.StringIO()
with mock.patch("sys.stdout", stdout), mock.patch("sys.stderr", stderr):
status = mmoctl.main(["session", "list", "--limt", "1", "--json"])
self.assertEqual(status, 2)
self.assertEqual(stdout.getvalue(), "")
error = json.loads(stderr.getvalue())
self.assertEqual(error["error_type"], "usage")
self.assertIn("--limit", error["hint"])
def test_cross_option_errors_fail_before_runtime_initialization(self) -> None:
cases = (
(["resume"], "exactly one"),
(["resume", "session-id", "--last"], "exactly one"),
(["resume", "session-id", "--all"], "only with --last"),
(["doctor", "--probe"], "requires --live"),
(["eval", "compare", "one-run"], "at least two"),
(
["catalog", "refresh", "--no-codex", "--install-codex-overlay"],
"cannot be combined",
),
(
[
"catalog",
"refresh",
"--no-remote",
"--openrouter-url",
"https://example.invalid/models",
],
"cannot be combined",
),
(
["catalog", "refresh", "--no-codex", "--codex-bin", "/bin/false"],
"cannot be combined",
),
(["catalog", "discover", "codex", "--timeout", "2"], "not valid"),
(
[
"catalog",
"discover",
"opencode-go",
"--url",
"https://one.invalid/models",
"--opencode-url",
"https://two.invalid/models",
],
"not both",
),
(
["catalog", "discover", "openrouter", "--codex-bin", "/bin/false"],
"not valid",
),
(["run", "--json"], "not supported"),
)
for argv, expected in cases:
with self.subTest(argv=argv):
stderr = io.StringIO()
with mock.patch("sys.stdout", io.StringIO()), mock.patch("sys.stderr", stderr):
status = mmoctl.main(argv)
self.assertEqual(status, 2)
self.assertIn(expected, stderr.getvalue())
def test_exec_never_blocks_for_an_implicit_interactive_prompt(self) -> None:
stderr = io.StringIO()
with (
mock.patch("sys.stdin", UnreadableTTY()),
mock.patch("sys.stdout", io.StringIO()),
mock.patch("sys.stderr", stderr),
):
status = mmoctl.main(["exec"])
self.assertEqual(status, 2)
self.assertIn("requires PROMPT or piped stdin", stderr.getvalue())
stdout = io.StringIO()
with (
mock.patch("sys.stdin", io.StringIO("inspect this\n")),
mock.patch("sys.stdout", stdout),
mock.patch("sys.stderr", io.StringIO()),
mock.patch(
"mmoctl.run_root_exec",
return_value={"exit_code": 0, "result": "complete"},
) as execute,
):
status = mmoctl.main(["exec", "--profile", "fixture"])
self.assertEqual(status, 0)
self.assertEqual(stdout.getvalue(), "complete\n")
self.assertEqual(execute.call_args.kwargs["prompt"], "inspect this\n")
def test_structured_output_is_human_on_tty_and_json_in_pipelines(self) -> None:
session_id = "20260823-120000-reverse-engineering-0123456789"
sessions = [
{
"session_id": session_id,
"status": "paused",
"profile_id": "reverse-engineering",
"session_kind": "interactive",
"last_active_at": "2026-08-23T12:00:00+00:00",
"resumable": True,
}
]
human = TTYBuffer()
with mock.patch("sys.stdout", human), mock.patch.dict(os.environ, {"COLUMNS": "50"}):
mmo_cli_output.emit_structured("session.list", sessions)
self.assertIn(session_id, human.getvalue())
self.assertIn("Status: paused", human.getvalue())
self.assertNotIn("\x1b[", human.getvalue())
machine = io.StringIO()
with mock.patch("sys.stdout", machine):
mmo_cli_output.emit_structured("session.list", sessions)
self.assertEqual(json.loads(machine.getvalue()), sessions)
forced = TTYBuffer()
with mock.patch("sys.stdout", forced):
mmo_cli_output.emit_structured("session.list", sessions, force_json=True)
self.assertEqual(json.loads(forced.getvalue()), sessions)
def test_human_empty_states_are_unambiguous(self) -> None:
output = TTYBuffer()
with mock.patch("sys.stdout", output):
mmo_cli_output.emit_structured("jobs.list", [])
self.assertEqual(output.getvalue(), "No jobs found.\n")
def test_catalog_tables_show_domain_fields_instead_of_empty_generic_columns(self) -> None:
resources = {
"chatgpt_subscription": {
"max_active": 4,
"lock_key": "provider:chatgpt",
"description": "Built-in ChatGPT account concurrency",
}
}
output = TTYBuffer()
with mock.patch("sys.stdout", output), mock.patch.dict(os.environ, {"COLUMNS": "120"}):
mmo_cli_output.emit_structured("catalog.resources", resources)
rendered = output.getvalue()
self.assertIn("CAPACITY", rendered)
self.assertIn("provider:chatgpt", rendered)
self.assertIn("Built-in ChatGPT", rendered)
def test_progress_uses_only_interactive_stderr_and_honors_quiet(self) -> None:
stdout = TTYBuffer()
stderr = TTYBuffer()
with mock.patch("sys.stdout", stdout), mock.patch("sys.stderr", stderr):
mmo_cli_output.progress("Checking profile...")
self.assertEqual(stdout.getvalue(), "")
self.assertEqual(stderr.getvalue(), "Checking profile...\n")
quiet = TTYBuffer()
with mock.patch("sys.stderr", quiet):
mmo_cli_output.progress("Checking profile...", quiet=True)
self.assertEqual(quiet.getvalue(), "")
redirected = io.StringIO()
with mock.patch("sys.stderr", redirected):
mmo_cli_output.progress("Checking profile...")
self.assertEqual(redirected.getvalue(), "")
def test_runtime_errors_never_pollute_structured_stdout(self) -> None:
stdout = io.StringIO()
stderr = io.StringIO()
with (
mock.patch("sys.stdout", stdout),
mock.patch("sys.stderr", stderr),
mock.patch("mmoctl._handle_catalog", side_effect=RuntimeError("provider failed")),
):
status = mmoctl.main(["catalog", "summary", "--json"])
self.assertEqual(status, 1)
self.assertEqual(stdout.getvalue(), "")
error = json.loads(stderr.getvalue())
self.assertEqual(error["error_type"], "runtime")
self.assertEqual(error["error"], "provider failed")
def test_scalar_commands_offer_explicit_json_without_changing_text_mode(self) -> None:
stdout = io.StringIO()
with (
mock.patch("sys.stdout", stdout),
mock.patch("mmoctl.active_profile_id", return_value="reverse-engineering"),
):
self.assertEqual(mmoctl.main(["profile", "current"]), 0)
self.assertEqual(stdout.getvalue(), "reverse-engineering\n")
stdout = io.StringIO()
with (
mock.patch("sys.stdout", stdout),
mock.patch("mmoctl.active_profile_id", return_value="reverse-engineering"),
):
self.assertEqual(mmoctl.main(["profile", "current", "--json"]), 0)
self.assertEqual(json.loads(stdout.getvalue()), {"profile_id": "reverse-engineering"})
def test_clean_reports_scope_and_preserves_dry_run_semantics(self) -> None:
stdout = io.StringIO()
with (
mock.patch("sys.stdout", stdout),
mock.patch(
"mmoctl.load_settings",
return_value={"job_retention_days": 30, "session_retention_days": 90},
),
mock.patch(
"mmoctl.clean_state", return_value={"jobs_removed": 4, "sessions_removed": 2}
) as clean,
):
self.assertEqual(mmoctl.main(["clean", "--dry-run"]), 0)
clean.assert_called_once_with(job_days=30, session_days=90, dry_run=True)
self.assertEqual(
json.loads(stdout.getvalue()),
{
"dry_run": True,
"job_days": 30,
"jobs_removed": 4,
"session_days": 90,
"sessions_removed": 2,
},
)
def test_generated_wrappers_delegate_all_routing_to_the_python_cli(self) -> None:
entrypoint = Path("/tmp/codex mmo/mmoctl.py")
primary = install_script.main_wrapper(Path("/usr/bin/python3"), entrypoint)
control = install_script.wrapper(
Path("/usr/bin/python3"),
entrypoint,
environment={"MMO_CLI_ENTRYPOINT": "codex-mmoctl"},
)
self.assertIn("export MMO_CLI_ENTRYPOINT=codex-mmo", primary)
self.assertIn("export MMO_CLI_ENTRYPOINT=codex-mmoctl", control)
self.assertNotIn("case ", primary)
self.assertIn("'$@'".replace("'", '"'), primary)
def test_installer_help_is_descriptive_and_color_free(self) -> None:
help_text = install_script.parser().format_help()
self.assertIn("atomically replace", help_text)
self.assertIn("--install-root", help_text)
self.assertIn("--debug", help_text)
self.assertNotIn("\x1b[", help_text)
if __name__ == "__main__":
unittest.main()