feat: add ActionBook browser automation tools
Integrate ActionBook CLI as three native browser automation tools: - browser_search: find action manuals for websites - browser_get: retrieve CSS selectors and page structure by ID - browser: execute browser commands (open, click, fill, text, etc.) Zero new dependencies — thin CLI wrapper using os/exec. Config-gated via tools.browser.enabled (default: true). 18 unit tests + 1 integration test, all passing. Closes: browser automation support for agent loop
This commit is contained in:
parent
13e4028d42
commit
62d2c5eeed
4 changed files with 712 additions and 1 deletions
|
|
@ -84,6 +84,13 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg
|
|||
}
|
||||
registry.Register(tools.NewWebFetchTool(50000))
|
||||
|
||||
// Browser automation tools (ActionBook)
|
||||
if cfg.Tools.Browser.Enabled {
|
||||
registry.Register(tools.NewBrowserSearchTool(cfg.Tools.Browser.Headless))
|
||||
registry.Register(tools.NewBrowserGetTool())
|
||||
registry.Register(tools.NewBrowserTool(cfg.Tools.Browser.Headless))
|
||||
}
|
||||
|
||||
// Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
|
||||
registry.Register(tools.NewI2CTool())
|
||||
registry.Register(tools.NewSPITool())
|
||||
|
|
|
|||
|
|
@ -211,8 +211,14 @@ type WebToolsConfig struct {
|
|||
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
|
||||
}
|
||||
|
||||
type BrowserConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_BROWSER_ENABLED"`
|
||||
Headless bool `json:"headless" env:"PICOCLAW_TOOLS_BROWSER_HEADLESS"`
|
||||
}
|
||||
|
||||
type ToolsConfig struct {
|
||||
Web WebToolsConfig `json:"web"`
|
||||
Browser BrowserConfig `json:"browser"`
|
||||
}
|
||||
|
||||
func DefaultConfig() *Config {
|
||||
|
|
@ -322,6 +328,10 @@ func DefaultConfig() *Config {
|
|||
MaxResults: 5,
|
||||
},
|
||||
},
|
||||
Browser: BrowserConfig{
|
||||
Enabled: true,
|
||||
Headless: true,
|
||||
},
|
||||
},
|
||||
Heartbeat: HeartbeatConfig{
|
||||
Enabled: true,
|
||||
|
|
|
|||
411
pkg/tools/browser.go
Normal file
411
pkg/tools/browser.go
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// actionbookBinary caches the resolved path to the actionbook CLI.
|
||||
var actionbookBinary string
|
||||
|
||||
func resolveActionbook() (string, error) {
|
||||
if actionbookBinary != "" {
|
||||
return actionbookBinary, nil
|
||||
}
|
||||
path, err := exec.LookPath("actionbook")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("actionbook CLI not found in PATH: %w", err)
|
||||
}
|
||||
actionbookBinary = path
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// runActionbook executes an actionbook CLI command with a timeout.
|
||||
func runActionbook(ctx context.Context, timeout time.Duration, args ...string) (string, error) {
|
||||
bin, err := resolveActionbook()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(cmdCtx, bin, args...)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
if cmdCtx.Err() == context.DeadlineExceeded {
|
||||
return "", fmt.Errorf("actionbook command timed out after %v", timeout)
|
||||
}
|
||||
// Many actionbook commands write useful output even on non-zero exit.
|
||||
// Return stdout+stderr as output with the error.
|
||||
out := stdout.String()
|
||||
if stderr.Len() > 0 {
|
||||
out += "\nSTDERR: " + stderr.String()
|
||||
}
|
||||
if out != "" {
|
||||
return out, nil // treat as non-fatal; LLM can interpret
|
||||
}
|
||||
return "", fmt.Errorf("actionbook failed: %w — %s", err, stderr.String())
|
||||
}
|
||||
|
||||
return stdout.String(), nil
|
||||
}
|
||||
|
||||
// truncateOutput limits output length to avoid blowing up context.
|
||||
func truncateOutput(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + fmt.Sprintf("\n... (truncated, %d more chars)", len(s)-max)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BrowserSearchTool — actionbook search
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// BrowserSearchTool wraps `actionbook search` to find action manuals.
|
||||
type BrowserSearchTool struct {
|
||||
headless bool
|
||||
}
|
||||
|
||||
func NewBrowserSearchTool(headless bool) *BrowserSearchTool {
|
||||
return &BrowserSearchTool{headless: headless}
|
||||
}
|
||||
|
||||
func (t *BrowserSearchTool) Name() string { return "browser_search" }
|
||||
func (t *BrowserSearchTool) Description() string {
|
||||
return "Search ActionBook for browser action manuals matching a task. Returns action IDs, descriptions, URLs, and health scores. Use the returned ID with browser_get to retrieve selectors."
|
||||
}
|
||||
|
||||
func (t *BrowserSearchTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"query": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Search query describing the task (e.g. 'airbnb search listings Tokyo')",
|
||||
},
|
||||
"domain": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Optional domain to filter results (e.g. 'airbnb.com')",
|
||||
},
|
||||
},
|
||||
"required": []string{"query"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *BrowserSearchTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||
query, ok := args["query"].(string)
|
||||
if !ok || query == "" {
|
||||
return ErrorResult("query is required")
|
||||
}
|
||||
|
||||
cmdArgs := []string{"search", query, "--json"}
|
||||
if domain, ok := args["domain"].(string); ok && domain != "" {
|
||||
cmdArgs = append(cmdArgs, "--domain", domain)
|
||||
}
|
||||
|
||||
output, err := runActionbook(ctx, 30*time.Second, cmdArgs...)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("browser_search failed: %v", err))
|
||||
}
|
||||
|
||||
output = truncateOutput(output, 10000)
|
||||
return &ToolResult{
|
||||
ForLLM: output,
|
||||
ForUser: output,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BrowserGetTool — actionbook get
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// BrowserGetTool wraps `actionbook get` to retrieve action details.
|
||||
type BrowserGetTool struct{}
|
||||
|
||||
func NewBrowserGetTool() *BrowserGetTool { return &BrowserGetTool{} }
|
||||
|
||||
func (t *BrowserGetTool) Name() string { return "browser_get" }
|
||||
func (t *BrowserGetTool) Description() string {
|
||||
return "Retrieve a specific ActionBook action manual by its ID. Returns page structure with CSS selectors, element types, and allowed methods. Use the ID from browser_search results."
|
||||
}
|
||||
|
||||
func (t *BrowserGetTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"action_id": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Action manual ID from browser_search (e.g. 'airbnb.com:/:default')",
|
||||
},
|
||||
},
|
||||
"required": []string{"action_id"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *BrowserGetTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||
actionID, ok := args["action_id"].(string)
|
||||
if !ok || actionID == "" {
|
||||
return ErrorResult("action_id is required")
|
||||
}
|
||||
|
||||
output, err := runActionbook(ctx, 30*time.Second, "get", actionID, "--json")
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("browser_get failed: %v", err))
|
||||
}
|
||||
|
||||
output = truncateOutput(output, 15000)
|
||||
return &ToolResult{
|
||||
ForLLM: output,
|
||||
ForUser: output,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BrowserTool — actionbook browser *
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// allowedBrowserActions is the set of browser subcommands the LLM may invoke.
|
||||
var allowedBrowserActions = map[string]bool{
|
||||
"open": true,
|
||||
"goto": true,
|
||||
"click": true,
|
||||
"fill": true,
|
||||
"type": true,
|
||||
"select": true,
|
||||
"hover": true,
|
||||
"focus": true,
|
||||
"press": true,
|
||||
"text": true,
|
||||
"snapshot": true,
|
||||
"screenshot": true,
|
||||
"wait": true,
|
||||
"wait-nav": true,
|
||||
"back": true,
|
||||
"forward": true,
|
||||
"reload": true,
|
||||
"close": true,
|
||||
"pages": true,
|
||||
"switch": true,
|
||||
"eval": true,
|
||||
"html": true,
|
||||
"pdf": true,
|
||||
"cookies": true,
|
||||
"status": true,
|
||||
"viewport": true,
|
||||
}
|
||||
|
||||
// BrowserTool wraps `actionbook browser` for browser automation.
|
||||
type BrowserTool struct {
|
||||
headless bool
|
||||
}
|
||||
|
||||
func NewBrowserTool(headless bool) *BrowserTool {
|
||||
return &BrowserTool{headless: headless}
|
||||
}
|
||||
|
||||
func (t *BrowserTool) Name() string { return "browser" }
|
||||
func (t *BrowserTool) Description() string {
|
||||
return `Execute browser automation commands via ActionBook. Supported actions: open, goto, click, fill, type, select, hover, focus, press, text, snapshot, screenshot, wait, wait-nav, back, forward, reload, close, pages, switch, eval, html, pdf, cookies, status, viewport. Typical workflow: browser_search → browser_get → browser (open → interact → close).`
|
||||
}
|
||||
|
||||
func (t *BrowserTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"action": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Browser action to perform (e.g. 'open', 'click', 'fill', 'text', 'snapshot', 'close')",
|
||||
},
|
||||
"url": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "URL for open/goto actions",
|
||||
},
|
||||
"selector": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "CSS selector for click/fill/type/select/hover/focus/wait/text/html actions",
|
||||
},
|
||||
"value": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Value for fill/type/select/press/eval/switch actions",
|
||||
},
|
||||
"timeout": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "Timeout in milliseconds for wait/wait-nav actions (default: 30000)",
|
||||
},
|
||||
"full_page": map[string]interface{}{
|
||||
"type": "boolean",
|
||||
"description": "For screenshot: capture full page instead of viewport",
|
||||
},
|
||||
},
|
||||
"required": []string{"action"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *BrowserTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||
action, ok := args["action"].(string)
|
||||
if !ok || action == "" {
|
||||
return ErrorResult("action is required")
|
||||
}
|
||||
|
||||
action = strings.TrimSpace(strings.ToLower(action))
|
||||
if !allowedBrowserActions[action] {
|
||||
return ErrorResult(fmt.Sprintf("unknown browser action %q — allowed: open, goto, click, fill, type, select, hover, focus, press, text, snapshot, screenshot, wait, wait-nav, back, forward, reload, close, pages, switch, eval, html, pdf, cookies, status, viewport", action))
|
||||
}
|
||||
|
||||
// Build argument list
|
||||
cmdArgs := []string{}
|
||||
|
||||
// Global flags
|
||||
if t.headless {
|
||||
cmdArgs = append(cmdArgs, "--headless")
|
||||
}
|
||||
|
||||
cmdArgs = append(cmdArgs, "browser", action)
|
||||
|
||||
switch action {
|
||||
case "open", "goto":
|
||||
if u, ok := args["url"].(string); ok && u != "" {
|
||||
cmdArgs = append(cmdArgs, u)
|
||||
} else {
|
||||
return ErrorResult(fmt.Sprintf("url is required for %s action", action))
|
||||
}
|
||||
if timeout, ok := getIntArg(args, "timeout"); ok {
|
||||
cmdArgs = append(cmdArgs, "--timeout", fmt.Sprintf("%d", timeout))
|
||||
}
|
||||
|
||||
case "click", "hover", "focus":
|
||||
sel, ok := args["selector"].(string)
|
||||
if !ok || sel == "" {
|
||||
return ErrorResult(fmt.Sprintf("selector is required for %s action", action))
|
||||
}
|
||||
cmdArgs = append(cmdArgs, sel)
|
||||
if timeout, ok := getIntArg(args, "timeout"); ok {
|
||||
cmdArgs = append(cmdArgs, "--wait", fmt.Sprintf("%d", timeout))
|
||||
}
|
||||
|
||||
case "fill", "type":
|
||||
sel, ok := args["selector"].(string)
|
||||
if !ok || sel == "" {
|
||||
return ErrorResult(fmt.Sprintf("selector is required for %s action", action))
|
||||
}
|
||||
val, ok := args["value"].(string)
|
||||
if !ok {
|
||||
return ErrorResult(fmt.Sprintf("value is required for %s action", action))
|
||||
}
|
||||
cmdArgs = append(cmdArgs, sel, val)
|
||||
if timeout, ok := getIntArg(args, "timeout"); ok {
|
||||
cmdArgs = append(cmdArgs, "--wait", fmt.Sprintf("%d", timeout))
|
||||
}
|
||||
|
||||
case "select":
|
||||
sel, ok := args["selector"].(string)
|
||||
if !ok || sel == "" {
|
||||
return ErrorResult("selector is required for select action")
|
||||
}
|
||||
val, ok := args["value"].(string)
|
||||
if !ok || val == "" {
|
||||
return ErrorResult("value is required for select action")
|
||||
}
|
||||
cmdArgs = append(cmdArgs, sel, val)
|
||||
|
||||
case "press":
|
||||
key, ok := args["value"].(string)
|
||||
if !ok || key == "" {
|
||||
return ErrorResult("value is required for press action (e.g. 'Enter', 'Tab')")
|
||||
}
|
||||
cmdArgs = append(cmdArgs, key)
|
||||
|
||||
case "wait":
|
||||
sel, ok := args["selector"].(string)
|
||||
if !ok || sel == "" {
|
||||
return ErrorResult("selector is required for wait action")
|
||||
}
|
||||
cmdArgs = append(cmdArgs, sel)
|
||||
if timeout, ok := getIntArg(args, "timeout"); ok {
|
||||
cmdArgs = append(cmdArgs, "--timeout", fmt.Sprintf("%d", timeout))
|
||||
}
|
||||
|
||||
case "wait-nav":
|
||||
if timeout, ok := getIntArg(args, "timeout"); ok {
|
||||
cmdArgs = append(cmdArgs, "--timeout", fmt.Sprintf("%d", timeout))
|
||||
}
|
||||
|
||||
case "switch":
|
||||
pageID, ok := args["value"].(string)
|
||||
if !ok || pageID == "" {
|
||||
return ErrorResult("value (page_id) is required for switch action")
|
||||
}
|
||||
cmdArgs = append(cmdArgs, pageID)
|
||||
|
||||
case "eval":
|
||||
script, ok := args["value"].(string)
|
||||
if !ok || script == "" {
|
||||
return ErrorResult("value (javascript expression) is required for eval action")
|
||||
}
|
||||
cmdArgs = append(cmdArgs, script)
|
||||
|
||||
case "text", "html":
|
||||
if sel, ok := args["selector"].(string); ok && sel != "" {
|
||||
cmdArgs = append(cmdArgs, sel)
|
||||
}
|
||||
|
||||
case "screenshot":
|
||||
if fullPage, ok := args["full_page"].(bool); ok && fullPage {
|
||||
cmdArgs = append(cmdArgs, "--full-page")
|
||||
}
|
||||
|
||||
case "cookies":
|
||||
// cookies sub-actions via value: list, get <name>, set <name> <val>, delete <name>, clear
|
||||
if val, ok := args["value"].(string); ok && val != "" {
|
||||
cmdArgs = append(cmdArgs, strings.Fields(val)...)
|
||||
}
|
||||
|
||||
// For actions with no extra args: back, forward, reload, close, pages, snapshot, status, viewport, pdf
|
||||
// — nothing extra needed, cmdArgs already has "browser" and action.
|
||||
}
|
||||
|
||||
// Determine timeout
|
||||
cmdTimeout := 30 * time.Second
|
||||
if action == "screenshot" || action == "pdf" {
|
||||
cmdTimeout = 60 * time.Second
|
||||
}
|
||||
|
||||
output, err := runActionbook(ctx, cmdTimeout, cmdArgs...)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("browser %s failed: %v", action, err))
|
||||
}
|
||||
|
||||
if output == "" {
|
||||
output = "(no output)"
|
||||
}
|
||||
|
||||
output = truncateOutput(output, 15000)
|
||||
return &ToolResult{
|
||||
ForLLM: output,
|
||||
ForUser: output,
|
||||
}
|
||||
}
|
||||
|
||||
// getIntArg extracts an integer argument from the args map.
|
||||
// JSON numbers arrive as float64.
|
||||
func getIntArg(args map[string]interface{}, key string) (int, bool) {
|
||||
if v, ok := args[key].(float64); ok {
|
||||
return int(v), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
283
pkg/tools/browser_test.go
Normal file
283
pkg/tools/browser_test.go
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestBrowserSearchTool_Name verifies tool name
|
||||
func TestBrowserSearchTool_Name(t *testing.T) {
|
||||
tool := NewBrowserSearchTool(true)
|
||||
if tool.Name() != "browser_search" {
|
||||
t.Errorf("expected name 'browser_search', got '%s'", tool.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserSearchTool_Description verifies description is non-empty
|
||||
func TestBrowserSearchTool_Description(t *testing.T) {
|
||||
tool := NewBrowserSearchTool(true)
|
||||
if tool.Description() == "" {
|
||||
t.Error("expected non-empty description")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserSearchTool_Parameters verifies parameter schema
|
||||
func TestBrowserSearchTool_Parameters(t *testing.T) {
|
||||
tool := NewBrowserSearchTool(true)
|
||||
params := tool.Parameters()
|
||||
props, ok := params["properties"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected properties map")
|
||||
}
|
||||
if _, ok := props["query"]; !ok {
|
||||
t.Error("expected 'query' parameter")
|
||||
}
|
||||
if _, ok := props["domain"]; !ok {
|
||||
t.Error("expected 'domain' parameter")
|
||||
}
|
||||
required, ok := params["required"].([]string)
|
||||
if !ok || len(required) != 1 || required[0] != "query" {
|
||||
t.Error("expected 'query' in required list")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserSearchTool_MissingQuery verifies error when query is missing
|
||||
func TestBrowserSearchTool_MissingQuery(t *testing.T) {
|
||||
tool := NewBrowserSearchTool(true)
|
||||
result := tool.Execute(context.Background(), map[string]interface{}{})
|
||||
if !result.IsError {
|
||||
t.Error("expected error when query is missing")
|
||||
}
|
||||
if !strings.Contains(result.ForLLM, "query is required") {
|
||||
t.Errorf("expected 'query is required' in error, got: %s", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserGetTool_Name verifies tool name
|
||||
func TestBrowserGetTool_Name(t *testing.T) {
|
||||
tool := NewBrowserGetTool()
|
||||
if tool.Name() != "browser_get" {
|
||||
t.Errorf("expected name 'browser_get', got '%s'", tool.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserGetTool_MissingID verifies error when action_id is missing
|
||||
func TestBrowserGetTool_MissingID(t *testing.T) {
|
||||
tool := NewBrowserGetTool()
|
||||
result := tool.Execute(context.Background(), map[string]interface{}{})
|
||||
if !result.IsError {
|
||||
t.Error("expected error when action_id is missing")
|
||||
}
|
||||
if !strings.Contains(result.ForLLM, "action_id is required") {
|
||||
t.Errorf("expected 'action_id is required' in error, got: %s", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserTool_Name verifies tool name
|
||||
func TestBrowserTool_Name(t *testing.T) {
|
||||
tool := NewBrowserTool(true)
|
||||
if tool.Name() != "browser" {
|
||||
t.Errorf("expected name 'browser', got '%s'", tool.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserTool_MissingAction verifies error when action is missing
|
||||
func TestBrowserTool_MissingAction(t *testing.T) {
|
||||
tool := NewBrowserTool(true)
|
||||
result := tool.Execute(context.Background(), map[string]interface{}{})
|
||||
if !result.IsError {
|
||||
t.Error("expected error when action is missing")
|
||||
}
|
||||
if !strings.Contains(result.ForLLM, "action is required") {
|
||||
t.Errorf("expected 'action is required' in error, got: %s", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserTool_InvalidAction verifies rejection of unknown actions
|
||||
func TestBrowserTool_InvalidAction(t *testing.T) {
|
||||
tool := NewBrowserTool(true)
|
||||
result := tool.Execute(context.Background(), map[string]interface{}{
|
||||
"action": "malicious_command",
|
||||
})
|
||||
if !result.IsError {
|
||||
t.Error("expected error for invalid action")
|
||||
}
|
||||
if !strings.Contains(result.ForLLM, "unknown browser action") {
|
||||
t.Errorf("expected 'unknown browser action' in error, got: %s", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserTool_ActionAllowlist verifies all valid actions pass validation
|
||||
func TestBrowserTool_ActionAllowlist(t *testing.T) {
|
||||
// Only test the allowlist check, not actual execution
|
||||
for action := range allowedBrowserActions {
|
||||
if !allowedBrowserActions[action] {
|
||||
t.Errorf("action %q should be in allowlist", action)
|
||||
}
|
||||
}
|
||||
|
||||
invalidActions := []string{"rm", "exec", "sudo", "rm -rf", "shell", ""}
|
||||
for _, action := range invalidActions {
|
||||
if allowedBrowserActions[action] {
|
||||
t.Errorf("action %q should NOT be in allowlist", action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserTool_OpenRequiresURL verifies open action needs url parameter
|
||||
func TestBrowserTool_OpenRequiresURL(t *testing.T) {
|
||||
tool := NewBrowserTool(true)
|
||||
result := tool.Execute(context.Background(), map[string]interface{}{
|
||||
"action": "open",
|
||||
})
|
||||
if !result.IsError {
|
||||
t.Error("expected error when url is missing for open")
|
||||
}
|
||||
if !strings.Contains(result.ForLLM, "url is required") {
|
||||
t.Errorf("expected 'url is required' in error, got: %s", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserTool_ClickRequiresSelector verifies click action needs selector
|
||||
func TestBrowserTool_ClickRequiresSelector(t *testing.T) {
|
||||
tool := NewBrowserTool(true)
|
||||
result := tool.Execute(context.Background(), map[string]interface{}{
|
||||
"action": "click",
|
||||
})
|
||||
if !result.IsError {
|
||||
t.Error("expected error when selector is missing for click")
|
||||
}
|
||||
if !strings.Contains(result.ForLLM, "selector is required") {
|
||||
t.Errorf("expected 'selector is required' in error, got: %s", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserTool_FillRequiresSelectorAndValue verifies fill needs both params
|
||||
func TestBrowserTool_FillRequiresSelectorAndValue(t *testing.T) {
|
||||
tool := NewBrowserTool(true)
|
||||
|
||||
// Missing selector
|
||||
result := tool.Execute(context.Background(), map[string]interface{}{
|
||||
"action": "fill",
|
||||
"value": "text",
|
||||
})
|
||||
if !result.IsError {
|
||||
t.Error("expected error when selector is missing for fill")
|
||||
}
|
||||
|
||||
// Missing value
|
||||
result = tool.Execute(context.Background(), map[string]interface{}{
|
||||
"action": "fill",
|
||||
"selector": "#input",
|
||||
})
|
||||
if !result.IsError {
|
||||
t.Error("expected error when value is missing for fill")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserTool_PressRequiresValue verifies press needs key value
|
||||
func TestBrowserTool_PressRequiresValue(t *testing.T) {
|
||||
tool := NewBrowserTool(true)
|
||||
result := tool.Execute(context.Background(), map[string]interface{}{
|
||||
"action": "press",
|
||||
})
|
||||
if !result.IsError {
|
||||
t.Error("expected error when value is missing for press")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserTool_EvalRequiresValue verifies eval needs script value
|
||||
func TestBrowserTool_EvalRequiresValue(t *testing.T) {
|
||||
tool := NewBrowserTool(true)
|
||||
result := tool.Execute(context.Background(), map[string]interface{}{
|
||||
"action": "eval",
|
||||
})
|
||||
if !result.IsError {
|
||||
t.Error("expected error when value is missing for eval")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserTool_WaitRequiresSelector verifies wait needs selector
|
||||
func TestBrowserTool_WaitRequiresSelector(t *testing.T) {
|
||||
tool := NewBrowserTool(true)
|
||||
result := tool.Execute(context.Background(), map[string]interface{}{
|
||||
"action": "wait",
|
||||
})
|
||||
if !result.IsError {
|
||||
t.Error("expected error when selector is missing for wait")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserTool_CaseInsensitive verifies that action matching is case-insensitive
|
||||
func TestBrowserTool_CaseInsensitive(t *testing.T) {
|
||||
tool := NewBrowserTool(true)
|
||||
// "OPEN" should normalize to "open" and then require url
|
||||
result := tool.Execute(context.Background(), map[string]interface{}{
|
||||
"action": "OPEN",
|
||||
})
|
||||
// It should not error with "unknown action" — it should error with "url is required"
|
||||
if !result.IsError {
|
||||
t.Error("expected error")
|
||||
}
|
||||
if strings.Contains(result.ForLLM, "unknown browser action") {
|
||||
t.Error("action should be case-insensitive")
|
||||
}
|
||||
if !strings.Contains(result.ForLLM, "url is required") {
|
||||
t.Errorf("expected 'url is required' for 'OPEN' action, got: %s", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetIntArg verifies integer argument extraction from float64
|
||||
func TestGetIntArg(t *testing.T) {
|
||||
args := map[string]interface{}{
|
||||
"timeout": float64(5000),
|
||||
}
|
||||
v, ok := getIntArg(args, "timeout")
|
||||
if !ok || v != 5000 {
|
||||
t.Errorf("expected 5000, got %d (ok=%v)", v, ok)
|
||||
}
|
||||
|
||||
_, ok = getIntArg(args, "nonexistent")
|
||||
if ok {
|
||||
t.Error("expected false for missing key")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserSearchTool_Integration performs a real search if actionbook is available
|
||||
func TestBrowserSearchTool_Integration(t *testing.T) {
|
||||
if _, err := exec.LookPath("actionbook"); err != nil {
|
||||
t.Skip("actionbook not in PATH, skipping integration test")
|
||||
}
|
||||
|
||||
tool := NewBrowserSearchTool(true)
|
||||
result := tool.Execute(context.Background(), map[string]interface{}{
|
||||
"query": "google search",
|
||||
})
|
||||
|
||||
if result.IsError {
|
||||
t.Errorf("expected success, got error: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
if result.ForLLM == "" {
|
||||
t.Error("expected non-empty output")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTruncateOutput verifies output truncation
|
||||
func TestTruncateOutput(t *testing.T) {
|
||||
short := "hello"
|
||||
if truncateOutput(short, 100) != short {
|
||||
t.Error("short string should not be truncated")
|
||||
}
|
||||
|
||||
long := strings.Repeat("x", 200)
|
||||
truncated := truncateOutput(long, 100)
|
||||
if len(truncated) >= 200 {
|
||||
t.Errorf("expected truncation, got length %d", len(truncated))
|
||||
}
|
||||
if !strings.Contains(truncated, "truncated") {
|
||||
t.Error("expected truncation marker")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue