from pathlib import Path from types import SimpleNamespace import os import sys from click.testing import CliRunner from unittest.mock import patch from browser_cli.cli import main, _project_version from browser_cli.client import BrowserTarget from browser_cli.commands.extract import _clean_markdown_output, _convert_html_to_markdown def _expected_version() -> str: pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml" for line in pyproject.read_text(encoding="utf-8").splitlines(): if line.startswith("version = "): return line.split('"')[1] raise AssertionError("version not found in pyproject.toml") def test_short_version_option(): result = CliRunner().invoke(main, ["-V"]) assert result.exit_code == 0 assert result.output.strip() == _expected_version() def test_long_version_option(): result = CliRunner().invoke(main, ["--version"]) assert result.exit_code == 0 assert result.output.strip() == _expected_version() def test_project_version_falls_back_to_installed_package_metadata(): with patch("browser_cli.cli.Path.read_text", side_effect=OSError), patch( "browser_cli.cli.package_version", return_value="9.9.9" ): assert _project_version() == "9.9.9" def test_clients_rename_uses_command_level_browser_target(): with patch("browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")), patch( "browser_cli.cli.send_command" ) as send_command: result = CliRunner().invoke(main, ["clients", "rename", "--browser", "old-id", "work"]) assert result.exit_code == 0 send_command.assert_called_once_with("clients.rename_profile", {"alias": "work"}, profile="old-id") def test_clients_rename_uses_global_browser_target_when_set(): with patch("browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")), patch( "browser_cli.cli.send_command" ) as send_command: result = CliRunner().invoke(main, ["--browser", "old-id", "clients", "rename", "work"]) assert result.exit_code == 0 send_command.assert_called_once_with("clients.rename_profile", {"alias": "work"}, profile=None) assert "Restart the browser" not in result.output def test_clients_rename_rejects_duplicate_alias(tmp_path): registry_path = tmp_path / "registry.json" registry_path.write_text('{"work": "/tmp/work.sock", "old-id": "/tmp/old-id.sock"}', encoding="utf-8") with patch("browser_cli.cli.REGISTRY_PATH", registry_path), patch("browser_cli.cli.send_command") as send_command: result = CliRunner().invoke(main, ["clients", "rename", "--browser", "old-id", "work"]) assert result.exit_code != 0 assert "Browser alias 'work' already exists" in result.output send_command.assert_not_called() def test_clients_rename_allows_same_alias_for_same_target(tmp_path): registry_path = tmp_path / "registry.json" registry_path.write_text('{"work": "/tmp/work.sock"}', encoding="utf-8") with patch("browser_cli.cli.REGISTRY_PATH", registry_path), patch("browser_cli.cli.send_command") as send_command: result = CliRunner().invoke(main, ["clients", "rename", "--browser", "work", "work"]) assert result.exit_code == 0 send_command.assert_called_once_with("clients.rename_profile", {"alias": "work"}, profile="work") def test_install_help_lists_supported_browsers(): result = CliRunner().invoke(main, ["install", "--help"]) assert result.exit_code == 0 assert "[chrome|chromium|brave|edge|vivaldi]" in result.output def test_install_windows_registers_native_host(tmp_path): writes = [] class FakeKey: def __init__(self, path): self.path = path def __enter__(self): return self def __exit__(self, _exc_type, _exc, _tb): return False fake_winreg = SimpleNamespace( HKEY_CURRENT_USER="HKCU", KEY_WRITE=0x20006, KEY_WOW64_32KEY=0x0200, KEY_WOW64_64KEY=0x0100, REG_SZ=1, CreateKeyEx=lambda _root, path, _reserved, _access: FakeKey(path), SetValueEx=lambda key, name, _reserved, _reg_type, value: writes.append((key.path, name, value)), ) host_exe = tmp_path / "browser-cli-native-host.exe" with patch("browser_cli.cli.is_windows", return_value=True), patch( "browser_cli.cli._native_host_exe", return_value=host_exe ), patch("browser_cli.cli._write_native_host_exe"), patch( "browser_cli.cli.Path.write_text" ), patch.dict(sys.modules, {"winreg": fake_winreg}): result = CliRunner().invoke(main, ["install", "edge"]) assert result.exit_code == 0 assert any("Software\\Microsoft\\Edge\\NativeMessagingHosts\\com.browsercli.host" in path for path, _, _ in writes) def test_write_native_host_exe_unix(tmp_path): from browser_cli.cli import _write_native_host_exe host = tmp_path / "libexec" / "browser-cli-native-host" with patch("browser_cli.cli.is_windows", return_value=False): _write_native_host_exe(host) assert host.exists() content = host.read_text() assert content.startswith(f"#!{sys.executable}") assert "from browser_cli.native_host import main" in content assert host.stat().st_mode & 0o111 # executable bit set def test_write_native_host_exe_windows(tmp_path): from browser_cli.cli import _write_native_host_exe host = tmp_path / "libexec" / "browser-cli-native-host.cmd" with patch("browser_cli.cli.is_windows", return_value=True): _write_native_host_exe(host) assert host.exists() content = host.read_text(encoding="utf-8") assert "@echo off" in content assert "browser_cli.native_host" in content def test_clients_exits_cleanly_when_registry_is_missing(): with patch("browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")), patch( "browser_cli.cli.active_browser_targets", return_value=[] ): result = CliRunner().invoke(main, ["clients"]) assert result.exit_code == 1 assert "No browser clients found" in result.output def test_clients_reads_registry_with_trailing_garbage(tmp_path): registry_path = tmp_path / "registry.json" registry_path.write_text('{"main": "/tmp/.browser_cli/main.sock"}"}', encoding="utf-8") def fake_send_command(command, args=None, profile=None, remote=None, key=None): assert command == "clients.list" assert profile == "main" return [{"profile": "main", "name": "Chrome", "version": "1", "extensionVersion": "0.8.2"}] with patch("browser_cli.cli.REGISTRY_PATH", registry_path), patch( "browser_cli.cli.send_command", side_effect=fake_send_command ), patch("browser_cli.cli.active_browser_targets", return_value=[]): result = CliRunner().invoke(main, ["clients"]) assert result.exit_code == 0 assert "main" in result.output assert "0.8.2" in result.output def test_clients_remote_uses_remote_endpoint_without_local_registry(): def fake_send_command(command, args=None, profile=None, remote=None, key=None): assert command == "clients.list" assert profile is None assert remote == "127.0.0.1:8765" return [{"name": "Chrome", "version": "1", "extensionVersion": "2.3.4"}] with patch.dict(os.environ, {}, clear=True), patch( "browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json") ), patch("browser_cli.cli.send_command", side_effect=fake_send_command) as send_command: result = CliRunner().invoke(main, ["--remote", "127.0.0.1:8765", "clients"]) assert result.exit_code == 0 send_command.assert_called_once() assert "remote" in result.output assert "Chrome" in result.output assert "2.3.4" in result.output def test_clients_remote_respects_global_browser_route(): with patch.dict(os.environ, {}, clear=True), patch("browser_cli.cli.send_command", return_value=[]) as send_command: result = CliRunner().invoke(main, ["--remote", "127.0.0.1:8765", "--browser", "work", "clients"]) assert result.exit_code == 1 send_command.assert_called_once_with("clients.list", profile="work", remote="127.0.0.1:8765", key=None) def test_clients_browser_alias_resolves_to_remote(): """--browser without --remote resolves the alias, fetches all targets from that remote, and shows only clients from that host (not local profiles).""" from browser_cli.client import BrowserTarget resolved_target = BrowserTarget( profile="automatisation", display_name="192.168.188.104:automatisation", socket_path="", remote="192.168.188.104:8765", ) all_remote_targets = [resolved_target] def fake_send_command(command, args=None, profile=None, remote=None, key=None): assert command == "clients.list" assert profile == "automatisation" assert remote == "192.168.188.104:8765" return [{"name": "Chrome", "version": "147.0.0.0", "extensionVersion": "0.8.5"}] with patch.dict(os.environ, {}, clear=True), patch( "browser_cli.cli.remote_target_for_alias", return_value=resolved_target ), patch( "browser_cli.cli.remote_browser_targets", return_value=all_remote_targets ), patch("browser_cli.cli.send_command", side_effect=fake_send_command) as send_command: result = CliRunner().invoke(main, ["--browser", "192.168.188.104", "clients"]) assert result.exit_code == 0 send_command.assert_called_once() assert "Chrome" in result.output assert "0.8.5" in result.output def test_clients_shows_named_profile_and_uses_socket_uuid_for_default(tmp_path): registry_path = tmp_path / "registry.json" default_socket = tmp_path / "550e8400-e29b-41d4-a716-446655440000.sock" work_socket = tmp_path / "work.sock" registry_path.write_text( '{"default": "%s", "work": "%s"}' % (default_socket, work_socket), encoding="utf-8", ) responses = { "default": [{"profile": "default", "name": "Chrome", "version": "1", "extensionVersion": "2.3.4"}], "work": [{"profile": "default", "name": "Chrome", "version": "1", "extensionVersion": "2.3.4"}], } def fake_send_command(command, args=None, profile=None, remote=None, key=None): assert command == "clients.list" return responses[profile] with patch("browser_cli.cli.REGISTRY_PATH", registry_path), patch( "browser_cli.cli.send_command", side_effect=fake_send_command ), patch("browser_cli.cli.active_browser_targets", return_value=[]): result = CliRunner().invoke(main, ["clients"]) assert result.exit_code == 0 assert "550e8400-e29b-41d4-a716-446655440000" in result.output assert "work" in result.output assert "Extension Version" in result.output assert "2.3.4" in result.output def test_tabs_list_multi_browser_shows_browser_column(): def fake_send_command(command, args=None, profile=None): assert command == "tabs.list" return [{"id": 1 if profile == "default" else 2, "windowId": 1, "active": True, "title": profile, "url": "https://example.com"}] with patch( "browser_cli.active_browser_targets", return_value=[ BrowserTarget("default", "550e8400-e29b-41d4-a716-446655440000", "/tmp/default.sock"), BrowserTarget("work", "work", "/tmp/work.sock"), ], ), patch("browser_cli.send_command", side_effect=fake_send_command): result = CliRunner().invoke(main, ["tabs", "list"]) assert result.exit_code == 0 assert "Browser" in result.output assert "550e8400-e29b-41d4-a716-446655440000" in result.output assert "work" in result.output def test_tabs_list_with_remote_uses_only_remote_targets(): with patch( "browser_cli.active_browser_targets", side_effect=AssertionError("local targets should not be used for explicit remote"), ), patch( "browser_cli.remote_browser_targets", return_value=[BrowserTarget("work", "remote-host:work", "", remote="remote-host:8765")], ), patch( "browser_cli.send_command", return_value=[{"id": 1, "windowId": 1, "active": True, "title": "Remote", "url": "https://example.com"}], ) as send_command: result = CliRunner().invoke(main, ["--remote", "remote-host:8765", "tabs", "list"]) assert result.exit_code == 0 assert "remote-host:work" in result.output assert "Remote" in result.output send_command.assert_called_once_with("tabs.list", {}, profile="work", remote="remote-host:8765", key=None) def test_tabs_list_with_explicit_browser_does_not_show_browser_column(): with patch( "browser_cli.active_browser_targets", return_value=[ BrowserTarget("default", "uuid-1", "/tmp/default.sock"), BrowserTarget("work", "work", "/tmp/work.sock"), ], ), patch( "browser_cli.send_command", return_value=[{"id": 1, "windowId": 1, "active": True, "title": "Example", "url": "https://example.com"}], ) as send_command: result = CliRunner().invoke(main, ["--browser", "work", "tabs", "list"]) assert result.exit_code == 0 assert "Browser" not in result.output send_command.assert_called_once_with("tabs.list", {}, profile="work", remote=None, key=None) def test_tabs_count_multi_browser_shows_total(): counts = {"default": 3, "work": 4} def fake_send_command(command, args=None, profile=None): assert command == "tabs.count" assert args == {"pattern": "github"} return counts[profile] with patch( "browser_cli.active_browser_targets", return_value=[ BrowserTarget("default", "uuid-1", "/tmp/default.sock"), BrowserTarget("work", "work", "/tmp/work.sock"), ], ), patch("browser_cli.send_command", side_effect=fake_send_command): result = CliRunner().invoke(main, ["tabs", "count", "github"]) assert result.exit_code == 0 assert "Browser" in result.output assert "Total" in result.output assert "7" in result.output def test_group_count_multi_browser_shows_total(): counts = {"default": 1, "work": 2} def fake_send_command(command, args=None, profile=None): assert command == "group.count" return counts[profile] with patch( "browser_cli.active_browser_targets", return_value=[ BrowserTarget("default", "uuid-1", "/tmp/default.sock"), BrowserTarget("work", "work", "/tmp/work.sock"), ], ), patch("browser_cli.send_command", side_effect=fake_send_command): result = CliRunner().invoke(main, ["groups", "count"]) assert result.exit_code == 0 assert "Browser" in result.output assert "Total" in result.output assert "3" in result.output def test_group_list_leaves_unnamed_group_cell_empty(): with patch( "browser_cli.send_command", return_value=[{"id": 42, "title": "", "color": "grey", "collapsed": False, "tabCount": 1}], ): result = CliRunner().invoke(main, ["groups", "list"]) assert result.exit_code == 0 assert "(unnamed)" not in result.output assert "42" in result.output assert "grey" in result.output def test_tabs_move_accepts_right_short_alias(): with patch("browser_cli.send_command") as send_command: result = CliRunner().invoke(main, ["tabs", "move", "12", "-r"]) assert result.exit_code == 0 send_command.assert_called_once_with( "tabs.move", {"tabId": 12, "forward": True, "backward": False, "groupId": None, "windowId": None, "index": None}, profile=None, remote=None, key=None, ) def test_groups_move_accepts_left_short_alias(): with patch("browser_cli.send_command") as send_command: result = CliRunner().invoke(main, ["groups", "move", "research", "-l"]) assert result.exit_code == 0 send_command.assert_called_once_with( "group.move", {"group": "research", "forward": False, "backward": True}, profile=None, remote=None, key=None ) def test_windows_list_multi_browser_shows_browser_column(): def fake_send_command(command, args=None, profile=None): assert command == "windows.list" return [{"id": 1, "alias": profile, "focused": True, "tabCount": 2, "state": "normal"}] with patch( "browser_cli.active_browser_targets", return_value=[ BrowserTarget("default", "uuid-1", "/tmp/default.sock"), BrowserTarget("work", "work", "/tmp/work.sock"), ], ), patch("browser_cli.send_command", side_effect=fake_send_command): result = CliRunner().invoke(main, ["windows", "list"]) assert result.exit_code == 0 assert "Browser" in result.output assert "Focused" not in result.output assert "uuid-1" in result.output assert "work" in result.output def test_session_list_multi_browser_shows_browser_column(): def fake_send_command(command, args=None, profile=None): assert command == "session.list" return [{"name": f"{profile}-session", "tabs": 2, "savedAt": 1712707200000}] with patch( "browser_cli.active_browser_targets", return_value=[ BrowserTarget("default", "uuid-1", "/tmp/default.sock"), BrowserTarget("work", "work", "/tmp/work.sock"), ], ), patch("browser_cli.send_command", side_effect=fake_send_command): result = CliRunner().invoke(main, ["session", "list"]) assert result.exit_code == 0 assert "Browser" in result.output assert "uuid-1" in result.output assert "work" in result.output assert "default-session" in result.output assert "work-session" in result.output def test_session_list_with_explicit_browser_does_not_show_browser_column(): with patch( "browser_cli.active_browser_targets", return_value=[ BrowserTarget("default", "uuid-1", "/tmp/default.sock"), BrowserTarget("work", "work", "/tmp/work.sock"), ], ), patch( "browser_cli.send_command", return_value=[{"name": "work-session", "tabs": 2, "savedAt": 1712707200000}], ) as send_command: result = CliRunner().invoke(main, ["--browser", "work", "session", "list"]) assert result.exit_code == 0 assert "Browser" not in result.output send_command.assert_called_once_with("session.list", {}, profile="work", remote=None, key=None) def test_windows_open_passes_url(): with patch("browser_cli.send_command", return_value={"id": 7}) as send_command: result = CliRunner().invoke(main, ["windows", "open", "https://example.com"]) assert result.exit_code == 0 assert "https://example.com" in result.output send_command.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None, remote=None, key=None) def test_extract_markdown_command(): with patch("browser_cli.send_command", return_value="# Title") as send_command: result = CliRunner().invoke(main, ["extract", "markdown"]) assert result.exit_code == 0 assert result.output == "# Title\n" send_command.assert_called_once_with("extract.markdown", {"selector": None}, profile=None, remote=None, key=None) def test_extract_markdown_command_with_selector(): with patch("browser_cli.send_command", return_value="## Post") as send_command: result = CliRunner().invoke(main, ["extract", "markdown", "--selector", "article"]) assert result.exit_code == 0 assert result.output == "## Post\n" send_command.assert_called_once_with("extract.markdown", {"selector": "article"}, profile=None, remote=None, key=None) def test_clean_markdown_output_removes_escaped_underscores_and_dashes(): assert _clean_markdown_output(r"hello\_world \- item") == "hello_world - item" def test_clean_markdown_output_trims_useless_whitespace(): raw = " # Title \n\n\n paragraph with space \n next line\t \n" assert _clean_markdown_output(raw) == "# Title\n\nparagraph with space\nnext line" def test_clean_markdown_output_repairs_empty_table_header_rows(): raw = ( "| | | |\n" "| --- | --- | --- |\n" "| Bereich | Plan | Ist |\n" "| A | B | C |\n" ) assert _clean_markdown_output(raw) == ( "| Bereich | Plan | Ist |\n" "| --- | --- | --- |\n" "| A | B | C |" ) def test_clean_markdown_output_preserves_graph_code_blocks(): raw = "```\n\nA\n │\n ▼\nB\n\n```" assert _clean_markdown_output(raw) == "```\nA\n │\n ▼\nB\n```" def test_clean_markdown_output_renders_code_block_list_branches(): raw = "```\nPlattformen\n- Omnifact\n- Open WebUI + Ollama\n- Le Chat\n```" assert _clean_markdown_output(raw) == ( "```\n" "Plattformen\n" "├ Omnifact\n" "├ Open WebUI + Ollama\n" "└ Le Chat\n" "```" ) def test_clean_markdown_output_unflattens_graph_code_blocks(): raw = ( "```\n" "Golden Set │ ▼Promptfoo(Testausführung) │ ▼UpTrain(Qualitätsbewertung) │ " "▼Langfuse(Logging / Observability) │ ▼Plattformen├ Omnifact├ Open WebUI + Ollama└ Le Chat\n" "```" ) assert _clean_markdown_output(raw) == ( "```\n" "Golden Set\n" " │\n" " ▼\n" "Promptfoo\n" "(Testausführung)\n" " │\n" " ▼\n" "UpTrain\n" "(Qualitätsbewertung)\n" " │\n" " ▼\n" "Langfuse\n" "(Logging / Observability)\n" " │\n" " ▼\n" "Plattformen\n" "├ Omnifact\n" "├ Open WebUI + Ollama\n" "└ Le Chat\n" "```" ) def test_extract_markdown_command_repairs_malformed_tables_and_code_blocks(): raw = ( "| | | |\n" "| --- | --- | --- |\n" "| Bereich | Plan | Ist |\n" "| Eval-Stack | Testumgebung | funktionsfähig |\n\n" "```\n" "Golden Set │ ▼Promptfoo(Testausführung) │ ▼Plattformen├ Omnifact└ Le Chat\n" "```" ) with patch("browser_cli.send_command", return_value=raw): result = CliRunner().invoke(main, ["extract", "markdown"]) assert result.exit_code == 0 assert "| Bereich | Plan | Ist |" in result.output assert "| | | |" not in result.output assert "Golden Set\n │\n ▼\nPromptfoo\n(Testausführung)" in result.output assert "├ Omnifact" in result.output assert "└ Le Chat" in result.output def test_convert_html_to_markdown_normalizes_blank_table_header_rows(): html = """
RisikoBeschreibungAuswirkungGegenmaßnahme
DatenschutzXYZ
""" markdown = _convert_html_to_markdown(html) assert "| Risiko | Beschreibung | Auswirkung | Gegenmaßnahme |" in markdown assert "| | | | |" not in markdown def test_convert_html_to_markdown_preserves_codemirror_graph_blocks(): html = """

