feat: improve remote browser tree routing
Testing / remote-protocol-compat (0.9.3) (push) Successful in 43s
Testing / test (push) Successful in 1m1s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 39s
Build & Publish Package / publish (push) Successful in 58s
Package Extension / package-extension (push) Successful in 1m15s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 43s
Testing / test (push) Successful in 1m1s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 39s
Build & Publish Package / publish (push) Successful in 58s
Package Extension / package-extension (push) Successful in 1m15s
- Allow remote host aliases passed via --browser to fan out for read-only multi-browser SDK paths while preserving strict routing for mutating commands. - Add remote host grouping and scoped profile labels to tabs tree output so global views avoid repeated host prefixes. - Carry browser family metadata through remote targets, tabs, and groups and style tree browser labels by family. - Split CLI rendering helpers into a typed rendering package with dedicated common, label, tabs-tree, and windows-tree modules. - Bump browser-cli and extension versions to 0.15.5. - Cover the new routing and rendering behavior with unit and CLI tests.
This commit is contained in:
@@ -36,7 +36,7 @@ Commands are grouped into namespaces on the client:
|
||||
"""
|
||||
from collections.abc import Callable
|
||||
|
||||
from browser_cli.client import active_browser_targets, remote_browser_targets, send_command, send_command_async
|
||||
from browser_cli.client import active_browser_targets, remote_browser_targets, remote_targets_for_alias, send_command, send_command_async
|
||||
from browser_cli.errors import BrowserNotConnected
|
||||
from browser_cli.models import BrowserCounts, Group, Tab
|
||||
from browser_cli.sdk import (
|
||||
|
||||
@@ -220,18 +220,40 @@ class AsyncBrowserCLI:
|
||||
async def clients(self) -> list[dict]:
|
||||
return await self._cmd("clients.list", {})
|
||||
|
||||
def tab_from(self, data: dict, *, browser_profile: str | None = None, browser_name: str | None = None, browser_remote: str | None = None) -> Tab:
|
||||
def tab_from(
|
||||
self,
|
||||
data: dict,
|
||||
*,
|
||||
browser_profile: str | None = None,
|
||||
browser_name: str | None = None,
|
||||
browser_remote: str | None = None,
|
||||
browser_type: str | None = None,
|
||||
browser_group: str | None = None,
|
||||
) -> Tab:
|
||||
return self._sync.tab_from(
|
||||
data,
|
||||
browser_profile=browser_profile,
|
||||
browser_name=browser_name,
|
||||
browser_remote=browser_remote,
|
||||
browser_type=browser_type,
|
||||
browser_group=browser_group,
|
||||
)
|
||||
|
||||
def group_from(self, data: dict, *, browser_profile: str | None = None, browser_name: str | None = None, browser_remote: str | None = None) -> Group:
|
||||
def group_from(
|
||||
self,
|
||||
data: dict,
|
||||
*,
|
||||
browser_profile: str | None = None,
|
||||
browser_name: str | None = None,
|
||||
browser_remote: str | None = None,
|
||||
browser_type: str | None = None,
|
||||
browser_group: str | None = None,
|
||||
) -> Group:
|
||||
return self._sync.group_from(
|
||||
data,
|
||||
browser_profile=browser_profile,
|
||||
browser_name=browser_name,
|
||||
browser_remote=browser_remote,
|
||||
browser_type=browser_type,
|
||||
browser_group=browser_group,
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ from browser_cli.client.core import (
|
||||
remote_browser_targets,
|
||||
remote_browser_targets_async,
|
||||
remote_target_for_alias,
|
||||
remote_targets_for_alias,
|
||||
send_command,
|
||||
send_command_async,
|
||||
)
|
||||
@@ -42,6 +43,7 @@ __all__ = [
|
||||
"remote_browser_targets",
|
||||
"remote_browser_targets_async",
|
||||
"remote_target_for_alias",
|
||||
"remote_targets_for_alias",
|
||||
"send_command",
|
||||
"send_command_async",
|
||||
]
|
||||
|
||||
+27
-12
@@ -25,12 +25,16 @@ def _remote_target_items(endpoint: str, items: list[dict] | None) -> list[Browse
|
||||
for item in items or []:
|
||||
profile = str(item.get("profile") or "default")
|
||||
display = str(item.get("displayName") or profile)
|
||||
display_name = _remote_display_name(endpoint, profile, display)
|
||||
browser_name = item.get("browserName") or item.get("name")
|
||||
targets.append(
|
||||
BrowserTarget(
|
||||
profile=profile,
|
||||
display_name=_remote_display_name(endpoint, profile, display),
|
||||
display_name=display_name,
|
||||
socket_path="",
|
||||
remote=endpoint,
|
||||
browser_name=str(browser_name) if browser_name else None,
|
||||
display_group=display_name.rsplit(":", 1)[0],
|
||||
)
|
||||
)
|
||||
return targets
|
||||
@@ -52,15 +56,21 @@ def _remote_browser_targets(key=None, *, suppress_pq_warning: bool = False) -> l
|
||||
continue
|
||||
return targets
|
||||
|
||||
def remote_target_for_alias(alias: str | None) -> BrowserTarget | None:
|
||||
"""Resolve a user-facing remote alias such as 'host:profile' to a target."""
|
||||
def remote_targets_for_alias(alias: str | None, key=None) -> list[BrowserTarget]:
|
||||
"""Return remote targets matching a user-facing alias.
|
||||
|
||||
Exact browser aliases such as ``host:profile`` return one target. Endpoint
|
||||
aliases such as ``host`` or ``host:8765`` may return multiple targets, which
|
||||
lets read/list SDK commands fan out while command dispatch can still reject
|
||||
the ambiguous target.
|
||||
"""
|
||||
if not alias:
|
||||
return None
|
||||
targets = _remote_browser_targets()
|
||||
return []
|
||||
targets = _remote_browser_targets(key=key) if key is not None else _remote_browser_targets()
|
||||
for target in targets:
|
||||
endpoint_profile = f"{target.remote}:{target.profile}" if target.remote else None
|
||||
if alias in {target.display_name, endpoint_profile}:
|
||||
return target
|
||||
return [target]
|
||||
|
||||
endpoint_matches = []
|
||||
for target in targets:
|
||||
@@ -69,16 +79,21 @@ def remote_target_for_alias(alias: str | None) -> BrowserTarget | None:
|
||||
remote_host, sep, _remote_port = target.remote.rpartition(":")
|
||||
if alias == target.remote or (sep and alias == remote_host):
|
||||
endpoint_matches.append(target)
|
||||
if len(endpoint_matches) == 1:
|
||||
return endpoint_matches[0]
|
||||
if len(endpoint_matches) > 1:
|
||||
aliases = [target.profile for target in endpoint_matches]
|
||||
endpoint = endpoint_matches[0].remote or alias
|
||||
return endpoint_matches
|
||||
|
||||
def remote_target_for_alias(alias: str | None) -> BrowserTarget | None:
|
||||
"""Resolve a user-facing remote alias such as 'host:profile' to a target."""
|
||||
matches = remote_targets_for_alias(alias)
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
if len(matches) > 1:
|
||||
aliases = [target.profile for target in matches]
|
||||
endpoint = matches[0].remote or alias or "remote"
|
||||
examples = "\n".join(
|
||||
f" browser-cli --remote {endpoint} --browser {a} ..."
|
||||
for a in aliases
|
||||
)
|
||||
display_aliases = [target.display_name for target in endpoint_matches]
|
||||
display_aliases = [target.display_name for target in matches]
|
||||
shorthand_examples = "\n".join(
|
||||
f" browser-cli --browser {a} ..."
|
||||
for a in display_aliases
|
||||
|
||||
@@ -17,6 +17,8 @@ class BrowserTarget:
|
||||
display_name: str
|
||||
socket_path: str
|
||||
remote: str | None = None
|
||||
browser_name: str | None = None
|
||||
display_group: str | None = None
|
||||
|
||||
def is_reachable_unix_endpoint(endpoint: str) -> bool:
|
||||
"""Return True when a Unix socket path exists and accepts connections."""
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
"""Reusable rendering helpers for CLI command modules."""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from collections.abc import Callable, Iterable, Sequence
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
from rich.tree import Tree
|
||||
|
||||
Column = tuple[str, Callable[[Any], Any]]
|
||||
|
||||
def item_value(item: Any, name: str, default: Any = None) -> Any:
|
||||
"""Read *name* from a dict-like or attribute object."""
|
||||
if isinstance(item, dict):
|
||||
return item.get(name, default)
|
||||
return getattr(item, name, default)
|
||||
|
||||
def shorten(value: str | None, limit: int) -> str:
|
||||
"""Return *value* shortened to *limit* cells-ish, using an ellipsis."""
|
||||
value = value or ""
|
||||
return value if len(value) <= limit else value[:max(0, limit - 1)] + "…"
|
||||
|
||||
def terminal_width(console: Console | None = None, *, fallback: int = 120) -> int:
|
||||
"""Best-effort terminal width for interactive and redirected output.
|
||||
|
||||
Rich falls back to 80 columns when stdout is redirected. browser-cli output is
|
||||
often piped into files for inspection, so also consult ``shutil``/``COLUMNS``
|
||||
and prefer the wider value.
|
||||
"""
|
||||
rich_width = (console.width if console is not None else 0) or 0
|
||||
shell_width = shutil.get_terminal_size((fallback, 20)).columns
|
||||
return max(rich_width, shell_width)
|
||||
|
||||
def tree_title_limit(*, console: Console | None = None, show_browser: bool = False, show_urls: bool = False) -> int:
|
||||
"""Title width for tree labels, reserving space for branches/IDs/metadata."""
|
||||
reserve = 48 if show_urls else 32
|
||||
if show_browser:
|
||||
reserve += 4
|
||||
return max(50, terminal_width(console) - reserve)
|
||||
|
||||
def tree_url_limit(title_limit: int, *, console: Console | None = None) -> int:
|
||||
"""URL width for tree labels when URLs are displayed."""
|
||||
return max(35, terminal_width(console) - title_limit - 40)
|
||||
|
||||
def no_wrap_text() -> Text:
|
||||
"""Text configured for one-line tree labels with edge ellipsis."""
|
||||
return Text(no_wrap=True, overflow="ellipsis")
|
||||
|
||||
def tab_tree_label(tab: Any, *, title_limit: int, show_urls: bool = False, url_limit: int = 55) -> Text:
|
||||
"""Reusable one-line label for a browser tab in tree views."""
|
||||
label = no_wrap_text()
|
||||
label.append(f"[{item_value(tab, 'id')}] ", style="dim")
|
||||
label.append(shorten(item_value(tab, 'title') or "(untitled)", title_limit))
|
||||
if item_value(tab, "active", False):
|
||||
label.append(" *", style="green")
|
||||
url = item_value(tab, "url")
|
||||
if show_urls and url:
|
||||
label.append(" — ", style="dim")
|
||||
label.append(shorten(url, url_limit), style="dim")
|
||||
return label
|
||||
|
||||
def group_tree_label(group_id: int, group: Any, *, title_limit: int) -> Text:
|
||||
"""Reusable one-line label for a browser tab group in tree views."""
|
||||
title = item_value(group, "title", "") or f"Group {group_id}"
|
||||
color = item_value(group, "color", "") or "group"
|
||||
count = item_value(group, "tab_count", item_value(group, "tabCount", 0)) or 0
|
||||
collapsed = bool(item_value(group, "collapsed", False))
|
||||
label = no_wrap_text()
|
||||
label.append(shorten(title, title_limit), style="bold")
|
||||
meta = [color]
|
||||
if count:
|
||||
meta.append(f"{count} tab" + ("" if count == 1 else "s"))
|
||||
if collapsed:
|
||||
meta.append("collapsed")
|
||||
label.append(" (" + ", ".join(meta) + ")", style="dim")
|
||||
return label
|
||||
|
||||
def tab_sort_key(tab: Any) -> tuple:
|
||||
"""Stable tab ordering across multi-browser responses."""
|
||||
group_id = item_value(tab, "group_id", item_value(tab, "groupId"))
|
||||
return (
|
||||
item_value(tab, "browser") or "",
|
||||
item_value(tab, "window_id", item_value(tab, "windowId", 0)),
|
||||
item_value(tab, "index", 0) or 0,
|
||||
group_id if group_id is not None else -1,
|
||||
item_value(tab, "id", 0),
|
||||
)
|
||||
|
||||
def print_tree(tree: Tree, *, console: Console | None = None) -> None:
|
||||
"""Render a Rich tree using the detected full terminal width."""
|
||||
Console(width=terminal_width(console)).print(tree)
|
||||
|
||||
def print_table_rows(
|
||||
rows: Sequence[Any],
|
||||
columns: Sequence[Column],
|
||||
*,
|
||||
console: Console,
|
||||
empty_message: str,
|
||||
show_header: bool = True,
|
||||
header_style: str = "bold cyan",
|
||||
) -> None:
|
||||
"""Render a small Rich table from arbitrary row objects."""
|
||||
if not rows:
|
||||
console.print(empty_message)
|
||||
return
|
||||
table = Table(show_header=show_header, header_style=header_style)
|
||||
for header, _getter in columns:
|
||||
table.add_column(header)
|
||||
for row in rows:
|
||||
table.add_row(*[str(getter(row) or "") for _header, getter in columns])
|
||||
Console(width=terminal_width(console)).print(table)
|
||||
|
||||
def build_tabs_tree(
|
||||
tabs: Iterable[Any],
|
||||
groups: Iterable[Any],
|
||||
*,
|
||||
console: Console,
|
||||
show_urls: bool = False,
|
||||
) -> Tree:
|
||||
"""Build a browser → window → group/tab tree from tab and group responses."""
|
||||
tabs = sorted(tabs, key=tab_sort_key)
|
||||
show_browser = any(item_value(tab, "browser") for tab in tabs)
|
||||
title_limit = tree_title_limit(console=console, show_browser=show_browser, show_urls=show_urls)
|
||||
url_limit = tree_url_limit(title_limit, console=console)
|
||||
group_info = {
|
||||
(
|
||||
item_value(group, "browser") or "local",
|
||||
item_value(group, "window_id", item_value(group, "windowId")),
|
||||
item_value(group, "id"),
|
||||
): group
|
||||
for group in groups
|
||||
}
|
||||
root = Tree("[bold]Tabs[/bold]")
|
||||
browser_nodes: dict[str, Tree] = {}
|
||||
window_nodes: dict[tuple[str, int], Tree] = {}
|
||||
group_nodes: dict[tuple[str, int, int], Tree] = {}
|
||||
for tab in tabs:
|
||||
browser_key = item_value(tab, "browser") or "local"
|
||||
browser_node = browser_nodes.get(browser_key)
|
||||
if browser_node is None:
|
||||
browser_node = root.add(Text(browser_key, style="bold cyan")) if show_browser else root
|
||||
browser_nodes[browser_key] = browser_node
|
||||
window_id = item_value(tab, "window_id", item_value(tab, "windowId", 0))
|
||||
window_key = (browser_key, window_id)
|
||||
window_node = window_nodes.get(window_key)
|
||||
if window_node is None:
|
||||
window_node = browser_node.add(f"Window {window_id}")
|
||||
window_nodes[window_key] = window_node
|
||||
group_id = item_value(tab, "group_id", item_value(tab, "groupId"))
|
||||
if group_id is None:
|
||||
window_node.add(tab_tree_label(tab, title_limit=title_limit, show_urls=show_urls, url_limit=url_limit))
|
||||
continue
|
||||
group_key = (browser_key, window_id, group_id)
|
||||
group_node = group_nodes.get(group_key)
|
||||
if group_node is None:
|
||||
group = group_info.get(group_key) or group_info.get((browser_key, None, group_id))
|
||||
group_node = window_node.add(group_tree_label(group_id, group, title_limit=title_limit))
|
||||
group_nodes[group_key] = group_node
|
||||
group_node.add(tab_tree_label(tab, title_limit=title_limit, show_urls=show_urls, url_limit=url_limit))
|
||||
return root
|
||||
|
||||
def build_windows_tree(windows: Iterable[dict], tabs: Iterable[Any], *, console: Console) -> Tree:
|
||||
"""Build a window → tab tree from window and tab responses."""
|
||||
windows = list(windows)
|
||||
tabs = list(tabs)
|
||||
title_limit = tree_title_limit(console=console, show_browser=any("browser" in w for w in windows), show_urls=True)
|
||||
url_limit = tree_url_limit(title_limit, console=console)
|
||||
root = Tree("[bold]Windows[/bold]")
|
||||
for window in sorted(windows, key=lambda item: (item.get("browser", ""), item.get("id", 0))):
|
||||
window_id = window.get("id")
|
||||
label = f"Window {window_id}"
|
||||
if window.get("alias"):
|
||||
label += f" ({window['alias']})"
|
||||
if window.get("browser"):
|
||||
label = f"{window['browser']}: " + label
|
||||
node = root.add(label)
|
||||
window_tabs = [
|
||||
tab for tab in tabs
|
||||
if item_value(tab, "window_id", item_value(tab, "windowId")) == window_id
|
||||
and (not window.get("browser") or item_value(tab, "browser") == window.get("browser"))
|
||||
]
|
||||
for tab in sorted(window_tabs, key=lambda item: item_value(item, "index", 0) or 0):
|
||||
node.add(tab_tree_label(tab, title_limit=title_limit, show_urls=True, url_limit=url_limit))
|
||||
return root
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Reusable rendering helpers for CLI command modules."""
|
||||
from browser_cli.commands.rendering.common import (
|
||||
Column,
|
||||
item_value,
|
||||
print_table_rows,
|
||||
print_tree,
|
||||
shorten,
|
||||
terminal_width,
|
||||
tree_title_limit,
|
||||
tree_url_limit,
|
||||
)
|
||||
from browser_cli.commands.rendering.labels import (
|
||||
BROWSER_FAMILY_STYLES,
|
||||
DEFAULT_BROWSER_STYLE,
|
||||
DEFAULT_SCOPE,
|
||||
browser_label_style,
|
||||
group_tree_label,
|
||||
no_wrap_text,
|
||||
scoped_browser_label,
|
||||
tab_tree_label,
|
||||
)
|
||||
from browser_cli.commands.rendering.tabs_tree import (
|
||||
TabsTreeBuilder,
|
||||
browser_label_key,
|
||||
browser_scope,
|
||||
build_tabs_tree,
|
||||
tab_group_id,
|
||||
tab_sort_key,
|
||||
tab_window_id,
|
||||
)
|
||||
from browser_cli.commands.rendering.windows_tree import build_windows_tree
|
||||
|
||||
__all__ = [
|
||||
"BROWSER_FAMILY_STYLES",
|
||||
"Column",
|
||||
"DEFAULT_BROWSER_STYLE",
|
||||
"DEFAULT_SCOPE",
|
||||
"TabsTreeBuilder",
|
||||
"browser_label_key",
|
||||
"browser_label_style",
|
||||
"browser_scope",
|
||||
"build_tabs_tree",
|
||||
"build_windows_tree",
|
||||
"group_tree_label",
|
||||
"item_value",
|
||||
"no_wrap_text",
|
||||
"print_table_rows",
|
||||
"print_tree",
|
||||
"scoped_browser_label",
|
||||
"shorten",
|
||||
"tab_group_id",
|
||||
"tab_sort_key",
|
||||
"tab_tree_label",
|
||||
"tab_window_id",
|
||||
"terminal_width",
|
||||
"tree_title_limit",
|
||||
"tree_url_limit",
|
||||
]
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Common Rich rendering helpers for CLI command modules."""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from typing import TypeVar, cast
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich.tree import Tree
|
||||
|
||||
Row = object
|
||||
CellValue = object
|
||||
Column = tuple[str, Callable[[Row], CellValue]]
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def item_value(item: Row, name: str, default: T | None = None) -> CellValue | T | None:
|
||||
"""Read *name* from a dict-like or attribute object."""
|
||||
if isinstance(item, Mapping):
|
||||
return cast(Mapping[str, CellValue], item).get(name, default)
|
||||
return getattr(item, name, default)
|
||||
|
||||
def text_value(value: CellValue | None, default: str = "") -> str:
|
||||
"""Coerce a nullable cell value to display text."""
|
||||
return default if value is None else str(value)
|
||||
|
||||
def int_value(value: CellValue | None, default: int = 0) -> int:
|
||||
"""Coerce a cell value to int, falling back when conversion is not possible."""
|
||||
try:
|
||||
return int(cast(int | str | float | bool, value))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def shorten(value: str | None, limit: int) -> str:
|
||||
"""Return *value* shortened to *limit* cells-ish, using an ellipsis."""
|
||||
value = value or ""
|
||||
return value if len(value) <= limit else value[:max(0, limit - 1)] + "…"
|
||||
|
||||
def terminal_width(console: Console | None = None, *, fallback: int = 120) -> int:
|
||||
"""Best-effort terminal width for interactive and redirected output.
|
||||
|
||||
Rich falls back to 80 columns when stdout is redirected. browser-cli output is
|
||||
often piped into files for inspection, so also consult ``shutil``/``COLUMNS``
|
||||
and prefer the wider value.
|
||||
"""
|
||||
rich_width = (console.width if console is not None else 0) or 0
|
||||
shell_width = shutil.get_terminal_size((fallback, 20)).columns
|
||||
return max(rich_width, shell_width)
|
||||
|
||||
def tree_title_limit(*, console: Console | None = None, show_browser: bool = False, show_urls: bool = False) -> int:
|
||||
"""Title width for tree labels, reserving space for branches/IDs/metadata."""
|
||||
reserve = 48 if show_urls else 32
|
||||
if show_browser:
|
||||
reserve += 4
|
||||
return max(50, terminal_width(console) - reserve)
|
||||
|
||||
def tree_url_limit(title_limit: int, *, console: Console | None = None) -> int:
|
||||
"""URL width for tree labels when URLs are displayed."""
|
||||
return max(35, terminal_width(console) - title_limit - 40)
|
||||
|
||||
def print_tree(tree: Tree, *, console: Console | None = None) -> None:
|
||||
"""Render a Rich tree using the detected full terminal width."""
|
||||
Console(width=terminal_width(console)).print(tree)
|
||||
|
||||
def print_table_rows(
|
||||
rows: Sequence[Row],
|
||||
columns: Sequence[Column],
|
||||
*,
|
||||
console: Console,
|
||||
empty_message: str,
|
||||
show_header: bool = True,
|
||||
header_style: str = "bold cyan",
|
||||
) -> None:
|
||||
"""Render a small Rich table from arbitrary row objects."""
|
||||
if not rows:
|
||||
console.print(empty_message)
|
||||
return
|
||||
table = Table(show_header=show_header, header_style=header_style)
|
||||
for header, _getter in columns:
|
||||
table.add_column(header)
|
||||
for row in rows:
|
||||
table.add_row(*[text_value(getter(row)) for _header, getter in columns])
|
||||
Console(width=terminal_width(console)).print(table)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Rich label helpers for tab/window tree renderers."""
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.text import Text
|
||||
|
||||
from browser_cli.commands.rendering.common import Row, int_value, item_value, shorten, text_value
|
||||
|
||||
BROWSER_FAMILY_STYLES = {
|
||||
"firefox": "orange1",
|
||||
"chrome": "cyan",
|
||||
"chromium": "cyan",
|
||||
"brave": "cyan",
|
||||
"edge": "cyan",
|
||||
"vivaldi": "cyan",
|
||||
}
|
||||
DEFAULT_SCOPE = "local"
|
||||
DEFAULT_BROWSER_STYLE = "bold cyan"
|
||||
|
||||
def no_wrap_text() -> Text:
|
||||
"""Text configured for one-line tree labels with edge ellipsis."""
|
||||
return Text(no_wrap=True, overflow="ellipsis")
|
||||
|
||||
def tab_tree_label(tab: Row, *, title_limit: int, show_urls: bool = False, url_limit: int = 55) -> Text:
|
||||
"""Reusable one-line label for a browser tab in tree views."""
|
||||
label = no_wrap_text()
|
||||
label.append(f"[{text_value(item_value(tab, 'id'))}] ", style="dim")
|
||||
label.append(shorten(text_value(item_value(tab, 'title'), "(untitled)") or "(untitled)", title_limit))
|
||||
if bool(item_value(tab, "active", False)):
|
||||
label.append(" *", style="green")
|
||||
url = text_value(item_value(tab, "url"))
|
||||
if show_urls and url:
|
||||
label.append(" — ", style="dim")
|
||||
label.append(shorten(url, url_limit), style="dim")
|
||||
return label
|
||||
|
||||
def group_tree_label(group_id: object, group: Row | None, *, title_limit: int) -> Text:
|
||||
"""Reusable one-line label for a browser tab group in tree views."""
|
||||
title = text_value(item_value(group, "title", "") if group is not None else "") or f"Group {group_id}"
|
||||
color = text_value(item_value(group, "color", "") if group is not None else "") or "group"
|
||||
count = int_value(item_value(group, "tab_count", item_value(group, "tabCount", 0)) if group is not None else 0)
|
||||
collapsed = bool(item_value(group, "collapsed", False)) if group is not None else False
|
||||
label = no_wrap_text()
|
||||
label.append(shorten(title, title_limit), style="bold")
|
||||
meta = [color]
|
||||
if count:
|
||||
meta.append(f"{count} tab" + ("" if count == 1 else "s"))
|
||||
if collapsed:
|
||||
meta.append("collapsed")
|
||||
label.append(" (" + ", ".join(meta) + ")", style="dim")
|
||||
return label
|
||||
|
||||
def browser_label_style(browser_name: str | None) -> str:
|
||||
"""Return a Rich style for a browser family label."""
|
||||
name = (browser_name or "").lower()
|
||||
for family, style in BROWSER_FAMILY_STYLES.items():
|
||||
if family in name:
|
||||
return style
|
||||
return DEFAULT_BROWSER_STYLE
|
||||
|
||||
def scoped_browser_label(browser: str, scope: str, *, grouped: bool) -> str:
|
||||
"""Shorten browser labels under a remote/local group node."""
|
||||
prefix = f"{scope}:"
|
||||
return browser[len(prefix):] if grouped and browser.startswith(prefix) else browser
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Tabs tree renderer."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
from rich.console import Console
|
||||
from rich.text import Text
|
||||
from rich.tree import Tree
|
||||
|
||||
from browser_cli.commands.rendering.common import Row, int_value, item_value, text_value, tree_title_limit, tree_url_limit
|
||||
from browser_cli.commands.rendering.labels import (
|
||||
DEFAULT_BROWSER_STYLE,
|
||||
DEFAULT_SCOPE,
|
||||
browser_label_style,
|
||||
group_tree_label,
|
||||
scoped_browser_label,
|
||||
tab_tree_label,
|
||||
)
|
||||
|
||||
GroupId = object
|
||||
GroupKey = tuple[str, str, int | None, GroupId]
|
||||
TreeNodeKey = tuple[str, str]
|
||||
WindowNodeKey = tuple[str, str, int]
|
||||
BrowserGroupNodeKey = tuple[str, str, int, GroupId]
|
||||
|
||||
def browser_scope(item: Row) -> str:
|
||||
"""Return the remote/local scope key used by tree renderers."""
|
||||
return text_value(item_value(item, "browser_group")) or DEFAULT_SCOPE
|
||||
|
||||
def browser_label_key(item: Row) -> str:
|
||||
"""Return the browser/profile key used by tree renderers."""
|
||||
return text_value(item_value(item, "browser")) or DEFAULT_SCOPE
|
||||
|
||||
def tab_window_id(tab: Row) -> int:
|
||||
"""Return a stable window id from object or dict-shaped tab responses."""
|
||||
return int_value(item_value(tab, "window_id", item_value(tab, "windowId", 0)))
|
||||
|
||||
def tab_group_id(tab: Row) -> GroupId | None:
|
||||
"""Return a tab group id from object or dict-shaped tab responses."""
|
||||
group_id = item_value(tab, "group_id", item_value(tab, "groupId"))
|
||||
return None if group_id is None else group_id
|
||||
|
||||
def tab_sort_key(tab: Row) -> tuple[str, str, int, int, int, int]:
|
||||
"""Stable tab ordering across multi-browser responses."""
|
||||
group_id = tab_group_id(tab)
|
||||
return (
|
||||
browser_scope(tab),
|
||||
browser_label_key(tab),
|
||||
tab_window_id(tab),
|
||||
int_value(item_value(tab, "index", 0)),
|
||||
int_value(group_id, -1) if group_id is not None else -1,
|
||||
int_value(item_value(tab, "id", 0)),
|
||||
)
|
||||
|
||||
class TabsTreeBuilder:
|
||||
"""Stateful builder for the browser tabs tree.
|
||||
|
||||
The tree has optional scope nodes (remote host/local), then browser/profile,
|
||||
then window, then browser tab-groups/tabs. Keeping this state in a helper
|
||||
keeps ``build_tabs_tree`` small while preserving stable node reuse.
|
||||
"""
|
||||
|
||||
tabs: list[Row]
|
||||
groups: list[Row]
|
||||
show_urls: bool
|
||||
show_browser: bool
|
||||
group_by_scope: bool
|
||||
title_limit: int
|
||||
url_limit: int
|
||||
root: Tree
|
||||
group_info: dict[GroupKey, Row]
|
||||
browser_styles: dict[str, str]
|
||||
scope_nodes: dict[str, Tree]
|
||||
browser_nodes: dict[TreeNodeKey, Tree]
|
||||
window_nodes: dict[WindowNodeKey, Tree]
|
||||
group_nodes: dict[BrowserGroupNodeKey, Tree]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tabs: Iterable[Row],
|
||||
groups: Iterable[Row],
|
||||
*,
|
||||
console: Console,
|
||||
show_urls: bool = False,
|
||||
):
|
||||
self.tabs = sorted(tabs, key=tab_sort_key)
|
||||
self.groups = list(groups)
|
||||
self.show_urls = show_urls
|
||||
self.show_browser = any(bool(item_value(tab, "browser")) for tab in self.tabs)
|
||||
self.group_by_scope = any(bool(item_value(item, "browser_group")) for item in self.tabs + self.groups)
|
||||
self.title_limit = tree_title_limit(console=console, show_browser=self.show_browser, show_urls=show_urls)
|
||||
self.url_limit = tree_url_limit(self.title_limit, console=console)
|
||||
self.root = Tree("[bold]Tabs[/bold]")
|
||||
self.group_info = self._group_info()
|
||||
self.browser_styles = self._browser_styles()
|
||||
self.scope_nodes = {}
|
||||
self.browser_nodes = {}
|
||||
self.window_nodes = {}
|
||||
self.group_nodes = {}
|
||||
|
||||
def build(self) -> Tree:
|
||||
for tab in self.tabs:
|
||||
self._add_tab(tab)
|
||||
return self.root
|
||||
|
||||
def _group_info(self) -> dict[GroupKey, Row]:
|
||||
return {
|
||||
(
|
||||
browser_scope(group),
|
||||
browser_label_key(group),
|
||||
int_value(item_value(group, "window_id", item_value(group, "windowId")), 0),
|
||||
item_value(group, "id"),
|
||||
): group
|
||||
for group in self.groups
|
||||
}
|
||||
|
||||
def _browser_styles(self) -> dict[str, str]:
|
||||
styles: dict[str, str] = {}
|
||||
for item in self.tabs + self.groups:
|
||||
key = browser_label_key(item)
|
||||
styles.setdefault(key, browser_label_style(text_value(item_value(item, "browser_name")) or None))
|
||||
return styles
|
||||
|
||||
def _scope_node(self, scope: str) -> Tree:
|
||||
if not self.group_by_scope:
|
||||
return self.root
|
||||
node = self.scope_nodes.get(scope)
|
||||
if node is None:
|
||||
node = self.root.add(Text(scope, style="bold"))
|
||||
self.scope_nodes[scope] = node
|
||||
return node
|
||||
|
||||
def _browser_node(self, scope: str, browser: str) -> Tree:
|
||||
key = (scope, browser)
|
||||
node = self.browser_nodes.get(key)
|
||||
if node is None:
|
||||
parent = self._scope_node(scope)
|
||||
if self.show_browser:
|
||||
label = scoped_browser_label(browser, scope, grouped=self.group_by_scope)
|
||||
node = parent.add(Text(label, style=self.browser_styles.get(browser, DEFAULT_BROWSER_STYLE)))
|
||||
else:
|
||||
node = parent
|
||||
self.browser_nodes[key] = node
|
||||
return node
|
||||
|
||||
def _window_node(self, scope: str, browser: str, window_id: int) -> Tree:
|
||||
key = (scope, browser, window_id)
|
||||
node = self.window_nodes.get(key)
|
||||
if node is None:
|
||||
node = self._browser_node(scope, browser).add(f"Window {window_id}")
|
||||
self.window_nodes[key] = node
|
||||
return node
|
||||
|
||||
def _group_node(self, scope: str, browser: str, window_id: int, group_id: GroupId, parent: Tree) -> Tree:
|
||||
key = (scope, browser, window_id, group_id)
|
||||
node = self.group_nodes.get(key)
|
||||
if node is None:
|
||||
group = self.group_info.get(key) or self.group_info.get((scope, browser, None, group_id))
|
||||
node = parent.add(group_tree_label(group_id, group, title_limit=self.title_limit))
|
||||
self.group_nodes[key] = node
|
||||
return node
|
||||
|
||||
def _add_tab(self, tab: Row) -> None:
|
||||
scope = browser_scope(tab)
|
||||
browser = browser_label_key(tab)
|
||||
window_id = tab_window_id(tab)
|
||||
window_node = self._window_node(scope, browser, window_id)
|
||||
group_id = tab_group_id(tab)
|
||||
parent = window_node if group_id is None else self._group_node(scope, browser, window_id, group_id, window_node)
|
||||
parent.add(tab_tree_label(
|
||||
tab,
|
||||
title_limit=self.title_limit,
|
||||
show_urls=self.show_urls,
|
||||
url_limit=self.url_limit,
|
||||
))
|
||||
|
||||
def build_tabs_tree(
|
||||
tabs: Iterable[Row],
|
||||
groups: Iterable[Row],
|
||||
*,
|
||||
console: Console,
|
||||
show_urls: bool = False,
|
||||
) -> Tree:
|
||||
"""Build a remote/local → browser → window → group/tab tree from tab responses."""
|
||||
return TabsTreeBuilder(tabs, groups, console=console, show_urls=show_urls).build()
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Windows tree renderer."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping
|
||||
|
||||
from rich.console import Console
|
||||
from rich.tree import Tree
|
||||
|
||||
from browser_cli.commands.rendering.common import Row, int_value, item_value, text_value, tree_title_limit, tree_url_limit
|
||||
from browser_cli.commands.rendering.labels import tab_tree_label
|
||||
|
||||
WindowRow = Mapping[str, object]
|
||||
|
||||
def build_windows_tree(windows: Iterable[WindowRow], tabs: Iterable[Row], *, console: Console) -> Tree:
|
||||
"""Build a window → tab tree from window and tab responses."""
|
||||
windows = list(windows)
|
||||
tabs = list(tabs)
|
||||
title_limit = tree_title_limit(console=console, show_browser=any("browser" in w for w in windows), show_urls=True)
|
||||
url_limit = tree_url_limit(title_limit, console=console)
|
||||
root = Tree("[bold]Windows[/bold]")
|
||||
for window in sorted(windows, key=lambda item: (text_value(item.get("browser")), int_value(item.get("id")))):
|
||||
window_id = int_value(window.get("id"))
|
||||
label = f"Window {window_id}"
|
||||
alias = text_value(window.get("alias"))
|
||||
browser = text_value(window.get("browser"))
|
||||
if alias:
|
||||
label += f" ({alias})"
|
||||
if browser:
|
||||
label = f"{browser}: " + label
|
||||
node = root.add(label)
|
||||
window_tabs = [
|
||||
tab for tab in tabs
|
||||
if int_value(item_value(tab, "window_id", item_value(tab, "windowId"))) == window_id
|
||||
and (not browser or text_value(item_value(tab, "browser")) == browser)
|
||||
]
|
||||
for tab in sorted(window_tabs, key=lambda item: int_value(item_value(item, "index", 0))):
|
||||
node.add(tab_tree_label(tab, title_limit=title_limit, show_urls=True, url_limit=url_limit))
|
||||
return root
|
||||
@@ -46,6 +46,8 @@ class Tab:
|
||||
group_id: int | None = None
|
||||
index: int = 0
|
||||
browser: str | None = None
|
||||
browser_name: str | None = None
|
||||
browser_group: str | None = None
|
||||
_browser: BoundBrowser | None = field(default=None, repr=False, compare=False, init=False)
|
||||
|
||||
def _b(self) -> BoundBrowser:
|
||||
@@ -152,6 +154,8 @@ class Group:
|
||||
tab_count: int
|
||||
window_id: int | None = None
|
||||
browser: str | None = None
|
||||
browser_name: str | None = None
|
||||
browser_group: str | None = None
|
||||
_browser: BoundBrowser | None = field(default=None, repr=False, compare=False, init=False)
|
||||
|
||||
def _b(self) -> BoundBrowser:
|
||||
|
||||
@@ -28,6 +28,8 @@ class FactoryMixin:
|
||||
browser_profile: str | None = None,
|
||||
browser_name: str | None = None,
|
||||
browser_remote: str | None = None,
|
||||
browser_type: str | None = None,
|
||||
browser_group: str | None = None,
|
||||
) -> Tab:
|
||||
tab = Tab(
|
||||
id=data["id"],
|
||||
@@ -39,6 +41,8 @@ class FactoryMixin:
|
||||
group_id=data.get("groupId") or None,
|
||||
index=data.get("index", 0) or 0,
|
||||
browser=browser_name,
|
||||
browser_name=browser_type,
|
||||
browser_group=browser_group,
|
||||
)
|
||||
client = cast(_FactoryClient, self)
|
||||
tab._browser = self if browser_profile is None else cast(Any, type(self))(
|
||||
@@ -62,6 +66,8 @@ class FactoryMixin:
|
||||
browser_profile: str | None = None,
|
||||
browser_name: str | None = None,
|
||||
browser_remote: str | None = None,
|
||||
browser_type: str | None = None,
|
||||
browser_group: str | None = None,
|
||||
) -> Group:
|
||||
group = Group(
|
||||
id=data["id"],
|
||||
@@ -71,6 +77,8 @@ class FactoryMixin:
|
||||
tab_count=data.get("tabCount", 0),
|
||||
window_id=data.get("windowId"),
|
||||
browser=browser_name,
|
||||
browser_name=browser_type,
|
||||
browser_group=browser_group,
|
||||
)
|
||||
client = cast(_FactoryClient, self)
|
||||
group._browser = self if browser_profile is None else cast(Any, type(self))(
|
||||
@@ -88,6 +96,8 @@ class FactoryMixin:
|
||||
browser_profile=target.profile if target else None,
|
||||
browser_name=target.display_name if target else None,
|
||||
browser_remote=target.remote if target else None,
|
||||
browser_type=getattr(target, "browser_name", None) if target else None,
|
||||
browser_group=getattr(target, "display_group", None) if target else None,
|
||||
)
|
||||
|
||||
def group_from_target(self, data: dict, target) -> Group:
|
||||
@@ -97,6 +107,8 @@ class FactoryMixin:
|
||||
browser_profile=target.profile if target else None,
|
||||
browser_name=target.display_name if target else None,
|
||||
browser_remote=target.remote if target else None,
|
||||
browser_type=getattr(target, "browser_name", None) if target else None,
|
||||
browser_group=getattr(target, "display_group", None) if target else None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -37,6 +37,20 @@ _UNSET = object()
|
||||
def _browser_cli_package():
|
||||
return sys.modules.get("browser_cli") or importlib.import_module("browser_cli")
|
||||
|
||||
def _with_profile_display(targets: list[BrowserTarget]) -> list[BrowserTarget]:
|
||||
"""Use profile-only labels when a command is already scoped to one remote."""
|
||||
return [
|
||||
BrowserTarget(
|
||||
profile=target.profile,
|
||||
display_name=target.profile if target.remote else target.display_name,
|
||||
socket_path=target.socket_path,
|
||||
remote=target.remote,
|
||||
browser_name=target.browser_name,
|
||||
display_group=None,
|
||||
)
|
||||
for target in targets
|
||||
]
|
||||
|
||||
class RoutingMixin:
|
||||
"""Fan-out + aggregation across active browsers, mixed into ``BrowserCLI``.
|
||||
|
||||
@@ -51,10 +65,15 @@ class RoutingMixin:
|
||||
def _multi_browser_targets(self) -> list[BrowserTarget]:
|
||||
client = self._client
|
||||
package = _browser_cli_package()
|
||||
if client._browser is not None:
|
||||
if client._browser is not None and not client._remote:
|
||||
targets = package.remote_targets_for_alias(client._browser, key=client._key)
|
||||
if len(targets) <= 1:
|
||||
return []
|
||||
targets = _with_profile_display(targets)
|
||||
elif client._browser is not None:
|
||||
return []
|
||||
if client._remote:
|
||||
targets = package.remote_browser_targets(client._remote, key=client._key)
|
||||
elif client._remote:
|
||||
targets = _with_profile_display(package.remote_browser_targets(client._remote, key=client._key))
|
||||
else:
|
||||
targets = package.active_browser_targets()
|
||||
if len(targets) <= 1 and not any(target.remote for target in targets):
|
||||
|
||||
@@ -16,11 +16,19 @@ class ServeControlMixin:
|
||||
|
||||
async def handle_control_command(self, msg: dict) -> bool:
|
||||
if self.command == "browser-cli.targets":
|
||||
from browser_cli.client import active_browser_targets
|
||||
targets = [
|
||||
{"profile": target.profile, "displayName": target.display_name}
|
||||
for target in active_browser_targets(include_remotes=False)
|
||||
]
|
||||
from browser_cli.client import active_browser_targets, send_command
|
||||
targets = []
|
||||
for target in active_browser_targets(include_remotes=False):
|
||||
item = {"profile": target.profile, "displayName": target.display_name}
|
||||
try:
|
||||
clients = send_command("clients.list", profile=target.profile, suppress_pq_warning=True)
|
||||
if clients:
|
||||
browser_name = clients[0].get("name")
|
||||
if browser_name:
|
||||
item["browserName"] = browser_name
|
||||
except Exception:
|
||||
pass
|
||||
targets.append(item)
|
||||
await self.send_ok(targets, self.command)
|
||||
log_request(self.addr, self.command, None, "OK")
|
||||
return True
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "browser-cli",
|
||||
"version": "0.15.4",
|
||||
"version": "0.15.5",
|
||||
"description": "Control your browser from the terminal or Python SDK",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "real-browser-cli"
|
||||
version = "0.15.4"
|
||||
version = "0.15.5"
|
||||
description = "Control your real running browser from the terminal or Python SDK"
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
|
||||
+24
-2
@@ -470,7 +470,7 @@ class TestTabs:
|
||||
tabs = b.tabs.list()
|
||||
tabs[0].close()
|
||||
|
||||
assert [tab.browser for tab in tabs] == ["host:work"]
|
||||
assert [tab.browser for tab in tabs] == ["work"]
|
||||
assert mock_send.call_args_list == [
|
||||
call("tabs.list", {}, profile="work", remote="host:8765", key=None),
|
||||
call("tabs.close", {"tabId": 10}, profile="work", remote="host:8765", key=None),
|
||||
@@ -491,6 +491,28 @@ class TestTabs:
|
||||
call("tabs.close", {"tabId": 10}, profile="work", remote="browser-host.example", key="agent"),
|
||||
]
|
||||
|
||||
def test_tabs_list_browser_host_alias_fans_out_to_remote_targets(self, mock_send):
|
||||
b = BrowserCLI(browser="browser-host.example", key="agent")
|
||||
with patch(
|
||||
"browser_cli.remote_targets_for_alias",
|
||||
return_value=[
|
||||
BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", browser_name="Chrome"),
|
||||
BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", browser_name="Firefox"),
|
||||
],
|
||||
):
|
||||
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] == ["main", "work"]
|
||||
assert [tab.browser_name for tab in tabs] == ["Chrome", "Firefox"]
|
||||
assert [tab.browser_group for tab in tabs] == [None, None]
|
||||
assert mock_send.call_args_list == [
|
||||
call("tabs.list", {}, profile="main", remote="browser-host.example:8765", key="agent"),
|
||||
call("tabs.list", {}, profile="work", remote="browser-host.example:8765", key="agent"),
|
||||
call("tabs.close", {"tabId": 11}, profile="work", remote="browser-host.example:8765", key="agent"),
|
||||
]
|
||||
|
||||
def test_tabs_active_returns_active_tab(self, b, mock_send):
|
||||
mock_send.side_effect = [[TAB_DATA], TAB_DATA]
|
||||
|
||||
@@ -659,7 +681,7 @@ class TestGroups:
|
||||
groups = b.groups.list()
|
||||
groups[0].close()
|
||||
|
||||
assert [group.browser for group in groups] == ["host:work"]
|
||||
assert [group.browser for group in groups] == ["work"]
|
||||
assert mock_send.call_args_list == [
|
||||
call("group.list", {}, profile="work", remote="host:8765", key=None),
|
||||
call("group.close", {"groupId": 42}, profile="work", remote="host:8765", key=None),
|
||||
|
||||
+78
-2
@@ -4,7 +4,7 @@ import os
|
||||
import sys
|
||||
|
||||
from click.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import call, patch
|
||||
|
||||
from browser_cli.cli import main, _project_version
|
||||
from browser_cli.client import BrowserTarget
|
||||
@@ -379,7 +379,8 @@ def test_tabs_list_with_remote_uses_only_remote_targets():
|
||||
result = CliRunner().invoke(main, ["--remote", "remote-host:8765", "tabs", "list"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "remote-host:work" in result.output
|
||||
assert "work" in result.output
|
||||
assert "remote-host:work" not in result.output
|
||||
assert "Remote" in result.output
|
||||
send_command.assert_called_once_with("tabs.list", {}, profile="work", remote="remote-host:8765", key=None)
|
||||
|
||||
@@ -400,6 +401,81 @@ def test_tabs_list_with_explicit_browser_does_not_show_browser_column():
|
||||
assert "Browser" not in result.output
|
||||
send_command.assert_called_once_with("tabs.list", {}, profile="work", remote=None, key=None)
|
||||
|
||||
def test_tabs_tree_with_browser_host_alias_fans_out_to_remote_targets():
|
||||
targets = [
|
||||
BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", browser_name="Chrome"),
|
||||
BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", browser_name="Firefox"),
|
||||
]
|
||||
|
||||
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
|
||||
assert remote == "browser-host.example:8765"
|
||||
if command == "tabs.list":
|
||||
return [{
|
||||
"id": 1 if profile == "main" else 2,
|
||||
"windowId": 1,
|
||||
"active": True,
|
||||
"index": 0,
|
||||
"title": f"{profile} tab",
|
||||
"url": "https://example.com",
|
||||
}]
|
||||
if command == "group.list":
|
||||
return []
|
||||
raise AssertionError(command)
|
||||
|
||||
with patch("browser_cli.remote_targets_for_alias", return_value=targets), patch(
|
||||
"browser_cli.send_command", side_effect=fake_send_command
|
||||
) as send_command:
|
||||
result = CliRunner().invoke(main, ["--browser", "browser-host.example", "tabs", "tree"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "main" in result.output
|
||||
assert "work" in result.output
|
||||
assert "browser-host.example:main" not in result.output
|
||||
assert "browser-host.example:work" not in result.output
|
||||
assert "main tab" in result.output
|
||||
assert "work tab" in result.output
|
||||
assert send_command.call_args_list == [
|
||||
call("tabs.list", {}, profile="main", remote="browser-host.example:8765", key=None),
|
||||
call("tabs.list", {}, profile="work", remote="browser-host.example:8765", key=None),
|
||||
call("group.list", {}, profile="main", remote="browser-host.example:8765", key=None),
|
||||
call("group.list", {}, profile="work", remote="browser-host.example:8765", key=None),
|
||||
]
|
||||
|
||||
def test_tabs_tree_unscoped_groups_remote_targets_by_host():
|
||||
targets = [
|
||||
BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", display_group="browser-host.example"),
|
||||
BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", display_group="browser-host.example"),
|
||||
]
|
||||
|
||||
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
|
||||
assert remote == "browser-host.example:8765"
|
||||
if command == "tabs.list":
|
||||
return [{
|
||||
"id": 1 if profile == "main" else 2,
|
||||
"windowId": 1,
|
||||
"active": True,
|
||||
"index": 0,
|
||||
"title": f"{profile} tab",
|
||||
"url": "https://example.com",
|
||||
}]
|
||||
if command == "group.list":
|
||||
return []
|
||||
raise AssertionError(command)
|
||||
|
||||
with patch("browser_cli.active_browser_targets", return_value=targets), patch(
|
||||
"browser_cli.send_command", side_effect=fake_send_command
|
||||
):
|
||||
result = CliRunner().invoke(main, ["tabs", "tree"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "browser-host.example" in result.output
|
||||
assert "main" in result.output
|
||||
assert "work" in result.output
|
||||
assert "browser-host.example:main" not in result.output
|
||||
assert "browser-host.example:work" not in result.output
|
||||
assert "main tab" in result.output
|
||||
assert "work tab" in result.output
|
||||
|
||||
def test_tabs_count_multi_browser_shows_total():
|
||||
counts = {"default": 3, "work": 4}
|
||||
|
||||
|
||||
@@ -342,7 +342,7 @@ def test_active_browser_targets_includes_remote_targets(monkeypatch, tmp_path):
|
||||
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
|
||||
assert command == "browser-cli.targets"
|
||||
assert remote == endpoint
|
||||
return [{"profile": "work", "displayName": "work"}]
|
||||
return [{"profile": "work", "displayName": "work", "browserName": "Firefox"}]
|
||||
|
||||
monkeypatch.setattr("browser_cli.client.core.send_command", fake_send_command)
|
||||
|
||||
@@ -352,6 +352,8 @@ def test_active_browser_targets_includes_remote_targets(monkeypatch, tmp_path):
|
||||
assert targets[0].profile == "work"
|
||||
assert targets[0].display_name == "browser-host.example:work"
|
||||
assert targets[0].remote == endpoint
|
||||
assert targets[0].browser_name == "Firefox"
|
||||
assert targets[0].display_group == "browser-host.example"
|
||||
|
||||
def test_looks_like_domain():
|
||||
assert _looks_like_domain("browsercli.yiprawr.dev") is True
|
||||
|
||||
+29
-3
@@ -5,15 +5,26 @@ from rich.tree import Tree
|
||||
|
||||
from browser_cli.models import Tab
|
||||
from browser_cli.commands import rendering
|
||||
from browser_cli.commands.rendering import common
|
||||
|
||||
def test_shorten_uses_ellipsis():
|
||||
assert rendering.shorten("abcdef", 4) == "abc…"
|
||||
assert rendering.shorten("abc", 4) == "abc"
|
||||
|
||||
def test_terminal_width_prefers_shell_width_when_rich_is_redirected(monkeypatch):
|
||||
monkeypatch.setattr(rendering.shutil, "get_terminal_size", lambda fallback: terminal_size((140, 20)))
|
||||
monkeypatch.setattr(common.shutil, "get_terminal_size", lambda fallback: terminal_size((140, 20)))
|
||||
assert rendering.terminal_width(Console(width=80)) == 140
|
||||
|
||||
def test_browser_label_style_distinguishes_browser_families():
|
||||
assert rendering.browser_label_style("Firefox") == "orange1"
|
||||
assert rendering.browser_label_style("Chrome") == "cyan"
|
||||
assert rendering.browser_label_style(None) == "bold cyan"
|
||||
|
||||
def test_scoped_browser_label_strips_repeated_remote_prefix():
|
||||
assert rendering.scoped_browser_label("browser-host.example:work", "browser-host.example", grouped=True) == "work"
|
||||
assert rendering.scoped_browser_label("work", "browser-host.example", grouped=True) == "work"
|
||||
assert rendering.scoped_browser_label("browser-host.example:work", "browser-host.example", grouped=False) == "browser-host.example:work"
|
||||
|
||||
def test_tab_tree_label_is_reusable_no_wrap_text():
|
||||
tab = type("Tab", (), {"id": 1, "title": "abcdef", "active": True, "url": "https://example.com"})()
|
||||
label = rendering.tab_tree_label(tab, title_limit=4, show_urls=True, url_limit=12)
|
||||
@@ -29,8 +40,8 @@ def test_print_tree_uses_detected_width(monkeypatch):
|
||||
widths.append(kwargs.get("width"))
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(rendering, "Console", CapturingConsole)
|
||||
monkeypatch.setattr(rendering, "terminal_width", lambda console=None: 132)
|
||||
monkeypatch.setattr(common, "Console", CapturingConsole)
|
||||
monkeypatch.setattr(common, "terminal_width", lambda console=None: 132)
|
||||
rendering.print_tree(Tree("Root"))
|
||||
assert widths == [132]
|
||||
|
||||
@@ -48,6 +59,21 @@ def test_build_tabs_tree_groups_by_browser_window_and_group():
|
||||
assert "collapsed" in text
|
||||
assert "Inside" in text
|
||||
|
||||
def test_build_tabs_tree_groups_remote_browsers_by_scope():
|
||||
tabs = [
|
||||
Tab(id=1, window_id=5, active=False, muted=False, title="Remote A", url="https://example.com/a", index=0, browser="main", browser_group="browser-host.example"),
|
||||
Tab(id=2, window_id=6, active=False, muted=False, title="Remote B", url="https://example.com/b", index=0, browser="work", browser_group="browser-host.example"),
|
||||
Tab(id=3, window_id=7, active=False, muted=False, title="Local", url="https://example.com/local", index=0, browser="local"),
|
||||
]
|
||||
tree = rendering.build_tabs_tree(tabs, [], console=Console(width=120))
|
||||
text = "\n".join(str(line) for line in tree.__rich_console__(Console(width=120), Console(width=120).options))
|
||||
assert "browser-host.example" in text
|
||||
assert "main" in text
|
||||
assert "work" in text
|
||||
assert "browser-host.example:main" not in text
|
||||
assert "browser-host.example:work" not in text
|
||||
assert "Local" in text
|
||||
|
||||
def test_build_windows_tree_keeps_multi_browser_windows_separate():
|
||||
tabs = [
|
||||
Tab(id=1, window_id=5, active=False, muted=False, title="Work Tab", url="https://example.com/work", index=0, browser="work"),
|
||||
|
||||
Reference in New Issue
Block a user