diff --git a/README.md b/README.md index 9ede40f..787204b 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ ### Features -- **Comprehensive CLI Tools** - 67 tools organized into 15 categories; Designed to integrate with any AI agent +- **Comprehensive CLI Tools** - 69 tools organized into 15 categories; Designed to integrate with any AI agent - **Python-based Tools** - Used for agent / workflow code generation; Easier integration with [Bridgic](https://github.com/bitsky-tech/bridgic) - **Snapshot with Semantic Invariance** - A representation of page snapshot based on accessibility tree and a specially designed ref-generation algorithm that ensures element refs remain unchanged across page reloads - **Skills** - Used for guided exploration and code generation; Compatible with most of coding agents @@ -168,7 +168,7 @@ if __name__ == "__main__": ### CLI Tools -`bridgic-browser` ships with a command-line interface for controlling a browser from the terminal (67 tools organized into 15 categories). A persistent daemon process holds a browser instance; each CLI invocation connects over a Unix domain socket and exits immediately. +`bridgic-browser` ships with a command-line interface for controlling a browser from the terminal (69 tools organized into 15 categories). A persistent daemon process holds a browser instance; each CLI invocation connects over a Unix domain socket and exits immediately. #### Configuration @@ -410,7 +410,7 @@ browser = Browser(cdp="ws://localhost:9222/devtools/browser/...") | Wait | `wait [SECONDS] [TEXT] [--gone]` | | Tabs | `tabs`, `new-tab`, `switch-tab`, `close-tab` | | Evaluate | `eval`, `eval-on` | -| Capture | `screenshot`, `pdf` | +| Capture | `screenshot`, `pdf`, `downloads`, `wait-download` | | Network | `network-start`, `network-stop`, `network`, `wait-network` | | Dialog | `dialog-setup`, `dialog`, `dialog-remove` | | Storage | `storage-save`, `storage-load`, `cookies-clear`, `cookies`, `cookie-set` | @@ -427,7 +427,7 @@ bridgic-browser scroll -h ### Python Tools -Bridgic Browser provides 67 tools organized into 15 categories. Use `BrowserToolSetBuilder` with category/name selection for scenario-focused tool sets. +Bridgic Browser provides 69 tools organized into 15 categories. Use `BrowserToolSetBuilder` with category/name selection for scenario-focused tool sets. #### Category-based Selection @@ -533,9 +533,11 @@ tools = [*builder1.build()["tool_specs"], *builder2.build()["tool_specs"]] **Wait (1 tool):** - `wait_for(time_seconds, text, text_gone, selector, state, timeout)` - Wait for conditions -**Capture (2 tools):** +**Capture (4 tools):** - `take_screenshot(filename=None, ref=None, full_page=False, type="png")` - Capture screenshot - `save_pdf(filename)` - Save page as PDF +- `get_downloaded_files_text()` - Numbered list of all files downloaded in this session +- `wait_for_next_download(timeout=30.0)` - Block until next download completes; returns a one-line summary or a timeout message **Network (4 tools):** - `start_network_capture()` / `stop_network_capture()` / `get_network_requests()` - Network monitoring @@ -609,6 +611,8 @@ tools = [*builder1.build()["tool_specs"], *builder2.build()["tool_specs"]] | `wait` | `wait_for` | | `screenshot` | `take_screenshot` | | `pdf` | `save_pdf` | +| `downloads` | `get_downloaded_files_text` | +| `wait-download` | `wait_for_next_download` | | `network-start` | `start_network_capture` | | `network` | `get_network_requests` | | `network-stop` | `stop_network_capture` | @@ -702,6 +706,8 @@ browser = Browser(stealth=config, headless=False) #### Downloads +`Browser` always creates a `DownloadManager` and always accepts downloads. Files are saved to `downloads_path` if configured, or `~/Downloads` by default. + bridgic preserves the original filename, suppresses the "Save As" dialog, and keeps the API the same across modes. Internally there are two pipelines — `DownloadManager` for non-CDP / CDP-owned, and `CdpDownloadRenamer` for CDP-borrowed (page-level CDP routing of `setDownloadBehavior(allowAndName)`). See [CLAUDE.md → Downloads](CLAUDE.md#downloads) for the full design. ##### Download path matrix @@ -713,7 +719,7 @@ bridgic preserves the original filename, suppresses the "Save As" dialog, and ke | **CLI** | CDP (`--cdp ...`) | yes | the explicit value | | **CLI** | CDP | no | the CLI client's working directory at command time (`os.getcwd()`) — `curl -O`-style ergonomics | | **SDK** (`Browser(...)`) | non-CDP | yes | the explicit value | -| **SDK** | non-CDP | no | downloads not captured (Playwright wipes the temp dir on close — pass `downloads_path`) | +| **SDK** | non-CDP | no | `~/Downloads` (DownloadManager auto-default) | | **SDK** | CDP (`Browser(cdp=...)`) | yes | the explicit value | | **SDK** | CDP | no | `~/Downloads` (SDK has no CLI CWD hint) | @@ -721,13 +727,22 @@ bridgic preserves the original filename, suppresses the "Save As" dialog, and ke # Non-CDP (DownloadManager pipeline) browser = Browser(downloads_path="./downloads", headless=True) await browser.navigate_to("https://example.com") -# Programmatic access to completed downloads + +# Trigger a download, then wait for it to complete +await browser.click_element_by_ref("8d4b03a9") +result = await browser.wait_for_next_download(timeout=30.0) +# "Download complete: report.pdf — 261.0 KB — /home/user/Downloads/report.pdf" + +# Get the formatted list of everything downloaded in this session +print(await browser.get_downloaded_files_text()) + +# Or access the raw list for f in browser.download_manager.downloaded_files: print(f"Downloaded: {f.file_name} ({f.file_size} bytes)") # CDP-borrowed (CdpDownloadRenamer pipeline; downloads land at downloads_path -# with real filenames; download_manager is None — wait_for_download is -# unsupported here). +# with real filenames; download_manager is None — wait_for_download / +# wait_for_next_download are unsupported here). browser = Browser(cdp="auto", downloads_path="./downloads") ``` diff --git a/README_zh.md b/README_zh.md index 3f3a674..5d27f08 100644 --- a/README_zh.md +++ b/README_zh.md @@ -8,7 +8,7 @@ ### 特性 -- **完善的 CLI 工具** — 67 个工具分为 15 类;可与各类 AI 智能体集成 +- **完善的 CLI 工具** — 69 个工具分为 15 类;可与各类 AI 智能体集成 - **基于 Python 的工具** — 用于智能体 / 工作流代码生成;更易与 [Bridgic](https://github.com/bitsky-tech/bridgic) 集成 - **语义不变的快照** — 基于无障碍树与专门设计的 ref 生成算法,保证元素 ref 在页面重载后仍可对应同一元素 - **Skills** — 用于引导探索与代码生成;兼容多数编程类智能体 @@ -168,7 +168,7 @@ if __name__ == "__main__": ### CLI 工具 -`bridgic-browser` 提供命令行界面,用于在终端控制浏览器(67 个工具、15 类)。持久化 daemon 进程持有浏览器实例;每次 CLI 调用通过 Unix 域套接字连接后立即退出。 +`bridgic-browser` 提供命令行界面,用于在终端控制浏览器(69 个工具、15 类)。持久化 daemon 进程持有浏览器实例;每次 CLI 调用通过 Unix 域套接字连接后立即退出。 #### 配置 @@ -404,7 +404,7 @@ browser = Browser(cdp="ws://localhost:9222/devtools/browser/...") | 等待 | `wait [SECONDS] [TEXT] [--gone]` | | 标签页 | `tabs`, `new-tab`, `switch-tab`, `close-tab` | | 执行 | `eval`, `eval-on` | -| 捕获 | `screenshot`, `pdf` | +| 捕获 | `screenshot`, `pdf`, `downloads`, `wait-download` | | 网络 | `network-start`, `network-stop`, `network`, `wait-network` | | 对话框 | `dialog-setup`, `dialog`, `dialog-remove` | | 存储 | `storage-save`, `storage-load`, `cookies-clear`, `cookies`, `cookie-set` | @@ -421,7 +421,7 @@ bridgic-browser scroll -h ### Python 工具 -Bridgic Browser 提供 67 个工具,分为 15 类。使用 `BrowserToolSetBuilder` 按类别/名称选择,以适配不同场景。 +Bridgic Browser 提供 69 个工具,分为 15 类。使用 `BrowserToolSetBuilder` 按类别/名称选择,以适配不同场景。 #### 按类别选择 @@ -527,9 +527,11 @@ tools = [*builder1.build()["tool_specs"], *builder2.build()["tool_specs"]] **等待(1 个工具):** - `wait_for(time_seconds, text, text_gone, selector, state, timeout)` - 等待条件 -**捕获(2 个工具):** +**捕获(4 个工具):** - `take_screenshot(filename=None, ref=None, full_page=False, type="png")` - 截图 - `save_pdf(filename)` - 将页面保存为 PDF +- `get_downloaded_files_text()` - 返回本次会话所有已下载文件的编号列表 +- `wait_for_next_download(timeout=30.0)` - 等待下一个下载完成;返回单行摘要或超时提示 **网络(4 个工具):** - `start_network_capture()` / `stop_network_capture()` / `get_network_requests()` - 网络监控 @@ -603,6 +605,8 @@ tools = [*builder1.build()["tool_specs"], *builder2.build()["tool_specs"]] | `wait` | `wait_for` | | `screenshot` | `take_screenshot` | | `pdf` | `save_pdf` | +| `downloads` | `get_downloaded_files_text` | +| `wait-download` | `wait_for_next_download` | | `network-start` | `start_network_capture` | | `network` | `get_network_requests` | | `network-stop` | `stop_network_capture` | @@ -696,6 +700,8 @@ browser = Browser(stealth=config, headless=False) #### 下载 +`Browser` 始终创建 `DownloadManager` 并自动接受下载。文件保存至 `downloads_path`(若已配置),否则默认保存到 `~/Downloads`。 + bridgic 在所有模式下都保留原始文件名、屏蔽"另存为"对话框,API 一致。内部有两条流水线 —— 非 CDP / CDP-owned 用 `DownloadManager`,CDP-borrowed 用 `CdpDownloadRenamer`(通过 page-level CDP session 下发 `setDownloadBehavior(allowAndName)`)。完整设计见 [CLAUDE.md → Downloads](CLAUDE.md#downloads)。 ##### 下载路径矩阵 @@ -707,7 +713,7 @@ bridgic 在所有模式下都保留原始文件名、屏蔽"另存为"对话框, | **CLI** | CDP (`--cdp ...`) | 有 | 显式值 | | **CLI** | CDP | 无 | CLI 启动时的工作目录(`os.getcwd()`)—— `curl -O` 风格 | | **SDK** (`Browser(...)`) | 非 CDP | 有 | 显式值 | -| **SDK** | 非 CDP | 无 | 下载不被捕获(Playwright 会清掉 temp dir —— 请显式传 `downloads_path`) | +| **SDK** | 非 CDP | 无 | `~/Downloads`(DownloadManager 自动默认) | | **SDK** | CDP (`Browser(cdp=...)`) | 有 | 显式值 | | **SDK** | CDP | 无 | `~/Downloads`(SDK 没有 CLI CWD 提示) | @@ -715,12 +721,21 @@ bridgic 在所有模式下都保留原始文件名、屏蔽"另存为"对话框, # 非 CDP(DownloadManager 流水线) browser = Browser(downloads_path="./downloads", headless=True) await browser.navigate_to("https://example.com") -# 程序化访问已完成的下载 + +# 触发下载,然后等待其完成 +await browser.click_element_by_ref("8d4b03a9") +result = await browser.wait_for_next_download(timeout=30.0) +# "Download complete: report.pdf — 261.0 KB — /home/user/Downloads/report.pdf" + +# 列出本次会话的所有下载记录 +print(await browser.get_downloaded_files_text()) + +# 或直接访问原始列表 for f in browser.download_manager.downloaded_files: print(f"已下载:{f.file_name}({f.file_size} 字节)") # CDP-borrowed(CdpDownloadRenamer 流水线;文件以真名落到 downloads_path, -# download_manager 为 None —— wait_for_download 在此模式不支持) +# download_manager 为 None —— wait_for_download / wait_for_next_download 在此模式不支持) browser = Browser(cdp="auto", downloads_path="./downloads") ``` diff --git a/bridgic/browser/_cli_catalog.py b/bridgic/browser/_cli_catalog.py index 1b796a4..cc69b88 100644 --- a/bridgic/browser/_cli_catalog.py +++ b/bridgic/browser/_cli_catalog.py @@ -43,7 +43,7 @@ (ToolCategory.KEYBOARD, ["type", "press", "key-down", "key-up"]), (ToolCategory.MOUSE, ["scroll", "mouse-click", "mouse-move", "mouse-drag", "mouse-down", "mouse-up"]), (ToolCategory.WAIT, ["wait"]), - (ToolCategory.CAPTURE, ["screenshot", "pdf"]), + (ToolCategory.CAPTURE, ["screenshot", "pdf", "downloads", "wait-download"]), (ToolCategory.NETWORK, ["network-start", "network", "network-stop", "wait-network"]), (ToolCategory.DIALOG, ["dialog-setup", "dialog", "dialog-remove"]), (ToolCategory.STORAGE, ["cookies", "cookie-set", "cookies-clear", "storage-save", "storage-load"]), @@ -115,6 +115,8 @@ "close-tab": (ToolCategory.TABS, "Close a tab by page_id (or current tab if omitted); run 'tabs' first to list page IDs"), "screenshot": (ToolCategory.CAPTURE, "Save a screenshot to PATH [--full-page]"), "pdf": (ToolCategory.CAPTURE, "Save the current page as PDF"), + "downloads": (ToolCategory.CAPTURE, "List all files downloaded in this session"), + "wait-download": (ToolCategory.CAPTURE, "Wait up to SECONDS for the next download to complete (default: 30)"), "console-start": (ToolCategory.DEVELOPER, "Start capturing browser console output"), "console-stop": (ToolCategory.DEVELOPER, "Stop capturing browser console output"), "console": (ToolCategory.DEVELOPER, "Get captured console messages [--filter TYPE] [--no-clear]"), @@ -219,6 +221,8 @@ "video-stop": "stop_video", "close": "close", "resize": "browser_resize", + "downloads": "get_downloaded_files_text", + "wait-download": "wait_for_next_download", } # CLI commands that are informational and not backed by Browser tool methods. diff --git a/bridgic/browser/cli/_commands.py b/bridgic/browser/cli/_commands.py index c860173..af68352 100644 --- a/bridgic/browser/cli/_commands.py +++ b/bridgic/browser/cli/_commands.py @@ -1009,6 +1009,32 @@ def cmd_video_stop(path: str | None) -> None: _err(exc) +# ── Downloads ───────────────────────────────────────────────────────────────── + +@cli.command("downloads", context_settings=CONTEXT_SETTINGS) +def cmd_downloads() -> None: + """List all files downloaded in this browser session.""" + try: + _ok(send_command("downloads", {}, start_if_needed=False)) + except Exception as exc: + _err(exc) + + +@cli.command("wait-download", context_settings=CONTEXT_SETTINGS) +@click.argument("seconds", type=float, required=False, default=30.0) +def cmd_wait_download(seconds: float) -> None: + """Wait up to SECONDS for the next download to complete (default: 30). + + Run this immediately after clicking a download link or button. + Returns the file name, size, and path when the download finishes, + or an error message if the timeout expires with no download. + """ + try: + _ok(send_command("wait_download", {"timeout": seconds}, start_if_needed=False)) + except Exception as exc: + _err(exc) + + # ── Lifecycle ───────────────────────────────────────────────────────────────── @cli.command("close", context_settings=CONTEXT_SETTINGS) diff --git a/bridgic/browser/cli/_daemon.py b/bridgic/browser/cli/_daemon.py index a597846..9b5872c 100644 --- a/bridgic/browser/cli/_daemon.py +++ b/bridgic/browser/cli/_daemon.py @@ -527,6 +527,17 @@ async def _handle_resize(browser: "Browser", args: Dict[str, Any]) -> str: return await browser.browser_resize(args.get("width", 1280), args.get("height", 720)) +# ── Downloads ───────────────────────────────────────────────────────────────── + +async def _handle_downloads(browser: "Browser", _args: Dict[str, Any]) -> str: + return await browser.get_downloaded_files_text() + + +async def _handle_wait_download(browser: "Browser", args: Dict[str, Any]) -> str: + timeout = float(args.get("timeout", 30.0)) + return await browser.wait_for_next_download(timeout=timeout) + + _HANDLERS = { # Navigation "open": _handle_open, @@ -611,6 +622,9 @@ async def _handle_resize(browser: "Browser", args: Dict[str, Any]) -> str: # ("close" is intercepted in the connection handler — see comment above # the lifecycle section.) "resize": _handle_resize, + # Downloads + "downloads": _handle_downloads, + "wait_download": _handle_wait_download, } diff --git a/bridgic/browser/session/_browser.py b/bridgic/browser/session/_browser.py index 0c2da74..d281b2e 100644 --- a/bridgic/browser/session/_browser.py +++ b/bridgic/browser/session/_browser.py @@ -588,10 +588,11 @@ def __init__( # than hit a misleading NO_ACTIVE_PAGE when `_page` is mid-teardown. self._closing: bool = False - # Download manager - handles saving files with correct filenames - self._download_manager: Optional[DownloadManager] = None - if self._downloads_path: - self._download_manager = DownloadManager(downloads_path=self._downloads_path) + # Download manager - always active so CLI can query/wait for downloads. + # Uses the configured path if provided, otherwise defaults to ~/Downloads. + self._download_manager: Optional[DownloadManager] = DownloadManager( + downloads_path=self._downloads_path or None + ) # CDP-borrowed download infrastructure. Populated in `_start()` when # connecting via CDP without owning the context. The renamer is the @@ -723,6 +724,32 @@ def downloaded_files(self) -> List[DownloadedFile]: return self._download_manager.downloaded_files return [] + async def get_downloaded_files_text(self) -> str: + """Return a human-readable summary of all downloads in this session.""" + files = self.downloaded_files + if not files: + return "No downloads in this session." + lines = [] + for i, f in enumerate(files, 1): + size_kb = f.file_size / 1024 + size_str = f"{size_kb / 1024:.2f} MB" if size_kb >= 1024 else f"{size_kb:.1f} KB" + lines.append(f"[{i}] {f.file_name} — {size_str} — {f.path}") + return "\n".join(lines) + + async def wait_for_next_download(self, timeout: float = 30.0) -> str: + """Wait up to *timeout* seconds for the next download to complete. + + Returns a one-line summary of the downloaded file, or a timeout message. + """ + if not self._download_manager: + return "Download manager not available." + file = await self._download_manager.wait_for_next_download(timeout=timeout) + if file is None: + return f"No download completed within {timeout:.0f}s timeout." + size_kb = file.file_size / 1024 + size_str = f"{size_kb / 1024:.2f} MB" if size_kb >= 1024 else f"{size_kb:.1f} KB" + return f"Download complete: {file.file_name} — {size_str} — {file.path}" + @property def headless(self) -> bool: """Whether the user requested a windowless (headless) browser. @@ -1041,8 +1068,8 @@ def _get_context_options(self) -> Dict[str, Any]: if self._color_scheme is not None: options["color_scheme"] = self._color_scheme - # Auto-enable downloads if downloads_path is configured - if self._downloads_path and "accept_downloads" not in self._extra_kwargs: + # Always accept downloads so DownloadManager can track them + if "accept_downloads" not in self._extra_kwargs: options["accept_downloads"] = True # Extract context-specific kwargs (user values override everything) diff --git a/bridgic/browser/session/_download.py b/bridgic/browser/session/_download.py index 6c09a78..c404524 100644 --- a/bridgic/browser/session/_download.py +++ b/bridgic/browser/session/_download.py @@ -147,6 +147,11 @@ def __init__( # own Future; _handle_download fulfils the oldest pending waiter on # completion so callers do not stomp on each other's callbacks. self._pending_waiters: List[asyncio.Future[DownloadedFile]] = [] + # Persistent FIFO of all successfully saved downloads. Drained by + # wait_for_next_download() — independent from _pending_waiters so the + # CLI's `wait-download` command can pick up completions that already + # happened before the agent asked. + self._completed_queue: asyncio.Queue[DownloadedFile] = asyncio.Queue() @property def downloads_path(self) -> Path: @@ -348,6 +353,7 @@ async def _handle_download(self, download: "Download") -> None: ) self._downloaded_files.append(downloaded_file) + self._completed_queue.put_nowait(downloaded_file) logger.info(f"Download saved: {target_path} ({file_size} bytes)") # Call complete callback if configured @@ -546,9 +552,29 @@ async def wait_for_download( if not waiter.done(): waiter.cancel() + async def wait_for_next_download(self, timeout: float = 30.0) -> Optional[DownloadedFile]: + """Wait up to *timeout* seconds for the next download to complete. + + Returns the DownloadedFile when one arrives, or None on timeout. + """ + try: + return await asyncio.wait_for(self._completed_queue.get(), timeout=timeout) + except asyncio.TimeoutError: + return None + def clear_history(self) -> None: - """Clear the download history.""" + """Clear the download history. + + Also drains :attr:`_completed_queue` so a subsequent + :meth:`wait_for_next_download` won't immediately yield a stale + completion from before the clear. + """ self._downloaded_files.clear() + while not self._completed_queue.empty(): + try: + self._completed_queue.get_nowait() + except asyncio.QueueEmpty: + break def get_downloads_by_type(self, file_type: str) -> List[DownloadedFile]: """Get all downloads of a specific file type. diff --git a/docs/API.md b/docs/API.md index 77b9ec2..c7bc25b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -18,8 +18,10 @@ Short reference for the main session and download APIs. For tool lists and selec | `await browser.get_current_page_title()` | Get current page title string, or `None` if no page is open. | | `browser.get_current_page_url()` | Get current page URL string, or `None` if no page is open. (sync) | | `browser.get_config()` | Return dict of all current browser configuration options. | -| `browser.download_manager` | `DownloadManager` instance (after `start()`) when `downloads_path` is set **and** the active pipeline uses it (non-CDP / CDP-owned). In **CDP-borrowed** mode the download manager is intentionally not attached — see [CdpDownloadRenamer](#cdp-borrowed-downloads-cdpdownloadrenamer). | -| `browser.downloaded_files` | Shortcut for `browser.download_manager.downloaded_files`. Returns `[]` if no download manager (including CDP-borrowed). | +| `await browser.get_downloaded_files_text()` | Numbered list of all files downloaded in this session, or `"No downloads in this session."` if none. Each line: `[N] filename — size — /path/to/file`. | +| `await browser.wait_for_next_download(timeout=30.0)` | Block until the next download completes and return a one-line summary, or a timeout message. **Call immediately after the action that triggers the download.** Timeout unit is seconds. Not supported in CDP-borrowed mode (the manager isn't attached there). | +| `browser.download_manager` | Always-created `DownloadManager` instance. Defaults to `~/Downloads` when `downloads_path` is not configured. In **CDP-borrowed** mode the manager is intentionally not attached to the borrowed context — `CdpDownloadRenamer` handles downloads instead. See [CdpDownloadRenamer](#cdp-borrowed-downloads-cdpdownloadrenamer). | +| `browser.downloaded_files` | Shortcut for `browser.download_manager.downloaded_files`. Returns `[]` in CDP-borrowed mode because the manager isn't attached there. | | `browser.headless` | `bool` — whether the browser runs in headless mode. | | `browser.viewport` | `dict` or `None` — current viewport size configuration. | | `browser.channel` | `str` or `None` — browser distribution channel. | @@ -37,18 +39,19 @@ bridgic has two download pipelines, picked by mode. The download path resolution | Mode | Pipeline | `downloads_path` unset → falls back to | |---|---|---| -| non-CDP (`Browser()`) | `DownloadManager.save_as` from Playwright's `artifactsDir` | DownloadManager is **not** attached; files are lost when Playwright wipes `artifactsDir` on close. Always pass `downloads_path` in non-CDP mode. | -| CDP-owned (rare — `Browser(cdp=...)` against a remote Chrome that has no contexts yet) | Same as non-CDP | Same caveat as non-CDP. | +| non-CDP (`Browser()`) | `DownloadManager.save_as` from Playwright's `artifactsDir` | `~/Downloads` (DownloadManager auto-default). | +| CDP-owned (rare — `Browser(cdp=...)` against a remote Chrome that has no contexts yet) | Same as non-CDP | Same as non-CDP. | | CDP-borrowed (`Browser(cdp=...)` against a user's running Chrome) | `CdpDownloadRenamer` (rename GUID → real name post-completion) via a page-level CDP session | `~/Downloads`. In daemon mode the CLI client's CWD (`os.getcwd()`) takes priority over the `~/Downloads` fallback — see [CLAUDE.md → Downloads](../CLAUDE.md#downloads). | ### DownloadManager (non-CDP / CDP-owned modes) -`Browser` creates and manages a `DownloadManager` automatically when `downloads_path` is provided. Access it via `browser.download_manager` after the browser has started (via `navigate_to()`, `search()`, or `_start()`). +`Browser` always creates a `DownloadManager`. It saves files to the configured `downloads_path`, or `~/Downloads` by default. Access it via `browser.download_manager` after the browser has started. | Method / property | Description | |------------------|-------------| -| `browser.download_manager` | The auto-created `DownloadManager` (None when `downloads_path` is unset, or in CDP-borrowed mode regardless of `downloads_path`). | +| `browser.download_manager` | The `DownloadManager` instance. Always non-`None` after `__init__`. In CDP-borrowed mode it is created but not attached to the context; downloads in that mode go through `CdpDownloadRenamer` instead. | | `browser.download_manager.downloaded_files` | List of `DownloadedFile` (`.url`, `.path`, `.file_name`, `.file_size`). | +| `await browser.download_manager.wait_for_next_download(timeout=30.0)` | Wait up to *timeout* seconds for the next download to complete. Returns `DownloadedFile` or `None` on timeout. | `browser.wait_for_download(...)` is **not supported in CDP-borrowed mode** — Playwright's per-context `download` event does not fire when the file is routed away from `artifactsDir` by the page-session override. diff --git a/docs/BROWSER_TOOLS_GUIDE.md b/docs/BROWSER_TOOLS_GUIDE.md index 88cf1ce..6cc3dc4 100644 --- a/docs/BROWSER_TOOLS_GUIDE.md +++ b/docs/BROWSER_TOOLS_GUIDE.md @@ -14,7 +14,7 @@ This guide helps you choose the right tools for different browser automation sce | Keyboard | 4 | Keyboard typing and key state | | Mouse | 6 | Coordinate-based pointer control | | Wait | 1 | Time/text/selector waits | -| Capture | 2 | Screenshot and PDF | +| Capture | 4 | Screenshot, PDF, and file download tracking | | Network | 4 | Request capture and network idle waits | | Dialog | 3 | Alert/confirm/prompt handling | | Storage | 5 | Cookies and storage state | @@ -352,7 +352,7 @@ builder = BrowserToolSetBuilder.for_categories( ) tools = builder.build()["tool_specs"] -# Full access (all 67 tools) +# Full access (all 69 tools) builder = BrowserToolSetBuilder.for_categories(browser, ToolCategory.ALL) tools = builder.build()["tool_specs"] ``` @@ -428,6 +428,25 @@ await browser.select_dropdown_option_by_ref("8d4b03a9", "a") await browser.upload_file_by_ref("e10", "/path/to/file.pdf") ``` +### File Download + +Click (or otherwise trigger) the download, then immediately await `wait_for_next_download`. Downloads are always saved to `~/Downloads` unless `downloads_path` is configured. + +```python +# Trigger the download +await browser.click_element_by_ref("8d4b03a9") + +# Block until the file is saved (timeout in seconds) +result = await browser.wait_for_next_download(timeout=30.0) +# result: "Download complete: report.pdf — 261.0 KB — /home/user/Downloads/report.pdf" +# or "No download completed within 30s timeout." + +# Inspect all downloads in the session +files = browser.downloaded_files # List[DownloadedFile] +for f in files: + print(f.file_name, f.file_size, f.path) +``` + ### Handling Dialogs ```python diff --git a/skills/bridgic-browser/references/cli-guide.md b/skills/bridgic-browser/references/cli-guide.md index b80ce21..7b93236 100644 --- a/skills/bridgic-browser/references/cli-guide.md +++ b/skills/bridgic-browser/references/cli-guide.md @@ -50,7 +50,7 @@ bridgic-browser close | Keyboard | `press`, `type`, `key-down`, `key-up` | | Mouse | `scroll`, `mouse-click`, `mouse-move`, `mouse-drag`, `mouse-down`, `mouse-up` | | Wait | `wait` | -| Capture | `screenshot`, `pdf` | +| Capture | `screenshot`, `pdf`, `downloads`, `wait-download` | | Network | `network-start`, `network`, `network-stop`, `wait-network` | | Dialog | `dialog-setup`, `dialog`, `dialog-remove` | | Storage | `cookies`, `cookie-set`, `cookies-clear`, `storage-save`, `storage-load` | @@ -104,6 +104,11 @@ bridgic-browser dialog-setup --action accept bridgic-browser open https://example.com # any alert triggered will be auto-accepted bridgic-browser dialog-remove +# File download workflow — click the trigger, then wait +bridgic-browser click @37015433 # click a download button/link +bridgic-browser wait-download 30 # block until download completes (default 30s) +bridgic-browser downloads # list all downloads in this session + # Close the browser when everything is done bridgic-browser close ``` @@ -158,6 +163,7 @@ For how to enable CDP on the target Chrome (Chrome 144+ `chrome://inspect/#remot - `bridgic-browser wait --gone "Loading"` — wait until "Loading" disappears (`--gone` only works with a text argument) - **`type` requires a focused element**: `type` sends keystrokes at the current cursor position. Run `bridgic-browser click @` or `bridgic-browser focus @` on the target input first. - **`mouse-move`, `mouse-click`, `mouse-drag` use viewport pixel coordinates** measured from the top-left corner of the browser viewport. Example: `bridgic-browser mouse-click 500 300`. +- **File downloads require two commands**: `click` (or whatever triggers the download) followed immediately by `wait-download [SECONDS]`. `wait-download` blocks until the browser signals completion and returns the file name, size, and path. After that, `downloads` lists all files saved in the session. Downloads are always saved to `~/Downloads` unless a `downloads_path` is set in config. - **`eval-on` CODE must be an arrow or named function** that accepts the element as its argument: - `bridgic-browser eval-on @8d4b03a9 "(el) => el.textContent"` ✓ - `bridgic-browser eval-on @8d4b03a9 "el.textContent"` ✗ (not a function) diff --git a/skills/bridgic-browser/references/cli-sdk-api-mapping.md b/skills/bridgic-browser/references/cli-sdk-api-mapping.md index a2a7820..a6afe79 100644 --- a/skills/bridgic-browser/references/cli-sdk-api-mapping.md +++ b/skills/bridgic-browser/references/cli-sdk-api-mapping.md @@ -74,6 +74,8 @@ This model is the foundation of all correspondence in this guide. | `wait` | `wait_for` | | `screenshot` | `take_screenshot` | | `pdf` | `save_pdf` | +| `downloads` | `get_downloaded_files_text` | +| `wait-download` | `wait_for_next_download` | | `network-start` | `start_network_capture` | | `network` | `get_network_requests` | | `network-stop` | `stop_network_capture` | @@ -137,6 +139,8 @@ This model is the foundation of all correspondence in this guide. - `pdf path.pdf` -> `save_pdf(filename="path.pdf")` - SDK-only params: `display_header_footer`, `print_background`, `scale`, `paper_width`, `paper_height`, `margin_top`, `margin_bottom`, `margin_left`, `margin_right`, `landscape` - `video-stop path.webm` -> `stop_video(filename="path.webm")` +- `downloads` -> `get_downloaded_files_text()` — returns a numbered human-readable list of all files downloaded in the session, or `"No downloads in this session."` if none +- `wait-download [SECONDS]` -> `wait_for_next_download(timeout=30.0)` — **unit is SECONDS** (default 30); blocks until the next download completes and returns a one-line summary (`"Download complete: filename — size — path"`), or a timeout message if no download arrives within the limit. Call immediately after the action that triggers the download. - `type TEXT [--submit]` -> `type_text(text, submit=False)` — **requires a focused element**; call `focus_element_by_ref` or `click_element_by_ref` on the target before `type` - `eval-on REF CODE` -> `evaluate_javascript_on_ref(ref, code)` — **CODE must be an arrow or named function** that accepts the element: - `"(el) => el.textContent"` ✓ @@ -196,6 +200,7 @@ These CLI behaviors have no direct SDK equivalent or work differently: | `fill-form` input format | JSON string on command line | Python list of dicts | | `take_screenshot` return value | CLI always writes to a file path | SDK: `filename=None` returns base64 data URL; `filename="path.png"` writes file | | Video file write timing | `video-stop` stops the recorder and saves the `.webm` file immediately | Same for SDK: `stop_video()` saves the file immediately — no page close needed | +| Download workflow | `click @ref` then `wait-download [s]` as separate commands; `downloads` to list all | SDK: call `await browser.wait_for_next_download(timeout=30.0)` after the action that triggers the download; `browser.downloaded_files` for the full list | ## Practical Rule for Mixed Tasks diff --git a/tests/unit/test_browser.py b/tests/unit/test_browser.py index ef88e2b..ee57677 100644 --- a/tests/unit/test_browser.py +++ b/tests/unit/test_browser.py @@ -157,11 +157,11 @@ def test_downloads_path_creates_manager(self): assert browser.download_manager is not None assert browser.download_manager.downloads_path == Path(tmpdir) - def test_no_downloads_path_no_manager(self): - """Test that no downloads_path means no download manager.""" + def test_no_downloads_path_uses_default_manager(self): + """Download manager is always created; defaults to ~/Downloads when no path given.""" browser = Browser() - assert browser.download_manager is None + assert browser.download_manager is not None assert browser.downloaded_files == [] def test_get_config(self): diff --git a/tests/unit/test_browser_methods.py b/tests/unit/test_browser_methods.py index 493b93c..fefc9d0 100644 --- a/tests/unit/test_browser_methods.py +++ b/tests/unit/test_browser_methods.py @@ -30,6 +30,7 @@ "verify_element_visible", "verify_text_visible", "verify_value", "verify_element_state", "verify_url", "verify_title", "start_tracing", "stop_tracing", "start_video", "stop_video", "add_trace_chunk", + "get_downloaded_files_text", "wait_for_next_download", ] diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 8be3c67..281cc81 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -176,6 +176,8 @@ def invoke_raw(args: list[str]): # Capture "screenshot": ["screenshot", "out.png"], "pdf": ["pdf", "out.pdf"], + "downloads": ["downloads"], + "wait-download": ["wait-download"], # Network "network-start": ["network-start"], "network": ["network"], diff --git a/tests/unit/test_tools.py b/tests/unit/test_tools.py index 4161e06..aecb27b 100644 --- a/tests/unit/test_tools.py +++ b/tests/unit/test_tools.py @@ -1762,7 +1762,7 @@ def test_for_categories_adds_tools(self): from bridgic.browser.tools import BrowserToolSetBuilder, ToolCategory browser = MagicMock() - for name in ("take_screenshot", "save_pdf"): + for name in ("take_screenshot", "save_pdf", "get_downloaded_files_text", "wait_for_next_download"): method = MagicMock() method.__name__ = name method.__doc__ = f"Mock {name} method." @@ -1770,7 +1770,7 @@ def test_for_categories_adds_tools(self): builder = BrowserToolSetBuilder.for_categories(browser, ToolCategory.CAPTURE) names = {spec.to_tool().name for spec in builder.build()["tool_specs"]} - assert names == {"take_screenshot", "save_pdf"} + assert names == {"take_screenshot", "save_pdf", "get_downloaded_files_text", "wait_for_next_download"} def test_for_categories_accepts_string_aliases(self): """String aliases should map to ToolCategory values."""