feat: AutoZero 通用 Agent 框架(从 AutoGo 五阶段拆分完成)

llm(三协议 Provider)/ session(JSONL 持久化)/ tools(Registry、围栏、
builtin、computer)/ cdp(browser)/ skills / mcp / agent(循环、两级压缩、
编排、审批门控)+ 底座 jsonl/textx/execx/diag/scratch/spill/imagecache。

产品装配代码(config 桥接、schedule 工具、memory/plan/retro/prompt)
留在 AutoGo,宿主接入方式见 MIGRATION.md。
This commit is contained in:
Sakurasan committed 2026-10-26 00:54:46 +08:00
commit d5dac5a6fc
140 files changed
+26497

No files matched your search

+221
View File
@@ -0,0 +1,221 @@
// Package cdp 实现跨平台 browser 工具所需的 Chrome DevTools Protocol 客户端:
// WebSocket 传输(coder/websocket)、CDP 连接层、Chrome 启动发现、browser 工具。
package cdp
import (
"context"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
"github.com/coder/websocket"
)
// maxWSMessage 限制单条 CDP 消息大小(截图 base64 可达数 MB,留足余量)。
const maxWSMessage = 64 << 20
// Conn 是一条到 CDP target 的 WebSocket 连接:请求/响应按 id 配对,
// 事件按方法名分发给先注册的等待者(必须先 Subscribe 再触发事件)。
type Conn struct {
ws *websocket.Conn
ctx context.Context // 连接生命周期(readLoop 用)
wmu sync.Mutex // coder/websocket 要求写方串行
mu sync.Mutex
nextID int64
pending map[int64]chan cdpReply
subs map[string][]*EventWaiter
closed bool
}
type cdpReply struct {
Result json.RawMessage
Err *cdpError
}
type cdpError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type cdpEvent struct {
Method string
Params json.RawMessage
}
type cdpWireMsg struct {
ID int64 `json:"id"`
Result json.RawMessage `json:"result"`
Error *cdpError `json:"error"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
// Dial 连接一个 target 的 WebSocket 调试端点并启动读取循环。
func Dial(ctx context.Context, wsURL string) (*Conn, error) {
dctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
ws, _, err := websocket.Dial(dctx, wsURL, &websocket.DialOptions{CompressionMode: websocket.CompressionDisabled})
if err != nil {
return nil, fmt.Errorf("ws dial %s: %w", wsURL, err)
}
ws.SetReadLimit(maxWSMessage)
c := &Conn{
ws: ws,
ctx: ctx,
pending: map[int64]chan cdpReply{},
subs: map[string][]*EventWaiter{},
}
go c.readLoop()
return c, nil
}
// Call 发送一条 CDP 命令并等待同 id 响应。
func (c *Conn) Call(ctx context.Context, method string, params any) (json.RawMessage, error) {
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return nil, errors.New("cdp connection closed")
}
c.nextID++
id := c.nextID
ch := make(chan cdpReply, 1)
c.pending[id] = ch
c.mu.Unlock()
msg := map[string]any{"id": id, "method": method}
if params != nil {
msg["params"] = params
}
data, err := json.Marshal(msg)
if err != nil {
c.removePending(id)
return nil, err
}
c.wmu.Lock()
err = c.ws.Write(ctx, websocket.MessageText, data)
c.wmu.Unlock()
if err != nil {
c.removePending(id)
return nil, fmt.Errorf("%s: %w", method, err)
}
select {
case r := <-ch:
if r.Err != nil {
return nil, fmt.Errorf("%s: %s (code %d)", method, r.Err.Message, r.Err.Code)
}
return r.Result, nil
case <-ctx.Done():
c.removePending(id)
return nil, ctx.Err()
}
}
// Subscribe 注册事件等待者(必须在事件触发前调用;缓冲 8,满则丢弃)。
func (c *Conn) Subscribe(event string) *EventWaiter {
w := &EventWaiter{ch: make(chan cdpEvent, 8), method: event}
c.mu.Lock()
c.subs[event] = append(c.subs[event], w)
c.mu.Unlock()
return w
}
// EventWaiter 等待一个已订阅的 CDP 事件。
type EventWaiter struct {
ch chan cdpEvent
method string
}
// Wait 等待事件到达(超时返回错误)。
func (w *EventWaiter) Wait(ctx context.Context, timeout time.Duration) (json.RawMessage, error) {
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case ev := <-w.ch:
return ev.Params, nil
case <-ctx.Done():
return nil, ctx.Err()
case <-timer.C:
return nil, fmt.Errorf("timeout waiting for event %s", w.method)
}
}
func (c *Conn) removePending(id int64) {
c.mu.Lock()
delete(c.pending, id)
c.mu.Unlock()
}
// readLoop 按 id 分发响应、按方法名分发事件;连接断开时唤醒所有等待者。
func (c *Conn) readLoop() {
for {
_, data, err := c.ws.Read(c.ctx)
if err != nil {
c.failAll(err)
return
}
var m cdpWireMsg
if err := json.Unmarshal(data, &m); err != nil {
continue
}
if m.ID != 0 {
c.mu.Lock()
ch := c.pending[m.ID]
delete(c.pending, m.ID)
c.mu.Unlock()
if ch != nil {
ch <- cdpReply{Result: m.Result, Err: m.Error}
}
continue
}
if m.Method != "" {
c.mu.Lock()
waiters := c.subs[m.Method]
c.mu.Unlock()
for _, w := range waiters {
select {
case w.ch <- cdpEvent{Method: m.Method, Params: m.Params}:
default: // 满则丢弃,避免阻塞 readLoop
}
}
}
}
}
func (c *Conn) failAll(err error) {
c.mu.Lock()
defer c.mu.Unlock()
c.closed = true
for id, ch := range c.pending {
ch <- cdpReply{Err: &cdpError{Message: "connection lost: " + err.Error()}}
delete(c.pending, id)
}
}
// Close 关闭连接(幂等)。
func (c *Conn) Close() {
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return
}
c.closed = true
pending := c.pending
subs := c.subs
c.pending = map[int64]chan cdpReply{}
c.subs = map[string][]*EventWaiter{}
c.mu.Unlock()
for id, ch := range pending {
ch <- cdpReply{Err: &cdpError{Message: "connection closed"}}
delete(pending, id)
}
for _, ws := range subs {
for _, w := range ws {
close(w.ch)
}
}
_ = c.ws.Close(websocket.StatusNormalClosure, "")
}
+113
View File
@@ -0,0 +1,113 @@
package cdp
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/coder/websocket"
)
// tinyPNG 是 1x1 红色 PNG 的 base64(截图测试用)。
const tinyPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
// fakeCDPHandler 处理一条 CDP 命令并回响应;navigate 先发事件再回响应,
// 用于验证"先订阅后触发"的事件时序。
func fakeCDPHandler(t *testing.T, title string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
c, err := websocket.Accept(w, r, nil)
if err != nil {
return
}
defer c.Close(websocket.StatusInternalError, "")
ctx := r.Context()
for {
_, data, err := c.Read(ctx)
if err != nil {
return
}
var req struct {
ID int64 `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
if err := json.Unmarshal(data, &req); err != nil {
continue
}
reply := func(v map[string]any) {
b, _ := json.Marshal(map[string]any{"id": req.ID, "result": v})
_ = c.Write(ctx, websocket.MessageText, b)
}
switch req.Method {
case "Page.enable":
reply(map[string]any{})
case "Page.navigate":
// 事件先于响应到达:验证 Subscribe 必须先行
ev, _ := json.Marshal(map[string]any{"method": "Page.loadEventFired", "params": map[string]any{}})
_ = c.Write(ctx, websocket.MessageText, ev)
reply(map[string]any{"frameId": "f1"})
case "Runtime.evaluate":
reply(map[string]any{"result": map[string]any{"type": "string", "value": title}})
case "Page.captureScreenshot":
reply(map[string]any{"data": tinyPNG})
default:
b, _ := json.Marshal(map[string]any{"id": req.ID, "error": map[string]any{"code": -32601, "message": "unknown method"}})
_ = c.Write(ctx, websocket.MessageText, b)
}
}
}
}
// TestConnCallAndEventTiming:事件先到时 Call 仍能配对响应、Subscribe 先行能等到事件。
func TestConnCallAndEventTiming(t *testing.T) {
srv := httptest.NewServer(fakeCDPHandler(t, "Fake Page"))
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/devtools/page/t1"
ctx := context.Background()
conn, err := Dial(ctx, wsURL)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
loaded := conn.Subscribe("Page.loadEventFired")
if _, err := conn.Call(ctx, "Page.enable", nil); err != nil {
t.Fatal(err)
}
if _, err := conn.Call(ctx, "Page.navigate", map[string]any{"url": "https://example.com"}); err != nil {
t.Fatalf("navigate: %v", err)
}
// 事件在响应前已发出:订阅先行则必然能等到
if _, err := loaded.Wait(ctx, 2*time.Second); err != nil {
t.Fatalf("event wait: %v", err)
}
title, err := evaluateString(ctx, conn, "document.title")
if err != nil || title != "Fake Page" {
t.Fatalf("evaluate: %q %v", title, err)
}
// 服务端错误响应 → Go error
if _, err := conn.Call(ctx, "NoSuch.Method", nil); err == nil || !strings.Contains(err.Error(), "unknown method") {
t.Fatalf("expected rpc error, got %v", err)
}
}
// TestConnClosed:连接断开后 Call 立即失败。
func TestConnClosed(t *testing.T) {
srv := httptest.NewServer(fakeCDPHandler(t, "x"))
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/devtools/page/t1"
ctx := context.Background()
conn, err := Dial(ctx, wsURL)
if err != nil {
t.Fatal(err)
}
conn.Close()
if _, err := conn.Call(ctx, "Page.enable", nil); err == nil {
t.Fatal("call on closed connection should fail")
}
}
+301
View File
@@ -0,0 +1,301 @@
package cdp
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
neturl "net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
)
// Browser 管理一个 Chrome 实例(懒启动、进程内单例语义由 tool 层保证):
// 首次使用时以 --remote-debugging-port 启动并轮询就绪;DebugBase 非空则
// 直接连接已有调试端点(测试 / 外部浏览器),不启动进程。
type Browser struct {
DebugBase string // 直接使用的调试基址(http://127.0.0.1:9222)
Bin string // 浏览器可执行文件覆盖;空 = 自动发现
Headless bool // 默认 true;AUTOGO_BROWSER_HEADLESS=0 关闭
ProfileDir string // 用户数据目录;空 = ~/.autogo/browser-profile(持久,登录态留得住)
mu sync.Mutex
cmd *exec.Cmd
base string
current string // 当前 page target id(open/js/screenshot 的作用目标)
actionMu sync.Mutex
}
// Target 是调试端点上的一个 tab(page)。
type Target struct {
ID string `json:"id"`
Title string `json:"title"`
URL string `json:"url"`
Type string `json:"type"`
WebSocketDebuggerURL string `json:"webSocketDebuggerURL"`
}
func (b *Browser) baseURL(ctx context.Context) (string, error) {
if b.DebugBase != "" {
return strings.TrimSuffix(b.DebugBase, "/"), nil
}
b.mu.Lock()
defer b.mu.Unlock()
if b.base != "" {
return b.base, nil
}
bin, err := findBrowser(b.Bin)
if err != nil {
return "", err
}
port, err := freePort()
if err != nil {
return "", err
}
dir := b.ProfileDir
if dir == "" {
// 默认用**持久**目录,不用临时目录:Chrome 的登录态与扩展都在 user-data-dir 里,
// 临时目录等于每次启动都像一台从没登录过的新机器(登录一次也留不住),而且旧的
// 临时目录从来没人清理,会在 /tmp 里越堆越多。
dir, err = PersistentProfileDir()
if err != nil {
return "", err
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
}
// profile 体检结论不在这里直接打印(TUI 的 alt-screen 打不得),而是挂到启动
// 失败的 error 上——那正是用户需要看到它们的时候(见下面 waitDebugReady 分支)。
profileWarns := CheckProfile(dir)
args := []string{
fmt.Sprintf("--remote-debugging-port=%d", port),
"--user-data-dir=" + dir,
"--no-first-run", "--no-default-browser-check",
"--disable-background-networking",
"--window-size=1366,900",
"about:blank",
}
if b.Headless {
args = append([]string{"--headless=new", "--hide-scrollbars"}, args...)
}
cmd := exec.Command(bin, args...)
cmd.Stdout, cmd.Stderr = io.Discard, io.Discard
if err := cmd.Start(); err != nil {
return "", fmt.Errorf("launch %s: %w", bin, err)
}
base := fmt.Sprintf("http://127.0.0.1:%d", port)
if err := waitDebugReady(ctx, base, 10*time.Second); err != nil {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
// 起不来时把 profile 和体检结论一起给出:绝大多数"浏览器连不上"都是 profile
// 相关(被运行中的 Chrome 占着 / 指向了默认目录 / 指向了 Default 子目录)。
msg := fmt.Sprintf("浏览器调试端口没起来(%s,profile=%s):%v", bin, dir, err)
for _, w := range profileWarns {
msg += "\n - " + w
}
if len(profileWarns) == 0 {
msg += "\n - profile 看起来正常;可以先手动跑一下该浏览器确认能启动,或用 browser.debug_base 连接一个已开的调试端口"
}
return "", errors.New(msg)
}
b.cmd, b.base = cmd, base
return base, nil
}
// waitDebugReady 轮询 /json/version 直到调试端点就绪。
func waitDebugReady(ctx context.Context, base string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
client := &http.Client{Timeout: 2 * time.Second}
for time.Now().Before(deadline) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, base+"/json/version", nil)
if req != nil {
if resp, err := client.Do(req); err == nil {
resp.Body.Close()
return nil
}
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(100 * time.Millisecond):
}
}
return fmt.Errorf("debug endpoint %s not ready within %s", base, timeout)
}
func freePort() (int, error) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, err
}
port := l.Addr().(*net.TCPAddr).Port
l.Close()
return port, nil
}
// findBrowser 按 GOOS 定位浏览器可执行文件(Chrome / Chromium / Edge 均支持 CDP)。
func findBrowser(explicit string) (string, error) {
if explicit != "" {
if _, err := os.Stat(explicit); err == nil {
return explicit, nil
}
if _, err := exec.LookPath(explicit); err == nil {
return explicit, nil
}
return "", fmt.Errorf("AUTOGO_BROWSER_BIN %q not found", explicit)
}
var candidates []string
switch runtime.GOOS {
case "darwin":
candidates = []string{
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
}
case "windows":
pf := os.Getenv("ProgramFiles")
pf86 := os.Getenv("ProgramFiles(x86)")
lap := os.Getenv("LOCALAPPDATA")
// ARM64 Windows 可能安装在 Program Files (Arm) 或 x86 目录
for _, root := range []string{pf, pf86, lap} {
if root == "" {
continue
}
candidates = append(candidates,
filepath.Join(root, `Google\Chrome\Application\chrome.exe`),
filepath.Join(root, `Microsoft\Edge\Application\msedge.exe`),
)
}
// Chrome SxS (Canary) 和 Edge Beta 也常见于 ARM64
if lap != "" {
candidates = append(candidates,
filepath.Join(lap, `Google\Chrome SxS\Application\chrome.exe`),
)
}
default: // linux 等 unix
// 标准 PATH 查找
for _, name := range []string{"google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge", "microsoft-edge-stable"} {
if p, err := exec.LookPath(name); err == nil {
return p, nil
}
}
// Flatpak 路径
flatpakPaths := []string{
"/var/lib/flatpak/exports/bin/com.google.Chrome",
"/var/lib/flatpak/exports/bin/org.chromium.Chromium",
"/home/*/.local/share/flatpak/exports/bin/com.google.Chrome",
"/home/*/.local/share/flatpak/exports/bin/org.chromium.Chromium",
}
for _, p := range flatpakPaths {
if strings.Contains(p, "*") {
// 展开 home 目录通配符
home, _ := os.UserHomeDir()
if home != "" {
p = strings.Replace(p, "/home/*", home, 1)
}
}
if _, err := os.Stat(p); err == nil {
return p, nil
}
}
// Snap 路径
snapPaths := []string{
"/snap/chromium/current/usr/bin/chromium",
"/snap/google-chrome/current/usr/bin/google-chrome",
}
for _, p := range snapPaths {
if _, err := os.Stat(p); err == nil {
return p, nil
}
}
}
for _, p := range candidates {
if _, err := os.Stat(p); err == nil {
return p, nil
}
}
for _, name := range []string{"google-chrome", "chromium", "chrome"} {
if p, err := exec.LookPath(name); err == nil {
return p, nil
}
}
return "", fmt.Errorf("no Chrome/Chromium/Edge found (set AUTOGO_BROWSER_BIN; candidates tried: %v)", candidates)
}
// ListTargets 列出调试端点上的全部 target。
func (b *Browser) ListTargets(ctx context.Context) ([]Target, error) {
base, err := b.baseURL(ctx)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/json/list", nil)
if err != nil {
return nil, err
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var targets []Target
if err := json.NewDecoder(resp.Body).Decode(&targets); err != nil {
return nil, err
}
return targets, nil
}
// NewTab 新开一个 page 并导航到 url。
func (b *Browser) NewTab(ctx context.Context, url string) (*Target, error) {
base, err := b.baseURL(ctx)
if err != nil {
return nil, err
}
client := &http.Client{Timeout: 10 * time.Second}
do := func(method string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, base+"/json/new?url="+neturl.QueryEscape(url), nil)
if err != nil {
return nil, err
}
return client.Do(req)
}
resp, err := do(http.MethodPut)
if err == nil && resp.StatusCode == http.StatusMethodNotAllowed {
resp.Body.Close()
resp, err = do(http.MethodGet) // 老版本 Chrome 只支持 GET /json/new
}
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("/json/new: HTTP %d", resp.StatusCode)
}
var t Target
if err := json.NewDecoder(resp.Body).Decode(&t); err != nil {
return nil, err
}
return &t, nil
}
// Close 终止托管的 Chrome 进程(DebugBase 注入模式为空操作)。
func (b *Browser) Close() {
b.mu.Lock()
cmd := b.cmd
b.cmd, b.base = nil, ""
b.mu.Unlock()
if cmd != nil && cmd.Process != nil {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
}
}
+251
View File
@@ -0,0 +1,251 @@
package cdp
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
)
// 浏览器 profile 的发现、体检与复制。
//
// 为什么需要这些:Chrome 的**登录态(cookies)与扩展都存在 user-data-dir 里**。
// 以前 profile 为空就用 os.MkdirTemp 建个空目录,于是每次启动都像一台从没登录过的
// 新机器——而且那个临时目录**从不清理**,退出后在 /tmp 留下几百 MB。
//
// 想复用已有登录态,就把 browser.profile 指到一份**已登录的** user-data-dir(通常是
// 系统默认目录的副本)。体检负责把几个常见坑说清楚:指到了 Default 子目录、指到了
// Chrome 默认目录、或者那份 profile 正被一个运行中的 Chrome 占用。
// DefaultUserDataDir 返回当前平台 Chrome 的默认用户数据目录(那份带登录态与扩展的)。
// 找不到时返回候选列表供人肉判断。
func DefaultUserDataDir() (string, error) {
for _, c := range defaultUserDataDirCandidates() {
if st, err := os.Stat(c); err == nil && st.IsDir() {
return c, nil
}
}
return "", fmt.Errorf("找不到默认的 Chrome 用户数据目录(找过:%s)", strings.Join(defaultUserDataDirCandidates(), ", "))
}
// defaultUserDataDirCandidates 列出各平台可能的默认 user-data-dir。
func defaultUserDataDirCandidates() []string {
home, _ := os.UserHomeDir()
switch runtime.GOOS {
case "darwin":
return []string{
filepath.Join(home, "Library", "Application Support", "Google", "Chrome"),
filepath.Join(home, "Library", "Application Support", "Chromium"),
filepath.Join(home, "Library", "Application Support", "Microsoft Edge"),
}
case "windows":
base := os.Getenv("LOCALAPPDATA")
if base == "" {
return nil
}
return []string{
filepath.Join(base, "Google", "Chrome", "User Data"),
filepath.Join(base, "Microsoft", "Edge", "User Data"),
}
default:
return []string{
filepath.Join(home, ".config", "google-chrome"),
filepath.Join(home, ".config", "chromium"),
filepath.Join(home, ".config", "microsoft-edge"),
}
}
}
// PersistentProfileDir 是 AutoGo 自己的持久 profile 目录。
//
// 用它而不是临时目录:临时目录意味着"登录一次也留不住"(每次进程启动都是新的空
// profile),而且没人清理。持久目录下登录一次就长期有效。
func PersistentProfileDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".autogo", "browser-profile"), nil
}
// IsChromeDefaultDir 报告 dir 是不是(或位于)系统默认的 Chrome user-data-dir。
// 是的话要提醒:Chrome 136+ 起出于安全**禁止在默认 profile 上开启远程调试**,必须用
// 非默认的 user-data-dir;而且默认目录通常被日常 Chrome 占着(单实例锁)。
func IsChromeDefaultDir(dir string) bool {
abs, err := filepath.Abs(dir)
if err != nil {
return false
}
for _, c := range defaultUserDataDirCandidates() {
if abs == c {
return true
}
}
return false
}
// looksLikeProfileDir 判断 dir 像是"某个 profile 本体"(Default/ 里的那种),
// 而不是 user-data-dir。这是最常见的配置错误:--user-data-dir 要指向**父目录**。
func looksLikeProfileDir(dir string) bool {
for _, f := range []string{"Cookies", "Login Data", "Preferences"} {
if st, err := os.Stat(filepath.Join(dir, f)); err == nil && !st.IsDir() {
return true
}
}
return false
}
// CheckProfile 对配置里的 profile 目录做体检,返回若干条**人类可读的告警**(可以为空)。
// 不存在之类属于致命问题,由调用方按错误处理——这里只做"能启动但可能不符合预期"的提醒。
func CheckProfile(dir string) []string {
var warns []string
if dir == "" {
return nil
}
st, err := os.Stat(dir)
if err != nil || !st.IsDir() {
return []string{fmt.Sprintf("profile 目录不存在:%s(它不会被自动创建;先确认路径,或用 `a2go browser profile-copy` 复制一份)", dir)}
}
if looksLikeProfileDir(dir) {
warns = append(warns, fmt.Sprintf(
"profile 指向的像是**profile 本体**(里面有 Cookies/Preferences):%s\n"+
" --user-data-dir 要指向它的**父目录**,否则 Chrome 会在里面另建一套结构,你的登录态不会生效。", dir))
}
if IsChromeDefaultDir(dir) {
warns = append(warns, fmt.Sprintf(
"profile 是 Chrome 的**默认目录**:%s\n"+
" Chrome 136+ 起禁止在默认 profile 上开启远程调试,而且日常 Chrome 通常正占着它。\n"+
" 建议改用副本:a2go browser profile-copy", dir))
}
if _, err := os.Lstat(filepath.Join(dir, "SingletonLock")); err == nil {
warns = append(warns, fmt.Sprintf(
"该 profile 里有 SingletonLock:%s\n"+
" 说明可能有一个正在运行的 Chrome 在用这份 profile。同一个 user-data-dir 起第二个实例通常\n"+
" 拿不到调试端口(会被转交给已运行的实例)。请先完全退出 Chrome,或改用副本。", dir))
}
return warns
}
// profileCacheDirs 是复制时跳过的缓存目录:体积大、且与登录态无关(含扩展的缓存)。
// 注意 **不跳过 Extensions** —— 复用方案里扩展正是要带过去的东西之一。
var profileCacheDirs = map[string]bool{
"Cache": true,
"Code Cache": true,
"GPUCache": true,
"ShaderCache": true,
"GrShaderCache": true,
"DawnCache": true,
"DawnGraphiteCache": true,
"GraphiteDawnCache": true,
"Media Cache": true,
"Service Worker": true,
"Crashpad": true,
"BrowserMetrics": true,
"component_crx_cache": true,
"extensions_crx_cache": true,
"Safe Browsing": true,
}
// profileSkipFile 判断某个文件是否属于"复制时跳过"(单实例锁等,带过去反而会误导 Chrome)。
func profileSkipFile(name string) bool {
return strings.HasPrefix(name, "Singleton")
}
// CopyProfile 把 src 里的登录态与扩展复制到 dst。
//
// 复制前会拒绝"源正被占用"的情况:Chrome 运行中复制的 profile 可能不一致,而且它的
// 单实例锁文件会一起被带过去。
func CopyProfile(src, dst string) (int64, error) {
st, err := os.Stat(src)
if err != nil || !st.IsDir() {
return 0, fmt.Errorf("源 profile 不存在或不是目录:%s", src)
}
if looksLikeProfileDir(src) {
return 0, fmt.Errorf("源指向的像是 profile 本体(含 Cookies/Preferences):%s\n要复制的是它的**父目录**(user-data-dir)", src)
}
if _, err := os.Lstat(filepath.Join(src, "SingletonLock")); err == nil {
return 0, fmt.Errorf("源 profile 正被占用(有 SingletonLock):%s\n请先完全退出 Chrome 再复制——运行中复制的 profile 可能不一致", src)
}
if err := os.MkdirAll(dst, 0o755); err != nil {
return 0, err
}
var total int64
err = filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil // 个别文件读不了(权限/正在写)不该中断整次复制
}
rel, err := filepath.Rel(src, path)
if err != nil {
return nil
}
if rel == "." {
return nil
}
// 跳过缓存目录(整个子树)、单实例锁、以及 *-journal 之类的临时文件。
for _, part := range strings.Split(rel, string(filepath.Separator)) {
if profileCacheDirs[part] {
return filepath.SkipDir
}
}
if profileSkipFile(d.Name()) {
return nil
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
if !d.Type().IsRegular() {
return nil // 符号链接等一律跳过
}
n, err := copyFile(path, target)
total += n
return err
})
if err != nil {
return total, err
}
// 目标里也清一次:万一 dst 是之前从运行中的 Chrome 拷来的。
for _, f := range []string{"SingletonLock", "SingletonSocket", "SingletonCookie"} {
_ = os.Remove(filepath.Join(dst, f))
}
return total, nil
}
func copyFile(src, dst string) (int64, error) {
in, err := os.Open(src)
if err != nil {
return 0, nil // 读不了就跳过,不中断
}
defer in.Close()
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return 0, err
}
out, err := os.Create(dst)
if err != nil {
return 0, err
}
defer out.Close()
n, err := io.Copy(out, in)
if err != nil {
return n, nil
}
return n, nil
}
// LooksLikeBrowserProfile 判断 dir 是否像一份浏览器 user-data-dir(用于 --force 前的安全校验:
// 只有确认目标确实是 profile 才允许覆盖删除,避免手滑删掉无关目录)。
func LooksLikeBrowserProfile(dir string) bool {
for _, f := range []string{"Local State", "Default", "First Run"} {
if _, err := os.Stat(filepath.Join(dir, f)); err == nil {
return true
}
}
return false
}
// ErrProfileMissing 是 profile 不存在时的哨兵(调用方据此给出配置建议)。
var ErrProfileMissing = errors.New("browser profile not found")
+186
View File
@@ -0,0 +1,186 @@
package cdp
import (
"os"
"path/filepath"
"strings"
"testing"
)
// writeFile 建一个带内容的文件(自动建父目录)。
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
// TestCheckProfileWarnings:三个最常见的坑都要被明确讲出来。
//
// 这三条正是"配了 profile 却用不上登录态"的真实原因:
// 1. 指到了 profile 本体(Default/ 里面)而不是 user-data-dir;
// 2. 指到了 Chrome 默认目录(Chrome 136+ 禁止在其上开远程调试,且日常 Chrome 占着);
// 3. 那份 profile 正被一个运行中的 Chrome 占用。
func TestCheckProfileWarnings(t *testing.T) {
t.Run("目录不存在", func(t *testing.T) {
warns := CheckProfile(filepath.Join(t.TempDir(), "nope"))
if len(warns) != 1 || !strings.Contains(warns[0], "不存在") {
t.Fatalf("应提示目录不存在,实际 %v", warns)
}
})
t.Run("指向 profile 本体", func(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "Cookies"), "x")
warns := CheckProfile(dir)
if !hasSubstr(warns, "父目录") {
t.Fatalf("应提示 --user-data-dir 要指向父目录,实际 %v", warns)
}
})
t.Run("指向 Chrome 默认目录", func(t *testing.T) {
cands := defaultUserDataDirCandidates()
if len(cands) == 0 {
t.Skip("当前平台没有已知的默认目录")
}
// 直接对候选路径做判断(纯字符串逻辑,不需要它真的存在)。
if !IsChromeDefaultDir(cands[0]) {
t.Fatalf("IsChromeDefaultDir(%q) 应为 true", cands[0])
}
if IsChromeDefaultDir(filepath.Join(t.TempDir(), "somecopy")) {
t.Error("非默认目录不该被判为默认目录")
}
})
t.Run("被运行中的 Chrome 占用", func(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "Local State"), "{}")
writeFile(t, filepath.Join(dir, "SingletonLock"), "")
warns := CheckProfile(dir)
if !hasSubstr(warns, "SingletonLock") {
t.Fatalf("应提示 profile 被占用,实际 %v", warns)
}
})
}
func hasSubstr(list []string, sub string) bool {
for _, s := range list {
if strings.Contains(s, sub) {
return true
}
}
return false
}
// TestCopyProfile:复制要**保留登录态与扩展、跳过缓存、不带单实例锁**。
func TestCopyProfile(t *testing.T) {
src := t.TempDir()
dst := filepath.Join(t.TempDir(), "copy")
// 要保留的:登录态与扩展。
writeFile(t, filepath.Join(src, "Local State"), "{}")
writeFile(t, filepath.Join(src, "Default", "Cookies"), "cookies-db")
writeFile(t, filepath.Join(src, "Default", "Preferences"), "{}")
writeFile(t, filepath.Join(src, "Default", "Extensions", "abc", "manifest.json"), "{}")
// 要跳过的:缓存(体积大且与登录态无关)。
writeFile(t, filepath.Join(src, "Default", "Cache", "big.bin"), strings.Repeat("x", 4096))
writeFile(t, filepath.Join(src, "Default", "Code Cache", "big.bin"), strings.Repeat("y", 4096))
writeFile(t, filepath.Join(src, "Default", "Service Worker", "s.bin"), "z")
n, err := CopyProfile(src, dst)
if err != nil {
t.Fatalf("复制失败: %v", err)
}
if n == 0 {
t.Error("复制字节数为 0")
}
for _, want := range []string{
filepath.Join("Default", "Cookies"),
filepath.Join("Default", "Preferences"),
filepath.Join("Default", "Extensions", "abc", "manifest.json"),
"Local State",
} {
if _, err := os.Stat(filepath.Join(dst, want)); err != nil {
t.Errorf("应保留 %s:%v", want, err)
}
}
for _, skip := range []string{
filepath.Join("Default", "Cache"),
filepath.Join("Default", "Code Cache"),
filepath.Join("Default", "Service Worker"),
} {
if _, err := os.Stat(filepath.Join(dst, skip)); err == nil {
t.Errorf("缓存目录应被跳过:%s", skip)
}
}
if !LooksLikeBrowserProfile(dst) {
t.Error("复制结果应当被识别为浏览器 profile")
}
}
// TestCopyProfileRefusesRunningChrome:源被占用(SingletonLock)时必须拒绝——
// 运行中复制的 profile 可能不一致,而且锁文件带过去会让 Chrome 犯迷糊。
func TestCopyProfileRefusesRunningChrome(t *testing.T) {
src := t.TempDir()
writeFile(t, filepath.Join(src, "Local State"), "{}")
writeFile(t, filepath.Join(src, "SingletonLock"), "")
if _, err := CopyProfile(src, filepath.Join(t.TempDir(), "copy")); err == nil {
t.Fatal("源被占用时应拒绝复制")
} else if !strings.Contains(err.Error(), "退出 Chrome") {
t.Errorf("错误应提示先退出 Chrome:%v", err)
}
}
// TestCopyProfileNoSingletonInDst:目标里不该出现单实例锁,否则 Chrome 会以为另有一个实例在跑。
func TestCopyProfileNoSingletonInDst(t *testing.T) {
src := t.TempDir()
writeFile(t, filepath.Join(src, "Local State"), "{}")
writeFile(t, filepath.Join(src, "Default", "Cookies"), "c")
dst := filepath.Join(t.TempDir(), "copy")
if _, err := CopyProfile(src, dst); err != nil {
t.Fatal(err)
}
for _, f := range []string{"SingletonLock", "SingletonSocket", "SingletonCookie"} {
if _, err := os.Lstat(filepath.Join(dst, f)); err == nil {
t.Errorf("目标不该有 %s", f)
}
}
}
// TestCopyProfileRejectsProfileBodyAsSource:源指向 profile 本体时拒绝并说清该指哪。
func TestCopyProfileRejectsProfileBodyAsSource(t *testing.T) {
src := t.TempDir()
writeFile(t, filepath.Join(src, "Cookies"), "x")
if _, err := CopyProfile(src, filepath.Join(t.TempDir(), "copy")); err == nil {
t.Fatal("源是 profile 本体时应拒绝")
}
}
// TestPersistentProfileDir:默认目录必须是**固定**路径。
//
// 旧的实现用 os.MkdirTemp——每次都给出一个新目录,于是每次进程启动都像一台从没
// 登录过的新机器(登录一次也留不住)。这里断言的关键性质就是"两次调用同一个路径"。
func TestPersistentProfileDir(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
dir, err := PersistentProfileDir()
if err != nil {
t.Fatal(err)
}
want := filepath.Join(home, ".autogo", "browser-profile")
if dir != want {
t.Errorf("默认 profile = %q, want %q", dir, want)
}
again, err := PersistentProfileDir()
if err != nil {
t.Fatal(err)
}
if again != dir {
t.Errorf("两次调用应返回同一路径(临时目录才会每次不同):%q vs %q", dir, again)
}
}
+332
View File
@@ -0,0 +1,332 @@
package cdp
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"strings"
"sync"
"time"
"git.gopher.ink/mirrors2/AutoZero/llm"
"git.gopher.ink/mirrors2/AutoZero/textx"
"git.gopher.ink/mirrors2/AutoZero/tools"
)
// browser 工具:经 Chrome DevTools Protocol 跨平台驱动浏览器
// (替代旧 macOS-only 的 AppleScript 方案)。截图走 Page.captureScreenshot,
// 不依赖屏幕录制权限;js 执行走 Runtime.evaluate,无需任何浏览器开关。
const (
navigateTimeout = 15 * time.Second
evaluateTimeout = 30 * time.Second
maxJSOutput = 16000
)
var (
browserMu sync.Mutex
browserShared *Browser
defaultsMu sync.Mutex
defaultsSet bool
defaultsVal *Browser
)
// SetBrowserDefaults 由 main 在配置装载后调用(env 已并入 config);
// 未调用时 sharedBrowser 回退为直接读环境变量(库独立使用场景)。
func SetBrowserDefaults(bin string, headless bool, profile, debugBase string) {
defaultsMu.Lock()
defer defaultsMu.Unlock()
defaultsSet = true
defaultsVal = &Browser{Bin: bin, Headless: headless, ProfileDir: profile, DebugBase: debugBase}
}
func sharedBrowser() *Browser {
browserMu.Lock()
defer browserMu.Unlock()
if browserShared == nil {
defaultsMu.Lock()
if defaultsSet {
browserShared = defaultsVal
} else {
browserShared = &Browser{
Bin: os.Getenv("AUTOGO_BROWSER_BIN"),
Headless: os.Getenv("AUTOGO_BROWSER_HEADLESS") != "0",
ProfileDir: os.Getenv("AUTOGO_BROWSER_PROFILE"),
DebugBase: os.Getenv("AUTOGO_BROWSER_DEBUG_BASE"),
}
}
defaultsMu.Unlock()
}
return browserShared
}
// CloseBrowser 终止托管的浏览器进程(main 退出前调用)。
func CloseBrowser() {
browserMu.Lock()
defer browserMu.Unlock()
if browserShared != nil {
browserShared.Close()
}
}
// BrowserTool 返回跨平台 browser 工具。
func BrowserTool() *tools.Tool {
return browserToolWith(sharedBrowser())
}
func browserToolWith(br *Browser) *tools.Tool {
return &tools.Tool{
Name: "browser",
Description: "Control a browser via Chrome DevTools Protocol (cross-platform; spawns a headless Chrome by default, set AUTOGO_BROWSER_HEADLESS=0 to show a window). " +
"Actions: open (navigate the current tab to url), tabs (list open tabs), js (execute JavaScript in the current tab and return the result; async/await supported), screenshot (capture the current tab, returned as an image you can see). " +
"Prefer `js` to read and verify page state (fast, returns values like document.title or video.paused); use `open`/`js` to navigate and act. Use `screenshot` only to deliver a visual to the user, not for routine verification. " +
"AUTOGO_BROWSER_BIN overrides the browser binary (Chrome/Chromium/Edge).",
SideEffect: true,
Kind: tools.KindNetwork, // 访问外部网络
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"action": map[string]any{"type": "string", "enum": []string{"open", "tabs", "js", "screenshot"},
"description": "open: navigate to url; tabs: list tabs; js: run javascript in current tab; screenshot: capture current tab"},
"url": map[string]any{"type": "string", "description": "URL to open (action=open)"},
"javascript": map[string]any{"type": "string",
"description": "JavaScript to execute, should end with an expression or return value, e.g. document.title or JSON.stringify(document.body.innerText.slice(0,5000)) (action=js)"},
},
"required": []string{"action"},
},
Run: func(ctx context.Context, input string) (tools.Result, error) {
m, err := arg(input)
if err != nil {
return tools.Result{}, err
}
switch action := str(m, "action"); action {
case "open":
u := str(m, "url")
if strings.TrimSpace(u) == "" {
return tools.Result{}, fmt.Errorf("action open requires url")
}
return br.open(ctx, u)
case "tabs":
return br.tabs(ctx)
case "js":
js := str(m, "javascript")
if strings.TrimSpace(js) == "" {
return tools.Result{}, fmt.Errorf("action js requires javascript")
}
return br.js(ctx, js)
case "screenshot":
return br.screenshot(ctx)
default:
return tools.Result{}, fmt.Errorf("unknown action %q", action)
}
},
}
}
// resolveTarget 选定当前作用 tab:优先记住的 current(已关闭则回退),
// 其次第一个 page target;都没有则新开 about:blank。
func (b *Browser) resolveTarget(ctx context.Context) (*Target, error) {
targets, err := b.ListTargets(ctx)
if err != nil {
return nil, err
}
var pages []Target
for _, t := range targets {
if t.Type == "page" {
pages = append(pages, t)
}
}
for i := range pages {
if pages[i].ID == b.current {
return &pages[i], nil
}
}
if len(pages) > 0 {
b.current = pages[0].ID
return &pages[0], nil
}
t, err := b.NewTab(ctx, "about:blank")
if err != nil {
return nil, err
}
b.current = t.ID
return t, nil
}
// withTargetConn 解析目标 tab 并建立临时 CDP 连接(动作结束即关闭)。
func (b *Browser) withTargetConn(ctx context.Context, fn func(c *Conn) (tools.Result, error)) (tools.Result, error) {
b.actionMu.Lock()
defer b.actionMu.Unlock()
t, err := b.resolveTarget(ctx)
if err != nil {
return tools.Result{}, err
}
conn, err := Dial(ctx, t.WebSocketDebuggerURL)
if err != nil {
return tools.Result{}, err
}
defer conn.Close()
return fn(conn)
}
func (b *Browser) open(ctx context.Context, u string) (tools.Result, error) {
return b.withTargetConn(ctx, func(c *Conn) (tools.Result, error) {
if _, err := c.Call(ctx, "Page.enable", nil); err != nil {
return tools.Result{}, err
}
loaded := c.Subscribe("Page.loadEventFired")
if _, err := c.Call(ctx, "Page.navigate", map[string]any{"url": u}); err != nil {
return tools.Result{}, err
}
note := ""
if _, err := loaded.Wait(ctx, navigateTimeout); err != nil {
note = " (load event timeout, page may still be loading)"
}
title, _ := evaluateString(ctx, c, "document.title")
return tools.TextResult("opened " + u + " — title: " + title + note), nil
})
}
func (b *Browser) tabs(ctx context.Context) (tools.Result, error) {
targets, err := b.ListTargets(ctx)
if err != nil {
return tools.Result{}, err
}
var lines []string
for _, t := range targets {
if t.Type != "page" {
continue
}
lines = append(lines, fmt.Sprintf("- %s | %s | %s", t.ID, t.Title, t.URL))
}
if len(lines) == 0 {
return tools.TextResult("no open tabs"), nil
}
return tools.TextResult(strings.Join(lines, "\n")), nil
}
func (b *Browser) js(ctx context.Context, code string) (tools.Result, error) {
return b.withTargetConn(ctx, func(c *Conn) (tools.Result, error) {
out, err := evaluateString(ctx, c, jsWrap(code))
if err != nil {
return tools.Result{}, err
}
if len(out) > maxJSOutput {
// 页面文本几乎必然含中文:按字节切会在结果尾部留下半个字,
// 那既污染 transcript(模型看到乱码),也会让会话 JSONL 里出现非法序列。
out = textx.ClipBytes(out, maxJSOutput) + "...[truncated]"
}
return tools.TextResult(out), nil
})
}
func (b *Browser) screenshot(ctx context.Context) (tools.Result, error) {
return b.withTargetConn(ctx, func(c *Conn) (tools.Result, error) {
raw, err := c.Call(ctx, "Page.captureScreenshot", map[string]any{"format": "png"})
if err != nil {
return tools.Result{}, err
}
var res struct {
Data string `json:"data"`
}
if err := json.Unmarshal(raw, &res); err != nil || res.Data == "" {
return tools.Result{}, fmt.Errorf("captureScreenshot: bad result")
}
png, err := base64.StdEncoding.DecodeString(res.Data)
if err != nil {
return tools.Result{}, err
}
path := fmt.Sprintf("%s/autogo-page-%d.png", os.TempDir(), time.Now().UnixNano())
if err := os.WriteFile(path, png, 0o644); err != nil {
return tools.Result{}, err
}
if p, serr := tools.ShrinkForVision(path); serr == nil {
path = p
}
title, _ := evaluateString(ctx, c, "document.location.href")
return tools.Result{Text: "page screenshot: " + title, Images: []llm.Image{{Path: path}}}, nil
})
}
// evaluateString 执行 JS 并把结果转为字符串(错误详情带回)。
func evaluateString(ctx context.Context, c *Conn, expression string) (string, error) {
raw, err := c.Call(ctx, "Runtime.evaluate", map[string]any{
"expression": expression,
"returnByValue": true,
"awaitPromise": true,
"userGesture": true,
})
if err != nil {
return "", err
}
var res struct {
Result struct {
Type string `json:"type"`
Value any `json:"value"`
} `json:"result"`
ExceptionDetails *struct {
Text string `json:"text"`
Exception struct {
Description string `json:"description"`
} `json:"exception"`
} `json:"exceptionDetails"`
}
if err := json.Unmarshal(raw, &res); err != nil {
return "", err
}
if res.ExceptionDetails != nil {
detail := res.ExceptionDetails.Exception.Description
if detail == "" {
detail = res.ExceptionDetails.Text
}
return "", fmt.Errorf("js error: %s", detail)
}
switch v := res.Result.Value.(type) {
case nil:
return "", nil
case string:
return v, nil
default:
b, err := json.Marshal(v)
if err != nil {
return fmt.Sprint(v), nil
}
return string(b), nil
}
}
// jsWrap 包一层 async + try/catch:支持 await,保证返回字符串。
func jsWrap(js string) string {
return fmt.Sprintf(
`(async()=>{try{const r=await eval(%q);return typeof r==="string"?r:JSON.stringify(r)}catch(e){return "JS_ERROR: "+e.message}})()`,
js,
)
}
// arg 解析工具输入 JSON(与 tools 包内同名逻辑一致)。
// 兼容模型可能生成的 {"arguments": {...}} 包装格式。
func arg(input string) (map[string]any, error) {
m := map[string]any{}
if input == "" {
return m, nil
}
if err := json.Unmarshal([]byte(input), &m); err != nil {
return nil, fmt.Errorf("invalid tool arguments: %w", err)
}
// 兼容 {"arguments": {...}} 包装:提取内层对象
if args, ok := m["arguments"]; ok {
if argsMap, ok := args.(map[string]any); ok {
return argsMap, nil
}
}
return m, nil
}
func str(m map[string]any, key string) string {
if v, ok := m[key].(string); ok {
return v
}
return ""
}
+137
View File
@@ -0,0 +1,137 @@
package cdp
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"testing"
)
// fakeBrowserEnv 构造完整的假调试端点:/json/list、/json/new(PUT)、
// 每个 target 的 ws 端点(CDP 协议)。
func fakeBrowserEnv(t *testing.T) (*Browser, *httptest.Server) {
t.Helper()
var mu sync.Mutex
targets := []Target{{
ID: "t1", Title: "Blank", URL: "about:blank", Type: "page",
WebSocketDebuggerURL: "", // srv.URL 已知后再填
}}
var srv *httptest.Server
wsHandler := fakeCDPHandler(t, "Fake Page")
mux := http.NewServeMux()
mux.HandleFunc("/json/list", func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
_ = json.NewEncoder(w).Encode(targets)
})
mux.HandleFunc("/json/new", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
mu.Lock()
targets = append(targets, Target{ID: "t2", Title: "New", URL: r.URL.Query().Get("url"), Type: "page"})
nt := targets[len(targets)-1]
mu.Unlock()
_ = json.NewEncoder(w).Encode(nt)
})
mux.HandleFunc("/devtools/page/t1", wsHandler)
mux.HandleFunc("/devtools/page/t2", wsHandler)
srv = httptest.NewServer(mux)
mu.Lock()
targets[0].WebSocketDebuggerURL = "ws" + strings.TrimPrefix(srv.URL, "http") + "/devtools/page/t1"
mu.Unlock()
br := &Browser{DebugBase: srv.URL}
t.Cleanup(func() { srv.Close(); br.Close() })
return br, srv
}
func TestBrowserToolEndToEnd(t *testing.T) {
br, _ := fakeBrowserEnv(t)
tool := browserToolWith(br)
ctx := context.Background()
// tabs
res, err := tool.Run(ctx, `{"action":"tabs"}`)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(res.Text, "t1 | Blank | about:blank") {
t.Fatalf("tabs: %q", res.Text)
}
// open:navigate + loadEventFired + 读取标题
res, err = tool.Run(ctx, `{"action":"open","url":"https://example.com/x"}`)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(res.Text, "opened https://example.com/x") || !strings.Contains(res.Text, "Fake Page") {
t.Fatalf("open: %q", res.Text)
}
// js:Runtime.evaluate 结果 + 错误路径
res, err = tool.Run(ctx, `{"action":"js","javascript":"document.title"}`)
if err != nil {
t.Fatal(err)
}
if res.Text != "Fake Page" {
t.Fatalf("js: %q", res.Text)
}
// screenshot:PNG 落盘并作为 Image 返回
res, err = tool.Run(ctx, `{"action":"screenshot"}`)
if err != nil {
t.Fatal(err)
}
if len(res.Images) != 1 {
t.Fatalf("screenshot images: %v", res.Images)
}
data, err := os.ReadFile(res.Images[0].Path)
if err != nil {
t.Fatal(err)
}
if string(data[:4]) != "\x89PNG" {
t.Fatalf("not a png: %x", data[:4])
}
os.Remove(res.Images[0].Path)
// 参数校验
if _, err := tool.Run(ctx, `{"action":"open"}`); err == nil {
t.Fatal("open without url should fail")
}
if _, err := tool.Run(ctx, `{"action":"js"}`); err == nil {
t.Fatal("js without javascript should fail")
}
if _, err := tool.Run(ctx, `{"action":"bogus"}`); err == nil {
t.Fatal("unknown action should fail")
}
}
// TestNewTabPUT:无 page target 时经 PUT /json/new 新开 tab。
func TestNewTabPUT(t *testing.T) {
br, srv := fakeBrowserEnv(t)
// 清空 targets:强制走 NewTab
_ = srv
ctx := context.Background()
tg, err := br.NewTab(ctx, "https://example.com/new")
if err != nil {
t.Fatal(err)
}
if tg.ID != "t2" || tg.URL != "https://example.com/new" {
t.Fatalf("new tab: %+v", tg)
}
}
// TestFindBrowserExplicit:显式指定不存在的二进制给出可操作错误。
func TestFindBrowserExplicit(t *testing.T) {
if _, err := findBrowser("/no/such/browser"); err == nil || !strings.Contains(err.Error(), "AUTOGO_BROWSER_BIN") {
t.Fatalf("expected explicit-bin error, got %v", err)
}
}