323 lines
13 KiB
Python
323 lines
13 KiB
Python
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()
|