fix: single-key TTY menu UX for the Gitea config menu (#36)
Make the interactive profile menu feel like a real terminal menu, via a new injectable MenuIO abstraction (no menu logic change, no auth/secret-storage change). - Single-key top-level actions in a TTY (termios/tty raw read); no Enter needed. Non-TTY / test runs fall back to line input. - Enter backs out: Enter (or 0) on the main menu quits; Enter cancels any submenu/profile prompt and returns. - Profile chooser: everywhere a profile is needed, show a numbered list and pick by key (1-9), with an explicit 'm) type a name manually' path and Enter to cancel. Empty config handled gracefully. - Clear screen before redrawing the main menu and chooser — TTY only; never emits clear codes in non-TTY/test runs. - Result actions (validate/test-auth/whoami/eligibility) print a concise result then pause for a keypress in a TTY; non-TTY never blocks. Helpers: read_key (via default_io) / choose_menu_option / choose_profile / clear_screen / pause_for_key, plus MenuIO(is_tty, clear_enabled). TTY detected with sys.stdin.isatty() and sys.stdout.isatty(); stdlib only. Safety unchanged: no tokens/passwords printed, no raw config dumps, no .env.personal, no change to auth behavior or secret storage. Tests: rewrote menu tests around a scripted _FakeIO (no real terminal): single- key select + clear, main-menu Enter/0 quit, submenu Enter cancel (no change), chooser lists/selects/no-profiles/manual/out-of-range, non-TTY line fallback, clear-only-when-enabled, pause never hangs non-TTY, and add-flow proving the token value never reaches disk or stdout. Docs: runbook note on single-key nav / Enter back-out / numbered chooser. scripts/gitea-config-menu unchanged. Closes #36. Refs #31, #34. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+169
-41
@@ -238,57 +238,185 @@ class TestIdentityAndEligibility(unittest.TestCase):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompt flow + full menu loop (no real IO/keychain/network)
|
||||
# UX: single-key nav, Enter back-out, profile chooser, clear, pause (#36)
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestMenuFlow(unittest.TestCase):
|
||||
class _FakeIO:
|
||||
"""Duck-typed MenuIO: scripted keys/lines, captured output, clear counter.
|
||||
|
||||
def test_prompt_keychain_stores_but_profile_has_no_token(self):
|
||||
stored = {}
|
||||
answers = ["prgs-reviewer", "https://gitea.prgs.cc", "jcwalker3",
|
||||
"Org", "Repo", "personal-prgs", "keychain",
|
||||
"prgs-reviewer-token", "y"]
|
||||
out = _Out()
|
||||
name, profile = menu._prompt_profile(
|
||||
_InputQueue(answers), secret_fn=lambda _p: SECRET, out=out,
|
||||
keychain_set=lambda i, t: stored.update({i: t}))
|
||||
self.assertEqual(name, "prgs-reviewer")
|
||||
self.assertEqual(profile["auth"], {"type": "keychain", "id": "prgs-reviewer-token"})
|
||||
self.assertNotIn(SECRET, json.dumps(profile)) # token not in profile
|
||||
self.assertNotIn(SECRET, out.text) # token not printed
|
||||
self.assertEqual(stored, {"prgs-reviewer-token": SECRET}) # stored via keychain
|
||||
read_key/read_line pop from their queues; when a queue is exhausted they
|
||||
return "" (Enter/EOF → cancel/quit), so a script can never hang the loop.
|
||||
"""
|
||||
|
||||
def test_prompt_env_reference(self):
|
||||
answers = ["prgs-author", "https://gitea.prgs.cc", "jcwalker3",
|
||||
"", "", "personal-prgs", "env", ""] # blank -> default var name
|
||||
name, profile = menu._prompt_profile(
|
||||
_InputQueue(answers), secret_fn=lambda _p: SECRET, out=_Out(),
|
||||
keychain_set=lambda *a: None)
|
||||
self.assertEqual(profile["auth"], {"type": "env", "name": "GITEA_TOKEN_PRGS_AUTHOR"})
|
||||
def __init__(self, keys=(), lines=(), is_tty=False, clear_enabled=None):
|
||||
self.keys = list(keys)
|
||||
self.lines = list(lines)
|
||||
self.out_lines = []
|
||||
self.clears = 0
|
||||
self.is_tty = is_tty
|
||||
self.clear_enabled = is_tty if clear_enabled is None else clear_enabled
|
||||
|
||||
def test_menu_add_then_quit_writes_config_without_secrets(self):
|
||||
def read_key(self, prompt=""):
|
||||
return self.keys.pop(0) if self.keys else ""
|
||||
|
||||
def read_line(self, prompt=""):
|
||||
return self.lines.pop(0) if self.lines else ""
|
||||
|
||||
def out(self, text=""):
|
||||
self.out_lines.append(str(text))
|
||||
|
||||
def clear(self):
|
||||
if self.clear_enabled:
|
||||
self.clears += 1
|
||||
|
||||
@property
|
||||
def text(self):
|
||||
return "\n".join(self.out_lines)
|
||||
|
||||
|
||||
def _cfg(*names):
|
||||
cfg = gitea_config.empty_config()
|
||||
for n in names:
|
||||
cfg["profiles"][n] = {"base_url": "https://x",
|
||||
"auth": gitea_config.env_auth("V")}
|
||||
return cfg
|
||||
|
||||
|
||||
class TestChooser(unittest.TestCase):
|
||||
|
||||
def test_menu_single_key_and_clears(self):
|
||||
io = _FakeIO(keys=["3"], is_tty=True)
|
||||
self.assertEqual(menu.choose_menu_option(io), "3") # one keypress, no Enter
|
||||
self.assertEqual(io.clears, 1) # cleared before draw
|
||||
self.assertIn("Gitea MCP profile setup", io.text)
|
||||
|
||||
def test_profile_chooser_lists_and_selects_by_key(self):
|
||||
io = _FakeIO(keys=["2"])
|
||||
self.assertEqual(menu.choose_profile(io, _cfg("aaa", "bbb", "ccc")), "bbb")
|
||||
self.assertIn("1) aaa", io.text)
|
||||
self.assertIn("2) bbb", io.text)
|
||||
|
||||
def test_profile_chooser_enter_cancels(self):
|
||||
io = _FakeIO(keys=[""])
|
||||
self.assertIsNone(menu.choose_profile(io, _cfg("aaa")))
|
||||
|
||||
def test_profile_chooser_no_profiles(self):
|
||||
io = _FakeIO(keys=[])
|
||||
self.assertIsNone(menu.choose_profile(io, gitea_config.empty_config()))
|
||||
self.assertIn("no profiles", io.text)
|
||||
|
||||
def test_profile_chooser_manual_entry(self):
|
||||
io = _FakeIO(keys=["m"], lines=["typed-name"])
|
||||
self.assertEqual(menu.choose_profile(io, _cfg("aaa")), "typed-name")
|
||||
|
||||
def test_profile_chooser_out_of_range_key(self):
|
||||
io = _FakeIO(keys=["9"])
|
||||
self.assertIsNone(menu.choose_profile(io, _cfg("aaa")))
|
||||
self.assertIn("invalid selection", io.text)
|
||||
|
||||
|
||||
class TestClearAndPause(unittest.TestCase):
|
||||
|
||||
def test_clear_only_when_enabled(self):
|
||||
off = _FakeIO(keys=["x"], clear_enabled=False)
|
||||
menu.choose_menu_option(off)
|
||||
self.assertEqual(off.clears, 0)
|
||||
on = _FakeIO(keys=["x"], clear_enabled=True)
|
||||
menu.choose_menu_option(on)
|
||||
self.assertEqual(on.clears, 1)
|
||||
|
||||
def test_pause_does_not_read_in_non_tty(self):
|
||||
io = _FakeIO(keys=["a"], is_tty=False)
|
||||
menu.pause_for_key(io)
|
||||
self.assertEqual(io.keys, ["a"]) # nothing consumed, no hang
|
||||
|
||||
def test_pause_reads_one_key_in_tty(self):
|
||||
io = _FakeIO(keys=["a"], is_tty=True)
|
||||
menu.pause_for_key(io)
|
||||
self.assertEqual(io.keys, []) # consumed the keypress
|
||||
|
||||
def test_default_io_non_tty_line_fallback(self):
|
||||
import io as _io
|
||||
with patch("gitea_config_menu.sys.stdin", _io.StringIO("7\nrest\n")):
|
||||
mio = menu.default_io()
|
||||
self.assertFalse(mio.is_tty)
|
||||
self.assertEqual(mio.read_key(), "7") # first char of the line
|
||||
|
||||
|
||||
class TestMenuNav(unittest.TestCase):
|
||||
|
||||
def test_main_enter_quits(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
io = _FakeIO(keys=[""]) # Enter at main menu
|
||||
rc = menu.main(io=io, config_path=os.path.join(d, "p.json"),
|
||||
secret_fn=lambda _p: SECRET, keychain_set=lambda *a: None)
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertIn("bye", io.text)
|
||||
|
||||
def test_main_zero_quits(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
io = _FakeIO(keys=["0"])
|
||||
self.assertEqual(
|
||||
menu.main(io=io, config_path=os.path.join(d, "p.json"),
|
||||
secret_fn=lambda _p: SECRET, keychain_set=lambda *a: None), 0)
|
||||
|
||||
def test_submenu_enter_cancels_without_change(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "p.json")
|
||||
gitea_config.save_config(_cfg("keep-me"), path)
|
||||
io = _FakeIO(keys=["4", ""]) # remove → chooser Enter cancels → quit
|
||||
menu.main(io=io, config_path=path,
|
||||
secret_fn=lambda _p: SECRET, keychain_set=lambda *a: None)
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
self.assertIn("keep-me", fh.read()) # not removed
|
||||
|
||||
|
||||
class TestMenuAddFlow(unittest.TestCase):
|
||||
|
||||
def test_add_then_quit_writes_config_without_secrets(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "profiles.json")
|
||||
answers = [
|
||||
"2", # add profile
|
||||
"prgs-reviewer", "https://gitea.prgs.cc", "jcwalker3",
|
||||
"Org", "Repo", "personal-prgs", "keychain",
|
||||
"prgs-reviewer-token", "y",
|
||||
"1", # list
|
||||
"0", # quit
|
||||
]
|
||||
out = _Out()
|
||||
rc = menu.main(
|
||||
input_fn=_InputQueue(answers), secret_fn=lambda _p: SECRET,
|
||||
out=out, config_path=path, keychain_set=lambda *a: None)
|
||||
io = _FakeIO(
|
||||
keys=["2", ""], # add profile, then Enter → quit
|
||||
lines=["prgs-reviewer", "https://gitea.prgs.cc", "jcwalker3",
|
||||
"Org", "Repo", "personal-prgs", "keychain",
|
||||
"prgs-reviewer-token", "y"],
|
||||
)
|
||||
rc = menu.main(io=io, config_path=path, secret_fn=lambda _p: SECRET,
|
||||
keychain_set=lambda *a: None)
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertTrue(os.path.isfile(path))
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
body = fh.read()
|
||||
self.assertIn("prgs-reviewer", body)
|
||||
self.assertIn("prgs-reviewer-token", body) # keychain id (non-secret)
|
||||
self.assertNotIn(SECRET, body) # never the token value
|
||||
self.assertNotIn(SECRET, out.text)
|
||||
self.assertIn("prgs-reviewer", out.text) # listed
|
||||
self.assertIn("prgs-reviewer-token", body) # keychain id (non-secret)
|
||||
self.assertNotIn(SECRET, body) # token value never on disk
|
||||
self.assertNotIn(SECRET, io.text) # nor printed
|
||||
|
||||
def test_prompt_fields_keychain_no_token_leak(self):
|
||||
stored = {}
|
||||
io = _FakeIO(lines=["https://gitea.prgs.cc", "jcwalker3", "Org", "Repo",
|
||||
"personal-prgs", "keychain", "prgs-reviewer-token", "y"])
|
||||
profile = menu._prompt_profile_fields(
|
||||
io, secret_fn=lambda _p: SECRET,
|
||||
keychain_set=lambda i, t: stored.update({i: t}), name="prgs-reviewer")
|
||||
self.assertEqual(profile["auth"], {"type": "keychain", "id": "prgs-reviewer-token"})
|
||||
self.assertNotIn(SECRET, json.dumps(profile))
|
||||
self.assertNotIn(SECRET, io.text)
|
||||
self.assertEqual(stored, {"prgs-reviewer-token": SECRET})
|
||||
|
||||
def test_prompt_fields_env_reference(self):
|
||||
io = _FakeIO(lines=["https://gitea.prgs.cc", "jcwalker3", "", "",
|
||||
"personal-prgs", "env", ""])
|
||||
profile = menu._prompt_profile_fields(
|
||||
io, secret_fn=lambda _p: SECRET, keychain_set=lambda *a: None,
|
||||
name="prgs-author")
|
||||
self.assertEqual(profile["auth"],
|
||||
{"type": "env", "name": "GITEA_TOKEN_PRGS_AUTHOR"})
|
||||
|
||||
def test_prompt_fields_blank_base_url_cancels(self):
|
||||
io = _FakeIO(lines=[""]) # blank Base URL → cancel
|
||||
self.assertIsNone(menu._prompt_profile_fields(
|
||||
io, secret_fn=lambda _p: SECRET, keychain_set=lambda *a: None,
|
||||
name="x"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user