diff --git a/tests/test_commands_cli.py b/tests/test_commands_cli.py new file mode 100644 index 0000000..11b55c2 --- /dev/null +++ b/tests/test_commands_cli.py @@ -0,0 +1,993 @@ +""" +Unit tests for CLI command modules. +All send_command calls are mocked — no live browser required. +Uses Click's CliRunner to exercise the CLI output formatting paths. +""" +from unittest.mock import patch, MagicMock +import pytest +from click.testing import CliRunner + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _run(group, args, return_value): + """Invoke a Click group/command with a mocked send_command return value.""" + with patch("browser_cli.commands.send_command", return_value=return_value): + result = CliRunner().invoke(group, args) + return result + +def _run_error(group, args, exc): + """Invoke a Click group/command where send_command raises exc.""" + with patch("browser_cli.commands.send_command", side_effect=exc): + result = CliRunner().invoke(group, args) + return result + +def _run_no_multi(group, args, return_value, module_path=None): + """Invoke a command patching send_command AND _multi_browser_targets → single-browser mode.""" + ctx1 = patch("browser_cli.commands.send_command", return_value=return_value) + if module_path: + ctx2 = patch(f"{module_path}._multi_browser_targets", return_value=[]) + with ctx1, ctx2: + return CliRunner().invoke(group, args) + else: + with ctx1: + return CliRunner().invoke(group, args) + +# --------------------------------------------------------------------------- +# dom commands +# --------------------------------------------------------------------------- + +from browser_cli.commands.dom import dom_group + +def test_cli_dom_query_no_results(): + result = _run(dom_group, ["query", "span.nothing"], []) + assert result.exit_code == 0 + assert "No elements found" in result.output + +def test_cli_dom_query_with_results(): + elements = [{"tag": "div", "text": "hello", "attrs": {"class": "foo"}}] + result = _run(dom_group, ["query", "div"], elements) + assert result.exit_code == 0 + assert "div" in result.output + +def test_cli_dom_click(): + result = _run(dom_group, ["click", "button#submit"], None) + assert result.exit_code == 0 + assert "Clicked" in result.output + +def test_cli_dom_type(): + result = _run(dom_group, ["type", "input#name", "pytest"], None) + assert result.exit_code == 0 + assert "Typed into" in result.output + +def test_cli_dom_attr(): + result = _run(dom_group, ["attr", "a", "href"], ["https://example.com"]) + assert result.exit_code == 0 + assert "https://example.com" in result.output + +def test_cli_dom_attr_empty(): + result = _run(dom_group, ["attr", "span", "href"], []) + assert result.exit_code == 0 + +def test_cli_dom_text(): + result = _run(dom_group, ["text", "h1"], ["Example Domain"]) + assert result.exit_code == 0 + assert "Example Domain" in result.output + +def test_cli_dom_text_empty(): + result = _run(dom_group, ["text", "h9"], []) + assert result.exit_code == 0 + +def test_cli_dom_exists_true(): + result = _run(dom_group, ["exists", "html"], True) + assert result.exit_code == 0 + assert "exists" in result.output + +def test_cli_dom_exists_false(): + result = _run(dom_group, ["exists", "#no-such"], False) + assert result.exit_code != 0 + assert "not found" in result.output + +def test_cli_dom_scroll_coord(): + result = _run(dom_group, ["scroll", "--x", "0", "--y", "500"], None) + assert result.exit_code == 0 + assert "Scrolled" in result.output + +def test_cli_dom_scroll_selector(): + result = _run(dom_group, ["scroll", "footer"], None) + assert result.exit_code == 0 + assert "Scrolled" in result.output + +def test_cli_dom_eval_string(): + result = _run(dom_group, ["eval", "document.title"], "My Page") + assert result.exit_code == 0 + assert "My Page" in result.output + +def test_cli_dom_eval_null(): + result = _run(dom_group, ["eval", "void 0"], None) + assert result.exit_code == 0 + assert "null" in result.output + +def test_cli_dom_eval_dict(): + result = _run(dom_group, ["eval", "({a:1})"], {"a": 1}) + assert result.exit_code == 0 + assert '"a"' in result.output + +def test_cli_dom_wait_for(): + result = _run(dom_group, ["wait-for", "html"], None) + assert result.exit_code == 0 + assert "Ready" in result.output + +def test_cli_dom_wait_for_visible(): + result = _run(dom_group, ["wait-for", "html", "--visible"], None) + assert result.exit_code == 0 + assert "visible" in result.output + +def test_cli_dom_wait_for_hidden(): + result = _run(dom_group, ["wait-for", "#spinner", "--hidden"], None) + assert result.exit_code == 0 + assert "hidden" in result.output + +def test_cli_dom_key(): + result = _run(dom_group, ["key", "Enter"], None) + assert result.exit_code == 0 + assert "Key 'Enter'" in result.output + +def test_cli_dom_key_with_selector(): + result = _run(dom_group, ["key", "Tab", "--selector", "input"], None) + assert result.exit_code == 0 + assert "input" in result.output + +def test_cli_dom_hover(): + result = _run(dom_group, ["hover", ".menu"], None) + assert result.exit_code == 0 + assert "Hovered" in result.output + +def test_cli_dom_check(): + result = _run(dom_group, ["check", "#accept"], None) + assert result.exit_code == 0 + assert "Checked" in result.output + +def test_cli_dom_uncheck(): + result = _run(dom_group, ["uncheck", "#accept"], None) + assert result.exit_code == 0 + assert "Unchecked" in result.output + +def test_cli_dom_clear(): + result = _run(dom_group, ["clear", "input#q"], None) + assert result.exit_code == 0 + assert "Cleared" in result.output + +def test_cli_dom_focus(): + result = _run(dom_group, ["focus", "input#q"], None) + assert result.exit_code == 0 + assert "Focused" in result.output + +def test_cli_dom_submit(): + result = _run(dom_group, ["submit", "form"], None) + assert result.exit_code == 0 + assert "Submitted" in result.output + +def test_cli_dom_select(): + result = _run(dom_group, ["select", "#lang", "en"], None) + assert result.exit_code == 0 + assert "Selected" in result.output + +def test_cli_dom_poll(): + result = _run(dom_group, ["poll", "#status", "ready"], {"value": "ready"}) + assert result.exit_code == 0 + assert "Matched" in result.output + +# --------------------------------------------------------------------------- +# cookies commands +# --------------------------------------------------------------------------- + +from browser_cli.commands.cookies import cookies_group + +def test_cli_cookies_list_empty(): + result = _run(cookies_group, ["list"], []) + assert result.exit_code == 0 + assert "No cookies found" in result.output + +def test_cli_cookies_list_with_cookies(): + cookies = [{"name": "session", "value": "abc123", "domain": "example.com", + "path": "/", "secure": True, "httpOnly": False}] + result = _run(cookies_group, ["list"], cookies) + assert result.exit_code == 0 + assert "session" in result.output + assert "example.com" in result.output + +def test_cli_cookies_list_filter_url(): + cookies = [{"name": "x", "value": "y", "domain": "example.com", + "path": "/", "secure": False, "httpOnly": False}] + result = _run(cookies_group, ["list", "--url", "https://example.com"], cookies) + assert result.exit_code == 0 + assert "example.com" in result.output + +def test_cli_cookies_list_filter_domain(): + cookies = [{"name": "x", "value": "y", "domain": "example.com", + "path": "/", "secure": False, "httpOnly": False}] + result = _run(cookies_group, ["list", "--domain", "example.com"], cookies) + assert result.exit_code == 0 + +def test_cli_cookies_list_filter_name(): + cookies = [{"name": "session", "value": "abc", "domain": "example.com", + "path": "/", "secure": False, "httpOnly": True}] + result = _run(cookies_group, ["list", "--name", "session"], cookies) + assert result.exit_code == 0 + assert "session" in result.output + +def test_cli_cookies_get_found(): + cookie = {"name": "tok", "value": "secret123", "domain": "example.com", "path": "/"} + result = _run(cookies_group, ["get", "https://example.com", "tok"], cookie) + assert result.exit_code == 0 + assert "secret123" in result.output + +def test_cli_cookies_get_not_found(): + result = _run(cookies_group, ["get", "https://example.com", "missing"], None) + assert result.exit_code != 0 + assert "not found" in result.output + +def test_cli_cookies_set(): + result = _run(cookies_group, ["set", "https://example.com", "tok", "val"], None) + assert result.exit_code == 0 + assert "Set cookie" in result.output + +def test_cli_cookies_set_with_options(): + result = _run(cookies_group, [ + "set", "https://example.com", "tok", "val", + "--secure", "--http-only", "--path", "/app", + "--same-site", "lax", + ], None) + assert result.exit_code == 0 + assert "Set cookie" in result.output + +# --------------------------------------------------------------------------- +# page commands +# --------------------------------------------------------------------------- + +from browser_cli.commands.page import page_group + +def test_cli_page_info_basic(): + info = {"title": "Example", "url": "https://example.com", "readyState": "complete", "lang": "en", "meta": {}} + result = _run(page_group, ["info"], info) + assert result.exit_code == 0 + assert "Example" in result.output + assert "https://example.com" in result.output + +def test_cli_page_info_with_meta(): + info = {"title": "My Page", "url": "https://example.com", "readyState": "complete", "lang": "de", + "meta": {"description": "A test page", "author": "Tester"}} + result = _run(page_group, ["info"], info) + assert result.exit_code == 0 + assert "description" in result.output + assert "A test page" in result.output + +def test_cli_page_info_empty_response(): + result = _run(page_group, ["info"], {}) + assert result.exit_code == 0 + +# --------------------------------------------------------------------------- +# storage commands +# --------------------------------------------------------------------------- + +from browser_cli.commands.storage import storage_group + +def test_cli_storage_get_null(): + result = _run(storage_group, ["get", "mykey"], None) + assert result.exit_code == 0 + assert "null" in result.output + +def test_cli_storage_get_string(): + result = _run(storage_group, ["get", "mykey"], "hello-world") + assert result.exit_code == 0 + assert "hello-world" in result.output + +def test_cli_storage_get_dict(): + result = _run(storage_group, ["get"], {"k1": "v1", "k2": "v2"}) + assert result.exit_code == 0 + assert "k1" in result.output + +def test_cli_storage_get_session(): + result = _run(storage_group, ["get", "key", "--type", "session"], "ses-val") + assert result.exit_code == 0 + assert "ses-val" in result.output + +def test_cli_storage_set(): + result = _run(storage_group, ["set", "mykey", "myvalue"], None) + assert result.exit_code == 0 + assert "Set" in result.output + assert "mykey" in result.output + +def test_cli_storage_set_session(): + result = _run(storage_group, ["set", "sk", "sv", "--type", "session"], None) + assert result.exit_code == 0 + assert "session" in result.output + +# --------------------------------------------------------------------------- +# perf commands +# --------------------------------------------------------------------------- + +from browser_cli.commands.perf import perf_group + +def test_cli_perf_status_no_jobs(): + status = {"performanceProfile": "auto", "audible": False, + "throttle": {"batchSize": 10, "pauseMs": 50, "mode": "auto"}, "jobs": []} + result = _run(perf_group, ["status"], status) + assert result.exit_code == 0 + assert "auto" in result.output + assert "No running jobs" in result.output + +def test_cli_perf_status_with_jobs(): + status = { + "performanceProfile": "gentle", + "audible": True, + "throttle": {"batchSize": 5, "pauseMs": 200, "mode": "gentle"}, + "jobs": [ + {"id": "j1", "command": "session.load", "status": "running", + "current": 3, "total": 10, "percent": 30, "phase": "open_tabs"}, + ], + } + result = _run(perf_group, ["status"], status) + assert result.exit_code == 0 + assert "j1" in result.output + assert "session.load" in result.output + assert "30%" in result.output + +def test_cli_perf_profile_set(): + result = _run(perf_group, ["profile", "normal"], {"performanceProfile": "normal"}) + assert result.exit_code == 0 + assert "normal" in result.output + +def test_cli_perf_profile_gentle(): + result = _run(perf_group, ["profile", "gentle"], {"performanceProfile": "gentle"}) + assert result.exit_code == 0 + assert "gentle" in result.output + +def test_cli_perf_profile_ultra(): + result = _run(perf_group, ["profile", "ultra"], {"performanceProfile": "ultra"}) + assert result.exit_code == 0 + assert "ultra" in result.output + +# --------------------------------------------------------------------------- +# navigate commands +# --------------------------------------------------------------------------- + +from browser_cli.commands.navigate import nav_group + +def test_cli_nav_open(): + result = _run(nav_group, ["open", "https://example.com"], {"id": 42, "url": "https://example.com"}) + assert result.exit_code == 0 + assert "Opened" in result.output + +def test_cli_nav_open_bg(): + result = _run(nav_group, ["open", "https://example.com", "--bg"], {"id": 42}) + assert result.exit_code == 0 + assert "Opened" in result.output + +def test_cli_nav_open_with_group(): + result = _run(nav_group, ["open", "https://example.com", "--group", "work"], {"id": 42}) + assert result.exit_code == 0 + assert "work" in result.output + +def test_cli_nav_open_with_window(): + result = _run(nav_group, ["open", "https://example.com", "--window", "main"], {"id": 42}) + assert result.exit_code == 0 + assert "main" in result.output + +def test_cli_nav_reload(): + result = _run(nav_group, ["reload"], None) + assert result.exit_code == 0 + assert "Reloaded" in result.output + +def test_cli_nav_hard_reload(): + result = _run(nav_group, ["hard-reload"], None) + assert result.exit_code == 0 + assert "Hard reloaded" in result.output + +def test_cli_nav_back(): + result = _run(nav_group, ["back"], None) + assert result.exit_code == 0 + assert "back" in result.output.lower() + +def test_cli_nav_forward(): + result = _run(nav_group, ["forward"], None) + assert result.exit_code == 0 + assert "forward" in result.output.lower() + +def test_cli_nav_focus_found(): + result = _run(nav_group, ["focus", "example.com"], {"url": "https://example.com"}) + assert result.exit_code == 0 + assert "Focused" in result.output + +def test_cli_nav_focus_not_found(): + result = _run(nav_group, ["focus", "no-match"], None) + assert result.exit_code == 0 + assert "No tab found" in result.output + +def test_cli_nav_open_wait(): + result = _run(nav_group, ["open-wait", "https://example.com"], + {"id": 1, "title": "Example Domain", "url": "https://example.com"}) + assert result.exit_code == 0 + assert "Loaded" in result.output + assert "Example Domain" in result.output + +def test_cli_nav_open_wait_no_title(): + result = _run(nav_group, ["open-wait", "https://example.com"], {"id": 1}) + assert result.exit_code == 0 + assert "Loaded" in result.output + +def test_cli_nav_wait(): + result = _run(nav_group, ["wait"], {"url": "https://example.com", "title": "Example"}) + assert result.exit_code == 0 + assert "Ready" in result.output + +# --------------------------------------------------------------------------- +# navigate commands — with tab_id argument +# --------------------------------------------------------------------------- + +def test_cli_nav_reload_with_tab_id(): + result = _run(nav_group, ["reload", "42"], None) + assert result.exit_code == 0 + +def test_cli_nav_back_with_tab_id(): + result = _run(nav_group, ["back", "42"], None) + assert result.exit_code == 0 + +def test_cli_nav_forward_with_tab_id(): + result = _run(nav_group, ["forward", "42"], None) + assert result.exit_code == 0 + +# --------------------------------------------------------------------------- +# tabs commands (CLI module) — single-browser mode +# --------------------------------------------------------------------------- + +from browser_cli.commands.tabs import tabs_group + +_TABS_MOD = "browser_cli.commands.tabs" + +_SAMPLE_TAB = {"id": 1, "windowId": 1, "active": True, "muted": False, + "title": "Example", "url": "https://example.com", "groupId": -1} + +def test_cli_tabs_list_empty(): + with patch("browser_cli.commands.send_command", return_value=[]), \ + patch(f"{_TABS_MOD}._multi_browser_targets", return_value=[]): + result = CliRunner().invoke(tabs_group, ["list"]) + assert result.exit_code == 0 + assert "No tabs found" in result.output + +def test_cli_tabs_list_with_tabs(): + with patch("browser_cli.commands.send_command", return_value=[_SAMPLE_TAB]), \ + patch(f"{_TABS_MOD}._multi_browser_targets", return_value=[]): + result = CliRunner().invoke(tabs_group, ["list"]) + assert result.exit_code == 0 + assert "example.com" in result.output + +def test_cli_tabs_close_by_id(): + with patch("browser_cli.commands.send_command", return_value={"closed": 1}): + result = CliRunner().invoke(tabs_group, ["close", "42"]) + assert result.exit_code == 0 + assert "Closed 1" in result.output + +def test_cli_tabs_close_inactive(): + with patch("browser_cli.commands.send_command", return_value={"closed": 3}): + result = CliRunner().invoke(tabs_group, ["close", "--inactive"]) + assert result.exit_code == 0 + assert "Closed 3" in result.output + +def test_cli_tabs_close_duplicates(): + with patch("browser_cli.commands.send_command", return_value={"closed": 2}): + result = CliRunner().invoke(tabs_group, ["close", "--duplicates"]) + assert result.exit_code == 0 + assert "Closed 2" in result.output + +def test_cli_tabs_move_forward(): + with patch("browser_cli.commands.send_command", return_value=None): + result = CliRunner().invoke(tabs_group, ["move", "42", "--forward"]) + assert result.exit_code == 0 + assert "Tab moved" in result.output + +def test_cli_tabs_move_to_window(): + with patch("browser_cli.commands.send_command", return_value=None): + result = CliRunner().invoke(tabs_group, ["move", "42", "--window", "2"]) + assert result.exit_code == 0 + +def test_cli_tabs_active(): + with patch("browser_cli.commands.send_command", return_value=None): + result = CliRunner().invoke(tabs_group, ["active", "42"]) + assert result.exit_code == 0 + assert "42" in result.output + +def test_cli_tabs_status(): + tab = {"id": 5, "windowId": 1, "active": True, "muted": False, + "title": "MyPage", "url": "https://my.page"} + with patch("browser_cli.commands.send_command", return_value=tab): + result = CliRunner().invoke(tabs_group, ["status"]) + assert result.exit_code == 0 + assert "MyPage" in result.output + +def test_cli_tabs_filter(): + with patch("browser_cli.commands.send_command", return_value=[_SAMPLE_TAB]): + result = CliRunner().invoke(tabs_group, ["filter", "example"]) + assert result.exit_code == 0 + assert "example.com" in result.output + +def test_cli_tabs_filter_empty(): + with patch("browser_cli.commands.send_command", return_value=[]): + result = CliRunner().invoke(tabs_group, ["filter", "nope"]) + assert result.exit_code == 0 + assert "No tabs" in result.output + +def test_cli_tabs_count(): + with patch("browser_cli.commands.send_command", return_value=7), \ + patch(f"{_TABS_MOD}._multi_browser_targets", return_value=[]): + result = CliRunner().invoke(tabs_group, ["count"]) + assert result.exit_code == 0 + assert "7" in result.output + +def test_cli_tabs_count_with_pattern(): + with patch("browser_cli.commands.send_command", return_value=3), \ + patch(f"{_TABS_MOD}._multi_browser_targets", return_value=[]): + result = CliRunner().invoke(tabs_group, ["count", "http"]) + assert result.exit_code == 0 + assert "3" in result.output + assert "http" in result.output + +def test_cli_tabs_query(): + with patch("browser_cli.commands.send_command", return_value=[_SAMPLE_TAB]): + result = CliRunner().invoke(tabs_group, ["query", "example"]) + assert result.exit_code == 0 + assert "example.com" in result.output + +def test_cli_tabs_html(): + with patch("browser_cli.commands.send_command", return_value="
hello"): + result = CliRunner().invoke(tabs_group, ["html"]) + assert result.exit_code == 0 + assert "hello" in result.output + +def test_cli_tabs_dedupe(): + with patch("browser_cli.commands.send_command", return_value={"closed": 4}): + result = CliRunner().invoke(tabs_group, ["dedupe"]) + assert result.exit_code == 0 + assert "4 duplicate" in result.output + +def test_cli_tabs_sort(): + with patch("browser_cli.commands.send_command", return_value=None): + result = CliRunner().invoke(tabs_group, ["sort", "--by", "title"]) + assert result.exit_code == 0 + assert "title" in result.output + +def test_cli_tabs_merge_windows(): + with patch("browser_cli.commands.send_command", return_value={"moved": 2}): + result = CliRunner().invoke(tabs_group, ["merge-windows"]) + assert result.exit_code == 0 + assert "2" in result.output + +def test_cli_tabs_mute(): + with patch("browser_cli.commands.send_command", return_value={"tabId": 5}): + result = CliRunner().invoke(tabs_group, ["mute", "5"]) + assert result.exit_code == 0 + assert "Muted tab 5" in result.output + +def test_cli_tabs_unmute(): + with patch("browser_cli.commands.send_command", return_value={"tabId": 5}): + result = CliRunner().invoke(tabs_group, ["unmute", "5"]) + assert result.exit_code == 0 + assert "Unmuted tab 5" in result.output + +def test_cli_tabs_pin(): + with patch("browser_cli.commands.send_command", return_value={"tabId": 5}): + result = CliRunner().invoke(tabs_group, ["pin", "5"]) + assert result.exit_code == 0 + assert "Pinned tab 5" in result.output + +def test_cli_tabs_unpin(): + with patch("browser_cli.commands.send_command", return_value={"tabId": 5}): + result = CliRunner().invoke(tabs_group, ["unpin", "5"]) + assert result.exit_code == 0 + assert "Unpinned tab 5" in result.output + +def test_cli_tabs_watch_url(): + with patch("browser_cli.commands.send_command", return_value={"url": "https://done.com"}): + result = CliRunner().invoke(tabs_group, ["watch-url", "done\\.com"]) + assert result.exit_code == 0 + assert "done.com" in result.output + +def test_cli_tabs_screenshot_stdout(): + with patch("browser_cli.commands.send_command", return_value={"dataUrl": "data:image/png;base64,abc"}): + result = CliRunner().invoke(tabs_group, ["screenshot"]) + assert result.exit_code == 0 + assert "data:image/png" in result.output + +def test_cli_tabs_screenshot_to_file(tmp_path): + import base64 + png_bytes = b"\x89PNG\r\n\x1a\n" + b"\x00" * 8 # minimal header + data_url = "data:image/png;base64," + base64.b64encode(png_bytes).decode() + out = tmp_path / "shot.png" + with patch("browser_cli.commands.send_command", return_value={"dataUrl": data_url}): + result = CliRunner().invoke(tabs_group, ["screenshot", str(out)]) + assert result.exit_code == 0 + assert "saved" in result.output.lower() + assert out.exists() + +# --------------------------------------------------------------------------- +# groups commands (CLI module) +# --------------------------------------------------------------------------- + +from browser_cli.commands.groups import group_group + +_GROUPS_MOD = "browser_cli.commands.groups" + +_SAMPLE_GROUP = {"id": 10, "title": "Work", "color": "blue", "collapsed": False, "tabCount": 3} + +def test_cli_groups_list_empty(): + with patch("browser_cli.commands.send_command", return_value=[]), \ + patch(f"{_GROUPS_MOD}._multi_browser_targets", return_value=[]): + result = CliRunner().invoke(group_group, ["list"]) + assert result.exit_code == 0 + assert "No groups found" in result.output + +def test_cli_groups_list_with_groups(): + with patch("browser_cli.commands.send_command", return_value=[_SAMPLE_GROUP]), \ + patch(f"{_GROUPS_MOD}._multi_browser_targets", return_value=[]): + result = CliRunner().invoke(group_group, ["list"]) + assert result.exit_code == 0 + assert "Work" in result.output + +def test_cli_groups_tabs(): + tabs = [_SAMPLE_TAB] + with patch("browser_cli.commands.send_command", return_value=tabs): + result = CliRunner().invoke(group_group, ["tabs", "10"]) + assert result.exit_code == 0 + assert "example.com" in result.output + +def test_cli_groups_tabs_empty(): + with patch("browser_cli.commands.send_command", return_value=[]): + result = CliRunner().invoke(group_group, ["tabs", "10"]) + assert result.exit_code == 0 + assert "No tabs" in result.output + +def test_cli_groups_count(): + with patch("browser_cli.commands.send_command", return_value=5), \ + patch(f"{_GROUPS_MOD}._multi_browser_targets", return_value=[]): + result = CliRunner().invoke(group_group, ["count"]) + assert result.exit_code == 0 + assert "5" in result.output + +def test_cli_groups_query_empty(): + with patch("browser_cli.commands.send_command", return_value=[]): + result = CliRunner().invoke(group_group, ["query", "nothing"]) + assert result.exit_code == 0 + assert "No groups" in result.output + +def test_cli_groups_query_found(): + with patch("browser_cli.commands.send_command", return_value=[_SAMPLE_GROUP]): + result = CliRunner().invoke(group_group, ["query", "Work"]) + assert result.exit_code == 0 + assert "Work" in result.output + +def test_cli_groups_close(): + with patch("browser_cli.commands.send_command", return_value=None): + result = CliRunner().invoke(group_group, ["close", "10"]) + assert result.exit_code == 0 + assert "10" in result.output + +def test_cli_groups_create(): + with patch("browser_cli.commands.send_command", return_value={"id": 42}): + result = CliRunner().invoke(group_group, ["create", "Research"]) + assert result.exit_code == 0 + assert "Research" in result.output + assert "42" in result.output + +def test_cli_groups_add_tab_no_url(): + with patch("browser_cli.commands.send_command", return_value={"tabId": 7}): + result = CliRunner().invoke(group_group, ["add-tab", "Work"]) + assert result.exit_code == 0 + assert "Work" in result.output + +def test_cli_groups_add_tab_with_url(): + with patch("browser_cli.commands.send_command", return_value={"tabId": 9}): + result = CliRunner().invoke(group_group, ["add-tab", "Work", "https://docs.example.com"]) + assert result.exit_code == 0 + assert "docs.example.com" in result.output + +def test_cli_groups_move_forward(): + with patch("browser_cli.commands.send_command", return_value={"moved": True}): + result = CliRunner().invoke(group_group, ["move", "10", "--forward"]) + assert result.exit_code == 0 + assert "forward" in result.output + +def test_cli_groups_move_backward(): + with patch("browser_cli.commands.send_command", return_value={"moved": True}): + result = CliRunner().invoke(group_group, ["move", "10", "--backward"]) + assert result.exit_code == 0 + assert "backward" in result.output + +def test_cli_groups_move_no_direction(): + with patch("browser_cli.commands.send_command", return_value=None): + result = CliRunner().invoke(group_group, ["move", "10"]) + assert result.exit_code != 0 + +def test_cli_groups_move_already_at_end(): + with patch("browser_cli.commands.send_command", return_value={"moved": False}): + result = CliRunner().invoke(group_group, ["move", "10", "--forward"]) + assert result.exit_code == 0 + assert "already at" in result.output + +# --------------------------------------------------------------------------- +# session commands (CLI module) +# --------------------------------------------------------------------------- + +from browser_cli.commands.session import session_group + +_SESSION_MOD = "browser_cli.commands.session" + +def test_cli_session_save(): + with patch("browser_cli.commands.send_command", return_value={"tabs": 5}): + result = CliRunner().invoke(session_group, ["save", "work"]) + assert result.exit_code == 0 + assert "work" in result.output + assert "5" in result.output + +def test_cli_session_load(): + with patch("browser_cli.commands.send_command", return_value={"tabs": 8}): + result = CliRunner().invoke(session_group, ["load", "work"]) + assert result.exit_code == 0 + assert "work" in result.output + assert "8" in result.output + +def test_cli_session_load_background(): + with patch("browser_cli.commands.send_command", return_value={"jobId": "j-abc", "status": "running"}): + result = CliRunner().invoke(session_group, ["load", "work", "--background"]) + assert result.exit_code == 0 + assert "j-abc" in result.output + +def test_cli_session_load_lazy(): + with patch("browser_cli.commands.send_command", return_value={"tabs": 20}): + result = CliRunner().invoke(session_group, ["load", "work", "--lazy", "--eager-tabs", "5"]) + assert result.exit_code == 0 + +def test_cli_session_diff_has_changes(): + diff = {"added": ["https://new.com"], "removed": ["https://old.com"]} + with patch("browser_cli.commands.send_command", return_value=diff): + result = CliRunner().invoke(session_group, ["diff", "a", "b"]) + assert result.exit_code == 0 + assert "new.com" in result.output + assert "old.com" in result.output + +def test_cli_session_diff_identical(): + with patch("browser_cli.commands.send_command", return_value={"added": [], "removed": []}): + result = CliRunner().invoke(session_group, ["diff", "a", "b"]) + assert result.exit_code == 0 + assert "identical" in result.output + +def test_cli_session_diff_no_data(): + with patch("browser_cli.commands.send_command", return_value=None): + result = CliRunner().invoke(session_group, ["diff", "a", "b"]) + assert result.exit_code == 0 + assert "No diff" in result.output + +def test_cli_session_list_empty(): + with patch("browser_cli.commands.send_command", return_value=[]), \ + patch(f"{_SESSION_MOD}._multi_browser_targets", return_value=[]): + result = CliRunner().invoke(session_group, ["list"]) + assert result.exit_code == 0 + assert "No saved sessions" in result.output + +def test_cli_session_list_with_sessions(): + sessions = [{"name": "work", "tabs": 3, "savedAt": 1700000000000}] + with patch("browser_cli.commands.send_command", return_value=sessions), \ + patch(f"{_SESSION_MOD}._multi_browser_targets", return_value=[]): + result = CliRunner().invoke(session_group, ["list"]) + assert result.exit_code == 0 + assert "work" in result.output + +def test_cli_session_remove(): + with patch("browser_cli.commands.send_command", return_value=None): + result = CliRunner().invoke(session_group, ["remove", "old-session"]) + assert result.exit_code == 0 + assert "old-session" in result.output + +def test_cli_session_job_status_running(): + with patch("browser_cli.commands.send_command", return_value={"status": "running", "percent": 42}): + result = CliRunner().invoke(session_group, ["job-status", "j-xyz"]) + assert result.exit_code == 0 + assert "running" in result.output + +def test_cli_session_job_status_with_error(): + with patch("browser_cli.commands.send_command", return_value={"status": "failed", "error": "something broke"}): + result = CliRunner().invoke(session_group, ["job-status", "j-bad"]) + assert result.exit_code == 0 + assert "something broke" in result.output + +def test_cli_session_job_status_with_result(): + with patch("browser_cli.commands.send_command", return_value={"status": "done", "result": "Opened 5 tabs"}): + result = CliRunner().invoke(session_group, ["job-status", "j-done"]) + assert result.exit_code == 0 + assert "Opened 5 tabs" in result.output + +def test_cli_session_job_cancel(): + with patch("browser_cli.commands.send_command", return_value=None): + result = CliRunner().invoke(session_group, ["job-cancel", "j-running"]) + assert result.exit_code == 0 + assert "j-running" in result.output + +def test_cli_session_auto_save_on(): + with patch("browser_cli.commands.send_command", return_value=None): + result = CliRunner().invoke(session_group, ["auto-save", "on"]) + assert result.exit_code == 0 + assert "on" in result.output + +def test_cli_session_auto_save_off(): + with patch("browser_cli.commands.send_command", return_value=None): + result = CliRunner().invoke(session_group, ["auto-save", "off"]) + assert result.exit_code == 0 + assert "off" in result.output + +# --------------------------------------------------------------------------- +# multi-browser error paths (None targets → "Cannot resolve" exit) +# --------------------------------------------------------------------------- + +from browser_cli.client import BrowserTarget + +def _fake_target(name="browser-a"): + return BrowserTarget(profile=name, display_name=name, socket_path="/tmp/fake.sock") + +def test_cli_tabs_list_multi_browser_all_none(): + """If every multi-browser target returns None, show error and exit 1.""" + target = _fake_target() + with patch("browser_cli.commands.send_command", return_value=None), \ + patch(f"{_TABS_MOD}._multi_browser_targets", return_value=[target]), \ + patch(f"{_TABS_MOD}._handle_multi", return_value=None): + result = CliRunner().invoke(tabs_group, ["list"]) + assert result.exit_code != 0 + +def test_cli_tabs_count_multi_browser_all_none(): + """If every multi-browser count returns None, show error and exit 1.""" + target = _fake_target() + with patch("browser_cli.commands.send_command", return_value=None), \ + patch(f"{_TABS_MOD}._multi_browser_targets", return_value=[target]), \ + patch(f"{_TABS_MOD}._handle_multi", return_value=None): + result = CliRunner().invoke(tabs_group, ["count"]) + assert result.exit_code != 0 + +def test_cli_groups_list_multi_browser_all_none(): + """If every multi-browser group list returns None, exit 1.""" + target = _fake_target() + with patch("browser_cli.commands.send_command", return_value=None), \ + patch(f"{_GROUPS_MOD}._multi_browser_targets", return_value=[target]), \ + patch(f"{_GROUPS_MOD}._handle_multi", return_value=None): + result = CliRunner().invoke(group_group, ["list"]) + assert result.exit_code != 0 + +def test_cli_groups_count_multi_browser_all_none(): + """If every multi-browser group count returns None, exit 1.""" + target = _fake_target() + with patch("browser_cli.commands.send_command", return_value=None), \ + patch(f"{_GROUPS_MOD}._multi_browser_targets", return_value=[target]), \ + patch(f"{_GROUPS_MOD}._handle_multi", return_value=None): + result = CliRunner().invoke(group_group, ["count"]) + assert result.exit_code != 0 + +def test_cli_session_list_multi_browser_all_none(): + """If every multi-browser session list returns None, exit 1.""" + target = _fake_target() + with patch("browser_cli.commands.send_command", return_value=None), \ + patch(f"{_SESSION_MOD}._multi_browser_targets", return_value=[target]), \ + patch(f"{_SESSION_MOD}._handle_multi", return_value=None): + result = CliRunner().invoke(session_group, ["list"]) + assert result.exit_code != 0 + +# --------------------------------------------------------------------------- +# tabs screenshot error paths +# --------------------------------------------------------------------------- + +def test_cli_tabs_screenshot_bad_dataurl(tmp_path): + """Screenshot command exits non-zero when dataUrl has wrong format.""" + out = tmp_path / "bad.png" + with patch("browser_cli.commands.send_command", return_value={"dataUrl": "not-a-dataurl"}): + result = CliRunner().invoke(tabs_group, ["screenshot", str(out)]) + assert result.exit_code != 0 + +def test_cli_tabs_screenshot_bad_base64(tmp_path): + """Screenshot command exits non-zero when base64 data is corrupt.""" + out = tmp_path / "bad.png" + with patch("browser_cli.commands.send_command", return_value={"dataUrl": "data:image/png;base64,!!!NOT_VALID_BASE64!!!"}): + result = CliRunner().invoke(tabs_group, ["screenshot", str(out)]) + assert result.exit_code != 0 + +# --------------------------------------------------------------------------- +# windows commands (CLI module) +# --------------------------------------------------------------------------- + +from browser_cli.commands.windows import windows_group + +_WINDOWS_MOD = "browser_cli.commands.windows" + +_SAMPLE_WINDOW = {"id": 1, "alias": "main", "tabCount": 5, "state": "normal"} + +def test_cli_windows_list_empty(): + with patch("browser_cli.commands.send_command", return_value=[]), \ + patch(f"{_WINDOWS_MOD}._multi_browser_targets", return_value=[]): + result = CliRunner().invoke(windows_group, ["list"]) + assert result.exit_code == 0 + assert "No windows found" in result.output + +def test_cli_windows_list_with_windows(): + with patch("browser_cli.commands.send_command", return_value=[_SAMPLE_WINDOW]), \ + patch(f"{_WINDOWS_MOD}._multi_browser_targets", return_value=[]): + result = CliRunner().invoke(windows_group, ["list"]) + assert result.exit_code == 0 + assert "main" in result.output + +def test_cli_windows_list_multi_all_none(): + target = _fake_target() + with patch("browser_cli.commands.send_command", return_value=None), \ + patch(f"{_WINDOWS_MOD}._multi_browser_targets", return_value=[target]), \ + patch(f"{_WINDOWS_MOD}._handle_multi", return_value=None): + result = CliRunner().invoke(windows_group, ["list"]) + assert result.exit_code != 0 + +def test_cli_windows_rename(): + with patch("browser_cli.commands.send_command", return_value=None): + result = CliRunner().invoke(windows_group, ["rename", "1", "work"]) + assert result.exit_code == 0 + assert "work" in result.output + assert "1" in result.output + +def test_cli_windows_close(): + with patch("browser_cli.commands.send_command", return_value=None): + result = CliRunner().invoke(windows_group, ["close", "2"]) + assert result.exit_code == 0 + assert "2" in result.output + +def test_cli_windows_open_no_url(): + with patch("browser_cli.commands.send_command", return_value={"id": 5}): + result = CliRunner().invoke(windows_group, ["open"]) + assert result.exit_code == 0 + assert "5" in result.output + +def test_cli_windows_open_with_url(): + with patch("browser_cli.commands.send_command", return_value={"id": 6}): + result = CliRunner().invoke(windows_group, ["open", "https://example.com"]) + assert result.exit_code == 0 + assert "example.com" in result.output + +# --------------------------------------------------------------------------- +# commands/__init__.py error paths +# --------------------------------------------------------------------------- + +from browser_cli.commands import _handle, _handle_multi +from browser_cli.client import BrowserNotConnected + +def test_handle_raises_system_exit_on_browser_not_connected(): + """_handle converts BrowserNotConnected into SystemExit(1).""" + with patch("browser_cli.commands.send_command", side_effect=BrowserNotConnected("no socket")): + with pytest.raises(SystemExit): + _handle("tabs.list") + +def test_handle_raises_system_exit_on_runtime_error(): + """_handle converts RuntimeError into SystemExit(1).""" + with patch("browser_cli.commands.send_command", side_effect=RuntimeError("browser blew up")): + with pytest.raises(SystemExit): + _handle("tabs.list") + +def test_handle_multi_returns_none_on_error(): + """_handle_multi silently returns None on BrowserNotConnected.""" + with patch("browser_cli.commands.send_command", side_effect=BrowserNotConnected("gone")): + result = _handle_multi("tabs.list") + assert result is None + +def test_handle_multi_returns_none_on_runtime_error(): + """_handle_multi silently returns None on RuntimeError.""" + with patch("browser_cli.commands.send_command", side_effect=RuntimeError("oops")): + result = _handle_multi("tabs.list") + assert result is None + +def test_handle_multi_with_remote(): + """_handle_multi routes through remote when remote arg is set.""" + with patch("browser_cli.commands.send_command", return_value={"ok": True}) as mock_send: + result = _handle_multi("tabs.list", profile="brave", remote="host:8765") + assert result == {"ok": True} + mock_send.assert_called_once_with("tabs.list", {}, profile="brave", remote="host:8765") diff --git a/tests/test_cookies.py b/tests/test_cookies.py new file mode 100644 index 0000000..7d36e5b --- /dev/null +++ b/tests/test_cookies.py @@ -0,0 +1,103 @@ +"""Integration tests for cookies.* commands — require a live browser.""" +import time + +def test_cookies_list_returns_list(browser, http_tab): + """cookies.list returns a list (may be empty on a plain https://example.com).""" + browser("tabs.active", {"tabId": http_tab["id"]}) + cookies = browser("cookies.list", {}) + assert isinstance(cookies, list) + +def test_cookies_list_has_required_fields(browser, http_tab): + """Every cookie returned has at least name, domain and path fields.""" + browser("tabs.active", {"tabId": http_tab["id"]}) + # Set a known cookie so the list is non-empty + browser("cookies.set", { + "url": "https://example.com", + "name": "__pytest_field_check", + "value": "1", + }) + cookies = browser("cookies.list", {"url": "https://example.com"}) + assert isinstance(cookies, list) + assert len(cookies) > 0 + for c in cookies: + assert "name" in c + assert "domain" in c + assert "path" in c + +def test_cookies_set_and_list(browser, http_tab): + """Set a cookie and verify it appears in the list.""" + browser("tabs.active", {"tabId": http_tab["id"]}) + cookie_name = "__pytest_set_test" + cookie_value = "hello-pytest" + + browser("cookies.set", { + "url": "https://example.com", + "name": cookie_name, + "value": cookie_value, + }) + + cookies = browser("cookies.list", {"url": "https://example.com"}) + found = next((c for c in cookies if c.get("name") == cookie_name), None) + assert found is not None, f"Cookie '{cookie_name}' not found after set" + assert found["value"] == cookie_value + +def test_cookies_get(browser, http_tab): + """Get a single cookie by URL + name.""" + browser("tabs.active", {"tabId": http_tab["id"]}) + name = "__pytest_get_test" + value = "get-value-42" + + browser("cookies.set", {"url": "https://example.com", "name": name, "value": value}) + + cookie = browser("cookies.get", {"url": "https://example.com", "name": name}) + assert cookie is not None + assert cookie.get("value") == value + +def test_cookies_get_missing_returns_none(browser, http_tab): + """Getting a non-existent cookie returns None.""" + browser("tabs.active", {"tabId": http_tab["id"]}) + cookie = browser("cookies.get", { + "url": "https://example.com", + "name": "__pytest_no_such_cookie_zzz", + }) + assert cookie is None + +def test_cookies_list_filter_by_domain(browser, http_tab): + """Filtering by domain only returns matching cookies.""" + browser("tabs.active", {"tabId": http_tab["id"]}) + browser("cookies.set", { + "url": "https://example.com", + "name": "__pytest_domain_filter", + "value": "yes", + }) + cookies = browser("cookies.list", {"domain": "example.com"}) + assert isinstance(cookies, list) + for c in cookies: + assert "example.com" in c.get("domain", "") + +def test_cookies_list_filter_by_name(browser, http_tab): + """Filtering by name only returns cookies with that name.""" + browser("tabs.active", {"tabId": http_tab["id"]}) + name = "__pytest_name_filter_unique" + browser("cookies.set", {"url": "https://example.com", "name": name, "value": "y"}) + + cookies = browser("cookies.list", {"name": name}) + assert isinstance(cookies, list) + assert len(cookies) > 0 + for c in cookies: + assert c["name"] == name + +def test_cookies_set_with_secure_flag(browser, http_tab): + """Setting a cookie with secure=True persists the secure attribute.""" + browser("tabs.active", {"tabId": http_tab["id"]}) + name = "__pytest_secure_cookie" + browser("cookies.set", { + "url": "https://example.com", + "name": name, + "value": "secured", + "secure": True, + }) + cookies = browser("cookies.list", {"url": "https://example.com", "name": name}) + found = next((c for c in cookies if c["name"] == name), None) + assert found is not None + assert found.get("secure") is True diff --git a/tests/test_dom.py b/tests/test_dom.py index eb0d6a3..68095e6 100644 --- a/tests/test_dom.py +++ b/tests/test_dom.py @@ -58,3 +58,210 @@ def test_dom_attr_html_lang(browser, http_tab): assert isinstance(langs, list) # html element exists so we get exactly one entry (may be empty string if no lang attr) assert len(langs) <= 1 + +# --------------------------------------------------------------------------- +# dom.eval +# --------------------------------------------------------------------------- + +def test_dom_eval_returns_string(browser, http_tab): + """Evaluating document.title returns the page title as a string.""" + browser("tabs.active", {"tabId": http_tab["id"]}) + result = browser("dom.eval", {"code": "document.title", "tabId": http_tab["id"]}) + assert isinstance(result, str) + +def test_dom_eval_arithmetic(browser, http_tab): + """Evaluating a JS expression returns the computed value.""" + browser("tabs.active", {"tabId": http_tab["id"]}) + result = browser("dom.eval", {"code": "2 + 2", "tabId": http_tab["id"]}) + assert result == 4 + +def test_dom_eval_returns_null_for_void(browser, http_tab): + """Evaluating a void expression returns None.""" + browser("tabs.active", {"tabId": http_tab["id"]}) + result = browser("dom.eval", {"code": "void 0", "tabId": http_tab["id"]}) + assert result is None + +def test_dom_eval_returns_dict(browser, http_tab): + """Evaluating an object expression returns a dict.""" + browser("tabs.active", {"tabId": http_tab["id"]}) + result = browser("dom.eval", {"code": "({a: 1, b: 2})", "tabId": http_tab["id"]}) + assert isinstance(result, dict) + assert result.get("a") == 1 + assert result.get("b") == 2 + +def test_dom_eval_dom_read(browser, http_tab): + """Can read a property of a DOM element via eval.""" + browser("tabs.active", {"tabId": http_tab["id"]}) + result = browser("dom.eval", {"code": "document.querySelector('h1') ? document.querySelector('h1').textContent : null", "tabId": http_tab["id"]}) + # result is either a string (h1 text) or None — both are valid + assert result is None or isinstance(result, str) + +# --------------------------------------------------------------------------- +# dom.scroll +# --------------------------------------------------------------------------- + +def test_dom_scroll_to_coordinates(browser, http_tab): + """Scrolling to (x, y) coordinates does not raise.""" + browser("tabs.active", {"tabId": http_tab["id"]}) + result = browser("dom.scroll", {"x": 0, "y": 0}) + assert result is None or isinstance(result, (dict, bool)) + +def test_dom_scroll_to_selector(browser, http_tab): + """Scrolling to an existing selector does not raise.""" + browser("tabs.active", {"tabId": http_tab["id"]}) + result = browser("dom.scroll", {"selector": "body"}) + assert result is None or isinstance(result, (dict, bool)) + +# --------------------------------------------------------------------------- +# dom.wait_for +# --------------------------------------------------------------------------- + +def test_dom_wait_for_existing_element(browser, http_tab): + """wait_for an element that already exists returns quickly.""" + browser("tabs.active", {"tabId": http_tab["id"]}) + result = browser("dom.wait_for", { + "selector": "html", + "timeout": 5000, + "visible": False, + "hidden": False, + "tabId": http_tab["id"], + }) + # Returns None or a dict on success + assert result is None or isinstance(result, dict) + +def test_dom_wait_for_visible(browser, http_tab): + """wait_for visible=True on a visible element succeeds.""" + browser("tabs.active", {"tabId": http_tab["id"]}) + result = browser("dom.wait_for", { + "selector": "body", + "timeout": 5000, + "visible": True, + "hidden": False, + "tabId": http_tab["id"], + }) + assert result is None or isinstance(result, dict) + +# --------------------------------------------------------------------------- +# dom.focus +# --------------------------------------------------------------------------- + +def test_dom_focus_element(browser, http_tab): + """Focusing an existing element does not raise.""" + browser("tabs.active", {"tabId": http_tab["id"]}) + result = browser("dom.focus", {"selector": "body"}) + assert result is None or isinstance(result, (dict, bool)) + +# --------------------------------------------------------------------------- +# dom.hover +# --------------------------------------------------------------------------- + +def test_dom_hover_element(browser, http_tab): + """Hovering over an existing element does not raise.""" + browser("tabs.active", {"tabId": http_tab["id"]}) + result = browser("dom.hover", {"selector": "body"}) + assert result is None or isinstance(result, (dict, bool)) + +# --------------------------------------------------------------------------- +# dom.type / dom.clear (use a data: URL tab with an ) +# --------------------------------------------------------------------------- + +def test_dom_type_into_input(browser, http_tab): + """Type text into an injected input field and read it back via eval.""" + browser("tabs.active", {"tabId": http_tab["id"]}) + # Inject a fresh input element with a unique id + input_id = "__pytest_type_input" + browser("dom.eval", { + "code": f"(function(){{ var e=document.getElementById('{input_id}'); if(!e){{e=document.createElement('input');e.id='{input_id}';e.type='text';document.body.appendChild(e);}} return true; }})()", + "tabId": http_tab["id"], + }) + + browser("dom.type", {"selector": f"#{input_id}", "text": "hello"}) + + value = browser("dom.eval", {"code": f"document.getElementById('{input_id}').value", "tabId": http_tab["id"]}) + assert value == "hello" + +def test_dom_clear_input(browser, http_tab): + """Clear an input field sets its value to empty string.""" + browser("tabs.active", {"tabId": http_tab["id"]}) + input_id = "__pytest_clear_input" + browser("dom.eval", { + "code": f"(function(){{ var e=document.getElementById('{input_id}'); if(!e){{e=document.createElement('input');e.id='{input_id}';e.type='text';document.body.appendChild(e);}} e.value='prefilled'; return true; }})()", + "tabId": http_tab["id"], + }) + + browser("dom.clear", {"selector": f"#{input_id}"}) + + value = browser("dom.eval", {"code": f"document.getElementById('{input_id}').value", "tabId": http_tab["id"]}) + assert value == "" + +# --------------------------------------------------------------------------- +# dom.key +# --------------------------------------------------------------------------- + +def test_dom_key_event_does_not_raise(browser, http_tab): + """Sending a key event to the body does not raise.""" + browser("tabs.active", {"tabId": http_tab["id"]}) + result = browser("dom.key", {"key": "Tab"}) + assert result is None or isinstance(result, (dict, bool)) + +def test_dom_key_with_selector(browser, http_tab): + """Sending a key event to a specific selector does not raise.""" + browser("tabs.active", {"tabId": http_tab["id"]}) + result = browser("dom.key", {"key": "Escape", "selector": "body"}) + assert result is None or isinstance(result, (dict, bool)) + +# --------------------------------------------------------------------------- +# dom.select (requires a