Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

595 lines
17 KiB
Go

package browser
import (
"context"
"encoding/json"
"os"
"slices"
"strings"
"sync"
"testing"
"time"
agentruntime "github.com/tutti-os/tutti/packages/agent/daemon/runtime"
preferencesbiz "github.com/tutti-os/tutti/services/tuttid/biz/preferences"
managedruntime "github.com/tutti-os/tutti/services/tuttid/service/managedruntime"
)
// scriptedConn fakes an MCP server over the process connection: it answers
// initialize, and on tools/call it first sends a server-initiated
// elicitation/create request (to exercise the client's auto-accept) and then
// the tool result.
type scriptedConn struct {
mu sync.Mutex
frames chan agentruntime.ProcessFrame
sent []map[string]any
}
func newScriptedConn() *scriptedConn {
return &scriptedConn{frames: make(chan agentruntime.ProcessFrame, 16)}
}
func (c *scriptedConn) push(v map[string]any) {
data, _ := json.Marshal(v)
c.frames <- agentruntime.ProcessFrame{Stdout: append(data, '\n')}
}
func (c *scriptedConn) Send(data []byte) error {
var msg map[string]any
if err := json.Unmarshal(data, &msg); err != nil {
return err
}
c.mu.Lock()
c.sent = append(c.sent, msg)
c.mu.Unlock()
method, _ := msg["method"].(string)
id := msg["id"]
switch method {
case "initialize":
c.push(map[string]any{"jsonrpc": "2.0", "id": id, "result": map[string]any{
"protocolVersion": mcpProtocolVersion, "capabilities": map[string]any{},
}})
case "tools/call":
// Server asks the client to elicit; the client must auto-accept.
c.push(map[string]any{"jsonrpc": "2.0", "id": 999, "method": "elicitation/create", "params": map[string]any{}})
c.push(map[string]any{"jsonrpc": "2.0", "id": id, "result": map[string]any{
"isError": false,
"content": []map[string]any{{"type": "text", "text": "Successfully navigated to https://example.com"}},
}})
}
return nil
}
func (c *scriptedConn) Recv() (agentruntime.ProcessFrame, error) {
frame, ok := <-c.frames
if !ok {
return agentruntime.ProcessFrame{}, context.Canceled
}
return frame, nil
}
func (c *scriptedConn) Close() error { close(c.frames); return nil }
func (c *scriptedConn) sentMessages() []map[string]any {
c.mu.Lock()
defer c.mu.Unlock()
return append([]map[string]any(nil), c.sent...)
}
type scriptedTransport struct {
mu sync.Mutex
conns []*scriptedConn
specs []agentruntime.ProcessSpec
startCnt int
}
func (t *scriptedTransport) Start(_ context.Context, spec agentruntime.ProcessSpec) (agentruntime.ProcessConnection, error) {
t.mu.Lock()
defer t.mu.Unlock()
t.startCnt++
t.specs = append(t.specs, spec)
conn := newScriptedConn()
t.conns = append(t.conns, conn)
return conn, nil
}
func newTestService(transport agentruntime.ProcessTransport) *Service {
svc := &Service{transport: transport, idleTTL: time.Hour, sessions: make(map[string]*browserSession)}
svc.autoConnectPreflight = func() error { return nil }
return svc
}
func TestCallToolNavigatesAndAutoAcceptsElicitation(t *testing.T) {
transport := &scriptedTransport{}
svc := newTestService(transport)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
res, err := svc.CallTool(ctx, "ws-1", "/tmp", "navigate_page", map[string]any{"url": "https://example.com"})
if err != nil {
t.Fatalf("CallTool: %v", err)
}
if !strings.Contains(res.Text, "Successfully navigated") {
t.Fatalf("unexpected result text: %q", res.Text)
}
// The client must have auto-accepted the elicitation request (id 999).
var accepted bool
for _, msg := range transport.conns[0].sentMessages() {
if _, isReq := msg["method"]; isReq {
continue
}
if idOf(msg["id"]) == 999 {
result, _ := msg["result"].(map[string]any)
if result != nil && result["action"] == "accept" {
accepted = true
}
}
}
if !accepted {
t.Fatal("expected the client to auto-accept the elicitation/create request")
}
}
func TestCallToolReusesSession(t *testing.T) {
transport := &scriptedTransport{}
svc := newTestService(transport)
ctx := context.Background()
for i := 0; i < 2; i++ {
if _, err := svc.CallTool(ctx, "ws-1", "", "navigate_page", map[string]any{"url": "https://example.com"}); err != nil {
t.Fatalf("CallTool #%d: %v", i, err)
}
}
if transport.startCnt != 1 {
t.Fatalf("expected the subprocess to start once and be reused, got %d starts", transport.startCnt)
}
// A different workspace gets its own subprocess.
if _, err := svc.CallTool(ctx, "ws-2", "", "navigate_page", map[string]any{"url": "https://example.com"}); err != nil {
t.Fatalf("CallTool ws-2: %v", err)
}
if transport.startCnt != 2 {
t.Fatalf("expected a second subprocess for ws-2, got %d starts", transport.startCnt)
}
}
func TestCallToolUsesAutoConnectWhenDesktopPreferenceReusesChrome(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
transport := &scriptedTransport{}
svc := newTestService(transport)
svc.preferences = staticPreferencesReader{
preferences: preferencesbiz.DesktopPreferences{
BrowserUseConnectionMode: "autoConnect",
},
}
ctx := context.Background()
if _, err := svc.CallTool(ctx, "ws-1", "", "list_pages", nil); err != nil {
t.Fatalf("CallTool: %v", err)
}
if len(transport.specs) != 1 {
t.Fatalf("started specs len = %d, want 1", len(transport.specs))
}
command := transport.specs[0].Command
if !slices.Contains(command, "--autoConnect") && !slices.Contains(command, "--wsEndpoint") {
t.Fatalf("command = %#v, want --autoConnect or --wsEndpoint", command)
}
if slices.Contains(command, "--isolated") {
t.Fatalf("command = %#v, did not want --isolated", command)
}
}
func TestCallToolUsesManagedNodeForVendoredBrowserMCPEntry(t *testing.T) {
transport := &scriptedTransport{}
svc := newTestService(transport)
entryPath := "/Applications/Tutti.app/Contents/Resources/bin/browser-mcp/chrome-devtools-mcp.js"
nodePath := "/Users/example/.tutti/app-runtimes/darwin-arm64/node/bin/node"
svc.managedRuntime = browserRuntimeResolverStub{
runtime: managedruntime.ResolvedRuntime{Node: nodePath},
}
t.Setenv(browserMCPEntryPathEnv, entryPath)
t.Setenv("TUTTI_APP_NODE", "")
if _, err := svc.CallTool(context.Background(), "ws-1", "", "list_pages", nil); err != nil {
t.Fatalf("CallTool: %v", err)
}
if len(transport.specs) != 1 {
t.Fatalf("started specs len = %d, want 1", len(transport.specs))
}
command := transport.specs[0].Command
if len(command) < 2 || command[0] != nodePath || command[1] != entryPath {
t.Fatalf("command = %#v, want managed node plus vendored entry", command)
}
if !slices.Contains(command, "--isolated") {
t.Fatalf("command = %#v, want normal browser connection args", command)
}
}
func TestCallToolRestartsSessionWhenConnectionModeChanges(t *testing.T) {
transport := &scriptedTransport{}
svc := newTestService(transport)
prefs := &mutablePreferencesReader{
preferences: preferencesbiz.DesktopPreferences{
BrowserUseConnectionMode: "isolated",
},
}
svc.preferences = prefs
ctx := context.Background()
if _, err := svc.CallTool(ctx, "ws-1", "", "list_pages", nil); err != nil {
t.Fatalf("CallTool isolated: %v", err)
}
if len(transport.specs) != 1 {
t.Fatalf("started specs len = %d, want 1", len(transport.specs))
}
if !slices.Contains(transport.specs[0].Command, "--isolated") {
t.Fatalf("first command = %#v, want --isolated", transport.specs[0].Command)
}
prefs.setMode("autoConnect")
if _, err := svc.CallTool(ctx, "ws-1", "", "list_pages", nil); err != nil {
t.Fatalf("CallTool autoConnect: %v", err)
}
if transport.startCnt != 2 {
t.Fatalf("start count = %d, want 2 after connection mode change", transport.startCnt)
}
if !slices.Contains(transport.specs[1].Command, "--autoConnect") && !slices.Contains(transport.specs[1].Command, "--wsEndpoint") {
t.Fatalf("second command = %#v, want --autoConnect or --wsEndpoint", transport.specs[1].Command)
}
}
func TestCallToolRestartsAfterProcessExit(t *testing.T) {
transport := &exitAfterFirstToolTransport{}
svc := newTestService(transport)
ctx := context.Background()
if _, err := svc.CallTool(ctx, "ws-1", "", "list_pages", nil); err != nil {
t.Fatalf("first CallTool: %v", err)
}
if transport.startCnt != 1 {
t.Fatalf("start count = %d, want 1", transport.startCnt)
}
waitUntilBrowserSessionClosed(t, svc, "ws-1")
if _, err := svc.CallTool(ctx, "ws-1", "", "list_pages", nil); err != nil {
t.Fatalf("second CallTool after process exit: %v", err)
}
if transport.startCnt != 2 {
t.Fatalf("start count = %d, want restart after process exit", transport.startCnt)
}
}
// TestCallToolRecoversAfterUserClosesBrowserWindow reproduces the case where
// the user manually closes the automated browser window: chrome-devtools-mcp
// stays connected (no process exit, so isClosed() never trips) but has zero
// open pages, and returns "The selected page has been closed..." for any
// page-scoped tool call. CallTool must self-heal by opening a fresh page and
// retrying, rather than surfacing that error forever.
func TestCallToolRecoversAfterUserClosesBrowserWindow(t *testing.T) {
transport := &pageGoneOnceTransport{}
svc := newTestService(transport)
ctx := context.Background()
res, err := svc.CallTool(ctx, "ws-1", "", "navigate_page", map[string]any{"url": "https://example.org"})
if err != nil {
t.Fatalf("CallTool: %v", err)
}
if !strings.Contains(res.Text, "Successfully navigated") {
t.Fatalf("unexpected result text: %q", res.Text)
}
if transport.startCnt != 1 {
t.Fatalf("start count = %d, want 1 (self-heal must not restart the subprocess)", transport.startCnt)
}
sent := transport.conn.toolNames()
want := []string{"navigate_page", "new_page", "navigate_page"}
if len(sent) != len(want) {
t.Fatalf("tool calls = %#v, want %#v", sent, want)
}
for i, name := range want {
if sent[i] != name {
t.Fatalf("tool calls = %#v, want %#v", sent, want)
}
}
}
func waitUntilBrowserSessionClosed(t *testing.T, svc *Service, workspaceID string) {
t.Helper()
timeout := time.After(2 * time.Second)
tick := time.NewTicker(time.Millisecond)
defer tick.Stop()
for {
svc.mu.Lock()
session := svc.sessions[workspaceID]
svc.mu.Unlock()
if session != nil && session.client != nil && session.client.isClosed() {
return
}
select {
case <-timeout:
t.Fatal("timed out waiting for browser MCP process exit")
case <-tick.C:
}
}
}
func TestIdleTimerDoesNotShutdownDuringInFlightCall(t *testing.T) {
transport := &slowToolTransport{}
svc := newTestService(transport)
svc.idleTTL = 25 * time.Millisecond
ctx := context.Background()
done := make(chan error, 1)
go func() {
_, err := svc.CallTool(ctx, "ws-1", "", "list_pages", nil)
done <- err
}()
time.Sleep(60 * time.Millisecond)
if transport.startCnt != 1 {
t.Fatalf("start count = %d during in-flight call, want 1", transport.startCnt)
}
close(transport.release)
if err := <-done; err != nil {
t.Fatalf("CallTool: %v", err)
}
if transport.startCnt != 1 {
t.Fatalf("start count = %d after call completed, want 1", transport.startCnt)
}
}
func idOf(v any) int {
switch n := v.(type) {
case float64:
return int(n)
case json.Number:
i, _ := n.Int64()
return int(i)
}
return -1
}
type exitAfterFirstToolTransport struct {
mu sync.Mutex
startCnt int
conn *exitAfterFirstToolConn
}
func (t *exitAfterFirstToolTransport) Start(_ context.Context, _ agentruntime.ProcessSpec) (agentruntime.ProcessConnection, error) {
t.mu.Lock()
defer t.mu.Unlock()
t.startCnt++
if t.conn == nil {
t.conn = newExitAfterFirstToolConn()
} else {
t.conn = newExitAfterFirstToolConn()
}
return t.conn, nil
}
type exitAfterFirstToolConn struct {
*scriptedConn
toolCalls int
}
func newExitAfterFirstToolConn() *exitAfterFirstToolConn {
return &exitAfterFirstToolConn{scriptedConn: newScriptedConn()}
}
func (c *exitAfterFirstToolConn) Send(data []byte) error {
var msg map[string]any
_ = json.Unmarshal(data, &msg)
method, _ := msg["method"].(string)
err := c.scriptedConn.Send(data)
if method == "tools/call" {
c.toolCalls++
if c.toolCalls == 1 {
code := 1
c.frames <- agentruntime.ProcessFrame{ExitCode: &code}
}
}
return err
}
type slowToolTransport struct {
mu sync.Mutex
startCnt int
release chan struct{}
conn *slowToolConn
}
func (t *slowToolTransport) Start(_ context.Context, _ agentruntime.ProcessSpec) (agentruntime.ProcessConnection, error) {
t.mu.Lock()
defer t.mu.Unlock()
t.startCnt++
if t.release == nil {
t.release = make(chan struct{})
}
t.conn = newSlowToolConn(t.release)
return t.conn, nil
}
type slowToolConn struct {
*scriptedConn
release <-chan struct{}
}
func newSlowToolConn(release <-chan struct{}) *slowToolConn {
return &slowToolConn{scriptedConn: newScriptedConn(), release: release}
}
func (c *slowToolConn) Send(data []byte) error {
var msg map[string]any
if err := json.Unmarshal(data, &msg); err != nil {
return err
}
method, _ := msg["method"].(string)
id := msg["id"]
switch method {
case "initialize":
c.push(map[string]any{"jsonrpc": "2.0", "id": id, "result": map[string]any{
"protocolVersion": mcpProtocolVersion, "capabilities": map[string]any{},
}})
case "tools/call":
<-c.release
c.push(map[string]any{"jsonrpc": "2.0", "id": id, "result": map[string]any{
"isError": false,
"content": []map[string]any{{"type": "text", "text": "slow ok"}},
}})
}
return nil
}
// pageGoneOnceTransport starts a single conn whose first navigate_page call
// fails with the "selected page has been closed" error chrome-devtools-mcp
// returns after the user closes the browser window out of band; a subsequent
// new_page call, and any navigate_page call after that, succeed.
type pageGoneOnceTransport struct {
mu sync.Mutex
startCnt int
conn *pageGoneOnceConn
}
func (t *pageGoneOnceTransport) Start(_ context.Context, _ agentruntime.ProcessSpec) (agentruntime.ProcessConnection, error) {
t.mu.Lock()
defer t.mu.Unlock()
t.startCnt++
t.conn = newPageGoneOnceConn()
return t.conn, nil
}
type pageGoneOnceConn struct {
*scriptedConn
mu sync.Mutex
navigateCalls int
toolNamesCalls []string
}
func newPageGoneOnceConn() *pageGoneOnceConn {
return &pageGoneOnceConn{scriptedConn: newScriptedConn()}
}
func (c *pageGoneOnceConn) toolNames() []string {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.toolNamesCalls...)
}
func (c *pageGoneOnceConn) Send(data []byte) error {
var msg map[string]any
if err := json.Unmarshal(data, &msg); err != nil {
return err
}
method, _ := msg["method"].(string)
id := msg["id"]
if method != "tools/call" {
return c.scriptedConn.Send(data)
}
params, _ := msg["params"].(map[string]any)
name, _ := params["name"].(string)
c.mu.Lock()
c.toolNamesCalls = append(c.toolNamesCalls, name)
c.mu.Unlock()
if name == "navigate_page" {
c.mu.Lock()
c.navigateCalls++
first := c.navigateCalls == 1
c.mu.Unlock()
if first {
c.push(map[string]any{"jsonrpc": "2.0", "id": id, "result": map[string]any{
"isError": true,
"content": []map[string]any{{"type": "text", "text": "Error: The selected page has been closed. Call list_pages to see open pages."}},
}})
return nil
}
}
c.push(map[string]any{"jsonrpc": "2.0", "id": id, "result": map[string]any{
"isError": false,
"content": []map[string]any{{"type": "text", "text": "Successfully navigated to https://example.org"}},
}})
return nil
}
type staticPreferencesReader struct {
preferences preferencesbiz.DesktopPreferences
}
func (r staticPreferencesReader) GetDesktopPreferences(context.Context) (preferencesbiz.DesktopPreferences, error) {
return r.preferences, nil
}
type mutablePreferencesReader struct {
mu sync.Mutex
preferences preferencesbiz.DesktopPreferences
}
func (r *mutablePreferencesReader) GetDesktopPreferences(context.Context) (preferencesbiz.DesktopPreferences, error) {
r.mu.Lock()
defer r.mu.Unlock()
return r.preferences, nil
}
func (r *mutablePreferencesReader) setMode(mode string) {
r.mu.Lock()
defer r.mu.Unlock()
r.preferences.BrowserUseConnectionMode = mode
}
type browserRuntimeResolverStub struct {
runtime managedruntime.ResolvedRuntime
err error
}
func (r browserRuntimeResolverStub) Resolve(context.Context) (managedruntime.ResolvedRuntime, error) {
return r.runtime, r.err
}
func (r browserRuntimeResolverStub) ResolveProfile(context.Context, string) (managedruntime.ResolvedRuntime, error) {
return r.runtime, r.err
}
func (r browserRuntimeResolverStub) PreloadProfile(context.Context, string) error {
return r.err
}
// TestE2ENavigateRealChrome drives a real chrome-devtools-mcp + Chrome through
// the production transport. Skipped unless TUTTI_BROWSER_E2E=1 (slow; needs
// npx + Chrome).
func TestE2ENavigateRealChrome(t *testing.T) {
if os.Getenv("TUTTI_BROWSER_E2E") != "1" {
t.Skip("set TUTTI_BROWSER_E2E=1 to run the real-Chrome integration test")
}
svc := NewService()
defer svc.Close()
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
res, err := svc.CallTool(ctx, "e2e-ws", "", "navigate_page", map[string]any{"url": "https://example.com"})
if err != nil {
t.Fatalf("navigate: %v", err)
}
t.Logf("navigate result: %s", res.Text)
if !strings.Contains(res.Text, "example.com") {
t.Fatalf("unexpected navigate result: %q", res.Text)
}
snap, err := svc.CallTool(ctx, "e2e-ws", "", "take_snapshot", map[string]any{})
if err != nil {
t.Fatalf("snapshot: %v", err)
}
t.Logf("snapshot (first 200): %s", truncate(snap.Text, 200))
}
func truncate(s string, n int) string {
if len(s) > n {
return s[:n]
}
return s
}