Compare commits

...

2 Commits

Author SHA1 Message Date
daniel156161 479a0f1964 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
- 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.
2026-06-18 00:12:17 +02:00
daniel156161 371b794170 chore: prepare verified CRX uploads and release 0.15.4
Testing / remote-protocol-compat (0.9.5) (push) Successful in 36s
Package Extension / package-extension (push) Successful in 33s
Build & Publish Package / publish (push) Successful in 31s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 32s
Testing / test (push) Successful in 36s
- Add helper scripts for Chrome Web Store verified CRX uploads using a dedicated RSA upload key protected by GPG.
- Document the verified upload packaging flow and ignore local signing secrets.
- Add npm packaging entry point for signed webstore CRX artifacts.
- Chunk large SDK tab close batches to avoid native-host response timeouts.
- Bump project and extension versions to 0.15.4 with matching tests.
2026-06-17 16:54:20 +02:00
28 changed files with 916 additions and 225 deletions
+5
View File
@@ -5,6 +5,11 @@ extension/test-dist/
node_modules/ node_modules/
dist/ dist/
# Local secrets / signing keys
secrets/
*.pem
*.pem.gpg
# Python # Python
__pycache__/ __pycache__/
*.pyc *.pyc
+13 -4
View File
@@ -515,12 +515,21 @@ The extension source lives in `extension/src/`. `extension/background.js` and `e
Packaging: Packaging:
```bash ```bash
npm run package:extension # testing/unpacked zip, keeps manifest.key for stable Chromium native-messaging ID npm run package:extension # testing/unpacked zip, keeps manifest.key for stable Chromium native-messaging ID
npm run package:extension:webstore # Chrome Web Store zip, strips manifest.key npm run package:extension:webstore # Chrome Web Store zip, strips manifest.key
npm run package:extension:firefox # Firefox zip, strips manifest.key and Firefox-incompatible permissions npm run package:extension:webstore:verified # Chrome Web Store CRX signed for verified uploads
npm run package:extension:firefox # Firefox zip, strips manifest.key and Firefox-incompatible permissions
``` ```
Chrome Web Store rejects `manifest.key`, so upload the `*-webstore-*` zip from `dist/`. For Firefox, use the `*-firefox-*` zip. Chrome Web Store rejects `manifest.key`, so upload the `*-webstore-*` zip from `dist/`. For verified CRX uploads, create a dedicated RSA upload key once and protect it with your GPG key:
```bash
scripts/setup_verified_crx_key.sh --recipient '<your GPG key id or email>'
# Add the generated public key in Chrome Developer Dashboard -> Package -> Verified uploads.
npm run package:extension:webstore:verified
```
The verified-upload private key is not a GPG key; Chrome requires an RSA CRX signing key. GPG is used here to encrypt that RSA private key at rest. The signed `*.crx` from `dist/` is the upload artifact after verified uploads are enabled. For Firefox, use the `*-firefox-*` zip.
For Firefox temporary testing via `about:debugging#/runtime/this-firefox`, run `npm run package:extension:firefox` first and load `dist/extension-package-firefox/manifest.json`. Do **not** load `extension/manifest.json` directly: it is the Chromium MV3 manifest and Firefox currently rejects `background.service_worker` for temporary add-ons. For Firefox temporary testing via `about:debugging#/runtime/this-firefox`, run `npm run package:extension:firefox` first and load `dist/extension-package-firefox/manifest.json`. Do **not** load `extension/manifest.json` directly: it is the Chromium MV3 manifest and Firefox currently rejects `background.service_worker` for temporary add-ons.
+1 -1
View File
@@ -36,7 +36,7 @@ Commands are grouped into namespaces on the client:
""" """
from collections.abc import Callable 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.errors import BrowserNotConnected
from browser_cli.models import BrowserCounts, Group, Tab from browser_cli.models import BrowserCounts, Group, Tab
from browser_cli.sdk import ( from browser_cli.sdk import (
+24 -2
View File
@@ -220,18 +220,40 @@ class AsyncBrowserCLI:
async def clients(self) -> list[dict]: async def clients(self) -> list[dict]:
return await self._cmd("clients.list", {}) 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( return self._sync.tab_from(
data, data,
browser_profile=browser_profile, browser_profile=browser_profile,
browser_name=browser_name, browser_name=browser_name,
browser_remote=browser_remote, 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( return self._sync.group_from(
data, data,
browser_profile=browser_profile, browser_profile=browser_profile,
browser_name=browser_name, browser_name=browser_name,
browser_remote=browser_remote, browser_remote=browser_remote,
browser_type=browser_type,
browser_group=browser_group,
) )
+2
View File
@@ -10,6 +10,7 @@ from browser_cli.client.core import (
remote_browser_targets, remote_browser_targets,
remote_browser_targets_async, remote_browser_targets_async,
remote_target_for_alias, remote_target_for_alias,
remote_targets_for_alias,
send_command, send_command,
send_command_async, send_command_async,
) )
@@ -42,6 +43,7 @@ __all__ = [
"remote_browser_targets", "remote_browser_targets",
"remote_browser_targets_async", "remote_browser_targets_async",
"remote_target_for_alias", "remote_target_for_alias",
"remote_targets_for_alias",
"send_command", "send_command",
"send_command_async", "send_command_async",
] ]
+27 -12
View File
@@ -25,12 +25,16 @@ def _remote_target_items(endpoint: str, items: list[dict] | None) -> list[Browse
for item in items or []: for item in items or []:
profile = str(item.get("profile") or "default") profile = str(item.get("profile") or "default")
display = str(item.get("displayName") or profile) 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( targets.append(
BrowserTarget( BrowserTarget(
profile=profile, profile=profile,
display_name=_remote_display_name(endpoint, profile, display), display_name=display_name,
socket_path="", socket_path="",
remote=endpoint, remote=endpoint,
browser_name=str(browser_name) if browser_name else None,
display_group=display_name.rsplit(":", 1)[0],
) )
) )
return targets return targets
@@ -52,15 +56,21 @@ def _remote_browser_targets(key=None, *, suppress_pq_warning: bool = False) -> l
continue continue
return targets return targets
def remote_target_for_alias(alias: str | None) -> BrowserTarget | None: def remote_targets_for_alias(alias: str | None, key=None) -> list[BrowserTarget]:
"""Resolve a user-facing remote alias such as 'host:profile' to a target.""" """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: if not alias:
return None return []
targets = _remote_browser_targets() targets = _remote_browser_targets(key=key) if key is not None else _remote_browser_targets()
for target in targets: for target in targets:
endpoint_profile = f"{target.remote}:{target.profile}" if target.remote else None endpoint_profile = f"{target.remote}:{target.profile}" if target.remote else None
if alias in {target.display_name, endpoint_profile}: if alias in {target.display_name, endpoint_profile}:
return target return [target]
endpoint_matches = [] endpoint_matches = []
for target in targets: 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(":") remote_host, sep, _remote_port = target.remote.rpartition(":")
if alias == target.remote or (sep and alias == remote_host): if alias == target.remote or (sep and alias == remote_host):
endpoint_matches.append(target) endpoint_matches.append(target)
if len(endpoint_matches) == 1: return endpoint_matches
return endpoint_matches[0]
if len(endpoint_matches) > 1: def remote_target_for_alias(alias: str | None) -> BrowserTarget | None:
aliases = [target.profile for target in endpoint_matches] """Resolve a user-facing remote alias such as 'host:profile' to a target."""
endpoint = endpoint_matches[0].remote or alias 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( examples = "\n".join(
f" browser-cli --remote {endpoint} --browser {a} ..." f" browser-cli --remote {endpoint} --browser {a} ..."
for a in aliases 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( shorthand_examples = "\n".join(
f" browser-cli --browser {a} ..." f" browser-cli --browser {a} ..."
for a in display_aliases for a in display_aliases
+2
View File
@@ -17,6 +17,8 @@ class BrowserTarget:
display_name: str display_name: str
socket_path: str socket_path: str
remote: str | None = None remote: str | None = None
browser_name: str | None = None
display_group: str | None = None
def is_reachable_unix_endpoint(endpoint: str) -> bool: def is_reachable_unix_endpoint(endpoint: str) -> bool:
"""Return True when a Unix socket path exists and accepts connections.""" """Return True when a Unix socket path exists and accepts connections."""
-187
View File
@@ -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",
]
+84
View File
@@ -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)
+63
View File
@@ -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
+185
View File
@@ -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
+4
View File
@@ -46,6 +46,8 @@ class Tab:
group_id: int | None = None group_id: int | None = None
index: int = 0 index: int = 0
browser: str | 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) _browser: BoundBrowser | None = field(default=None, repr=False, compare=False, init=False)
def _b(self) -> BoundBrowser: def _b(self) -> BoundBrowser:
@@ -152,6 +154,8 @@ class Group:
tab_count: int tab_count: int
window_id: int | None = None window_id: int | None = None
browser: str | 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) _browser: BoundBrowser | None = field(default=None, repr=False, compare=False, init=False)
def _b(self) -> BoundBrowser: def _b(self) -> BoundBrowser:
+12
View File
@@ -28,6 +28,8 @@ class FactoryMixin:
browser_profile: str | None = None, browser_profile: str | None = None,
browser_name: str | None = None, browser_name: str | None = None,
browser_remote: str | None = None, browser_remote: str | None = None,
browser_type: str | None = None,
browser_group: str | None = None,
) -> Tab: ) -> Tab:
tab = Tab( tab = Tab(
id=data["id"], id=data["id"],
@@ -39,6 +41,8 @@ class FactoryMixin:
group_id=data.get("groupId") or None, group_id=data.get("groupId") or None,
index=data.get("index", 0) or 0, index=data.get("index", 0) or 0,
browser=browser_name, browser=browser_name,
browser_name=browser_type,
browser_group=browser_group,
) )
client = cast(_FactoryClient, self) client = cast(_FactoryClient, self)
tab._browser = self if browser_profile is None else cast(Any, type(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_profile: str | None = None,
browser_name: str | None = None, browser_name: str | None = None,
browser_remote: str | None = None, browser_remote: str | None = None,
browser_type: str | None = None,
browser_group: str | None = None,
) -> Group: ) -> Group:
group = Group( group = Group(
id=data["id"], id=data["id"],
@@ -71,6 +77,8 @@ class FactoryMixin:
tab_count=data.get("tabCount", 0), tab_count=data.get("tabCount", 0),
window_id=data.get("windowId"), window_id=data.get("windowId"),
browser=browser_name, browser=browser_name,
browser_name=browser_type,
browser_group=browser_group,
) )
client = cast(_FactoryClient, self) client = cast(_FactoryClient, self)
group._browser = self if browser_profile is None else cast(Any, type(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_profile=target.profile if target else None,
browser_name=target.display_name if target else None, browser_name=target.display_name if target else None,
browser_remote=target.remote 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: def group_from_target(self, data: dict, target) -> Group:
@@ -97,6 +107,8 @@ class FactoryMixin:
browser_profile=target.profile if target else None, browser_profile=target.profile if target else None,
browser_name=target.display_name if target else None, browser_name=target.display_name if target else None,
browser_remote=target.remote 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 @staticmethod
+22 -3
View File
@@ -37,6 +37,20 @@ _UNSET = object()
def _browser_cli_package(): def _browser_cli_package():
return sys.modules.get("browser_cli") or importlib.import_module("browser_cli") 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: class RoutingMixin:
"""Fan-out + aggregation across active browsers, mixed into ``BrowserCLI``. """Fan-out + aggregation across active browsers, mixed into ``BrowserCLI``.
@@ -51,10 +65,15 @@ class RoutingMixin:
def _multi_browser_targets(self) -> list[BrowserTarget]: def _multi_browser_targets(self) -> list[BrowserTarget]:
client = self._client client = self._client
package = _browser_cli_package() 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 [] return []
if client._remote: elif client._remote:
targets = package.remote_browser_targets(client._remote, key=client._key) targets = _with_profile_display(package.remote_browser_targets(client._remote, key=client._key))
else: else:
targets = package.active_browser_targets() targets = package.active_browser_targets()
if len(targets) <= 1 and not any(target.remote for target in targets): if len(targets) <= 1 and not any(target.remote for target in targets):
+19
View File
@@ -6,6 +6,11 @@ from collections.abc import Callable, Iterable
from browser_cli.models import BrowserCounts, Tab from browser_cli.models import BrowserCounts, Tab
from browser_cli.sdk.base import Namespace from browser_cli.sdk.base import Namespace
# Keep SDK-driven bulk closes comfortably below the native-host response
# timeout. The extension can close larger batches, but real browsers may take
# much longer when hundreds of visible tabs are involved.
BULK_CLOSE_CHUNK_SIZE = 50
class TabsNS(Namespace): class TabsNS(Namespace):
"""List, open, close, move, and inspect browser tabs.""" """List, open, close, move, and inspect browser tabs."""
@@ -75,6 +80,20 @@ class TabsNS(Namespace):
ids = None ids = None
if tab_ids is not None: if tab_ids is not None:
ids = [t.id if isinstance(t, Tab) else t for t in tab_ids] ids = [t.id if isinstance(t, Tab) else t for t in tab_ids]
if ids is not None and len(ids) > BULK_CLOSE_CHUNK_SIZE and not inactive and not duplicates and tab_id is None:
closed = 0
for start in range(0, len(ids), BULK_CLOSE_CHUNK_SIZE):
chunk = ids[start:start + BULK_CLOSE_CHUNK_SIZE]
result = self.command("tabs.close", {
"tabId": None,
"tabIds": chunk,
"inactive": False,
"duplicates": False,
"gentleMode": gentle_mode,
})
closed += self.field(result, "closed", len(chunk))
return closed
result = self.command("tabs.close", { result = self.command("tabs.close", {
"tabId": tab_id, "tabId": tab_id,
"tabIds": ids, "tabIds": ids,
+13 -5
View File
@@ -16,11 +16,19 @@ class ServeControlMixin:
async def handle_control_command(self, msg: dict) -> bool: async def handle_control_command(self, msg: dict) -> bool:
if self.command == "browser-cli.targets": if self.command == "browser-cli.targets":
from browser_cli.client import active_browser_targets from browser_cli.client import active_browser_targets, send_command
targets = [ targets = []
{"profile": target.profile, "displayName": target.display_name} for target in active_browser_targets(include_remotes=False):
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) await self.send_ok(targets, self.command)
log_request(self.addr, self.command, None, "OK") log_request(self.addr, self.command, None, "OK")
return True return True
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "browser-cli", "name": "browser-cli",
"version": "0.15.3", "version": "0.15.5",
"description": "Control your browser from the terminal or Python SDK", "description": "Control your browser from the terminal or Python SDK",
"browser_specific_settings": { "browser_specific_settings": {
"gecko": { "gecko": {
+1
View File
@@ -9,6 +9,7 @@
"check:extension": "tsc -p tsconfig.json --noEmit && npm run build:extension && node --check extension/background.js && npm run test:extension", "check:extension": "tsc -p tsconfig.json --noEmit && npm run build:extension && node --check extension/background.js && npm run test:extension",
"package:extension": "npm run build:extension && python scripts/package_extension.py", "package:extension": "npm run build:extension && python scripts/package_extension.py",
"package:extension:webstore": "npm run build:extension && python scripts/package_extension.py --webstore", "package:extension:webstore": "npm run build:extension && python scripts/package_extension.py --webstore",
"package:extension:webstore:verified": "scripts/package_verified_crx.sh",
"package:extension:firefox": "npm run build:extension && python scripts/package_extension.py --firefox" "package:extension:firefox": "npm run build:extension && python scripts/package_extension.py --firefox"
}, },
"devDependencies": { "devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "real-browser-cli" name = "real-browser-cli"
version = "0.15.3" version = "0.15.5"
description = "Control your real running browser from the terminal or Python SDK" description = "Control your real running browser from the terminal or Python SDK"
readme = "README.md" readme = "README.md"
license = { file = "LICENSE" } license = { file = "LICENSE" }
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: scripts/package_verified_crx.sh [--key FILE.gpg] [--browser COMMAND] [--out FILE.crx]
Builds the Chrome Web Store package and creates a CRX signed with the dedicated
verified-upload RSA key. The RSA private key is expected to be GPG-encrypted.
Environment alternatives:
VERIFIED_CRX_KEY_GPG Path to encrypted RSA private key
CHROME_FOR_PACKING Browser command with --pack-extension support
EOF
}
key_gpg="${VERIFIED_CRX_KEY_GPG:-secrets/verified-crx/chrome-webstore-verified-crx-private-key.pem.gpg}"
browser_cmd="${CHROME_FOR_PACKING:-}"
out=""
while [[ $# -gt 0 ]]; do
case "$1" in
--key)
key_gpg="${2:-}"
shift 2
;;
--browser)
browser_cmd="${2:-}"
shift 2
;;
--out)
out="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
if [[ ! -f "$key_gpg" ]]; then
echo "Encrypted verified CRX key not found: $key_gpg" >&2
echo "Create it with: scripts/setup_verified_crx_key.sh --recipient '<your GPG key>'" >&2
exit 1
fi
if [[ -z "$browser_cmd" ]]; then
for candidate in google-chrome chrome chromium chromium-browser brave-browser brave; do
if command -v "$candidate" >/dev/null 2>&1; then
browser_cmd="$candidate"
break
fi
done
fi
if [[ -z "$browser_cmd" ]]; then
echo "No Chromium-based browser with --pack-extension found. Pass --browser or set CHROME_FOR_PACKING." >&2
exit 1
fi
version="$(python - <<'PY'
import json
from pathlib import Path
print(json.loads(Path('extension/manifest.json').read_text())['version'])
PY
)"
out="${out:-dist/browser-cli-extension-webstore-verified-v${version}.crx}"
npm run build:extension
python scripts/package_extension.py --webstore --out "dist/browser-cli-extension-webstore-v${version}.zip" >/dev/null
staging="$PWD/dist/extension-package-webstore"
if [[ ! -d "$staging" ]]; then
echo "Missing webstore staging directory: $staging" >&2
exit 1
fi
tmp_dir="$(mktemp -d)"
private_key="$tmp_dir/verified-crx-private-key.pem"
trap 'rm -rf "$tmp_dir"' EXIT
gpg --decrypt --output "$private_key" "$key_gpg"
chmod 600 "$private_key"
rm -f "$staging.crx"
"$browser_cmd" \
--pack-extension="$staging" \
--pack-extension-key="$private_key" \
--no-message-box \
--disable-gpu \
--no-sandbox >/dev/null
mkdir -p "$(dirname "$out")"
mv "$staging.crx" "$out"
echo "$out"
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: scripts/setup_verified_crx_key.sh [--recipient GPG_RECIPIENT] [--out-dir DIR]
Generates a dedicated RSA private key for Chrome Web Store verified CRX uploads,
encrypts it to your GPG key, and writes the public key material for the Chrome
Developer Dashboard.
Chrome Web Store verified uploads require an RSA CRX signing key. A GPG/OpenPGP
key cannot be used directly for CRX signing, but it can protect the RSA private
key at rest.
EOF
}
recipient=""
out_dir="secrets/verified-crx"
while [[ $# -gt 0 ]]; do
case "$1" in
--recipient)
recipient="${2:-}"
shift 2
;;
--out-dir)
out_dir="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
if [[ -z "$recipient" ]]; then
recipient="$(gpg --list-secret-keys --with-colons 2>/dev/null | awk -F: '$1 == "uid" { print $10; exit }')"
fi
if [[ -z "$recipient" ]]; then
echo "No GPG recipient found. Pass --recipient '<key id or email>'." >&2
exit 1
fi
mkdir -p "$out_dir"
chmod 700 "$out_dir"
private_key="$(mktemp)"
public_pem="$out_dir/chrome-webstore-verified-crx-public-key.pem"
public_der_b64="$out_dir/chrome-webstore-verified-crx-public-key.der.base64.txt"
encrypted_private="$out_dir/chrome-webstore-verified-crx-private-key.pem.gpg"
trap 'rm -f "$private_key"' EXIT
if [[ -e "$encrypted_private" ]]; then
echo "Refusing to overwrite existing encrypted private key: $encrypted_private" >&2
exit 1
fi
openssl genrsa -out "$private_key" 2048 >/dev/null 2>&1
chmod 600 "$private_key"
openssl rsa -in "$private_key" -pubout -out "$public_pem" >/dev/null 2>&1
openssl rsa -in "$private_key" -pubout -outform DER 2>/dev/null | base64 -w0 > "$public_der_b64"
printf '\n' >> "$public_der_b64"
gpg --encrypt --recipient "$recipient" --output "$encrypted_private" "$private_key"
chmod 600 "$encrypted_private"
cat <<EOF
Created verified CRX upload key material:
encrypted private key: $encrypted_private
public key PEM: $public_pem
public key DER/base64: $public_der_b64
Use the public key in the Chrome Developer Dashboard -> Package -> Verified uploads.
Keep the encrypted private key. Do not commit or upload the decrypted PEM.
EOF
+45 -2
View File
@@ -364,6 +364,27 @@ class TestTabs:
profile=None, remote=None, key=None, profile=None, remote=None, key=None,
) )
def test_tabs_close_by_ids_chunks_large_batches(self, b, mock_send):
mock_send.side_effect = [{"closed": 50}, {"closed": 50}, {"closed": 20}]
assert b.tabs.close(tab_ids=range(120), gentle_mode="normal") == 120
assert mock_send.call_args_list == [
call(
"tabs.close",
{"tabId": None, "tabIds": list(range(0, 50)), "inactive": False, "duplicates": False, "gentleMode": "normal"},
profile=None, remote=None, key=None,
),
call(
"tabs.close",
{"tabId": None, "tabIds": list(range(50, 100)), "inactive": False, "duplicates": False, "gentleMode": "normal"},
profile=None, remote=None, key=None,
),
call(
"tabs.close",
{"tabId": None, "tabIds": list(range(100, 120)), "inactive": False, "duplicates": False, "gentleMode": "normal"},
profile=None, remote=None, key=None,
),
]
def test_tabs_move(self, b, mock_send): def test_tabs_move(self, b, mock_send):
b.tabs.move(10, forward=True) b.tabs.move(10, forward=True)
mock_send.assert_called_once_with( mock_send.assert_called_once_with(
@@ -449,7 +470,7 @@ class TestTabs:
tabs = b.tabs.list() tabs = b.tabs.list()
tabs[0].close() 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 == [ assert mock_send.call_args_list == [
call("tabs.list", {}, profile="work", remote="host:8765", key=None), call("tabs.list", {}, profile="work", remote="host:8765", key=None),
call("tabs.close", {"tabId": 10}, profile="work", remote="host:8765", key=None), call("tabs.close", {"tabId": 10}, profile="work", remote="host:8765", key=None),
@@ -470,6 +491,28 @@ class TestTabs:
call("tabs.close", {"tabId": 10}, profile="work", remote="browser-host.example", key="agent"), 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): def test_tabs_active_returns_active_tab(self, b, mock_send):
mock_send.side_effect = [[TAB_DATA], TAB_DATA] mock_send.side_effect = [[TAB_DATA], TAB_DATA]
@@ -638,7 +681,7 @@ class TestGroups:
groups = b.groups.list() groups = b.groups.list()
groups[0].close() 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 == [ assert mock_send.call_args_list == [
call("group.list", {}, profile="work", remote="host:8765", key=None), call("group.list", {}, profile="work", remote="host:8765", key=None),
call("group.close", {"groupId": 42}, profile="work", remote="host:8765", key=None), call("group.close", {"groupId": 42}, profile="work", remote="host:8765", key=None),
+78 -2
View File
@@ -4,7 +4,7 @@ import os
import sys import sys
from click.testing import CliRunner 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.cli import main, _project_version
from browser_cli.client import BrowserTarget 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"]) result = CliRunner().invoke(main, ["--remote", "remote-host:8765", "tabs", "list"])
assert result.exit_code == 0 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 assert "Remote" in result.output
send_command.assert_called_once_with("tabs.list", {}, profile="work", remote="remote-host:8765", key=None) 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 assert "Browser" not in result.output
send_command.assert_called_once_with("tabs.list", {}, profile="work", remote=None, key=None) 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(): def test_tabs_count_multi_browser_shows_total():
counts = {"default": 3, "work": 4} counts = {"default": 3, "work": 4}
+3 -1
View File
@@ -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): def fake_send_command(command, args=None, profile=None, remote=None, key=None):
assert command == "browser-cli.targets" assert command == "browser-cli.targets"
assert remote == endpoint 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) 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].profile == "work"
assert targets[0].display_name == "browser-host.example:work" assert targets[0].display_name == "browser-host.example:work"
assert targets[0].remote == endpoint assert targets[0].remote == endpoint
assert targets[0].browser_name == "Firefox"
assert targets[0].display_group == "browser-host.example"
def test_looks_like_domain(): def test_looks_like_domain():
assert _looks_like_domain("browsercli.yiprawr.dev") is True assert _looks_like_domain("browsercli.yiprawr.dev") is True
+29 -3
View File
@@ -5,15 +5,26 @@ from rich.tree import Tree
from browser_cli.models import Tab from browser_cli.models import Tab
from browser_cli.commands import rendering from browser_cli.commands import rendering
from browser_cli.commands.rendering import common
def test_shorten_uses_ellipsis(): def test_shorten_uses_ellipsis():
assert rendering.shorten("abcdef", 4) == "abc…" assert rendering.shorten("abcdef", 4) == "abc…"
assert rendering.shorten("abc", 4) == "abc" assert rendering.shorten("abc", 4) == "abc"
def test_terminal_width_prefers_shell_width_when_rich_is_redirected(monkeypatch): 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 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(): def test_tab_tree_label_is_reusable_no_wrap_text():
tab = type("Tab", (), {"id": 1, "title": "abcdef", "active": True, "url": "https://example.com"})() 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) 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")) widths.append(kwargs.get("width"))
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
monkeypatch.setattr(rendering, "Console", CapturingConsole) monkeypatch.setattr(common, "Console", CapturingConsole)
monkeypatch.setattr(rendering, "terminal_width", lambda console=None: 132) monkeypatch.setattr(common, "terminal_width", lambda console=None: 132)
rendering.print_tree(Tree("Root")) rendering.print_tree(Tree("Root"))
assert widths == [132] assert widths == [132]
@@ -48,6 +59,21 @@ def test_build_tabs_tree_groups_by_browser_window_and_group():
assert "collapsed" in text assert "collapsed" in text
assert "Inside" 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(): def test_build_windows_tree_keeps_multi_browser_windows_separate():
tabs = [ tabs = [
Tab(id=1, window_id=5, active=False, muted=False, title="Work Tab", url="https://example.com/work", index=0, browser="work"), Tab(id=1, window_id=5, active=False, muted=False, title="Work Tab", url="https://example.com/work", index=0, browser="work"),
Generated
+1 -1
View File
@@ -465,7 +465,7 @@ wheels = [
[[package]] [[package]]
name = "real-browser-cli" name = "real-browser-cli"
version = "0.15.3" version = "0.15.5"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },