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
595 lines
17 KiB
Go
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
|
|
}
|