From 9aad012bdc59ca58d38f21838a6cc42af8ded2d3 Mon Sep 17 00:00:00 2001 From: Daniel Dolezal Date: Wed, 20 May 2026 23:53:56 +0200 Subject: [PATCH] test: expand browser command coverage - Add mocked Click CLI tests across DOM, cookies, page, storage, perf, navigation, tabs, groups, sessions, and windows commands. - Cover integration paths for cookies, page info, performance profiles, and web storage commands. - Extend DOM integration coverage for eval, scrolling, waiting, focus, hover, typing, selection, keyboard, and checkbox interactions. - Add native host unit coverage for message framing, socket helpers, paging guards, timeouts, and profile alias resolution. --- tests/test_commands_cli.py | 993 +++++++++++++++++++++++++++++++++++++ tests/test_cookies.py | 103 ++++ tests/test_dom.py | 207 ++++++++ tests/test_native_host.py | 230 ++++++++- tests/test_page.py | 39 ++ tests/test_perf.py | 72 +++ tests/test_storage.py | 54 ++ 7 files changed, 1689 insertions(+), 9 deletions(-) create mode 100644 tests/test_commands_cli.py create mode 100644 tests/test_cookies.py create mode 100644 tests/test_page.py create mode 100644 tests/test_perf.py create mode 100644 tests/test_storage.py 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 value changes it and it can be read back.""" + browser("tabs.active", {"tabId": http_tab["id"]}) + sel_id = "__pytest_select" + browser("dom.eval", { + "code": ( + f"(function(){{" + f" var s=document.getElementById('{sel_id}');" + f" if(!s){{" + f" s=document.createElement('select');s.id='{sel_id}';" + f" ['a','b','c'].forEach(function(v){{var o=document.createElement('option');o.value=v;o.text=v;s.appendChild(o);}});" + f" document.body.appendChild(s);" + f" }} return true;" + f"}})()" + ), + "tabId": http_tab["id"], + }) + + browser("dom.select", {"selector": f"#{sel_id}", "value": "b"}) + + value = browser("dom.eval", {"code": f"document.getElementById('{sel_id}').value", "tabId": http_tab["id"]}) + assert value == "b" + +# --------------------------------------------------------------------------- +# dom.check / dom.uncheck +# --------------------------------------------------------------------------- + +def test_dom_check_and_uncheck(browser, http_tab): + """Checking and unchecking a checkbox toggles its checked state.""" + browser("tabs.active", {"tabId": http_tab["id"]}) + cb_id = "__pytest_checkbox" + browser("dom.eval", { + "code": ( + f"(function(){{" + f" var c=document.getElementById('{cb_id}');" + f" if(!c){{" + f" c=document.createElement('input');c.id='{cb_id}';c.type='checkbox';" + f" document.body.appendChild(c);" + f" }} c.checked=false; return true;" + f"}})()" + ), + "tabId": http_tab["id"], + }) + + browser("dom.check", {"selector": f"#{cb_id}"}) + checked = browser("dom.eval", {"code": f"document.getElementById('{cb_id}').checked", "tabId": http_tab["id"]}) + assert checked is True + + browser("dom.uncheck", {"selector": f"#{cb_id}"}) + checked = browser("dom.eval", {"code": f"document.getElementById('{cb_id}').checked", "tabId": http_tab["id"]}) + assert checked is False diff --git a/tests/test_native_host.py b/tests/test_native_host.py index 0f85f0d..642e17d 100644 --- a/tests/test_native_host.py +++ b/tests/test_native_host.py @@ -6,11 +6,9 @@ import pytest import browser_cli.native_host as native_host - def _raise_system_exit(code: int): raise SystemExit(code) - def test_cleanup_removes_socket_and_registry_entry(monkeypatch, tmp_path): alias = "work" socket_path = tmp_path / "work.sock" @@ -27,7 +25,6 @@ def test_cleanup_removes_socket_and_registry_entry(monkeypatch, tmp_path): assert not socket_path.exists() assert json.loads(registry_path.read_text()) == {"other": str(tmp_path / "other.sock")} - def test_stdin_reader_cleans_up_on_eof(monkeypatch): cleaned = [] @@ -41,7 +38,6 @@ def test_stdin_reader_cleans_up_on_eof(monkeypatch): assert cleaned == ["work"] - def test_cleanup_windows_skips_socket_unlink(monkeypatch, tmp_path): registry_path = tmp_path / "registry.json" registry_path.write_text(json.dumps({"work": r"\\.\pipe\browser-cli-work"})) @@ -53,7 +49,6 @@ def test_cleanup_windows_skips_socket_unlink(monkeypatch, tmp_path): assert json.loads(registry_path.read_text()) == {} - def test_stdin_reader_cleans_up_on_bye(monkeypatch): cleaned = [] messages = iter([{"type": "bye"}]) @@ -68,7 +63,6 @@ def test_stdin_reader_cleans_up_on_bye(monkeypatch): assert cleaned == ["work"] - def test_stdin_reader_routes_response_messages(monkeypatch): response_queue = native_host.queue.Queue() native_host.PENDING["msg-1"] = response_queue @@ -85,7 +79,6 @@ def test_stdin_reader_routes_response_messages(monkeypatch): assert response_queue.get_nowait() == {"id": "msg-1", "success": True} native_host.PENDING.clear() - def test_collect_paged_browser_command_accumulates_pages(monkeypatch): calls = [] pages = iter([ @@ -110,7 +103,6 @@ def test_collect_paged_browser_command_accumulates_pages(monkeypatch): assert all(call["args"]["foo"] == "bar" for call in calls) assert all(call["id"] != "orig" for call in calls) - def test_collect_paged_browser_command_passes_through_non_paged_response(monkeypatch): monkeypatch.setattr(native_host, "_send_browser_command", lambda cmd: {"id": cmd["id"], "success": True, "data": {"value": 1}}) @@ -118,7 +110,6 @@ def test_collect_paged_browser_command_passes_through_non_paged_response(monkeyp assert result == {"id": "orig", "success": True, "data": {"value": 1}} - def test_handle_browser_command_pages_known_list_commands(monkeypatch): seen = [] @@ -128,3 +119,224 @@ def test_handle_browser_command_pages_known_list_commands(monkeypatch): assert result == {"success": True, "data": []} assert seen[0]["command"] == "tabs.list" + +def test_handle_browser_command_sends_non_pageable_directly(monkeypatch): + seen = [] + monkeypatch.setattr(native_host, "_send_browser_command", lambda cmd: seen.append(cmd) or {"success": True, "data": "ok"}) + + result = native_host._handle_browser_command({"id": "x", "command": "navigate.open", "args": {}}) + + assert result == {"success": True, "data": "ok"} + assert seen[0]["command"] == "navigate.open" + +# --------------------------------------------------------------------------- +# _read_exact_stream +# --------------------------------------------------------------------------- + +def test_read_exact_stream_full_read(): + """Returns the exact bytes when stream delivers them in one shot.""" + import io + stream = io.BytesIO(b"hello") + assert native_host._read_exact_stream(stream, 5) == b"hello" + +def test_read_exact_stream_partial_chunks(): + """Accumulates multiple short chunks until n bytes are read.""" + import io + + class _ChunkyStream: + def __init__(self, data, chunk_size): + self._data = data + self._pos = 0 + self._chunk_size = chunk_size + + def read(self, n): + end = min(self._pos + self._chunk_size, len(self._data)) + chunk = self._data[self._pos:end] + self._pos = end + return chunk + + stream = _ChunkyStream(b"abcdefgh", 3) + assert native_host._read_exact_stream(stream, 8) == b"abcdefgh" + +def test_read_exact_stream_eof_returns_none(): + """Returns None if stream is exhausted before n bytes are delivered.""" + import io + stream = io.BytesIO(b"ab") # only 2 bytes, asking for 4 + assert native_host._read_exact_stream(stream, 4) is None + +def test_read_exact_stream_immediate_eof(): + """Returns None on an empty stream.""" + import io + stream = io.BytesIO(b"") + assert native_host._read_exact_stream(stream, 1) is None + +# --------------------------------------------------------------------------- +# write_native_message / read_native_message round-trip +# --------------------------------------------------------------------------- + +def test_write_and_read_native_message_roundtrip(): + """write_native_message followed by read_native_message recovers the original dict.""" + import io + buf = io.BytesIO() + msg = {"id": "abc", "command": "tabs.list", "args": {}} + native_host.write_native_message(buf, msg) + buf.seek(0) + result = native_host.read_native_message(buf) + assert result == msg + +def test_read_native_message_eof_at_length_prefix(): + """Returns None when the stream is empty (no length prefix).""" + import io + stream = io.BytesIO(b"") + assert native_host.read_native_message(stream) is None + +def test_read_native_message_eof_at_body(): + """Returns None when the body is truncated after reading the length prefix.""" + import io + import struct + # Write a 10-byte length prefix but only 5 bytes of body + buf = struct.pack(" 0 + +def test_page_info_meta_is_dict(browser, http_tab): + """meta field is a dict (may be empty for simple pages).""" + browser("tabs.active", {"tabId": http_tab["id"]}) + info = browser("page.info") + meta = info.get("meta") + assert meta is None or isinstance(meta, dict) diff --git a/tests/test_perf.py b/tests/test_perf.py new file mode 100644 index 0000000..b989f96 --- /dev/null +++ b/tests/test_perf.py @@ -0,0 +1,72 @@ +"""Integration tests for perf.* commands — require a live browser.""" +import time + +_VALID_PROFILES = {"auto", "normal", "gentle", "ultra"} + +def test_perf_status_returns_profile_field(browser): + """perf.status returns a dict with a performanceProfile field.""" + result = browser("perf.status") + assert isinstance(result, dict) + assert "performanceProfile" in result + assert result["performanceProfile"] in _VALID_PROFILES + +def test_perf_status_has_throttle(browser): + """perf.status includes a throttle dict with batchSize and pauseMs.""" + result = browser("perf.status") + throttle = result.get("throttle") + assert isinstance(throttle, dict) + assert "batchSize" in throttle + assert "pauseMs" in throttle + assert isinstance(throttle["batchSize"], int) + assert isinstance(throttle["pauseMs"], int) + assert throttle["batchSize"] > 0 + assert throttle["pauseMs"] >= 0 + +def test_perf_status_has_audible_field(browser): + """perf.status contains an audible boolean.""" + result = browser("perf.status") + assert "audible" in result + assert isinstance(result["audible"], bool) + +def test_perf_status_has_jobs_field(browser): + """perf.status contains a jobs list (may be empty).""" + result = browser("perf.status") + assert "jobs" in result + assert isinstance(result["jobs"], list) + +def test_perf_set_profile_returns_profile(browser): + """perf.set_profile responds with the applied profile.""" + # Save current + current = browser("perf.status").get("performanceProfile", "auto") + target = "normal" if current != "normal" else "gentle" + + try: + result = browser("perf.set_profile", {"profile": target}) + assert isinstance(result, dict) + assert result.get("performanceProfile") == target + finally: + browser("perf.set_profile", {"profile": current}) + +def test_perf_set_profile_updates_status(browser): + """After set_profile the status command reflects the new profile.""" + current = browser("perf.status").get("performanceProfile", "auto") + target = "gentle" if current != "gentle" else "normal" + + try: + browser("perf.set_profile", {"profile": target}) + status = browser("perf.status") + assert status.get("performanceProfile") == target + finally: + browser("perf.set_profile", {"profile": current}) + +def test_perf_profile_roundtrip_all_values(browser): + """Every valid profile can be set and read back without error.""" + original = browser("perf.status").get("performanceProfile", "auto") + try: + for profile in sorted(_VALID_PROFILES): + result = browser("perf.set_profile", {"profile": profile}) + assert result.get("performanceProfile") == profile + status = browser("perf.status") + assert status.get("performanceProfile") == profile + finally: + browser("perf.set_profile", {"profile": original}) diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..d247798 --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,54 @@ +"""Integration tests for storage.get / storage.set — require a live browser.""" +import time + +_KEY_PREFIX = "__pytest_storage_" + +def test_storage_set_and_get_roundtrip(browser, http_tab): + """Set a localStorage key then read it back.""" + browser("tabs.active", {"tabId": http_tab["id"]}) + key = _KEY_PREFIX + "roundtrip" + value = "hello-storage" + + browser("storage.set", {"key": key, "value": value, "type": "local", "tabId": http_tab["id"]}) + result = browser("storage.get", {"key": key, "type": "local", "tabId": http_tab["id"]}) + assert result == value + +def test_storage_get_nonexistent_returns_null(browser, http_tab): + """Getting a key that was never set returns None.""" + browser("tabs.active", {"tabId": http_tab["id"]}) + result = browser("storage.get", { + "key": _KEY_PREFIX + "no_such_key_zzz", + "type": "local", + "tabId": http_tab["id"], + }) + assert result is None + +def test_storage_get_all_returns_dict(browser, http_tab): + """Calling storage.get without a key dumps all entries as a dict.""" + browser("tabs.active", {"tabId": http_tab["id"]}) + # Plant at least one key so the dump isn't trivially empty + browser("storage.set", {"key": _KEY_PREFIX + "dump", "value": "present", "type": "local", "tabId": http_tab["id"]}) + + result = browser("storage.get", {"key": None, "type": "local", "tabId": http_tab["id"]}) + assert isinstance(result, dict) + assert _KEY_PREFIX + "dump" in result + +def test_storage_session_type_roundtrip(browser, http_tab): + """sessionStorage works the same way as localStorage.""" + browser("tabs.active", {"tabId": http_tab["id"]}) + key = _KEY_PREFIX + "session" + value = "ses-val" + + browser("storage.set", {"key": key, "value": value, "type": "session", "tabId": http_tab["id"]}) + result = browser("storage.get", {"key": key, "type": "session", "tabId": http_tab["id"]}) + assert result == value + +def test_storage_overwrite_updates_value(browser, http_tab): + """Setting the same key twice reflects the latest value.""" + browser("tabs.active", {"tabId": http_tab["id"]}) + key = _KEY_PREFIX + "overwrite" + + browser("storage.set", {"key": key, "value": "v1", "type": "local", "tabId": http_tab["id"]}) + browser("storage.set", {"key": key, "value": "v2", "type": "local", "tabId": http_tab["id"]}) + result = browser("storage.get", {"key": key, "type": "local", "tabId": http_tab["id"]}) + assert result == "v2"