add multi browser mode to arragate data from all browsers by tabs list, tabs count, group list, group count and windows list
remove (unnamed) into the group names just leave it a empty string, remove Focused on windows how should the browser know what windows are focused
This commit is contained in:
@@ -122,7 +122,7 @@ browser-cli/
|
|||||||
|
|
||||||
All commands are run with `uv run browser-cli [--browser ALIAS] <command>`.
|
All commands are run with `uv run browser-cli [--browser ALIAS] <command>`.
|
||||||
|
|
||||||
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 <current-alias> <new-alias>`. 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 <current-alias> <new-alias>`. 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.
|
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")
|
b.focus_url("github")
|
||||||
|
|
||||||
# Tabs
|
# 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_active(1234)
|
||||||
b.tabs_close(1234)
|
b.tabs_close(1234)
|
||||||
b.tabs_close_inactive()
|
b.tabs_close_inactive()
|
||||||
b.tabs_close_duplicates()
|
b.tabs_close_duplicates()
|
||||||
b.tabs_filter("youtube") # list of matching tabs
|
b.tabs_filter("youtube") # list of matching tabs
|
||||||
b.tabs_query("pull request")
|
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
|
html = b.tabs_html() # full HTML string of active tab
|
||||||
b.tabs_sort(by="domain")
|
b.tabs_sort(by="domain")
|
||||||
b.tabs_merge_windows()
|
b.tabs_merge_windows()
|
||||||
b.tabs_dedupe()
|
b.tabs_dedupe()
|
||||||
|
|
||||||
# Tab groups
|
# 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_open("research") # creates group, returns { id, name }
|
||||||
b.group_close(42)
|
b.group_close(42)
|
||||||
b.group_tabs(42) # tabs inside a group
|
b.group_tabs(42) # tabs inside a group
|
||||||
|
b.group_count() # int, or BrowserCounts(...) in multi-browser mode
|
||||||
|
|
||||||
# Windows
|
# 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_rename(1, "work")
|
||||||
b.windows_open()
|
b.windows_open()
|
||||||
b.windows_close(1)
|
b.windows_close(1)
|
||||||
@@ -368,6 +369,21 @@ except RuntimeError as e:
|
|||||||
print(f"Browser returned an error: {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
|
## Example scripts
|
||||||
|
|||||||
+95
-12
@@ -17,11 +17,19 @@ Usage:
|
|||||||
b = BrowserCLI(browser="brave")
|
b = BrowserCLI(browser="brave")
|
||||||
"""
|
"""
|
||||||
from collections.abc import Callable, Iterable
|
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
|
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:
|
class BrowserCLI:
|
||||||
@@ -36,9 +44,35 @@ class BrowserCLI:
|
|||||||
def _cmd(self, command: str, args: dict | None = None):
|
def _cmd(self, command: str, args: dict | None = None):
|
||||||
return send_command(command, args, profile=self._browser)
|
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 <alias> / set BROWSER_CLI_PROFILE to a known alias."
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
# ── Internal factories ────────────────────────────────────────────────
|
# ── 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(
|
tab = Tab(
|
||||||
id=data["id"],
|
id=data["id"],
|
||||||
window_id=data.get("windowId", 0),
|
window_id=data.get("windowId", 0),
|
||||||
@@ -46,19 +80,21 @@ class BrowserCLI:
|
|||||||
title=data.get("title") or "",
|
title=data.get("title") or "",
|
||||||
url=data.get("url") or "",
|
url=data.get("url") or "",
|
||||||
group_id=data.get("groupId") or None,
|
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
|
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(
|
group = Group(
|
||||||
id=data["id"],
|
id=data["id"],
|
||||||
title=data.get("title") or "",
|
title=data.get("title") or "",
|
||||||
color=data.get("color") or "",
|
color=data.get("color") or "",
|
||||||
collapsed=data.get("collapsed", False),
|
collapsed=data.get("collapsed", False),
|
||||||
tab_count=data.get("tabCount", 0),
|
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
|
return group
|
||||||
|
|
||||||
# ── Navigation ────────────────────────────────────────────────────────
|
# ── Navigation ────────────────────────────────────────────────────────
|
||||||
@@ -99,7 +135,18 @@ class BrowserCLI:
|
|||||||
# ── Tabs ──────────────────────────────────────────────────────────────
|
# ── Tabs ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def tabs_list(self) -> list[Tab]:
|
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 [])]
|
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:
|
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._make_tab(t) for t in (self._cmd("tabs.filter", {"pattern": pattern_or_filter}) or [])]
|
||||||
return self._apply_tab_filter(pattern_or_filter)
|
return self._apply_tab_filter(pattern_or_filter)
|
||||||
|
|
||||||
def tabs_count(self, pattern: str | None = None) -> int:
|
def tabs_count(self, pattern: str | None = None) -> int | BrowserCounts:
|
||||||
"""Count open tabs, optionally filtered by URL pattern."""
|
"""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})
|
return self._cmd("tabs.count", {"pattern": pattern})
|
||||||
|
|
||||||
def tabs_query(self, search: str) -> list[Tab]:
|
def tabs_query(self, search: str) -> list[Tab]:
|
||||||
@@ -166,15 +220,33 @@ class BrowserCLI:
|
|||||||
# ── Tab Groups ────────────────────────────────────────────────────────
|
# ── Tab Groups ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def group_list(self) -> list[Group]:
|
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 [])]
|
return [self._make_group(g) for g in (self._cmd("group.list", {}) or [])]
|
||||||
|
|
||||||
def group_tabs(self, group_id: int) -> list[Tab]:
|
def group_tabs(self, group_id: int) -> list[Tab]:
|
||||||
"""Return all tabs inside a group."""
|
"""Return all tabs inside a group."""
|
||||||
return [self._make_tab(t) for t in (self._cmd("group.tabs", {"groupId": group_id}) or [])]
|
return [self._make_tab(t) for t in (self._cmd("group.tabs", {"groupId": group_id}) or [])]
|
||||||
|
|
||||||
def group_count(self) -> int:
|
def group_count(self) -> int | BrowserCounts:
|
||||||
"""Return the number of tab groups."""
|
"""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", {})
|
return self._cmd("group.count", {})
|
||||||
|
|
||||||
def group_query(self, search: str) -> list[Group]:
|
def group_query(self, search: str) -> list[Group]:
|
||||||
@@ -202,6 +274,17 @@ class BrowserCLI:
|
|||||||
# ── Windows ───────────────────────────────────────────────────────────
|
# ── Windows ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def windows_list(self) -> list[dict]:
|
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", {})
|
return self._cmd("windows.list", {})
|
||||||
|
|
||||||
def windows_rename(self, window_id: int, name: str) -> None:
|
def windows_rename(self, window_id: int, name: str) -> None:
|
||||||
|
|||||||
+5
-14
@@ -21,7 +21,7 @@ from browser_cli.commands.dom import dom_group
|
|||||||
from browser_cli.commands.extract import extract_group
|
from browser_cli.commands.extract import extract_group
|
||||||
from browser_cli.commands.session import session_group
|
from browser_cli.commands.session import session_group
|
||||||
from browser_cli.commands.search import search_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()
|
console = Console()
|
||||||
|
|
||||||
@@ -85,13 +85,6 @@ def _print_version(ctx, param, value):
|
|||||||
click.echo(_project_version())
|
click.echo(_project_version())
|
||||||
ctx.exit()
|
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.group()
|
||||||
@click.option(
|
@click.option(
|
||||||
"-V", "--version",
|
"-V", "--version",
|
||||||
@@ -109,6 +102,8 @@ def _client_display_profile(profile_name: str, sock_path: str) -> str:
|
|||||||
def main(ctx, browser):
|
def main(ctx, browser):
|
||||||
"""Control your running browser from the terminal via a Chrome extension."""
|
"""Control your running browser from the terminal via a Chrome extension."""
|
||||||
ctx.ensure_object(dict)
|
ctx.ensure_object(dict)
|
||||||
|
ctx.obj["browser"] = browser
|
||||||
|
ctx.obj["browser_explicit"] = browser is not None
|
||||||
if browser:
|
if browser:
|
||||||
os.environ["BROWSER_CLI_PROFILE"] = browser
|
os.environ["BROWSER_CLI_PROFILE"] = browser
|
||||||
|
|
||||||
@@ -129,20 +124,16 @@ main.add_command(search_group)
|
|||||||
@main.command("clients")
|
@main.command("clients")
|
||||||
def cmd_clients():
|
def cmd_clients():
|
||||||
"""Show connected browser 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] = {}
|
profiles: dict[str, str] = {}
|
||||||
if REGISTRY_PATH.exists():
|
if REGISTRY_PATH.exists():
|
||||||
try:
|
try:
|
||||||
profiles = _json.loads(REGISTRY_PATH.read_text())
|
profiles = json.loads(REGISTRY_PATH.read_text())
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
all_clients = []
|
all_clients = []
|
||||||
for profile_name, sock_path in profiles.items():
|
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:
|
try:
|
||||||
result = send_command("clients.list", profile=profile_name)
|
result = send_command("clients.list", profile=profile_name)
|
||||||
for c in (result or []):
|
for c in (result or []):
|
||||||
|
|||||||
+42
-17
@@ -13,6 +13,7 @@ import os
|
|||||||
import socket
|
import socket
|
||||||
import struct
|
import struct
|
||||||
import uuid
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -24,11 +25,37 @@ class BrowserNotConnected(Exception):
|
|||||||
"""Raised when the native host socket is not available."""
|
"""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:
|
def _active_sockets(reg: dict) -> dict:
|
||||||
"""Return only entries whose socket file exists on disk."""
|
"""Return only entries whose socket file exists on disk."""
|
||||||
return {k: v for k, v in reg.items() if Path(v).exists()}
|
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:
|
def _resolve_socket(profile: str | None = None) -> str:
|
||||||
"""Return the socket path for the given profile (or auto-detect)."""
|
"""Return the socket path for the given profile (or auto-detect)."""
|
||||||
target = profile or os.environ.get("BROWSER_CLI_PROFILE")
|
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")
|
return str(SOCKET_DIR / f"{safe}.sock")
|
||||||
|
|
||||||
# Auto-detect: error when multiple browser instances are active
|
# Auto-detect: error when multiple browser instances are active
|
||||||
if REGISTRY_PATH.exists():
|
try:
|
||||||
try:
|
active = active_browser_targets()
|
||||||
reg = json.loads(REGISTRY_PATH.read_text())
|
if len(active) > 1:
|
||||||
active = _active_sockets(reg)
|
aliases = [target.profile for target in active]
|
||||||
if len(active) > 1:
|
examples = "\n".join(f" browser-cli --browser {a} ..." for a in aliases)
|
||||||
aliases = list(active.keys())
|
raise BrowserNotConnected(
|
||||||
examples = "\n".join(f" browser-cli --browser {a} ..." for a in aliases)
|
f"Multiple browser instances are active: {', '.join(aliases)}\n"
|
||||||
raise BrowserNotConnected(
|
f"Use --browser <alias> to select one:\n{examples}"
|
||||||
f"Multiple browser instances are active: {', '.join(aliases)}\n"
|
)
|
||||||
f"Use --browser <alias> to select one:\n{examples}"
|
if active:
|
||||||
)
|
return active[0].socket_path
|
||||||
if active:
|
except BrowserNotConnected:
|
||||||
return next(iter(active.values()))
|
raise
|
||||||
except BrowserNotConnected:
|
except Exception:
|
||||||
raise
|
pass
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
raise BrowserNotConnected(
|
raise BrowserNotConnected(
|
||||||
"Cannot resolve a browser socket automatically.\n"
|
"Cannot resolve a browser socket automatically.\n"
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import click
|
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.console import Console
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
def _handle(command, args=None):
|
def _handle(command, args=None, profile=None):
|
||||||
try:
|
try:
|
||||||
return send_command(command, args or {})
|
return send_command(command, args or {}, profile=profile)
|
||||||
except BrowserNotConnected as e:
|
except BrowserNotConnected as e:
|
||||||
console.print(f"[red]Error:[/red] {e}")
|
console.print(f"[red]Error:[/red] {e}")
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
@@ -17,24 +17,45 @@ def _handle(command, args=None):
|
|||||||
raise SystemExit(1)
|
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:
|
if not groups:
|
||||||
console.print("[yellow]No groups found[/yellow]")
|
console.print("[yellow]No groups found[/yellow]")
|
||||||
return
|
return
|
||||||
table = Table(show_header=True, header_style="bold cyan")
|
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("ID", style="dim", no_wrap=True)
|
||||||
table.add_column("Name")
|
table.add_column("Name")
|
||||||
table.add_column("Color", width=10)
|
table.add_column("Color", width=10)
|
||||||
table.add_column("Collapsed", width=10)
|
table.add_column("Collapsed", width=10)
|
||||||
table.add_column("Tabs", width=6)
|
table.add_column("Tabs", width=6)
|
||||||
for g in groups:
|
for g in groups:
|
||||||
table.add_row(
|
row = [
|
||||||
|
g.get("browser", "") if show_browser else None,
|
||||||
str(g.get("id", "")),
|
str(g.get("id", "")),
|
||||||
g.get("title") or "(unnamed)",
|
g.get("title") or "",
|
||||||
g.get("color") or "",
|
g.get("color") or "",
|
||||||
"yes" if g.get("collapsed") else "no",
|
"yes" if g.get("collapsed") else "no",
|
||||||
str(g.get("tabCount", "")),
|
str(g.get("tabCount", "")),
|
||||||
)
|
]
|
||||||
|
table.add_row(*[value for value in row if value is not None])
|
||||||
console.print(table)
|
console.print(table)
|
||||||
|
|
||||||
|
|
||||||
@@ -46,6 +67,19 @@ def group_group():
|
|||||||
@group_group.command("list")
|
@group_group.command("list")
|
||||||
def group_list():
|
def group_list():
|
||||||
"""List all tab groups."""
|
"""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")
|
groups = _handle("group.list")
|
||||||
_print_groups(groups or [])
|
_print_groups(groups or [])
|
||||||
|
|
||||||
@@ -62,6 +96,27 @@ def group_tabs(group_id):
|
|||||||
@group_group.command("count")
|
@group_group.command("count")
|
||||||
def group_count():
|
def group_count():
|
||||||
"""Count all tab groups."""
|
"""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")
|
count = _handle("group.count")
|
||||||
console.print(f"[bold]{count}[/bold] group(s)")
|
console.print(f"[bold]{count}[/bold] group(s)")
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import click
|
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.console import Console
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
def _handle(command, args=None):
|
def _handle(command, args=None, profile=None):
|
||||||
try:
|
try:
|
||||||
return send_command(command, args or {})
|
return send_command(command, args or {}, profile=profile)
|
||||||
except BrowserNotConnected as e:
|
except BrowserNotConnected as e:
|
||||||
console.print(f"[red]Error:[/red] {e}")
|
console.print(f"[red]Error:[/red] {e}")
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
@@ -17,11 +17,30 @@ def _handle(command, args=None):
|
|||||||
raise SystemExit(1)
|
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:
|
if not tabs:
|
||||||
console.print("[yellow]No tabs found[/yellow]")
|
console.print("[yellow]No tabs found[/yellow]")
|
||||||
return
|
return
|
||||||
table = Table(show_header=True, header_style="bold cyan")
|
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("ID", style="dim", no_wrap=True)
|
||||||
table.add_column("Window", no_wrap=True)
|
table.add_column("Window", no_wrap=True)
|
||||||
table.add_column("Active", width=7)
|
table.add_column("Active", width=7)
|
||||||
@@ -29,13 +48,15 @@ def _print_tabs(tabs: list[dict]) -> None:
|
|||||||
table.add_column("URL")
|
table.add_column("URL")
|
||||||
for t in tabs:
|
for t in tabs:
|
||||||
active = "[green]✓[/green]" if t.get("active") else ""
|
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("id", "")),
|
||||||
str(t.get("windowId", "")),
|
str(t.get("windowId", "")),
|
||||||
active,
|
active,
|
||||||
(t.get("title") or "")[:60],
|
(t.get("title") or "")[:60],
|
||||||
(t.get("url") or "")[:80],
|
(t.get("url") or "")[:80],
|
||||||
)
|
]
|
||||||
|
table.add_row(*[value for value in row if value is not None])
|
||||||
console.print(table)
|
console.print(table)
|
||||||
|
|
||||||
|
|
||||||
@@ -47,6 +68,19 @@ def tabs_group():
|
|||||||
@tabs_group.command("list")
|
@tabs_group.command("list")
|
||||||
def tabs_list():
|
def tabs_list():
|
||||||
"""List all open tabs across all windows."""
|
"""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")
|
tabs = _handle("tabs.list")
|
||||||
_print_tabs(tabs or [])
|
_print_tabs(tabs or [])
|
||||||
|
|
||||||
@@ -98,6 +132,27 @@ def tabs_filter(pattern):
|
|||||||
@click.argument("pattern", required=False)
|
@click.argument("pattern", required=False)
|
||||||
def tabs_count(pattern):
|
def tabs_count(pattern):
|
||||||
"""Count open tabs, optionally filtered by URL 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})
|
count = _handle("tabs.count", {"pattern": pattern})
|
||||||
label = f" matching '{pattern}'" if pattern else ""
|
label = f" matching '{pattern}'" if pattern else ""
|
||||||
console.print(f"[bold]{count}[/bold] tab(s){label}")
|
console.print(f"[bold]{count}[/bold] tab(s){label}")
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import click
|
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.console import Console
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
def _handle(command, args=None):
|
def _handle(command, args=None, profile=None):
|
||||||
try:
|
try:
|
||||||
return send_command(command, args or {})
|
return send_command(command, args or {}, profile=profile)
|
||||||
except BrowserNotConnected as e:
|
except BrowserNotConnected as e:
|
||||||
console.print(f"[red]Error:[/red] {e}")
|
console.print(f"[red]Error:[/red] {e}")
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
@@ -17,25 +17,43 @@ def _handle(command, args=None):
|
|||||||
raise SystemExit(1)
|
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:
|
if not windows:
|
||||||
console.print("[yellow]No windows found[/yellow]")
|
console.print("[yellow]No windows found[/yellow]")
|
||||||
return
|
return
|
||||||
table = Table(show_header=True, header_style="bold cyan")
|
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("ID", style="dim", no_wrap=True)
|
||||||
table.add_column("Alias", width=20)
|
table.add_column("Alias", width=20)
|
||||||
table.add_column("Focused", width=8)
|
|
||||||
table.add_column("Tabs", width=6)
|
table.add_column("Tabs", width=6)
|
||||||
table.add_column("State", width=12)
|
table.add_column("State", width=12)
|
||||||
for w in windows:
|
for w in windows:
|
||||||
focused = "[green]✓[/green]" if w.get("focused") else ""
|
row = [
|
||||||
table.add_row(
|
w.get("browser", "") if show_browser else None,
|
||||||
str(w.get("id", "")),
|
str(w.get("id", "")),
|
||||||
w.get("alias") or "",
|
w.get("alias") or "",
|
||||||
focused,
|
|
||||||
str(w.get("tabCount", "")),
|
str(w.get("tabCount", "")),
|
||||||
w.get("state") or "",
|
w.get("state") or "",
|
||||||
)
|
]
|
||||||
|
table.add_row(*[value for value in row if value is not None])
|
||||||
console.print(table)
|
console.print(table)
|
||||||
|
|
||||||
|
|
||||||
@@ -47,6 +65,19 @@ def windows_group():
|
|||||||
@windows_group.command("list")
|
@windows_group.command("list")
|
||||||
def windows_list():
|
def windows_list():
|
||||||
"""List all browser windows."""
|
"""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")
|
windows = _handle("windows.list")
|
||||||
_print_windows(windows or [])
|
_print_windows(windows or [])
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class Tab:
|
|||||||
title: str
|
title: str
|
||||||
url: str
|
url: str
|
||||||
group_id: int | None = None
|
group_id: int | None = None
|
||||||
|
browser: str | None = None
|
||||||
_browser: BrowserCLI | None = field(default=None, repr=False, compare=False, init=False)
|
_browser: BrowserCLI | None = field(default=None, repr=False, compare=False, init=False)
|
||||||
|
|
||||||
def _b(self) -> BrowserCLI:
|
def _b(self) -> BrowserCLI:
|
||||||
@@ -103,6 +104,7 @@ class Group:
|
|||||||
color: str
|
color: str
|
||||||
collapsed: bool
|
collapsed: bool
|
||||||
tab_count: int
|
tab_count: int
|
||||||
|
browser: str | None = None
|
||||||
_browser: BrowserCLI | None = field(default=None, repr=False, compare=False, init=False)
|
_browser: BrowserCLI | None = field(default=None, repr=False, compare=False, init=False)
|
||||||
|
|
||||||
def _b(self) -> BrowserCLI:
|
def _b(self) -> BrowserCLI:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "browser-cli",
|
"name": "browser-cli",
|
||||||
"version": "0.5.2",
|
"version": "0.5.4",
|
||||||
"description": "Control your browser from the terminal via browser-cli",
|
"description": "Control your browser from the terminal via browser-cli",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"tabs",
|
"tabs",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "browser-cli"
|
name = "browser-cli"
|
||||||
version = "0.5.2"
|
version = "0.5.4"
|
||||||
description = "Control your real running browser from the terminal via a Chrome extension"
|
description = "Control your real running browser from the terminal via a Chrome extension"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
+104
-3
@@ -5,8 +5,8 @@ These tests mock `send_command` so no live browser connection is required.
|
|||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import MagicMock, patch, call
|
from unittest.mock import MagicMock, patch, call
|
||||||
|
|
||||||
from browser_cli import BrowserCLI, Tab, Group
|
from browser_cli import BrowserCLI, BrowserCounts, Tab, Group
|
||||||
from browser_cli.client import BrowserNotConnected
|
from browser_cli.client import BrowserNotConnected, BrowserTarget
|
||||||
|
|
||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
@@ -36,7 +36,7 @@ def mock_send():
|
|||||||
BrowserCLI._cmd calls the `send_command` name that was imported into
|
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.
|
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
|
yield m
|
||||||
|
|
||||||
|
|
||||||
@@ -268,6 +268,48 @@ class TestTabs:
|
|||||||
mock_send.return_value = 5
|
mock_send.return_value = 5
|
||||||
assert b.tabs_count() == 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):
|
def test_tabs_query(self, b, mock_send):
|
||||||
mock_send.return_value = [TAB_DATA]
|
mock_send.return_value = [TAB_DATA]
|
||||||
result = b.tabs_query("example")
|
result = b.tabs_query("example")
|
||||||
@@ -330,6 +372,44 @@ class TestGroups:
|
|||||||
mock_send.return_value = 7
|
mock_send.return_value = 7
|
||||||
assert b.group_count() == 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):
|
def test_group_query(self, b, mock_send):
|
||||||
mock_send.return_value = [GROUP_DATA]
|
mock_send.return_value = [GROUP_DATA]
|
||||||
groups = b.group_query("Work")
|
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 ─────────────────────────────────────────────────────────────────
|
# ── Tab model ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class TestTabModel:
|
class TestTabModel:
|
||||||
|
|||||||
+120
-2
@@ -4,6 +4,7 @@ from click.testing import CliRunner
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from browser_cli.cli import main, _project_version
|
from browser_cli.cli import main, _project_version
|
||||||
|
from browser_cli.client import BrowserTarget
|
||||||
|
|
||||||
def _expected_version() -> str:
|
def _expected_version() -> str:
|
||||||
pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
|
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
|
assert "[chrome|chromium|brave|edge|vivaldi]" in result.output
|
||||||
|
|
||||||
def test_clients_exits_cleanly_when_registry_is_missing():
|
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"])
|
result = CliRunner().invoke(main, ["clients"])
|
||||||
|
|
||||||
assert result.exit_code == 1
|
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"
|
assert command == "clients.list"
|
||||||
return responses[profile]
|
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
|
"browser_cli.cli.send_command", side_effect=fake_send_command
|
||||||
):
|
):
|
||||||
result = CliRunner().invoke(main, ["clients"])
|
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 "Extension Version" in result.output
|
||||||
assert "2.3.4" 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():
|
def test_extract_markdown_command():
|
||||||
with patch("browser_cli.commands.extract.send_command", return_value="# Title\n") as send_command:
|
with patch("browser_cli.commands.extract.send_command", return_value="# Title\n") as send_command:
|
||||||
result = CliRunner().invoke(main, ["extract", "markdown"])
|
result = CliRunner().invoke(main, ["extract", "markdown"])
|
||||||
|
|||||||
+23
-1
@@ -3,7 +3,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
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):
|
def test_resolve_socket_raises_when_registry_missing(monkeypatch):
|
||||||
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
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"):
|
with pytest.raises(BrowserNotConnected, match="Multiple browser instances are active: uuid-1, uuid-2"):
|
||||||
_resolve_socket()
|
_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"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ requires-python = ">=3.10"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "browser-cli"
|
name = "browser-cli"
|
||||||
version = "0.5.2"
|
version = "0.5.4"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
|
|||||||
Reference in New Issue
Block a user