diff --git a/README.md b/README.md index 48acbe5..1e14649 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ browser-cli/ All commands are run with `uv run browser-cli [--browser ALIAS] `. -If exactly one browser instance is connected, commands auto-target it. Use `--browser ALIAS` when multiple browser instances are connected. You can inspect the active instances with `browser-cli clients` and assign a persistent profile alias from inside the target browser with `browser-cli rename-profile --browser `. Closed browsers are removed from the client registry automatically. +If exactly one browser instance is connected, commands auto-target it. Use `--browser ALIAS` when multiple browser instances are connected. `tabs list`, `tabs count`, `group list`, `group count`, and `windows list` are the only commands that aggregate across all active browsers when `--browser` is omitted; in that mode they show the source browser alias or UUID. You can inspect the active instances with `browser-cli clients` and assign a persistent profile alias from inside the target browser with `browser-cli rename-profile --browser `. Closed browsers are removed from the client registry automatically. Important: profile aliases are browser-instance aliases, not window aliases. Window aliases created with `windows rename` are only for targeting windows in commands like `nav open --window work`. If a browser instance has no explicit profile alias set, the native host gives it a generated UUID alias so multiple unaliased browsers stay distinct. @@ -302,27 +302,28 @@ b.forward(tab_id=1234) b.focus_url("github") # Tabs -tabs = b.tabs_list() # list of dicts: id, windowId, title, url, active, ... +tabs = b.tabs_list() # list[Tab]; in multi-browser mode each tab.browser is set b.tabs_active(1234) b.tabs_close(1234) b.tabs_close_inactive() b.tabs_close_duplicates() b.tabs_filter("youtube") # list of matching tabs b.tabs_query("pull request") -b.tabs_count("github") # int +counts = b.tabs_count("github") # int, or BrowserCounts(total=..., by_browser=...) in multi-browser mode html = b.tabs_html() # full HTML string of active tab b.tabs_sort(by="domain") b.tabs_merge_windows() b.tabs_dedupe() # Tab groups -groups = b.group_list() # list of dicts: id, title, color, collapsed, tabCount +groups = b.group_list() # list[Group]; in multi-browser mode each group.browser is set b.group_open("research") # creates group, returns { id, name } b.group_close(42) b.group_tabs(42) # tabs inside a group +b.group_count() # int, or BrowserCounts(...) in multi-browser mode # Windows -windows = b.windows_list() +windows = b.windows_list() # in multi-browser mode each dict has a "browser" key b.windows_rename(1, "work") b.windows_open() b.windows_close(1) @@ -368,6 +369,21 @@ except RuntimeError as e: print(f"Browser returned an error: {e}") ``` +```python +from browser_cli import BrowserCLI, BrowserCounts + +b = BrowserCLI() + +tabs = b.tabs_list() +for tab in tabs: + print(tab.browser, tab.title) + +counts = b.tabs_count() +if isinstance(counts, BrowserCounts): + print(counts.total) + print(counts.by_browser) +``` + --- ## Example scripts diff --git a/browser_cli/__init__.py b/browser_cli/__init__.py index d085fee..85351e7 100644 --- a/browser_cli/__init__.py +++ b/browser_cli/__init__.py @@ -17,11 +17,19 @@ Usage: b = BrowserCLI(browser="brave") """ from collections.abc import Callable, Iterable +from dataclasses import dataclass -from browser_cli.client import BrowserNotConnected, send_command +from browser_cli.client import BrowserNotConnected, active_browser_targets, send_command from browser_cli.models import Group, Tab -__all__ = ["BrowserCLI", "BrowserNotConnected", "Tab", "Group"] +__all__ = ["BrowserCLI", "BrowserCounts", "BrowserNotConnected", "Tab", "Group"] + + +@dataclass(frozen=True) +class BrowserCounts: + """Aggregated per-browser counts returned in implicit multi-browser mode.""" + total: int + by_browser: dict[str, int] class BrowserCLI: @@ -36,9 +44,35 @@ class BrowserCLI: def _cmd(self, command: str, args: dict | None = None): return send_command(command, args, profile=self._browser) + def _multi_browser_targets(self): + if self._browser is not None: + return [] + targets = active_browser_targets() + if len(targets) <= 1: + return [] + return targets + + def _collect_multi_browser(self, command: str, args: dict | None = None): + results = [] + for target in self._multi_browser_targets(): + try: + data = send_command(command, args, profile=target.profile) + except (BrowserNotConnected, RuntimeError): + continue + results.append((target, data)) + if results: + return results + if self._multi_browser_targets(): + raise BrowserNotConnected( + "Cannot resolve a browser socket automatically.\n" + "Make sure the browser is running with the browser-cli extension enabled,\n" + "or pass --browser / set BROWSER_CLI_PROFILE to a known alias." + ) + return [] + # ── Internal factories ──────────────────────────────────────────────── - def _make_tab(self, data: dict) -> Tab: + def _make_tab(self, data: dict, *, browser_profile: str | None = None, browser_name: str | None = None) -> Tab: tab = Tab( id=data["id"], window_id=data.get("windowId", 0), @@ -46,19 +80,21 @@ class BrowserCLI: title=data.get("title") or "", url=data.get("url") or "", group_id=data.get("groupId") or None, + browser=browser_name, ) - tab._browser = self + tab._browser = self if browser_profile is None else BrowserCLI(browser=browser_profile) return tab - def _make_group(self, data: dict) -> Group: + def _make_group(self, data: dict, *, browser_profile: str | None = None, browser_name: str | None = None) -> Group: group = Group( id=data["id"], title=data.get("title") or "", color=data.get("color") or "", collapsed=data.get("collapsed", False), tab_count=data.get("tabCount", 0), + browser=browser_name, ) - group._browser = self + group._browser = self if browser_profile is None else BrowserCLI(browser=browser_profile) return group # ── Navigation ──────────────────────────────────────────────────────── @@ -99,7 +135,18 @@ class BrowserCLI: # ── Tabs ────────────────────────────────────────────────────────────── def tabs_list(self) -> list[Tab]: - """Return all open tabs across all windows.""" + """Return all open tabs across all windows. + + When multiple browsers are active and no browser was specified, each Tab + includes ``tab.browser`` naming its source browser. + """ + multi_results = self._collect_multi_browser("tabs.list", {}) + if multi_results: + return [ + self._make_tab(tab, browser_profile=target.profile, browser_name=target.display_name) + for target, tabs in multi_results + for tab in (tabs or []) + ] return [self._make_tab(t) for t in (self._cmd("tabs.list", {}) or [])] def tabs_close(self, tab_id: int | None = None, *, inactive: bool = False, duplicates: bool = False) -> int: @@ -127,8 +174,15 @@ class BrowserCLI: return [self._make_tab(t) for t in (self._cmd("tabs.filter", {"pattern": pattern_or_filter}) or [])] return self._apply_tab_filter(pattern_or_filter) - def tabs_count(self, pattern: str | None = None) -> int: - """Count open tabs, optionally filtered by URL pattern.""" + def tabs_count(self, pattern: str | None = None) -> int | BrowserCounts: + """Count open tabs, optionally filtered by URL pattern. + + Returns ``BrowserCounts`` in implicit multi-browser mode. + """ + multi_results = self._collect_multi_browser("tabs.count", {"pattern": pattern}) + if multi_results: + by_browser = {target.display_name: int(count or 0) for target, count in multi_results} + return BrowserCounts(total=sum(by_browser.values()), by_browser=by_browser) return self._cmd("tabs.count", {"pattern": pattern}) def tabs_query(self, search: str) -> list[Tab]: @@ -166,15 +220,33 @@ class BrowserCLI: # ── Tab Groups ──────────────────────────────────────────────────────── def group_list(self) -> list[Group]: - """Return all tab groups.""" + """Return all tab groups. + + When multiple browsers are active and no browser was specified, each Group + includes ``group.browser`` naming its source browser. + """ + multi_results = self._collect_multi_browser("group.list", {}) + if multi_results: + return [ + self._make_group(group, browser_profile=target.profile, browser_name=target.display_name) + for target, groups in multi_results + for group in (groups or []) + ] return [self._make_group(g) for g in (self._cmd("group.list", {}) or [])] def group_tabs(self, group_id: int) -> list[Tab]: """Return all tabs inside a group.""" return [self._make_tab(t) for t in (self._cmd("group.tabs", {"groupId": group_id}) or [])] - def group_count(self) -> int: - """Return the number of tab groups.""" + def group_count(self) -> int | BrowserCounts: + """Return the number of tab groups. + + Returns ``BrowserCounts`` in implicit multi-browser mode. + """ + multi_results = self._collect_multi_browser("group.count", {}) + if multi_results: + by_browser = {target.display_name: int(count or 0) for target, count in multi_results} + return BrowserCounts(total=sum(by_browser.values()), by_browser=by_browser) return self._cmd("group.count", {}) def group_query(self, search: str) -> list[Group]: @@ -202,6 +274,17 @@ class BrowserCLI: # ── Windows ─────────────────────────────────────────────────────────── def windows_list(self) -> list[dict]: + """Return browser windows. + + In implicit multi-browser mode each window dict includes a ``browser`` key. + """ + multi_results = self._collect_multi_browser("windows.list", {}) + if multi_results: + return [ + {**window, "browser": target.display_name} + for target, windows in multi_results + for window in (windows or []) + ] return self._cmd("windows.list", {}) def windows_rename(self, window_id: int, name: str) -> None: diff --git a/browser_cli/cli.py b/browser_cli/cli.py index cc40545..94ed9cb 100755 --- a/browser_cli/cli.py +++ b/browser_cli/cli.py @@ -21,7 +21,7 @@ from browser_cli.commands.dom import dom_group from browser_cli.commands.extract import extract_group from browser_cli.commands.session import session_group from browser_cli.commands.search import search_group -from browser_cli.client import send_command, BrowserNotConnected +from browser_cli.client import send_command, BrowserNotConnected, REGISTRY_PATH, display_browser_name console = Console() @@ -85,13 +85,6 @@ def _print_version(ctx, param, value): click.echo(_project_version()) ctx.exit() - -def _client_display_profile(profile_name: str, sock_path: str) -> str: - if profile_name != "default": - return profile_name - return Path(sock_path).stem or profile_name - - @click.group() @click.option( "-V", "--version", @@ -109,6 +102,8 @@ def _client_display_profile(profile_name: str, sock_path: str) -> str: def main(ctx, browser): """Control your running browser from the terminal via a Chrome extension.""" ctx.ensure_object(dict) + ctx.obj["browser"] = browser + ctx.obj["browser_explicit"] = browser is not None if browser: os.environ["BROWSER_CLI_PROFILE"] = browser @@ -129,20 +124,16 @@ main.add_command(search_group) @main.command("clients") def cmd_clients(): """Show connected browser clients.""" - import json as _json - from browser_cli.client import REGISTRY_PATH - - # Build a map of profile → socket path from the registry profiles: dict[str, str] = {} if REGISTRY_PATH.exists(): try: - profiles = _json.loads(REGISTRY_PATH.read_text()) + profiles = json.loads(REGISTRY_PATH.read_text()) except Exception: pass all_clients = [] for profile_name, sock_path in profiles.items(): - display_profile = _client_display_profile(profile_name, sock_path) + display_profile = display_browser_name(profile_name, sock_path) try: result = send_command("clients.list", profile=profile_name) for c in (result or []): diff --git a/browser_cli/client.py b/browser_cli/client.py index 36f9e38..0267ceb 100644 --- a/browser_cli/client.py +++ b/browser_cli/client.py @@ -13,6 +13,7 @@ import os import socket import struct import uuid +from dataclasses import dataclass from pathlib import Path from typing import Any @@ -24,11 +25,37 @@ class BrowserNotConnected(Exception): """Raised when the native host socket is not available.""" +@dataclass(frozen=True) +class BrowserTarget: + profile: str + display_name: str + socket_path: str + + def _active_sockets(reg: dict) -> dict: """Return only entries whose socket file exists on disk.""" return {k: v for k, v in reg.items() if Path(v).exists()} +def display_browser_name(profile_name: str, sock_path: str) -> str: + if profile_name != "default": + return profile_name + return Path(sock_path).stem or profile_name + + +def active_browser_targets() -> list[BrowserTarget]: + if not REGISTRY_PATH.exists(): + return [] + try: + reg = json.loads(REGISTRY_PATH.read_text()) + except Exception: + return [] + return [ + BrowserTarget(profile=profile, display_name=display_browser_name(profile, sock_path), socket_path=sock_path) + for profile, sock_path in _active_sockets(reg).items() + ] + + def _resolve_socket(profile: str | None = None) -> str: """Return the socket path for the given profile (or auto-detect).""" target = profile or os.environ.get("BROWSER_CLI_PROFILE") @@ -45,23 +72,21 @@ def _resolve_socket(profile: str | None = None) -> str: return str(SOCKET_DIR / f"{safe}.sock") # Auto-detect: error when multiple browser instances are active - if REGISTRY_PATH.exists(): - try: - reg = json.loads(REGISTRY_PATH.read_text()) - active = _active_sockets(reg) - if len(active) > 1: - aliases = list(active.keys()) - examples = "\n".join(f" browser-cli --browser {a} ..." for a in aliases) - raise BrowserNotConnected( - f"Multiple browser instances are active: {', '.join(aliases)}\n" - f"Use --browser to select one:\n{examples}" - ) - if active: - return next(iter(active.values())) - except BrowserNotConnected: - raise - except Exception: - pass + try: + active = active_browser_targets() + if len(active) > 1: + aliases = [target.profile for target in active] + examples = "\n".join(f" browser-cli --browser {a} ..." for a in aliases) + raise BrowserNotConnected( + f"Multiple browser instances are active: {', '.join(aliases)}\n" + f"Use --browser to select one:\n{examples}" + ) + if active: + return active[0].socket_path + except BrowserNotConnected: + raise + except Exception: + pass raise BrowserNotConnected( "Cannot resolve a browser socket automatically.\n" diff --git a/browser_cli/commands/groups.py b/browser_cli/commands/groups.py index 9166d40..be7fbcd 100644 --- a/browser_cli/commands/groups.py +++ b/browser_cli/commands/groups.py @@ -1,14 +1,14 @@ import click -from browser_cli.client import send_command, BrowserNotConnected +from browser_cli.client import BrowserNotConnected, active_browser_targets, send_command from rich.console import Console from rich.table import Table console = Console() -def _handle(command, args=None): +def _handle(command, args=None, profile=None): try: - return send_command(command, args or {}) + return send_command(command, args or {}, profile=profile) except BrowserNotConnected as e: console.print(f"[red]Error:[/red] {e}") raise SystemExit(1) @@ -17,24 +17,45 @@ def _handle(command, args=None): raise SystemExit(1) -def _print_groups(groups: list[dict]) -> None: +def _handle_multi(command, args=None, profile=None): + try: + return send_command(command, args or {}, profile=profile) + except (BrowserNotConnected, RuntimeError): + return None + + +def _multi_browser_targets(): + root = click.get_current_context().find_root() + if root.obj.get("browser_explicit"): + return [] + targets = active_browser_targets() + if len(targets) <= 1: + return [] + return targets + + +def _print_groups(groups: list[dict], *, show_browser: bool = False) -> None: if not groups: console.print("[yellow]No groups found[/yellow]") return table = Table(show_header=True, header_style="bold cyan") + if show_browser: + table.add_column("Browser") table.add_column("ID", style="dim", no_wrap=True) table.add_column("Name") table.add_column("Color", width=10) table.add_column("Collapsed", width=10) table.add_column("Tabs", width=6) for g in groups: - table.add_row( + row = [ + g.get("browser", "") if show_browser else None, str(g.get("id", "")), - g.get("title") or "(unnamed)", + g.get("title") or "", g.get("color") or "", "yes" if g.get("collapsed") else "no", str(g.get("tabCount", "")), - ) + ] + table.add_row(*[value for value in row if value is not None]) console.print(table) @@ -46,6 +67,19 @@ def group_group(): @group_group.command("list") def group_list(): """List all tab groups.""" + targets = _multi_browser_targets() + if targets: + groups = [] + for target in targets: + result = _handle_multi("group.list", profile=target.profile) + if result is None: + continue + groups.extend({**group, "browser": target.display_name} for group in result) + if not groups: + console.print("[red]Error:[/red] Cannot resolve a browser socket automatically.") + raise SystemExit(1) + _print_groups(groups, show_browser=True) + return groups = _handle("group.list") _print_groups(groups or []) @@ -62,6 +96,27 @@ def group_tabs(group_id): @group_group.command("count") def group_count(): """Count all tab groups.""" + targets = _multi_browser_targets() + if targets: + table = Table(show_header=True, header_style="bold cyan") + table.add_column("Browser") + table.add_column("Groups", justify="right") + total = 0 + rows = 0 + for target in targets: + count = _handle_multi("group.count", profile=target.profile) + if count is None: + continue + count = int(count or 0) + total += count + rows += 1 + table.add_row(target.display_name, str(count)) + if rows == 0: + console.print("[red]Error:[/red] Cannot resolve a browser socket automatically.") + raise SystemExit(1) + table.add_row("Total", str(total)) + console.print(table) + return count = _handle("group.count") console.print(f"[bold]{count}[/bold] group(s)") diff --git a/browser_cli/commands/tabs.py b/browser_cli/commands/tabs.py index ac75f10..e4332b8 100644 --- a/browser_cli/commands/tabs.py +++ b/browser_cli/commands/tabs.py @@ -1,14 +1,14 @@ import click -from browser_cli.client import send_command, BrowserNotConnected +from browser_cli.client import BrowserNotConnected, active_browser_targets, send_command from rich.console import Console from rich.table import Table console = Console() -def _handle(command, args=None): +def _handle(command, args=None, profile=None): try: - return send_command(command, args or {}) + return send_command(command, args or {}, profile=profile) except BrowserNotConnected as e: console.print(f"[red]Error:[/red] {e}") raise SystemExit(1) @@ -17,11 +17,30 @@ def _handle(command, args=None): raise SystemExit(1) -def _print_tabs(tabs: list[dict]) -> None: +def _handle_multi(command, args=None, profile=None): + try: + return send_command(command, args or {}, profile=profile) + except (BrowserNotConnected, RuntimeError): + return None + + +def _multi_browser_targets(): + root = click.get_current_context().find_root() + if root.obj.get("browser_explicit"): + return [] + targets = active_browser_targets() + if len(targets) <= 1: + return [] + return targets + + +def _print_tabs(tabs: list[dict], *, show_browser: bool = False) -> None: if not tabs: console.print("[yellow]No tabs found[/yellow]") return table = Table(show_header=True, header_style="bold cyan") + if show_browser: + table.add_column("Browser", no_wrap=True) table.add_column("ID", style="dim", no_wrap=True) table.add_column("Window", no_wrap=True) table.add_column("Active", width=7) @@ -29,13 +48,15 @@ def _print_tabs(tabs: list[dict]) -> None: table.add_column("URL") for t in tabs: active = "[green]✓[/green]" if t.get("active") else "" - table.add_row( + row = [ + t.get("browser", "") if show_browser else None, str(t.get("id", "")), str(t.get("windowId", "")), active, (t.get("title") or "")[:60], (t.get("url") or "")[:80], - ) + ] + table.add_row(*[value for value in row if value is not None]) console.print(table) @@ -47,6 +68,19 @@ def tabs_group(): @tabs_group.command("list") def tabs_list(): """List all open tabs across all windows.""" + targets = _multi_browser_targets() + if targets: + tabs = [] + for target in targets: + result = _handle_multi("tabs.list", profile=target.profile) + if result is None: + continue + tabs.extend({**tab, "browser": target.display_name} for tab in result) + if not tabs: + console.print("[red]Error:[/red] Cannot resolve a browser socket automatically.") + raise SystemExit(1) + _print_tabs(tabs, show_browser=True) + return tabs = _handle("tabs.list") _print_tabs(tabs or []) @@ -98,6 +132,27 @@ def tabs_filter(pattern): @click.argument("pattern", required=False) def tabs_count(pattern): """Count open tabs, optionally filtered by URL PATTERN.""" + targets = _multi_browser_targets() + if targets: + table = Table(show_header=True, header_style="bold cyan") + table.add_column("Browser") + table.add_column("Tabs", justify="right") + total = 0 + rows = 0 + for target in targets: + count = _handle_multi("tabs.count", {"pattern": pattern}, profile=target.profile) + if count is None: + continue + count = int(count or 0) + total += count + rows += 1 + table.add_row(target.display_name, str(count)) + if rows == 0: + console.print("[red]Error:[/red] Cannot resolve a browser socket automatically.") + raise SystemExit(1) + table.add_row("Total", str(total)) + console.print(table) + return count = _handle("tabs.count", {"pattern": pattern}) label = f" matching '{pattern}'" if pattern else "" console.print(f"[bold]{count}[/bold] tab(s){label}") diff --git a/browser_cli/commands/windows.py b/browser_cli/commands/windows.py index 547f9a1..76d41a9 100644 --- a/browser_cli/commands/windows.py +++ b/browser_cli/commands/windows.py @@ -1,14 +1,14 @@ import click -from browser_cli.client import send_command, BrowserNotConnected +from browser_cli.client import BrowserNotConnected, active_browser_targets, send_command from rich.console import Console from rich.table import Table console = Console() -def _handle(command, args=None): +def _handle(command, args=None, profile=None): try: - return send_command(command, args or {}) + return send_command(command, args or {}, profile=profile) except BrowserNotConnected as e: console.print(f"[red]Error:[/red] {e}") raise SystemExit(1) @@ -17,25 +17,43 @@ def _handle(command, args=None): raise SystemExit(1) -def _print_windows(windows: list[dict]) -> None: +def _handle_multi(command, args=None, profile=None): + try: + return send_command(command, args or {}, profile=profile) + except (BrowserNotConnected, RuntimeError): + return None + + +def _multi_browser_targets(): + root = click.get_current_context().find_root() + if root.obj.get("browser_explicit"): + return [] + targets = active_browser_targets() + if len(targets) <= 1: + return [] + return targets + + +def _print_windows(windows: list[dict], *, show_browser: bool = False) -> None: if not windows: console.print("[yellow]No windows found[/yellow]") return table = Table(show_header=True, header_style="bold cyan") + if show_browser: + table.add_column("Browser") table.add_column("ID", style="dim", no_wrap=True) table.add_column("Alias", width=20) - table.add_column("Focused", width=8) table.add_column("Tabs", width=6) table.add_column("State", width=12) for w in windows: - focused = "[green]✓[/green]" if w.get("focused") else "" - table.add_row( + row = [ + w.get("browser", "") if show_browser else None, str(w.get("id", "")), w.get("alias") or "", - focused, str(w.get("tabCount", "")), w.get("state") or "", - ) + ] + table.add_row(*[value for value in row if value is not None]) console.print(table) @@ -47,6 +65,19 @@ def windows_group(): @windows_group.command("list") def windows_list(): """List all browser windows.""" + targets = _multi_browser_targets() + if targets: + windows = [] + for target in targets: + result = _handle_multi("windows.list", profile=target.profile) + if result is None: + continue + windows.extend({**window, "browser": target.display_name} for window in result) + if not windows: + console.print("[red]Error:[/red] Cannot resolve a browser socket automatically.") + raise SystemExit(1) + _print_windows(windows, show_browser=True) + return windows = _handle("windows.list") _print_windows(windows or []) diff --git a/browser_cli/models.py b/browser_cli/models.py index 8f566b3..8064a5c 100644 --- a/browser_cli/models.py +++ b/browser_cli/models.py @@ -32,6 +32,7 @@ class Tab: title: str url: str group_id: int | None = None + browser: str | None = None _browser: BrowserCLI | None = field(default=None, repr=False, compare=False, init=False) def _b(self) -> BrowserCLI: @@ -103,6 +104,7 @@ class Group: color: str collapsed: bool tab_count: int + browser: str | None = None _browser: BrowserCLI | None = field(default=None, repr=False, compare=False, init=False) def _b(self) -> BrowserCLI: diff --git a/extension/manifest.json b/extension/manifest.json index f18b61b..818ef8d 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "browser-cli", - "version": "0.5.2", + "version": "0.5.4", "description": "Control your browser from the terminal via browser-cli", "permissions": [ "tabs", diff --git a/pyproject.toml b/pyproject.toml index f007263..d64a8f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browser-cli" -version = "0.5.2" +version = "0.5.4" description = "Control your real running browser from the terminal via a Chrome extension" requires-python = ">=3.10" dependencies = [ diff --git a/tests/test_api.py b/tests/test_api.py index 9cdc336..02d9323 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -5,8 +5,8 @@ These tests mock `send_command` so no live browser connection is required. import pytest from unittest.mock import MagicMock, patch, call -from browser_cli import BrowserCLI, Tab, Group -from browser_cli.client import BrowserNotConnected +from browser_cli import BrowserCLI, BrowserCounts, Tab, Group +from browser_cli.client import BrowserNotConnected, BrowserTarget # ── Helpers ─────────────────────────────────────────────────────────────────── @@ -36,7 +36,7 @@ def mock_send(): BrowserCLI._cmd calls the `send_command` name that was imported into browser_cli/__init__.py, so we must patch it there, not in the client module. """ - with patch("browser_cli.send_command") as m: + with patch("browser_cli.send_command") as m, patch("browser_cli.active_browser_targets", return_value=[]): yield m @@ -268,6 +268,48 @@ class TestTabs: mock_send.return_value = 5 assert b.tabs_count() == 5 + def test_tabs_list_multi_browser_annotates_browser_and_binds_actions(self, b, mock_send): + with patch( + "browser_cli.active_browser_targets", + return_value=[ + BrowserTarget("default", "uuid-1", "/tmp/uuid-1.sock"), + BrowserTarget("work", "work", "/tmp/work.sock"), + ], + ): + mock_send.side_effect = [ + [TAB_DATA], + [{**TAB_DATA, "id": 11}], + None, + ] + + tabs = b.tabs_list() + tabs[1].close() + + assert [tab.browser for tab in tabs] == ["uuid-1", "work"] + assert [tab.id for tab in tabs] == [10, 11] + assert mock_send.call_args_list == [ + call("tabs.list", {}, profile="default"), + call("tabs.list", {}, profile="work"), + call("tabs.close", {"tabId": 11}, profile="work"), + ] + + def test_tabs_count_multi_browser_returns_browser_counts(self, b, mock_send): + with patch( + "browser_cli.active_browser_targets", + return_value=[ + BrowserTarget("default", "uuid-1", "/tmp/uuid-1.sock"), + BrowserTarget("work", "work", "/tmp/work.sock"), + ], + ): + mock_send.side_effect = [3, 4] + result = b.tabs_count("github") + + assert result == BrowserCounts(total=7, by_browser={"uuid-1": 3, "work": 4}) + assert mock_send.call_args_list == [ + call("tabs.count", {"pattern": "github"}, profile="default"), + call("tabs.count", {"pattern": "github"}, profile="work"), + ] + def test_tabs_query(self, b, mock_send): mock_send.return_value = [TAB_DATA] result = b.tabs_query("example") @@ -330,6 +372,44 @@ class TestGroups: mock_send.return_value = 7 assert b.group_count() == 7 + def test_group_list_multi_browser_annotates_browser_and_binds_actions(self, b, mock_send): + with patch( + "browser_cli.active_browser_targets", + return_value=[ + BrowserTarget("default", "uuid-1", "/tmp/uuid-1.sock"), + BrowserTarget("work", "work", "/tmp/work.sock"), + ], + ): + mock_send.side_effect = [ + [GROUP_DATA], + [{**GROUP_DATA, "id": 99, "title": "Later"}], + None, + ] + + groups = b.group_list() + groups[1].close() + + assert [group.browser for group in groups] == ["uuid-1", "work"] + assert [group.id for group in groups] == [42, 99] + assert mock_send.call_args_list == [ + call("group.list", {}, profile="default"), + call("group.list", {}, profile="work"), + call("group.close", {"groupId": 99}, profile="work"), + ] + + def test_group_count_multi_browser_returns_browser_counts(self, b, mock_send): + with patch( + "browser_cli.active_browser_targets", + return_value=[ + BrowserTarget("default", "uuid-1", "/tmp/uuid-1.sock"), + BrowserTarget("work", "work", "/tmp/work.sock"), + ], + ): + mock_send.side_effect = [2, 5] + result = b.group_count() + + assert result == BrowserCounts(total=7, by_browser={"uuid-1": 2, "work": 5}) + def test_group_query(self, b, mock_send): mock_send.return_value = [GROUP_DATA] groups = b.group_query("Work") @@ -371,6 +451,27 @@ class TestGroups: ) +class TestWindows: + def test_windows_list_multi_browser_adds_browser(self, b, mock_send): + with patch( + "browser_cli.active_browser_targets", + return_value=[ + BrowserTarget("default", "uuid-1", "/tmp/uuid-1.sock"), + BrowserTarget("work", "work", "/tmp/work.sock"), + ], + ): + mock_send.side_effect = [ + [{"id": 1, "tabCount": 2, "state": "normal"}], + [{"id": 2, "tabCount": 3, "state": "maximized"}], + ] + result = b.windows_list() + + assert result == [ + {"id": 1, "tabCount": 2, "state": "normal", "browser": "uuid-1"}, + {"id": 2, "tabCount": 3, "state": "maximized", "browser": "work"}, + ] + + # ── Tab model ───────────────────────────────────────────────────────────────── class TestTabModel: diff --git a/tests/test_cli.py b/tests/test_cli.py index b6ef267..5c29927 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4,6 +4,7 @@ from click.testing import CliRunner from unittest.mock import patch from browser_cli.cli import main, _project_version +from browser_cli.client import BrowserTarget def _expected_version() -> str: pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml" @@ -50,7 +51,7 @@ def test_install_help_lists_supported_browsers(): assert "[chrome|chromium|brave|edge|vivaldi]" in result.output def test_clients_exits_cleanly_when_registry_is_missing(): - with patch("browser_cli.client.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")): + with patch("browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")): result = CliRunner().invoke(main, ["clients"]) assert result.exit_code == 1 @@ -74,7 +75,7 @@ def test_clients_shows_named_profile_and_uses_socket_uuid_for_default(tmp_path): assert command == "clients.list" return responses[profile] - with patch("browser_cli.client.REGISTRY_PATH", registry_path), patch( + with patch("browser_cli.cli.REGISTRY_PATH", registry_path), patch( "browser_cli.cli.send_command", side_effect=fake_send_command ): result = CliRunner().invoke(main, ["clients"]) @@ -85,6 +86,123 @@ def test_clients_shows_named_profile_and_uses_socket_uuid_for_default(tmp_path): 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.commands.tabs.active_browser_targets", + return_value=[ + BrowserTarget("default", "550e8400-e29b-41d4-a716-446655440000", "/tmp/default.sock"), + BrowserTarget("work", "work", "/tmp/work.sock"), + ], + ), patch("browser_cli.commands.tabs.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_explicit_browser_does_not_show_browser_column(): + with patch( + "browser_cli.commands.tabs.active_browser_targets", + return_value=[ + BrowserTarget("default", "uuid-1", "/tmp/default.sock"), + BrowserTarget("work", "work", "/tmp/work.sock"), + ], + ), patch( + "browser_cli.commands.tabs.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=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.commands.tabs.active_browser_targets", + return_value=[ + BrowserTarget("default", "uuid-1", "/tmp/default.sock"), + BrowserTarget("work", "work", "/tmp/work.sock"), + ], + ), patch("browser_cli.commands.tabs.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.commands.groups.active_browser_targets", + return_value=[ + BrowserTarget("default", "uuid-1", "/tmp/default.sock"), + BrowserTarget("work", "work", "/tmp/work.sock"), + ], + ), patch("browser_cli.commands.groups.send_command", side_effect=fake_send_command): + result = CliRunner().invoke(main, ["group", "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.commands.groups.send_command", + return_value=[{"id": 42, "title": "", "color": "grey", "collapsed": False, "tabCount": 1}], + ): + result = CliRunner().invoke(main, ["group", "list"]) + + assert result.exit_code == 0 + assert "(unnamed)" not in result.output + assert "42" in result.output + assert "grey" in result.output + + +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.commands.windows.active_browser_targets", + return_value=[ + BrowserTarget("default", "uuid-1", "/tmp/default.sock"), + BrowserTarget("work", "work", "/tmp/work.sock"), + ], + ), patch("browser_cli.commands.windows.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_extract_markdown_command(): with patch("browser_cli.commands.extract.send_command", return_value="# Title\n") as send_command: result = CliRunner().invoke(main, ["extract", "markdown"]) diff --git a/tests/test_client.py b/tests/test_client.py index 2eb9562..7c0315a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -3,7 +3,7 @@ from pathlib import Path import pytest -from browser_cli.client import BrowserNotConnected, _resolve_socket +from browser_cli.client import BrowserNotConnected, _resolve_socket, active_browser_targets, display_browser_name def test_resolve_socket_raises_when_registry_missing(monkeypatch): monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False) @@ -38,3 +38,25 @@ def test_resolve_socket_raises_when_multiple_active_entries(monkeypatch, tmp_pat with pytest.raises(BrowserNotConnected, match="Multiple browser instances are active: uuid-1, uuid-2"): _resolve_socket() + + +def test_display_browser_name_uses_uuid_stem_for_default(): + assert display_browser_name("default", "/tmp/.browser_cli/550e8400-e29b-41d4-a716-446655440000.sock") == ( + "550e8400-e29b-41d4-a716-446655440000" + ) + + +def test_active_browser_targets_filters_stale_entries(monkeypatch, tmp_path): + active_socket = tmp_path / "work.sock" + active_socket.write_text("") + stale_socket = tmp_path / "stale.sock" + registry_path = tmp_path / "registry.json" + registry_path.write_text(json.dumps({"work": str(active_socket), "default": str(stale_socket)})) + + monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", registry_path) + + targets = active_browser_targets() + + assert len(targets) == 1 + assert targets[0].profile == "work" + assert targets[0].display_name == "work" diff --git a/uv.lock b/uv.lock index 2448086..60ee6d5 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.10" [[package]] name = "browser-cli" -version = "0.5.2" +version = "0.5.4" source = { editable = "." } dependencies = [ { name = "click" },