feat: group multi-browser output by source
Testing / remote-protocol-compat (0.9.3) (push) Successful in 52s
Testing / test (push) Successful in 1m2s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 1m0s
Package Extension / package-extension (push) Successful in 1m11s
Build & Publish Package / publish (push) Successful in 1m7s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 52s
Testing / test (push) Successful in 1m2s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 1m0s
Package Extension / package-extension (push) Successful in 1m11s
Build & Publish Package / publish (push) Successful in 1m7s
- Add browser source grouping metadata to SDK-created tabs, groups, list results, and aggregate count results. - Render grouped local/remote browser tables consistently for clients, tabs, groups, windows, sessions, and remote status output. - Document remote control, auth, HTTP gateway usage, and the refreshed project structure in the README. - Add coverage for grouped output and BrowserCounts browser_groups. - Bump the Python package, extension manifest, and lockfile to 0.15.6. - Add a just publish helper for building and publishing release artifacts.
This commit is contained in:
@@ -1,20 +1,21 @@
|
|||||||
# browser-cli
|
# browser-cli
|
||||||
Control your real, running browser from the terminal or the Python SDK — no headless browser, no Playwright, no virtual display. Your actual open tabs, windows, and tab groups respond to your commands.
|
Control your real, running browser from the terminal, Python SDK, or a trusted remote client — no headless browser, no Playwright, no virtual display. Your actual open tabs, windows, and tab groups respond to your commands.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
You have 40 tabs open. You want to close all the duplicates, group the GitHub ones, save your session before a meeting, and open a few URLs into a specific group — all from a script. That is what browser-cli is for.
|
You have 40 tabs open. You want to close all the duplicates, group the GitHub ones, save your session before a meeting, and open a few URLs into a specific group — all from a script. That is what browser-cli is for.
|
||||||
|
|
||||||
It works by pairing a small browser extension with a Python package that provides both a CLI and SDK. The extension has full access to your browser's tabs, windows, groups, and page DOM. The CLI and SDK talk to it in real time over a local IPC channel.
|
It works by pairing a small browser extension with a Python package that provides both a CLI and SDK. The extension has full access to your browser's tabs, windows, groups, and page DOM. The CLI and SDK talk to it in real time over a local IPC channel, or through the optional authenticated TCP remote bridge.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
```
|
```
|
||||||
terminal / python script
|
terminal / python script / remote client
|
||||||
│
|
│
|
||||||
│ Local IPC (Unix socket on Linux/macOS, named pipe on Windows)
|
│ Local IPC (Unix socket on Linux/macOS, named pipe on Windows)
|
||||||
|
│ or TCP remote bridge (Ed25519 auth, optional compression)
|
||||||
▼
|
▼
|
||||||
Native Messaging Host (Python process, launched by the browser)
|
Native Messaging Host (Python process, launched by the browser)
|
||||||
│
|
│
|
||||||
@@ -33,7 +34,7 @@ terminal / python script
|
|||||||
4. CLI commands connect to that socket, send a JSON command, and wait for the result.
|
4. CLI commands connect to that socket, send a JSON command, and wait for the result.
|
||||||
5. The native host relays the command to the extension via stdout, receives the result via stdin, and sends it back to the CLI.
|
5. The native host relays the command to the extension via stdout, receives the result via stdin, and sends it back to the CLI.
|
||||||
|
|
||||||
No server needs to be running beforehand. The browser manages the native host's lifecycle.
|
No local server needs to be running beforehand. The browser manages the native host's lifecycle. For cross-machine control, `browser-cli serve` starts an explicit TCP listener protected by Ed25519 public-key authentication unless you opt out with `--no-auth`.
|
||||||
|
|
||||||
**Message format**
|
**Message format**
|
||||||
|
|
||||||
@@ -53,7 +54,7 @@ Every response:
|
|||||||
**Requirements:** Python 3.10+, [uv](https://github.com/astral-sh/uv), Chrome, Chromium, Brave, Edge, Vivaldi, or Firefox
|
**Requirements:** Python 3.10+, [uv](https://github.com/astral-sh/uv), Chrome, Chromium, Brave, Edge, Vivaldi, or Firefox
|
||||||
|
|
||||||
### Install with uv
|
### Install with uv
|
||||||
Once published on PyPI, install the CLI as a uv tool:
|
Install the CLI from PyPI as a uv tool:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
uv tool install real-browser-cli
|
uv tool install real-browser-cli
|
||||||
@@ -100,62 +101,42 @@ Only the `browser-cli` command needs to be on your `PATH`. The browser launches
|
|||||||
```text
|
```text
|
||||||
browser-cli/
|
browser-cli/
|
||||||
├── browser_cli/
|
├── browser_cli/
|
||||||
│ ├── __init__.py # Python SDK — BrowserCLI class and SDK entry point
|
│ ├── __init__.py # Public sync SDK: BrowserCLI and namespace wiring
|
||||||
│ ├── cli.py # Click CLI entry point
|
│ ├── async_sdk.py # AsyncBrowserCLI
|
||||||
│ ├── client/ # Client-side command routing used by CLI and SDK
|
│ ├── cli.py # Click root command and native-host entry point
|
||||||
│ │ ├── core.py # send_command and remote command routing
|
│ ├── client/ # send_command path, local/remote routing, message helpers
|
||||||
│ │ ├── targets.py # Browser target discovery and socket resolution
|
│ ├── sdk/ # SDK namespaces: nav, tabs, groups, windows, dom, session, ...
|
||||||
│ │ ├── auth.py # Remote auth fields and key lookup
|
│ ├── commands/ # CLI presentation layer over the SDK namespaces
|
||||||
│ │ └── messages.py # Request/response helpers
|
│ ├── native/ # Browser-launched Native Messaging host + local IPC server
|
||||||
│ ├── models.py # Tab and Group helper models
|
│ ├── remote/ # TCP remote client transport and saved endpoint registry
|
||||||
│ ├── native/ # Native messaging host internals
|
│ ├── serve/ # Authenticated TCP server runtime
|
||||||
│ │ ├── host.py # Browser-launched native host entry point
|
│ ├── transport/ # JSON/msgpack response encoding and compression helpers
|
||||||
│ │ ├── local_server.py # Local CLI IPC server
|
│ ├── markdown/ # HTML-to-Markdown extraction helpers
|
||||||
│ │ └── protocol.py # Chrome Native Messaging framing
|
│ ├── auth/ # Ed25519 keys, signing, SSH-agent/YubiKey helpers, PQ KEX
|
||||||
│ ├── remote/ # Client-side remote browser support
|
│ └── models.py # Tab, Group, BrowserCounts dataclasses
|
||||||
│ │ ├── transport.py # TCP/TLS remote transport
|
|
||||||
│ │ └── registry.py # Saved remote endpoints/keys
|
|
||||||
│ └── commands/
|
|
||||||
│ ├── navigate.py # nav open/reload/back/forward/focus
|
|
||||||
│ ├── search.py # search engine shortcuts
|
|
||||||
│ ├── tabs.py # tab management
|
|
||||||
│ ├── groups.py # tab group management
|
|
||||||
│ ├── windows.py # window management
|
|
||||||
│ ├── dom.py # DOM querying and interaction
|
|
||||||
│ ├── extract.py # content extraction
|
|
||||||
│ └── session.py # session save/load
|
|
||||||
├── extension/
|
├── extension/
|
||||||
│ ├── manifest.json # MV3 extension manifest
|
│ ├── manifest.json # Chromium MV3 manifest
|
||||||
│ ├── content.js # Content-script helpers
|
│ └── src/ # TypeScript WebExtension source
|
||||||
│ └── src/ # TypeScript source split by command area
|
│ ├── index.ts # Background/service-worker bundle entry
|
||||||
│ ├── index.ts # Builds generated extension/background.js
|
│ ├── content-dispatch.ts
|
||||||
│ └── content/ # Builds generated extension/content-dispatch.js
|
│ ├── commands/ # Browser-side command implementations
|
||||||
├── examples/
|
│ ├── content/ # DOM/extract/Markdown logic injected into pages
|
||||||
│ ├── demo.py # Python SDK walkthrough
|
│ └── core/ # Shared extension helpers
|
||||||
│ └── demo.sh # Bash CLI walkthrough
|
├── examples/ # Python and shell walkthroughs
|
||||||
├── tests/
|
├── scripts/ # Packaging and release helper scripts
|
||||||
│ ├── conftest.py # shared pytest fixtures
|
├── tests/ # pytest suite
|
||||||
│ ├── test_api.py
|
├── package.json # Extension build/test/package scripts
|
||||||
│ ├── test_cli.py
|
├── pyproject.toml # Python package metadata
|
||||||
│ ├── test_dom.py
|
└── uv.lock # locked Python dependencies
|
||||||
│ ├── test_extract.py
|
|
||||||
│ ├── test_groups.py
|
|
||||||
│ ├── test_nav.py
|
|
||||||
│ ├── test_session.py
|
|
||||||
│ ├── test_tabs.py
|
|
||||||
│ └── test_windows.py
|
|
||||||
├── com.browsercli.host.json # native messaging manifest template
|
|
||||||
├── pyproject.toml # package metadata and CLI entry point
|
|
||||||
└── uv.lock # locked dependencies for uv
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CLI reference
|
## CLI reference
|
||||||
|
|
||||||
All commands are run with `uv run browser-cli [--browser ALIAS] <command>`.
|
During source development, commands are usually run as `uv run browser-cli [--browser ALIAS] <command>`. After tool installation, use `browser-cli ...` directly. Add `--remote HOST[:PORT]` and optionally `--key PATH` to target a browser exposed by `browser-cli serve`.
|
||||||
|
|
||||||
If exactly one browser instance is connected, commands auto-target it. Use `--browser ALIAS` when multiple browser instances are connected. `tabs list`, `tabs count`, `groups list`, `groups count`, `windows list`, and `session list` aggregate across all active browsers when `--browser` is omitted; in that mode they show the source browser alias or UUID. You can inspect the active instances with `browser-cli clients` and assign a persistent profile alias from inside the target browser with `browser-cli clients rename --browser <current-alias> <new-alias>`. Closed browsers are removed from the client registry automatically.
|
If exactly one browser instance is connected, commands auto-target it. Use `--browser ALIAS` when multiple browser instances are connected. `tabs list`, `tabs count`, `groups list`, `groups count`, `windows list`, and `session list` aggregate across all active browsers when `--browser` is omitted; in that mode they show the source browser alias or UUID. When local and saved remote browsers are mixed, tables group rows by source (`local` or the remote endpoint) and indent the browser profile below that group. You can inspect active instances with `browser-cli clients` and assign a persistent profile alias from inside the target browser with `browser-cli clients rename --browser <current-alias> <new-alias>`. Closed browsers are removed from the client registry automatically.
|
||||||
|
|
||||||
Important: profile aliases are browser-instance aliases, not window aliases. Window aliases created with `windows rename` are only for targeting windows in commands like `nav open --window work`. If a browser instance has no explicit profile alias set, the native host gives it a generated UUID alias so multiple unaliased browsers stay distinct.
|
Important: profile aliases are browser-instance aliases, not window aliases. Window aliases created with `windows rename` are only for targeting windows in commands like `nav open --window work`. If a browser instance has no explicit profile alias set, the native host gives it a generated UUID alias so multiple unaliased browsers stay distinct.
|
||||||
|
|
||||||
@@ -315,6 +296,27 @@ browser-cli completion zsh # print setup instructions
|
|||||||
browser-cli completion zsh --script # output raw completion script
|
browser-cli completion zsh --script # output raw completion script
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Remote control, auth, and gateways
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# On the machine with the browser
|
||||||
|
browser-cli auth keygen --output ~/.config/browser-cli/client.key
|
||||||
|
PUBKEY=$(browser-cli auth show --key ~/.config/browser-cli/client.key | tail -n1)
|
||||||
|
browser-cli auth trust "$PUBKEY"
|
||||||
|
browser-cli serve --host 0.0.0.0 --port 8765 --authorized-keys ~/.config/browser-cli/authorized_keys
|
||||||
|
|
||||||
|
# From another machine
|
||||||
|
browser-cli --remote browser-host.example:8765 --key ~/.config/browser-cli/client.key tabs list
|
||||||
|
browser-cli remote trust browser-host.example:8765 ~/.config/browser-cli/client.key
|
||||||
|
browser-cli --remote browser-host.example:8765 clients
|
||||||
|
|
||||||
|
# Local HTTP JSON gateway for small integrations
|
||||||
|
browser-cli serve-http --port 8766
|
||||||
|
curl -H "Authorization: Bearer <token>" http://127.0.0.1:8766/tabs
|
||||||
|
```
|
||||||
|
|
||||||
|
Remote auth uses Ed25519 challenge/response. `--remote` domains default to port 443; explicit `host:port` endpoints are also supported. Saved remote endpoints participate in aggregate list/count commands, where output is grouped by endpoint.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Python SDK
|
## Python SDK
|
||||||
@@ -325,7 +327,7 @@ from browser_cli import AsyncBrowserCLI, BrowserCLI
|
|||||||
b = BrowserCLI()
|
b = BrowserCLI()
|
||||||
```
|
```
|
||||||
|
|
||||||
Commands are grouped into namespaces on the client (`b.tabs`, `b.dom`, `b.session`, ...). Each sync call blocks until the browser responds and returns the data directly as a Python object. For asyncio programs, `AsyncBrowserCLI` exposes the same namespaces as native awaitable methods over async Unix/TCP transport.
|
Commands are grouped into namespaces on the client (`b.tabs`, `b.dom`, `b.session`, ...). Each sync call blocks until the browser responds and returns the data directly as a Python object. Create `BrowserCLI(remote="host:8765", key="client.key")` to target a remote server. For asyncio programs, `AsyncBrowserCLI` exposes the same namespaces as native awaitable methods over async Unix/TCP transport.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Navigation ── b.nav
|
# Navigation ── b.nav
|
||||||
@@ -480,6 +482,7 @@ counts = b.tabs.count()
|
|||||||
if isinstance(counts, BrowserCounts):
|
if isinstance(counts, BrowserCounts):
|
||||||
print(counts.total)
|
print(counts.total)
|
||||||
print(counts.by_browser)
|
print(counts.by_browser)
|
||||||
|
print(counts.browser_groups) # e.g. {"local:work": "local", "remote:work": "remote"}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -515,6 +518,7 @@ The extension source lives in `extension/src/`. `extension/background.js` and `e
|
|||||||
Packaging:
|
Packaging:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
just publish # build to /tmp/dist-browser-cli and publish with .env credentials
|
||||||
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:webstore:verified # Chrome Web Store CRX signed for verified uploads
|
npm run package:extension:webstore:verified # Chrome Web Store CRX signed for verified uploads
|
||||||
|
|||||||
@@ -39,9 +39,23 @@ def print_counts(result, noun: str, *, single_suffix: str = "") -> None:
|
|||||||
"""
|
"""
|
||||||
if isinstance(result, BrowserCounts):
|
if isinstance(result, BrowserCounts):
|
||||||
table = Table(show_header=True, header_style="bold cyan")
|
table = Table(show_header=True, header_style="bold cyan")
|
||||||
table.add_column("Browser")
|
table.add_column("Browser", no_wrap=True)
|
||||||
table.add_column(f"{noun.capitalize()}s", justify="right")
|
table.add_column(f"{noun.capitalize()}s", justify="right")
|
||||||
|
rendered_groups: set[str] = set()
|
||||||
for name, count in result.by_browser.items():
|
for name, count in result.by_browser.items():
|
||||||
|
group = result.browser_groups.get(name)
|
||||||
|
if group:
|
||||||
|
if group not in rendered_groups:
|
||||||
|
group_total = sum(
|
||||||
|
browser_count
|
||||||
|
for browser_name, browser_count in result.by_browser.items()
|
||||||
|
if result.browser_groups.get(browser_name) == group
|
||||||
|
)
|
||||||
|
table.add_row(f"[bold]{group}[/bold]", str(group_total))
|
||||||
|
rendered_groups.add(group)
|
||||||
|
display_name = name.removeprefix(f"{group}:")
|
||||||
|
table.add_row(f" {display_name}", str(count))
|
||||||
|
else:
|
||||||
table.add_row(name, str(count))
|
table.add_row(name, str(count))
|
||||||
table.add_row("Total", str(result.total))
|
table.add_row("Total", str(result.total))
|
||||||
_console.print(table)
|
_console.print(table)
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ def _ensure_unique_browser_alias(alias: str, target_browser: str | None) -> None
|
|||||||
if alias in profiles and alias != target_profile:
|
if alias in profiles and alias != target_profile:
|
||||||
raise click.ClickException(f"Browser alias '{alias}' already exists")
|
raise click.ClickException(f"Browser alias '{alias}' already exists")
|
||||||
|
|
||||||
def _append_clients(into, label, *, profile=None, remote=None, key=None, quiet_remote_warning=False):
|
def _append_clients(into, label, *, profile=None, remote=None, key=None, quiet_remote_warning=False, profile_group=None):
|
||||||
"""Query clients.list for one target and append each, tagged with *label*."""
|
"""Query clients.list for one target and append each, tagged with *label*."""
|
||||||
if quiet_remote_warning:
|
if quiet_remote_warning:
|
||||||
result = send_command(
|
result = send_command(
|
||||||
@@ -50,6 +50,8 @@ def _append_clients(into, label, *, profile=None, remote=None, key=None, quiet_r
|
|||||||
result = send_command("clients.list", profile=profile, remote=remote, key=key)
|
result = send_command("clients.list", profile=profile, remote=remote, key=key)
|
||||||
for c in (result or []):
|
for c in (result or []):
|
||||||
c["profile"] = label
|
c["profile"] = label
|
||||||
|
if profile_group:
|
||||||
|
c["profileGroup"] = profile_group
|
||||||
into.append(c)
|
into.append(c)
|
||||||
|
|
||||||
@click.group("clients", invoke_without_command=True)
|
@click.group("clients", invoke_without_command=True)
|
||||||
@@ -89,7 +91,14 @@ def _collect_remote_alias_clients(all_clients: list, browser_alias: str, key) ->
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
for target in targets:
|
for target in targets:
|
||||||
try:
|
try:
|
||||||
_append_clients(all_clients, target.display_name, profile=target.profile, remote=resolved.remote, key=key)
|
_append_clients(
|
||||||
|
all_clients,
|
||||||
|
target.display_name,
|
||||||
|
profile=target.profile,
|
||||||
|
remote=resolved.remote,
|
||||||
|
key=key,
|
||||||
|
profile_group=target.display_group,
|
||||||
|
)
|
||||||
except (BrowserNotConnected, RuntimeError):
|
except (BrowserNotConnected, RuntimeError):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -109,10 +118,11 @@ def _collect_local_and_saved_remote_clients(all_clients: list) -> None:
|
|||||||
for profile_name, sock_path in profiles.items():
|
for profile_name, sock_path in profiles.items():
|
||||||
display_profile = display_browser_name(profile_name, sock_path)
|
display_profile = display_browser_name(profile_name, sock_path)
|
||||||
try:
|
try:
|
||||||
_append_clients(all_clients, display_profile, profile=profile_name)
|
_append_clients(all_clients, display_profile, profile=profile_name, profile_group="local")
|
||||||
except (BrowserNotConnected, RuntimeError):
|
except (BrowserNotConnected, RuntimeError):
|
||||||
all_clients.append({
|
all_clients.append({
|
||||||
"profile": display_profile,
|
"profile": display_profile,
|
||||||
|
"profileGroup": "local",
|
||||||
"name": "—",
|
"name": "—",
|
||||||
"version": "—",
|
"version": "—",
|
||||||
"extensionVersion": "disconnected",
|
"extensionVersion": "disconnected",
|
||||||
@@ -130,6 +140,7 @@ def _collect_local_and_saved_remote_clients(all_clients: list) -> None:
|
|||||||
profile=target.profile,
|
profile=target.profile,
|
||||||
remote=target.remote,
|
remote=target.remote,
|
||||||
quiet_remote_warning=True,
|
quiet_remote_warning=True,
|
||||||
|
profile_group=target.display_group,
|
||||||
)
|
)
|
||||||
except (BrowserNotConnected, RuntimeError):
|
except (BrowserNotConnected, RuntimeError):
|
||||||
continue
|
continue
|
||||||
@@ -137,13 +148,25 @@ def _collect_local_and_saved_remote_clients(all_clients: list) -> None:
|
|||||||
def _print_clients(all_clients: list) -> None:
|
def _print_clients(all_clients: list) -> None:
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
table = Table(show_header=True, header_style="bold cyan")
|
table = Table(show_header=True, header_style="bold cyan")
|
||||||
table.add_column("Profile")
|
table.add_column("Profile", no_wrap=True)
|
||||||
table.add_column("Browser")
|
table.add_column("Browser")
|
||||||
table.add_column("Version")
|
table.add_column("Version")
|
||||||
table.add_column("Extension Version")
|
table.add_column("Extension Version")
|
||||||
|
rendered_groups: set[str] = set()
|
||||||
|
groups = {c.get("profileGroup") for c in all_clients if c.get("profileGroup")}
|
||||||
|
grouped = bool(groups and groups != {"local"})
|
||||||
for c in all_clients:
|
for c in all_clients:
|
||||||
|
group = c.get("profileGroup") if grouped else None
|
||||||
|
if group:
|
||||||
|
if group not in rendered_groups:
|
||||||
|
table.add_row(f"[bold]{group}[/bold]", "", "", "")
|
||||||
|
rendered_groups.add(group)
|
||||||
|
profile = str(c.get("profile", "")).removeprefix(f"{group}:")
|
||||||
|
profile = f" {profile}"
|
||||||
|
else:
|
||||||
|
profile = c.get("profile", "")
|
||||||
table.add_row(
|
table.add_row(
|
||||||
c.get("profile", ""),
|
profile,
|
||||||
c.get("name", ""),
|
c.get("name", ""),
|
||||||
c.get("version", ""),
|
c.get("version", ""),
|
||||||
c.get("extensionVersion", ""),
|
c.get("extensionVersion", ""),
|
||||||
|
|||||||
@@ -1,33 +1,19 @@
|
|||||||
import click
|
import click
|
||||||
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors, print_counts
|
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors, print_counts
|
||||||
|
from browser_cli.commands.rendering import print_browser_grouped_table_rows
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.table import Table
|
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
def _print_groups(groups, *, show_browser: bool = False) -> None:
|
def _print_groups(groups, *, show_browser: bool = False) -> None:
|
||||||
if not groups:
|
columns = [
|
||||||
console.print("[yellow]No groups found[/yellow]")
|
("ID", lambda g: g.id),
|
||||||
return
|
("Name", lambda g: g.title or ""),
|
||||||
table = Table(show_header=True, header_style="bold cyan")
|
("Color", lambda g: g.color or ""),
|
||||||
if show_browser:
|
("Collapsed", lambda g: "yes" if g.collapsed else "no"),
|
||||||
table.add_column("Browser")
|
("Tabs", lambda g: g.tab_count),
|
||||||
table.add_column("ID", style="dim", no_wrap=True)
|
|
||||||
table.add_column("Name")
|
|
||||||
table.add_column("Color", width=10)
|
|
||||||
table.add_column("Collapsed", width=10)
|
|
||||||
table.add_column("Tabs", width=6)
|
|
||||||
for g in groups:
|
|
||||||
row = [
|
|
||||||
(g.browser or "") if show_browser else None,
|
|
||||||
str(g.id),
|
|
||||||
g.title or "",
|
|
||||||
g.color or "",
|
|
||||||
"yes" if g.collapsed else "no",
|
|
||||||
str(g.tab_count),
|
|
||||||
]
|
]
|
||||||
table.add_row(*[value for value in row if value is not None])
|
print_browser_grouped_table_rows(groups, columns, console=console, empty_message="[yellow]No groups found[/yellow]")
|
||||||
console.print(table)
|
|
||||||
|
|
||||||
@click.group("groups")
|
@click.group("groups")
|
||||||
def group_group():
|
def group_group():
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from rich.table import Table
|
|||||||
|
|
||||||
from browser_cli import BrowserCLI
|
from browser_cli import BrowserCLI
|
||||||
from browser_cli.commands import handle_errors
|
from browser_cli.commands import handle_errors
|
||||||
|
from browser_cli.commands.rendering import print_browser_grouped_table_rows
|
||||||
from browser_cli.remote.registry import REMOTE_REGISTRY_PATH, load_remotes, save_remote_key
|
from browser_cli.remote.registry import REMOTE_REGISTRY_PATH, load_remotes, save_remote_key
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
@@ -23,14 +24,23 @@ def remote_group():
|
|||||||
def remote_status(endpoint, key):
|
def remote_status(endpoint, key):
|
||||||
"""Probe a remote endpoint and show server/client status."""
|
"""Probe a remote endpoint and show server/client status."""
|
||||||
client = BrowserCLI(remote=endpoint, key=key)
|
client = BrowserCLI(remote=endpoint, key=key)
|
||||||
clients = client.clients()
|
clients = [
|
||||||
table = Table(show_header=True, header_style="bold cyan")
|
{**item, "profileLabel": item.get("profile", ""), "profileGroup": endpoint}
|
||||||
table.add_column("Profile")
|
for item in client.clients()
|
||||||
table.add_column("Browser")
|
]
|
||||||
table.add_column("Extension")
|
columns = [
|
||||||
for item in clients:
|
("Browser", lambda item: item.get("name", "")),
|
||||||
table.add_row(str(item.get("profile", "")), str(item.get("name", "")), str(item.get("extensionVersion", "")))
|
("Extension", lambda item: item.get("extensionVersion", "")),
|
||||||
console.print(table)
|
]
|
||||||
|
print_browser_grouped_table_rows(
|
||||||
|
clients,
|
||||||
|
columns,
|
||||||
|
console=console,
|
||||||
|
empty_message="[yellow]No browser clients found[/yellow]",
|
||||||
|
browser_getter=lambda item: item.get("profileLabel", ""),
|
||||||
|
group_getter=lambda item: item.get("profileGroup", ""),
|
||||||
|
browser_header="Profile",
|
||||||
|
)
|
||||||
|
|
||||||
@remote_group.command("trust")
|
@remote_group.command("trust")
|
||||||
@click.argument("endpoint")
|
@click.argument("endpoint")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
from browser_cli.commands.rendering.common import (
|
from browser_cli.commands.rendering.common import (
|
||||||
Column,
|
Column,
|
||||||
item_value,
|
item_value,
|
||||||
|
print_browser_grouped_table_rows,
|
||||||
print_table_rows,
|
print_table_rows,
|
||||||
print_tree,
|
print_tree,
|
||||||
shorten,
|
shorten,
|
||||||
@@ -44,6 +45,7 @@ __all__ = [
|
|||||||
"group_tree_label",
|
"group_tree_label",
|
||||||
"item_value",
|
"item_value",
|
||||||
"no_wrap_text",
|
"no_wrap_text",
|
||||||
|
"print_browser_grouped_table_rows",
|
||||||
"print_table_rows",
|
"print_table_rows",
|
||||||
"print_tree",
|
"print_tree",
|
||||||
"scoped_browser_label",
|
"scoped_browser_label",
|
||||||
|
|||||||
@@ -82,3 +82,56 @@ def print_table_rows(
|
|||||||
for row in rows:
|
for row in rows:
|
||||||
table.add_row(*[text_value(getter(row)) for _header, getter in columns])
|
table.add_row(*[text_value(getter(row)) for _header, getter in columns])
|
||||||
Console(width=terminal_width(console)).print(table)
|
Console(width=terminal_width(console)).print(table)
|
||||||
|
|
||||||
|
def print_browser_grouped_table_rows(
|
||||||
|
rows: Sequence[Row],
|
||||||
|
columns: Sequence[Column],
|
||||||
|
*,
|
||||||
|
console: Console,
|
||||||
|
empty_message: str,
|
||||||
|
browser_getter: Callable[[Row], CellValue | None] = lambda row: item_value(row, "browser"),
|
||||||
|
group_getter: Callable[[Row], CellValue | None] = lambda row: item_value(row, "browser_group", item_value(row, "browserGroup")),
|
||||||
|
browser_header: str = "Browser",
|
||||||
|
show_header: bool = True,
|
||||||
|
header_style: str = "bold cyan",
|
||||||
|
) -> None:
|
||||||
|
"""Render rows with optional local/remote browser grouping.
|
||||||
|
|
||||||
|
Rows without a browser label are rendered as a normal table. Rows with
|
||||||
|
``browser_group``/``browserGroup`` get a group header (for example ``local``
|
||||||
|
or a remote host) and a short indented profile label below it.
|
||||||
|
"""
|
||||||
|
if not rows:
|
||||||
|
console.print(empty_message)
|
||||||
|
return
|
||||||
|
|
||||||
|
show_browser = any(bool(browser_getter(row)) for row in rows)
|
||||||
|
if not show_browser:
|
||||||
|
print_table_rows(
|
||||||
|
rows,
|
||||||
|
columns,
|
||||||
|
console=console,
|
||||||
|
empty_message=empty_message,
|
||||||
|
show_header=show_header,
|
||||||
|
header_style=header_style,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
table = Table(show_header=show_header, header_style=header_style)
|
||||||
|
table.add_column(browser_header, no_wrap=True)
|
||||||
|
for header, _getter in columns:
|
||||||
|
table.add_column(header)
|
||||||
|
|
||||||
|
rendered_groups: set[str] = set()
|
||||||
|
for row in rows:
|
||||||
|
browser = text_value(browser_getter(row))
|
||||||
|
group = text_value(group_getter(row))
|
||||||
|
if group:
|
||||||
|
if group not in rendered_groups:
|
||||||
|
table.add_row(f"[bold]{group}[/bold]", *["" for _header, _getter in columns])
|
||||||
|
rendered_groups.add(group)
|
||||||
|
browser = browser.removeprefix(f"{group}:")
|
||||||
|
browser = f" {browser}"
|
||||||
|
table.add_row(browser, *[text_value(getter(row)) for _header, getter in columns])
|
||||||
|
|
||||||
|
Console(width=terminal_width(console)).print(table)
|
||||||
|
|||||||
@@ -105,24 +105,20 @@ def session_diff(name_a, name_b):
|
|||||||
def session_list():
|
def session_list():
|
||||||
"""List all saved sessions."""
|
"""List all saved sessions."""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from rich.table import Table
|
from browser_cli.commands.rendering import print_browser_grouped_table_rows
|
||||||
sessions = client_from_ctx().session.list()
|
sessions = client_from_ctx().session.list()
|
||||||
if not sessions:
|
if not sessions:
|
||||||
console.print("[yellow]No saved sessions[/yellow]")
|
console.print("[yellow]No saved sessions[/yellow]")
|
||||||
return
|
return
|
||||||
show_browser = any("browser" in s for s in sessions)
|
def saved_at(session):
|
||||||
table = Table(show_header=True, header_style="bold cyan")
|
return datetime.fromtimestamp(session["savedAt"] / 1000).strftime("%Y-%m-%d %H:%M") if session.get("savedAt") else ""
|
||||||
if show_browser:
|
|
||||||
table.add_column("Browser")
|
columns = [
|
||||||
table.add_column("Name")
|
("Name", lambda session: session["name"]),
|
||||||
table.add_column("Tabs", width=6)
|
("Tabs", lambda session: session["tabs"]),
|
||||||
table.add_column("Saved at")
|
("Saved at", saved_at),
|
||||||
for s in sessions:
|
]
|
||||||
saved = datetime.fromtimestamp(s["savedAt"] / 1000).strftime("%Y-%m-%d %H:%M") if s.get("savedAt") else ""
|
print_browser_grouped_table_rows(sessions, columns, console=console, empty_message="[yellow]No saved sessions[/yellow]")
|
||||||
row = [s.get("browser", "")] if show_browser else []
|
|
||||||
row.extend([s["name"], str(s["tabs"]), saved])
|
|
||||||
table.add_row(*row)
|
|
||||||
console.print(table)
|
|
||||||
|
|
||||||
@session_group.command("remove")
|
@session_group.command("remove")
|
||||||
@click.argument("name")
|
@click.argument("name")
|
||||||
|
|||||||
@@ -2,25 +2,22 @@ import base64
|
|||||||
import binascii
|
import binascii
|
||||||
import click
|
import click
|
||||||
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors, print_counts, tab_option
|
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors, print_counts, tab_option
|
||||||
from browser_cli.commands.rendering import build_tabs_tree, print_table_rows, print_tree
|
from browser_cli.commands.rendering import build_tabs_tree, print_browser_grouped_table_rows, print_tree
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
def _print_tabs(tabs, *, show_browser: bool = False) -> None:
|
def _print_tabs(tabs, *, show_browser: bool = False) -> None:
|
||||||
columns = []
|
columns = [
|
||||||
if show_browser:
|
|
||||||
columns.append(("Browser", lambda tab: tab.browser or ""))
|
|
||||||
columns.extend([
|
|
||||||
("ID", lambda tab: tab.id),
|
("ID", lambda tab: tab.id),
|
||||||
("Window", lambda tab: tab.window_id),
|
("Window", lambda tab: tab.window_id),
|
||||||
("Active", lambda tab: "[green]✓[/green]" if tab.active else ""),
|
("Active", lambda tab: "[green]✓[/green]" if tab.active else ""),
|
||||||
("Muted", lambda tab: "[yellow]✓[/yellow]" if tab.muted else ""),
|
("Muted", lambda tab: "[yellow]✓[/yellow]" if tab.muted else ""),
|
||||||
("Title", lambda tab: (tab.title or "")[:60]),
|
("Title", lambda tab: (tab.title or "")[:60]),
|
||||||
("URL", lambda tab: (tab.url or "")[:80]),
|
("URL", lambda tab: (tab.url or "")[:80]),
|
||||||
])
|
]
|
||||||
print_table_rows(tabs, columns, console=console, empty_message="[yellow]No tabs found[/yellow]")
|
print_browser_grouped_table_rows(tabs, columns, console=console, empty_message="[yellow]No tabs found[/yellow]")
|
||||||
|
|
||||||
@click.group("tabs")
|
@click.group("tabs")
|
||||||
def tabs_group():
|
def tabs_group():
|
||||||
|
|||||||
@@ -1,21 +1,18 @@
|
|||||||
import click
|
import click
|
||||||
from browser_cli.commands import client_from_ctx, handle_errors
|
from browser_cli.commands import client_from_ctx, handle_errors
|
||||||
from browser_cli.commands.rendering import build_windows_tree, print_table_rows, print_tree
|
from browser_cli.commands.rendering import build_windows_tree, print_browser_grouped_table_rows, print_tree
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
def _print_windows(windows: list[dict], *, show_browser: bool = False) -> None:
|
def _print_windows(windows: list[dict], *, show_browser: bool = False) -> None:
|
||||||
columns = []
|
columns = [
|
||||||
if show_browser:
|
|
||||||
columns.append(("Browser", lambda window: window.get("browser", "")))
|
|
||||||
columns.extend([
|
|
||||||
("ID", lambda window: window.get("id", "")),
|
("ID", lambda window: window.get("id", "")),
|
||||||
("Alias", lambda window: window.get("alias") or ""),
|
("Alias", lambda window: window.get("alias") or ""),
|
||||||
("Tabs", lambda window: window.get("tabCount", "")),
|
("Tabs", lambda window: window.get("tabCount", "")),
|
||||||
("State", lambda window: window.get("state") or ""),
|
("State", lambda window: window.get("state") or ""),
|
||||||
])
|
]
|
||||||
print_table_rows(windows, columns, console=console, empty_message="[yellow]No windows found[/yellow]")
|
print_browser_grouped_table_rows(windows, columns, console=console, empty_message="[yellow]No windows found[/yellow]")
|
||||||
|
|
||||||
@click.group("windows")
|
@click.group("windows")
|
||||||
def windows_group():
|
def windows_group():
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class BrowserCounts:
|
|||||||
"""Aggregated per-browser counts returned in implicit multi-browser mode."""
|
"""Aggregated per-browser counts returned in implicit multi-browser mode."""
|
||||||
total: int
|
total: int
|
||||||
by_browser: dict[str, int]
|
by_browser: dict[str, int]
|
||||||
|
browser_groups: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
# ── Tab ───────────────────────────────────────────────────────────────────────
|
# ── Tab ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ from typing import Any, Protocol, cast
|
|||||||
|
|
||||||
from browser_cli.models import Group, Tab
|
from browser_cli.models import Group, Tab
|
||||||
|
|
||||||
|
def _target_group(target) -> str | None:
|
||||||
|
if target is None:
|
||||||
|
return None
|
||||||
|
return getattr(target, "display_group", None) or ("local" if target.remote is None else None)
|
||||||
|
|
||||||
class _FactoryClient(Protocol):
|
class _FactoryClient(Protocol):
|
||||||
_key: str | None
|
_key: str | None
|
||||||
|
|
||||||
@@ -97,7 +102,7 @@ class FactoryMixin:
|
|||||||
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_type=getattr(target, "browser_name", None) if target else None,
|
||||||
browser_group=getattr(target, "display_group", None) if target else None,
|
browser_group=_target_group(target),
|
||||||
)
|
)
|
||||||
|
|
||||||
def group_from_target(self, data: dict, target) -> Group:
|
def group_from_target(self, data: dict, target) -> Group:
|
||||||
@@ -108,10 +113,12 @@ class FactoryMixin:
|
|||||||
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_type=getattr(target, "browser_name", None) if target else None,
|
||||||
browser_group=getattr(target, "display_group", None) if target else None,
|
browser_group=_target_group(target),
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def tag_browser(item: dict, target) -> dict:
|
def tag_browser(item: dict, target) -> dict:
|
||||||
"""Return *item* as-is locally, or with a ``browser`` key in multi-browser mode."""
|
"""Return *item* as-is locally, or with browser metadata in multi-browser mode."""
|
||||||
return item if target is None else {**item, "browser": target.display_name}
|
if target is None:
|
||||||
|
return item
|
||||||
|
return {**item, "browser": target.display_name, "browserGroup": _target_group(target)}
|
||||||
|
|||||||
@@ -126,7 +126,12 @@ class RoutingMixin:
|
|||||||
if not multi_results:
|
if not multi_results:
|
||||||
return self._client.dispatch(command, args or {})
|
return self._client.dispatch(command, args or {})
|
||||||
by_browser = {target.display_name: int(count or 0) for target, count in multi_results}
|
by_browser = {target.display_name: int(count or 0) for target, count in multi_results}
|
||||||
return BrowserCounts(total=sum(by_browser.values()), by_browser=by_browser)
|
browser_groups = {
|
||||||
|
target.display_name: target.display_group or "local"
|
||||||
|
for target, _count in multi_results
|
||||||
|
if target.display_group or target.remote is None
|
||||||
|
}
|
||||||
|
return BrowserCounts(total=sum(by_browser.values()), by_browser=by_browser, browser_groups=browser_groups)
|
||||||
|
|
||||||
def multi_list(self, command: str, args: dict | None, mapper):
|
def multi_list(self, command: str, args: dict | None, mapper):
|
||||||
"""List command, flattening per-browser results in multi-browser mode.
|
"""List command, flattening per-browser results in multi-browser mode.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "browser-cli",
|
"name": "browser-cli",
|
||||||
"version": "0.15.5",
|
"version": "0.15.6",
|
||||||
"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": {
|
||||||
|
|||||||
@@ -74,6 +74,18 @@ version-check:
|
|||||||
ext=$(grep -m1 '"version"' extension/manifest.json | cut -d'"' -f4); \
|
ext=$(grep -m1 '"version"' extension/manifest.json | cut -d'"' -f4); \
|
||||||
if [ "$py" = "$ext" ]; then echo "ok: $py"; else echo "MISMATCH pyproject=$py manifest=$ext"; exit 1; fi
|
if [ "$py" = "$ext" ]; then echo "ok: $py"; else echo "MISMATCH pyproject=$py manifest=$ext"; exit 1; fi
|
||||||
|
|
||||||
|
# Build into /tmp/dist-browser-cli and publish using credentials from .env
|
||||||
|
publish:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
set -a
|
||||||
|
[ ! -f .env ] || source .env
|
||||||
|
set +a
|
||||||
|
rm -rf /tmp/dist-browser-cli
|
||||||
|
mkdir -p /tmp/dist-browser-cli
|
||||||
|
uv build --out-dir /tmp/dist-browser-cli
|
||||||
|
uv publish /tmp/dist-browser-cli/*
|
||||||
|
|
||||||
# ── Demos ──────────────────────────────────────────────────────────────
|
# ── Demos ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Run the Python SDK demo
|
# Run the Python SDK demo
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "real-browser-cli"
|
name = "real-browser-cli"
|
||||||
version = "0.15.5"
|
version = "0.15.6"
|
||||||
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" }
|
||||||
|
|||||||
+35
-6
@@ -559,12 +559,37 @@ class TestTabs:
|
|||||||
mock_send.side_effect = [3, 4]
|
mock_send.side_effect = [3, 4]
|
||||||
result = b.tabs.count("github")
|
result = b.tabs.count("github")
|
||||||
|
|
||||||
assert result == BrowserCounts(total=7, by_browser={"uuid-1": 3, "work": 4})
|
assert result == BrowserCounts(
|
||||||
|
total=7,
|
||||||
|
by_browser={"uuid-1": 3, "work": 4},
|
||||||
|
browser_groups={"uuid-1": "local", "work": "local"},
|
||||||
|
)
|
||||||
assert mock_send.call_args_list == [
|
assert mock_send.call_args_list == [
|
||||||
call("tabs.count", {"pattern": "github"}, profile="default"),
|
call("tabs.count", {"pattern": "github"}, profile="default"),
|
||||||
call("tabs.count", {"pattern": "github"}, profile="work"),
|
call("tabs.count", {"pattern": "github"}, profile="work"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def test_tabs_count_multi_browser_keeps_remote_display_groups(self, b, mock_send):
|
||||||
|
with patch(
|
||||||
|
"browser_cli.active_browser_targets",
|
||||||
|
return_value=[
|
||||||
|
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"),
|
||||||
|
],
|
||||||
|
):
|
||||||
|
mock_send.side_effect = [1, 2]
|
||||||
|
result = b.tabs.count()
|
||||||
|
|
||||||
|
assert result == BrowserCounts(
|
||||||
|
total=3,
|
||||||
|
by_browser={"browser-host.example:main": 1, "browser-host.example:work": 2},
|
||||||
|
browser_groups={"browser-host.example:main": "browser-host.example", "browser-host.example:work": "browser-host.example"},
|
||||||
|
)
|
||||||
|
assert mock_send.call_args_list == [
|
||||||
|
call("tabs.count", {"pattern": None}, profile="main", remote="browser-host.example:8765", key=None),
|
||||||
|
call("tabs.count", {"pattern": None}, profile="work", remote="browser-host.example:8765", key=None),
|
||||||
|
]
|
||||||
|
|
||||||
def test_tabs_query(self, b, mock_send):
|
def test_tabs_query(self, b, mock_send):
|
||||||
mock_send.return_value = [TAB_DATA]
|
mock_send.return_value = [TAB_DATA]
|
||||||
result = b.tabs.query("example")
|
result = b.tabs.query("example")
|
||||||
@@ -713,7 +738,11 @@ class TestGroups:
|
|||||||
mock_send.side_effect = [2, 5]
|
mock_send.side_effect = [2, 5]
|
||||||
result = b.groups.count()
|
result = b.groups.count()
|
||||||
|
|
||||||
assert result == BrowserCounts(total=7, by_browser={"uuid-1": 2, "work": 5})
|
assert result == BrowserCounts(
|
||||||
|
total=7,
|
||||||
|
by_browser={"uuid-1": 2, "work": 5},
|
||||||
|
browser_groups={"uuid-1": "local", "work": "local"},
|
||||||
|
)
|
||||||
|
|
||||||
def test_group_query(self, b, mock_send):
|
def test_group_query(self, b, mock_send):
|
||||||
mock_send.return_value = [GROUP_DATA]
|
mock_send.return_value = [GROUP_DATA]
|
||||||
@@ -772,8 +801,8 @@ class TestWindows:
|
|||||||
result = b.windows.list()
|
result = b.windows.list()
|
||||||
|
|
||||||
assert result == [
|
assert result == [
|
||||||
{"id": 1, "tabCount": 2, "state": "normal", "browser": "uuid-1"},
|
{"id": 1, "tabCount": 2, "state": "normal", "browser": "uuid-1", "browserGroup": "local"},
|
||||||
{"id": 2, "tabCount": 3, "state": "maximized", "browser": "work"},
|
{"id": 2, "tabCount": 3, "state": "maximized", "browser": "work", "browserGroup": "local"},
|
||||||
]
|
]
|
||||||
|
|
||||||
def test_windows_open_without_url(self, b, mock_send):
|
def test_windows_open_without_url(self, b, mock_send):
|
||||||
@@ -907,8 +936,8 @@ class TestSession:
|
|||||||
result = b.session.list()
|
result = b.session.list()
|
||||||
|
|
||||||
assert result == [
|
assert result == [
|
||||||
{"name": "first", "tabs": 2, "savedAt": 1712707200000, "browser": "uuid-1"},
|
{"name": "first", "tabs": 2, "savedAt": 1712707200000, "browser": "uuid-1", "browserGroup": "local"},
|
||||||
{"name": "second", "tabs": 5, "savedAt": 1712707300000, "browser": "work"},
|
{"name": "second", "tabs": 5, "savedAt": 1712707300000, "browser": "work", "browserGroup": "local"},
|
||||||
]
|
]
|
||||||
assert mock_send.call_args_list == [
|
assert mock_send.call_args_list == [
|
||||||
call("session.list", {}, profile="default"),
|
call("session.list", {}, profile="default"),
|
||||||
|
|||||||
+135
-1
@@ -217,7 +217,13 @@ def test_clients_without_remote_shows_saved_remotes_without_pq_warning(tmp_path)
|
|||||||
|
|
||||||
registry_path = tmp_path / "registry.json"
|
registry_path = tmp_path / "registry.json"
|
||||||
registry_path.write_text('{"main": "/tmp/.browser_cli/main.sock"}', encoding="utf-8")
|
registry_path.write_text('{"main": "/tmp/.browser_cli/main.sock"}', encoding="utf-8")
|
||||||
remote_target = BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765")
|
remote_target = 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, suppress_pq_warning=False):
|
def fake_send_command(command, args=None, profile=None, remote=None, key=None, suppress_pq_warning=False):
|
||||||
assert command == "clients.list"
|
assert command == "clients.list"
|
||||||
@@ -239,7 +245,11 @@ def test_clients_without_remote_shows_saved_remotes_without_pq_warning(tmp_path)
|
|||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
active_targets.assert_called_once_with(suppress_pq_warning=True)
|
active_targets.assert_called_once_with(suppress_pq_warning=True)
|
||||||
|
assert "local" in result.output
|
||||||
assert "Chrome" in result.output
|
assert "Chrome" in result.output
|
||||||
|
assert "browser-host.example" in result.output
|
||||||
|
assert "browser-host.example:work" not in result.output
|
||||||
|
assert "work" in result.output
|
||||||
assert "Remote Chrome" in result.output
|
assert "Remote Chrome" in result.output
|
||||||
assert "post-quantum" not in result.output
|
assert "post-quantum" not in result.output
|
||||||
|
|
||||||
@@ -362,9 +372,33 @@ def test_tabs_list_multi_browser_shows_browser_column():
|
|||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Browser" in result.output
|
assert "Browser" in result.output
|
||||||
|
assert "local" in result.output
|
||||||
assert "550e8400-e29b-41d4-a716-446655440000" in result.output
|
assert "550e8400-e29b-41d4-a716-446655440000" in result.output
|
||||||
assert "work" in result.output
|
assert "work" in result.output
|
||||||
|
|
||||||
|
def test_tabs_list_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 command == "tabs.list"
|
||||||
|
assert remote == "browser-host.example:8765"
|
||||||
|
return [{"id": 1, "windowId": 1, "active": True, "title": profile, "url": "https://example.com"}]
|
||||||
|
|
||||||
|
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", "list"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "browser-host.example" in result.output
|
||||||
|
assert "browser-host.example:main" not in result.output
|
||||||
|
assert "browser-host.example:work" not in result.output
|
||||||
|
assert "main" in result.output
|
||||||
|
assert "work" in result.output
|
||||||
|
|
||||||
def test_tabs_list_with_remote_uses_only_remote_targets():
|
def test_tabs_list_with_remote_uses_only_remote_targets():
|
||||||
with patch(
|
with patch(
|
||||||
"browser_cli.active_browser_targets",
|
"browser_cli.active_browser_targets",
|
||||||
@@ -495,9 +529,36 @@ def test_tabs_count_multi_browser_shows_total():
|
|||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Browser" in result.output
|
assert "Browser" in result.output
|
||||||
|
assert "local" in result.output
|
||||||
assert "Total" in result.output
|
assert "Total" in result.output
|
||||||
assert "7" in result.output
|
assert "7" in result.output
|
||||||
|
|
||||||
|
def test_tabs_count_unscoped_groups_remote_targets_by_host():
|
||||||
|
counts = {"main": 1, "work": 2}
|
||||||
|
|
||||||
|
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
|
||||||
|
assert command == "tabs.count"
|
||||||
|
assert remote == "browser-host.example:8765"
|
||||||
|
return counts[profile]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"browser_cli.active_browser_targets",
|
||||||
|
return_value=[
|
||||||
|
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"),
|
||||||
|
],
|
||||||
|
), patch("browser_cli.send_command", side_effect=fake_send_command):
|
||||||
|
result = CliRunner().invoke(main, ["tabs", "count"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "browser-host.example" in result.output
|
||||||
|
assert "browser-host.example:main" not in result.output
|
||||||
|
assert "browser-host.example:work" not in result.output
|
||||||
|
assert "main" in result.output
|
||||||
|
assert "work" in result.output
|
||||||
|
assert "Total" in result.output
|
||||||
|
assert "3" in result.output
|
||||||
|
|
||||||
def test_group_count_multi_browser_shows_total():
|
def test_group_count_multi_browser_shows_total():
|
||||||
counts = {"default": 1, "work": 2}
|
counts = {"default": 1, "work": 2}
|
||||||
|
|
||||||
@@ -519,6 +580,29 @@ def test_group_count_multi_browser_shows_total():
|
|||||||
assert "Total" in result.output
|
assert "Total" in result.output
|
||||||
assert "3" in result.output
|
assert "3" in result.output
|
||||||
|
|
||||||
|
def test_groups_list_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 command == "group.list"
|
||||||
|
assert remote == "browser-host.example:8765"
|
||||||
|
return [{"id": 1, "title": profile, "color": "blue", "collapsed": False, "tabCount": 2}]
|
||||||
|
|
||||||
|
with patch("browser_cli.active_browser_targets", return_value=targets), patch(
|
||||||
|
"browser_cli.send_command", side_effect=fake_send_command
|
||||||
|
):
|
||||||
|
result = CliRunner().invoke(main, ["groups", "list"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "browser-host.example" in result.output
|
||||||
|
assert "browser-host.example:main" not in result.output
|
||||||
|
assert "browser-host.example:work" not in result.output
|
||||||
|
assert "main" in result.output
|
||||||
|
assert "work" in result.output
|
||||||
|
|
||||||
def test_group_list_leaves_unnamed_group_cell_empty():
|
def test_group_list_leaves_unnamed_group_cell_empty():
|
||||||
with patch(
|
with patch(
|
||||||
"browser_cli.send_command",
|
"browser_cli.send_command",
|
||||||
@@ -567,10 +651,34 @@ def test_windows_list_multi_browser_shows_browser_column():
|
|||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Browser" in result.output
|
assert "Browser" in result.output
|
||||||
|
assert "local" in result.output
|
||||||
assert "Focused" not in result.output
|
assert "Focused" not in result.output
|
||||||
assert "uuid-1" in result.output
|
assert "uuid-1" in result.output
|
||||||
assert "work" in result.output
|
assert "work" in result.output
|
||||||
|
|
||||||
|
def test_windows_list_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 command == "windows.list"
|
||||||
|
assert remote == "browser-host.example:8765"
|
||||||
|
return [{"id": 1, "alias": profile, "focused": True, "tabCount": 2, "state": "normal"}]
|
||||||
|
|
||||||
|
with patch("browser_cli.active_browser_targets", return_value=targets), patch(
|
||||||
|
"browser_cli.send_command", side_effect=fake_send_command
|
||||||
|
):
|
||||||
|
result = CliRunner().invoke(main, ["windows", "list"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "browser-host.example" in result.output
|
||||||
|
assert "browser-host.example:main" not in result.output
|
||||||
|
assert "browser-host.example:work" not in result.output
|
||||||
|
assert "main" in result.output
|
||||||
|
assert "work" in result.output
|
||||||
|
|
||||||
def test_session_list_multi_browser_shows_browser_column():
|
def test_session_list_multi_browser_shows_browser_column():
|
||||||
def fake_send_command(command, args=None, profile=None):
|
def fake_send_command(command, args=None, profile=None):
|
||||||
assert command == "session.list"
|
assert command == "session.list"
|
||||||
@@ -587,11 +695,37 @@ def test_session_list_multi_browser_shows_browser_column():
|
|||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Browser" in result.output
|
assert "Browser" in result.output
|
||||||
|
assert "local" in result.output
|
||||||
assert "uuid-1" in result.output
|
assert "uuid-1" in result.output
|
||||||
assert "work" in result.output
|
assert "work" in result.output
|
||||||
assert "default-session" in result.output
|
assert "default-session" in result.output
|
||||||
assert "work-session" in result.output
|
assert "work-session" in result.output
|
||||||
|
|
||||||
|
def test_session_list_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 command == "session.list"
|
||||||
|
assert remote == "browser-host.example:8765"
|
||||||
|
return [{"name": f"{profile}-session", "tabs": 2, "savedAt": 1712707200000}]
|
||||||
|
|
||||||
|
with patch("browser_cli.active_browser_targets", return_value=targets), patch(
|
||||||
|
"browser_cli.send_command", side_effect=fake_send_command
|
||||||
|
):
|
||||||
|
result = CliRunner().invoke(main, ["session", "list"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "browser-host.example" in result.output
|
||||||
|
assert "browser-host.example:main" not in result.output
|
||||||
|
assert "browser-host.example:work" not in result.output
|
||||||
|
assert "main" in result.output
|
||||||
|
assert "work" in result.output
|
||||||
|
assert "main-session" in result.output
|
||||||
|
assert "work-session" in result.output
|
||||||
|
|
||||||
def test_session_list_with_explicit_browser_does_not_show_browser_column():
|
def test_session_list_with_explicit_browser_does_not_show_browser_column():
|
||||||
with patch(
|
with patch(
|
||||||
"browser_cli.active_browser_targets",
|
"browser_cli.active_browser_targets",
|
||||||
|
|||||||
@@ -465,7 +465,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "real-browser-cli"
|
name = "real-browser-cli"
|
||||||
version = "0.15.5"
|
version = "0.15.6"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
|
|||||||
Reference in New Issue
Block a user