"""Tests for bin/hatch_menu/* (mocked CDP, no live browser traffic). One class per module: mouse, dialog, controls, toggles, and the tab contracts with live-captured Settings fixtures. """ import json import sys import unittest from pathlib import Path from unittest import mock REPO_ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(REPO_ROOT / "bin")) from hatch_menu import controls, dialog, mouse, toggles # noqa: E402 from hatch_menu.dialog import TAB_NAMES # noqa: E402 from hatch_menu.tabs import ( # noqa: E402 TAB_MODULES, data_controls, general, permissions, ) PIP_GENERAL = ( "General\nWallet\nWeekly limit resets on Oct 10\n100% used\n" "Additional tokens\nNever expires\n10% used (901M tokens left)\n" "Upgrade\nLanguage\nAppearance\nMode\nTheme color") SIX_GENERAL = ( "100% used\nAdditional tokens\nNever expires\n" "100% used (0 tokens left)\nUpgrade\nLanguage\nAppearance\n" "Mode\nTheme color") FRESH_GENERAL = ( "Usage\nFree plan\nWeekly limit resets on Oct 12\n15% used\n" "Redeem invite code\n") class FakeWS: """Websocket double; recv echoes the last sent id.""" def __init__(self): self.sent = [] self.closed = False def send(self, raw): self.sent.append(json.loads(raw)) def recv(self): return json.dumps({"id": self.sent[-1]["id"], "result": {}}) def close(self): self.closed = True class MouseTests(unittest.TestCase): def test_real_click_sends_trusted_press_release(self): ws = FakeWS() mouse.real_click(ws, 36.0, 468.0) self.assertEqual(len(ws.sent), 2) press, release = ws.sent self.assertEqual(press["method"], "Input.dispatchMouseEvent") self.assertEqual(press["params"]["type"], "mousePressed") self.assertEqual(release["params"]["type"], "mouseReleased") self.assertEqual((press["params"]["x"], press["params"]["y"]), (36.0, 468.0)) self.assertNotEqual(press["id"], release["id"]) def test_real_click_timeout_raises(self): ws = mock.Mock() ws.recv.side_effect = RuntimeError("boom") with self.assertRaises(mouse.MouseError): mouse.real_click(ws, 1, 2, timeout=0.01) def test_escape_and_close_swallow_errors(self): ws = mock.Mock() ws.close.side_effect = RuntimeError("boom") mouse.close(ws) # must not raise with mock.patch.object(mouse, "cdp_evaluate", side_effect=RuntimeError("boom")): self.assertIsNone(mouse.escape(ws)) class DialogTests(unittest.TestCase): def setUp(self): p = mock.patch("time.sleep") p.start() self.addCleanup(p.stop) def test_tab_names_complete(self): self.assertEqual(len(TAB_NAMES), 10) self.assertEqual(sorted(TAB_MODULES), sorted(TAB_NAMES)) def test_open_settings_success(self): with mock.patch.object(dialog, "cdp_evaluate", side_effect=[{"x": 1, "y": 2}, "CLICKED", True]), \ mock.patch.object(dialog, "real_click") as click: self.assertTrue(dialog.open_settings(mock.Mock())) click.assert_called_once_with(mock.ANY, 1, 2) def test_open_settings_dock_missing(self): with mock.patch.object(dialog, "cdp_evaluate", side_effect=[None]), \ mock.patch.object(dialog, "real_click") as click: self.assertFalse(dialog.open_settings(mock.Mock(), tries=1)) click.assert_not_called() def test_open_settings_retries_then_succeeds(self): seq = [{"x": 1, "y": 2}, "CLICKED", False, {"x": 1, "y": 2}, "CLICKED", True] with mock.patch.object(dialog, "cdp_evaluate", side_effect=seq), \ mock.patch.object(dialog, "real_click"): self.assertTrue(dialog.open_settings(mock.Mock(), tries=2)) def test_goto_tab_clicks(self): with mock.patch.object(dialog, "cdp_evaluate", return_value="CLICKED"): self.assertTrue(dialog.goto_tab(mock.Mock(), "General")) def test_goto_tab_no_match(self): with mock.patch.object(dialog, "cdp_evaluate", return_value="NO_TAB"): self.assertFalse(dialog.goto_tab(mock.Mock(), "General")) def test_dialog_text_none_on_error(self): with mock.patch.object(dialog, "cdp_evaluate", side_effect=RuntimeError("boom")): self.assertIsNone(dialog.dialog_text(mock.Mock())) def test_dialog_text_rejects_non_string(self): with mock.patch.object(dialog, "cdp_evaluate", return_value=123): self.assertIsNone(dialog.dialog_text(mock.Mock())) def test_describe_rows_rejects_non_list(self): with mock.patch.object(dialog, "goto_tab", return_value=True), \ mock.patch.object(dialog, "cdp_evaluate", return_value="error"): self.assertEqual(dialog.describe_rows(mock.Mock(), "Permissions"), []) def test_click_row_success_and_no_match(self): with mock.patch.object(dialog, "cdp_evaluate", side_effect=["CLICKED", "CLICKED", "Websites\nrows"]): self.assertTrue(dialog.click_row(mock.Mock(), "Websites", "Permissions")) with mock.patch.object(dialog, "cdp_evaluate", side_effect=["CLICKED", "NO_MATCH"]): self.assertFalse(dialog.click_row(mock.Mock(), "Websites", "Permissions")) def test_click_row_tab_failure(self): with mock.patch.object(dialog, "cdp_evaluate", return_value="NO_TAB") as cdp: self.assertFalse(dialog.click_row(mock.Mock(), "Websites", "Permissions")) self.assertEqual(cdp.call_count, 1) def test_row_js_excludes_tab_rail(self): blob = json.dumps(dialog.TAB_NAMES) self.assertIn(blob, dialog.JS_CLICK_ROW_TMPL) self.assertIn(blob, dialog.JS_DESCRIBE_ROWS) def test_describe_rows_maps(self): rows = [{"name": "Websites", "help": "1 site", "extra": ""}] with mock.patch.object(dialog, "goto_tab", return_value=True), \ mock.patch.object(dialog, "cdp_evaluate", return_value=rows): self.assertEqual(dialog.describe_rows(mock.Mock(), "Permissions"), rows) class ControlsTests(unittest.TestCase): def setUp(self): p = mock.patch("time.sleep") p.start() self.addCleanup(p.stop) def test_listers_empty_on_failure(self): with mock.patch.object(controls, "cdp_evaluate", side_effect=RuntimeError("boom")): self.assertEqual(controls.list_radios(mock.Mock()), []) self.assertEqual(controls.list_switches(mock.Mock()), []) def test_set_radio_synthetic_success_skips_real_click(self): rows = [{"heading": "Connector defaults", "value": "auto_allow", "checked": True}] with mock.patch.object(controls, "cdp_evaluate", side_effect=["CLICKED", rows]), \ mock.patch.object(controls, "real_click") as click: self.assertTrue(controls.set_radio_by_heading( mock.Mock(), "Connector defaults", "auto_allow")) click.assert_not_called() def test_set_radio_falls_back_to_trusted_click(self): off = [{"heading": "Connector defaults", "value": "auto_allow", "checked": False}] on = [{"heading": "Connector defaults", "value": "auto_allow", "checked": True}] with mock.patch.object(controls, "cdp_evaluate", side_effect=["CLICKED"] + [off] * 10 + [{"x": 5, "y": 6}, on]), \ mock.patch.object(controls, "real_click") as click: self.assertTrue(controls.set_radio_by_heading( mock.Mock(), "Connector defaults", "auto_allow")) click.assert_called_once() def test_set_radio_no_rect_fails(self): off = [{"heading": "Connector defaults", "value": "auto_allow", "checked": False}] with mock.patch.object(controls, "cdp_evaluate", side_effect=["CLICKED"] + [off] * 10 + [None]), \ mock.patch.object(controls, "real_click"): self.assertFalse(controls.set_radio_by_heading( mock.Mock(), "Connector defaults", "auto_allow")) def test_switch_state_unique_and_ambiguous(self): one = [{"label": "Transparent proxy row", "aria": "", "checked": False}] two = one + [{"label": "Transparent proxy clone", "aria": "", "checked": True}] with mock.patch.object(controls, "cdp_evaluate", return_value=one): self.assertIs(controls.switch_state(mock.Mock(), "Transparent proxy"), False) with mock.patch.object(controls, "cdp_evaluate", return_value=two): self.assertIsNone(controls.switch_state(mock.Mock(), "Transparent proxy")) with mock.patch.object(controls, "cdp_evaluate", return_value=[]): self.assertIsNone(controls.switch_state(mock.Mock(), "nope")) def test_set_switch_noop_when_already_there(self): one = [{"label": "Transparent proxy row", "aria": "", "checked": True}] with mock.patch.object(controls, "cdp_evaluate", return_value=one) as cdp: self.assertTrue(controls.set_switch(mock.Mock(), "Transparent proxy", True)) self.assertEqual(cdp.call_count, 1) # list only, no click def test_set_switch_flips(self): off = [{"label": "Transparent proxy row", "aria": "", "checked": False}] on = [{"label": "Transparent proxy row", "aria": "", "checked": True}] with mock.patch.object(controls, "cdp_evaluate", side_effect=[off, "CLICKED", on]): self.assertTrue(controls.set_switch(mock.Mock(), "Transparent proxy", True)) def test_set_switch_polls_then_lands(self): off = [{"label": "Transparent proxy row", "aria": "", "checked": False}] on = [{"label": "Transparent proxy row", "aria": "", "checked": True}] with mock.patch.object(controls, "cdp_evaluate", side_effect=[off, "CLICKED", off, off, on]): self.assertTrue(controls.set_switch(mock.Mock(), "Transparent proxy", True)) def test_listers_reject_wrong_types(self): with mock.patch.object(controls, "cdp_evaluate", return_value="error"): self.assertEqual(controls.list_radios(mock.Mock()), []) self.assertEqual(controls.list_switches(mock.Mock()), []) class TogglesTests(unittest.TestCase): def test_resolve_static_website_protocol(self): spec = toggles.resolve_toggle("permissions.web_access") self.assertEqual(spec["kind"], "radio-heading") spec = toggles.resolve_toggle("permissions.websites:example.com") self.assertEqual((spec["kind"], spec["host"]), ("website", "example.com")) spec = toggles.resolve_toggle("permissions.protocols:mcp-sse") self.assertEqual(spec["kind"], "protocol") spec = toggles.resolve_toggle( "permissions.protocols:Agent Skills endpoints") self.assertEqual(spec["label"], "Agent Skills endpoints") def test_resolve_protocol_network_and_substring(self): spec = toggles.resolve_toggle("permissions.protocols:outbound-ssh") self.assertEqual(spec["label"], "Outbound SSH") spec = toggles.resolve_toggle("permissions.protocols:ssh") self.assertEqual(spec["label"], "Outbound SSH") with self.assertRaises(toggles.MenuError): toggles.resolve_toggle("permissions.protocols:mail") def test_resolve_unknowns_raise_before_cdp(self): for bad in ("nope", "", "permissions.websites:", "permissions.protocols:nope"): with self.assertRaises(toggles.MenuError, msg=bad): toggles.resolve_toggle(bad) def test_normalize_onoff_table(self): for yes in ("on", "ON", "true", "1", "yes", True): self.assertTrue(toggles.normalize_onoff(yes), yes) for no in ("off", "OFF", "false", "0", "no", False): self.assertFalse(toggles.normalize_onoff(no), no) for bad in ("maybe", "", None, 42): self.assertIsNone(toggles.normalize_onoff(bad), bad) def test_get_toggle_bad_node_and_toggle_raise(self): with mock.patch.object(toggles, "get_cdp_ws") as g: with self.assertRaises(toggles.MenuError): toggles.get_toggle("nope", "permissions.web_access") with self.assertRaises(toggles.MenuError): toggles.get_toggle("pip", "nope") g.assert_not_called() def test_get_toggle_cdp_down_returns_error(self): with mock.patch.object(toggles, "get_cdp_ws", side_effect=ConnectionError("refused")): res = toggles.get_toggle("pip", "permissions.web_access") self.assertFalse(res["ok"]) self.assertIn("refused", res["error"]) def test_get_toggle_radio_heading(self): rows = [{"heading": "Web access defaults", "value": "auto_allow", "checked": False}, {"heading": "Web access defaults", "value": "always_ask", "checked": True}] with mock.patch.object(toggles, "get_cdp_ws", return_value=(mock.Mock(), {})), \ mock.patch.object(dialog, "open_settings", return_value=True), \ mock.patch.object(dialog, "goto_tab", return_value=True), \ mock.patch.object(controls, "list_radios", return_value=rows): res = toggles.get_toggle("pip", "permissions.web_access") self.assertEqual((res["ok"], res["value"]), (True, "always_ask")) def test_set_toggle_bad_value_raises_before_cdp(self): with mock.patch.object(toggles, "get_cdp_ws") as g: with self.assertRaises(toggles.MenuError): toggles.set_toggle("pip", "permissions.web_access", "sometimes") with self.assertRaises(toggles.MenuError): toggles.set_toggle("pip", "permissions.websites:x.com", "sometimes") g.assert_not_called() def test_set_toggle_success_with_readback(self): with mock.patch.object(toggles, "get_cdp_ws", return_value=(mock.Mock(), {})), \ mock.patch.object(dialog, "open_settings", return_value=True), \ mock.patch.object(dialog, "goto_tab", return_value=True), \ mock.patch.object(controls, "set_radio_by_heading", return_value=True), \ mock.patch.object(toggles, "get_toggle", return_value={"ok": True, "value": "always_ask"}): res = toggles.set_toggle("pip", "permissions.web_access", "always_ask") self.assertEqual((res["ok"], res["value"]), (True, "always_ask")) self.assertNotIn("readback_only", res) def test_set_toggle_readback_only_success(self): with mock.patch.object(toggles, "get_cdp_ws", return_value=(mock.Mock(), {})), \ mock.patch.object(dialog, "open_settings", return_value=True), \ mock.patch.object(dialog, "goto_tab", return_value=True), \ mock.patch.object(controls, "set_radio_by_heading", return_value=False), \ mock.patch.object(toggles, "get_toggle", return_value={"ok": True, "value": "always_ask"}): res = toggles.set_toggle("pip", "permissions.web_access", "always_ask") self.assertTrue(res["ok"]) self.assertTrue(res["readback_only"]) def test_set_toggle_readback_mismatch_fails(self): with mock.patch.object(toggles, "get_cdp_ws", return_value=(mock.Mock(), {})), \ mock.patch.object(dialog, "open_settings", return_value=True), \ mock.patch.object(dialog, "goto_tab", return_value=True), \ mock.patch.object(controls, "set_radio_by_heading", return_value=True), \ mock.patch.object(toggles, "get_toggle", return_value={"ok": True, "value": "auto_allow"}): res = toggles.set_toggle("pip", "permissions.web_access", "always_ask") self.assertFalse(res["ok"]) self.assertIn("readback", res["error"]) def test_list_toggles_bad_tab_raises(self): with self.assertRaises(toggles.MenuError): toggles.list_toggles("pip", tab="Nope") def test_list_toggles_aggregates(self): with mock.patch.object(toggles, "get_cdp_ws", return_value=(mock.Mock(), {})), \ mock.patch.object(dialog, "open_settings", return_value=True), \ mock.patch("hatch_menu.tabs.permissions.all_toggles", return_value={"permissions.web_access": "always_ask"}), \ mock.patch("hatch_menu.tabs.data_controls.ai_improvement", return_value=True), \ mock.patch("hatch_menu.tabs.general.theme", return_value="blue"): res = toggles.list_toggles("pip") self.assertTrue(res["ok"]) self.assertEqual(res["toggles"]["permissions.web_access"], "always_ask") self.assertEqual(res["toggles"]["data_controls.ai_improvement"], "on") self.assertEqual(res["toggles"]["general.theme"], "blue") def test_describe_tab(self): with mock.patch.object(toggles, "get_cdp_ws", return_value=(mock.Mock(), {})), \ mock.patch.object(dialog, "open_settings", return_value=True), \ mock.patch.object(dialog, "goto_tab", return_value=True), \ mock.patch("hatch_menu.tabs.general.describe", return_value={"theme": "blue"}): res = toggles.describe_tab("pip", "General") self.assertEqual(res["inventory"], {"theme": "blue"}) with self.assertRaises(toggles.MenuError): toggles.describe_tab("pip", "Nope") class TabContractTests(unittest.TestCase): def test_general_parse_pip(self): u = general.parse_usage_text(PIP_GENERAL) self.assertEqual(u["weekly"], {"pct_used": 100, "resets_on": "Oct 10"}) self.assertEqual(u["additional"]["tokens_left"], "901M") self.assertEqual(u["additional"]["pct_used"], 10) self.assertTrue(u["additional"]["never_expires"]) def test_general_parse_646_partial(self): u = general.parse_usage_text(SIX_GENERAL) self.assertEqual(u["weekly"]["pct_used"], 100) self.assertIsNone(u["weekly"]["resets_on"]) self.assertEqual(u["additional"]["tokens_left"], "0") def test_general_parse_empty_and_full_month(self): self.assertIsNone(general.parse_usage_text("")) self.assertIsNone(general.parse_usage_text(None)) u = general.parse_usage_text("Weekly limit resets on October 9") self.assertEqual(u["weekly"]["resets_on"], "October 9") def test_general_theme(self): rows = [{"value": "default", "checked": False}, {"value": "blue", "checked": True}] with mock.patch.object(dialog, "goto_tab", return_value=True), \ mock.patch.object(controls, "list_radios", return_value=rows): self.assertEqual(general.theme(mock.Mock()), "blue") with mock.patch.object(dialog, "goto_tab", return_value=False): self.assertIsNone(general.theme(mock.Mock())) def test_protocol_resolution(self): self.assertEqual(permissions.resolve_protocol("mcp-sse"), "Model Context Protocol servers (SSE)") self.assertEqual(permissions.resolve_protocol( "Agent Skills endpoints"), "Agent Skills endpoints") self.assertEqual(permissions.resolve_protocol("outbound-ssh"), "Outbound SSH") self.assertEqual(permissions.resolve_protocol("Outbound SSH"), "Outbound SSH") self.assertEqual(permissions.resolve_protocol("ssh"), "Outbound SSH") self.assertEqual(permissions.resolve_protocol("tcp"), "Other TCP connections") self.assertIsNone(permissions.resolve_protocol("mail")) self.assertIsNone(permissions.resolve_protocol("nope")) self.assertIsNone(permissions.resolve_protocol("")) self.assertIsNone(permissions.resolve_protocol(None)) def test_website_mode_rejects_bad_mode_without_cdp(self): with mock.patch.object(permissions, "_eval") as ev: self.assertFalse(permissions.set_website_mode( mock.Mock(), "x.com", "Sometimes")) ev.assert_not_called() def test_set_website_noop_without_click(self): rows = [{"host": "x.com", "mode": "Allow", "x": 1, "y": 2}] with mock.patch.object(permissions, "_websites_raw", return_value=rows), \ mock.patch.object(permissions, "_back_to_root", return_value=True), \ mock.patch.object(permissions, "real_click") as click, \ mock.patch("time.sleep"): self.assertTrue(permissions.set_website_mode( mock.Mock(), "x.com", "Allow")) click.assert_not_called() def test_set_website_absent_host_fails(self): with mock.patch.object(permissions, "_websites_raw", return_value=[]), \ mock.patch.object(permissions, "_back_to_root", return_value=True), \ mock.patch("time.sleep"): self.assertFalse(permissions.set_website_mode( mock.Mock(), "y.com", "Ask")) def test_set_website_ask_verifies_by_absence(self): rows = [{"host": "x.com", "mode": "Allow", "x": 1, "y": 2}] items = [{"text": "Allow"}, {"text": "Ask"}, {"text": "Deny"}] with mock.patch.object(permissions, "_websites_raw", return_value=rows), \ mock.patch.object(permissions, "_back_to_root", return_value=True), \ mock.patch.object(permissions, "real_click"), \ mock.patch.object(permissions, "_eval", side_effect=[items, "CLICKED", []]), \ mock.patch("time.sleep"): self.assertTrue(permissions.set_website_mode( mock.Mock(), "x.com", "Ask")) def test_stable_rows_waits_for_agreement(self): rows = [{"host": "x.com"}] with mock.patch.object(permissions, "_eval", side_effect=[None, rows, rows]), \ mock.patch("time.sleep"): self.assertEqual( permissions._stable_rows(mock.Mock(), "js"), rows) def test_stable_rows_gives_up(self): with mock.patch.object(permissions, "_eval", return_value=None), \ mock.patch("time.sleep"): self.assertIsNone( permissions._stable_rows(mock.Mock(), "js")) def test_back_to_root_prefers_back_affordance(self): with mock.patch.object(dialog, "go_back", return_value=True), \ mock.patch.object(dialog, "dialog_text", return_value="Manage permissions\nrows"): self.assertTrue(permissions._back_to_root(mock.Mock())) def test_theme_values_match_live(self): self.assertIn("avatar", general.THEME_VALUES) self.assertNotIn("match", general.THEME_VALUES) def test_data_controls_pinned_label(self): self.assertEqual(data_controls.SWITCH_LABEL, "Help improve our AI models") sws = [{"label": "Something else", "aria": "", "checked": False}, {"label": "Help improve our AI models", "aria": "", "checked": True}] with mock.patch.object(dialog, "goto_tab", return_value=True), \ mock.patch.object(controls, "list_switches", return_value=sws): self.assertTrue(data_controls.ai_improvement(mock.Mock())) def test_data_controls_single_switch(self): one = [{"label": "Help improve the model", "aria": "", "checked": True}] with mock.patch.object(dialog, "goto_tab", return_value=True), \ mock.patch.object(controls, "list_switches", return_value=one): self.assertTrue(data_controls.ai_improvement(mock.Mock())) with mock.patch.object(dialog, "goto_tab", return_value=True), \ mock.patch.object(controls, "list_switches", return_value=[]): self.assertIsNone(data_controls.ai_improvement(mock.Mock())) if __name__ == "__main__": unittest.main()