Compare commits

..

4 Commits

Author SHA1 Message Date
daniel156161 93f8994f6a fix: prefer active local browser profiles
Testing / remote-protocol-compat (0.9.3) (push) Successful in 30s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 29s
Package Extension / package-extension (push) Successful in 29s
Build & Publish Package / publish (push) Successful in 33s
Testing / test (push) Successful in 25s
- Avoid resolving a saved remote alias when the requested profile is currently reachable as a local endpoint.
- Add a helper that checks the registry and local socket path before remote alias discovery.
- Cover the routing precedence with a client unit test.
- Bump package and extension versions to 0.10.1.
2026-05-21 22:56:05 +02:00
daniel156161 9aad012bdc test: expand browser command coverage
- Add mocked Click CLI tests across DOM, cookies, page, storage, perf, navigation, tabs, groups, sessions, and windows commands.
- Cover integration paths for cookies, page info, performance profiles, and web storage commands.
- Extend DOM integration coverage for eval, scrolling, waiting, focus, hover, typing, selection, keyboard, and checkbox interactions.
- Add native host unit coverage for message framing, socket helpers, paging guards, timeouts, and profile alias resolution.
2026-05-20 23:53:56 +02:00
daniel156161 545abeb515 feat: add performance controls for large browser ops
- Add throttled large-operation handling for tab, group, and session commands.
- Introduce performance profiles, audible-tab aware gentle mode, and job progress tracking.
- Support background session restores with status/cancel commands and lazy placeholders.
- Expose new perf and extension CLI groups plus matching Python SDK methods.
- Preserve pinned tabs during session snapshots and debounce auto-save updates.
- Bump browser-cli and extension versions to 0.10.0 and add pytest-cov to dev deps.
- Add coverage for performance controls, background jobs, lazy restores, and tab metadata.
2026-05-20 22:13:57 +02:00
daniel156161 e1e4adbb25 feat(sdk): improve Python SDK ergonomics
Testing / remote-protocol-compat (0.9.3) (push) Successful in 42s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 45s
Testing / test (push) Successful in 42s
- Position browser-cli as a CLI plus Python SDK in docs and package metadata.
- Add public target properties and a raw command escape hatch for unsupported commands.
- Add convenience helpers for opening, finding, closing, and accessing tabs.
- Add plural group aliases and a wait_for_selector DOM convenience alias.
- Extend bound Tab objects with screenshot, pin, refresh, load wait, and URL watch helpers.
- Preserve remote auth key configuration when binding remote Tab and Group objects.
- Bump project and extension versions to 0.9.9 and cover SDK additions with tests.
2026-05-19 20:12:16 +02:00
30 changed files with 3165 additions and 172 deletions
+28 -11
View File
@@ -1,19 +1,16 @@
# 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 or a Python script — 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 CLI tool. The extension has full access to your browser's tabs, windows, groups, and page DOM. The CLI talks 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.
--- ---
## How it works ## How it works
``` ```
terminal / python script terminal / python script
@@ -79,9 +76,9 @@ 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 API — BrowserCLI class and Python API entry point │ ├── __init__.py # Python SDK — BrowserCLI class and SDK entry point
│ ├── cli.py # Click CLI entry point │ ├── cli.py # Click CLI entry point
│ ├── client.py # Local IPC client used by CLI and API │ ├── client.py # Local IPC client used by CLI and SDK
│ ├── models.py # Tab and Group helper models │ ├── models.py # Tab and Group helper models
│ ├── native_host.py # Native messaging host launched by the browser │ ├── native_host.py # Native messaging host launched by the browser
│ └── commands/ │ └── commands/
@@ -99,7 +96,7 @@ browser-cli/
│ └── src/ # TypeScript source split by command area │ └── src/ # TypeScript source split by command area
│ └── index.ts # Builds generated extension/background.js │ └── index.ts # Builds generated extension/background.js
├── examples/ ├── examples/
│ ├── demo.py # Python API walkthrough │ ├── demo.py # Python SDK walkthrough
│ └── demo.sh # Bash CLI walkthrough │ └── demo.sh # Bash CLI walkthrough
├── tests/ ├── tests/
│ ├── conftest.py # shared pytest fixtures │ ├── conftest.py # shared pytest fixtures
@@ -285,7 +282,7 @@ browser-cli completion zsh --script # output raw completion script
--- ---
## Python API ## Python SDK
```python ```python
from browser_cli import BrowserCLI from browser_cli import BrowserCLI
@@ -293,11 +290,13 @@ from browser_cli import BrowserCLI
b = BrowserCLI() b = BrowserCLI()
``` ```
Every CLI command has a corresponding method. The call blocks until the browser responds and returns the data directly as a Python object. Every CLI command has a corresponding SDK method. The call blocks until the browser responds and returns the data directly as a Python object.
```python ```python
# Navigation # Navigation
b.open("https://example.com") b.open("https://example.com")
tab = b.open_tab("https://example.com") # returns a bound Tab object
tab = b.open_tab("https://example.com", wait=True, timeout=10)
b.open("https://example.com", background=True) b.open("https://example.com", background=True)
b.open("https://example.com", window="work") b.open("https://example.com", window="work")
b.reload() b.reload()
@@ -308,8 +307,14 @@ b.focus_url("github")
# Tabs # Tabs
tabs = b.tabs_list() # list[Tab]; in multi-browser mode each tab.browser is set tabs = b.tabs_list() # list[Tab]; in multi-browser mode each tab.browser is set
tabs = b.tabs() # short alias for tabs_list()
active = b.active_tab() # active Tab object
tab = b.tab(1234) # tab by ID
tab = b.find_tab("github") # first matching tab or None
tabs = b.find_tabs("github") # alias for tabs_query()
b.tabs_active(1234) b.tabs_active(1234)
b.tabs_close(1234) b.tabs_close(1234)
b.close_tab(tab) # accepts Tab or tab ID
b.tabs_close_inactive() b.tabs_close_inactive()
b.tabs_close_duplicates() b.tabs_close_duplicates()
b.tabs_filter("youtube") # list of matching tabs b.tabs_filter("youtube") # list of matching tabs
@@ -320,9 +325,19 @@ b.tabs_sort(by="domain")
b.tabs_merge_windows() b.tabs_merge_windows()
b.tabs_dedupe() b.tabs_dedupe()
# Bound Tab helpers
tab = b.active_tab()
tab.pin()
tab.screenshot()
tab.refresh()
tab.wait_for_load(timeout=10)
tab.watch_url(r"/done$")
# Tab groups # Tab groups
groups = b.group_list() # list[Group]; in multi-browser mode each group.browser is set groups = b.group_list() # list[Group]; in multi-browser mode each group.browser is set
b.group_open("research") # creates group, returns { id, name } groups = b.groups() # short alias for group_list()
b.groups_create("research") # plural alias for group_create()
b.group_create("research") # creates group, returns Group
b.group_close(42) b.group_close(42)
b.group_tabs(42) # tabs inside a group b.group_tabs(42) # tabs inside a group
b.group_count() # int, or BrowserCounts(...) in multi-browser mode b.group_count() # int, or BrowserCounts(...) in multi-browser mode
@@ -341,6 +356,7 @@ attrs = b.dom_attr("a", "href") # list of strings
exists = b.dom_exists(".cookie-banner")# bool exists = b.dom_exists(".cookie-banner")# bool
b.dom_click(".accept-button") b.dom_click(".accept-button")
b.dom_type("#search", "hello world") b.dom_type("#search", "hello world")
b.wait_for_selector("#results", visible=True, timeout=10)
# Extract # Extract
links = b.extract_links() # list of { text, href } links = b.extract_links() # list of { text, href }
@@ -359,6 +375,7 @@ b.session_auto_save(True)
# Misc # Misc
clients = b.clients() clients = b.clients()
raw = b.command("tabs.count", {"pattern": "github"}) # escape hatch for raw commands
``` ```
**Error handling** **Error handling**
+164 -3
View File
@@ -1,5 +1,5 @@
""" """
browser_cli — Python API for controlling your running browser. browser_cli — Python SDK for controlling your running browser.
Usage: Usage:
from browser_cli import BrowserCLI from browser_cli import BrowserCLI
@@ -50,9 +50,32 @@ class BrowserCLI:
self._remote = remote self._remote = remote
self._key = key if key else None self._key = key if key else None
@property
def browser(self) -> str | None:
"""Target browser/profile alias, equivalent to ``--browser``."""
return self._browser
@property
def remote(self) -> str | None:
"""Remote endpoint used by this client, if any."""
return self._remote
@property
def key(self) -> str | None:
"""Ed25519 key spec used for remote auth, if explicitly configured."""
return self._key
def _cmd(self, command: str, args: dict | None = None): def _cmd(self, command: str, args: dict | None = None):
return send_command(command, args, profile=self._browser, remote=self._remote, key=self._key) return send_command(command, args, profile=self._browser, remote=self._remote, key=self._key)
def command(self, command: str, args: dict | None = None):
"""Send a raw browser-cli command and return its response.
This is the SDK escape hatch for commands that do not have a dedicated
convenience method yet.
"""
return self._cmd(command, args or {})
def _multi_browser_targets(self): def _multi_browser_targets(self):
if self._browser is not None: if self._browser is not None:
return [] return []
@@ -109,6 +132,7 @@ class BrowserCLI:
tab._browser = self if browser_profile is None else BrowserCLI( tab._browser = self if browser_profile is None else BrowserCLI(
browser=browser_profile, browser=browser_profile,
remote=browser_remote, remote=browser_remote,
key=self._key,
) )
return tab return tab
@@ -131,6 +155,7 @@ class BrowserCLI:
group._browser = self if browser_profile is None else BrowserCLI( group._browser = self if browser_profile is None else BrowserCLI(
browser=browser_profile, browser=browser_profile,
remote=browser_remote, remote=browser_remote,
key=self._key,
) )
return group return group
@@ -139,6 +164,27 @@ class BrowserCLI:
def open(self, url: str, *, background: bool = False, window: str | None = None, group: str | None = None) -> None: def open(self, url: str, *, background: bool = False, window: str | None = None, group: str | None = None) -> None:
self._cmd("navigate.open", {"url": url, "background": background, "window": window, "group": group}) self._cmd("navigate.open", {"url": url, "background": background, "window": window, "group": group})
def open_tab(
self,
url: str,
*,
wait: bool = False,
timeout: float = 30.0,
background: bool = False,
window: str | None = None,
group: str | None = None,
) -> "Tab":
"""Open URL in a new tab and return a bound :class:`Tab` object.
Set ``wait=True`` to block until the page reaches ``readyState=complete``.
"""
if wait:
return self.open_wait(url, timeout=timeout, background=background, window=window, group=group)
data = self._cmd("navigate.open", {"url": url, "background": background, "window": window, "group": group})
if not isinstance(data, dict) or "id" not in data:
raise RuntimeError("navigate.open returned unexpected data")
return self._make_tab(data)
def reload(self, tab_id: int | None = None) -> None: def reload(self, tab_id: int | None = None) -> None:
self._cmd("navigate.reload", {"tabId": tab_id}) self._cmd("navigate.reload", {"tabId": tab_id})
@@ -216,6 +262,32 @@ class BrowserCLI:
# ── Tabs ────────────────────────────────────────────────────────────── # ── Tabs ──────────────────────────────────────────────────────────────
def tabs(self) -> list[Tab]:
"""Alias for :meth:`tabs_list`."""
return self.tabs_list()
def tab(self, tab_id: int) -> Tab:
"""Return a specific tab by ID."""
return self.tabs_status(tab_id)
def active_tab(self) -> Tab:
"""Return the active tab."""
return self.tabs_status()
def find_tabs(self, search: str) -> list[Tab]:
"""Alias for :meth:`tabs_query`."""
return self.tabs_query(search)
def find_tab(self, search: str) -> Tab | None:
"""Return the first tab matching *search*, or ``None``."""
matches = self.tabs_query(search)
return matches[0] if matches else None
def close_tab(self, tab: int | Tab) -> int:
"""Close a tab by ID or :class:`Tab` object. Returns count closed."""
tab_id = tab.id if isinstance(tab, Tab) else tab
return self.tabs_close(tab_id)
def tabs_list(self) -> list[Tab]: def tabs_list(self) -> list[Tab]:
"""Return all open tabs across all windows. """Return all open tabs across all windows.
@@ -370,6 +442,14 @@ class BrowserCLI:
# ── Tab Groups ──────────────────────────────────────────────────────── # ── Tab Groups ────────────────────────────────────────────────────────
def groups(self) -> list[Group]:
"""Alias for :meth:`group_list`."""
return self.group_list()
def groups_list(self) -> list[Group]:
"""Alias for :meth:`group_list`."""
return self.group_list()
def group_list(self) -> list[Group]: def group_list(self) -> list[Group]:
"""Return all tab groups. """Return all tab groups.
@@ -394,6 +474,10 @@ class BrowserCLI:
"""Return all tabs inside a group.""" """Return all tabs inside a group."""
return [self._make_tab(t) for t in (self._cmd("group.tabs", {"groupId": group_id}) or [])] return [self._make_tab(t) for t in (self._cmd("group.tabs", {"groupId": group_id}) or [])]
def groups_count(self) -> int | BrowserCounts:
"""Alias for :meth:`group_count`."""
return self.group_count()
def group_count(self) -> int | BrowserCounts: def group_count(self) -> int | BrowserCounts:
"""Return the number of tab groups. """Return the number of tab groups.
@@ -405,6 +489,10 @@ class BrowserCLI:
return BrowserCounts(total=sum(by_browser.values()), by_browser=by_browser) return BrowserCounts(total=sum(by_browser.values()), by_browser=by_browser)
return self._cmd("group.count", {}) return self._cmd("group.count", {})
def groups_query(self, search: str) -> list[Group]:
"""Alias for :meth:`group_query`."""
return self.group_query(search)
def group_query(self, search: str) -> list[Group]: def group_query(self, search: str) -> list[Group]:
"""Search groups by name.""" """Search groups by name."""
return [self._make_group(g) for g in (self._cmd("group.query", {"search": search}) or [])] return [self._make_group(g) for g in (self._cmd("group.query", {"search": search}) or [])]
@@ -413,6 +501,14 @@ class BrowserCLI:
"""Ungroup (and close) a tab group by ID.""" """Ungroup (and close) a tab group by ID."""
self._cmd("group.close", {"groupId": group_id}) self._cmd("group.close", {"groupId": group_id})
def groups_create(self, name: str) -> Group:
"""Alias for :meth:`group_create`."""
return self.group_create(name)
def group_open(self, name: str) -> Group:
"""Alias for :meth:`group_create`."""
return self.group_create(name)
def group_create(self, name: str) -> Group: def group_create(self, name: str) -> Group:
"""Create a new tab group with *name*. Returns the created Group.""" """Create a new tab group with *name*. Returns the created Group."""
data = self._cmd("group.open", {"name": name}) data = self._cmd("group.open", {"name": name})
@@ -536,6 +632,18 @@ class BrowserCLI:
"tabId": tab_id, "tabId": tab_id,
}) })
def wait_for_selector(
self,
selector: str,
*,
timeout: float = 10.0,
visible: bool = False,
hidden: bool = False,
tab_id: int | None = None,
) -> dict:
"""Alias for :meth:`dom_wait_for`."""
return self.dom_wait_for(selector, timeout=timeout, visible=visible, hidden=hidden, tab_id=tab_id)
def dom_wait_for( def dom_wait_for(
self, self,
selector: str, selector: str,
@@ -650,8 +758,61 @@ class BrowserCLI:
def session_save(self, name: str) -> None: def session_save(self, name: str) -> None:
self._cmd("session.save", {"name": name}) self._cmd("session.save", {"name": name})
def session_load(self, name: str) -> None: def session_load(
self._cmd("session.load", {"name": name}) self,
name: str,
*,
gentle_mode: str = "auto",
discard_background_tabs: bool = False,
lazy: bool = False,
eager_tabs: int = 10,
) -> None:
self._cmd("session.load", {
"name": name,
"gentleMode": gentle_mode,
"discardBackgroundTabs": discard_background_tabs,
"lazy": lazy,
"eagerTabs": eager_tabs,
})
def session_load_background(
self,
name: str,
*,
gentle_mode: str = "auto",
discard_background_tabs: bool = False,
lazy: bool = False,
eager_tabs: int = 10,
) -> dict:
return self._cmd("session.load", {
"name": name,
"gentleMode": gentle_mode,
"discardBackgroundTabs": discard_background_tabs,
"lazy": lazy,
"eagerTabs": eager_tabs,
"__background": True,
}) or {}
def job_status(self, job_id: str) -> dict:
return self._cmd("jobs.status", {"jobId": job_id}) or {}
def job_cancel(self, job_id: str) -> dict:
return self._cmd("jobs.cancel", {"jobId": job_id}) or {}
def reload_extension(self) -> None:
"""Reload the browser-cli extension service worker.
Schedules a ``chrome.runtime.reload()`` inside the extension and returns
immediately. The extension restarts ~200 ms later and reconnects via the
keepalive alarm within ~25 seconds.
"""
self._cmd("extension.reload", {})
def perf_status(self) -> dict:
return self._cmd("perf.status", {}) or {}
def set_performance_profile(self, profile: str) -> dict:
return self._cmd("perf.set_profile", {"profile": profile}) or {}
def session_diff(self, name_a: str, name_b: str) -> dict: def session_diff(self, name_a: str, name_b: str) -> dict:
return self._cmd("session.diff", {"nameA": name_a, "nameB": name_b}) or {} return self._cmd("session.diff", {"nameA": name_a, "nameB": name_b}) or {}
+4
View File
@@ -23,6 +23,8 @@ from browser_cli.commands.search import search_group
from browser_cli.commands.page import page_group from browser_cli.commands.page import page_group
from browser_cli.commands.storage import storage_group from browser_cli.commands.storage import storage_group
from browser_cli.commands.cookies import cookies_group from browser_cli.commands.cookies import cookies_group
from browser_cli.commands.perf import perf_group
from browser_cli.commands.extension import extension_group
from browser_cli.commands.serve import cmd_serve from browser_cli.commands.serve import cmd_serve
from browser_cli.client import ( from browser_cli.client import (
send_command, send_command,
@@ -375,6 +377,8 @@ main.add_command(search_group)
main.add_command(page_group) main.add_command(page_group)
main.add_command(storage_group) main.add_command(storage_group)
main.add_command(cookies_group) main.add_command(cookies_group)
main.add_command(perf_group)
main.add_command(extension_group)
main.add_command(cmd_serve) main.add_command(cmd_serve)
+17 -1
View File
@@ -214,6 +214,22 @@ def active_browser_targets(*, include_remotes: bool = True, key=None) -> list[Br
return targets return targets
def _is_active_local_profile(profile: str | None) -> bool:
"""Return True when profile names a reachable local browser endpoint."""
if not profile:
return False
if REGISTRY_PATH.exists():
reg = load_registry(REGISTRY_PATH)
if profile in _active_endpoints(reg):
return True
if not is_windows():
try:
return Path(endpoint_for_alias(profile)).exists()
except Exception:
return False
return False
def _resolve_socket(profile: str | None = None) -> str: def _resolve_socket(profile: str | None = None) -> str:
"""Return the socket path for the given profile (or auto-detect).""" """Return the socket path for the given profile (or auto-detect)."""
target = profile or os.environ.get("BROWSER_CLI_PROFILE") target = profile or os.environ.get("BROWSER_CLI_PROFILE")
@@ -387,7 +403,7 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
if remote_endpoint: if remote_endpoint:
remote_endpoint = _normalize_endpoint(remote_endpoint) remote_endpoint = _normalize_endpoint(remote_endpoint)
remote_alias_target = None remote_alias_target = None
if not remote_endpoint and requested_profile: if not remote_endpoint and requested_profile and not _is_active_local_profile(requested_profile):
remote_alias_target = remote_target_for_alias(requested_profile) remote_alias_target = remote_target_for_alias(requested_profile)
if remote_alias_target: if remote_alias_target:
remote_endpoint = remote_alias_target.remote remote_endpoint = remote_alias_target.remote
+21
View File
@@ -0,0 +1,21 @@
import time
import click
from rich.console import Console
from browser_cli.commands import _handle
console = Console()
@click.group("extension")
def extension_group():
"""Manage the browser-cli browser extension."""
@extension_group.command("reload")
def extension_reload():
"""Reload the browser-cli extension service worker.
Useful after updating background.js without restarting the browser.
The command returns immediately; the extension restarts ~200 ms later.
Re-connects automatically via the keepalive alarm within ~25 seconds.
"""
_handle("extension.reload")
console.print("[green]Extension reloading…[/green] reconnects automatically")
+3 -2
View File
@@ -103,9 +103,10 @@ def group_query(search):
@group_group.command("close") @group_group.command("close")
@click.argument("group_id", type=int) @click.argument("group_id", type=int)
def group_close(group_id): @click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large group operations.")
def group_close(group_id, gentle_mode):
"""Close (ungroup and optionally close) a tab group.""" """Close (ungroup and optionally close) a tab group."""
_handle("group.close", {"groupId": group_id}) _handle("group.close", {"groupId": group_id, "gentleMode": gentle_mode})
console.print(f"[green]Group {group_id} closed[/green]") console.print(f"[green]Group {group_id} closed[/green]")
+45
View File
@@ -0,0 +1,45 @@
import click
from rich.console import Console
from rich.table import Table
from browser_cli.commands import _handle
console = Console()
@click.group("perf")
def perf_group():
"""Inspect and tune browser-cli performance behavior."""
@perf_group.command("status")
def perf_status():
"""Show performance profile, throttle and running jobs."""
result = _handle("perf.status") or {}
console.print(f"Profile: [bold]{result.get('performanceProfile', 'auto')}[/bold]")
console.print(f"Audible tabs: {'yes' if result.get('audible') else 'no'}")
throttle = result.get("throttle") or {}
console.print(f"Throttle: batch={throttle.get('batchSize')} pause={throttle.get('pauseMs')}ms mode={throttle.get('mode')}")
jobs = result.get("jobs") or []
if not jobs:
console.print("[yellow]No running jobs[/yellow]")
return
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Job")
table.add_column("Command")
table.add_column("Status")
table.add_column("Progress", justify="right")
table.add_column("Phase")
for job in jobs:
total = job.get("total")
current = job.get("current") or 0
percent = job.get("percent") or 0
progress = f"{current}/{total} ({percent}%)" if total else f"{percent}%"
table.add_row(job.get("id", ""), job.get("command", ""), job.get("status", ""), progress, job.get("phase", ""))
console.print(table)
@perf_group.command("profile")
@click.argument("profile", type=click.Choice(["auto", "normal", "gentle", "ultra"]))
def perf_profile(profile):
"""Set global performance profile."""
result = _handle("perf.set_profile", {"profile": profile}) or {}
console.print(f"[green]Performance profile set to {result.get('performanceProfile', profile)}[/green]")
+35 -8
View File
@@ -4,12 +4,10 @@ from rich.console import Console
console = Console() console = Console()
@click.group("session") @click.group("session")
def session_group(): def session_group():
"""Save and restore browser sessions.""" """Save and restore browser sessions."""
@session_group.command("save") @session_group.command("save")
@click.argument("name") @click.argument("name")
def session_save(name): def session_save(name):
@@ -18,16 +16,29 @@ def session_save(name):
count = result.get("tabs", 0) if isinstance(result, dict) else 0 count = result.get("tabs", 0) if isinstance(result, dict) else 0
console.print(f"[green]Session '{name}' saved[/green] ({count} tabs)") console.print(f"[green]Session '{name}' saved[/green] ({count} tabs)")
@session_group.command("load") @session_group.command("load")
@click.argument("name") @click.argument("name")
def session_load(name): @click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large restores.")
@click.option("--discard-background-tabs", is_flag=True, help="Discard restored background tabs after opening to reduce load.")
@click.option("--lazy", is_flag=True, help="Create lightweight placeholder tabs after --eager-tabs; placeholders load when selected.")
@click.option("--eager-tabs", type=int, default=10, show_default=True, help="Number of real tabs to open before lazy placeholders.")
@click.option("--background", "background_job", is_flag=True, help="Start restore as a background job and return immediately.")
def session_load(name, gentle_mode, discard_background_tabs, lazy, eager_tabs, background_job):
"""Restore session NAME (opens all saved tabs).""" """Restore session NAME (opens all saved tabs)."""
result = _handle("session.load", {"name": name}) result = _handle("session.load", {
"name": name,
"gentleMode": gentle_mode,
"discardBackgroundTabs": discard_background_tabs,
"lazy": lazy,
"eagerTabs": eager_tabs,
"__background": background_job,
})
if background_job and isinstance(result, dict) and result.get("jobId"):
console.print(f"[green]Session restore started[/green] job={result['jobId']}")
return
count = result.get("tabs", 0) if isinstance(result, dict) else 0 count = result.get("tabs", 0) if isinstance(result, dict) else 0
console.print(f"[green]Session '{name}' loaded[/green] ({count} tabs opened)") console.print(f"[green]Session '{name}' loaded[/green] ({count} tabs opened)")
@session_group.command("diff") @session_group.command("diff")
@click.argument("name_a") @click.argument("name_a")
@click.argument("name_b") @click.argument("name_b")
@@ -54,7 +65,6 @@ def session_diff(name_a, name_b):
if not added and not removed: if not added and not removed:
console.print("[green]Sessions are identical[/green]") console.print("[green]Sessions are identical[/green]")
@session_group.command("list") @session_group.command("list")
def session_list(): def session_list():
"""List all saved sessions.""" """List all saved sessions."""
@@ -90,7 +100,6 @@ def session_list():
table.add_row(*row) table.add_row(*row)
console.print(table) console.print(table)
@session_group.command("remove") @session_group.command("remove")
@click.argument("name") @click.argument("name")
def session_remove(name): def session_remove(name):
@@ -98,6 +107,24 @@ def session_remove(name):
_handle("session.remove", {"name": name}) _handle("session.remove", {"name": name})
console.print(f"[green]Session '{name}' removed[/green]") console.print(f"[green]Session '{name}' removed[/green]")
@session_group.command("job-status")
@click.argument("job_id")
def session_job_status(job_id):
"""Show status for a background session job."""
result = _handle("jobs.status", {"jobId": job_id}) or {}
status = result.get("status", "unknown")
console.print(f"[bold]{job_id}[/bold]: {status}")
if result.get("error"):
console.print(f"[red]{result['error']}[/red]")
elif result.get("result"):
console.print(result["result"])
@session_group.command("job-cancel")
@click.argument("job_id")
def session_job_cancel(job_id):
"""Cancel a running background job."""
_handle("jobs.cancel", {"jobId": job_id})
console.print(f"[green]Cancel requested for {job_id}[/green]")
@session_group.command("auto-save") @session_group.command("auto-save")
@click.argument("state", type=click.Choice(["on", "off"])) @click.argument("state", type=click.Choice(["on", "off"]))
+12 -8
View File
@@ -66,9 +66,10 @@ def tabs_list():
@click.argument("tab_id", type=int, required=False) @click.argument("tab_id", type=int, required=False)
@click.option("--inactive", is_flag=True, help="Close all inactive tabs") @click.option("--inactive", is_flag=True, help="Close all inactive tabs")
@click.option("--duplicates", is_flag=True, help="Close duplicate tabs (keep first)") @click.option("--duplicates", is_flag=True, help="Close duplicate tabs (keep first)")
def tabs_close(tab_id, inactive, duplicates): @click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large close operations.")
def tabs_close(tab_id, inactive, duplicates, gentle_mode):
"""Close a tab, all inactive tabs, or all duplicate tabs.""" """Close a tab, all inactive tabs, or all duplicate tabs."""
result = _handle("tabs.close", {"tabId": tab_id, "inactive": inactive, "duplicates": duplicates}) result = _handle("tabs.close", {"tabId": tab_id, "inactive": inactive, "duplicates": duplicates, "gentleMode": gentle_mode})
count = result.get("closed", 0) if isinstance(result, dict) else 1 count = result.get("closed", 0) if isinstance(result, dict) else 1
console.print(f"[green]Closed {count} tab(s)[/green]") console.print(f"[green]Closed {count} tab(s)[/green]")
@@ -171,25 +172,28 @@ def tabs_html(tab_id):
@tabs_group.command("dedupe") @tabs_group.command("dedupe")
def tabs_dedupe(): @click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large dedupe operations.")
def tabs_dedupe(gentle_mode):
"""Close duplicate tabs (keep the first occurrence of each URL).""" """Close duplicate tabs (keep the first occurrence of each URL)."""
result = _handle("tabs.dedupe") result = _handle("tabs.dedupe", {"gentleMode": gentle_mode})
count = result.get("closed", 0) if isinstance(result, dict) else 0 count = result.get("closed", 0) if isinstance(result, dict) else 0
console.print(f"[green]Closed {count} duplicate tab(s)[/green]") console.print(f"[green]Closed {count} duplicate tab(s)[/green]")
@tabs_group.command("sort") @tabs_group.command("sort")
@click.option("--by", type=click.Choice(["domain", "title", "time"]), default="domain", show_default=True) @click.option("--by", type=click.Choice(["domain", "title", "time"]), default="domain", show_default=True)
def tabs_sort(by): @click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large sort operations.")
def tabs_sort(by, gentle_mode):
"""Sort tabs within each window.""" """Sort tabs within each window."""
_handle("tabs.sort", {"by": by}) _handle("tabs.sort", {"by": by, "gentleMode": gentle_mode})
console.print(f"[green]Tabs sorted by {by}[/green]") console.print(f"[green]Tabs sorted by {by}[/green]")
@tabs_group.command("merge-windows") @tabs_group.command("merge-windows")
def tabs_merge_windows(): @click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large merge operations.")
def tabs_merge_windows(gentle_mode):
"""Move all tabs into the focused window.""" """Move all tabs into the focused window."""
result = _handle("tabs.merge_windows") result = _handle("tabs.merge_windows", {"gentleMode": gentle_mode})
count = result.get("moved", 0) if isinstance(result, dict) else 0 count = result.get("moved", 0) if isinstance(result, dict) else 0
console.print(f"[green]Merged — moved {count} tab(s) into current window[/green]") console.print(f"[green]Merged — moved {count} tab(s) into current window[/green]")
+24
View File
@@ -95,6 +95,30 @@ class Tab:
"""Return the full HTML source of this tab.""" """Return the full HTML source of this tab."""
return self._b()._cmd("tabs.html", {"tabId": self.id}) return self._b()._cmd("tabs.html", {"tabId": self.id})
def screenshot(self, *, format: str = "png", quality: int | None = None) -> str:
"""Capture this tab's visible area. Returns a base64 data URL."""
return self._b().tabs_screenshot(self.id, format=format, quality=quality)
def pin(self) -> None:
"""Pin this tab."""
self._b()._cmd("tabs.pin", {"tabId": self.id})
def unpin(self) -> None:
"""Unpin this tab."""
self._b()._cmd("tabs.unpin", {"tabId": self.id})
def refresh(self) -> Tab:
"""Return a fresh snapshot of this tab."""
return self._b().tabs_status(self.id)
def wait_for_load(self, *, timeout: float = 30.0, ready_state: str = "complete") -> Tab:
"""Wait until this tab reaches the requested readyState."""
return self._b().wait_for_load(self.id, timeout=timeout, ready_state=ready_state)
def watch_url(self, pattern: str, *, timeout: float = 30.0) -> Tab:
"""Wait until this tab's URL matches regex *pattern*."""
return self._b().tabs_watch_url(pattern, tab_id=self.id, timeout=timeout)
def open(self, url: str, *, background: bool = False) -> None: def open(self, url: str, *, background: bool = False) -> None:
"""Navigate this tab to *url* in place.""" """Navigate this tab to *url* in place."""
self._b().navigate_tab(self.id, url) self._b().navigate_tab(self.id, url)
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "browser-cli", "name": "browser-cli",
"version": "0.9.8", "version": "0.10.1",
"description": "Control your browser from the terminal via browser-cli", "description": "Control your browser from the terminal or Python SDK",
"permissions": [ "permissions": [
"tabs", "tabs",
"tabGroups", "tabGroups",
+14 -4
View File
@@ -1,5 +1,5 @@
// @ts-nocheck // @ts-nocheck
import { buildTabBlocks, resolveGroupId, tabInfo } from '../core'; import { buildTabBlocks, getLargeOperationThrottle, resolveGroupId, runLargeOperation, tabInfo, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core';
export async function groupList() { export async function groupList() {
const groups = await chrome.tabGroups.query({}); const groups = await chrome.tabGroups.query({});
const all = await chrome.tabs.query({}); const all = await chrome.tabs.query({});
@@ -29,11 +29,21 @@ export async function groupQuery({ search }) {
return groups.filter(g => g.title && g.title.toLowerCase().includes(q)); return groups.filter(g => g.title && g.title.toLowerCase().includes(q));
} }
export async function groupClose({ groupId }) { export async function groupClose({ groupId, gentleMode, __job } = {}) {
return runLargeOperation("group.close", async () => {
const tabs = await chrome.tabs.query({}); const tabs = await chrome.tabs.query({});
const groupTabs = tabs.filter(t => t.groupId === groupId); const groupTabs = tabs.filter(t => t.groupId === groupId);
await chrome.tabs.ungroup(groupTabs.map(t => t.id)); const tabIds = groupTabs.map(t => t.id);
return { groupId }; const throttle = await getLargeOperationThrottle(tabIds.length, gentleMode);
updateJobProgress(__job, { phase: "ungrouping tabs", current: 0, total: tabIds.length });
for (let i = 0; i < tabIds.length; i += throttle.batchSize) {
throwIfJobCancelled(__job);
await chrome.tabs.ungroup(tabIds.slice(i, i + throttle.batchSize));
updateJobProgress(__job, { phase: "ungrouping tabs", current: Math.min(i + throttle.batchSize, tabIds.length), total: tabIds.length });
await yieldForLargeOperation(i + throttle.batchSize, throttle.batchSize, throttle.pauseMs);
}
return { groupId, gentle: throttle.gentle, audible: throttle.audible };
});
} }
export async function groupOpen({ name }) { export async function groupOpen({ name }) {
+142 -16
View File
@@ -1,14 +1,14 @@
// @ts-nocheck // @ts-nocheck
import { getProfileAlias, getSessionTabs, getSessions, normalizeGroupColor } from '../core'; import { getLargeOperationThrottle, getProfileAlias, getSessionTabs, getSessions, normalizeGroupColor, runLargeOperation, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core';
export async function sessionSave({ name }) {
const tabs = await chrome.tabs.query({}); function buildSessionSnapshot(tabs, groups) {
const groups = await chrome.tabGroups.query({});
const groupById = new Map(groups.map(group => [group.id, group])); const groupById = new Map(groups.map(group => [group.id, group]));
const sessionTabs = tabs return tabs
.filter(tab => Boolean(tab.url)) .filter(tab => Boolean(tab.url || tab.pendingUrl))
.sort((a, b) => (a.windowId - b.windowId) || (a.index - b.index)) .sort((a, b) => (a.windowId - b.windowId) || (a.index - b.index))
.map(tab => { .map(tab => {
const entry = { url: tab.url }; const entry = { url: tab.url || tab.pendingUrl };
if (tab.pinned) entry.pinned = true;
if (tab.groupId >= 0) { if (tab.groupId >= 0) {
const group = groupById.get(tab.groupId); const group = groupById.get(tab.groupId);
entry.group = { entry.group = {
@@ -20,28 +20,82 @@ export async function sessionSave({ name }) {
} }
return entry; return entry;
}); });
}
function sessionSignature(sessionTabs) {
return JSON.stringify(sessionTabs.map(tab => ({
url: tab.url,
pinned: Boolean(tab.pinned),
group: tab.group ? {
key: tab.group.key || "",
title: tab.group.title || "",
color: normalizeGroupColor(tab.group.color),
collapsed: Boolean(tab.group.collapsed),
} : null,
})));
}
export async function sessionSave({ name }) {
const tabs = await chrome.tabs.query({});
const groups = await chrome.tabGroups.query({});
const sessionTabs = buildSessionSnapshot(tabs, groups);
const signature = sessionSignature(sessionTabs);
const sessions = await getSessions(); const sessions = await getSessions();
sessions[name] = { sessions[name] = {
tabs: sessionTabs, tabs: sessionTabs,
urls: sessionTabs.map(tab => tab.url), urls: sessionTabs.map(tab => tab.url),
savedAt: Date.now(), savedAt: Date.now(),
signature,
}; };
await chrome.storage.local.set({ sessions }); await chrome.storage.local.set({ sessions });
return { name, tabs: sessionTabs.length }; return { name, tabs: sessionTabs.length };
} }
export async function sessionLoad({ name }) { function lazyPlaceholderUrl(url) {
const escaped = String(url).replace(/[&<>"']/g, ch => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }[ch]));
const html = `<!doctype html><title>Lazy tab</title><body style="font-family:sans-serif;padding:2rem"><h1>Lazy tab</h1><p>This tab will load when selected.</p><p><a href="${escaped}">${escaped}</a></p></body>`;
return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
}
export async function activateLazyTab(tabId) {
const { lazySessionTabs } = await chrome.storage.local.get("lazySessionTabs");
const entry = lazySessionTabs?.[tabId];
if (!entry?.url) return false;
delete lazySessionTabs[tabId];
await chrome.storage.local.set({ lazySessionTabs });
await chrome.tabs.update(Number(tabId), { url: entry.url });
return true;
}
export async function sessionLoad({ name, gentleMode, discardBackgroundTabs = false, lazy = false, eagerTabs = 10, __job } = {}) {
return runLargeOperation("session.load", async () => {
const sessions = await getSessions(); const sessions = await getSessions();
const session = sessions[name]; const session = sessions[name];
if (!session) throw new Error(`Session '${name}' not found`); if (!session) throw new Error(`Session '${name}' not found`);
const sessionTabs = getSessionTabs(session); const sessionTabs = getSessionTabs(session).sort((a, b) => Number(Boolean(b.pinned)) - Number(Boolean(a.pinned)));
const createdTabs = []; const createdTabs = [];
const throttle = await getLargeOperationThrottle(sessionTabs.length, gentleMode);
const createBatchSize = Math.max(1, Math.min(10, throttle.batchSize));
const eagerLimit = lazy ? Math.max(0, Number(eagerTabs) || 0) : sessionTabs.length;
const { lazySessionTabs } = await chrome.storage.local.get("lazySessionTabs");
const lazyMap = lazySessionTabs || {};
updateJobProgress(__job, { phase: "opening", current: 0, total: sessionTabs.length });
for (const entry of sessionTabs) { for (const [idx, entry] of sessionTabs.entries()) {
const tab = await chrome.tabs.create({ url: entry.url, active: false }); throwIfJobCancelled(__job);
const shouldLazy = lazy && idx >= eagerLimit;
const tab = await chrome.tabs.create({ url: shouldLazy ? lazyPlaceholderUrl(entry.url) : entry.url, active: false, pinned: Boolean(entry.pinned) });
createdTabs.push({ tabId: tab.id, entry }); createdTabs.push({ tabId: tab.id, entry });
if (shouldLazy) {
lazyMap[String(tab.id)] = { url: entry.url, createdAt: Date.now() };
} else if (discardBackgroundTabs && !entry.pinned && chrome.tabs.discard) {
try { await chrome.tabs.discard(tab.id); } catch (_) {}
} }
updateJobProgress(__job, { phase: shouldLazy ? "creating lazy placeholders" : "opening", current: createdTabs.length, total: sessionTabs.length });
await yieldForLargeOperation(createdTabs.length, createBatchSize, Math.max(50, throttle.pauseMs));
}
if (lazy) await chrome.storage.local.set({ lazySessionTabs: lazyMap });
const groups = new Map(); const groups = new Map();
for (const { tabId, entry } of createdTabs) { for (const { tabId, entry } of createdTabs) {
@@ -53,16 +107,23 @@ export async function sessionLoad({ name }) {
groups.get(key).tabIds.push(tabId); groups.get(key).tabIds.push(tabId);
} }
let restoredGroups = 0;
updateJobProgress(__job, { phase: "restoring groups", current: 0, total: groups.size });
for (const { meta, tabIds } of groups.values()) { for (const { meta, tabIds } of groups.values()) {
throwIfJobCancelled(__job);
const restoredGroupId = await chrome.tabs.group({ tabIds }); const restoredGroupId = await chrome.tabs.group({ tabIds });
await chrome.tabGroups.update(restoredGroupId, { await chrome.tabGroups.update(restoredGroupId, {
title: meta.title || "", title: meta.title || "",
color: normalizeGroupColor(meta.color), color: normalizeGroupColor(meta.color),
collapsed: Boolean(meta.collapsed), collapsed: Boolean(meta.collapsed),
}); });
restoredGroups++;
updateJobProgress(__job, { phase: "restoring groups", current: restoredGroups, total: groups.size });
await yieldForLargeOperation(restoredGroups, 5, Math.max(50, throttle.pauseMs));
} }
return { name, tabs: sessionTabs.length }; return { name, tabs: sessionTabs.length, gentle: throttle.gentle, audible: throttle.audible, discarded: Boolean(discardBackgroundTabs), lazy: Boolean(lazy), eagerTabs: eagerLimit };
});
} }
export async function sessionList() { export async function sessionList() {
@@ -92,21 +153,86 @@ export async function sessionDiff({ nameA, nameB }) {
}; };
} }
let autoSaveTimer = null;
let autoSaveInFlight = false;
let autoSavePending = false;
export async function sessionAutoSave({ enabled }) { export async function sessionAutoSave({ enabled }) {
await chrome.storage.local.set({ autoSave: enabled }); await chrome.storage.local.set({ autoSave: enabled });
chrome.tabs.onUpdated.removeListener(autoSaveHandler); chrome.tabs.onCreated.removeListener(autoSaveHandler);
chrome.tabs.onRemoved.removeListener(autoSaveHandler); chrome.tabs.onRemoved.removeListener(autoSaveHandler);
chrome.tabs.onMoved.removeListener(autoSaveHandler);
chrome.tabs.onAttached.removeListener(autoSaveHandler);
chrome.tabs.onDetached.removeListener(autoSaveHandler);
chrome.tabs.onUpdated.removeListener(autoSaveUpdatedHandler);
if (chrome.tabGroups?.onUpdated) chrome.tabGroups.onUpdated.removeListener(autoSaveHandler);
if (autoSaveTimer) clearTimeout(autoSaveTimer);
autoSaveTimer = null;
autoSavePending = false;
if (enabled) { if (enabled) {
chrome.tabs.onUpdated.addListener(autoSaveHandler); chrome.tabs.onCreated.addListener(autoSaveHandler);
chrome.tabs.onRemoved.addListener(autoSaveHandler); chrome.tabs.onRemoved.addListener(autoSaveHandler);
chrome.tabs.onMoved.addListener(autoSaveHandler);
chrome.tabs.onAttached.addListener(autoSaveHandler);
chrome.tabs.onDetached.addListener(autoSaveHandler);
chrome.tabs.onUpdated.addListener(autoSaveUpdatedHandler);
if (chrome.tabGroups?.onUpdated) chrome.tabGroups.onUpdated.addListener(autoSaveHandler);
} }
return { enabled }; return { enabled };
} }
export async function autoSaveHandler() { async function saveAutoSessionIfChanged() {
const tabs = await chrome.tabs.query({});
const groups = await chrome.tabGroups.query({});
const sessionTabs = buildSessionSnapshot(tabs, groups);
const signature = sessionSignature(sessionTabs);
const { autoSaveSignature } = await chrome.storage.local.get("autoSaveSignature");
if (autoSaveSignature === signature) return { skipped: true, tabs: sessionTabs.length };
const sessions = await getSessions();
sessions.__auto__ = {
tabs: sessionTabs,
urls: sessionTabs.map(tab => tab.url),
savedAt: Date.now(),
signature,
};
await chrome.storage.local.set({ sessions, autoSaveSignature: signature });
return { skipped: false, tabs: sessionTabs.length };
}
async function runAutoSave() {
if (autoSaveInFlight) {
autoSavePending = true;
return;
}
autoSaveInFlight = true;
try {
const { autoSave } = await chrome.storage.local.get("autoSave");
if (autoSave) await runLargeOperation("session.auto_save", saveAutoSessionIfChanged);
} finally {
autoSaveInFlight = false;
if (autoSavePending) {
autoSavePending = false;
autoSaveTimer = setTimeout(runAutoSave, 1000);
}
}
}
async function scheduleAutoSave(delayMs = 1000) {
const { autoSave } = await chrome.storage.local.get("autoSave"); const { autoSave } = await chrome.storage.local.get("autoSave");
if (!autoSave) return; if (!autoSave) return;
await sessionSave({ name: "__auto__" }); if (autoSaveTimer) clearTimeout(autoSaveTimer);
autoSaveTimer = setTimeout(runAutoSave, delayMs);
}
export async function autoSaveHandler() {
await scheduleAutoSave();
}
export async function autoSaveUpdatedHandler(_tabId, changeInfo = {}) {
// Ignore noisy media/title/favicon/loading updates. Sessions only store URL and group/window structure.
if (!("url" in changeInfo)) return;
await scheduleAutoSave();
} }
// ── Misc ────────────────────────────────────────────────────────────────────── // ── Misc ──────────────────────────────────────────────────────────────────────
+40 -10
View File
@@ -1,5 +1,5 @@
// @ts-nocheck // @ts-nocheck
import { executeScript, getActiveTab, getAliases, isBrowserErrorUrl, isErrorPageScriptError, isScriptableUrl, resolveTabForDirectAction, tabInfo } from '../core'; import { executeScript, getActiveTab, getAliases, getLargeOperationThrottle, isBrowserErrorUrl, isErrorPageScriptError, isScriptableUrl, resolveTabForDirectAction, runLargeOperation, tabInfo, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core';
export async function tabsList() { export async function tabsList() {
const windows = await chrome.windows.getAll({ populate: true }); const windows = await chrome.windows.getAll({ populate: true });
const aliases = await getAliases(); const aliases = await getAliases();
@@ -17,7 +17,8 @@ export async function tabsList() {
return tabs; return tabs;
} }
export async function tabsClose({ tabId, inactive, duplicates }) { export async function tabsClose({ tabId, inactive, duplicates, gentleMode, __job } = {}) {
return runLargeOperation("tabs.close", async () => {
let toClose = []; let toClose = [];
if (duplicates) { if (duplicates) {
const all = await chrome.tabs.query({}); const all = await chrome.tabs.query({});
@@ -33,8 +34,16 @@ export async function tabsClose({ tabId, inactive, duplicates }) {
} else if (tabId) { } else if (tabId) {
toClose = [tabId]; toClose = [tabId];
} }
if (toClose.length) await chrome.tabs.remove(toClose); const throttle = await getLargeOperationThrottle(toClose.length, gentleMode);
return { closed: toClose.length }; updateJobProgress(__job, { phase: "closing tabs", current: 0, total: toClose.length });
for (let i = 0; i < toClose.length; i += throttle.batchSize) {
throwIfJobCancelled(__job);
await chrome.tabs.remove(toClose.slice(i, i + throttle.batchSize));
updateJobProgress(__job, { phase: "closing tabs", current: Math.min(i + throttle.batchSize, toClose.length), total: toClose.length });
await yieldForLargeOperation(i + throttle.batchSize, throttle.batchSize, throttle.pauseMs);
}
return { closed: toClose.length, gentle: throttle.gentle, audible: throttle.audible };
});
} }
export async function tabsMove({ tabId, groupId, windowId, index, forward, backward }) { export async function tabsMove({ tabId, groupId, windowId, index, forward, backward }) {
@@ -127,13 +136,16 @@ export async function tabsHtml({ tabId }) {
} }
} }
export async function tabsDedupe() { export async function tabsDedupe(args = {}) {
return tabsClose({ duplicates: true }); return tabsClose({ ...args, duplicates: true });
} }
export async function tabsSort({ by }) { export async function tabsSort({ by, gentleMode, __job } = {}) {
return runLargeOperation("tabs.sort", async () => {
const windows = await chrome.windows.getAll({ populate: true }); const windows = await chrome.windows.getAll({ populate: true });
let moved = 0; let moved = 0;
const totalTabs = windows.reduce((sum, w) => sum + (w.tabs?.length || 0), 0);
updateJobProgress(__job, { phase: "sorting tabs", current: 0, total: totalTabs });
for (const w of windows) { for (const w of windows) {
const sorted = [...w.tabs].sort((a, b) => { const sorted = [...w.tabs].sort((a, b) => {
if (by === "title") return (a.title || "").localeCompare(b.title || ""); if (by === "title") return (a.title || "").localeCompare(b.title || "");
@@ -143,25 +155,43 @@ export async function tabsSort({ by }) {
const db = new URL(b.url || b.pendingUrl || "about:blank").hostname; const db = new URL(b.url || b.pendingUrl || "about:blank").hostname;
return da.localeCompare(db); return da.localeCompare(db);
}); });
if (w.tabs.every((tab, index) => tab.id === sorted[index]?.id)) continue;
const throttle = await getLargeOperationThrottle(sorted.length, gentleMode);
const moveBatchSize = Math.max(1, Math.min(10, throttle.batchSize));
for (let i = 0; i < sorted.length; i++) { for (let i = 0; i < sorted.length; i++) {
throwIfJobCancelled(__job);
await chrome.tabs.move(sorted[i].id, { index: i }); await chrome.tabs.move(sorted[i].id, { index: i });
moved++; moved++;
updateJobProgress(__job, { phase: "sorting tabs", current: moved, total: totalTabs });
await yieldForLargeOperation(moved, moveBatchSize, throttle.pauseMs);
} }
} }
return { moved }; return { moved };
});
} }
export async function tabsMergeWindows() { export async function tabsMergeWindows({ gentleMode, __job } = {}) {
return runLargeOperation("tabs.merge_windows", async () => {
const current = await chrome.windows.getCurrent(); const current = await chrome.windows.getCurrent();
const all = await chrome.windows.getAll({ populate: true }); const all = await chrome.windows.getAll({ populate: true });
let moved = 0; let moved = 0;
const totalTabs = all.filter(w => w.id !== current.id).reduce((sum, w) => sum + (w.tabs?.length || 0), 0);
updateJobProgress(__job, { phase: "merging windows", current: 0, total: totalTabs });
for (const w of all) { for (const w of all) {
if (w.id === current.id) continue; if (w.id === current.id) continue;
const ids = w.tabs.map(t => t.id); const ids = w.tabs.map(t => t.id);
await chrome.tabs.move(ids, { windowId: current.id, index: -1 }); const throttle = await getLargeOperationThrottle(ids.length, gentleMode);
moved += ids.length; for (let i = 0; i < ids.length; i += throttle.batchSize) {
throwIfJobCancelled(__job);
const chunk = ids.slice(i, i + throttle.batchSize);
await chrome.tabs.move(chunk, { windowId: current.id, index: -1 });
moved += chunk.length;
updateJobProgress(__job, { phase: "merging windows", current: moved, total: totalTabs });
await yieldForLargeOperation(moved, throttle.batchSize, throttle.pauseMs);
}
} }
return { moved }; return { moved };
});
} }
export async function tabsPin({ tabId }) { export async function tabsPin({ tabId }) {
+84 -2
View File
@@ -32,13 +32,94 @@ export function isTransientScriptError(error) {
return message.includes("Frame with ID") || message.includes("No tab with id") || isErrorPageScriptError(error); return message.includes("Frame with ID") || message.includes("No tab with id") || isErrorPageScriptError(error);
} }
export function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
export const LARGE_OPERATION_BATCH_SIZE = 25;
export const LARGE_OPERATION_PAUSE_MS = 25;
export const GENTLE_OPERATION_BATCH_SIZE = 8;
export const GENTLE_OPERATION_PAUSE_MS = 100;
export async function hasAudibleTabs() {
const audibleTabs = await chrome.tabs.query({ audible: true });
return audibleTabs.some(tab => !(tab.mutedInfo && tab.mutedInfo.muted));
}
let largeOperationQueue = Promise.resolve();
export async function runLargeOperation(name, fn) {
const run = largeOperationQueue.then(async () => {
console.log(`[browser-cli] large operation start: ${name}`);
try {
return await fn();
} finally {
console.log(`[browser-cli] large operation done: ${name}`);
}
});
largeOperationQueue = run.catch(() => {});
return run;
}
export async function getPerformanceProfile() {
const { performanceProfile } = await chrome.storage.local.get("performanceProfile");
return performanceProfile || "auto";
}
export async function setPerformanceProfile(profile) {
const allowed = new Set(["auto", "normal", "gentle", "ultra"]);
const performanceProfile = allowed.has(profile) ? profile : "auto";
await chrome.storage.local.set({ performanceProfile });
return { performanceProfile };
}
export async function getLargeOperationThrottle(itemCount = 0, mode = "auto") {
const audible = await hasAudibleTabs();
const storedProfile = await getPerformanceProfile();
const configuredMode = mode && mode !== "auto" ? mode : storedProfile;
const gentle = configuredMode === "gentle" || configuredMode === "ultra" || (configuredMode === "auto" && audible);
let batchSize = gentle ? GENTLE_OPERATION_BATCH_SIZE : LARGE_OPERATION_BATCH_SIZE;
let pauseMs = gentle ? GENTLE_OPERATION_PAUSE_MS : LARGE_OPERATION_PAUSE_MS;
if (configuredMode === "ultra" || itemCount >= 300) {
batchSize = Math.max(3, Math.floor(batchSize / 2));
pauseMs *= 2;
} else if (itemCount >= 100) {
batchSize = Math.max(5, Math.floor(batchSize * 0.75));
pauseMs = Math.max(pauseMs, 75);
}
return { batchSize, pauseMs, gentle, audible, itemCount, mode: configuredMode };
}
export function updateJobProgress(job, { phase, current, total } = {}) {
if (!job) return;
if (phase) job.phase = phase;
if (total != null) job.total = total;
if (current != null) job.current = current;
if (job.total) job.percent = Math.min(100, Math.round((job.current || 0) * 100 / job.total));
job.updatedAt = Date.now();
}
export function throwIfJobCancelled(job) {
if (job?.cancelRequested) {
throw new Error(`Job '${job.id}' cancelled`);
}
}
export async function yieldForLargeOperation(processed, batchSize = LARGE_OPERATION_BATCH_SIZE, pauseMs = LARGE_OPERATION_PAUSE_MS) {
if (processed > 0 && processed % batchSize === 0) {
await sleep(pauseMs);
}
}
export async function executeScript(options, retries = 3) { export async function executeScript(options, retries = 3) {
for (let i = 0; i < retries; i++) { for (let i = 0; i < retries; i++) {
try { try {
return await chrome.scripting.executeScript(options); return await chrome.scripting.executeScript(options);
} catch (e) { } catch (e) {
if (i < retries - 1 && isTransientScriptError(e)) { if (i < retries - 1 && isTransientScriptError(e)) {
await new Promise(r => setTimeout(r, 300)); await sleep(300);
continue; continue;
} }
throw e; throw e;
@@ -52,8 +133,9 @@ export function tabInfo(t) {
windowId: t.windowId, windowId: t.windowId,
active: t.active, active: t.active,
muted: Boolean(t.mutedInfo && t.mutedInfo.muted), muted: Boolean(t.mutedInfo && t.mutedInfo.muted),
groupId: t.groupId >= 0 ? t.groupId : null,
title: t.title, title: t.title,
url: t.url, url: t.url || t.pendingUrl || "",
}; };
} }
+123 -4
View File
@@ -5,7 +5,7 @@
* Connects to the native host (com.browsercli.host) via Native Messaging. * Connects to the native host (com.browsercli.host) via Native Messaging.
*/ */
import { getProfileAlias } from './core'; import { getLargeOperationThrottle, getPerformanceProfile, hasAudibleTabs, setPerformanceProfile, getProfileAlias } from './core';
import * as nav from './commands/navigation'; import * as nav from './commands/navigation';
import * as tabs from './commands/tabs'; import * as tabs from './commands/tabs';
import * as groups from './commands/groups'; import * as groups from './commands/groups';
@@ -17,6 +17,15 @@ import * as session from './commands/session';
const NATIVE_HOST = "com.browsercli.host"; const NATIVE_HOST = "com.browsercli.host";
let port = null; let port = null;
let keepaliveEnabled = true; let keepaliveEnabled = true;
const jobs = new Map();
const BACKGROUND_COMMANDS = new Set([
"session.load",
"tabs.close",
"tabs.dedupe",
"tabs.sort",
"tabs.merge_windows",
"group.close",
]);
// ── Connection management ───────────────────────────────────────────────────── // ── Connection management ─────────────────────────────────────────────────────
function sendControlMessage(targetPort, message) { function sendControlMessage(targetPort, message) {
@@ -88,6 +97,10 @@ chrome.alarms.onAlarm.addListener((alarm) => {
} }
}); });
chrome.tabs.onActivated.addListener(async ({ tabId }) => {
await session.activateLazyTab(tabId);
});
// ── Message dispatcher ──────────────────────────────────────────────────────── // ── Message dispatcher ────────────────────────────────────────────────────────
async function onMessage(msg) { async function onMessage(msg) {
@@ -98,11 +111,15 @@ async function onMessage(msg) {
let data, error; let data, error;
try { try {
const { __page, ...commandArgs } = args || {}; const { __page, __background, ...commandArgs } = args || {};
if (__background && BACKGROUND_COMMANDS.has(command)) {
data = await startBackgroundJob(command, commandArgs);
} else {
data = await dispatch(command, commandArgs); data = await dispatch(command, commandArgs);
if (__page && Array.isArray(data)) { if (__page && Array.isArray(data)) {
data = makePagedData(data, __page); data = makePagedData(data, __page);
} }
}
} catch (e) { } catch (e) {
error = e.message || String(e); error = e.message || String(e);
} }
@@ -121,6 +138,94 @@ async function onMessage(msg) {
await connect(); await connect();
} }
} }
async function persistJobs() {
const recentJobs = [...jobs.values()].slice(-50).map(job => ({ ...job, __timer: undefined }));
await chrome.storage.local.set({ recentJobs });
}
async function startBackgroundJob(command, args) {
const jobId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
const job = {
id: jobId,
command,
status: "running",
phase: "queued",
current: 0,
total: null,
percent: 0,
cancelRequested: false,
startedAt: Date.now(),
updatedAt: Date.now(),
finishedAt: null,
result: null,
error: null,
};
jobs.set(jobId, job);
job.__timer = setInterval(persistJobs, 1000);
await persistJobs();
dispatch(command, { ...args, __job: job })
.then(async result => {
job.status = "done";
job.phase = "done";
job.result = result;
job.current = job.total || job.current;
job.percent = 100;
job.finishedAt = Date.now();
job.updatedAt = Date.now();
if (job.__timer) clearInterval(job.__timer);
await persistJobs();
})
.catch(async error => {
job.status = job.cancelRequested ? "cancelled" : "error";
job.phase = job.status;
job.error = error?.message || String(error);
job.finishedAt = Date.now();
job.updatedAt = Date.now();
if (job.__timer) clearInterval(job.__timer);
await persistJobs();
});
return { jobId, command, status: job.status };
}
async function jobStatus({ jobId }) {
const job = jobs.get(jobId);
if (job) return { ...job };
const { recentJobs } = await chrome.storage.local.get("recentJobs");
const stored = (recentJobs || []).find(entry => entry.id === jobId);
if (!stored) throw new Error(`Job '${jobId}' not found`);
return stored;
}
async function jobCancel({ jobId }) {
const job = jobs.get(jobId);
if (!job) throw new Error(`Job '${jobId}' not running`);
job.cancelRequested = true;
job.updatedAt = Date.now();
await persistJobs();
return { jobId, cancelled: true };
}
async function perfStatus() {
const profile = await getPerformanceProfile();
const audible = await hasAudibleTabs();
const throttle = await getLargeOperationThrottle(0, "auto");
return {
performanceProfile: profile,
audible,
throttle,
jobs: [...jobs.values()].map(job => ({
id: job.id,
command: job.command,
status: job.status,
phase: job.phase,
current: job.current,
total: job.total,
percent: job.percent,
cancelRequested: job.cancelRequested,
})),
};
}
function makePagedData(items, page) { function makePagedData(items, page) {
const total = items.length; const total = items.length;
const offset = Math.max(0, Number(page.offset) || 0); const offset = Math.max(0, Number(page.offset) || 0);
@@ -160,9 +265,9 @@ async function dispatch(command, args) {
case "tabs.count": return tabs.tabsCount(args); case "tabs.count": return tabs.tabsCount(args);
case "tabs.query": return tabs.tabsQuery(args); case "tabs.query": return tabs.tabsQuery(args);
case "tabs.html": return tabs.tabsHtml(args); case "tabs.html": return tabs.tabsHtml(args);
case "tabs.dedupe": return tabs.tabsDedupe(); case "tabs.dedupe": return tabs.tabsDedupe(args);
case "tabs.sort": return tabs.tabsSort(args); case "tabs.sort": return tabs.tabsSort(args);
case "tabs.merge_windows": return tabs.tabsMergeWindows(); case "tabs.merge_windows": return tabs.tabsMergeWindows(args);
case "tabs.mute": return tabs.tabsMute(args); case "tabs.mute": return tabs.tabsMute(args);
case "tabs.unmute": return tabs.tabsUnmute(args); case "tabs.unmute": return tabs.tabsUnmute(args);
case "tabs.pin": return tabs.tabsPin(args); case "tabs.pin": return tabs.tabsPin(args);
@@ -234,6 +339,20 @@ async function dispatch(command, args) {
case "session.diff": return session.sessionDiff(args); case "session.diff": return session.sessionDiff(args);
case "session.auto_save": return session.sessionAutoSave(args); case "session.auto_save": return session.sessionAutoSave(args);
// ── Jobs ──────────────────────────────────────────────────────────────
case "jobs.status": return jobStatus(args);
case "jobs.cancel": return jobCancel(args);
// ── Performance ───────────────────────────────────────────────────────
case "perf.status": return perfStatus();
case "perf.set_profile": return setPerformanceProfile(args.profile);
// ── Extension ─────────────────────────────────────────────────────────
case "extension.reload": {
setTimeout(() => chrome.runtime.reload(), 200);
return { reloading: true };
}
// ── Misc ────────────────────────────────────────────────────────────── // ── Misc ──────────────────────────────────────────────────────────────
case "clients.list": return session.clientsList(); case "clients.list": return session.clientsList();
case "clients.rename_profile": return session.clientsRenameProfile(args); case "clients.rename_profile": return session.clientsRenameProfile(args);
+6 -3
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "browser-cli" name = "browser-cli"
version = "0.9.8" version = "0.10.1"
description = "Control your real running browser from the terminal via a browser extension" description = "Control your real running browser from the terminal or Python SDK"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"click>=8", "click>=8",
@@ -13,7 +13,10 @@ dependencies = [
browser-cli = "browser_cli.cli:main" browser-cli = "browser_cli.cli:main"
[dependency-groups] [dependency-groups]
dev = ["pytest>=8"] dev = [
"pytest>=8",
"pytest-cov>=7.1.0",
]
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling"]
+198
View File
@@ -69,6 +69,31 @@ class TestBrowserCLIInit:
assert b._browser == "work" assert b._browser == "work"
assert b._remote == "host:8765" assert b._remote == "host:8765"
def test_public_target_properties(self):
b = BrowserCLI(browser="work", remote="browser-host.example:443", key="agent")
assert b.browser == "work"
assert b.remote == "browser-host.example:443"
assert b.key == "agent"
def test_raw_command_escape_hatch(self, mock_send):
mock_send.return_value = {"ok": True}
b = BrowserCLI(browser="work", remote="browser-host.example:443", key="agent")
result = b.command("custom.command", {"value": 1})
assert result == {"ok": True}
mock_send.assert_called_once_with(
"custom.command",
{"value": 1},
profile="work",
remote="browser-host.example:443",
key="agent",
)
def test_raw_command_defaults_args_to_empty_dict(self, b, mock_send):
b.command("custom.command")
mock_send.assert_called_once_with("custom.command", {}, profile=None, remote=None, key=None)
# ── Internal factories ──────────────────────────────────────────────────────── # ── Internal factories ────────────────────────────────────────────────────────
@@ -140,6 +165,41 @@ class TestNavigation:
b.open("https://x.com", group="Work") b.open("https://x.com", group="Work")
assert mock_send.call_args[0][1]["group"] == "Work" assert mock_send.call_args[0][1]["group"] == "Work"
def test_open_tab_returns_bound_tab(self, b, mock_send):
mock_send.return_value = {"id": 123, "url": "https://example.com"}
tab = b.open_tab("https://example.com", background=True)
assert tab.id == 123
assert tab.url == "https://example.com"
assert tab._browser is b
mock_send.assert_called_once_with(
"navigate.open",
{"url": "https://example.com", "background": True, "window": None, "group": None},
profile=None,
remote=None,
key=None,
)
def test_open_tab_wait_uses_open_wait(self, b, mock_send):
mock_send.return_value = TAB_DATA
tab = b.open_tab("https://example.com", wait=True, timeout=1.5)
assert tab.id == 10
mock_send.assert_called_once_with(
"navigate.open_wait",
{"url": "https://example.com", "timeout": 1500, "background": False, "window": None, "group": None},
profile=None,
remote=None,
key=None,
)
def test_open_tab_unexpected_response_raises(self, b, mock_send):
mock_send.return_value = None
with pytest.raises(RuntimeError, match="navigate.open returned unexpected data"):
b.open_tab("https://example.com")
def test_reload(self, b, mock_send): def test_reload(self, b, mock_send):
b.reload(tab_id=5) b.reload(tab_id=5)
mock_send.assert_called_once_with("navigate.reload", {"tabId": 5}, profile=None, remote=None, key=None) mock_send.assert_called_once_with("navigate.reload", {"tabId": 5}, profile=None, remote=None, key=None)
@@ -340,6 +400,64 @@ class TestTabs:
call("tabs.close", {"tabId": 10}, profile="work", remote="host:8765", key=None), call("tabs.close", {"tabId": 10}, profile="work", remote="host:8765", key=None),
] ]
def test_tabs_list_remote_bound_actions_preserve_key(self, mock_send):
b = BrowserCLI(remote="browser-host.example", key="agent")
with patch(
"browser_cli.remote_browser_targets",
return_value=[BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example")],
):
mock_send.side_effect = [[TAB_DATA], None]
tabs = b.tabs_list()
tabs[0].close()
assert mock_send.call_args_list == [
call("tabs.list", {}, profile="work", remote="browser-host.example", key="agent"),
call("tabs.close", {"tabId": 10}, profile="work", remote="browser-host.example", key="agent"),
]
def test_tabs_alias_and_active_tab(self, b, mock_send):
mock_send.side_effect = [[TAB_DATA], TAB_DATA]
tabs = b.tabs()
active = b.active_tab()
assert tabs[0].id == 10
assert active.id == 10
assert mock_send.call_args_list == [
call("tabs.list", {}, profile=None, remote=None, key=None),
call("tabs.status", {"tabId": None}, profile=None, remote=None, key=None),
]
def test_tab_find_and_close_helpers(self, b, mock_send):
mock_send.side_effect = [TAB_DATA, [TAB_DATA], [], {"closed": 1}, {"closed": 1}]
tab = b.tab(10)
found = b.find_tab("Example")
missing = b.find_tab("Missing")
closed_by_object = b.close_tab(tab)
closed_by_id = b.close_tab(10)
assert tab.id == 10
assert found and found.id == 10
assert missing is None
assert closed_by_object == 1
assert closed_by_id == 1
assert mock_send.call_args_list == [
call("tabs.status", {"tabId": 10}, profile=None, remote=None, key=None),
call("tabs.query", {"search": "Example"}, profile=None, remote=None, key=None),
call("tabs.query", {"search": "Missing"}, profile=None, remote=None, key=None),
call("tabs.close", {"tabId": 10, "inactive": False, "duplicates": False}, profile=None, remote=None, key=None),
call("tabs.close", {"tabId": 10, "inactive": False, "duplicates": False}, profile=None, remote=None, key=None),
]
def test_find_tabs_alias(self, b, mock_send):
mock_send.return_value = [TAB_DATA]
tabs = b.find_tabs("Example")
assert [tab.id for tab in tabs] == [10]
mock_send.assert_called_once_with("tabs.query", {"search": "Example"}, profile=None, remote=None, key=None)
def test_tabs_count_multi_browser_returns_browser_counts(self, b, mock_send): def test_tabs_count_multi_browser_returns_browser_counts(self, b, mock_send):
with patch( with patch(
"browser_cli.active_browser_targets", "browser_cli.active_browser_targets",
@@ -463,6 +581,40 @@ class TestGroups:
call("group.close", {"groupId": 42}, profile="work", remote="host:8765", key=None), call("group.close", {"groupId": 42}, profile="work", remote="host:8765", key=None),
] ]
def test_group_list_remote_bound_actions_preserve_key(self, mock_send):
b = BrowserCLI(remote="browser-host.example", key="agent")
with patch(
"browser_cli.remote_browser_targets",
return_value=[BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example")],
):
mock_send.side_effect = [[GROUP_DATA], None]
groups = b.group_list()
groups[0].close()
assert mock_send.call_args_list == [
call("group.list", {}, profile="work", remote="browser-host.example", key="agent"),
call("group.close", {"groupId": 42}, profile="work", remote="browser-host.example", key="agent"),
]
def test_group_aliases(self, b, mock_send):
mock_send.side_effect = [[GROUP_DATA], [GROUP_DATA], 1, [GROUP_DATA], GROUP_DATA, GROUP_DATA]
assert b.groups()[0].id == 42
assert b.groups_list()[0].id == 42
assert b.groups_count() == 1
assert b.groups_query("Work")[0].id == 42
assert b.groups_create("Work").id == 42
assert b.group_open("Work").id == 42
assert mock_send.call_args_list == [
call("group.list", {}, profile=None, remote=None, key=None),
call("group.list", {}, profile=None, remote=None, key=None),
call("group.count", {}, profile=None, remote=None, key=None),
call("group.query", {"search": "Work"}, profile=None, remote=None, key=None),
call("group.open", {"name": "Work"}, profile=None, remote=None, key=None),
call("group.open", {"name": "Work"}, profile=None, remote=None, key=None),
]
def test_group_count_multi_browser_returns_browser_counts(self, b, mock_send): def test_group_count_multi_browser_returns_browser_counts(self, b, mock_send):
with patch( with patch(
"browser_cli.active_browser_targets", "browser_cli.active_browser_targets",
@@ -554,6 +706,22 @@ class TestWindows:
mock_send.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None, remote=None, key=None) mock_send.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None, remote=None, key=None)
class TestDomConvenience:
def test_wait_for_selector_alias(self, b, mock_send):
mock_send.return_value = {"selector": "#done", "found": True}
result = b.wait_for_selector("#done", timeout=2.5, visible=True, tab_id=10)
assert result == {"selector": "#done", "found": True}
mock_send.assert_called_once_with(
"dom.wait_for",
{"selector": "#done", "timeout": 2500, "visible": True, "hidden": False, "tabId": 10},
profile=None,
remote=None,
key=None,
)
class TestSession: class TestSession:
def test_session_list(self, b, mock_send): def test_session_list(self, b, mock_send):
mock_send.return_value = [{"name": "saved", "tabs": 3, "savedAt": 1712707200000}] mock_send.return_value = [{"name": "saved", "tabs": 3, "savedAt": 1712707200000}]
@@ -633,6 +801,36 @@ class TestTabModel:
"navigate.to", {"tabId": 10, "url": "https://new.example.com"}, profile=None, remote=None, key=None "navigate.to", {"tabId": 10, "url": "https://new.example.com"}, profile=None, remote=None, key=None
) )
def test_screenshot(self, tab, mock_send):
mock_send.return_value = {"dataUrl": "data:image/png;base64,abc"}
assert tab.screenshot() == "data:image/png;base64,abc"
mock_send.assert_called_once_with(
"tabs.screenshot", {"tabId": 10, "format": "png", "quality": None}, profile=None, remote=None, key=None
)
def test_pin_unpin(self, tab, mock_send):
tab.pin()
tab.unpin()
assert mock_send.call_args_list == [
call("tabs.pin", {"tabId": 10}, profile=None, remote=None, key=None),
call("tabs.unpin", {"tabId": 10}, profile=None, remote=None, key=None),
]
def test_refresh(self, tab, mock_send):
mock_send.return_value = {**TAB_DATA, "title": "Fresh"}
fresh = tab.refresh()
assert fresh.title == "Fresh"
mock_send.assert_called_once_with("tabs.status", {"tabId": 10}, profile=None, remote=None, key=None)
def test_wait_for_load_and_watch_url(self, tab, mock_send):
mock_send.side_effect = [TAB_DATA, TAB_DATA]
tab.wait_for_load(timeout=1.5, ready_state="interactive")
tab.watch_url("example", timeout=2)
assert mock_send.call_args_list == [
call("navigate.wait", {"tabId": 10, "timeout": 1500, "readyState": "interactive"}, profile=None, remote=None, key=None),
call("tabs.watch_url", {"pattern": "example", "tabId": 10, "timeout": 2000}, profile=None, remote=None, key=None),
]
def test_open_background_changes_same_tab(self, tab, mock_send): def test_open_background_changes_same_tab(self, tab, mock_send):
tab.open("https://new.example.com", background=True) tab.open("https://new.example.com", background=True)
mock_send.assert_called_once_with( mock_send.assert_called_once_with(
+44
View File
@@ -117,6 +117,50 @@ def test_send_command_auto_routes_single_remote_target(monkeypatch):
assert "token" not in sent assert "token" not in sent
def test_send_command_prefers_active_local_profile_over_saved_remote_alias(monkeypatch, tmp_path):
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
socket_path = tmp_path / "work.sock"
socket_path.write_text("")
registry_path = tmp_path / "registry.json"
registry_path.write_text(json.dumps({"work": str(socket_path)}), encoding="utf-8")
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", registry_path)
monkeypatch.setattr(
"browser_cli.client.remote_target_for_alias",
lambda alias: pytest.fail("active local profile must not trigger remote alias discovery"),
)
payload = json.dumps({"success": True, "data": "local-ok"}).encode("utf-8")
framed = len(payload).to_bytes(4, "little") + payload
class FakeSocket:
def __init__(self, *args, **kwargs):
self.sent = b""
self._response = bytearray(framed)
self.connected_to = None
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def connect(self, path):
self.connected_to = path
def sendall(self, data):
self.sent += data
def recv(self, n):
chunk = bytes(self._response[:n])
del self._response[:n]
return chunk
monkeypatch.setattr("browser_cli.client.socket.socket", lambda *args, **kwargs: FakeSocket())
assert send_command("tabs.list", profile="work") == "local-ok"
def test_send_command_resolves_browser_alias_to_remote_target(monkeypatch): def test_send_command_resolves_browser_alias_to_remote_target(monkeypatch):
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False) monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
monkeypatch.setenv("BROWSER_CLI_PROFILE", "host:work") monkeypatch.setenv("BROWSER_CLI_PROFILE", "host:work")
+993
View File
@@ -0,0 +1,993 @@
"""
Unit tests for CLI command modules.
All send_command calls are mocked no live browser required.
Uses Click's CliRunner to exercise the CLI output formatting paths.
"""
from unittest.mock import patch, MagicMock
import pytest
from click.testing import CliRunner
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _run(group, args, return_value):
"""Invoke a Click group/command with a mocked send_command return value."""
with patch("browser_cli.commands.send_command", return_value=return_value):
result = CliRunner().invoke(group, args)
return result
def _run_error(group, args, exc):
"""Invoke a Click group/command where send_command raises exc."""
with patch("browser_cli.commands.send_command", side_effect=exc):
result = CliRunner().invoke(group, args)
return result
def _run_no_multi(group, args, return_value, module_path=None):
"""Invoke a command patching send_command AND _multi_browser_targets → single-browser mode."""
ctx1 = patch("browser_cli.commands.send_command", return_value=return_value)
if module_path:
ctx2 = patch(f"{module_path}._multi_browser_targets", return_value=[])
with ctx1, ctx2:
return CliRunner().invoke(group, args)
else:
with ctx1:
return CliRunner().invoke(group, args)
# ---------------------------------------------------------------------------
# dom commands
# ---------------------------------------------------------------------------
from browser_cli.commands.dom import dom_group
def test_cli_dom_query_no_results():
result = _run(dom_group, ["query", "span.nothing"], [])
assert result.exit_code == 0
assert "No elements found" in result.output
def test_cli_dom_query_with_results():
elements = [{"tag": "div", "text": "hello", "attrs": {"class": "foo"}}]
result = _run(dom_group, ["query", "div"], elements)
assert result.exit_code == 0
assert "div" in result.output
def test_cli_dom_click():
result = _run(dom_group, ["click", "button#submit"], None)
assert result.exit_code == 0
assert "Clicked" in result.output
def test_cli_dom_type():
result = _run(dom_group, ["type", "input#name", "pytest"], None)
assert result.exit_code == 0
assert "Typed into" in result.output
def test_cli_dom_attr():
result = _run(dom_group, ["attr", "a", "href"], ["https://example.com"])
assert result.exit_code == 0
assert "https://example.com" in result.output
def test_cli_dom_attr_empty():
result = _run(dom_group, ["attr", "span", "href"], [])
assert result.exit_code == 0
def test_cli_dom_text():
result = _run(dom_group, ["text", "h1"], ["Example Domain"])
assert result.exit_code == 0
assert "Example Domain" in result.output
def test_cli_dom_text_empty():
result = _run(dom_group, ["text", "h9"], [])
assert result.exit_code == 0
def test_cli_dom_exists_true():
result = _run(dom_group, ["exists", "html"], True)
assert result.exit_code == 0
assert "exists" in result.output
def test_cli_dom_exists_false():
result = _run(dom_group, ["exists", "#no-such"], False)
assert result.exit_code != 0
assert "not found" in result.output
def test_cli_dom_scroll_coord():
result = _run(dom_group, ["scroll", "--x", "0", "--y", "500"], None)
assert result.exit_code == 0
assert "Scrolled" in result.output
def test_cli_dom_scroll_selector():
result = _run(dom_group, ["scroll", "footer"], None)
assert result.exit_code == 0
assert "Scrolled" in result.output
def test_cli_dom_eval_string():
result = _run(dom_group, ["eval", "document.title"], "My Page")
assert result.exit_code == 0
assert "My Page" in result.output
def test_cli_dom_eval_null():
result = _run(dom_group, ["eval", "void 0"], None)
assert result.exit_code == 0
assert "null" in result.output
def test_cli_dom_eval_dict():
result = _run(dom_group, ["eval", "({a:1})"], {"a": 1})
assert result.exit_code == 0
assert '"a"' in result.output
def test_cli_dom_wait_for():
result = _run(dom_group, ["wait-for", "html"], None)
assert result.exit_code == 0
assert "Ready" in result.output
def test_cli_dom_wait_for_visible():
result = _run(dom_group, ["wait-for", "html", "--visible"], None)
assert result.exit_code == 0
assert "visible" in result.output
def test_cli_dom_wait_for_hidden():
result = _run(dom_group, ["wait-for", "#spinner", "--hidden"], None)
assert result.exit_code == 0
assert "hidden" in result.output
def test_cli_dom_key():
result = _run(dom_group, ["key", "Enter"], None)
assert result.exit_code == 0
assert "Key 'Enter'" in result.output
def test_cli_dom_key_with_selector():
result = _run(dom_group, ["key", "Tab", "--selector", "input"], None)
assert result.exit_code == 0
assert "input" in result.output
def test_cli_dom_hover():
result = _run(dom_group, ["hover", ".menu"], None)
assert result.exit_code == 0
assert "Hovered" in result.output
def test_cli_dom_check():
result = _run(dom_group, ["check", "#accept"], None)
assert result.exit_code == 0
assert "Checked" in result.output
def test_cli_dom_uncheck():
result = _run(dom_group, ["uncheck", "#accept"], None)
assert result.exit_code == 0
assert "Unchecked" in result.output
def test_cli_dom_clear():
result = _run(dom_group, ["clear", "input#q"], None)
assert result.exit_code == 0
assert "Cleared" in result.output
def test_cli_dom_focus():
result = _run(dom_group, ["focus", "input#q"], None)
assert result.exit_code == 0
assert "Focused" in result.output
def test_cli_dom_submit():
result = _run(dom_group, ["submit", "form"], None)
assert result.exit_code == 0
assert "Submitted" in result.output
def test_cli_dom_select():
result = _run(dom_group, ["select", "#lang", "en"], None)
assert result.exit_code == 0
assert "Selected" in result.output
def test_cli_dom_poll():
result = _run(dom_group, ["poll", "#status", "ready"], {"value": "ready"})
assert result.exit_code == 0
assert "Matched" in result.output
# ---------------------------------------------------------------------------
# cookies commands
# ---------------------------------------------------------------------------
from browser_cli.commands.cookies import cookies_group
def test_cli_cookies_list_empty():
result = _run(cookies_group, ["list"], [])
assert result.exit_code == 0
assert "No cookies found" in result.output
def test_cli_cookies_list_with_cookies():
cookies = [{"name": "session", "value": "abc123", "domain": "example.com",
"path": "/", "secure": True, "httpOnly": False}]
result = _run(cookies_group, ["list"], cookies)
assert result.exit_code == 0
assert "session" in result.output
assert "example.com" in result.output
def test_cli_cookies_list_filter_url():
cookies = [{"name": "x", "value": "y", "domain": "example.com",
"path": "/", "secure": False, "httpOnly": False}]
result = _run(cookies_group, ["list", "--url", "https://example.com"], cookies)
assert result.exit_code == 0
assert "example.com" in result.output
def test_cli_cookies_list_filter_domain():
cookies = [{"name": "x", "value": "y", "domain": "example.com",
"path": "/", "secure": False, "httpOnly": False}]
result = _run(cookies_group, ["list", "--domain", "example.com"], cookies)
assert result.exit_code == 0
def test_cli_cookies_list_filter_name():
cookies = [{"name": "session", "value": "abc", "domain": "example.com",
"path": "/", "secure": False, "httpOnly": True}]
result = _run(cookies_group, ["list", "--name", "session"], cookies)
assert result.exit_code == 0
assert "session" in result.output
def test_cli_cookies_get_found():
cookie = {"name": "tok", "value": "secret123", "domain": "example.com", "path": "/"}
result = _run(cookies_group, ["get", "https://example.com", "tok"], cookie)
assert result.exit_code == 0
assert "secret123" in result.output
def test_cli_cookies_get_not_found():
result = _run(cookies_group, ["get", "https://example.com", "missing"], None)
assert result.exit_code != 0
assert "not found" in result.output
def test_cli_cookies_set():
result = _run(cookies_group, ["set", "https://example.com", "tok", "val"], None)
assert result.exit_code == 0
assert "Set cookie" in result.output
def test_cli_cookies_set_with_options():
result = _run(cookies_group, [
"set", "https://example.com", "tok", "val",
"--secure", "--http-only", "--path", "/app",
"--same-site", "lax",
], None)
assert result.exit_code == 0
assert "Set cookie" in result.output
# ---------------------------------------------------------------------------
# page commands
# ---------------------------------------------------------------------------
from browser_cli.commands.page import page_group
def test_cli_page_info_basic():
info = {"title": "Example", "url": "https://example.com", "readyState": "complete", "lang": "en", "meta": {}}
result = _run(page_group, ["info"], info)
assert result.exit_code == 0
assert "Example" in result.output
assert "https://example.com" in result.output
def test_cli_page_info_with_meta():
info = {"title": "My Page", "url": "https://example.com", "readyState": "complete", "lang": "de",
"meta": {"description": "A test page", "author": "Tester"}}
result = _run(page_group, ["info"], info)
assert result.exit_code == 0
assert "description" in result.output
assert "A test page" in result.output
def test_cli_page_info_empty_response():
result = _run(page_group, ["info"], {})
assert result.exit_code == 0
# ---------------------------------------------------------------------------
# storage commands
# ---------------------------------------------------------------------------
from browser_cli.commands.storage import storage_group
def test_cli_storage_get_null():
result = _run(storage_group, ["get", "mykey"], None)
assert result.exit_code == 0
assert "null" in result.output
def test_cli_storage_get_string():
result = _run(storage_group, ["get", "mykey"], "hello-world")
assert result.exit_code == 0
assert "hello-world" in result.output
def test_cli_storage_get_dict():
result = _run(storage_group, ["get"], {"k1": "v1", "k2": "v2"})
assert result.exit_code == 0
assert "k1" in result.output
def test_cli_storage_get_session():
result = _run(storage_group, ["get", "key", "--type", "session"], "ses-val")
assert result.exit_code == 0
assert "ses-val" in result.output
def test_cli_storage_set():
result = _run(storage_group, ["set", "mykey", "myvalue"], None)
assert result.exit_code == 0
assert "Set" in result.output
assert "mykey" in result.output
def test_cli_storage_set_session():
result = _run(storage_group, ["set", "sk", "sv", "--type", "session"], None)
assert result.exit_code == 0
assert "session" in result.output
# ---------------------------------------------------------------------------
# perf commands
# ---------------------------------------------------------------------------
from browser_cli.commands.perf import perf_group
def test_cli_perf_status_no_jobs():
status = {"performanceProfile": "auto", "audible": False,
"throttle": {"batchSize": 10, "pauseMs": 50, "mode": "auto"}, "jobs": []}
result = _run(perf_group, ["status"], status)
assert result.exit_code == 0
assert "auto" in result.output
assert "No running jobs" in result.output
def test_cli_perf_status_with_jobs():
status = {
"performanceProfile": "gentle",
"audible": True,
"throttle": {"batchSize": 5, "pauseMs": 200, "mode": "gentle"},
"jobs": [
{"id": "j1", "command": "session.load", "status": "running",
"current": 3, "total": 10, "percent": 30, "phase": "open_tabs"},
],
}
result = _run(perf_group, ["status"], status)
assert result.exit_code == 0
assert "j1" in result.output
assert "session.load" in result.output
assert "30%" in result.output
def test_cli_perf_profile_set():
result = _run(perf_group, ["profile", "normal"], {"performanceProfile": "normal"})
assert result.exit_code == 0
assert "normal" in result.output
def test_cli_perf_profile_gentle():
result = _run(perf_group, ["profile", "gentle"], {"performanceProfile": "gentle"})
assert result.exit_code == 0
assert "gentle" in result.output
def test_cli_perf_profile_ultra():
result = _run(perf_group, ["profile", "ultra"], {"performanceProfile": "ultra"})
assert result.exit_code == 0
assert "ultra" in result.output
# ---------------------------------------------------------------------------
# navigate commands
# ---------------------------------------------------------------------------
from browser_cli.commands.navigate import nav_group
def test_cli_nav_open():
result = _run(nav_group, ["open", "https://example.com"], {"id": 42, "url": "https://example.com"})
assert result.exit_code == 0
assert "Opened" in result.output
def test_cli_nav_open_bg():
result = _run(nav_group, ["open", "https://example.com", "--bg"], {"id": 42})
assert result.exit_code == 0
assert "Opened" in result.output
def test_cli_nav_open_with_group():
result = _run(nav_group, ["open", "https://example.com", "--group", "work"], {"id": 42})
assert result.exit_code == 0
assert "work" in result.output
def test_cli_nav_open_with_window():
result = _run(nav_group, ["open", "https://example.com", "--window", "main"], {"id": 42})
assert result.exit_code == 0
assert "main" in result.output
def test_cli_nav_reload():
result = _run(nav_group, ["reload"], None)
assert result.exit_code == 0
assert "Reloaded" in result.output
def test_cli_nav_hard_reload():
result = _run(nav_group, ["hard-reload"], None)
assert result.exit_code == 0
assert "Hard reloaded" in result.output
def test_cli_nav_back():
result = _run(nav_group, ["back"], None)
assert result.exit_code == 0
assert "back" in result.output.lower()
def test_cli_nav_forward():
result = _run(nav_group, ["forward"], None)
assert result.exit_code == 0
assert "forward" in result.output.lower()
def test_cli_nav_focus_found():
result = _run(nav_group, ["focus", "example.com"], {"url": "https://example.com"})
assert result.exit_code == 0
assert "Focused" in result.output
def test_cli_nav_focus_not_found():
result = _run(nav_group, ["focus", "no-match"], None)
assert result.exit_code == 0
assert "No tab found" in result.output
def test_cli_nav_open_wait():
result = _run(nav_group, ["open-wait", "https://example.com"],
{"id": 1, "title": "Example Domain", "url": "https://example.com"})
assert result.exit_code == 0
assert "Loaded" in result.output
assert "Example Domain" in result.output
def test_cli_nav_open_wait_no_title():
result = _run(nav_group, ["open-wait", "https://example.com"], {"id": 1})
assert result.exit_code == 0
assert "Loaded" in result.output
def test_cli_nav_wait():
result = _run(nav_group, ["wait"], {"url": "https://example.com", "title": "Example"})
assert result.exit_code == 0
assert "Ready" in result.output
# ---------------------------------------------------------------------------
# navigate commands — with tab_id argument
# ---------------------------------------------------------------------------
def test_cli_nav_reload_with_tab_id():
result = _run(nav_group, ["reload", "42"], None)
assert result.exit_code == 0
def test_cli_nav_back_with_tab_id():
result = _run(nav_group, ["back", "42"], None)
assert result.exit_code == 0
def test_cli_nav_forward_with_tab_id():
result = _run(nav_group, ["forward", "42"], None)
assert result.exit_code == 0
# ---------------------------------------------------------------------------
# tabs commands (CLI module) — single-browser mode
# ---------------------------------------------------------------------------
from browser_cli.commands.tabs import tabs_group
_TABS_MOD = "browser_cli.commands.tabs"
_SAMPLE_TAB = {"id": 1, "windowId": 1, "active": True, "muted": False,
"title": "Example", "url": "https://example.com", "groupId": -1}
def test_cli_tabs_list_empty():
with patch("browser_cli.commands.send_command", return_value=[]), \
patch(f"{_TABS_MOD}._multi_browser_targets", return_value=[]):
result = CliRunner().invoke(tabs_group, ["list"])
assert result.exit_code == 0
assert "No tabs found" in result.output
def test_cli_tabs_list_with_tabs():
with patch("browser_cli.commands.send_command", return_value=[_SAMPLE_TAB]), \
patch(f"{_TABS_MOD}._multi_browser_targets", return_value=[]):
result = CliRunner().invoke(tabs_group, ["list"])
assert result.exit_code == 0
assert "example.com" in result.output
def test_cli_tabs_close_by_id():
with patch("browser_cli.commands.send_command", return_value={"closed": 1}):
result = CliRunner().invoke(tabs_group, ["close", "42"])
assert result.exit_code == 0
assert "Closed 1" in result.output
def test_cli_tabs_close_inactive():
with patch("browser_cli.commands.send_command", return_value={"closed": 3}):
result = CliRunner().invoke(tabs_group, ["close", "--inactive"])
assert result.exit_code == 0
assert "Closed 3" in result.output
def test_cli_tabs_close_duplicates():
with patch("browser_cli.commands.send_command", return_value={"closed": 2}):
result = CliRunner().invoke(tabs_group, ["close", "--duplicates"])
assert result.exit_code == 0
assert "Closed 2" in result.output
def test_cli_tabs_move_forward():
with patch("browser_cli.commands.send_command", return_value=None):
result = CliRunner().invoke(tabs_group, ["move", "42", "--forward"])
assert result.exit_code == 0
assert "Tab moved" in result.output
def test_cli_tabs_move_to_window():
with patch("browser_cli.commands.send_command", return_value=None):
result = CliRunner().invoke(tabs_group, ["move", "42", "--window", "2"])
assert result.exit_code == 0
def test_cli_tabs_active():
with patch("browser_cli.commands.send_command", return_value=None):
result = CliRunner().invoke(tabs_group, ["active", "42"])
assert result.exit_code == 0
assert "42" in result.output
def test_cli_tabs_status():
tab = {"id": 5, "windowId": 1, "active": True, "muted": False,
"title": "MyPage", "url": "https://my.page"}
with patch("browser_cli.commands.send_command", return_value=tab):
result = CliRunner().invoke(tabs_group, ["status"])
assert result.exit_code == 0
assert "MyPage" in result.output
def test_cli_tabs_filter():
with patch("browser_cli.commands.send_command", return_value=[_SAMPLE_TAB]):
result = CliRunner().invoke(tabs_group, ["filter", "example"])
assert result.exit_code == 0
assert "example.com" in result.output
def test_cli_tabs_filter_empty():
with patch("browser_cli.commands.send_command", return_value=[]):
result = CliRunner().invoke(tabs_group, ["filter", "nope"])
assert result.exit_code == 0
assert "No tabs" in result.output
def test_cli_tabs_count():
with patch("browser_cli.commands.send_command", return_value=7), \
patch(f"{_TABS_MOD}._multi_browser_targets", return_value=[]):
result = CliRunner().invoke(tabs_group, ["count"])
assert result.exit_code == 0
assert "7" in result.output
def test_cli_tabs_count_with_pattern():
with patch("browser_cli.commands.send_command", return_value=3), \
patch(f"{_TABS_MOD}._multi_browser_targets", return_value=[]):
result = CliRunner().invoke(tabs_group, ["count", "http"])
assert result.exit_code == 0
assert "3" in result.output
assert "http" in result.output
def test_cli_tabs_query():
with patch("browser_cli.commands.send_command", return_value=[_SAMPLE_TAB]):
result = CliRunner().invoke(tabs_group, ["query", "example"])
assert result.exit_code == 0
assert "example.com" in result.output
def test_cli_tabs_html():
with patch("browser_cli.commands.send_command", return_value="<html><body>hello</body></html>"):
result = CliRunner().invoke(tabs_group, ["html"])
assert result.exit_code == 0
assert "hello" in result.output
def test_cli_tabs_dedupe():
with patch("browser_cli.commands.send_command", return_value={"closed": 4}):
result = CliRunner().invoke(tabs_group, ["dedupe"])
assert result.exit_code == 0
assert "4 duplicate" in result.output
def test_cli_tabs_sort():
with patch("browser_cli.commands.send_command", return_value=None):
result = CliRunner().invoke(tabs_group, ["sort", "--by", "title"])
assert result.exit_code == 0
assert "title" in result.output
def test_cli_tabs_merge_windows():
with patch("browser_cli.commands.send_command", return_value={"moved": 2}):
result = CliRunner().invoke(tabs_group, ["merge-windows"])
assert result.exit_code == 0
assert "2" in result.output
def test_cli_tabs_mute():
with patch("browser_cli.commands.send_command", return_value={"tabId": 5}):
result = CliRunner().invoke(tabs_group, ["mute", "5"])
assert result.exit_code == 0
assert "Muted tab 5" in result.output
def test_cli_tabs_unmute():
with patch("browser_cli.commands.send_command", return_value={"tabId": 5}):
result = CliRunner().invoke(tabs_group, ["unmute", "5"])
assert result.exit_code == 0
assert "Unmuted tab 5" in result.output
def test_cli_tabs_pin():
with patch("browser_cli.commands.send_command", return_value={"tabId": 5}):
result = CliRunner().invoke(tabs_group, ["pin", "5"])
assert result.exit_code == 0
assert "Pinned tab 5" in result.output
def test_cli_tabs_unpin():
with patch("browser_cli.commands.send_command", return_value={"tabId": 5}):
result = CliRunner().invoke(tabs_group, ["unpin", "5"])
assert result.exit_code == 0
assert "Unpinned tab 5" in result.output
def test_cli_tabs_watch_url():
with patch("browser_cli.commands.send_command", return_value={"url": "https://done.com"}):
result = CliRunner().invoke(tabs_group, ["watch-url", "done\\.com"])
assert result.exit_code == 0
assert "done.com" in result.output
def test_cli_tabs_screenshot_stdout():
with patch("browser_cli.commands.send_command", return_value={"dataUrl": "data:image/png;base64,abc"}):
result = CliRunner().invoke(tabs_group, ["screenshot"])
assert result.exit_code == 0
assert "data:image/png" in result.output
def test_cli_tabs_screenshot_to_file(tmp_path):
import base64
png_bytes = b"\x89PNG\r\n\x1a\n" + b"\x00" * 8 # minimal header
data_url = "data:image/png;base64," + base64.b64encode(png_bytes).decode()
out = tmp_path / "shot.png"
with patch("browser_cli.commands.send_command", return_value={"dataUrl": data_url}):
result = CliRunner().invoke(tabs_group, ["screenshot", str(out)])
assert result.exit_code == 0
assert "saved" in result.output.lower()
assert out.exists()
# ---------------------------------------------------------------------------
# groups commands (CLI module)
# ---------------------------------------------------------------------------
from browser_cli.commands.groups import group_group
_GROUPS_MOD = "browser_cli.commands.groups"
_SAMPLE_GROUP = {"id": 10, "title": "Work", "color": "blue", "collapsed": False, "tabCount": 3}
def test_cli_groups_list_empty():
with patch("browser_cli.commands.send_command", return_value=[]), \
patch(f"{_GROUPS_MOD}._multi_browser_targets", return_value=[]):
result = CliRunner().invoke(group_group, ["list"])
assert result.exit_code == 0
assert "No groups found" in result.output
def test_cli_groups_list_with_groups():
with patch("browser_cli.commands.send_command", return_value=[_SAMPLE_GROUP]), \
patch(f"{_GROUPS_MOD}._multi_browser_targets", return_value=[]):
result = CliRunner().invoke(group_group, ["list"])
assert result.exit_code == 0
assert "Work" in result.output
def test_cli_groups_tabs():
tabs = [_SAMPLE_TAB]
with patch("browser_cli.commands.send_command", return_value=tabs):
result = CliRunner().invoke(group_group, ["tabs", "10"])
assert result.exit_code == 0
assert "example.com" in result.output
def test_cli_groups_tabs_empty():
with patch("browser_cli.commands.send_command", return_value=[]):
result = CliRunner().invoke(group_group, ["tabs", "10"])
assert result.exit_code == 0
assert "No tabs" in result.output
def test_cli_groups_count():
with patch("browser_cli.commands.send_command", return_value=5), \
patch(f"{_GROUPS_MOD}._multi_browser_targets", return_value=[]):
result = CliRunner().invoke(group_group, ["count"])
assert result.exit_code == 0
assert "5" in result.output
def test_cli_groups_query_empty():
with patch("browser_cli.commands.send_command", return_value=[]):
result = CliRunner().invoke(group_group, ["query", "nothing"])
assert result.exit_code == 0
assert "No groups" in result.output
def test_cli_groups_query_found():
with patch("browser_cli.commands.send_command", return_value=[_SAMPLE_GROUP]):
result = CliRunner().invoke(group_group, ["query", "Work"])
assert result.exit_code == 0
assert "Work" in result.output
def test_cli_groups_close():
with patch("browser_cli.commands.send_command", return_value=None):
result = CliRunner().invoke(group_group, ["close", "10"])
assert result.exit_code == 0
assert "10" in result.output
def test_cli_groups_create():
with patch("browser_cli.commands.send_command", return_value={"id": 42}):
result = CliRunner().invoke(group_group, ["create", "Research"])
assert result.exit_code == 0
assert "Research" in result.output
assert "42" in result.output
def test_cli_groups_add_tab_no_url():
with patch("browser_cli.commands.send_command", return_value={"tabId": 7}):
result = CliRunner().invoke(group_group, ["add-tab", "Work"])
assert result.exit_code == 0
assert "Work" in result.output
def test_cli_groups_add_tab_with_url():
with patch("browser_cli.commands.send_command", return_value={"tabId": 9}):
result = CliRunner().invoke(group_group, ["add-tab", "Work", "https://docs.example.com"])
assert result.exit_code == 0
assert "docs.example.com" in result.output
def test_cli_groups_move_forward():
with patch("browser_cli.commands.send_command", return_value={"moved": True}):
result = CliRunner().invoke(group_group, ["move", "10", "--forward"])
assert result.exit_code == 0
assert "forward" in result.output
def test_cli_groups_move_backward():
with patch("browser_cli.commands.send_command", return_value={"moved": True}):
result = CliRunner().invoke(group_group, ["move", "10", "--backward"])
assert result.exit_code == 0
assert "backward" in result.output
def test_cli_groups_move_no_direction():
with patch("browser_cli.commands.send_command", return_value=None):
result = CliRunner().invoke(group_group, ["move", "10"])
assert result.exit_code != 0
def test_cli_groups_move_already_at_end():
with patch("browser_cli.commands.send_command", return_value={"moved": False}):
result = CliRunner().invoke(group_group, ["move", "10", "--forward"])
assert result.exit_code == 0
assert "already at" in result.output
# ---------------------------------------------------------------------------
# session commands (CLI module)
# ---------------------------------------------------------------------------
from browser_cli.commands.session import session_group
_SESSION_MOD = "browser_cli.commands.session"
def test_cli_session_save():
with patch("browser_cli.commands.send_command", return_value={"tabs": 5}):
result = CliRunner().invoke(session_group, ["save", "work"])
assert result.exit_code == 0
assert "work" in result.output
assert "5" in result.output
def test_cli_session_load():
with patch("browser_cli.commands.send_command", return_value={"tabs": 8}):
result = CliRunner().invoke(session_group, ["load", "work"])
assert result.exit_code == 0
assert "work" in result.output
assert "8" in result.output
def test_cli_session_load_background():
with patch("browser_cli.commands.send_command", return_value={"jobId": "j-abc", "status": "running"}):
result = CliRunner().invoke(session_group, ["load", "work", "--background"])
assert result.exit_code == 0
assert "j-abc" in result.output
def test_cli_session_load_lazy():
with patch("browser_cli.commands.send_command", return_value={"tabs": 20}):
result = CliRunner().invoke(session_group, ["load", "work", "--lazy", "--eager-tabs", "5"])
assert result.exit_code == 0
def test_cli_session_diff_has_changes():
diff = {"added": ["https://new.com"], "removed": ["https://old.com"]}
with patch("browser_cli.commands.send_command", return_value=diff):
result = CliRunner().invoke(session_group, ["diff", "a", "b"])
assert result.exit_code == 0
assert "new.com" in result.output
assert "old.com" in result.output
def test_cli_session_diff_identical():
with patch("browser_cli.commands.send_command", return_value={"added": [], "removed": []}):
result = CliRunner().invoke(session_group, ["diff", "a", "b"])
assert result.exit_code == 0
assert "identical" in result.output
def test_cli_session_diff_no_data():
with patch("browser_cli.commands.send_command", return_value=None):
result = CliRunner().invoke(session_group, ["diff", "a", "b"])
assert result.exit_code == 0
assert "No diff" in result.output
def test_cli_session_list_empty():
with patch("browser_cli.commands.send_command", return_value=[]), \
patch(f"{_SESSION_MOD}._multi_browser_targets", return_value=[]):
result = CliRunner().invoke(session_group, ["list"])
assert result.exit_code == 0
assert "No saved sessions" in result.output
def test_cli_session_list_with_sessions():
sessions = [{"name": "work", "tabs": 3, "savedAt": 1700000000000}]
with patch("browser_cli.commands.send_command", return_value=sessions), \
patch(f"{_SESSION_MOD}._multi_browser_targets", return_value=[]):
result = CliRunner().invoke(session_group, ["list"])
assert result.exit_code == 0
assert "work" in result.output
def test_cli_session_remove():
with patch("browser_cli.commands.send_command", return_value=None):
result = CliRunner().invoke(session_group, ["remove", "old-session"])
assert result.exit_code == 0
assert "old-session" in result.output
def test_cli_session_job_status_running():
with patch("browser_cli.commands.send_command", return_value={"status": "running", "percent": 42}):
result = CliRunner().invoke(session_group, ["job-status", "j-xyz"])
assert result.exit_code == 0
assert "running" in result.output
def test_cli_session_job_status_with_error():
with patch("browser_cli.commands.send_command", return_value={"status": "failed", "error": "something broke"}):
result = CliRunner().invoke(session_group, ["job-status", "j-bad"])
assert result.exit_code == 0
assert "something broke" in result.output
def test_cli_session_job_status_with_result():
with patch("browser_cli.commands.send_command", return_value={"status": "done", "result": "Opened 5 tabs"}):
result = CliRunner().invoke(session_group, ["job-status", "j-done"])
assert result.exit_code == 0
assert "Opened 5 tabs" in result.output
def test_cli_session_job_cancel():
with patch("browser_cli.commands.send_command", return_value=None):
result = CliRunner().invoke(session_group, ["job-cancel", "j-running"])
assert result.exit_code == 0
assert "j-running" in result.output
def test_cli_session_auto_save_on():
with patch("browser_cli.commands.send_command", return_value=None):
result = CliRunner().invoke(session_group, ["auto-save", "on"])
assert result.exit_code == 0
assert "on" in result.output
def test_cli_session_auto_save_off():
with patch("browser_cli.commands.send_command", return_value=None):
result = CliRunner().invoke(session_group, ["auto-save", "off"])
assert result.exit_code == 0
assert "off" in result.output
# ---------------------------------------------------------------------------
# multi-browser error paths (None targets → "Cannot resolve" exit)
# ---------------------------------------------------------------------------
from browser_cli.client import BrowserTarget
def _fake_target(name="browser-a"):
return BrowserTarget(profile=name, display_name=name, socket_path="/tmp/fake.sock")
def test_cli_tabs_list_multi_browser_all_none():
"""If every multi-browser target returns None, show error and exit 1."""
target = _fake_target()
with patch("browser_cli.commands.send_command", return_value=None), \
patch(f"{_TABS_MOD}._multi_browser_targets", return_value=[target]), \
patch(f"{_TABS_MOD}._handle_multi", return_value=None):
result = CliRunner().invoke(tabs_group, ["list"])
assert result.exit_code != 0
def test_cli_tabs_count_multi_browser_all_none():
"""If every multi-browser count returns None, show error and exit 1."""
target = _fake_target()
with patch("browser_cli.commands.send_command", return_value=None), \
patch(f"{_TABS_MOD}._multi_browser_targets", return_value=[target]), \
patch(f"{_TABS_MOD}._handle_multi", return_value=None):
result = CliRunner().invoke(tabs_group, ["count"])
assert result.exit_code != 0
def test_cli_groups_list_multi_browser_all_none():
"""If every multi-browser group list returns None, exit 1."""
target = _fake_target()
with patch("browser_cli.commands.send_command", return_value=None), \
patch(f"{_GROUPS_MOD}._multi_browser_targets", return_value=[target]), \
patch(f"{_GROUPS_MOD}._handle_multi", return_value=None):
result = CliRunner().invoke(group_group, ["list"])
assert result.exit_code != 0
def test_cli_groups_count_multi_browser_all_none():
"""If every multi-browser group count returns None, exit 1."""
target = _fake_target()
with patch("browser_cli.commands.send_command", return_value=None), \
patch(f"{_GROUPS_MOD}._multi_browser_targets", return_value=[target]), \
patch(f"{_GROUPS_MOD}._handle_multi", return_value=None):
result = CliRunner().invoke(group_group, ["count"])
assert result.exit_code != 0
def test_cli_session_list_multi_browser_all_none():
"""If every multi-browser session list returns None, exit 1."""
target = _fake_target()
with patch("browser_cli.commands.send_command", return_value=None), \
patch(f"{_SESSION_MOD}._multi_browser_targets", return_value=[target]), \
patch(f"{_SESSION_MOD}._handle_multi", return_value=None):
result = CliRunner().invoke(session_group, ["list"])
assert result.exit_code != 0
# ---------------------------------------------------------------------------
# tabs screenshot error paths
# ---------------------------------------------------------------------------
def test_cli_tabs_screenshot_bad_dataurl(tmp_path):
"""Screenshot command exits non-zero when dataUrl has wrong format."""
out = tmp_path / "bad.png"
with patch("browser_cli.commands.send_command", return_value={"dataUrl": "not-a-dataurl"}):
result = CliRunner().invoke(tabs_group, ["screenshot", str(out)])
assert result.exit_code != 0
def test_cli_tabs_screenshot_bad_base64(tmp_path):
"""Screenshot command exits non-zero when base64 data is corrupt."""
out = tmp_path / "bad.png"
with patch("browser_cli.commands.send_command", return_value={"dataUrl": "data:image/png;base64,!!!NOT_VALID_BASE64!!!"}):
result = CliRunner().invoke(tabs_group, ["screenshot", str(out)])
assert result.exit_code != 0
# ---------------------------------------------------------------------------
# windows commands (CLI module)
# ---------------------------------------------------------------------------
from browser_cli.commands.windows import windows_group
_WINDOWS_MOD = "browser_cli.commands.windows"
_SAMPLE_WINDOW = {"id": 1, "alias": "main", "tabCount": 5, "state": "normal"}
def test_cli_windows_list_empty():
with patch("browser_cli.commands.send_command", return_value=[]), \
patch(f"{_WINDOWS_MOD}._multi_browser_targets", return_value=[]):
result = CliRunner().invoke(windows_group, ["list"])
assert result.exit_code == 0
assert "No windows found" in result.output
def test_cli_windows_list_with_windows():
with patch("browser_cli.commands.send_command", return_value=[_SAMPLE_WINDOW]), \
patch(f"{_WINDOWS_MOD}._multi_browser_targets", return_value=[]):
result = CliRunner().invoke(windows_group, ["list"])
assert result.exit_code == 0
assert "main" in result.output
def test_cli_windows_list_multi_all_none():
target = _fake_target()
with patch("browser_cli.commands.send_command", return_value=None), \
patch(f"{_WINDOWS_MOD}._multi_browser_targets", return_value=[target]), \
patch(f"{_WINDOWS_MOD}._handle_multi", return_value=None):
result = CliRunner().invoke(windows_group, ["list"])
assert result.exit_code != 0
def test_cli_windows_rename():
with patch("browser_cli.commands.send_command", return_value=None):
result = CliRunner().invoke(windows_group, ["rename", "1", "work"])
assert result.exit_code == 0
assert "work" in result.output
assert "1" in result.output
def test_cli_windows_close():
with patch("browser_cli.commands.send_command", return_value=None):
result = CliRunner().invoke(windows_group, ["close", "2"])
assert result.exit_code == 0
assert "2" in result.output
def test_cli_windows_open_no_url():
with patch("browser_cli.commands.send_command", return_value={"id": 5}):
result = CliRunner().invoke(windows_group, ["open"])
assert result.exit_code == 0
assert "5" in result.output
def test_cli_windows_open_with_url():
with patch("browser_cli.commands.send_command", return_value={"id": 6}):
result = CliRunner().invoke(windows_group, ["open", "https://example.com"])
assert result.exit_code == 0
assert "example.com" in result.output
# ---------------------------------------------------------------------------
# commands/__init__.py error paths
# ---------------------------------------------------------------------------
from browser_cli.commands import _handle, _handle_multi
from browser_cli.client import BrowserNotConnected
def test_handle_raises_system_exit_on_browser_not_connected():
"""_handle converts BrowserNotConnected into SystemExit(1)."""
with patch("browser_cli.commands.send_command", side_effect=BrowserNotConnected("no socket")):
with pytest.raises(SystemExit):
_handle("tabs.list")
def test_handle_raises_system_exit_on_runtime_error():
"""_handle converts RuntimeError into SystemExit(1)."""
with patch("browser_cli.commands.send_command", side_effect=RuntimeError("browser blew up")):
with pytest.raises(SystemExit):
_handle("tabs.list")
def test_handle_multi_returns_none_on_error():
"""_handle_multi silently returns None on BrowserNotConnected."""
with patch("browser_cli.commands.send_command", side_effect=BrowserNotConnected("gone")):
result = _handle_multi("tabs.list")
assert result is None
def test_handle_multi_returns_none_on_runtime_error():
"""_handle_multi silently returns None on RuntimeError."""
with patch("browser_cli.commands.send_command", side_effect=RuntimeError("oops")):
result = _handle_multi("tabs.list")
assert result is None
def test_handle_multi_with_remote():
"""_handle_multi routes through remote when remote arg is set."""
with patch("browser_cli.commands.send_command", return_value={"ok": True}) as mock_send:
result = _handle_multi("tabs.list", profile="brave", remote="host:8765")
assert result == {"ok": True}
mock_send.assert_called_once_with("tabs.list", {}, profile="brave", remote="host:8765")
+103
View File
@@ -0,0 +1,103 @@
"""Integration tests for cookies.* commands — require a live browser."""
import time
def test_cookies_list_returns_list(browser, http_tab):
"""cookies.list returns a list (may be empty on a plain https://example.com)."""
browser("tabs.active", {"tabId": http_tab["id"]})
cookies = browser("cookies.list", {})
assert isinstance(cookies, list)
def test_cookies_list_has_required_fields(browser, http_tab):
"""Every cookie returned has at least name, domain and path fields."""
browser("tabs.active", {"tabId": http_tab["id"]})
# Set a known cookie so the list is non-empty
browser("cookies.set", {
"url": "https://example.com",
"name": "__pytest_field_check",
"value": "1",
})
cookies = browser("cookies.list", {"url": "https://example.com"})
assert isinstance(cookies, list)
assert len(cookies) > 0
for c in cookies:
assert "name" in c
assert "domain" in c
assert "path" in c
def test_cookies_set_and_list(browser, http_tab):
"""Set a cookie and verify it appears in the list."""
browser("tabs.active", {"tabId": http_tab["id"]})
cookie_name = "__pytest_set_test"
cookie_value = "hello-pytest"
browser("cookies.set", {
"url": "https://example.com",
"name": cookie_name,
"value": cookie_value,
})
cookies = browser("cookies.list", {"url": "https://example.com"})
found = next((c for c in cookies if c.get("name") == cookie_name), None)
assert found is not None, f"Cookie '{cookie_name}' not found after set"
assert found["value"] == cookie_value
def test_cookies_get(browser, http_tab):
"""Get a single cookie by URL + name."""
browser("tabs.active", {"tabId": http_tab["id"]})
name = "__pytest_get_test"
value = "get-value-42"
browser("cookies.set", {"url": "https://example.com", "name": name, "value": value})
cookie = browser("cookies.get", {"url": "https://example.com", "name": name})
assert cookie is not None
assert cookie.get("value") == value
def test_cookies_get_missing_returns_none(browser, http_tab):
"""Getting a non-existent cookie returns None."""
browser("tabs.active", {"tabId": http_tab["id"]})
cookie = browser("cookies.get", {
"url": "https://example.com",
"name": "__pytest_no_such_cookie_zzz",
})
assert cookie is None
def test_cookies_list_filter_by_domain(browser, http_tab):
"""Filtering by domain only returns matching cookies."""
browser("tabs.active", {"tabId": http_tab["id"]})
browser("cookies.set", {
"url": "https://example.com",
"name": "__pytest_domain_filter",
"value": "yes",
})
cookies = browser("cookies.list", {"domain": "example.com"})
assert isinstance(cookies, list)
for c in cookies:
assert "example.com" in c.get("domain", "")
def test_cookies_list_filter_by_name(browser, http_tab):
"""Filtering by name only returns cookies with that name."""
browser("tabs.active", {"tabId": http_tab["id"]})
name = "__pytest_name_filter_unique"
browser("cookies.set", {"url": "https://example.com", "name": name, "value": "y"})
cookies = browser("cookies.list", {"name": name})
assert isinstance(cookies, list)
assert len(cookies) > 0
for c in cookies:
assert c["name"] == name
def test_cookies_set_with_secure_flag(browser, http_tab):
"""Setting a cookie with secure=True persists the secure attribute."""
browser("tabs.active", {"tabId": http_tab["id"]})
name = "__pytest_secure_cookie"
browser("cookies.set", {
"url": "https://example.com",
"name": name,
"value": "secured",
"secure": True,
})
cookies = browser("cookies.list", {"url": "https://example.com", "name": name})
found = next((c for c in cookies if c["name"] == name), None)
assert found is not None
assert found.get("secure") is True
+207
View File
@@ -58,3 +58,210 @@ def test_dom_attr_html_lang(browser, http_tab):
assert isinstance(langs, list) assert isinstance(langs, list)
# html element exists so we get exactly one entry (may be empty string if no lang attr) # html element exists so we get exactly one entry (may be empty string if no lang attr)
assert len(langs) <= 1 assert len(langs) <= 1
# ---------------------------------------------------------------------------
# dom.eval
# ---------------------------------------------------------------------------
def test_dom_eval_returns_string(browser, http_tab):
"""Evaluating document.title returns the page title as a string."""
browser("tabs.active", {"tabId": http_tab["id"]})
result = browser("dom.eval", {"code": "document.title", "tabId": http_tab["id"]})
assert isinstance(result, str)
def test_dom_eval_arithmetic(browser, http_tab):
"""Evaluating a JS expression returns the computed value."""
browser("tabs.active", {"tabId": http_tab["id"]})
result = browser("dom.eval", {"code": "2 + 2", "tabId": http_tab["id"]})
assert result == 4
def test_dom_eval_returns_null_for_void(browser, http_tab):
"""Evaluating a void expression returns None."""
browser("tabs.active", {"tabId": http_tab["id"]})
result = browser("dom.eval", {"code": "void 0", "tabId": http_tab["id"]})
assert result is None
def test_dom_eval_returns_dict(browser, http_tab):
"""Evaluating an object expression returns a dict."""
browser("tabs.active", {"tabId": http_tab["id"]})
result = browser("dom.eval", {"code": "({a: 1, b: 2})", "tabId": http_tab["id"]})
assert isinstance(result, dict)
assert result.get("a") == 1
assert result.get("b") == 2
def test_dom_eval_dom_read(browser, http_tab):
"""Can read a property of a DOM element via eval."""
browser("tabs.active", {"tabId": http_tab["id"]})
result = browser("dom.eval", {"code": "document.querySelector('h1') ? document.querySelector('h1').textContent : null", "tabId": http_tab["id"]})
# result is either a string (h1 text) or None — both are valid
assert result is None or isinstance(result, str)
# ---------------------------------------------------------------------------
# dom.scroll
# ---------------------------------------------------------------------------
def test_dom_scroll_to_coordinates(browser, http_tab):
"""Scrolling to (x, y) coordinates does not raise."""
browser("tabs.active", {"tabId": http_tab["id"]})
result = browser("dom.scroll", {"x": 0, "y": 0})
assert result is None or isinstance(result, (dict, bool))
def test_dom_scroll_to_selector(browser, http_tab):
"""Scrolling to an existing selector does not raise."""
browser("tabs.active", {"tabId": http_tab["id"]})
result = browser("dom.scroll", {"selector": "body"})
assert result is None or isinstance(result, (dict, bool))
# ---------------------------------------------------------------------------
# dom.wait_for
# ---------------------------------------------------------------------------
def test_dom_wait_for_existing_element(browser, http_tab):
"""wait_for an element that already exists returns quickly."""
browser("tabs.active", {"tabId": http_tab["id"]})
result = browser("dom.wait_for", {
"selector": "html",
"timeout": 5000,
"visible": False,
"hidden": False,
"tabId": http_tab["id"],
})
# Returns None or a dict on success
assert result is None or isinstance(result, dict)
def test_dom_wait_for_visible(browser, http_tab):
"""wait_for visible=True on a visible element succeeds."""
browser("tabs.active", {"tabId": http_tab["id"]})
result = browser("dom.wait_for", {
"selector": "body",
"timeout": 5000,
"visible": True,
"hidden": False,
"tabId": http_tab["id"],
})
assert result is None or isinstance(result, dict)
# ---------------------------------------------------------------------------
# dom.focus
# ---------------------------------------------------------------------------
def test_dom_focus_element(browser, http_tab):
"""Focusing an existing element does not raise."""
browser("tabs.active", {"tabId": http_tab["id"]})
result = browser("dom.focus", {"selector": "body"})
assert result is None or isinstance(result, (dict, bool))
# ---------------------------------------------------------------------------
# dom.hover
# ---------------------------------------------------------------------------
def test_dom_hover_element(browser, http_tab):
"""Hovering over an existing element does not raise."""
browser("tabs.active", {"tabId": http_tab["id"]})
result = browser("dom.hover", {"selector": "body"})
assert result is None or isinstance(result, (dict, bool))
# ---------------------------------------------------------------------------
# dom.type / dom.clear (use a data: URL tab with an <input>)
# ---------------------------------------------------------------------------
def test_dom_type_into_input(browser, http_tab):
"""Type text into an injected input field and read it back via eval."""
browser("tabs.active", {"tabId": http_tab["id"]})
# Inject a fresh input element with a unique id
input_id = "__pytest_type_input"
browser("dom.eval", {
"code": f"(function(){{ var e=document.getElementById('{input_id}'); if(!e){{e=document.createElement('input');e.id='{input_id}';e.type='text';document.body.appendChild(e);}} return true; }})()",
"tabId": http_tab["id"],
})
browser("dom.type", {"selector": f"#{input_id}", "text": "hello"})
value = browser("dom.eval", {"code": f"document.getElementById('{input_id}').value", "tabId": http_tab["id"]})
assert value == "hello"
def test_dom_clear_input(browser, http_tab):
"""Clear an input field sets its value to empty string."""
browser("tabs.active", {"tabId": http_tab["id"]})
input_id = "__pytest_clear_input"
browser("dom.eval", {
"code": f"(function(){{ var e=document.getElementById('{input_id}'); if(!e){{e=document.createElement('input');e.id='{input_id}';e.type='text';document.body.appendChild(e);}} e.value='prefilled'; return true; }})()",
"tabId": http_tab["id"],
})
browser("dom.clear", {"selector": f"#{input_id}"})
value = browser("dom.eval", {"code": f"document.getElementById('{input_id}').value", "tabId": http_tab["id"]})
assert value == ""
# ---------------------------------------------------------------------------
# dom.key
# ---------------------------------------------------------------------------
def test_dom_key_event_does_not_raise(browser, http_tab):
"""Sending a key event to the body does not raise."""
browser("tabs.active", {"tabId": http_tab["id"]})
result = browser("dom.key", {"key": "Tab"})
assert result is None or isinstance(result, (dict, bool))
def test_dom_key_with_selector(browser, http_tab):
"""Sending a key event to a specific selector does not raise."""
browser("tabs.active", {"tabId": http_tab["id"]})
result = browser("dom.key", {"key": "Escape", "selector": "body"})
assert result is None or isinstance(result, (dict, bool))
# ---------------------------------------------------------------------------
# dom.select (requires a <select> element)
# ---------------------------------------------------------------------------
def test_dom_select_dropdown(browser, http_tab):
"""Setting a <select> value changes it and it can be read back."""
browser("tabs.active", {"tabId": http_tab["id"]})
sel_id = "__pytest_select"
browser("dom.eval", {
"code": (
f"(function(){{"
f" var s=document.getElementById('{sel_id}');"
f" if(!s){{"
f" s=document.createElement('select');s.id='{sel_id}';"
f" ['a','b','c'].forEach(function(v){{var o=document.createElement('option');o.value=v;o.text=v;s.appendChild(o);}});"
f" document.body.appendChild(s);"
f" }} return true;"
f"}})()"
),
"tabId": http_tab["id"],
})
browser("dom.select", {"selector": f"#{sel_id}", "value": "b"})
value = browser("dom.eval", {"code": f"document.getElementById('{sel_id}').value", "tabId": http_tab["id"]})
assert value == "b"
# ---------------------------------------------------------------------------
# dom.check / dom.uncheck
# ---------------------------------------------------------------------------
def test_dom_check_and_uncheck(browser, http_tab):
"""Checking and unchecking a checkbox toggles its checked state."""
browser("tabs.active", {"tabId": http_tab["id"]})
cb_id = "__pytest_checkbox"
browser("dom.eval", {
"code": (
f"(function(){{"
f" var c=document.getElementById('{cb_id}');"
f" if(!c){{"
f" c=document.createElement('input');c.id='{cb_id}';c.type='checkbox';"
f" document.body.appendChild(c);"
f" }} c.checked=false; return true;"
f"}})()"
),
"tabId": http_tab["id"],
})
browser("dom.check", {"selector": f"#{cb_id}"})
checked = browser("dom.eval", {"code": f"document.getElementById('{cb_id}').checked", "tabId": http_tab["id"]})
assert checked is True
browser("dom.uncheck", {"selector": f"#{cb_id}"})
checked = browser("dom.eval", {"code": f"document.getElementById('{cb_id}').checked", "tabId": http_tab["id"]})
assert checked is False
+103
View File
@@ -35,3 +35,106 @@ def test_navigation_and_tabs_report_browser_error_pages():
assert "last URL:" in tabs assert "last URL:" in tabs
assert "isBrowserErrorUrl" in navigation assert "isBrowserErrorUrl" in navigation
assert "showing an error page while waiting for load" in navigation assert "showing an error page while waiting for load" in navigation
def test_large_extension_operations_yield_between_batches():
core = (ROOT / "extension" / "src" / "core.ts").read_text()
tabs = (ROOT / "extension" / "src" / "commands" / "tabs.ts").read_text()
groups = (ROOT / "extension" / "src" / "commands" / "groups.ts").read_text()
session = (ROOT / "extension" / "src" / "commands" / "session.ts").read_text()
assert "yieldForLargeOperation" in core
assert "getLargeOperationThrottle" in core
assert "hasAudibleTabs" in core
assert "runLargeOperation" in core
assert "largeOperationQueue" in core
assert "updateJobProgress" in core
assert "throwIfJobCancelled" in core
assert "getPerformanceProfile" in core
assert "setPerformanceProfile" in core
assert "GENTLE_OPERATION_BATCH_SIZE" in core
assert "GENTLE_OPERATION_PAUSE_MS" in core
assert "itemCount >= 300" in core
assert "itemCount >= 100" in core
assert "chrome.tabs.query({ audible: true })" in core
assert "yieldForLargeOperation" in tabs
assert "toClose.slice" in tabs
assert "ids.slice" in tabs
assert "w.tabs.every" in tabs
assert "getLargeOperationThrottle" in tabs
assert "runLargeOperation(\"tabs.sort\"" in tabs
assert "yieldForLargeOperation" in groups
assert "tabIds.slice" in groups
assert "getLargeOperationThrottle" in groups
assert "runLargeOperation(\"group.close\"" in groups
assert "yieldForLargeOperation(createdTabs.length" in session
assert "getLargeOperationThrottle" in session
assert "runLargeOperation(\"session.load\"" in session
assert "chrome.tabs.discard" in session
assert "lazyPlaceholderUrl" in session
assert "activateLazyTab" in session
assert "lazySessionTabs" in session
assert "throwIfJobCancelled" in session
assert "updateJobProgress" in session
index = (ROOT / "extension" / "src" / "index.ts").read_text()
assert "BACKGROUND_COMMANDS" in index
assert "startBackgroundJob" in index
assert "persistJobs" in index
assert "recentJobs" in index
assert "jobs.status" in index
assert "jobs.cancel" in index
assert "perf.status" in index
assert "perf.set_profile" in index
assert "__background" in index
def test_session_autosave_is_debounced_and_non_overlapping():
session = (ROOT / "extension" / "src" / "commands" / "session.ts").read_text()
assert "autoSaveTimer" in session
assert "autoSaveInFlight" in session
assert "autoSavePending" in session
assert "scheduleAutoSave" in session
assert "autoSaveUpdatedHandler" in session
assert "saveAutoSessionIfChanged" in session
assert "sessionSignature" in session
assert "autoSaveSignature" in session
assert "chrome.tabs.onUpdated.addListener(autoSaveUpdatedHandler)" in session
assert "chrome.tabs.onCreated.addListener(autoSaveHandler)" in session
assert "chrome.tabs.onMoved.addListener(autoSaveHandler)" in session
assert "if (!(\"url\" in changeInfo)) return;" in session
assert "setTimeout(runAutoSave, delayMs)" in session
assert "clearTimeout(autoSaveTimer)" in session
def test_cli_and_sdk_expose_gentle_restore_controls():
session_cli = (ROOT / "browser_cli" / "commands" / "session.py").read_text()
tabs_cli = (ROOT / "browser_cli" / "commands" / "tabs.py").read_text()
groups_cli = (ROOT / "browser_cli" / "commands" / "groups.py").read_text()
sdk = (ROOT / "browser_cli" / "__init__.py").read_text()
assert "--gentle-mode" in session_cli
assert "--discard-background-tabs" in session_cli
assert "--background" in session_cli
assert "--lazy" in session_cli
assert "--eager-tabs" in session_cli
assert "job-status" in session_cli
assert "job-cancel" in session_cli
assert "discardBackgroundTabs" in session_cli
assert "--gentle-mode" in tabs_cli
assert "gentleMode" in tabs_cli
assert "--gentle-mode" in groups_cli
assert "discard_background_tabs" in sdk
assert "discardBackgroundTabs" in sdk
assert "session_load_background" in sdk
assert "job_status" in sdk
assert "job_cancel" in sdk
assert "perf_status" in sdk
assert "set_performance_profile" in sdk
perf_cli = (ROOT / "browser_cli" / "commands" / "perf.py").read_text()
root_cli = (ROOT / "browser_cli" / "cli.py").read_text()
assert "perf_group" in perf_cli
assert "perf.status" in perf_cli
assert "perf.set_profile" in perf_cli
assert "main.add_command(perf_group)" in root_cli
+221 -9
View File
@@ -6,11 +6,9 @@ import pytest
import browser_cli.native_host as native_host import browser_cli.native_host as native_host
def _raise_system_exit(code: int): def _raise_system_exit(code: int):
raise SystemExit(code) raise SystemExit(code)
def test_cleanup_removes_socket_and_registry_entry(monkeypatch, tmp_path): def test_cleanup_removes_socket_and_registry_entry(monkeypatch, tmp_path):
alias = "work" alias = "work"
socket_path = tmp_path / "work.sock" socket_path = tmp_path / "work.sock"
@@ -27,7 +25,6 @@ def test_cleanup_removes_socket_and_registry_entry(monkeypatch, tmp_path):
assert not socket_path.exists() assert not socket_path.exists()
assert json.loads(registry_path.read_text()) == {"other": str(tmp_path / "other.sock")} assert json.loads(registry_path.read_text()) == {"other": str(tmp_path / "other.sock")}
def test_stdin_reader_cleans_up_on_eof(monkeypatch): def test_stdin_reader_cleans_up_on_eof(monkeypatch):
cleaned = [] cleaned = []
@@ -41,7 +38,6 @@ def test_stdin_reader_cleans_up_on_eof(monkeypatch):
assert cleaned == ["work"] assert cleaned == ["work"]
def test_cleanup_windows_skips_socket_unlink(monkeypatch, tmp_path): def test_cleanup_windows_skips_socket_unlink(monkeypatch, tmp_path):
registry_path = tmp_path / "registry.json" registry_path = tmp_path / "registry.json"
registry_path.write_text(json.dumps({"work": r"\\.\pipe\browser-cli-work"})) registry_path.write_text(json.dumps({"work": r"\\.\pipe\browser-cli-work"}))
@@ -53,7 +49,6 @@ def test_cleanup_windows_skips_socket_unlink(monkeypatch, tmp_path):
assert json.loads(registry_path.read_text()) == {} assert json.loads(registry_path.read_text()) == {}
def test_stdin_reader_cleans_up_on_bye(monkeypatch): def test_stdin_reader_cleans_up_on_bye(monkeypatch):
cleaned = [] cleaned = []
messages = iter([{"type": "bye"}]) messages = iter([{"type": "bye"}])
@@ -68,7 +63,6 @@ def test_stdin_reader_cleans_up_on_bye(monkeypatch):
assert cleaned == ["work"] assert cleaned == ["work"]
def test_stdin_reader_routes_response_messages(monkeypatch): def test_stdin_reader_routes_response_messages(monkeypatch):
response_queue = native_host.queue.Queue() response_queue = native_host.queue.Queue()
native_host.PENDING["msg-1"] = response_queue native_host.PENDING["msg-1"] = response_queue
@@ -85,7 +79,6 @@ def test_stdin_reader_routes_response_messages(monkeypatch):
assert response_queue.get_nowait() == {"id": "msg-1", "success": True} assert response_queue.get_nowait() == {"id": "msg-1", "success": True}
native_host.PENDING.clear() native_host.PENDING.clear()
def test_collect_paged_browser_command_accumulates_pages(monkeypatch): def test_collect_paged_browser_command_accumulates_pages(monkeypatch):
calls = [] calls = []
pages = iter([ pages = iter([
@@ -110,7 +103,6 @@ def test_collect_paged_browser_command_accumulates_pages(monkeypatch):
assert all(call["args"]["foo"] == "bar" for call in calls) assert all(call["args"]["foo"] == "bar" for call in calls)
assert all(call["id"] != "orig" for call in calls) assert all(call["id"] != "orig" for call in calls)
def test_collect_paged_browser_command_passes_through_non_paged_response(monkeypatch): def test_collect_paged_browser_command_passes_through_non_paged_response(monkeypatch):
monkeypatch.setattr(native_host, "_send_browser_command", lambda cmd: {"id": cmd["id"], "success": True, "data": {"value": 1}}) monkeypatch.setattr(native_host, "_send_browser_command", lambda cmd: {"id": cmd["id"], "success": True, "data": {"value": 1}})
@@ -118,7 +110,6 @@ def test_collect_paged_browser_command_passes_through_non_paged_response(monkeyp
assert result == {"id": "orig", "success": True, "data": {"value": 1}} assert result == {"id": "orig", "success": True, "data": {"value": 1}}
def test_handle_browser_command_pages_known_list_commands(monkeypatch): def test_handle_browser_command_pages_known_list_commands(monkeypatch):
seen = [] seen = []
@@ -128,3 +119,224 @@ def test_handle_browser_command_pages_known_list_commands(monkeypatch):
assert result == {"success": True, "data": []} assert result == {"success": True, "data": []}
assert seen[0]["command"] == "tabs.list" assert seen[0]["command"] == "tabs.list"
def test_handle_browser_command_sends_non_pageable_directly(monkeypatch):
seen = []
monkeypatch.setattr(native_host, "_send_browser_command", lambda cmd: seen.append(cmd) or {"success": True, "data": "ok"})
result = native_host._handle_browser_command({"id": "x", "command": "navigate.open", "args": {}})
assert result == {"success": True, "data": "ok"}
assert seen[0]["command"] == "navigate.open"
# ---------------------------------------------------------------------------
# _read_exact_stream
# ---------------------------------------------------------------------------
def test_read_exact_stream_full_read():
"""Returns the exact bytes when stream delivers them in one shot."""
import io
stream = io.BytesIO(b"hello")
assert native_host._read_exact_stream(stream, 5) == b"hello"
def test_read_exact_stream_partial_chunks():
"""Accumulates multiple short chunks until n bytes are read."""
import io
class _ChunkyStream:
def __init__(self, data, chunk_size):
self._data = data
self._pos = 0
self._chunk_size = chunk_size
def read(self, n):
end = min(self._pos + self._chunk_size, len(self._data))
chunk = self._data[self._pos:end]
self._pos = end
return chunk
stream = _ChunkyStream(b"abcdefgh", 3)
assert native_host._read_exact_stream(stream, 8) == b"abcdefgh"
def test_read_exact_stream_eof_returns_none():
"""Returns None if stream is exhausted before n bytes are delivered."""
import io
stream = io.BytesIO(b"ab") # only 2 bytes, asking for 4
assert native_host._read_exact_stream(stream, 4) is None
def test_read_exact_stream_immediate_eof():
"""Returns None on an empty stream."""
import io
stream = io.BytesIO(b"")
assert native_host._read_exact_stream(stream, 1) is None
# ---------------------------------------------------------------------------
# write_native_message / read_native_message round-trip
# ---------------------------------------------------------------------------
def test_write_and_read_native_message_roundtrip():
"""write_native_message followed by read_native_message recovers the original dict."""
import io
buf = io.BytesIO()
msg = {"id": "abc", "command": "tabs.list", "args": {}}
native_host.write_native_message(buf, msg)
buf.seek(0)
result = native_host.read_native_message(buf)
assert result == msg
def test_read_native_message_eof_at_length_prefix():
"""Returns None when the stream is empty (no length prefix)."""
import io
stream = io.BytesIO(b"")
assert native_host.read_native_message(stream) is None
def test_read_native_message_eof_at_body():
"""Returns None when the body is truncated after reading the length prefix."""
import io
import struct
# Write a 10-byte length prefix but only 5 bytes of body
buf = struct.pack("<I", 10) + b"hello"
stream = io.BytesIO(buf)
assert native_host.read_native_message(stream) is None
# ---------------------------------------------------------------------------
# _recv_exact / _recv_all / _send_all
# ---------------------------------------------------------------------------
def test_recv_exact_accumulates_data():
"""_recv_exact receives exactly n bytes from a socket-like object."""
class _FakeSock:
def __init__(self, data):
self._data = data
self._pos = 0
def recv(self, n):
chunk = self._data[self._pos:self._pos + n]
self._pos += len(chunk)
return chunk
sock = _FakeSock(b"0123456789")
assert native_host._recv_exact(sock, 5) == b"01234"
assert native_host._recv_exact(sock, 5) == b"56789"
def test_recv_exact_eof_returns_none():
class _EmptySock:
def recv(self, n):
return b""
assert native_host._recv_exact(_EmptySock(), 4) is None
def test_send_all_and_recv_all():
"""_send_all frames data with length prefix; _recv_all strips it."""
import socket
a, b = socket.socketpair()
try:
payload = b'{"command": "tabs.list"}'
native_host._send_all(a, payload)
received = native_host._recv_all(b)
assert received == payload
finally:
a.close()
b.close()
def test_recv_all_truncated_body():
"""_recv_all returns None when the body is shorter than the prefix promises."""
import socket
import struct
a, b = socket.socketpair()
try:
# Send a length of 100 but only 4 bytes of body
a.sendall(struct.pack("<I", 100) + b"tiny")
a.close()
result = native_host._recv_all(b)
assert result is None
finally:
b.close()
# ---------------------------------------------------------------------------
# _send_browser_command — timeout path
# ---------------------------------------------------------------------------
def test_send_browser_command_timeout(monkeypatch):
"""_send_browser_command returns an error dict when the response queue times out."""
import io
buf = io.BytesIO()
monkeypatch.setattr(native_host.sys, "stdout", SimpleNamespace(buffer=buf))
# Do not put anything into the response queue → timeout after 0 s
result = native_host._send_browser_command({"id": "t1", "command": "test", "args": {}}, timeout=0)
assert result["success"] is False
assert "timeout" in result["error"]
# Clean up PENDING
native_host.PENDING.clear()
# ---------------------------------------------------------------------------
# _collect_paged_browser_command — error and loop-guard paths
# ---------------------------------------------------------------------------
def test_collect_paged_browser_command_propagates_error(monkeypatch):
"""If _send_browser_command returns success=False the error is propagated."""
monkeypatch.setattr(
native_host, "_send_browser_command",
lambda cmd: {"id": cmd["id"], "success": False, "error": "extension crash"},
)
result = native_host._collect_paged_browser_command({"id": "e1", "command": "tabs.list", "args": {}})
assert result["success"] is False
assert "extension crash" in result["error"]
def test_collect_paged_browser_command_max_pages_guard(monkeypatch):
"""If paging never ends, the loop guard kicks in and returns an error."""
monkeypatch.setattr(native_host, "PAGE_SIZE", 1)
call_count = [0]
def _infinite_pages(cmd):
call_count[0] += 1
return {
"id": cmd["id"],
"success": True,
"data": {"__browserCliPage": True, "items": [call_count[0]], "total": 9999, "nextOffset": call_count[0]},
}
monkeypatch.setattr(native_host, "_send_browser_command", _infinite_pages)
result = native_host._collect_paged_browser_command({"id": "loop", "command": "tabs.list", "args": {}})
assert result["success"] is False
assert "paging loop exceeded" in result["error"]
def test_collect_paged_browser_command_invalid_items(monkeypatch):
"""If items is not a list the command returns an error dict."""
monkeypatch.setattr(
native_host, "_send_browser_command",
lambda cmd: {
"id": cmd["id"],
"success": True,
"data": {"__browserCliPage": True, "items": "not-a-list", "total": 1, "nextOffset": None},
},
)
result = native_host._collect_paged_browser_command({"id": "bad", "command": "tabs.list", "args": {}})
assert result["success"] is False
assert "invalid paged response" in result["error"]
# ---------------------------------------------------------------------------
# _resolve_profile_alias
# ---------------------------------------------------------------------------
def test_resolve_profile_alias_uses_hello_alias():
alias = native_host._resolve_profile_alias({"type": "hello", "alias": "brave-work"})
assert alias == "brave-work"
def test_resolve_profile_alias_no_hello_returns_uuid():
alias = native_host._resolve_profile_alias(None)
import uuid
uuid.UUID(alias) # raises ValueError if not a valid UUID
def test_resolve_profile_alias_default_alias_returns_uuid():
from browser_cli.platform import DEFAULT_ALIAS
alias = native_host._resolve_profile_alias({"type": "hello", "alias": DEFAULT_ALIAS})
import uuid
uuid.UUID(alias)
def test_resolve_profile_alias_non_hello_type_returns_uuid():
alias = native_host._resolve_profile_alias({"type": "bye", "alias": "some"})
import uuid
uuid.UUID(alias)
+39
View File
@@ -0,0 +1,39 @@
"""Integration tests for page.info command — require a live browser."""
def test_page_info_returns_required_fields(browser, http_tab):
"""page.info returns title, url, readyState and lang."""
browser("tabs.active", {"tabId": http_tab["id"]})
info = browser("page.info")
assert isinstance(info, dict)
assert "title" in info
assert "url" in info
assert "readyState" in info
assert "lang" in info
def test_page_info_url_matches_active_tab(browser, http_tab):
"""URL reported by page.info matches the tab URL in tabs.list."""
browser("tabs.active", {"tabId": http_tab["id"]})
info = browser("page.info")
assert info is not None
# Active tab URL should match (allow for trailing slash difference)
assert "example.com" in info.get("url", "")
def test_page_info_ready_state_complete(browser, http_tab):
"""A fully loaded page reports readyState == 'complete'."""
browser("tabs.active", {"tabId": http_tab["id"]})
info = browser("page.info")
assert info.get("readyState") == "complete"
def test_page_info_title_non_empty(browser, http_tab):
"""example.com has a non-empty title."""
browser("tabs.active", {"tabId": http_tab["id"]})
info = browser("page.info")
assert isinstance(info.get("title"), str)
assert len(info["title"]) > 0
def test_page_info_meta_is_dict(browser, http_tab):
"""meta field is a dict (may be empty for simple pages)."""
browser("tabs.active", {"tabId": http_tab["id"]})
info = browser("page.info")
meta = info.get("meta")
assert meta is None or isinstance(meta, dict)
+72
View File
@@ -0,0 +1,72 @@
"""Integration tests for perf.* commands — require a live browser."""
import time
_VALID_PROFILES = {"auto", "normal", "gentle", "ultra"}
def test_perf_status_returns_profile_field(browser):
"""perf.status returns a dict with a performanceProfile field."""
result = browser("perf.status")
assert isinstance(result, dict)
assert "performanceProfile" in result
assert result["performanceProfile"] in _VALID_PROFILES
def test_perf_status_has_throttle(browser):
"""perf.status includes a throttle dict with batchSize and pauseMs."""
result = browser("perf.status")
throttle = result.get("throttle")
assert isinstance(throttle, dict)
assert "batchSize" in throttle
assert "pauseMs" in throttle
assert isinstance(throttle["batchSize"], int)
assert isinstance(throttle["pauseMs"], int)
assert throttle["batchSize"] > 0
assert throttle["pauseMs"] >= 0
def test_perf_status_has_audible_field(browser):
"""perf.status contains an audible boolean."""
result = browser("perf.status")
assert "audible" in result
assert isinstance(result["audible"], bool)
def test_perf_status_has_jobs_field(browser):
"""perf.status contains a jobs list (may be empty)."""
result = browser("perf.status")
assert "jobs" in result
assert isinstance(result["jobs"], list)
def test_perf_set_profile_returns_profile(browser):
"""perf.set_profile responds with the applied profile."""
# Save current
current = browser("perf.status").get("performanceProfile", "auto")
target = "normal" if current != "normal" else "gentle"
try:
result = browser("perf.set_profile", {"profile": target})
assert isinstance(result, dict)
assert result.get("performanceProfile") == target
finally:
browser("perf.set_profile", {"profile": current})
def test_perf_set_profile_updates_status(browser):
"""After set_profile the status command reflects the new profile."""
current = browser("perf.status").get("performanceProfile", "auto")
target = "gentle" if current != "gentle" else "normal"
try:
browser("perf.set_profile", {"profile": target})
status = browser("perf.status")
assert status.get("performanceProfile") == target
finally:
browser("perf.set_profile", {"profile": current})
def test_perf_profile_roundtrip_all_values(browser):
"""Every valid profile can be set and read back without error."""
original = browser("perf.status").get("performanceProfile", "auto")
try:
for profile in sorted(_VALID_PROFILES):
result = browser("perf.set_profile", {"profile": profile})
assert result.get("performanceProfile") == profile
status = browser("perf.status")
assert status.get("performanceProfile") == profile
finally:
browser("perf.set_profile", {"profile": original})
+157
View File
@@ -0,0 +1,157 @@
"""Integration tests for browser performance controls and background jobs."""
import time
import uuid
import pytest
def _wait_for_job(browser, job_id, timeout=10):
deadline = time.time() + timeout
last = None
while time.time() < deadline:
last = browser("jobs.status", {"jobId": job_id})
if last.get("status") in {"done", "error", "cancelled"}:
return last
time.sleep(0.1)
return last or {}
def _close_tabs(browser, tab_ids):
for tab_id in tab_ids:
try:
browser("tabs.close", {"tabId": tab_id})
except Exception:
pass
def _require_perf_features(browser):
try:
return browser("perf.status", {})
except RuntimeError as exc:
if "Unknown command: perf.status" in str(exc):
pytest.skip("Running browser has not reloaded the v0.10.0 extension background worker yet")
raise
def test_perf_status_and_profile_roundtrip_real_browser(browser):
initial = _require_perf_features(browser)
assert "performanceProfile" in initial
assert "audible" in initial
assert "throttle" in initial
assert "jobs" in initial
original = initial.get("performanceProfile", "auto")
try:
changed = browser("perf.set_profile", {"profile": "gentle"})
assert changed["performanceProfile"] == "gentle"
status = browser("perf.status", {})
assert status["performanceProfile"] == "gentle"
assert status["throttle"]["mode"] == "gentle"
finally:
browser("perf.set_profile", {"profile": original})
def test_background_session_load_job_reports_progress_real_browser(browser):
_require_perf_features(browser)
name = f"_pytest_perf_job_{uuid.uuid4().hex}"
marker_url = f"https://example.com/?browser-cli-job={uuid.uuid4().hex}"
marker_tab = browser("navigate.open", {"url": marker_url, "background": True})
loaded_ids = set()
try:
browser("session.save", {"name": name})
baseline_ids = {tab["id"] for tab in browser("tabs.list")}
started = browser("session.load", {
"name": name,
"__background": True,
"lazy": True,
"eagerTabs": 0,
"gentleMode": "ultra",
})
assert started["status"] == "running"
assert started["jobId"]
status = _wait_for_job(browser, started["jobId"])
assert status["status"] == "done"
assert status["command"] == "session.load"
assert status["percent"] == 100
assert status["phase"] == "done"
assert status["total"] is None or status["total"] >= 0
assert status.get("result", {}).get("lazy") is True
loaded_ids = {tab["id"] for tab in browser("tabs.list")} - baseline_ids
assert loaded_ids, "Expected lazy session load to create tabs"
finally:
_close_tabs(browser, loaded_ids)
_close_tabs(browser, [marker_tab["id"]])
try:
browser("session.remove", {"name": name})
except Exception:
pass
def test_lazy_session_load_creates_lightweight_placeholders_real_browser(browser):
_require_perf_features(browser)
name = f"_pytest_lazy_{uuid.uuid4().hex}"
marker_url = f"https://example.com/?browser-cli-lazy={uuid.uuid4().hex}"
marker_tab = browser("navigate.open", {"url": marker_url, "background": True})
loaded_ids = set()
try:
browser("session.save", {"name": name})
baseline_ids = {tab["id"] for tab in browser("tabs.list")}
result = browser("session.load", {"name": name, "lazy": True, "eagerTabs": 0, "gentleMode": "ultra"})
assert result["lazy"] is True
assert result["eagerTabs"] == 0
tabs_after = browser("tabs.list")
loaded_tabs = [tab for tab in tabs_after if tab["id"] not in baseline_ids]
loaded_ids = {tab["id"] for tab in loaded_tabs}
assert loaded_tabs
assert any((tab.get("url") or "").startswith("data:text/html") for tab in loaded_tabs)
finally:
_close_tabs(browser, loaded_ids)
_close_tabs(browser, [marker_tab["id"]])
try:
browser("session.remove", {"name": name})
except Exception:
pass
def test_session_load_restores_pinned_tabs_real_browser(browser):
_require_perf_features(browser)
name = f"_pytest_pinned_{uuid.uuid4().hex}"
marker_url = f"https://example.com/?browser-cli-pinned={uuid.uuid4().hex}"
marker_tab = browser("navigate.open", {"url": marker_url, "background": True})
loaded_ids = set()
try:
browser("tabs.pin", {"tabId": marker_tab["id"]})
browser("session.save", {"name": name})
baseline_ids = {tab["id"] for tab in browser("tabs.list")}
result = browser("session.load", {"name": name, "gentleMode": "ultra", "discardBackgroundTabs": True})
assert result["tabs"] >= 1
loaded_tabs = [tab for tab in browser("tabs.list") if tab["id"] not in baseline_ids]
loaded_ids = {tab["id"] for tab in loaded_tabs}
matching = [tab for tab in loaded_tabs if tab.get("url") == marker_url]
assert matching, "Expected session load to restore marker tab"
assert matching[0].get("pinned") is True
finally:
_close_tabs(browser, loaded_ids)
_close_tabs(browser, [marker_tab["id"]])
try:
browser("session.remove", {"name": name})
except Exception:
pass
def test_job_cancel_command_real_browser(browser):
_require_perf_features(browser)
started = browser("tabs.sort", {"by": "domain", "__background": True, "gentleMode": "ultra"})
job_id = started["jobId"]
try:
cancelled = browser("jobs.cancel", {"jobId": job_id})
assert cancelled["cancelled"] is True
except RuntimeError:
# Tiny real-browser sorts can finish before the cancel request arrives.
status = browser("jobs.status", {"jobId": job_id})
assert status["status"] in {"done", "cancelled"}
return
status = _wait_for_job(browser, job_id)
assert status["status"] in {"cancelled", "done"}
+54
View File
@@ -0,0 +1,54 @@
"""Integration tests for storage.get / storage.set — require a live browser."""
import time
_KEY_PREFIX = "__pytest_storage_"
def test_storage_set_and_get_roundtrip(browser, http_tab):
"""Set a localStorage key then read it back."""
browser("tabs.active", {"tabId": http_tab["id"]})
key = _KEY_PREFIX + "roundtrip"
value = "hello-storage"
browser("storage.set", {"key": key, "value": value, "type": "local", "tabId": http_tab["id"]})
result = browser("storage.get", {"key": key, "type": "local", "tabId": http_tab["id"]})
assert result == value
def test_storage_get_nonexistent_returns_null(browser, http_tab):
"""Getting a key that was never set returns None."""
browser("tabs.active", {"tabId": http_tab["id"]})
result = browser("storage.get", {
"key": _KEY_PREFIX + "no_such_key_zzz",
"type": "local",
"tabId": http_tab["id"],
})
assert result is None
def test_storage_get_all_returns_dict(browser, http_tab):
"""Calling storage.get without a key dumps all entries as a dict."""
browser("tabs.active", {"tabId": http_tab["id"]})
# Plant at least one key so the dump isn't trivially empty
browser("storage.set", {"key": _KEY_PREFIX + "dump", "value": "present", "type": "local", "tabId": http_tab["id"]})
result = browser("storage.get", {"key": None, "type": "local", "tabId": http_tab["id"]})
assert isinstance(result, dict)
assert _KEY_PREFIX + "dump" in result
def test_storage_session_type_roundtrip(browser, http_tab):
"""sessionStorage works the same way as localStorage."""
browser("tabs.active", {"tabId": http_tab["id"]})
key = _KEY_PREFIX + "session"
value = "ses-val"
browser("storage.set", {"key": key, "value": value, "type": "session", "tabId": http_tab["id"]})
result = browser("storage.get", {"key": key, "type": "session", "tabId": http_tab["id"]})
assert result == value
def test_storage_overwrite_updates_value(browser, http_tab):
"""Setting the same key twice reflects the latest value."""
browser("tabs.active", {"tabId": http_tab["id"]})
key = _KEY_PREFIX + "overwrite"
browser("storage.set", {"key": key, "value": "v1", "type": "local", "tabId": http_tab["id"]})
browser("storage.set", {"key": key, "value": "v2", "type": "local", "tabId": http_tab["id"]})
result = browser("storage.get", {"key": key, "type": "local", "tabId": http_tab["id"]})
assert result == "v2"
+1 -16
View File
@@ -1,7 +1,6 @@
"""Tests for tabs.* commands.""" """Tests for tabs.* commands."""
import pytest import pytest
def test_tabs_list(browser): def test_tabs_list(browser):
tabs = browser("tabs.list") tabs = browser("tabs.list")
assert isinstance(tabs, list) assert isinstance(tabs, list)
@@ -12,59 +11,51 @@ def test_tabs_list(browser):
assert "url" in first assert "url" in first
assert "title" in first assert "title" in first
assert "muted" in first assert "muted" in first
assert "groupId" in first
def test_tabs_count(browser): def test_tabs_count(browser):
count = browser("tabs.count", {}) count = browser("tabs.count", {})
tabs = browser("tabs.list") tabs = browser("tabs.list")
assert count == len(tabs) assert count == len(tabs)
def test_tabs_count_with_pattern(browser): def test_tabs_count_with_pattern(browser):
count = browser("tabs.count", {"pattern": "http"}) count = browser("tabs.count", {"pattern": "http"})
assert isinstance(count, int) assert isinstance(count, int)
assert count >= 0 assert count >= 0
def test_tabs_filter(browser): def test_tabs_filter(browser):
result = browser("tabs.filter", {"pattern": "http"}) result = browser("tabs.filter", {"pattern": "http"})
assert isinstance(result, list) assert isinstance(result, list)
for tab in result: for tab in result:
assert "http" in tab.get("url", "") assert "http" in tab.get("url", "")
def test_tabs_query(browser): def test_tabs_query(browser):
result = browser("tabs.query", {"search": "a"}) result = browser("tabs.query", {"search": "a"})
assert isinstance(result, list) assert isinstance(result, list)
def test_tabs_active_exists(browser): def test_tabs_active_exists(browser):
tabs = browser("tabs.list") tabs = browser("tabs.list")
active = [t for t in tabs if t.get("active")] active = [t for t in tabs if t.get("active")]
assert len(active) >= 1, "Expected at least one active tab" assert len(active) >= 1, "Expected at least one active tab"
def test_tabs_active_in_window(browser): def test_tabs_active_in_window(browser):
active = next(t for t in browser("tabs.list") if t.get("active")) active = next(t for t in browser("tabs.list") if t.get("active"))
result = browser("tabs.active_in_window", {"windowId": active["windowId"]}) result = browser("tabs.active_in_window", {"windowId": active["windowId"]})
assert result["id"] == active["id"] assert result["id"] == active["id"]
assert result["windowId"] == active["windowId"] assert result["windowId"] == active["windowId"]
def test_tabs_status(browser): def test_tabs_status(browser):
result = browser("tabs.status", {}) result = browser("tabs.status", {})
assert isinstance(result, dict) assert isinstance(result, dict)
assert "id" in result assert "id" in result
assert "muted" in result assert "muted" in result
def test_tabs_html(browser, http_tab): def test_tabs_html(browser, http_tab):
html = browser("tabs.html", {"tabId": http_tab["id"]}) html = browser("tabs.html", {"tabId": http_tab["id"]})
assert isinstance(html, str) assert isinstance(html, str)
assert len(html) > 0 assert len(html) > 0
assert "<html" in html.lower() or "<!doctype" in html.lower() assert "<html" in html.lower() or "<!doctype" in html.lower()
def test_tabs_close_by_id(browser): def test_tabs_close_by_id(browser):
result = browser("navigate.open", {"url": "https://example.com", "background": True}) result = browser("navigate.open", {"url": "https://example.com", "background": True})
tab_id = result["id"] tab_id = result["id"]
@@ -74,7 +65,6 @@ def test_tabs_close_by_id(browser):
tabs = browser("tabs.list") tabs = browser("tabs.list")
assert tab_id not in [t["id"] for t in tabs] assert tab_id not in [t["id"] for t in tabs]
def test_tabs_dedupe(browser): def test_tabs_dedupe(browser):
# Open the same URL twice # Open the same URL twice
r1 = browser("navigate.open", {"url": "https://example.com", "background": True}) r1 = browser("navigate.open", {"url": "https://example.com", "background": True})
@@ -97,13 +87,11 @@ def test_tabs_dedupe(browser):
except Exception: except Exception:
pass pass
def test_tabs_sort(browser): def test_tabs_sort(browser):
result = browser("tabs.sort", {"by": "domain"}) result = browser("tabs.sort", {"by": "domain"})
# No error and at least returns something (None or dict) # No error and at least returns something (None or dict)
assert result is None or isinstance(result, dict) assert result is None or isinstance(result, dict)
def test_tabs_move_forward(browser): def test_tabs_move_forward(browser):
r1 = browser("navigate.open", {"url": "https://example.com", "background": True}) r1 = browser("navigate.open", {"url": "https://example.com", "background": True})
r2 = browser("navigate.open", {"url": "https://example.com", "background": True}) r2 = browser("navigate.open", {"url": "https://example.com", "background": True})
@@ -116,13 +104,11 @@ def test_tabs_move_forward(browser):
browser("tabs.close", {"tabId": id1}) browser("tabs.close", {"tabId": id1})
browser("tabs.close", {"tabId": id2}) browser("tabs.close", {"tabId": id2})
def test_tabs_merge_windows_no_crash(browser): def test_tabs_merge_windows_no_crash(browser):
result = browser("tabs.merge_windows") result = browser("tabs.merge_windows")
assert isinstance(result, dict) assert isinstance(result, dict)
assert "moved" in result assert "moved" in result
def test_tabs_mute_and_unmute(browser, http_tab): def test_tabs_mute_and_unmute(browser, http_tab):
muted = browser("tabs.mute", {"tabId": http_tab["id"]}) muted = browser("tabs.mute", {"tabId": http_tab["id"]})
assert isinstance(muted, dict) assert isinstance(muted, dict)
@@ -142,7 +128,6 @@ def test_tabs_mute_and_unmute(browser, http_tab):
status = browser("tabs.status", {"tabId": http_tab["id"]}) status = browser("tabs.status", {"tabId": http_tab["id"]})
assert status["muted"] is False assert status["muted"] is False
def test_tabs_mute_requires_explicit_tab_when_multiple_tabs_open(browser): def test_tabs_mute_requires_explicit_tab_when_multiple_tabs_open(browser):
opened = browser("navigate.open", {"url": "https://example.com", "background": True}) opened = browser("navigate.open", {"url": "https://example.com", "background": True})
try: try:
Generated
+144 -8
View File
@@ -4,7 +4,7 @@ requires-python = ">=3.10"
[[package]] [[package]]
name = "browser-cli" name = "browser-cli"
version = "0.9.8" version = "0.10.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },
@@ -15,6 +15,7 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-cov" },
] ]
[package.metadata] [package.metadata]
@@ -25,7 +26,10 @@ requires-dist = [
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8" }] dev = [
{ name = "pytest", specifier = ">=8" },
{ name = "pytest-cov", specifier = ">=7.1.0" },
]
[[package]] [[package]]
name = "cffi" name = "cffi"
@@ -111,14 +115,14 @@ wheels = [
[[package]] [[package]]
name = "click" name = "click"
version = "8.3.3" version = "8.4.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" }, { name = "colorama", marker = "sys_platform == 'win32'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, { url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" },
] ]
[[package]] [[package]]
@@ -130,6 +134,124 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
] ]
[[package]]
name = "coverage"
version = "7.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/9d/7c83ef51c3eb495f10010094e661833588b7709946da634c8b66520b97c7/coverage-7.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:84c32d90bf4537f0e7b4dec9aaa9a938fb8205136b9d2ecf4d7629d5262dc075", size = 219668, upload-time = "2026-05-10T17:59:23.106Z" },
{ url = "https://files.pythonhosted.org/packages/24/34/898546aefbd28f0af131201d0dc852c9e976f817bd7d5bfb8dc4e02863bb/coverage-7.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7c843572c605ab51cfdb5c6b5f2586e2a8467c0d28eca4bdef4ec70c5fecbd82", size = 220192, upload-time = "2026-05-10T17:59:26.095Z" },
{ url = "https://files.pythonhosted.org/packages/df/4a/b457c88aca72b0df13a98167ebd5d947135ccd9881ea88ce6a570e13aa9b/coverage-7.14.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0c451757d3fa2603354fdc789b5e58a0e327a117c370a40e3476ba4eabab228c", size = 246932, upload-time = "2026-05-10T17:59:27.806Z" },
{ url = "https://files.pythonhosted.org/packages/b5/d9/92600e89486fd074c50f0117422b2c9592c3e144e2f25bd5ac0bc62bc7a0/coverage-7.14.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3fd43f0616e765ab78d069cf8358def7363957a45cee446d65c502dcfeea7893", size = 248762, upload-time = "2026-05-10T17:59:29.479Z" },
{ url = "https://files.pythonhosted.org/packages/0d/e1/9ea1eb9c311da7f15853559dc1d9d82bef88ecd3e59fbeb51f16bc2ffa91/coverage-7.14.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:731e535b1498b27d13594a0527a79b0510867b0ad891532be41cb883f2128e20", size = 250625, upload-time = "2026-05-10T17:59:31.33Z" },
{ url = "https://files.pythonhosted.org/packages/a5/03/57afca1b8106f8549a5329139315041fe166d6099bd9381346b9430dfbd1/coverage-7.14.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c7492f2d493b976941c7ca050f273cbda2f43c381124f7586a3e3c16d1804fec", size = 252539, upload-time = "2026-05-10T17:59:32.692Z" },
{ url = "https://files.pythonhosted.org/packages/57/5e/2e9fc63c9928119c1dbae02222be51407d3e7ebac5811ebbda4af3557795/coverage-7.14.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc38367eaa2abb1b766ac333142bce7655335a73537f5c8b75aaa89c2b987757", size = 247636, upload-time = "2026-05-10T17:59:34.599Z" },
{ url = "https://files.pythonhosted.org/packages/f0/e2/0b7898cda21041cc67546e19b80ba66cbbb47cbece52a76a5904de6a3aaf/coverage-7.14.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0a951308cde22cf77f953955a754d04dccb57fe3bb8e345d685778ed9fc1632a", size = 248666, upload-time = "2026-05-10T17:59:36.232Z" },
{ url = "https://files.pythonhosted.org/packages/d6/e3/d33662a2fdaef23229c15921f39c84ec38441f3069ba26e134ed402c833b/coverage-7.14.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fab3877e4ebb06bd9d4d4d00ee53309ee5478e66873c66a382272e3ee33eb7ea", size = 246670, upload-time = "2026-05-10T17:59:38.029Z" },
{ url = "https://files.pythonhosted.org/packages/99/b2/533942c3bfbf6770b5c32d7f2ff029fe013dba31f3fe8b45cabbb250365e/coverage-7.14.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b812eb847b19876ebf33fb6c4f11819af05ab6050b0bfa1bc53412ae81779adb", size = 250484, upload-time = "2026-05-10T17:59:39.974Z" },
{ url = "https://files.pythonhosted.org/packages/d8/00/15acbad83a96de13c73831486c7627bfed73dfaec53b04e4a6315edf3fd8/coverage-7.14.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d9c8ef6ed820c433de075657d72dda1f89a2984955e58b8a75feb3f184250218", size = 246942, upload-time = "2026-05-10T17:59:41.659Z" },
{ url = "https://files.pythonhosted.org/packages/70/db/cef0228de493f2c740c760a9057a61d00c6849480073b70a75b87c7d4bab/coverage-7.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d128b1bba9361fbaaf6a19e179e6cfd6a9103ce0c0555876f72780acc93efd85", size = 247544, upload-time = "2026-05-10T17:59:43.471Z" },
{ url = "https://files.pythonhosted.org/packages/77/a0/d9ef8e148f3025c2ae8401d77cda1502b6d2a4d8102603a8af31460aedb6/coverage-7.14.0-cp310-cp310-win32.whl", hash = "sha256:65f267ca1370726ec2c1aa38bbe4df9a71a740f22878d2d4bf59d71a4cd8d323", size = 222285, upload-time = "2026-05-10T17:59:44.908Z" },
{ url = "https://files.pythonhosted.org/packages/85/c0/30c454c7d3cf47b2805d4e06f12443f5eece8a5d030d3b0350e7b74ecb49/coverage-7.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:b34ece8065914f938ed7f2c5872bb865336977a52919149846eac3744327267a", size = 223215, upload-time = "2026-05-10T17:59:46.779Z" },
{ url = "https://files.pythonhosted.org/packages/fc/e4/649c8d4f7f1709b6dbfc474358aa1bba02f67bcd52e2fec291a5014006cd/coverage-7.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a78e2a9d9c5e3b8d4ab9b9d28c985ea66fced0a7d7c2aec1f216e03a2011480", size = 219795, upload-time = "2026-05-10T17:59:48.198Z" },
{ url = "https://files.pythonhosted.org/packages/7f/8d/46692d24b3f395d4cbf17bfcc57136b4f2f9c0c0df864b0bddfc1d71a014/coverage-7.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1816c505187592dcd1c5a5f226601a549f70365fbd00930ac88b0c225b76bb4", size = 220299, upload-time = "2026-05-10T17:59:49.683Z" },
{ url = "https://files.pythonhosted.org/packages/12/c2/a40f5cb295bbcbb697a76947a56081c494c61950366294ee426ffe261099/coverage-7.14.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d8e1762f0e9cbc26ec315471e7b47855218e833cd5a032d706fbf43845d878c7", size = 250721, upload-time = "2026-05-10T17:59:51.494Z" },
{ url = "https://files.pythonhosted.org/packages/fd/35/202235eb5c3c14c212462cd91d61b7386bf8fc44bc7a77f4742d2a69174b/coverage-7.14.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9336e23e8bb3a3925398261385e2a1533957d3e760e91070dcb0e98bfa514eed", size = 252633, upload-time = "2026-05-10T17:59:53.244Z" },
{ url = "https://files.pythonhosted.org/packages/bb/80/5f596e8995785124ee191c42535664c5e62c65995b66f4ca21e28ae04c81/coverage-7.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd1169b2230f9cbe9c638ba38022ed7a2b1e641cc07f7cea0365e4be2a74980", size = 254743, upload-time = "2026-05-10T17:59:55.021Z" },
{ url = "https://files.pythonhosted.org/packages/1e/6d/0d178825be2350f0adb27984d0aa7cf84bbdab201f6fb926b535d23a8f5f/coverage-7.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1bb3543b58fea74d2cd1abc4054cc927e4724687cb4560cd2ed88d2c7d820c0", size = 256700, upload-time = "2026-05-10T17:59:56.511Z" },
{ url = "https://files.pythonhosted.org/packages/19/5b/9e549c2f6e9dfea472adadba06c294e64735dabc2dd19015fac082095013/coverage-7.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a93bac2cb577ef60074999ed56d8a1535894398e2ed920d4185c3ec0c8864742", size = 250854, upload-time = "2026-05-10T17:59:57.94Z" },
{ url = "https://files.pythonhosted.org/packages/3d/1c/b94f9f5f36396021ee2f62c5834b12e6a3d31f0bed5d6fc6d1c3caec087c/coverage-7.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5904abf7e18cddc463219b17552229650c6b79e061d31a1059283051169cf7d5", size = 252433, upload-time = "2026-05-10T17:59:59.688Z" },
{ url = "https://files.pythonhosted.org/packages/b5/cb/d192cd8e1345eccabc32016f2d39072ecd10cb4f4b983ed8d0ebdeaf00dc/coverage-7.14.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:741f57cddc9004a8c81b084660215f33a6b597dbe62c31386b983ee26310e327", size = 250494, upload-time = "2026-05-10T18:00:01.953Z" },
{ url = "https://files.pythonhosted.org/packages/53/c5/aac9f460a41d835dbddef1d377f105f6ac2311d0f3c1588e9f51046d8813/coverage-7.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:664123feb0929d7affc135717dbd70d61d98688a08ab1e5ba464739620c6252d", size = 254261, upload-time = "2026-05-10T18:00:03.779Z" },
{ url = "https://files.pythonhosted.org/packages/23/aa/7af7c0081980a9cb3d289c5a435a4b7657dcecbd128e25c580e6a50389b5/coverage-7.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c83d2399a51bbec8429266905d33616f04bc5726b1138c35844d5fcd896b2e20", size = 250216, upload-time = "2026-05-10T18:00:05.262Z" },
{ url = "https://files.pythonhosted.org/packages/35/60/a4257538ce2f6b978aeb51870d6c4208c510928a03db7e0339bb625dccb7/coverage-7.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb2e855b87321259a037429288ae85216d191c74de3e79bf57cd2bc0761992c", size = 251125, upload-time = "2026-05-10T18:00:06.858Z" },
{ url = "https://files.pythonhosted.org/packages/a1/ab/f91af47642ec1aa53490e835a95847168d9c77fc39aa58527604c051e145/coverage-7.14.0-cp311-cp311-win32.whl", hash = "sha256:731dc15b385ac52289743d476245b61e1a2927e803bef655b52bc3b2a75a21f3", size = 222300, upload-time = "2026-05-10T18:00:08.608Z" },
{ url = "https://files.pythonhosted.org/packages/f0/f0/a71ddbd874431e7a7cd96071f0c331cfbbad07704833c765d24ffbab8a67/coverage-7.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:bfb0ed8ec5d25e93face268115d7964db9df8b9aae8edcde9ec6b16c726a7cc1", size = 223241, upload-time = "2026-05-10T18:00:10.746Z" },
{ url = "https://files.pythonhosted.org/packages/d8/6e/d9d312a5151a96cd110efee32efc3fc97b01ebd86203fe618ccb29cf4c92/coverage-7.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:7ebb1c6df9f78046a1b1e0a89674cd4bf73b7c648914eebcf976a57fd99a5627", size = 221908, upload-time = "2026-05-10T18:00:12.242Z" },
{ url = "https://files.pythonhosted.org/packages/09/1e/2f996b2c8415cbb6f54b0f5ec1ee850c96d7911961afb4fc05f4a89d8c58/coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5", size = 219967, upload-time = "2026-05-10T18:00:13.756Z" },
{ url = "https://files.pythonhosted.org/packages/34/23/35c7aea1274aef7525bdd2dc92f710bdde6d11652239d71d1ec450067939/coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662", size = 220329, upload-time = "2026-05-10T18:00:15.264Z" },
{ url = "https://files.pythonhosted.org/packages/75/cf/a8f4b43a16e194b0261257ad28ded5853ec052570afef4a84e1d81189f3b/coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f", size = 251839, upload-time = "2026-05-10T18:00:17.16Z" },
{ url = "https://files.pythonhosted.org/packages/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67", size = 254576, upload-time = "2026-05-10T18:00:18.829Z" },
{ url = "https://files.pythonhosted.org/packages/22/ec/c936d495fcd67f48f03a9c4ad3297ff80d1f222a5df3980f15b34c186c21/coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9", size = 255690, upload-time = "2026-05-10T18:00:20.648Z" },
{ url = "https://files.pythonhosted.org/packages/5c/42/5af63f636cc62a4a2b1b3ba9146f6ee6f53a35a50d5cefc54d5670f60999/coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb", size = 257949, upload-time = "2026-05-10T18:00:22.28Z" },
{ url = "https://files.pythonhosted.org/packages/26/d3/a225317bd2012132a27e1176d51660b826f99bb975876463c44ea0d7ee5a/coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e", size = 252242, upload-time = "2026-05-10T18:00:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/f1/7f/9e65495298c3ea414742998539c37d048b5e81cc818fb1828cc6b51d10bf/coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3", size = 253608, upload-time = "2026-05-10T18:00:25.588Z" },
{ url = "https://files.pythonhosted.org/packages/94/46/1522b524a35bdad22b2b8c4f9d32d0a104b524726ec380b2db68db1746f5/coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4", size = 251753, upload-time = "2026-05-10T18:00:27.104Z" },
{ url = "https://files.pythonhosted.org/packages/f3/e9/cdf00d38817742c541ade405e115a3f7bf36e6f2a8b99d4f209861b85a2d/coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1", size = 255823, upload-time = "2026-05-10T18:00:29.038Z" },
{ url = "https://files.pythonhosted.org/packages/38/fc/5e7877cf5f902d08a17ff1c532511476d87e1bea355bd5028cb97f902e79/coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5", size = 251323, upload-time = "2026-05-10T18:00:30.647Z" },
{ url = "https://files.pythonhosted.org/packages/18/9d/50f05a72dff8487464fdd4178dda5daed642a060e60afb644e3d45123559/coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595", size = 253197, upload-time = "2026-05-10T18:00:32.211Z" },
{ url = "https://files.pythonhosted.org/packages/00/3f/6f61ffe6439df266c3cf60f5c99cfaa21103d0210d706a42fc6c30683ff8/coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27", size = 222515, upload-time = "2026-05-10T18:00:33.717Z" },
{ url = "https://files.pythonhosted.org/packages/85/19/93853133df2cb371083285ef6a93982a0173e7a233b0f61373ba9fd30eb2/coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2", size = 223324, upload-time = "2026-05-10T18:00:35.172Z" },
{ url = "https://files.pythonhosted.org/packages/74/18/9f7fe62f659f24b7a82a0be56bf94c1bd0a89e0ae7ab4c668f6e82404294/coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d", size = 221944, upload-time = "2026-05-10T18:00:37.014Z" },
{ url = "https://files.pythonhosted.org/packages/6b/76/b7c66ee3c66e1b0f9d894c8125983aa0c03fb2336f2fd16559f9c966157f/coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef", size = 219990, upload-time = "2026-05-10T18:00:38.887Z" },
{ url = "https://files.pythonhosted.org/packages/b3/af/e567cbad5ba69c013a50146dfa886dc7193361fda77521f51274ff620e1b/coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66", size = 220365, upload-time = "2026-05-10T18:00:40.864Z" },
{ url = "https://files.pythonhosted.org/packages/44/6f/9ad575d505b4d805b254febc8a5b338a2efe278f8786e56ff1cb8413f9c3/coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b", size = 251363, upload-time = "2026-05-10T18:00:42.489Z" },
{ url = "https://files.pythonhosted.org/packages/6f/5f/b5370068b2f57787454592ed7dcd1002f0f1703b7db1fa30f6a325a4ca6e/coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca", size = 253961, upload-time = "2026-05-10T18:00:44.079Z" },
{ url = "https://files.pythonhosted.org/packages/29/1e/51adf17738976e8f2b85ddef7b7aa12a0838b056c92f175941d8862767c1/coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7", size = 255193, upload-time = "2026-05-10T18:00:45.623Z" },
{ url = "https://files.pythonhosted.org/packages/9e/7b/5bfd7ac1df3b881c2ac7a5cbc99c7609e6296c402f5ef587cd81c6f355b3/coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2", size = 257326, upload-time = "2026-05-10T18:00:47.173Z" },
{ url = "https://files.pythonhosted.org/packages/7d/38/1d37d316b174fad3843a1d76dbdfe4398771c9ecd0515935dd9ece9cd627/coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367", size = 251582, upload-time = "2026-05-10T18:00:49.152Z" },
{ url = "https://files.pythonhosted.org/packages/34/46/746704f95980ba220214e1a41e18cec5aea80a898eaa53c51bf2d645ff36/coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9", size = 253325, upload-time = "2026-05-10T18:00:51.252Z" },
{ url = "https://files.pythonhosted.org/packages/e1/b9/bbe87206d9687b192352f893797825b5f5b15ecd3aa9c68fbff0c074d77b/coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087", size = 251291, upload-time = "2026-05-10T18:00:52.816Z" },
{ url = "https://files.pythonhosted.org/packages/46/57/b8cdb12ac0d73ef0243218bd5e22c9df8f92edab8018213a86aec67c5324/coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef", size = 255448, upload-time = "2026-05-10T18:00:54.548Z" },
{ url = "https://files.pythonhosted.org/packages/1f/d4/5002019538b2036ce3c84340f54d2fd5100d55b0a6b0894eee56128d03c7/coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52", size = 251110, upload-time = "2026-05-10T18:00:56.122Z" },
{ url = "https://files.pythonhosted.org/packages/37/53/20c5009477660f084e6ed60bc02a91894b8e234e617e86ecfd9aaf78e27b/coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe", size = 252885, upload-time = "2026-05-10T18:00:57.967Z" },
{ url = "https://files.pythonhosted.org/packages/ae/ab/3cf6427ac9c1f1db747dbb1ce71dde47984876d4c2cfd018a3fef0a78d4d/coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae", size = 222539, upload-time = "2026-05-10T18:00:59.581Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b8/9228523e80321c2cb4880d1f589bc0171f2f71432c35118ad04dc01decce/coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e", size = 223344, upload-time = "2026-05-10T18:01:01.531Z" },
{ url = "https://files.pythonhosted.org/packages/a3/99/118daa192f95e3a6cb2740100fbf8797cda1734b4134ef0b5d501a7fa8f3/coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96", size = 221966, upload-time = "2026-05-10T18:01:03.16Z" },
{ url = "https://files.pythonhosted.org/packages/e6/f1/a46cc0c013be170216253184a32366d7cbdb9252feaec866b05c2d12a894/coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90", size = 220679, upload-time = "2026-05-10T18:01:05.058Z" },
{ url = "https://files.pythonhosted.org/packages/64/8c/9c30a3d311a34177fa432995be7fbfc64477d8bac5630bd38055b1c9b424/coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1", size = 221033, upload-time = "2026-05-10T18:01:07.002Z" },
{ url = "https://files.pythonhosted.org/packages/9a/cd/3fb5e06c3badefd0c1b47e2044fdca67f8220a4ec2e7fcfb476aa0a67c6c/coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd", size = 262333, upload-time = "2026-05-10T18:01:08.903Z" },
{ url = "https://files.pythonhosted.org/packages/a8/e6/fbc322325c7294d3e22c1ad6b79e45d0806b25228c8e5842aed6d8169aa7/coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc", size = 264410, upload-time = "2026-05-10T18:01:10.531Z" },
{ url = "https://files.pythonhosted.org/packages/08/92/c497b264bec1673c47cc77e26f760fcda4654cabf1f39546d1a23a3b8c35/coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426", size = 266836, upload-time = "2026-05-10T18:01:12.19Z" },
{ url = "https://files.pythonhosted.org/packages/78/fc/045da320987f401af5d2815d351e8aa799aec859f60e29f445e3089eeedb/coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899", size = 267974, upload-time = "2026-05-10T18:01:13.926Z" },
{ url = "https://files.pythonhosted.org/packages/1b/ae/227b1e379497fb7a4fc3286e620f80c8a1e7cec66d45695a01639eb1af65/coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b", size = 261578, upload-time = "2026-05-10T18:01:15.564Z" },
{ url = "https://files.pythonhosted.org/packages/a0/f5/3570342900f2acea31d33ff1590c5d8bac1a8e1a2e1c6d34a5d5e61de681/coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90", size = 264394, upload-time = "2026-05-10T18:01:17.607Z" },
{ url = "https://files.pythonhosted.org/packages/16/29/de1bbc01c935b28f89b1dc3db85b011c055e843a8e5e3b83141c3f80af7f/coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f", size = 262022, upload-time = "2026-05-10T18:01:19.304Z" },
{ url = "https://files.pythonhosted.org/packages/35/95/f53890b0bf2fc10ab168e05d38869215e73ca24c4cb521c3bb0eb62fe16b/coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d", size = 265732, upload-time = "2026-05-10T18:01:21.494Z" },
{ url = "https://files.pythonhosted.org/packages/ed/ea/c919e259081dd2bdf0e43b87209709ba7ec2e4117c2a7f5185379c43463c/coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47", size = 260921, upload-time = "2026-05-10T18:01:23.533Z" },
{ url = "https://files.pythonhosted.org/packages/1a/2c/c2831889705a81dc5d1c6ca12e4d8e9b95dfc146d153488a6c0ea685d28e/coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477", size = 263109, upload-time = "2026-05-10T18:01:25.165Z" },
{ url = "https://files.pythonhosted.org/packages/5a/a9/2fcae5003cac3d63fe344d2166243c2756935f48420863c5272b240d550b/coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab", size = 223212, upload-time = "2026-05-10T18:01:27.157Z" },
{ url = "https://files.pythonhosted.org/packages/3f/bb/18e94d7b14b9b398164197114a587a04ab7c9fdbe1d237eef57311c5e883/coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917", size = 224272, upload-time = "2026-05-10T18:01:29.107Z" },
{ url = "https://files.pythonhosted.org/packages/db/56/4f14fad782b035c81c4ffd09159e7103d42bb1d93ac8496d04b90a11b7da/coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8", size = 222530, upload-time = "2026-05-10T18:01:31.151Z" },
{ url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" },
{ url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" },
{ url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" },
{ url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" },
{ url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" },
{ url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" },
{ url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" },
{ url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" },
{ url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" },
{ url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" },
{ url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" },
{ url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" },
{ url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" },
{ url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" },
{ url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" },
{ url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" },
{ url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" },
{ url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" },
{ url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" },
{ url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" },
{ url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" },
{ url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" },
{ url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" },
{ url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" },
{ url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" },
{ url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" },
{ url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" },
{ url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" },
{ url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" },
{ url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" },
{ url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" },
]
[package.optional-dependencies]
toml = [
{ name = "tomli", marker = "python_full_version <= '3.11'" },
]
[[package]] [[package]]
name = "cryptography" name = "cryptography"
version = "48.0.0" version = "48.0.0"
@@ -213,14 +335,14 @@ wheels = [
[[package]] [[package]]
name = "markdown-it-py" name = "markdown-it-py"
version = "4.0.0" version = "4.2.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "mdurl" }, { name = "mdurl" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
] ]
[[package]] [[package]]
@@ -286,6 +408,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
] ]
[[package]]
name = "pytest-cov"
version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage", extra = ["toml"] },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
[[package]] [[package]]
name = "rich" name = "rich"
version = "15.0.0" version = "15.0.0"