Teil 5 - Eval-Stack Architektur

Golden Set
Promptfoo
(Testausführung)
Plattformen
- Omnifact
- Open WebUI + Ollama
- Le Chat
""" markdown = _convert_html_to_markdown(html) assert "```\nGolden Set\n │\n ▼\nPromptfoo" in markdown assert "├ Omnifact" in markdown assert "└ Le Chat" in markdown def test_convert_html_to_markdown_indents_multiline_list_items(): html = """

2. Zielarchitektur

""" markdown = _convert_html_to_markdown(html) assert ( "- Unternehmensdaten → RAG → KI-Orchestrierung →\n" " Local LLMs / API Modelle / Spezialmodelle" ) in markdown def test_tabs_list_multi_browser_queries_remote_target(): endpoint = "browser-host.example:8765" remote_target = BrowserTarget( "work", "browser-host.example:work", "", remote=endpoint, ) with patch("browser_cli.active_browser_targets", return_value=[remote_target, BrowserTarget("local", "local", "/tmp/local.sock")]), patch( "browser_cli.send_command", return_value=[{"id": 1, "windowId": 1, "active": True, "title": "Remote", "url": "https://example.com"}], ) as send_command: result = CliRunner().invoke(main, ["tabs", "list"]) assert result.exit_code == 0 send_command.assert_any_call("tabs.list", {}, profile="work", remote=endpoint, key=None) assert "browser-host.example:work" in result.output