Compare commits

...
6 Commits
Author SHA1 Message Date
daniel156161 34aaf78c66 chore(deps): update python and node dependencies
Testing / test (push) Successful in 28s
Testing / remote-protocol-compat (0.16.0) (push) Successful in 27s
Testing / remote-protocol-compat (0.15.0) (push) Successful in 23s
- Refresh uv.lock with uv sync --upgrade for all resolved Python deps.
- Bump anyio 4.14.2 to 4.15.1 and coverage 7.15.4 to 7.16.0 in the lock.
- Bump pydantic 2.13.4 to 2.13.5 and pydantic-core 2.46.4 to 2.46.5.
- Bump sse-starlette 3.4.8 to 3.4.11 and wcwidth 0.8.2 to 0.8.3.
- Leave direct Python dependency ranges in pyproject.toml untouched.
- Update extension build tooling to esbuild 0.28.2 and typescript 7.0.2.
- Update @types/chrome to 0.2.9 for current MV3 typings.

- Bump n8n-nodes-browser-cli from 0.3.1 to 0.3.2 after the dep refresh.
- Update the node to @noble/post-quantum 0.7.1 and @types/node 26.4.1.
- Pin n8n-workflow dev dependency to 2.37.4 instead of the floating star range.
- The star range resolved to the 2.38.1 beta while npm latest points at 2.16.0.
- 2.37.4 is the current stable dist-tag and audits clean.
- Keep peerDependencies.n8n-workflow as star so host installs stay flexible.
- Switch the node tsconfig to module/moduleResolution Node16.
- TypeScript 7 removed moduleResolution node10, which broke the old build.

- Verified with uv run pytest -q: 639 passed, 98 skipped.
- Verified with npm run check:extension: typecheck, build and 37 tests pass.
- Verified the node with npm ci, npm run build and npm test: 42 tests pass.
- npm audit --audit-level=moderate reports no vulnerabilities in both packages.
2026-09-07 11:05:17 +02:00
daniel156161 914508e2db feat(read): target any tab for page info and extraction
Testing / test (push) Successful in 40s
Testing / remote-protocol-compat (0.16.0) (push) Successful in 31s
Testing / remote-protocol-compat (0.15.0) (push) Successful in 39s
- Add --tab option to all extract commands and page info
- Thread tab_id through the SDK extract and page namespaces
- Route page.info with a tab_id to tabs.status for cross-tab metadata
- Accept tab_id in the MCP page_info, extract_text, extract_markdown tools
- Forward tabId in the extension page.info and extract.html handlers
- Keep the active tab as default when no tab is given

- Cover tab-scoped reads in API, CLI, and MCP tests
- Bump package and extension version to 0.16.7
- Refresh uv.lock with current dependency versions
2026-08-28 11:19:02 +02:00
daniel156161 6352d9994e Bound extension job and window-alias state
Testing / test (push) Successful in 48s
Testing / remote-protocol-compat (0.16.0) (push) Successful in 39s
Testing / remote-protocol-compat (0.15.0) (push) Successful in 41s
Two service-worker leaks. Running jobs were only bounded by their watchdog, so
a command flood could hold many timers and results for up to JOB_TIMEOUT_MS;
cap concurrent jobs at 32 and reject beyond that, since a clear error beats an
unresponsive worker. Window aliases were never removed, so storage kept an
entry for every window the user had ever renamed.

Prune aliases against the live window set on list, rename, and close rather
than only on close, because windows also disappear without going through
windows.close.

Also extract the repeated job summary in perf.status into a helper.
2026-08-09 20:49:37 +02:00
daniel156161 541b950519 Bound remote pool and rate-limiter memory
Both tables grew one entry per distinct key and never shrank, so a long-running
SDK client or a public serve process accumulated state for every endpoint or
identity it had ever seen. GC cannot reclaim them while the pool and limiter
still reference them.

Cap the pool at 64 endpoint buckets and the limiter at 4096 buckets. Both evict
useless state first: connections past the idle timeout the server has likely
dropped anyway, and buckets that have fully refilled, which carry no throttling
information. Only then fall back to evicting the oldest entry.

Evicting a limiter bucket resets throttling for that identity, which is the
deliberate trade: an attacker cycling identities faster than they go idle can
regain tokens, but unbounded growth would take the process down instead.
2026-08-09 20:49:35 +02:00
daniel156161 1b32410575 Isolate the remote registry in tests
Testing / remote-protocol-compat (0.16.0) (push) Successful in 40s
Testing / remote-protocol-compat (0.15.0) (push) Successful in 42s
Testing / test (push) Successful in 49s
Endpoint resolution consults remembered remotes, so tests that assert the
bare-domain :443 default failed on any machine with an explicit-port remote
remembered for the same host, while passing in CI. That made a real behavior
difference look like a local-only flake.

Isolate the registry for the whole suite instead of patching the two affected
tests, since any test touching endpoint resolution has the same hidden
dependency. Tests that need remembered entries still override the path.

Also replace the hardcoded personal remote host with a documentation domain.
2026-08-09 20:45:56 +02:00
daniel156161 581cd73cac Default MCP tab tools to the active tab
Testing / remote-protocol-compat (0.15.0) (push) Successful in 30s
Testing / test (push) Successful in 40s
Testing / remote-protocol-compat (0.16.0) (push) Successful in 24s
Requiring an explicit tab_id forced every navigate or close through a
preceding tabs_list call, which costs an MCP client a full round trip just to
learn the ID the browser already considers current.

navigate and tabs_close now resolve the active tab when tab_id is omitted,
matching the screenshot tool. Resolution is explicit rather than forwarding
None into the SDK, so the acting tool knows which tab it touched; tabs_close
reports it, since closing the wrong tab is not recoverable.

