Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
93f8994f6a
|
|||
|
9aad012bdc
|
|||
|
545abeb515
|
|||
|
e1e4adbb25
|
@@ -1,19 +1,16 @@
|
||||
# browser-cli
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
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
|
||||
|
||||
```
|
||||
terminal / python script
|
||||
│
|
||||
@@ -79,9 +76,9 @@ Only the `browser-cli` command needs to be on your `PATH`. The browser launches
|
||||
```text
|
||||
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
|
||||
│ ├── 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
|
||||
│ ├── native_host.py # Native messaging host launched by the browser
|
||||
│ └── commands/
|
||||
@@ -99,7 +96,7 @@ browser-cli/
|
||||
│ └── src/ # TypeScript source split by command area
|
||||
│ └── index.ts # Builds generated extension/background.js
|
||||
├── examples/
|
||||
│ ├── demo.py # Python API walkthrough
|
||||
│ ├── demo.py # Python SDK walkthrough
|
||||
│ └── demo.sh # Bash CLI walkthrough
|
||||
├── tests/
|
||||
│ ├── conftest.py # shared pytest fixtures
|
||||
@@ -285,7 +282,7 @@ browser-cli completion zsh --script # output raw completion script
|
||||
|
||||
---
|
||||
|
||||
## Python API
|
||||
## Python SDK
|
||||
|
||||
```python
|
||||
from browser_cli import BrowserCLI
|
||||
@@ -293,11 +290,13 @@ from browser_cli import 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
|
||||
# Navigation
|
||||
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", window="work")
|
||||
b.reload()
|
||||
@@ -308,8 +307,14 @@ b.focus_url("github")
|
||||
|
||||
# Tabs
|
||||
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_close(1234)
|
||||
b.close_tab(tab) # accepts Tab or tab ID
|
||||
b.tabs_close_inactive()
|
||||
b.tabs_close_duplicates()
|
||||
b.tabs_filter("youtube") # list of matching tabs
|
||||
@@ -320,9 +325,19 @@ b.tabs_sort(by="domain")
|
||||
b.tabs_merge_windows()
|
||||
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
|
||||
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_tabs(42) # tabs inside a group
|
||||
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
|
||||
b.dom_click(".accept-button")
|
||||
b.dom_type("#search", "hello world")
|
||||
b.wait_for_selector("#results", visible=True, timeout=10)
|
||||
|
||||
# Extract
|
||||
links = b.extract_links() # list of { text, href }
|
||||
@@ -359,6 +375,7 @@ b.session_auto_save(True)
|
||||
|
||||
# Misc
|
||||
clients = b.clients()
|
||||
raw = b.command("tabs.count", {"pattern": "github"}) # escape hatch for raw commands
|
||||
```
|
||||
|
||||
**Error handling**
|
||||
|
||||
+164
-3
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
browser_cli — Python API for controlling your running browser.
|
||||
browser_cli — Python SDK for controlling your running browser.
|
||||
|
||||
Usage:
|
||||
from browser_cli import BrowserCLI
|
||||
@@ -50,9 +50,32 @@ class BrowserCLI:
|
||||
self._remote = remote
|
||||
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):
|
||||
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):
|
||||
if self._browser is not None:
|
||||
return []
|
||||
@@ -109,6 +132,7 @@ class BrowserCLI:
|
||||
tab._browser = self if browser_profile is None else BrowserCLI(
|
||||
browser=browser_profile,
|
||||
remote=browser_remote,
|
||||
key=self._key,
|
||||
)
|
||||
return tab
|
||||
|
||||
@@ -131,6 +155,7 @@ class BrowserCLI:
|
||||
group._browser = self if browser_profile is None else BrowserCLI(
|
||||
browser=browser_profile,
|
||||
remote=browser_remote,
|
||||
key=self._key,
|
||||
)
|
||||
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:
|
||||
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:
|
||||
self._cmd("navigate.reload", {"tabId": tab_id})
|
||||
|
||||
@@ -216,6 +262,32 @@ class BrowserCLI:
|
||||
|
||||
# ── 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]:
|
||||
"""Return all open tabs across all windows.
|
||||
|
||||
@@ -370,6 +442,14 @@ class BrowserCLI:
|
||||
|
||||
# ── 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]:
|
||||
"""Return all tab groups.
|
||||
|
||||
@@ -394,6 +474,10 @@ class BrowserCLI:
|
||||
"""Return all tabs inside a group."""
|
||||
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:
|
||||
"""Return the number of tab groups.
|
||||
|
||||
@@ -405,6 +489,10 @@ class BrowserCLI:
|
||||
return BrowserCounts(total=sum(by_browser.values()), by_browser=by_browser)
|
||||
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]:
|
||||
"""Search groups by name."""
|
||||
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."""
|
||||
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:
|
||||
"""Create a new tab group with *name*. Returns the created Group."""
|
||||
data = self._cmd("group.open", {"name": name})
|
||||
@@ -536,6 +632,18 @@ class BrowserCLI:
|
||||
"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(
|
||||
self,
|
||||
selector: str,
|
||||
@@ -650,8 +758,61 @@ class BrowserCLI:
|
||||
def session_save(self, name: str) -> None:
|
||||
self._cmd("session.save", {"name": name})
|
||||
|
||||
def session_load(self, name: str) -> None:
|
||||
self._cmd("session.load", {"name": name})
|
||||
def session_load(
|
||||
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:
|
||||
return self._cmd("session.diff", {"nameA": name_a, "nameB": name_b}) or {}
|
||||
|
||||
@@ -23,6 +23,8 @@ from browser_cli.commands.search import search_group
|
||||
from browser_cli.commands.page import page_group
|
||||
from browser_cli.commands.storage import storage_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.client import (
|
||||
send_command,
|
||||
@@ -375,6 +377,8 @@ main.add_command(search_group)
|
||||
main.add_command(page_group)
|
||||
main.add_command(storage_group)
|
||||
main.add_command(cookies_group)
|
||||
main.add_command(perf_group)
|
||||
main.add_command(extension_group)
|
||||
main.add_command(cmd_serve)
|
||||
|
||||
|
||||
|
||||
+17
-1
@@ -214,6 +214,22 @@ def active_browser_targets(*, include_remotes: bool = True, key=None) -> list[Br
|
||||
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:
|
||||
"""Return the socket path for the given profile (or auto-detect)."""
|
||||
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:
|
||||
remote_endpoint = _normalize_endpoint(remote_endpoint)
|
||||
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)
|
||||
if remote_alias_target:
|
||||
remote_endpoint = remote_alias_target.remote
|
||||
|
||||
@@ -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")
|
||||
@@ -103,9 +103,10 @@ def group_query(search):
|
||||
|
||||
@group_group.command("close")
|
||||
@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."""
|
||||
_handle("group.close", {"groupId": group_id})
|
||||
_handle("group.close", {"groupId": group_id, "gentleMode": gentle_mode})
|
||||
console.print(f"[green]Group {group_id} closed[/green]")
|
||||
|
||||
|
||||
|
||||
@@ -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]")
|
||||
@@ -4,12 +4,10 @@ from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
@click.group("session")
|
||||
def session_group():
|
||||
"""Save and restore browser sessions."""
|
||||
|
||||
|
||||
@session_group.command("save")
|
||||
@click.argument("name")
|
||||
def session_save(name):
|
||||
@@ -18,16 +16,29 @@ def session_save(name):
|
||||
count = result.get("tabs", 0) if isinstance(result, dict) else 0
|
||||
console.print(f"[green]Session '{name}' saved[/green] ({count} tabs)")
|
||||
|
||||
|
||||
@session_group.command("load")
|
||||
@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)."""
|
||||
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
|
||||
console.print(f"[green]Session '{name}' loaded[/green] ({count} tabs opened)")
|
||||
|
||||
|
||||
@session_group.command("diff")
|
||||
@click.argument("name_a")
|
||||
@click.argument("name_b")
|
||||
@@ -54,7 +65,6 @@ def session_diff(name_a, name_b):
|
||||
if not added and not removed:
|
||||
console.print("[green]Sessions are identical[/green]")
|
||||
|
||||
|
||||
@session_group.command("list")
|
||||
def session_list():
|
||||
"""List all saved sessions."""
|
||||
@@ -90,7 +100,6 @@ def session_list():
|
||||
table.add_row(*row)
|
||||
console.print(table)
|
||||
|
||||
|
||||
@session_group.command("remove")
|
||||
@click.argument("name")
|
||||
def session_remove(name):
|
||||
@@ -98,6 +107,24 @@ def session_remove(name):
|
||||
_handle("session.remove", {"name": name})
|
||||
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")
|
||||
@click.argument("state", type=click.Choice(["on", "off"]))
|
||||
|
||||
@@ -66,9 +66,10 @@ def tabs_list():
|
||||
@click.argument("tab_id", type=int, required=False)
|
||||
@click.option("--inactive", is_flag=True, help="Close all inactive tabs")
|
||||
@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."""
|
||||
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
|
||||
console.print(f"[green]Closed {count} tab(s)[/green]")
|
||||
|
||||
@@ -171,25 +172,28 @@ def tabs_html(tab_id):
|
||||
|
||||
|
||||
@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)."""
|
||||
result = _handle("tabs.dedupe")
|
||||
result = _handle("tabs.dedupe", {"gentleMode": gentle_mode})
|
||||
count = result.get("closed", 0) if isinstance(result, dict) else 0
|
||||
console.print(f"[green]Closed {count} duplicate tab(s)[/green]")
|
||||
|
||||
|
||||
@tabs_group.command("sort")
|
||||
@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."""
|
||||
_handle("tabs.sort", {"by": by})
|
||||
_handle("tabs.sort", {"by": by, "gentleMode": gentle_mode})
|
||||
console.print(f"[green]Tabs sorted by {by}[/green]")
|
||||
|
||||
|
||||
@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."""
|
||||
result = _handle("tabs.merge_windows")
|
||||
result = _handle("tabs.merge_windows", {"gentleMode": gentle_mode})
|
||||
count = result.get("moved", 0) if isinstance(result, dict) else 0
|
||||
console.print(f"[green]Merged — moved {count} tab(s) into current window[/green]")
|
||||
|
||||
|
||||
@@ -95,6 +95,30 @@ class Tab:
|
||||
"""Return the full HTML source of this tab."""
|
||||
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:
|
||||
"""Navigate this tab to *url* in place."""
|
||||
self._b().navigate_tab(self.id, url)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "browser-cli",
|
||||
"version": "0.9.8",
|
||||
"description": "Control your browser from the terminal via browser-cli",
|
||||
"version": "0.10.1",
|
||||
"description": "Control your browser from the terminal or Python SDK",
|
||||
"permissions": [
|
||||
"tabs",
|
||||
"tabGroups",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @ts-nocheck
|
||||
import { buildTabBlocks, resolveGroupId, tabInfo } from '../core';
|
||||
import { buildTabBlocks, getLargeOperationThrottle, resolveGroupId, runLargeOperation, tabInfo, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core';
|
||||
export async function groupList() {
|
||||
const groups = await chrome.tabGroups.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));
|
||||
}
|
||||
|
||||
export async function groupClose({ groupId }) {
|
||||
const tabs = await chrome.tabs.query({});
|
||||
const groupTabs = tabs.filter(t => t.groupId === groupId);
|
||||
await chrome.tabs.ungroup(groupTabs.map(t => t.id));
|
||||
return { groupId };
|
||||
export async function groupClose({ groupId, gentleMode, __job } = {}) {
|
||||
return runLargeOperation("group.close", async () => {
|
||||
const tabs = await chrome.tabs.query({});
|
||||
const groupTabs = tabs.filter(t => t.groupId === groupId);
|
||||
const tabIds = groupTabs.map(t => t.id);
|
||||
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 }) {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// @ts-nocheck
|
||||
import { getProfileAlias, getSessionTabs, getSessions, normalizeGroupColor } from '../core';
|
||||
export async function sessionSave({ name }) {
|
||||
const tabs = await chrome.tabs.query({});
|
||||
const groups = await chrome.tabGroups.query({});
|
||||
import { getLargeOperationThrottle, getProfileAlias, getSessionTabs, getSessions, normalizeGroupColor, runLargeOperation, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core';
|
||||
|
||||
function buildSessionSnapshot(tabs, groups) {
|
||||
const groupById = new Map(groups.map(group => [group.id, group]));
|
||||
const sessionTabs = tabs
|
||||
.filter(tab => Boolean(tab.url))
|
||||
return tabs
|
||||
.filter(tab => Boolean(tab.url || tab.pendingUrl))
|
||||
.sort((a, b) => (a.windowId - b.windowId) || (a.index - b.index))
|
||||
.map(tab => {
|
||||
const entry = { url: tab.url };
|
||||
const entry = { url: tab.url || tab.pendingUrl };
|
||||
if (tab.pinned) entry.pinned = true;
|
||||
if (tab.groupId >= 0) {
|
||||
const group = groupById.get(tab.groupId);
|
||||
entry.group = {
|
||||
@@ -20,49 +20,110 @@ export async function sessionSave({ name }) {
|
||||
}
|
||||
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();
|
||||
sessions[name] = {
|
||||
tabs: sessionTabs,
|
||||
urls: sessionTabs.map(tab => tab.url),
|
||||
savedAt: Date.now(),
|
||||
signature,
|
||||
};
|
||||
await chrome.storage.local.set({ sessions });
|
||||
return { name, tabs: sessionTabs.length };
|
||||
}
|
||||
|
||||
export async function sessionLoad({ name }) {
|
||||
const sessions = await getSessions();
|
||||
const session = sessions[name];
|
||||
if (!session) throw new Error(`Session '${name}' not found`);
|
||||
function lazyPlaceholderUrl(url) {
|
||||
const escaped = String(url).replace(/[&<>"']/g, ch => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[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)}`;
|
||||
}
|
||||
|
||||
const sessionTabs = getSessionTabs(session);
|
||||
const createdTabs = [];
|
||||
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;
|
||||
}
|
||||
|
||||
for (const entry of sessionTabs) {
|
||||
const tab = await chrome.tabs.create({ url: entry.url, active: false });
|
||||
createdTabs.push({ tabId: tab.id, entry });
|
||||
}
|
||||
export async function sessionLoad({ name, gentleMode, discardBackgroundTabs = false, lazy = false, eagerTabs = 10, __job } = {}) {
|
||||
return runLargeOperation("session.load", async () => {
|
||||
const sessions = await getSessions();
|
||||
const session = sessions[name];
|
||||
if (!session) throw new Error(`Session '${name}' not found`);
|
||||
|
||||
const groups = new Map();
|
||||
for (const { tabId, entry } of createdTabs) {
|
||||
if (!entry.group) continue;
|
||||
const key = entry.group.key || `${entry.group.title || "group"}:${groups.size}`;
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, { meta: entry.group, tabIds: [] });
|
||||
const sessionTabs = getSessionTabs(session).sort((a, b) => Number(Boolean(b.pinned)) - Number(Boolean(a.pinned)));
|
||||
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 [idx, entry] of sessionTabs.entries()) {
|
||||
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 });
|
||||
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));
|
||||
}
|
||||
groups.get(key).tabIds.push(tabId);
|
||||
}
|
||||
if (lazy) await chrome.storage.local.set({ lazySessionTabs: lazyMap });
|
||||
|
||||
for (const { meta, tabIds } of groups.values()) {
|
||||
const restoredGroupId = await chrome.tabs.group({ tabIds });
|
||||
await chrome.tabGroups.update(restoredGroupId, {
|
||||
title: meta.title || "",
|
||||
color: normalizeGroupColor(meta.color),
|
||||
collapsed: Boolean(meta.collapsed),
|
||||
});
|
||||
}
|
||||
const groups = new Map();
|
||||
for (const { tabId, entry } of createdTabs) {
|
||||
if (!entry.group) continue;
|
||||
const key = entry.group.key || `${entry.group.title || "group"}:${groups.size}`;
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, { meta: entry.group, tabIds: [] });
|
||||
}
|
||||
groups.get(key).tabIds.push(tabId);
|
||||
}
|
||||
|
||||
return { name, tabs: sessionTabs.length };
|
||||
let restoredGroups = 0;
|
||||
updateJobProgress(__job, { phase: "restoring groups", current: 0, total: groups.size });
|
||||
for (const { meta, tabIds } of groups.values()) {
|
||||
throwIfJobCancelled(__job);
|
||||
const restoredGroupId = await chrome.tabs.group({ tabIds });
|
||||
await chrome.tabGroups.update(restoredGroupId, {
|
||||
title: meta.title || "",
|
||||
color: normalizeGroupColor(meta.color),
|
||||
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, gentle: throttle.gentle, audible: throttle.audible, discarded: Boolean(discardBackgroundTabs), lazy: Boolean(lazy), eagerTabs: eagerLimit };
|
||||
});
|
||||
}
|
||||
|
||||
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 }) {
|
||||
await chrome.storage.local.set({ autoSave: enabled });
|
||||
chrome.tabs.onUpdated.removeListener(autoSaveHandler);
|
||||
chrome.tabs.onCreated.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) {
|
||||
chrome.tabs.onUpdated.addListener(autoSaveHandler);
|
||||
chrome.tabs.onCreated.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 };
|
||||
}
|
||||
|
||||
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");
|
||||
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 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @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() {
|
||||
const windows = await chrome.windows.getAll({ populate: true });
|
||||
const aliases = await getAliases();
|
||||
@@ -17,24 +17,33 @@ export async function tabsList() {
|
||||
return tabs;
|
||||
}
|
||||
|
||||
export async function tabsClose({ tabId, inactive, duplicates }) {
|
||||
let toClose = [];
|
||||
if (duplicates) {
|
||||
const all = await chrome.tabs.query({});
|
||||
const seen = new Set();
|
||||
for (const t of all) {
|
||||
if (!t.url) continue;
|
||||
if (seen.has(t.url)) toClose.push(t.id);
|
||||
else seen.add(t.url);
|
||||
export async function tabsClose({ tabId, inactive, duplicates, gentleMode, __job } = {}) {
|
||||
return runLargeOperation("tabs.close", async () => {
|
||||
let toClose = [];
|
||||
if (duplicates) {
|
||||
const all = await chrome.tabs.query({});
|
||||
const seen = new Set();
|
||||
for (const t of all) {
|
||||
if (!t.url) continue;
|
||||
if (seen.has(t.url)) toClose.push(t.id);
|
||||
else seen.add(t.url);
|
||||
}
|
||||
} else if (inactive) {
|
||||
const all = await chrome.tabs.query({});
|
||||
toClose = all.filter(t => !t.active).map(t => t.id);
|
||||
} else if (tabId) {
|
||||
toClose = [tabId];
|
||||
}
|
||||
} else if (inactive) {
|
||||
const all = await chrome.tabs.query({});
|
||||
toClose = all.filter(t => !t.active).map(t => t.id);
|
||||
} else if (tabId) {
|
||||
toClose = [tabId];
|
||||
}
|
||||
if (toClose.length) await chrome.tabs.remove(toClose);
|
||||
return { closed: toClose.length };
|
||||
const throttle = await getLargeOperationThrottle(toClose.length, gentleMode);
|
||||
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 }) {
|
||||
@@ -127,41 +136,62 @@ export async function tabsHtml({ tabId }) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function tabsDedupe() {
|
||||
return tabsClose({ duplicates: true });
|
||||
export async function tabsDedupe(args = {}) {
|
||||
return tabsClose({ ...args, duplicates: true });
|
||||
}
|
||||
|
||||
export async function tabsSort({ by }) {
|
||||
const windows = await chrome.windows.getAll({ populate: true });
|
||||
let moved = 0;
|
||||
for (const w of windows) {
|
||||
const sorted = [...w.tabs].sort((a, b) => {
|
||||
if (by === "title") return (a.title || "").localeCompare(b.title || "");
|
||||
if (by === "time") return a.id - b.id; // lower id = opened earlier
|
||||
// domain (default)
|
||||
const da = new URL(a.url || a.pendingUrl || "about:blank").hostname;
|
||||
const db = new URL(b.url || b.pendingUrl || "about:blank").hostname;
|
||||
return da.localeCompare(db);
|
||||
});
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
await chrome.tabs.move(sorted[i].id, { index: i });
|
||||
moved++;
|
||||
export async function tabsSort({ by, gentleMode, __job } = {}) {
|
||||
return runLargeOperation("tabs.sort", async () => {
|
||||
const windows = await chrome.windows.getAll({ populate: true });
|
||||
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) {
|
||||
const sorted = [...w.tabs].sort((a, b) => {
|
||||
if (by === "title") return (a.title || "").localeCompare(b.title || "");
|
||||
if (by === "time") return a.id - b.id; // lower id = opened earlier
|
||||
// domain (default)
|
||||
const da = new URL(a.url || a.pendingUrl || "about:blank").hostname;
|
||||
const db = new URL(b.url || b.pendingUrl || "about:blank").hostname;
|
||||
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++) {
|
||||
throwIfJobCancelled(__job);
|
||||
await chrome.tabs.move(sorted[i].id, { index: i });
|
||||
moved++;
|
||||
updateJobProgress(__job, { phase: "sorting tabs", current: moved, total: totalTabs });
|
||||
await yieldForLargeOperation(moved, moveBatchSize, throttle.pauseMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { moved };
|
||||
return { moved };
|
||||
});
|
||||
}
|
||||
|
||||
export async function tabsMergeWindows() {
|
||||
const current = await chrome.windows.getCurrent();
|
||||
const all = await chrome.windows.getAll({ populate: true });
|
||||
let moved = 0;
|
||||
for (const w of all) {
|
||||
if (w.id === current.id) continue;
|
||||
const ids = w.tabs.map(t => t.id);
|
||||
await chrome.tabs.move(ids, { windowId: current.id, index: -1 });
|
||||
moved += ids.length;
|
||||
}
|
||||
return { moved };
|
||||
export async function tabsMergeWindows({ gentleMode, __job } = {}) {
|
||||
return runLargeOperation("tabs.merge_windows", async () => {
|
||||
const current = await chrome.windows.getCurrent();
|
||||
const all = await chrome.windows.getAll({ populate: true });
|
||||
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) {
|
||||
if (w.id === current.id) continue;
|
||||
const ids = w.tabs.map(t => t.id);
|
||||
const throttle = await getLargeOperationThrottle(ids.length, gentleMode);
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
export async function tabsPin({ tabId }) {
|
||||
|
||||
+84
-2
@@ -32,13 +32,94 @@ export function isTransientScriptError(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) {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
return await chrome.scripting.executeScript(options);
|
||||
} catch (e) {
|
||||
if (i < retries - 1 && isTransientScriptError(e)) {
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
await sleep(300);
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
@@ -52,8 +133,9 @@ export function tabInfo(t) {
|
||||
windowId: t.windowId,
|
||||
active: t.active,
|
||||
muted: Boolean(t.mutedInfo && t.mutedInfo.muted),
|
||||
groupId: t.groupId >= 0 ? t.groupId : null,
|
||||
title: t.title,
|
||||
url: t.url,
|
||||
url: t.url || t.pendingUrl || "",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+126
-7
@@ -5,7 +5,7 @@
|
||||
* 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 tabs from './commands/tabs';
|
||||
import * as groups from './commands/groups';
|
||||
@@ -17,6 +17,15 @@ import * as session from './commands/session';
|
||||
const NATIVE_HOST = "com.browsercli.host";
|
||||
let port = null;
|
||||
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 ─────────────────────────────────────────────────────
|
||||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
async function onMessage(msg) {
|
||||
@@ -98,10 +111,14 @@ async function onMessage(msg) {
|
||||
|
||||
let data, error;
|
||||
try {
|
||||
const { __page, ...commandArgs } = args || {};
|
||||
data = await dispatch(command, commandArgs);
|
||||
if (__page && Array.isArray(data)) {
|
||||
data = makePagedData(data, __page);
|
||||
const { __page, __background, ...commandArgs } = args || {};
|
||||
if (__background && BACKGROUND_COMMANDS.has(command)) {
|
||||
data = await startBackgroundJob(command, commandArgs);
|
||||
} else {
|
||||
data = await dispatch(command, commandArgs);
|
||||
if (__page && Array.isArray(data)) {
|
||||
data = makePagedData(data, __page);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error = e.message || String(e);
|
||||
@@ -121,6 +138,94 @@ async function onMessage(msg) {
|
||||
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) {
|
||||
const total = items.length;
|
||||
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.query": return tabs.tabsQuery(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.merge_windows": return tabs.tabsMergeWindows();
|
||||
case "tabs.merge_windows": return tabs.tabsMergeWindows(args);
|
||||
case "tabs.mute": return tabs.tabsMute(args);
|
||||
case "tabs.unmute": return tabs.tabsUnmute(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.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 ──────────────────────────────────────────────────────────────
|
||||
case "clients.list": return session.clientsList();
|
||||
case "clients.rename_profile": return session.clientsRenameProfile(args);
|
||||
|
||||
+6
-3
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "browser-cli"
|
||||
version = "0.9.8"
|
||||
description = "Control your real running browser from the terminal via a browser extension"
|
||||
version = "0.10.1"
|
||||
description = "Control your real running browser from the terminal or Python SDK"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"click>=8",
|
||||
@@ -13,7 +13,10 @@ dependencies = [
|
||||
browser-cli = "browser_cli.cli:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["pytest>=8"]
|
||||
dev = [
|
||||
"pytest>=8",
|
||||
"pytest-cov>=7.1.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
|
||||
@@ -69,6 +69,31 @@ class TestBrowserCLIInit:
|
||||
assert b._browser == "work"
|
||||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -140,6 +165,41 @@ class TestNavigation:
|
||||
b.open("https://x.com", 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):
|
||||
b.reload(tab_id=5)
|
||||
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),
|
||||
]
|
||||
|
||||
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):
|
||||
with patch(
|
||||
"browser_cli.active_browser_targets",
|
||||
@@ -463,6 +581,40 @@ class TestGroups:
|
||||
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):
|
||||
with patch(
|
||||
"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)
|
||||
|
||||
|
||||
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:
|
||||
def test_session_list(self, b, mock_send):
|
||||
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
|
||||
)
|
||||
|
||||
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):
|
||||
tab.open("https://new.example.com", background=True)
|
||||
mock_send.assert_called_once_with(
|
||||
|
||||
@@ -117,6 +117,50 @@ def test_send_command_auto_routes_single_remote_target(monkeypatch):
|
||||
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):
|
||||
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
||||
monkeypatch.setenv("BROWSER_CLI_PROFILE", "host:work")
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -58,3 +58,210 @@ def test_dom_attr_html_lang(browser, http_tab):
|
||||
assert isinstance(langs, list)
|
||||
# html element exists so we get exactly one entry (may be empty string if no lang attr)
|
||||
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
|
||||
|
||||
@@ -35,3 +35,106 @@ def test_navigation_and_tabs_report_browser_error_pages():
|
||||
assert "last URL:" in tabs
|
||||
assert "isBrowserErrorUrl" 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
@@ -6,11 +6,9 @@ import pytest
|
||||
|
||||
import browser_cli.native_host as native_host
|
||||
|
||||
|
||||
def _raise_system_exit(code: int):
|
||||
raise SystemExit(code)
|
||||
|
||||
|
||||
def test_cleanup_removes_socket_and_registry_entry(monkeypatch, tmp_path):
|
||||
alias = "work"
|
||||
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 json.loads(registry_path.read_text()) == {"other": str(tmp_path / "other.sock")}
|
||||
|
||||
|
||||
def test_stdin_reader_cleans_up_on_eof(monkeypatch):
|
||||
cleaned = []
|
||||
|
||||
@@ -41,7 +38,6 @@ def test_stdin_reader_cleans_up_on_eof(monkeypatch):
|
||||
|
||||
assert cleaned == ["work"]
|
||||
|
||||
|
||||
def test_cleanup_windows_skips_socket_unlink(monkeypatch, tmp_path):
|
||||
registry_path = tmp_path / "registry.json"
|
||||
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()) == {}
|
||||
|
||||
|
||||
def test_stdin_reader_cleans_up_on_bye(monkeypatch):
|
||||
cleaned = []
|
||||
messages = iter([{"type": "bye"}])
|
||||
@@ -68,7 +63,6 @@ def test_stdin_reader_cleans_up_on_bye(monkeypatch):
|
||||
|
||||
assert cleaned == ["work"]
|
||||
|
||||
|
||||
def test_stdin_reader_routes_response_messages(monkeypatch):
|
||||
response_queue = native_host.queue.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}
|
||||
native_host.PENDING.clear()
|
||||
|
||||
|
||||
def test_collect_paged_browser_command_accumulates_pages(monkeypatch):
|
||||
calls = []
|
||||
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["id"] != "orig" for call in calls)
|
||||
|
||||
|
||||
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}})
|
||||
|
||||
@@ -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}}
|
||||
|
||||
|
||||
def test_handle_browser_command_pages_known_list_commands(monkeypatch):
|
||||
seen = []
|
||||
|
||||
@@ -128,3 +119,224 @@ def test_handle_browser_command_pages_known_list_commands(monkeypatch):
|
||||
|
||||
assert result == {"success": True, "data": []}
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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})
|
||||
@@ -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"}
|
||||
@@ -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
@@ -1,7 +1,6 @@
|
||||
"""Tests for tabs.* commands."""
|
||||
import pytest
|
||||
|
||||
|
||||
def test_tabs_list(browser):
|
||||
tabs = browser("tabs.list")
|
||||
assert isinstance(tabs, list)
|
||||
@@ -12,59 +11,51 @@ def test_tabs_list(browser):
|
||||
assert "url" in first
|
||||
assert "title" in first
|
||||
assert "muted" in first
|
||||
|
||||
assert "groupId" in first
|
||||
|
||||
def test_tabs_count(browser):
|
||||
count = browser("tabs.count", {})
|
||||
tabs = browser("tabs.list")
|
||||
assert count == len(tabs)
|
||||
|
||||
|
||||
def test_tabs_count_with_pattern(browser):
|
||||
count = browser("tabs.count", {"pattern": "http"})
|
||||
assert isinstance(count, int)
|
||||
assert count >= 0
|
||||
|
||||
|
||||
def test_tabs_filter(browser):
|
||||
result = browser("tabs.filter", {"pattern": "http"})
|
||||
assert isinstance(result, list)
|
||||
for tab in result:
|
||||
assert "http" in tab.get("url", "")
|
||||
|
||||
|
||||
def test_tabs_query(browser):
|
||||
result = browser("tabs.query", {"search": "a"})
|
||||
assert isinstance(result, list)
|
||||
|
||||
|
||||
def test_tabs_active_exists(browser):
|
||||
tabs = browser("tabs.list")
|
||||
active = [t for t in tabs if t.get("active")]
|
||||
assert len(active) >= 1, "Expected at least one active tab"
|
||||
|
||||
|
||||
def test_tabs_active_in_window(browser):
|
||||
active = next(t for t in browser("tabs.list") if t.get("active"))
|
||||
result = browser("tabs.active_in_window", {"windowId": active["windowId"]})
|
||||
assert result["id"] == active["id"]
|
||||
assert result["windowId"] == active["windowId"]
|
||||
|
||||
|
||||
def test_tabs_status(browser):
|
||||
result = browser("tabs.status", {})
|
||||
assert isinstance(result, dict)
|
||||
assert "id" in result
|
||||
assert "muted" in result
|
||||
|
||||
|
||||
def test_tabs_html(browser, http_tab):
|
||||
html = browser("tabs.html", {"tabId": http_tab["id"]})
|
||||
assert isinstance(html, str)
|
||||
assert len(html) > 0
|
||||
assert "<html" in html.lower() or "<!doctype" in html.lower()
|
||||
|
||||
|
||||
def test_tabs_close_by_id(browser):
|
||||
result = browser("navigate.open", {"url": "https://example.com", "background": True})
|
||||
tab_id = result["id"]
|
||||
@@ -74,7 +65,6 @@ def test_tabs_close_by_id(browser):
|
||||
tabs = browser("tabs.list")
|
||||
assert tab_id not in [t["id"] for t in tabs]
|
||||
|
||||
|
||||
def test_tabs_dedupe(browser):
|
||||
# Open the same URL twice
|
||||
r1 = browser("navigate.open", {"url": "https://example.com", "background": True})
|
||||
@@ -97,13 +87,11 @@ def test_tabs_dedupe(browser):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def test_tabs_sort(browser):
|
||||
result = browser("tabs.sort", {"by": "domain"})
|
||||
# No error and at least returns something (None or dict)
|
||||
assert result is None or isinstance(result, dict)
|
||||
|
||||
|
||||
def test_tabs_move_forward(browser):
|
||||
r1 = 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": id2})
|
||||
|
||||
|
||||
def test_tabs_merge_windows_no_crash(browser):
|
||||
result = browser("tabs.merge_windows")
|
||||
assert isinstance(result, dict)
|
||||
assert "moved" in result
|
||||
|
||||
|
||||
def test_tabs_mute_and_unmute(browser, http_tab):
|
||||
muted = browser("tabs.mute", {"tabId": http_tab["id"]})
|
||||
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"]})
|
||||
assert status["muted"] is False
|
||||
|
||||
|
||||
def test_tabs_mute_requires_explicit_tab_when_multiple_tabs_open(browser):
|
||||
opened = browser("navigate.open", {"url": "https://example.com", "background": True})
|
||||
try:
|
||||
|
||||
@@ -4,7 +4,7 @@ requires-python = ">=3.10"
|
||||
|
||||
[[package]]
|
||||
name = "browser-cli"
|
||||
version = "0.9.8"
|
||||
version = "0.10.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -15,6 +15,7 @@ dependencies = [
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-cov" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
@@ -25,7 +26,10 @@ requires-dist = [
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "pytest", specifier = ">=8" }]
|
||||
dev = [
|
||||
{ name = "pytest", specifier = ">=8" },
|
||||
{ name = "pytest-cov", specifier = ">=7.1.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
@@ -111,14 +115,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.3"
|
||||
version = "8.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ 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 = [
|
||||
{ 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]]
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "cryptography"
|
||||
version = "48.0.0"
|
||||
@@ -213,14 +335,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "4.0.0"
|
||||
version = "4.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ 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 = [
|
||||
{ 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]]
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "rich"
|
||||
version = "15.0.0"
|
||||
|
||||
Reference in New Issue
Block a user