mirror of
https://github.com/actionbook/actionbook.git
synced 2026-09-19 03:28:06 +08:00
93caf967e0
* [packages/dify-plugin]feat: add Dify plugin for browser automation - Implement search_actions and get_action_by_area_id tools - Support API key authentication via Dify plugin SDK - Add comprehensive tests (34 tests, 99% coverage) - Add documentation (README, PRIVACY) - Phase 1: Query action manuals via API Tools: - search_actions: Find website elements by keyword/domain filter - get_action_by_area_id: Get complete selector details by area ID Testing: - Unit tests: provider, search_actions, get_action_by_area_id - Coverage: 99% (412 statements, 3 missed) - Linting: ruff with zero errors Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * [packages/dify-plugin]fix: align SDK interface, fix API issues and improve reliability - Fix API endpoint and parameter alignment with Actionbook API - Align with Dify SDK interface (provider structure, tool signatures) - Restructure provider: replace provider.yaml with provider/ package - Add test infrastructure: unit tests, integration tests, E2E checklist - Improve error handling and remove legacy provider.yaml - Resolve empty response issue in Dify Cloud - Pin dify-plugin>=0.5.1,<0.6.0 to prevent breaking changes - Add *.difypkg, .coverage, .alma-snapshots to root .gitignore * [packages/dify-plugin]fix: address code review issues in source code - C4: Add insecure CDP URL warning for non-localhost ws:// connections - C5: Replace print(stderr) with proper logger in search/get tools - C6: Re-raise exception after logging in HyperbrowserSession.stop() - W8: Make API_BASE_URL configurable via ACTIONBOOK_API_URL env var - W-limit: Fix float limit validation with int() cast - W-except: Narrow bare Exception to PlaywrightTimeout in operator - W-use_proxy: Fix bool comparison for string/bool/whitespace inputs - W7: Document _get_active_page tab selection behavior - Fix type annotation in providers/__init__.py - Update misleading comment in provider/actionbook.py - Add cleanup warning in browser_create_session.yaml * [packages/dify-plugin]fix: correct integration tests to match tool behavior Tools yield error messages instead of raising exceptions. Fix tests that incorrectly used pytest.raises(ValueError) to instead assert on the returned message content. * [packages/dify-plugin]test: add missing unit tests for improved coverage - New test_browser_stop_session.py (6 tests) - Add HTTP 403, SSL/connection error branch tests for search/get tools - Add use_proxy edge cases and default provider tests - Add malformed cdp_url test for browser operator - Add insecure CDP URL warning tests - Update operator tests for PlaywrightTimeout narrowing - Add provider re-raise test 184 tests pass at 86% coverage. * [packages/dify-plugin]test: add E2E browser tests with Hyperbrowser - Add verify_hyperbrowser.py manual verification script - Add 8 E2E tests: tool wrappers (create/stop/navigate/screenshot) and Playwright direct (multi-step, form fill, nav history, HTML) - Add retry logic for intermittent gevent SSL errors - Add tenacity to dev dependencies Run: HYPERBROWSER_API_KEY=hb-xxx uv run pytest -m e2e -v --timeout=120 --no-cov * [packages/dify-plugin]feat: add connection pool, playwright worker and API key resolver - api_key.py: resolve provider key from params or env vars with placeholder filtering - connection_pool.py: in-process pool of browser workers keyed by session_id, using subprocess-based Playwright workers to avoid asyncio conflicts - playwright_worker.py: standalone Playwright worker process supporting CSS, XPath, and snapshot ref selectors (@eN / [ref=eN]) * [packages/dify-plugin]refactor: simplify cdp_client to URL validation only Remove CDP connection logic (cdp_page, _get_active_page, CdpConnectionError) since connections are now managed by the connection pool and playwright worker. Retain only validate_cdp_url for input validation. * [packages/dify-plugin]feat: add snapshot action, connection pool integration and improved error handling browser_operator: - Add snapshot action with accessibility tree (ported from actionbook-rs) - Replace cdp_page with connection pool for persistent sessions - Add SSRF protection for CDP URLs - Improve click/type/fill error messages with recovery hints - Remove screenshot action in favor of snapshot browser_create_session: - Integrate connection pool (pre-connect on session create) - Add profile_id fallback retry logic - Support API key from env vars via resolve_provider_api_key - Validate provider name against SUPPORTED_PROVIDERS browser_stop_session: - Disconnect pooled connection before stopping provider session - Improved error messages with troubleshooting hints hyperbrowser provider: - Add profile_id UUID normalization (uuid5 for non-UUID inputs) actionbook provider: - Add API health check in _validate_credentials * [packages/dify-plugin]fix: improve search/get_action tools with validation and fallback hints - Add area_id character validation regex to prevent injection - Add snapshot fallback guidance in tool descriptions - Reduce debug log verbosity (avoid logging raw parameters) * [packages/dify-plugin]docs: update README workflow guide, manifest version and difyignore - README: add snapshot-based recovery workflow and troubleshooting section - manifest: set version to 0.1.0 (initial release) - difyignore: add scripts/, dist/, .docs/, .DS_Store exclusions * [packages/dify-plugin]chore: remove verify_hyperbrowser script No longer needed — verification is covered by E2E tests. * [packages/dify-plugin]test: update tests for connection pool, snapshot and improved tools - Add test_connection_pool.py: pool connect/disconnect/execute lifecycle - Add test_playwright_worker.py: worker process smoke test - Add test_workflow_snapshot_recovery.py: snapshot-based selector recovery - Update browser_operator tests for snapshot action and error handling - Update browser_create/stop session tests for pool integration - Update cdp_client tests (simplified to URL validation only) - Update provider/search/get_action tests for new validation logic - Replace screenshot references with snapshot in E2E tests * [packages/dify-plugin]refactor: cache provider info in connection pool so stop_session auto-resolves credentials browser_stop_session no longer requires provider/api_key params — they are looked up from the pool via get_session_info(). api_key field in browser_create_session changed to secret-input with form-level input. * [packages/dify-plugin]docs: update README with browser tools and remove unused import - Add documentation for browser_create_session, browser_stop_session, browser_operator tools - Update features list and use cases to reflect browser automation - Remove outdated Phase 2 roadmap (already implemented) - Fix provider names (Hyperbrowser/Steel.dev, not Browserbase) - Note that browser_stop_session auto-resolves provider/api_key - Remove unused SUPPORTED_PROVIDERS import from browser_stop_session.py * [packages/dify-plugin]fix: correct YAML descriptions and add zh_Hans translations - Fix browser_create_session llm_description referencing non-existent tool names (browser_navigate → browser_operator) - Remove Steel.dev mention from descriptions (not yet available) - Update manifest description to reflect browser automation capabilities - Add proper zh_Hans translations for search_actions, get_action_by_area_id, manifest and provider YAML files * [packages/dify-plugin]fix: address CodeQL URL sanitization and logging findings * [packages/dify-plugin]fix: remove dynamic data from error logs * [packages/dify-plugin]feat: add tag-based release workflow and version sync Add automated dify-plugin release pipeline: - release.yml: tag detection (actionbook-dify-plugin-v{VERSION}) and release job that packages .difypkg via uv + creates GitHub Release - sync-versions.js: sync package.json version to manifest.yaml (root + meta.version) and pyproject.toml with exact equality checks and fail-loud on missing fields * [packages/dify-plugin]fix: handle stop/create session failure recovery * [packages/dify-plugin]fix: resolve critical review findings — deadlock, exception safety, timeout handling - Fix connection pool deadlock: move worker.stop() outside pool lock to prevent deadlock when _io_lock is held by another thread in call()/readline(). All call sites (disconnect, disconnect_all, cleanup_stale, connect eviction, get_page health check) now pop under lock and stop outside lock. - Fix BaseException swallowing: re-raise KeyboardInterrupt, SystemExit, and GeneratorExit in search_actions and get_action_by_area_id generators. Only non-Exception BaseException subclasses (e.g. gevent.Timeout) are caught. - Fix timeout exception misclassification: add WorkerTimeoutError exception class. SubprocessWorker.call() now detects timeout errors in worker responses and raises WorkerTimeoutError instead of RuntimeError, so browser_operator handlers can show proper "element not found" hints instead of generic errors. - Document SSRF DNS rebinding TOCTOU limitation in _is_ssrf_target with explanation of cloud browser architecture mitigation. --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
365 lines
13 KiB
Python
365 lines
13 KiB
Python
"""E2E browser tests — real Hyperbrowser sessions.
|
|
|
|
Tests are split into two categories:
|
|
|
|
1. **Tool wrapper tests**: Each test creates its own session, does ONE operator
|
|
call, and stops. This validates the real Dify tool behavior accurately
|
|
(each tool call = one CDP connect/disconnect cycle).
|
|
|
|
2. **Playwright direct tests**: Keep a single CDP connection open for multi-step
|
|
workflows (navigate → click → fill → snapshot). This is how real browser
|
|
automation works — the Dify workflow would need to keep the connection alive.
|
|
|
|
Run with:
|
|
HYPERBROWSER_API_KEY=hb-xxx uv run pytest -m e2e -v --timeout=120 --no-cov
|
|
"""
|
|
|
|
import logging
|
|
import ssl
|
|
import time
|
|
from urllib.parse import urlparse
|
|
|
|
import pytest
|
|
from playwright.sync_api import sync_playwright
|
|
|
|
from tests.e2e.conftest import _is_ssl_error, parse_session_output
|
|
from tools.browser_create_session import BrowserCreateSessionTool
|
|
from tools.browser_operator import BrowserOperatorTool
|
|
from tools.browser_stop_session import BrowserStopSessionTool
|
|
|
|
pytestmark = pytest.mark.e2e
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
MAX_RETRIES = 3
|
|
RETRY_DELAY = 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_create_tool() -> BrowserCreateSessionTool:
|
|
return BrowserCreateSessionTool.from_credentials({})
|
|
|
|
|
|
def _make_operator_tool() -> BrowserOperatorTool:
|
|
return BrowserOperatorTool.from_credentials({})
|
|
|
|
|
|
def _make_stop_tool() -> BrowserStopSessionTool:
|
|
return BrowserStopSessionTool.from_credentials({})
|
|
|
|
|
|
def _invoke_with_retry(tool, params: dict, retries: int = MAX_RETRIES) -> list:
|
|
"""Invoke a Dify tool with retry for gevent SSL errors."""
|
|
last_exc = None
|
|
for attempt in range(1, retries + 1):
|
|
try:
|
|
return list(tool._invoke(params))
|
|
except (ssl.SSLError, OSError, ConnectionError) as exc:
|
|
last_exc = exc
|
|
if attempt < retries and _is_ssl_error(exc):
|
|
logger.warning(
|
|
"SSL error in tool invoke (attempt %d/%d): %s — retrying",
|
|
attempt, retries, exc,
|
|
)
|
|
time.sleep(RETRY_DELAY)
|
|
else:
|
|
raise
|
|
except Exception as exc:
|
|
if _is_ssl_error(exc):
|
|
last_exc = exc
|
|
if attempt < retries:
|
|
logger.warning(
|
|
"SSL error in tool invoke (attempt %d/%d): %s — retrying",
|
|
attempt, retries, exc,
|
|
)
|
|
time.sleep(RETRY_DELAY)
|
|
else:
|
|
raise
|
|
else:
|
|
raise
|
|
raise last_exc # type: ignore[misc]
|
|
|
|
|
|
def _connect_cdp_with_retry(playwright, ws_endpoint: str, retries: int = MAX_RETRIES):
|
|
"""Connect to CDP with retry for gevent SSL errors."""
|
|
last_exc = None
|
|
for attempt in range(1, retries + 1):
|
|
try:
|
|
return playwright.chromium.connect_over_cdp(ws_endpoint, timeout=30000)
|
|
except (ssl.SSLError, OSError, ConnectionError) as exc:
|
|
last_exc = exc
|
|
if attempt < retries and _is_ssl_error(exc):
|
|
logger.warning(
|
|
"SSL error in CDP connect (attempt %d/%d): %s — retrying",
|
|
attempt, retries, exc,
|
|
)
|
|
time.sleep(RETRY_DELAY)
|
|
else:
|
|
raise
|
|
except Exception as exc:
|
|
if _is_ssl_error(exc):
|
|
last_exc = exc
|
|
if attempt < retries:
|
|
logger.warning(
|
|
"SSL error in CDP connect (attempt %d/%d): %s — retrying",
|
|
attempt, retries, exc,
|
|
)
|
|
time.sleep(RETRY_DELAY)
|
|
else:
|
|
raise
|
|
else:
|
|
raise
|
|
raise last_exc # type: ignore[misc]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Tool wrapper tests (one operation per session)
|
|
#
|
|
# Why one-op-per-session: browser_operator's cdp_page() opens a new Playwright
|
|
# connection and closes it after each _invoke(). Hyperbrowser sessions become
|
|
# unreachable (404) after the CDP client disconnects, so only the first
|
|
# connect_over_cdp succeeds.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestToolCreateAndStop:
|
|
"""Validate create_session and stop_session tools end-to-end."""
|
|
|
|
def test_create_session_returns_ws_endpoint(self, hyperbrowser_api_key):
|
|
"""create_session should return valid ws_endpoint and session_id."""
|
|
create_tool = _make_create_tool()
|
|
stop_tool = _make_stop_tool()
|
|
|
|
result = _invoke_with_retry(create_tool, {
|
|
"provider": "hyperbrowser",
|
|
"api_key": hyperbrowser_api_key,
|
|
})
|
|
|
|
assert len(result) == 1
|
|
text = result[0].message.text
|
|
assert "Error" not in text, f"Session creation failed: {text}"
|
|
|
|
session = parse_session_output(text)
|
|
assert session["ws_endpoint"].startswith("wss://")
|
|
assert len(session["session_id"]) > 0
|
|
assert session["provider"] == "hyperbrowser"
|
|
|
|
# Clean up
|
|
stop_result = _invoke_with_retry(stop_tool, {
|
|
"provider": "hyperbrowser",
|
|
"api_key": hyperbrowser_api_key,
|
|
"session_id": session["session_id"],
|
|
})
|
|
assert "stopped" in stop_result[0].message.text.lower()
|
|
|
|
def test_stop_session_works(self, hyperbrowser_api_key):
|
|
"""stop_session should succeed for an active session."""
|
|
create_tool = _make_create_tool()
|
|
stop_tool = _make_stop_tool()
|
|
|
|
create_text = _invoke_with_retry(create_tool, {
|
|
"provider": "hyperbrowser",
|
|
"api_key": hyperbrowser_api_key,
|
|
})[0].message.text
|
|
session = parse_session_output(create_text)
|
|
|
|
result = _invoke_with_retry(stop_tool, {
|
|
"provider": "hyperbrowser",
|
|
"api_key": hyperbrowser_api_key,
|
|
"session_id": session["session_id"],
|
|
})
|
|
|
|
assert len(result) == 1
|
|
assert "stopped" in result[0].message.text.lower()
|
|
assert session["session_id"] in result[0].message.text
|
|
|
|
|
|
class TestToolSingleOperation:
|
|
"""Each test creates a fresh session, does ONE browser operation, then stops.
|
|
|
|
This validates that each individual browser_operator action works through
|
|
the real Dify tool pipeline.
|
|
"""
|
|
|
|
def test_navigate(self, hyperbrowser_api_key):
|
|
"""navigate action works end-to-end."""
|
|
create_tool = _make_create_tool()
|
|
operator_tool = _make_operator_tool()
|
|
stop_tool = _make_stop_tool()
|
|
|
|
create_text = _invoke_with_retry(create_tool, {
|
|
"provider": "hyperbrowser",
|
|
"api_key": hyperbrowser_api_key,
|
|
})[0].message.text
|
|
session = parse_session_output(create_text)
|
|
|
|
try:
|
|
result = _invoke_with_retry(operator_tool, {
|
|
"cdp_url": session["ws_endpoint"],
|
|
"action": "navigate",
|
|
"url": "https://example.com",
|
|
"timeout_ms": 30000,
|
|
})
|
|
|
|
assert len(result) == 1
|
|
assert "Navigation successful" in result[0].message.text
|
|
assert "Example Domain" in result[0].message.text
|
|
finally:
|
|
_invoke_with_retry(stop_tool, {
|
|
"provider": "hyperbrowser",
|
|
"api_key": hyperbrowser_api_key,
|
|
"session_id": session["session_id"],
|
|
})
|
|
|
|
def test_snapshot(self, hyperbrowser_api_key):
|
|
"""snapshot action returns accessibility tree text."""
|
|
create_tool = _make_create_tool()
|
|
operator_tool = _make_operator_tool()
|
|
stop_tool = _make_stop_tool()
|
|
|
|
create_text = _invoke_with_retry(create_tool, {
|
|
"provider": "hyperbrowser",
|
|
"api_key": hyperbrowser_api_key,
|
|
})[0].message.text
|
|
session = parse_session_output(create_text)
|
|
|
|
try:
|
|
result = _invoke_with_retry(operator_tool, {
|
|
"cdp_url": session["ws_endpoint"],
|
|
"action": "snapshot",
|
|
})
|
|
|
|
assert len(result) == 1
|
|
text = result[0].message.text
|
|
assert "Page snapshot" in text
|
|
assert "Interactive elements" in text
|
|
finally:
|
|
_invoke_with_retry(stop_tool, {
|
|
"provider": "hyperbrowser",
|
|
"api_key": hyperbrowser_api_key,
|
|
"session_id": session["session_id"],
|
|
})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Playwright direct tests (multi-step with single connection)
|
|
#
|
|
# These use the hyperbrowser_session fixture which creates a session via SDK
|
|
# and provides the ws_endpoint. Playwright keeps the connection open for the
|
|
# entire test, enabling multi-step workflows.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPlaywrightDirect:
|
|
"""Multi-step browser workflows using Playwright directly.
|
|
|
|
These tests validate the full browser automation chain that a Dify workflow
|
|
would execute when keeping a single CDP connection alive.
|
|
"""
|
|
|
|
def test_navigate_read_text_snapshot(self, hyperbrowser_session):
|
|
"""Navigate → read text → snapshot lifecycle."""
|
|
ws = hyperbrowser_session["ws_endpoint"]
|
|
|
|
with sync_playwright() as p:
|
|
browser = _connect_cdp_with_retry(p, ws)
|
|
try:
|
|
ctx = browser.contexts[0] if browser.contexts else browser.new_context()
|
|
page = ctx.pages[0] if ctx.pages else ctx.new_page()
|
|
|
|
# Navigate
|
|
page.goto("https://example.com", wait_until="domcontentloaded", timeout=30000)
|
|
assert page.title() == "Example Domain"
|
|
|
|
# Read text
|
|
body = page.inner_text("body")
|
|
assert "Example Domain" in body
|
|
|
|
# Snapshot
|
|
snap = page.accessibility.snapshot()
|
|
assert snap is not None
|
|
finally:
|
|
browser.close()
|
|
|
|
def test_form_fill_workflow(self, hyperbrowser_session):
|
|
"""Navigate → wait → fill → read: simulates form interaction."""
|
|
ws = hyperbrowser_session["ws_endpoint"]
|
|
|
|
with sync_playwright() as p:
|
|
browser = _connect_cdp_with_retry(p, ws)
|
|
try:
|
|
ctx = browser.contexts[0] if browser.contexts else browser.new_context()
|
|
page = ctx.pages[0] if ctx.pages else ctx.new_page()
|
|
|
|
# Navigate to form page
|
|
page.goto(
|
|
"https://httpbin.org/forms/post",
|
|
wait_until="domcontentloaded",
|
|
timeout=30000,
|
|
)
|
|
|
|
# Wait for form
|
|
page.wait_for_selector("input[name='custname']", timeout=10000)
|
|
|
|
# Fill
|
|
page.fill("input[name='custname']", "Actionbook E2E")
|
|
|
|
# Verify fill worked
|
|
value = page.input_value("input[name='custname']")
|
|
assert value == "Actionbook E2E"
|
|
finally:
|
|
browser.close()
|
|
|
|
def test_navigation_history(self, hyperbrowser_session):
|
|
"""Navigate two pages → history.back() → history.forward()."""
|
|
ws = hyperbrowser_session["ws_endpoint"]
|
|
|
|
with sync_playwright() as p:
|
|
browser = _connect_cdp_with_retry(p, ws)
|
|
try:
|
|
ctx = browser.contexts[0] if browser.contexts else browser.new_context()
|
|
page = ctx.pages[0] if ctx.pages else ctx.new_page()
|
|
|
|
# Page 1
|
|
page.goto("https://example.com", wait_until="domcontentloaded", timeout=30000)
|
|
assert urlparse(page.url).hostname == "example.com"
|
|
|
|
# Page 2
|
|
page.goto("https://httpbin.org/html", wait_until="domcontentloaded", timeout=30000)
|
|
assert urlparse(page.url).hostname == "httpbin.org"
|
|
|
|
# Go back via JS (avoids Playwright's navigation wait which times
|
|
# out on back-forward cache hits in remote browsers)
|
|
page.evaluate("() => window.history.back()")
|
|
page.wait_for_timeout(2000)
|
|
assert urlparse(page.url).hostname == "example.com"
|
|
|
|
# Go forward via JS
|
|
page.evaluate("() => window.history.forward()")
|
|
page.wait_for_timeout(2000)
|
|
assert urlparse(page.url).hostname == "httpbin.org"
|
|
finally:
|
|
browser.close()
|
|
|
|
def test_get_html_content(self, hyperbrowser_session):
|
|
"""Navigate and retrieve HTML content."""
|
|
ws = hyperbrowser_session["ws_endpoint"]
|
|
|
|
with sync_playwright() as p:
|
|
browser = _connect_cdp_with_retry(p, ws)
|
|
try:
|
|
ctx = browser.contexts[0] if browser.contexts else browser.new_context()
|
|
page = ctx.pages[0] if ctx.pages else ctx.new_page()
|
|
|
|
page.goto("https://example.com", wait_until="domcontentloaded", timeout=30000)
|
|
|
|
html = page.content()
|
|
assert "<h1>Example Domain</h1>" in html
|
|
assert "<!doctype html>" in html.lower() or "<html" in html.lower()
|
|
finally:
|
|
browser.close()
|