This stays in the MCP layer: the SDK and CLI signatures are unchanged.
2026-08-09 20:41:41 +02:00
29 changed files with 2761 additions and 915 deletions
+5
View File
@@ -174,6 +174,11 @@ The server is stateless at the MCP layer. Every tool call creates a fresh
`browser`, `remote`, and `key` on a tool call when a specific local profile or `browser`, `remote`, and `key` on a tool call when a specific local profile or
authenticated browser-cli remote is required. authenticated browser-cli remote is required.
`browser_navigate`, `browser_tabs_close`, and `browser_screenshot` take an
optional `tab_id` and act on the active tab when it is omitted, so a caller
does not need a preceding `browser_tabs_list` round trip. `browser_tabs_close`
reports the tab it closed.
Available tools: Available tools:
- `browser_tabs_list`, `browser_tabs_open`, `browser_tabs_close` - `browser_tabs_list`, `browser_tabs_open`, `browser_tabs_close`
- `browser_navigate`, `browser_page_info` - `browser_navigate`, `browser_page_info`
+25 -19
View File
@@ -1,7 +1,7 @@
import json import json
import click import click
from browser_cli.commands import client_from_ctx, handle_errors from browser_cli.commands import client_from_ctx, handle_errors, tab_option
from rich.console import Console from rich.console import Console
from rich.table import Table from rich.table import Table
@@ -12,10 +12,11 @@ def extract_group():
"""Extract content from the active tab.""" """Extract content from the active tab."""
@extract_group.command("links") @extract_group.command("links")
@tab_option
@handle_errors @handle_errors
def extract_links(): def extract_links(tab_id):
"""Extract all links from the active tab.""" """Extract all links from the active tab or --tab."""
links = client_from_ctx().extract.links() links = client_from_ctx().extract.links(tab_id)
if not links: if not links:
console.print("[yellow]No links found[/yellow]") console.print("[yellow]No links found[/yellow]")
return return
@@ -27,10 +28,11 @@ def extract_links():
console.print(table) console.print(table)
@extract_group.command("images") @extract_group.command("images")
@tab_option
@handle_errors @handle_errors
def extract_images(): def extract_images(tab_id):
"""Extract all images from the active tab.""" """Extract all images from the active tab or --tab."""
images = client_from_ctx().extract.images() images = client_from_ctx().extract.images(tab_id)
if not images: if not images:
console.print("[yellow]No images found[/yellow]") console.print("[yellow]No images found[/yellow]")
return return
@@ -42,29 +44,33 @@ def extract_images():
console.print(table) console.print(table)
@extract_group.command("text") @extract_group.command("text")
@tab_option
@handle_errors @handle_errors
def extract_text(): def extract_text(tab_id):
"""Extract all visible text from the active tab.""" """Extract all visible text from the active tab or --tab."""
console.print(client_from_ctx().extract.text()) console.print(client_from_ctx().extract.text(tab_id))
@extract_group.command("json") @extract_group.command("json")
@click.argument("selector") @click.argument("selector")
@tab_option
@handle_errors @handle_errors
def extract_json(selector): def extract_json(selector, tab_id):
"""Parse and pretty-print JSON content inside SELECTOR.""" """Parse and pretty-print JSON content inside SELECTOR in the active tab or --tab."""
data = client_from_ctx().extract.json(selector) data = client_from_ctx().extract.json(selector, tab_id)
console.print_json(json.dumps(data)) console.print_json(json.dumps(data))
@extract_group.command("html") @extract_group.command("html")
@tab_option
@handle_errors @handle_errors
def extract_html(): def extract_html(tab_id):
"""Print the full HTML of the active tab to stdout.""" """Print the full HTML of the active tab or --tab to stdout."""
click.echo(client_from_ctx().extract.html()) click.echo(client_from_ctx().extract.html(tab_id))
@extract_group.command("markdown") @extract_group.command("markdown")
@click.option("--selector", help="Extract only the DOM subtree matching this CSS selector.") @click.option("--selector", help="Extract only the DOM subtree matching this CSS selector.")
@tab_option
@handle_errors @handle_errors
def extract_markdown(selector): def extract_markdown(selector, tab_id):
"""Extract the page's main content as Markdown.""" """Extract the page's main content as Markdown from the active tab or --tab."""
markdown = client_from_ctx().extract.markdown(selector) markdown = client_from_ctx().extract.markdown(selector, tab_id)
click.echo(markdown or "", nl=not (markdown or "").endswith("\n")) click.echo(markdown or "", nl=not (markdown or "").endswith("\n"))
+5 -4
View File
@@ -1,5 +1,5 @@
import click import click
from browser_cli.commands import client_from_ctx, handle_errors from browser_cli.commands import client_from_ctx, handle_errors, tab_option
from rich.console import Console from rich.console import Console
from rich.table import Table from rich.table import Table
@@ -10,10 +10,11 @@ def page_group():
"""Inspect current page metadata.""" """Inspect current page metadata."""
@page_group.command("info") @page_group.command("info")
@tab_option
@handle_errors @handle_errors
def page_info(): def page_info(tab_id):
"""Show title, URL, readyState, language, and meta tags of the active tab.""" """Show title, URL, readyState, language, and meta tags of the active tab or --tab."""
info = client_from_ctx().page.info() info = client_from_ctx().page.info(tab_id)
table = Table(show_header=False) table = Table(show_header=False)
table.add_column("Field", style="bold cyan", no_wrap=True) table.add_column("Field", style="bold cyan", no_wrap=True)
table.add_column("Value") table.add_column("Value")
+23 -14
View File
@@ -16,6 +16,7 @@ from urllib.parse import urlsplit
from browser_cli import BrowserCLI from browser_cli import BrowserCLI
from browser_cli.mcp.naming import resolve_tool_prefix, tool_name from browser_cli.mcp.naming import resolve_tool_prefix, tool_name
from browser_cli.mcp.serialization import structured from browser_cli.mcp.serialization import structured
from browser_cli.mcp.targets import resolve_tab_id
ClientFactory = Callable[..., BrowserCLI] ClientFactory = Callable[..., BrowserCLI]
@@ -93,55 +94,63 @@ def create_server(*, client_factory: ClientFactory = BrowserCLI, tool_prefix: st
@mcp.tool(name=tool_name("tabs_close", prefix)) @mcp.tool(name=tool_name("tabs_close", prefix))
def tabs_close( def tabs_close(
tab_id: int, tab_id: int | None = None,
browser: str | None = None, browser: str | None = None,
remote: str | None = None, remote: str | None = None,
key: str | None = None, key: str | None = None,
) -> dict[str, int]: ) -> dict[str, int]:
"""Close one tab by its current ID. This changes the user's real browser.""" """Close a tab, defaulting to the active tab. This changes the real browser."""
closed = _client(client_factory, browser, remote, key).tabs.close(tab_id) client = _client(client_factory, browser, remote, key)
return {"closed": closed} target = resolve_tab_id(client, tab_id)
return {"closed": client.tabs.close(target), "tab_id": target}
@mcp.tool(name=tool_name("navigate", prefix)) @mcp.tool(name=tool_name("navigate", prefix))
def navigate( def navigate(
tab_id: int,
url: str, url: str,
tab_id: int | None = None,
browser: str | None = None, browser: str | None = None,
remote: str | None = None, remote: str | None = None,
key: str | None = None, key: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Navigate an existing tab to a URL and return a fresh tab snapshot.""" """Navigate a tab to a URL, defaulting to the active tab, and return it."""
client = _client(client_factory, browser, remote, key) client = _client(client_factory, browser, remote, key)
client.nav.to(tab_id, url) target = resolve_tab_id(client, tab_id)
return structured(client.tabs.status(tab_id)) client.nav.to(target, url)
return structured(client.tabs.status(target))
@mcp.tool(name=tool_name("page_info", prefix)) @mcp.tool(name=tool_name("page_info", prefix))
def page_info( def page_info(
tab_id: int | None = None,
browser: str | None = None, browser: str | None = None,
remote: str | None = None, remote: str | None = None,
key: str | None = None, key: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return title, URL, readiness, language, and metadata for the active page.""" """Return title, URL, readiness, language, and metadata for the active page or tab_id."""
return structured(_client(client_factory, browser, remote, key).page.info()) client = _client(client_factory, browser, remote, key)
if tab_id is not None:
return structured(client.tabs.status(tab_id))
return structured(client.page.info())
@mcp.tool(name=tool_name("extract_text", prefix)) @mcp.tool(name=tool_name("extract_text", prefix))
def extract_text( def extract_text(
tab_id: int | None = None,
browser: str | None = None, browser: str | None = None,
remote: str | None = None, remote: str | None = None,
key: str | None = None, key: str | None = None,
) -> str: ) -> str:
"""Extract plain text from the active page.""" """Extract plain text from the active page or tab_id."""
return _client(client_factory, browser, remote, key).extract.text() return _client(client_factory, browser, remote, key).extract.text(tab_id)
@mcp.tool(name=tool_name("extract_markdown", prefix)) @mcp.tool(name=tool_name("extract_markdown", prefix))
def extract_markdown( def extract_markdown(
selector: str | None = None, selector: str | None = None,
tab_id: int | None = None,
browser: str | None = None, browser: str | None = None,
remote: str | None = None, remote: str | None = None,
key: str | None = None, key: str | None = None,
) -> str: ) -> str:
"""Extract clean Markdown from the active page or an optional CSS selector.""" """Extract clean Markdown from the active page or tab_id, optionally scoped by CSS selector."""
return _client(client_factory, browser, remote, key).extract.markdown(selector) return _client(client_factory, browser, remote, key).extract.markdown(selector, tab_id)
@mcp.tool(name=tool_name("dom_query", prefix)) @mcp.tool(name=tool_name("dom_query", prefix))
def dom_query( def dom_query(
+16
View File
@@ -0,0 +1,16 @@
"""Tab targeting for the MCP surface.
MCP callers pay a full round trip for every extra tool call, so tools that act
on a tab accept an optional ``tab_id`` and fall back to the browser's current
active tab. Resolution happens here rather than by forwarding ``None`` into the
SDK, so the acting tool always knows which tab it touched and can report it.
"""
from __future__ import annotations
from browser_cli import BrowserCLI
def resolve_tab_id(client: BrowserCLI, tab_id: int | None) -> int:
"""Return *tab_id*, or the ID of the currently active tab when it is ``None``."""
if tab_id is not None:
return tab_id
return client.tabs.active().id
+23
View File
@@ -26,6 +26,7 @@ from browser_cli.framing import frame
# hand back one the server has just timed out and closed. # hand back one the server has just timed out and closed.
_MAX_IDLE_SECONDS = max(5, REMOTE_SESSION_IDLE_TIMEOUT - 5) _MAX_IDLE_SECONDS = max(5, REMOTE_SESSION_IDLE_TIMEOUT - 5)
_MAX_PER_ENDPOINT = 8 _MAX_PER_ENDPOINT = 8
_MAX_ENDPOINTS = 64
class PooledConnection: class PooledConnection:
__slots__ = ("sock", "secret", "last_used") __slots__ = ("sock", "secret", "last_used")
@@ -56,10 +57,32 @@ def checkout(endpoint: str) -> PooledConnection | None:
_close(conn.sock) # too old — assume the server has dropped it _close(conn.sock) # too old — assume the server has dropped it
return None return None
def _prune_endpoints_locked(now: float) -> None:
"""Keep the number of endpoint buckets bounded for long-running SDK users."""
for endpoint, bucket in list(_POOL.items()):
fresh = [conn for conn in bucket if now - conn.last_used <= _MAX_IDLE_SECONDS]
if fresh:
_POOL[endpoint] = fresh
else:
for conn in bucket:
_close(conn.sock)
_POOL.pop(endpoint, None)
while len(_POOL) >= _MAX_ENDPOINTS:
oldest_endpoint, bucket = min(
_POOL.items(),
key=lambda item: min(conn.last_used for conn in item[1]) if item[1] else 0.0,
)
for conn in bucket:
_close(conn.sock)
_POOL.pop(oldest_endpoint, None)
def checkin(endpoint: str, conn: PooledConnection) -> None: def checkin(endpoint: str, conn: PooledConnection) -> None:
"""Return a still-healthy connection to the pool for reuse.""" """Return a still-healthy connection to the pool for reuse."""
conn.last_used = time.monotonic() conn.last_used = time.monotonic()
with _LOCK: with _LOCK:
if endpoint not in _POOL and len(_POOL) >= _MAX_ENDPOINTS:
_prune_endpoints_locked(conn.last_used)
bucket = _POOL.setdefault(endpoint, []) bucket = _POOL.setdefault(endpoint, [])
if len(bucket) >= _MAX_PER_ENDPOINT: if len(bucket) >= _MAX_PER_ENDPOINT:
_close(conn.sock) _close(conn.sock)
+25 -23
View File
@@ -131,39 +131,41 @@ class DomNS(Namespace):
}) })
class ExtractNS(Namespace): class ExtractNS(Namespace):
"""Extract structured content from the active tab.""" """Extract structured content from the active (or specified) tab."""
@sdk_command("extract.links", default=[]) @sdk_command("extract.links", lambda self, tab_id=None: {"tabId": tab_id}, default=[])
def links(self) -> list[dict]: def links(self, tab_id: int | None = None) -> list[dict]:
"""Return links from the active tab.""" """Return links from the active tab or *tab_id*."""
@sdk_command("extract.images", default=[]) @sdk_command("extract.images", lambda self, tab_id=None: {"tabId": tab_id}, default=[])
def images(self) -> list[dict]: def images(self, tab_id: int | None = None) -> list[dict]:
"""Return images from the active tab.""" """Return images from the active tab or *tab_id*."""
@sdk_command("extract.text", default="") @sdk_command("extract.text", lambda self, tab_id=None: {"tabId": tab_id}, default="")
def text(self) -> str: def text(self, tab_id: int | None = None) -> str:
"""Return plain text from the active tab.""" """Return plain text from the active tab or *tab_id*."""
@sdk_command("extract.json", lambda self, selector: {"selector": selector}) @sdk_command("extract.json", lambda self, selector, tab_id=None: {"selector": selector, "tabId": tab_id})
def json(self, selector: str): def json(self, selector: str, tab_id: int | None = None):
"""Extract JSON-like structured data from a selector.""" """Extract JSON-like structured data from a selector in the active tab or *tab_id*."""
@sdk_command("extract.html", default="") @sdk_command("extract.html", lambda self, tab_id=None: {"tabId": tab_id}, default="")
def html(self) -> str: def html(self, tab_id: int | None = None) -> str:
"""Return the full HTML source of the active tab.""" """Return the full HTML source of the active tab or *tab_id*."""
@sdk_command("extract.markdown", lambda self, selector=None: {"selector": selector}, mapper=_extract_markdown) @sdk_command("extract.markdown", lambda self, selector=None, tab_id=None: {"selector": selector, "tabId": tab_id}, mapper=_extract_markdown)
def markdown(self, selector: str | None = None) -> str: def markdown(self, selector: str | None = None, tab_id: int | None = None) -> str:
"""Extract the page's main content as clean Markdown. """Extract the page's main content as clean Markdown from the active tab or *tab_id*.
The extractor may return either Markdown or raw HTML; both are The extractor may return either Markdown or raw HTML; both are
normalized to Markdown here so SDK and CLI callers get identical output. normalized to Markdown here so SDK and CLI callers get identical output.
""" """
class PageNS(Namespace): class PageNS(Namespace):
"""Inspect the active page.""" """Inspect the active page or a specified tab."""
@sdk_command("page.info", default={}) def info(self, tab_id: int | None = None) -> dict:
def info(self) -> dict: """Return metadata for the active page, or tab status for *tab_id*."""
"""Return title, URL, readyState, lang, and meta tags of the active tab.""" if tab_id is not None:
return self.command("tabs.status", {"tabId": tab_id}) or {}
return self.command("page.info", {}) or {}
+30 -1
View File
@@ -70,19 +70,48 @@ class RateLimiter:
``rate`` is the sustained refill in tokens/second; ``burst`` is the bucket ``rate`` is the sustained refill in tokens/second; ``burst`` is the bucket
capacity (defaults to ``rate``). ``rate <= 0`` disables limiting entirely. capacity (defaults to ``rate``). ``rate <= 0`` disables limiting entirely.
Thread-safe so it can be shared across all connections of one serve process. Thread-safe so it can be shared across all connections of one serve process.
The bucket table is capped. Without that bound, a long-running public server
could retain one entry per ever-seen identity/IP forever; GC cannot reclaim
those entries because the limiter still references them.
""" """
def __init__(self, rate: float, burst: float | None = None) -> None: def __init__(self, rate: float, burst: float | None = None, max_buckets: int = 4096) -> None:
self.rate = float(rate) self.rate = float(rate)
self.capacity = float(burst) if burst is not None else max(float(rate), 1.0) self.capacity = float(burst) if burst is not None else max(float(rate), 1.0)
self.max_buckets = max(1, int(max_buckets))
self._buckets: dict[str, tuple[float, float]] = {} self._buckets: dict[str, tuple[float, float]] = {}
self._lock = threading.Lock() self._lock = threading.Lock()
def _prune_locked(self, now: float) -> None:
"""Drop idle/full buckets, then oldest buckets, until the table is bounded."""
if len(self._buckets) < self.max_buckets or self.rate <= 0:
return
# Once a bucket has fully refilled, keeping it around carries no useful
# throttling state. Use at least 60s so normal active identities are not
# churned out aggressively on high-rate configs.
idle_seconds = max(60.0, (self.capacity / self.rate) * 2)
full_epsilon = 1e-9
for bucket_key, (tokens, last) in list(self._buckets.items()):
refilled = min(self.capacity, tokens + (now - last) * self.rate)
if refilled >= self.capacity - full_epsilon and now - last >= idle_seconds:
self._buckets.pop(bucket_key, None)
# If an attacker keeps creating fresh identities faster than they go idle,
# still keep memory bounded. Evict the oldest identity state; that may reset
# throttling for that identity, but bounded memory is more important here.
while len(self._buckets) >= self.max_buckets:
oldest_key = min(self._buckets, key=lambda k: self._buckets[k][1])
self._buckets.pop(oldest_key, None)
def allow(self, key: str) -> bool: def allow(self, key: str) -> bool:
if self.rate <= 0: if self.rate <= 0:
return True return True
now = time.monotonic() now = time.monotonic()
with self._lock: with self._lock:
if key not in self._buckets and len(self._buckets) >= self.max_buckets:
self._prune_locked(now)
tokens, last = self._buckets.get(key, (self.capacity, now)) tokens, last = self._buckets.get(key, (self.capacity, now))
tokens = min(self.capacity, tokens + (now - last) * self.rate) tokens = min(self.capacity, tokens + (now - last) * self.rate)
if tokens < 1.0: if tokens < 1.0:
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "browser-cli", "name": "browser-cli",
"version": "0.16.6", "version": "0.16.7",
"description": "Control your browser from the terminal or Python SDK", "description": "Control your browser from the terminal or Python SDK",
"browser_specific_settings": { "browser_specific_settings": {
"gecko": { "gecko": {
+10
View File
@@ -15,6 +15,11 @@ import type { Job, Serializable, ErrorLike, DispatchArgs } from '../types';
// jobs only need to survive long enough for the CLI to poll their result. // jobs only need to survive long enough for the CLI to poll their result.
export const MAX_FINISHED_JOBS = 20; export const MAX_FINISHED_JOBS = 20;
// Cap simultaneously running background jobs. A hung job has a watchdog, but a
// command flood could still pin many timers/results for up to JOB_TIMEOUT_MS.
// Rejecting above this bound keeps service-worker memory predictable.
export const MAX_RUNNING_JOBS = 32;
// Watchdog: if a runner never resolves/rejects (e.g. executeScript against a // Watchdog: if a runner never resolves/rejects (e.g. executeScript against a
// dead tab), finalize the job as an error so its persist interval stops instead // dead tab), finalize the job as an error so its persist interval stops instead
// of writing to api.storage.local every second forever. // of writing to api.storage.local every second forever.
@@ -77,6 +82,11 @@ export class JobManager {
} }
async start(command: string, args: DispatchArgs, runner: JobRunner) { async start(command: string, args: DispatchArgs, runner: JobRunner) {
const runningCount = [...this.jobs.values()].filter(job => job.status === "running").length;
if (runningCount >= MAX_RUNNING_JOBS) {
throw new Error(`too many background jobs running (${runningCount}); wait for jobs to finish or cancel one`);
}
const jobId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; const jobId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
const job: Job = { const job: Job = {
id: jobId, id: jobId,
+2 -2
View File
@@ -56,7 +56,7 @@ export class DomCommands extends CommandGroup {
"dom.poll": (a: DomPollArgs) => this.domPoll(a), "dom.poll": (a: DomPollArgs) => this.domPoll(a),
// ── Page ───────────────────────────────────────────────────────────── // ── Page ─────────────────────────────────────────────────────────────
"page.info": () => this.domOp("pageInfo", {}), "page.info": (a: DomArgs) => this.domOp("pageInfo", a),
// ── Extract ────────────────────────────────────────────────────────── // ── Extract ──────────────────────────────────────────────────────────
"extract.links": (a: DomArgs) => this.domOp("extractLinks", a), "extract.links": (a: DomArgs) => this.domOp("extractLinks", a),
@@ -64,7 +64,7 @@ export class DomCommands extends CommandGroup {
"extract.text": (a: DomArgs) => this.domOp("extractText", a), "extract.text": (a: DomArgs) => this.domOp("extractText", a),
"extract.json": (a: DomArgs) => this.domOp("extractJson", a), "extract.json": (a: DomArgs) => this.domOp("extractJson", a),
"extract.markdown": (a: DomArgs) => this.domOp("extractMarkdown", a), "extract.markdown": (a: DomArgs) => this.domOp("extractMarkdown", a),
"extract.html": () => fetchTabHtml(undefined), "extract.html": (a: DomArgs = {}) => fetchTabHtml(a.tabId),
}; };
private async domOp(funcName: string, args: DomArgs = {}) { private async domOp(funcName: string, args: DomArgs = {}) {
+15 -11
View File
@@ -1,7 +1,7 @@
import { getLargeOperationThrottle, getPerformanceProfile, hasAudibleTabs, setPerformanceProfile } from '../core'; import { getLargeOperationThrottle, getPerformanceProfile, hasAudibleTabs, setPerformanceProfile } from '../core';
import { CommandGroup } from '../classes/CommandGroup'; import { CommandGroup } from '../classes/CommandGroup';
import type { CommandEntry } from '../classes/CommandGroup'; import type { CommandEntry } from '../classes/CommandGroup';
import type { PerfSetProfileArgs, JobIdArgs } from '../types'; import type { Job, PerfSetProfileArgs, JobIdArgs } from '../types';
// PerfCommands also owns the jobs.* status/cancel queries: they read the same // PerfCommands also owns the jobs.* status/cancel queries: they read the same
// JobManager (ctx.jobs) that perf.status reports, and there is no dedicated // JobManager (ctx.jobs) that perf.status reports, and there is no dedicated
@@ -15,6 +15,19 @@ export class PerfCommands extends CommandGroup {
"jobs.cancel": (a: JobIdArgs) => this.ctx.jobs.cancel(a), "jobs.cancel": (a: JobIdArgs) => this.ctx.jobs.cancel(a),
}; };
private jobSummary(job: Job) {
return {
id: job.id,
command: job.command,
status: job.status,
phase: job.phase,
current: job.current,
total: job.total,
percent: job.percent,
cancelRequested: job.cancelRequested,
};
}
private async perfStatus() { private async perfStatus() {
const profile = await getPerformanceProfile(); const profile = await getPerformanceProfile();
const audible = await hasAudibleTabs(); const audible = await hasAudibleTabs();
@@ -23,16 +36,7 @@ export class PerfCommands extends CommandGroup {
performanceProfile: profile, performanceProfile: profile,
audible, audible,
throttle, throttle,
jobs: this.ctx.jobs.list().map(job => ({ jobs: this.ctx.jobs.list().map(job => this.jobSummary(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,
})),
}; };
} }
} }
+29 -2
View File
@@ -14,9 +14,31 @@ export class WindowsCommands extends CommandGroup {
"windows.open": (a: WindowsOpenArgs) => this.windowsOpen(a), "windows.open": (a: WindowsOpenArgs) => this.windowsOpen(a),
}; };
private async activeWindowIds(): Promise<Set<number>> {
const windows = await api.windows.getAll({});
return new Set(windows.map(w => w.id).filter(id => typeof id === "number"));
}
private async pruneAliases(activeIds?: Set<number>): Promise<Record<string, string>> {
const aliases = await getAliases();
const liveIds = activeIds || await this.activeWindowIds();
const pruned: Record<string, string> = {};
let changed = false;
for (const [id, alias] of Object.entries(aliases)) {
if (liveIds.has(Number(id))) {
pruned[id] = alias;
} else {
changed = true;
}
}
if (changed) await api.storage.local.set({ windowAliases: pruned });
return pruned;
}
private async windowsList() { private async windowsList() {
const windows = await api.windows.getAll({ populate: true }); const windows = await api.windows.getAll({ populate: true });
const aliases = await getAliases(); const activeIds = new Set(windows.map(w => w.id).filter(id => typeof id === "number"));
const aliases = await this.pruneAliases(activeIds);
return windows.map(w => ({ return windows.map(w => ({
id: w.id, id: w.id,
alias: aliases[w.id] || null, alias: aliases[w.id] || null,
@@ -27,7 +49,7 @@ export class WindowsCommands extends CommandGroup {
} }
private async windowsRename({ windowId, name }: WindowsRenameArgs) { private async windowsRename({ windowId, name }: WindowsRenameArgs) {
const aliases = await getAliases(); const aliases = await this.pruneAliases();
aliases[windowId] = name; aliases[windowId] = name;
await api.storage.local.set({ windowAliases: aliases }); await api.storage.local.set({ windowAliases: aliases });
return { windowId, name }; return { windowId, name };
@@ -35,6 +57,11 @@ export class WindowsCommands extends CommandGroup {
private async windowsClose({ windowId }: WindowsCloseArgs) { private async windowsClose({ windowId }: WindowsCloseArgs) {
await api.windows.remove(windowId); await api.windows.remove(windowId);
const aliases = await this.pruneAliases();
if (windowId in aliases) {
delete aliases[windowId];
await api.storage.local.set({ windowAliases: aliases });
}
return { windowId }; return { windowId };
} }
+16 -1
View File
@@ -1,7 +1,7 @@
// @ts-nocheck // @ts-nocheck
import { test, mock } from 'node:test'; import { test, mock } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { JobManager, JOB_TIMEOUT_MS, MAX_FINISHED_JOBS, pruneFinishedJobs } from '../src/classes/JobManager'; import { JobManager, JOB_TIMEOUT_MS, MAX_FINISHED_JOBS, MAX_RUNNING_JOBS, pruneFinishedJobs } from '../src/classes/JobManager';
import { makeChromeMock } from './chrome-mock'; import { makeChromeMock } from './chrome-mock';
// Drain pending microtasks (finalize() chains several awaits). setImmediate is // Drain pending microtasks (finalize() chains several awaits). setImmediate is
@@ -129,6 +129,21 @@ test('JobManager: a runner that settles after the watchdog cannot resurrect the
mock.timers.reset(); mock.timers.reset();
}); });
test('JobManager: rejects new background jobs above the running-job cap', async () => {
mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
globalThis.chrome = makeChromeMock();
const mgr = new JobManager();
for (let i = 0; i < MAX_RUNNING_JOBS; i++) {
await mgr.start(`running${i}`, {}, () => new Promise(() => {}));
}
await assert.rejects(
() => mgr.start('overflow', {}, async () => 'nope'),
/too many background jobs running/,
);
assert.equal(mgr.list().filter(job => job.status === 'running').length, MAX_RUNNING_JOBS);
mock.timers.reset();
});
test('JobManager: persisted set keeps running jobs even past the finished cap', async () => { test('JobManager: persisted set keeps running jobs even past the finished cap', async () => {
mock.timers.enable({ apis: ['setInterval', 'setTimeout'] }); mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
globalThis.chrome = makeChromeMock(); globalThis.chrome = makeChromeMock();
+69
View File
@@ -0,0 +1,69 @@
// @ts-nocheck
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { WindowsCommands } from '../src/commands/windows';
import { makeChromeMock } from './chrome-mock';
function makeWindowsChromeMock(windows) {
const chrome = makeChromeMock();
chrome.windows = {
getAll: async () => windows,
remove: async () => {},
create: async () => ({ id: 99 }),
};
return chrome;
}
test('windows.list prunes aliases for closed windows', async () => {
globalThis.chrome = makeWindowsChromeMock([
{ id: 1, focused: true, state: 'normal', tabs: [{ id: 10 }] },
{ id: 2, focused: false, state: 'minimized', tabs: [] },
]);
globalThis.chrome.storage.local._store.windowAliases = {
1: 'main',
2: 'side',
999: 'closed',
};
const commands = new WindowsCommands({ jobs: {} });
const result = await commands.commands['windows.list']({});
assert.deepEqual(result.map(w => [w.id, w.alias]), [[1, 'main'], [2, 'side']]);
assert.deepEqual(globalThis.chrome.storage.local._store.windowAliases, { 1: 'main', 2: 'side' });
});
test('windows.rename prunes stale aliases before saving the new name', async () => {
globalThis.chrome = makeWindowsChromeMock([
{ id: 1, focused: true, state: 'normal', tabs: [] },
{ id: 2, focused: false, state: 'normal', tabs: [] },
]);
globalThis.chrome.storage.local._store.windowAliases = {
1: 'main',
999: 'closed',
};
const commands = new WindowsCommands({ jobs: {} });
await commands.commands['windows.rename']({ windowId: 2, name: 'work' });
assert.deepEqual(globalThis.chrome.storage.local._store.windowAliases, { 1: 'main', 2: 'work' });
});
test('windows.close removes the closed window alias immediately', async () => {
let removed = null;
globalThis.chrome = makeWindowsChromeMock([
{ id: 1, focused: true, state: 'normal', tabs: [] },
{ id: 2, focused: false, state: 'normal', tabs: [] },
]);
globalThis.chrome.windows.remove = async id => { removed = id; };
globalThis.chrome.storage.local._store.windowAliases = {
1: 'main',
2: 'side',
999: 'closed',
};
const commands = new WindowsCommands({ jobs: {} });
await commands.commands['windows.close']({ windowId: 2 });
assert.equal(removed, 2);
assert.deepEqual(globalThis.chrome.storage.local._store.windowAliases, { 1: 'main' });
});
+1310 -217
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "n8n-nodes-browser-cli", "name": "n8n-nodes-browser-cli",
"version": "0.3.1", "version": "0.3.2",
"description": "n8n community node that controls a remote browser by talking directly to a browser-cli serve endpoint (Ed25519 + post-quantum encrypted)", "description": "n8n community node that controls a remote browser by talking directly to a browser-cli serve endpoint (Ed25519 + post-quantum encrypted)",
"keywords": [ "keywords": [
"n8n-community-node-package", "n8n-community-node-package",
@@ -31,15 +31,15 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^26.4.1",
"esbuild": "^0.28.0", "esbuild": "^0.28.2",
"n8n-workflow": "*", "n8n-workflow": "2.37.4",
"typescript": "^5.6.0" "typescript": "^7.0.2"
}, },
"peerDependencies": { "peerDependencies": {
"n8n-workflow": "*" "n8n-workflow": "*"
}, },
"dependencies": { "dependencies": {
"@noble/post-quantum": "^0.6.1" "@noble/post-quantum": "^0.7.1"
} }
} }
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2021", "target": "ES2021",
"module": "CommonJS", "module": "Node16",
"moduleResolution": "Node", "moduleResolution": "Node16",
"lib": ["ES2021"], "lib": ["ES2021"],
"outDir": "dist", "outDir": "dist",
"rootDir": ".", "rootDir": ".",
+480 -119
View File
@@ -6,16 +6,16 @@
"": { "": {
"name": "browser-cli-extension-build", "name": "browser-cli-extension-build",
"devDependencies": { "devDependencies": {
"@types/chrome": "^0.1.40", "@types/chrome": "^0.2.9",
"@types/firefox-webext-browser": "^143.0.0", "@types/firefox-webext-browser": "^143.0.0",
"esbuild": "^0.28.0", "esbuild": "^0.28.2",
"typescript": "^6.0.3" "typescript": "^7.0.2"
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -30,9 +30,9 @@
} }
}, },
"node_modules/@esbuild/android-arm": { "node_modules/@esbuild/android-arm": {
"version": "0.28.1", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -47,9 +47,9 @@
} }
}, },
"node_modules/@esbuild/android-arm64": { "node_modules/@esbuild/android-arm64": {
"version": "0.28.1", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -64,9 +64,9 @@
} }
}, },
"node_modules/@esbuild/android-x64": { "node_modules/@esbuild/android-x64": {
"version": "0.28.1", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -81,9 +81,9 @@
} }
}, },
"node_modules/@esbuild/darwin-arm64": { "node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -98,9 +98,9 @@
} }
}, },
"node_modules/@esbuild/darwin-x64": { "node_modules/@esbuild/darwin-x64": {
"version": "0.28.1", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -115,9 +115,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-arm64": { "node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -132,9 +132,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-x64": { "node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -149,9 +149,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm": { "node_modules/@esbuild/linux-arm": {
"version": "0.28.1", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -166,9 +166,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm64": { "node_modules/@esbuild/linux-arm64": {
"version": "0.28.1", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -183,9 +183,9 @@
} }
}, },
"node_modules/@esbuild/linux-ia32": { "node_modules/@esbuild/linux-ia32": {
"version": "0.28.1", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -200,9 +200,9 @@
} }
}, },
"node_modules/@esbuild/linux-loong64": { "node_modules/@esbuild/linux-loong64": {
"version": "0.28.1", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@@ -217,9 +217,9 @@
} }
}, },
"node_modules/@esbuild/linux-mips64el": { "node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
@@ -234,9 +234,9 @@
} }
}, },
"node_modules/@esbuild/linux-ppc64": { "node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -251,9 +251,9 @@
} }
}, },
"node_modules/@esbuild/linux-riscv64": { "node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -268,9 +268,9 @@
} }
}, },
"node_modules/@esbuild/linux-s390x": { "node_modules/@esbuild/linux-s390x": {
"version": "0.28.1", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -285,9 +285,9 @@
} }
}, },
"node_modules/@esbuild/linux-x64": { "node_modules/@esbuild/linux-x64": {
"version": "0.28.1", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -302,9 +302,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-arm64": { "node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -319,9 +319,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-x64": { "node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -336,9 +336,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-arm64": { "node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -353,9 +353,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-x64": { "node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -370,9 +370,9 @@
} }
}, },
"node_modules/@esbuild/openharmony-arm64": { "node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -387,9 +387,9 @@
} }
}, },
"node_modules/@esbuild/sunos-x64": { "node_modules/@esbuild/sunos-x64": {
"version": "0.28.1", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -404,9 +404,9 @@
} }
}, },
"node_modules/@esbuild/win32-arm64": { "node_modules/@esbuild/win32-arm64": {
"version": "0.28.1", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -421,9 +421,9 @@
} }
}, },
"node_modules/@esbuild/win32-ia32": { "node_modules/@esbuild/win32-ia32": {
"version": "0.28.1", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -438,9 +438,9 @@
} }
}, },
"node_modules/@esbuild/win32-x64": { "node_modules/@esbuild/win32-x64": {
"version": "0.28.1", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -455,9 +455,9 @@
} }
}, },
"node_modules/@types/chrome": { "node_modules/@types/chrome": {
"version": "0.1.43", "version": "0.2.9",
"resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.1.43.tgz", "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.2.9.tgz",
"integrity": "sha512-ukH/HhmR6ht+UTX3PLUWJxgJ/RQcK2Foj4lBzsF24SIWsXgqhGuXqjd8FFuwioPP7d/JUKLM4g8GZxw3F4HTcA==", "integrity": "sha512-6rXrDPkkWv4D2Q23HqT+iED4voODU5CpOSc2VFgKyecSFItuEsIBvz9CY6jUQ3dd+Q7zo1bnCE9pKRMtkYut5g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -496,10 +496,350 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@typescript/typescript-aix-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-loong64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-mips64el": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-riscv64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-s390x": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-sunos-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.28.1", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
@@ -510,46 +850,67 @@
"node": ">=18" "node": ">=18"
}, },
"optionalDependencies": { "optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1", "@esbuild/aix-ppc64": "0.28.2",
"@esbuild/android-arm": "0.28.1", "@esbuild/android-arm": "0.28.2",
"@esbuild/android-arm64": "0.28.1", "@esbuild/android-arm64": "0.28.2",
"@esbuild/android-x64": "0.28.1", "@esbuild/android-x64": "0.28.2",
"@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-arm64": "0.28.2",
"@esbuild/darwin-x64": "0.28.1", "@esbuild/darwin-x64": "0.28.2",
"@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.2",
"@esbuild/freebsd-x64": "0.28.1", "@esbuild/freebsd-x64": "0.28.2",
"@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm": "0.28.2",
"@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-arm64": "0.28.2",
"@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-ia32": "0.28.2",
"@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-loong64": "0.28.2",
"@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-mips64el": "0.28.2",
"@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-ppc64": "0.28.2",
"@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-riscv64": "0.28.2",
"@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-s390x": "0.28.2",
"@esbuild/linux-x64": "0.28.1", "@esbuild/linux-x64": "0.28.2",
"@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.2",
"@esbuild/netbsd-x64": "0.28.1", "@esbuild/netbsd-x64": "0.28.2",
"@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.2",
"@esbuild/openbsd-x64": "0.28.1", "@esbuild/openbsd-x64": "0.28.2",
"@esbuild/openharmony-arm64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.2",
"@esbuild/sunos-x64": "0.28.1", "@esbuild/sunos-x64": "0.28.2",
"@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-arm64": "0.28.2",
"@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-ia32": "0.28.2",
"@esbuild/win32-x64": "0.28.1" "@esbuild/win32-x64": "0.28.2"
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "6.0.3", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc"
"tsserver": "bin/tsserver"
}, },
"engines": { "engines": {
"node": ">=14.17" "node": ">=16.20.0"
},
"optionalDependencies": {
"@typescript/typescript-aix-ppc64": "7.0.2",
"@typescript/typescript-darwin-arm64": "7.0.2",
"@typescript/typescript-darwin-x64": "7.0.2",
"@typescript/typescript-freebsd-arm64": "7.0.2",
"@typescript/typescript-freebsd-x64": "7.0.2",
"@typescript/typescript-linux-arm": "7.0.2",
"@typescript/typescript-linux-arm64": "7.0.2",
"@typescript/typescript-linux-loong64": "7.0.2",
"@typescript/typescript-linux-mips64el": "7.0.2",
"@typescript/typescript-linux-ppc64": "7.0.2",
"@typescript/typescript-linux-riscv64": "7.0.2",
"@typescript/typescript-linux-s390x": "7.0.2",
"@typescript/typescript-linux-x64": "7.0.2",
"@typescript/typescript-netbsd-arm64": "7.0.2",
"@typescript/typescript-netbsd-x64": "7.0.2",
"@typescript/typescript-openbsd-arm64": "7.0.2",
"@typescript/typescript-openbsd-x64": "7.0.2",
"@typescript/typescript-sunos-x64": "7.0.2",
"@typescript/typescript-win32-arm64": "7.0.2",
"@typescript/typescript-win32-x64": "7.0.2"
} }
} }
} }
+3 -3
View File
@@ -13,9 +13,9 @@
"package:extension:firefox": "npm run build:extension && python scripts/package_extension.py --firefox" "package:extension:firefox": "npm run build:extension && python scripts/package_extension.py --firefox"
}, },
"devDependencies": { "devDependencies": {
"@types/chrome": "^0.1.40", "@types/chrome": "^0.2.9",
"@types/firefox-webext-browser": "^143.0.0", "@types/firefox-webext-browser": "^143.0.0",
"esbuild": "^0.28.0", "esbuild": "^0.28.2",
"typescript": "^6.0.3" "typescript": "^7.0.2"
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "real-browser-cli" name = "real-browser-cli"
version = "0.16.6" version = "0.16.7"
description = "Control your real running browser from the terminal or Python SDK" description = "Control your real running browser from the terminal or Python SDK"
readme = "README.md" readme = "README.md"
license = { file = "LICENSE" } license = { file = "LICENSE" }
+12
View File
@@ -12,6 +12,18 @@ from browser_cli.remote import pool as _remote_pool
TEST_BROWSER_PROFILE = "testing" TEST_BROWSER_PROFILE = "testing"
@pytest.fixture(autouse=True)
def _isolate_remote_registry(monkeypatch, tmp_path):
"""Point the remembered-remote registry at an empty throwaway file.
Endpoint resolution consults remembered remotes, so without this a developer
who has remembered ``host:8765`` sees different results than CI for the same
code. Tests that need remembered entries still monkeypatch the path themselves.
"""
monkeypatch.setattr(
"browser_cli.remote.registry.REMOTE_REGISTRY_PATH", tmp_path / "empty-remotes.json"
)
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _clear_remote_pool(): def _clear_remote_pool():
"""Close any pooled remote connections between tests so a connection opened """Close any pooled remote connections between tests so a connection opened
+14 -3
View File
@@ -299,22 +299,28 @@ class TestExtract:
result = b.extract.markdown() result = b.extract.markdown()
assert result == "# Title" assert result == "# Title"
mock_send.assert_called_once_with("extract.markdown", {"selector": None}, profile=None, remote=None, key=None) mock_send.assert_called_once_with("extract.markdown", {"selector": None, "tabId": None}, profile=None, remote=None, key=None)
def test_extract_markdown_selector(self, b, mock_send): def test_extract_markdown_selector(self, b, mock_send):
mock_send.return_value = "## Post" mock_send.return_value = "## Post"
assert b.extract.markdown("article") == "## Post" assert b.extract.markdown("article") == "## Post"
mock_send.assert_called_once_with("extract.markdown", {"selector": "article"}, profile=None, remote=None, key=None) mock_send.assert_called_once_with("extract.markdown", {"selector": "article", "tabId": None}, profile=None, remote=None, key=None)
def test_extract_links(self, b, mock_send): def test_extract_links(self, b, mock_send):
mock_send.return_value = [{"href": "https://x"}] mock_send.return_value = [{"href": "https://x"}]
assert b.extract.links() == [{"href": "https://x"}] assert b.extract.links() == [{"href": "https://x"}]
mock_send.assert_called_once_with("extract.links", {}, profile=None, remote=None, key=None) mock_send.assert_called_once_with("extract.links", {"tabId": None}, profile=None, remote=None, key=None)
def test_extract_text_none(self, b, mock_send): def test_extract_text_none(self, b, mock_send):
mock_send.return_value = None mock_send.return_value = None
assert b.extract.text() == "" assert b.extract.text() == ""
mock_send.assert_called_once_with("extract.text", {"tabId": None}, profile=None, remote=None, key=None)
def test_extract_markdown_tab_id(self, b, mock_send):
mock_send.return_value = "# Specific"
assert b.extract.markdown(tab_id=42) == "# Specific"
mock_send.assert_called_once_with("extract.markdown", {"selector": None, "tabId": 42}, profile=None, remote=None, key=None)
# ── Tabs ────────────────────────────────────────────────────────────────────── # ── Tabs ──────────────────────────────────────────────────────────────────────
@@ -869,6 +875,11 @@ class TestPageStorageCookies:
assert b.page.info() == {"title": "X"} assert b.page.info() == {"title": "X"}
mock_send.assert_called_once_with("page.info", {}, profile=None, remote=None, key=None) mock_send.assert_called_once_with("page.info", {}, profile=None, remote=None, key=None)
def test_page_info_tab_id(self, b, mock_send):
mock_send.return_value = {"title": "X"}
assert b.page.info(tab_id=42) == {"title": "X"}
mock_send.assert_called_once_with("tabs.status", {"tabId": 42}, profile=None, remote=None, key=None)
def test_storage_get(self, b, mock_send): def test_storage_get(self, b, mock_send):
mock_send.return_value = "v" mock_send.return_value = "v"
assert b.storage.get("k") == "v" assert b.storage.get("k") == "v"
+18 -2
View File
@@ -796,13 +796,29 @@ def test_windows_open_passes_url():
assert "https://example.com" in result.output assert "https://example.com" in result.output
send_command.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None, remote=None, key=None) send_command.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None, remote=None, key=None)
def test_page_info_command_with_tab():
with patch("browser_cli.send_command", return_value={"title": "Example", "url": "https://example.com"}) as send_command:
result = CliRunner().invoke(main, ["page", "info", "--tab", "42"])
assert result.exit_code == 0
assert "Example" in result.output
send_command.assert_called_once_with("tabs.status", {"tabId": 42}, profile=None, remote=None, key=None)
def test_extract_markdown_command(): def test_extract_markdown_command():
with patch("browser_cli.send_command", return_value="# Title") as send_command: with patch("browser_cli.send_command", return_value="# Title") as send_command:
result = CliRunner().invoke(main, ["extract", "markdown"]) result = CliRunner().invoke(main, ["extract", "markdown"])
assert result.exit_code == 0 assert result.exit_code == 0
assert result.output == "# Title\n" assert result.output == "# Title\n"
send_command.assert_called_once_with("extract.markdown", {"selector": None}, profile=None, remote=None, key=None) send_command.assert_called_once_with("extract.markdown", {"selector": None, "tabId": None}, profile=None, remote=None, key=None)
def test_extract_markdown_command_with_tab():
with patch("browser_cli.send_command", return_value="# Title") as send_command:
result = CliRunner().invoke(main, ["extract", "markdown", "--tab", "42"])
assert result.exit_code == 0
assert result.output == "# Title\n"
send_command.assert_called_once_with("extract.markdown", {"selector": None, "tabId": 42}, profile=None, remote=None, key=None)
def test_extract_markdown_command_with_selector(): def test_extract_markdown_command_with_selector():
with patch("browser_cli.send_command", return_value="## Post") as send_command: with patch("browser_cli.send_command", return_value="## Post") as send_command:
@@ -810,7 +826,7 @@ def test_extract_markdown_command_with_selector():
assert result.exit_code == 0 assert result.exit_code == 0
assert result.output == "## Post\n" assert result.output == "## Post\n"
send_command.assert_called_once_with("extract.markdown", {"selector": "article"}, profile=None, remote=None, key=None) send_command.assert_called_once_with("extract.markdown", {"selector": "article", "tabId": None}, profile=None, remote=None, key=None)
def test_clean_markdown_output_removes_escaped_underscores_and_dashes(): def test_clean_markdown_output_removes_escaped_underscores_and_dashes():
assert _clean_markdown_output(r"hello\_world \- item") == "hello_world - item" assert _clean_markdown_output(r"hello\_world \- item") == "hello_world - item"
+15 -15
View File
@@ -356,7 +356,7 @@ def test_active_browser_targets_includes_remote_targets(monkeypatch, tmp_path):
assert targets[0].display_group == "browser-host.example" assert targets[0].display_group == "browser-host.example"
def test_looks_like_domain(): def test_looks_like_domain():
assert _looks_like_domain("browsercli.yiprawr.dev") is True assert _looks_like_domain("browser-host.example") is True
assert _looks_like_domain("browser-host.example") is True assert _looks_like_domain("browser-host.example") is True
assert _looks_like_domain("sub.domain.org") is True assert _looks_like_domain("sub.domain.org") is True
assert _looks_like_domain("localhost") is False assert _looks_like_domain("localhost") is False
@@ -365,17 +365,17 @@ def test_looks_like_domain():
assert _looks_like_domain("host") is False # no dot assert _looks_like_domain("host") is False # no dot
def test_normalize_endpoint_strips_443_for_domains(): def test_normalize_endpoint_strips_443_for_domains():
assert _normalize_endpoint("browsercli.yiprawr.dev:443") == "browsercli.yiprawr.dev" assert _normalize_endpoint("browser-host.example:443") == "browser-host.example"
assert _normalize_endpoint("browsercli.yiprawr.dev") == "browsercli.yiprawr.dev" assert _normalize_endpoint("browser-host.example") == "browser-host.example"
assert _normalize_endpoint("203.0.113.1:443") == "203.0.113.1:443" # IP: keep port assert _normalize_endpoint("203.0.113.1:443") == "203.0.113.1:443" # IP: keep port
assert _normalize_endpoint("localhost:443") == "localhost:443" # localhost: keep port assert _normalize_endpoint("localhost:443") == "localhost:443" # localhost: keep port
assert _normalize_endpoint("host:8765") == "host:8765" # non-443 port: unchanged assert _normalize_endpoint("host:8765") == "host:8765" # non-443 port: unchanged
assert _normalize_endpoint("browsercli.yiprawr.dev:8765") == "browsercli.yiprawr.dev:8765" assert _normalize_endpoint("browser-host.example:8765") == "browser-host.example:8765"
def test_resolve_connect_endpoint_adds_443_for_domain(): def test_resolve_connect_endpoint_adds_443_for_domain():
assert _resolve_connect_endpoint("browsercli.yiprawr.dev") == "browsercli.yiprawr.dev:443" assert _resolve_connect_endpoint("browser-host.example") == "browser-host.example:443"
assert _resolve_connect_endpoint("browsercli.yiprawr.dev:443") == "browsercli.yiprawr.dev:443" assert _resolve_connect_endpoint("browser-host.example:443") == "browser-host.example:443"
assert _resolve_connect_endpoint("browsercli.yiprawr.dev:8765") == "browsercli.yiprawr.dev:8765" assert _resolve_connect_endpoint("browser-host.example:8765") == "browser-host.example:8765"
assert _resolve_connect_endpoint("host:8765") == "host:8765" assert _resolve_connect_endpoint("host:8765") == "host:8765"
def test_resolve_connect_endpoint_raises_for_bare_non_domain(): def test_resolve_connect_endpoint_raises_for_bare_non_domain():
@@ -399,9 +399,9 @@ def test_send_command_normalizes_domain_port_443(monkeypatch):
monkeypatch.setattr("browser_cli.client.core._send_remote", fake_send_remote) monkeypatch.setattr("browser_cli.client.core._send_remote", fake_send_remote)
result = send_command("tabs.list", remote="browsercli.yiprawr.dev:443") result = send_command("tabs.list", remote="browser-host.example:443")
assert result == "ok" assert result == "ok"
assert sent_to["endpoint"] == "browsercli.yiprawr.dev" # stored/routed without port assert sent_to["endpoint"] == "browser-host.example" # stored/routed without port
def test_send_command_domain_without_port_defaults_to_443(monkeypatch): def test_send_command_domain_without_port_defaults_to_443(monkeypatch):
"""--remote domain (no port) is treated as :443.""" """--remote domain (no port) is treated as :443."""
@@ -420,14 +420,14 @@ def test_send_command_domain_without_port_defaults_to_443(monkeypatch):
monkeypatch.setattr("browser_cli.client.core._send_remote", fake_send_remote) monkeypatch.setattr("browser_cli.client.core._send_remote", fake_send_remote)
result = send_command("tabs.list", remote="browsercli.yiprawr.dev") result = send_command("tabs.list", remote="browser-host.example")
assert result == "ok" assert result == "ok"
assert sent_to["endpoint"] == "browsercli.yiprawr.dev" assert sent_to["endpoint"] == "browser-host.example"
def test_domain_display_name_omits_port(monkeypatch, tmp_path): def test_domain_display_name_omits_port(monkeypatch, tmp_path):
"""Domain endpoints stored without :443 display as 'domain:profile', not 'domain:443:profile'.""" """Domain endpoints stored without :443 display as 'domain:profile', not 'domain:443:profile'."""
remotes_path = tmp_path / "remotes.json" remotes_path = tmp_path / "remotes.json"
endpoint = "browsercli.yiprawr.dev" endpoint = "browser-host.example"
remotes_path.write_text(json.dumps({endpoint: {}}), encoding="utf-8") remotes_path.write_text(json.dumps({endpoint: {}}), encoding="utf-8")
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", tmp_path / "missing-registry.json") monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", tmp_path / "missing-registry.json")
monkeypatch.setattr("browser_cli.remote.registry.REMOTE_REGISTRY_PATH", remotes_path) monkeypatch.setattr("browser_cli.remote.registry.REMOTE_REGISTRY_PATH", remotes_path)
@@ -440,13 +440,13 @@ def test_domain_display_name_omits_port(monkeypatch, tmp_path):
targets = active_browser_targets() targets = active_browser_targets()
assert len(targets) == 1 assert len(targets) == 1
assert targets[0].display_name == "browsercli.yiprawr.dev:automatisation" assert targets[0].display_name == "browser-host.example:automatisation"
assert targets[0].remote == endpoint assert targets[0].remote == endpoint
def test_domain_display_name_backward_compat_with_stored_443(monkeypatch, tmp_path): def test_domain_display_name_backward_compat_with_stored_443(monkeypatch, tmp_path):
"""Old remotes.json with :443 still displays cleanly without the port.""" """Old remotes.json with :443 still displays cleanly without the port."""
remotes_path = tmp_path / "remotes.json" remotes_path = tmp_path / "remotes.json"
endpoint = "browsercli.yiprawr.dev:443" # old format endpoint = "browser-host.example:443" # old format
remotes_path.write_text(json.dumps({endpoint: {}}), encoding="utf-8") remotes_path.write_text(json.dumps({endpoint: {}}), encoding="utf-8")
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", tmp_path / "missing-registry.json") monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", tmp_path / "missing-registry.json")
monkeypatch.setattr("browser_cli.remote.registry.REMOTE_REGISTRY_PATH", remotes_path) monkeypatch.setattr("browser_cli.remote.registry.REMOTE_REGISTRY_PATH", remotes_path)
@@ -459,7 +459,7 @@ def test_domain_display_name_backward_compat_with_stored_443(monkeypatch, tmp_pa
targets = active_browser_targets() targets = active_browser_targets()
assert len(targets) == 1 assert len(targets) == 1
assert targets[0].display_name == "browsercli.yiprawr.dev:automatisation" assert targets[0].display_name == "browser-host.example:automatisation"
def test_send_command_explicit_key_does_not_persist_remote_key(monkeypatch, tmp_path): def test_send_command_explicit_key_does_not_persist_remote_key(monkeypatch, tmp_path):
"""--key is a one-shot override; use `browser-cli remote trust` to remember it.""" """--key is a one-shot override; use `browser-cli remote trust` to remember it."""
+43 -5
View File
@@ -26,6 +26,7 @@ class FakeClient:
list=self.tabs_list, list=self.tabs_list,
open=self.tabs_open, open=self.tabs_open,
close=self.tabs_close, close=self.tabs_close,
active=self.tabs_active,
status=self.tabs_status, status=self.tabs_status,
screenshot=self.tabs_screenshot, screenshot=self.tabs_screenshot,
) )
@@ -46,6 +47,10 @@ class FakeClient:
self.calls.append(("close", tab_id)) self.calls.append(("close", tab_id))
return 1 return 1
def tabs_active(self):
self.calls.append(("active",))
return SimpleNamespace(id=7)
def tabs_status(self, tab_id): def tabs_status(self, tab_id):
self.calls.append(("status", tab_id)) self.calls.append(("status", tab_id))
return {"id": tab_id, "title": "Navigated", "url": "https://example.com/next"} return {"id": tab_id, "title": "Navigated", "url": "https://example.com/next"}
@@ -57,14 +62,17 @@ class FakeClient:
def navigate_to(self, tab_id, url): def navigate_to(self, tab_id, url):
self.calls.append(("navigate", tab_id, url)) self.calls.append(("navigate", tab_id, url))
def page_info(self): def page_info(self, tab_id=None):
return {"title": "Example", "url": "https://example.com"} self.calls.append(("page_info", tab_id))
return {"title": "Example", "url": "https://example.com", "tab_id": tab_id}
def extract_text(self): def extract_text(self, tab_id=None):
self.calls.append(("extract_text", tab_id))
return "Page text" return "Page text"
def extract_markdown(self, selector=None): def extract_markdown(self, selector=None, tab_id=None):
return f"# Page {selector or ''}".rstrip() self.calls.append(("extract_markdown", selector, tab_id))
return f"# Page {selector or ''} {tab_id or ''}".rstrip()
def dom_query(self, selector): def dom_query(self, selector):
return [{"tag": "button", "selector": selector}] return [{"tag": "button", "selector": selector}]
@@ -107,6 +115,7 @@ async def test_each_tool_call_constructs_a_fresh_targeted_client(client, monkeyp
assert second.structured_content == { assert second.structured_content == {
"title": "Example", "title": "Example",
"url": "https://example.com", "url": "https://example.com",
"tab_id": None,
} }
assert len(FakeClient.instances) == 2 assert len(FakeClient.instances) == 2
assert FakeClient.instances[0].target == ("work", "browser-host.example:443", "agent") assert FakeClient.instances[0].target == ("work", "browser-host.example:443", "agent")
@@ -178,6 +187,35 @@ async def test_mutating_tools_use_sdk_and_return_fresh_state(client):
("navigate", 8, "https://example.com/next"), ("status", 8) ("navigate", 8, "https://example.com/next"), ("status", 8)
] ]
@pytest.mark.anyio
async def test_tab_tools_default_to_the_active_tab(client):
navigated = await client.call_tool("browser_navigate", {"url": "https://example.com/next"})
closed = await client.call_tool("browser_tabs_close", {})
assert navigated.structured_content["id"] == 7
assert closed.structured_content == {"closed": 1, "tab_id": 7}
assert FakeClient.instances[0].calls == [
("active",), ("navigate", 7, "https://example.com/next"), ("status", 7)
]
assert FakeClient.instances[1].calls == [("active",), ("close", 7)]
@pytest.mark.anyio
async def test_read_tools_accept_explicit_tab_id(client):
info = await client.call_tool("browser_page_info", {"tab_id": 42})
text = await client.call_tool("browser_extract_text", {"tab_id": 42})
markdown = await client.call_tool("browser_extract_markdown", {"selector": "main", "tab_id": 42})
assert info.structured_content == {
"id": 42,
"title": "Navigated",
"url": "https://example.com/next",
}
assert text.content[0].text == "Page text"
assert markdown.content[0].text == "# Page main 42"
assert FakeClient.instances[0].calls == [("status", 42)]
assert FakeClient.instances[1].calls == [("extract_text", 42)]
assert FakeClient.instances[2].calls == [("extract_markdown", "main", 42)]
@pytest.mark.anyio @pytest.mark.anyio
async def test_screenshot_returns_image_content(client): async def test_screenshot_returns_image_content(client):
result = await client.call_tool("browser_screenshot", {"tab_id": 7, "format": "png"}) result = await client.call_tool("browser_screenshot", {"tab_id": 7, "format": "png"})
+33
View File
@@ -43,6 +43,39 @@ def test_checkin_caps_pool_size():
b.close() b.close()
pool.close_all() pool.close_all()
def test_checkin_caps_endpoint_buckets():
pool.close_all()
peers = []
try:
for i in range(pool._MAX_ENDPOINTS + 5):
a, b = _socketpair()
peers.append(b)
pool.checkin(f"host-{i}:443", pool.PooledConnection(a, b"secret"))
assert len(pool._POOL) <= pool._MAX_ENDPOINTS
finally:
for peer in peers:
peer.close()
pool.close_all()
def test_checkin_prunes_stale_endpoint_buckets():
pool.close_all()
old_a, old_b = _socketpair()
old = pool.PooledConnection(old_a, b"secret")
pool.checkin("old:443", old)
old.last_used -= pool._MAX_IDLE_SECONDS + 1
peers = [old_b]
try:
for i in range(pool._MAX_ENDPOINTS):
a, b = _socketpair()
peers.append(b)
pool.checkin(f"new-{i}:443", pool.PooledConnection(a, b"secret"))
assert "old:443" not in pool._POOL
assert len(pool._POOL) <= pool._MAX_ENDPOINTS
finally:
for peer in peers:
peer.close()
pool.close_all()
def test_session_inner_message_strips_auth_fields(): def test_session_inner_message_strips_auth_fields():
msg = { msg = {
"id": "1", "command": "tabs.list", "args": {}, "user_agent": "browser-cli/1", "id": "1", "command": "tabs.list", "args": {}, "user_agent": "browser-cli/1",
+17
View File
@@ -181,6 +181,23 @@ def test_rate_limiter_is_per_key():
assert limiter.allow("a") is False assert limiter.allow("a") is False
assert limiter.allow("b") is False assert limiter.allow("b") is False
def test_rate_limiter_caps_identity_buckets():
limiter = RateLimiter(rate=0.0001, burst=1, max_buckets=3)
for i in range(10):
assert limiter.allow(f"key-{i}") is True
assert len(limiter._buckets) <= 3
def test_rate_limiter_prunes_refilled_idle_buckets(monkeypatch):
current = 1000.0
monkeypatch.setattr("browser_cli.serve.security.time.monotonic", lambda: current)
limiter = RateLimiter(rate=1, burst=2, max_buckets=2)
assert limiter.allow("old") is True
current += 120.0
assert limiter.allow("a") is True
assert limiter.allow("b") is True
assert "old" not in limiter._buckets
assert len(limiter._buckets) <= 2
# ── ServeSecurity ──────────────────────────────────────────────────────────────── # ── ServeSecurity ────────────────────────────────────────────────────────────────
def test_effective_policy_prefers_per_key_override(): def test_effective_policy_prefers_per_key_override():
Generated
+513 -464
View File
File diff suppressed because it is too large Load Diff