From 0a6c73da78e111f75a479811e234f27e06b00c35 Mon Sep 17 00:00:00 2001 From: dreamluyao Date: Fri, 24 Apr 2026 16:45:12 +0800 Subject: [PATCH] fix: agent exploration may fall into endless retry when task involves file download --- README.md | 28 +++++++++---- README_zh.md | 28 +++++++++---- bridgic/browser/_cli_catalog.py | 6 ++- bridgic/browser/cli/_commands.py | 26 +++++++++++++ bridgic/browser/cli/_daemon.py | 14 +++++++ bridgic/browser/session/_browser.py | 39 ++++++++++++++++--- bridgic/browser/session/_download.py | 20 +++++++++- docs/API.md | 11 ++++-- docs/BROWSER_TOOLS_GUIDE.md | 23 ++++++++++- .../bridgic-browser/references/cli-guide.md | 8 +++- .../references/cli-sdk-api-mapping.md | 5 +++ tests/unit/test_browser.py | 6 +-- tests/unit/test_browser_methods.py | 1 + tests/unit/test_cli.py | 2 + tests/unit/test_tools.py | 4 +- 15 files changed, 185 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 3410228..d72858a 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 @@ -219,7 +219,7 @@ BRIDGIC_BROWSER_JSON='{"clear_user_data":true}' bridgic-browser open URL | 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` | @@ -236,7 +236,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 @@ -342,9 +342,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 @@ -418,6 +420,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` | @@ -509,14 +513,22 @@ browser = Browser(stealth=config, headless=False) #### DownloadManager -Handle file downloads with proper filename preservation: +`Browser` always creates a `DownloadManager` and always accepts downloads. Files are saved to `downloads_path` if configured, or `~/Downloads` by default. ```python -# Pass downloads_path to Browser — it creates and manages the DownloadManager internally +# Optional: configure a custom download directory browser = Browser(downloads_path="./downloads", headless=True) await browser.navigate_to("https://example.com") # lazy start triggers here -# Access downloaded files via the built-in manager +# 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" + +# List all downloads in the session +print(await browser.get_downloaded_files_text()) + +# Or access the raw list for file in browser.download_manager.downloaded_files: print(f"Downloaded: {file.file_name} ({file.file_size} bytes)") ``` diff --git a/README_zh.md b/README_zh.md index abefcd7..39732ed 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 域套接字连接后立即退出。 #### 配置 @@ -218,7 +218,7 @@ BRIDGIC_BROWSER_JSON='{"clear_user_data":true}' bridgic-browser open URL | 等待 | `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` | @@ -235,7 +235,7 @@ bridgic-browser scroll -h ### Python 工具 -Bridgic Browser 提供 67 个工具,分为 15 类。使用 `BrowserToolSetBuilder` 按类别/名称选择,以适配不同场景。 +Bridgic Browser 提供 69 个工具,分为 15 类。使用 `BrowserToolSetBuilder` 按类别/名称选择,以适配不同场景。 #### 按类别选择 @@ -341,9 +341,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()` - 网络监控 @@ -417,6 +419,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` | @@ -508,14 +512,22 @@ browser = Browser(stealth=config, headless=False) #### DownloadManager -处理文件下载,正确保留文件名: +`Browser` 始终创建 `DownloadManager` 并自动接受下载。文件保存至 `downloads_path`(若已配置),否则默认保存到 `~/Downloads`。 ```python -# 将 downloads_path 传给 Browser — 它会内部创建并管理 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 file in browser.download_manager.downloaded_files: print(f"已下载:{file.file_name}({file.file_size} 字节)") ``` diff --git a/bridgic/browser/_cli_catalog.py b/bridgic/browser/_cli_catalog.py index 969a8c6..5c0b9b9 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)"), "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 915ed62..bac2ad6 100644 --- a/bridgic/browser/cli/_commands.py +++ b/bridgic/browser/cli/_commands.py @@ -952,6 +952,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 81f61d7..0ffc8b0 100644 --- a/bridgic/browser/cli/_daemon.py +++ b/bridgic/browser/cli/_daemon.py @@ -460,6 +460,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, @@ -543,6 +554,9 @@ async def _handle_resize(browser: "Browser", args: Dict[str, Any]) -> str: # Lifecycle "close": _handle_close, "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 d018c95..db5614c 100644 --- a/bridgic/browser/session/_browser.py +++ b/bridgic/browser/session/_browser.py @@ -589,10 +589,11 @@ def __init__( self._context: Optional[BrowserContext] = None self._page: Optional[Page] = None - # 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 + ) # Cache for last snapshot self._last_snapshot: Optional[EnhancedSnapshot] = None @@ -651,6 +652,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. @@ -898,8 +925,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 68c8455..97d80dc 100644 --- a/bridgic/browser/session/_download.py +++ b/bridgic/browser/session/_download.py @@ -124,6 +124,8 @@ def __init__( # Track handlers for cleanup self._page_handlers: Dict[str, Callable] = {} self._context_handlers: Dict[str, Callable] = {} + # Queue populated after each successful save; drained by wait_for_next_download() + self._completed_queue: asyncio.Queue[DownloadedFile] = asyncio.Queue() @property def downloads_path(self) -> Path: @@ -202,8 +204,13 @@ def _attach_to_page(self, page: "Page") -> None: # Remove old handler if exists self._detach_from_page(page) + def _on_task_done(task: asyncio.Task) -> None: + if not task.cancelled() and task.exception() is not None: + logger.error(f"Download handler raised an unhandled exception: {task.exception()}") + def handle_download(download): - asyncio.create_task(self._handle_download(download)) + task = asyncio.create_task(self._handle_download(download)) + task.add_done_callback(_on_task_done) page.on("download", handle_download) self._page_handlers[page_key] = handle_download @@ -292,6 +299,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 @@ -463,6 +471,16 @@ def on_complete(file: DownloadedFile): finally: self._config.on_download_complete = original_callback + 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.""" self._downloaded_files.clear() diff --git a/docs/API.md b/docs/API.md index 7162491..070d92f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -16,8 +16,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()`), or `None` if `downloads_path` not set. | -| `browser.downloaded_files` | Shortcut for `browser.download_manager.downloaded_files`. Returns `[]` if no download manager. | +| `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. | +| `browser.download_manager` | Always-created `DownloadManager` instance. Defaults to `~/Downloads` when `downloads_path` is not configured. | +| `browser.downloaded_files` | Shortcut for `browser.download_manager.downloaded_files`. | | `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. | @@ -29,12 +31,13 @@ Short reference for the main session and download APIs. For tool lists and selec ## DownloadManager -`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 if `downloads_path` not set). | +| `browser.download_manager` | The `DownloadManager` instance. Always non-`None` after `__init__`. | | `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. | ## Snapshot and state (see SNAPSHOT_AND_STATE.md) diff --git a/docs/BROWSER_TOOLS_GUIDE.md b/docs/BROWSER_TOOLS_GUIDE.md index 20e7d49..68843c7 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 de15afd..ff76ea4 100644 --- a/skills/bridgic-browser/references/cli-guide.md +++ b/skills/bridgic-browser/references/cli-guide.md @@ -49,7 +49,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` | @@ -103,6 +103,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 ``` @@ -142,6 +147,7 @@ Environment variables and login state persistence are documented in `env-vars.md - `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 d18afdc..ebfa319 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` registers path; file is written when daemon/browser closes | Same for SDK: `.webm` is written when page closes via `close()` or `close_tab()` | +| 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 e076c43..999e9a3 100644 --- a/tests/unit/test_browser.py +++ b/tests/unit/test_browser.py @@ -155,11 +155,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 206f24f..be5d72f 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 5f32e70..3b16b86 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -170,6 +170,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 91e122b..85d4beb 100644 --- a/tests/unit/test_tools.py +++ b/tests/unit/test_tools.py @@ -1630,7 +1630,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." @@ -1638,7 +1638,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."""