From a83d85ca25aaf7b2db9d1c85319267a4feb3bab8 Mon Sep 17 00:00:00 2001 From: Sam F <43347795+monadoid@users.noreply.github.com> Date: Sat, 25 Jul 2026 02:22:07 +0200 Subject: [PATCH 1/3] Add Browserbase support to Go --- justfile | 4 + packages/sdk-go/browser_source.go | 217 ++++ packages/sdk-go/browser_source_test.go | 285 +++++ packages/sdk-go/browserbase_client.go | 1033 +++++++++++++++++++ packages/sdk-go/browserbase_client_test.go | 520 ++++++++++ packages/sdk-go/browserbase_session.go | 191 ++++ packages/sdk-go/browserbase_session_test.go | 320 ++++++ packages/sdk-go/client.go | 23 +- packages/sdk-go/client_test.go | 10 +- packages/sdk-go/stagehand.go | 29 +- packages/sdk-go/stagehand_live_test.go | 173 ++++ 11 files changed, 2780 insertions(+), 25 deletions(-) create mode 100644 packages/sdk-go/browser_source.go create mode 100644 packages/sdk-go/browser_source_test.go create mode 100644 packages/sdk-go/browserbase_client.go create mode 100644 packages/sdk-go/browserbase_client_test.go create mode 100644 packages/sdk-go/browserbase_session.go create mode 100644 packages/sdk-go/browserbase_session_test.go create mode 100644 packages/sdk-go/stagehand_live_test.go diff --git a/justfile b/justfile index 53316e6a7..37e45c20d 100644 --- a/justfile +++ b/justfile @@ -5,6 +5,7 @@ go_generator_dir := "packages/sdk-go/internal/generator" install: pnpm install uv --directory {{python_dir}} sync --locked + go -C {{go_dir}} mod download go -C {{go_generator_dir}} mod download generate: @@ -42,6 +43,9 @@ example name="act": pnpm --filter ./packages/sdk-ts build pnpm exec tsx "packages/sdk-ts/examples/{{name}}.ts" +go-example name="act": + go -C {{go_dir}} run "./examples/{{name}}" + fmt: pnpm fmt uv --directory {{python_dir}} run --locked ruff check --fix . diff --git a/packages/sdk-go/browser_source.go b/packages/sdk-go/browser_source.go new file mode 100644 index 000000000..f72186901 --- /dev/null +++ b/packages/sdk-go/browser_source.go @@ -0,0 +1,217 @@ +package stagehand + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/browserbase/stagehand/packages/sdk-go/internal/extensionassets" +) + +type browserbaseSessionCreator interface { + createSession( + context.Context, + BrowserbaseClientBrowserSource, + ) (resolvedBrowserSource, error) +} + +type browserSourceResolverDependencies struct { + browserbase browserbaseSessionCreator + createBrowserbaseClient func(string) (browserbaseSessionCreator, error) + launchLocal func(context.Context, LocalBrowserSource) (resolvedBrowserSource, error) + materializeExtension func() (string, func() error, error) +} + +func resolveBrowserSource( + ctx context.Context, + params StagehandClientInitParams, +) (resolvedBrowserSource, error) { + return resolveBrowserSourceWithDependencies(ctx, params, browserSourceResolverDependencies{}) +} + +func resolveBrowserSourceWithDependencies( + ctx context.Context, + params StagehandClientInitParams, + dependencies browserSourceResolverDependencies, +) (resolvedBrowserSource, error) { + if ctx == nil { + return resolvedBrowserSource{}, errors.New( + "stagehand browser source context is required", + ) + } + + source := params.Browser + if source == nil { + source = BrowserbaseClientBrowserSource{} + } + switch browser := source.(type) { + case BrowserbaseClientBrowserSource: + return resolveBrowserbaseSource(ctx, params.APIKey, browser, dependencies) + case *BrowserbaseClientBrowserSource: + if browser == nil { + return resolvedBrowserSource{}, errors.New( + "stagehand Browserbase browser source is nil", + ) + } + return resolveBrowserbaseSource(ctx, params.APIKey, *browser, dependencies) + case LocalBrowserSource: + return resolveLocalSource(ctx, browser, dependencies) + case *LocalBrowserSource: + if browser == nil { + return resolvedBrowserSource{}, errors.New( + "stagehand local browser source is nil", + ) + } + return resolveLocalSource(ctx, *browser, dependencies) + case CDPBrowserSource: + return resolveCDPSource(browser, dependencies) + case *CDPBrowserSource: + if browser == nil { + return resolvedBrowserSource{}, errors.New( + "stagehand CDP browser source is nil", + ) + } + return resolveCDPSource(*browser, dependencies) + default: + return resolvedBrowserSource{}, fmt.Errorf( + "stagehand unsupported browser source %T", + source, + ) + } +} + +func resolveBrowserbaseSource( + ctx context.Context, + apiKey *string, + source BrowserbaseClientBrowserSource, + dependencies browserSourceResolverDependencies, +) (resolvedBrowserSource, error) { + if apiKey == nil || strings.TrimSpace(*apiKey) == "" { + return resolvedBrowserSource{}, errors.New( + "stagehand Browserbase API key is required for the Browserbase browser source", + ) + } + + client := dependencies.browserbase + if client == nil { + factory := dependencies.createBrowserbaseClient + if factory == nil { + factory = func(apiKey string) (browserbaseSessionCreator, error) { + return newBrowserbaseSessionClient(apiKey, browserbaseSessionClientOptions{}) + } + } + var err error + client, err = factory(*apiKey) + if err != nil { + return resolvedBrowserSource{}, fmt.Errorf( + "create Stagehand Browserbase client: %w", + err, + ) + } + } + + resolved, err := client.createSession(ctx, source) + if err != nil { + return resolvedBrowserSource{}, err + } + resolved.preloadedExtension = true + return resolved, nil +} + +func resolveLocalSource( + ctx context.Context, + source LocalBrowserSource, + dependencies browserSourceResolverDependencies, +) (resolvedBrowserSource, error) { + extensionDir, cleanup, err := materializeStagehandExtension(dependencies) + if err != nil { + return resolvedBrowserSource{}, err + } + + launch := dependencies.launchLocal + if launch == nil { + launch = launchLocalBrowser + } + resolved, err := launch(ctx, source) + if err != nil { + return resolvedBrowserSource{}, errors.Join(err, cleanup()) + } + resolved.extensionDir = extensionDir + resolved.cleanup = cleanup + if source.ConnectTimeoutMs > 0 { + resolved.connectTimeout = time.Duration(source.ConnectTimeoutMs) * time.Millisecond + } + return resolved, nil +} + +func resolveCDPSource( + source CDPBrowserSource, + dependencies browserSourceResolverDependencies, +) (resolvedBrowserSource, error) { + if strings.TrimSpace(source.CDPURL) == "" { + return resolvedBrowserSource{}, errors.New("stagehand CDP URL is required") + } + extensionDir, cleanup, err := materializeStagehandExtension(dependencies) + if err != nil { + return resolvedBrowserSource{}, err + } + + headers := make(http.Header, len(source.Headers)) + for name, value := range source.Headers { + if strings.TrimSpace(name) == "" { + return resolvedBrowserSource{}, errors.Join( + errors.New("stagehand CDP header name cannot be empty"), + cleanup(), + ) + } + headers.Set(name, value) + } + return resolvedBrowserSource{ + cdpURL: source.CDPURL, + cdpHeaders: headers, + extensionDir: extensionDir, + keepAlive: true, + cleanup: cleanup, + }, nil +} + +func materializeStagehandExtension( + dependencies browserSourceResolverDependencies, +) (string, func() error, error) { + materialize := dependencies.materializeExtension + if materialize == nil { + materialize = extensionassets.Materialize + } + directory, cleanup, err := materialize() + if err != nil { + return "", nil, fmt.Errorf("materialize bundled Stagehand extension: %w", err) + } + if strings.TrimSpace(directory) == "" || cleanup == nil { + if cleanup != nil { + err = cleanup() + } + return "", nil, errors.Join( + errors.New("materialized Stagehand extension is incomplete"), + err, + ) + } + return directory, cleanup, nil +} + +func connectResolvedBrowser( + ctx context.Context, + browser resolvedBrowserSource, + telemetry TelemetryConfig, +) (protocolClient, error) { + return connectRPCClient(ctx, cdpClientOptions{ + cdpURL: browser.cdpURL, + headers: browser.cdpHeaders, + extensionDir: browser.extensionDir, + preloadedExtension: browser.preloadedExtension, + serviceWorkerURLIncludes: "service-worker.js", + connectTimeout: browser.connectTimeout, + }, telemetry) +} diff --git a/packages/sdk-go/browser_source_test.go b/packages/sdk-go/browser_source_test.go new file mode 100644 index 000000000..c66846cb7 --- /dev/null +++ b/packages/sdk-go/browser_source_test.go @@ -0,0 +1,285 @@ +package stagehand + +import ( + "context" + "errors" + "net/http" + "reflect" + "strings" + "testing" + "time" +) + +type recordingBrowserbaseSessionCreator struct { + params BrowserbaseClientBrowserSource + result resolvedBrowserSource + err error +} + +func (creator *recordingBrowserbaseSessionCreator) createSession( + _ context.Context, + params BrowserbaseClientBrowserSource, +) (resolvedBrowserSource, error) { + creator.params = params + return creator.result, creator.err +} + +func TestResolveBrowserSourceSupportsEveryClientMode(t *testing.T) { + t.Run("Browserbase", func(t *testing.T) { + apiKey := "bb_test" + keepAlive := true + creator := &recordingBrowserbaseSessionCreator{ + result: resolvedBrowserSource{ + cdpURL: "wss://connect.browserbase.test/session", + browserbaseSessionID: "session_123", + keepAlive: true, + }, + } + factoryAPIKey := "" + materializeCalls := 0 + + resolved, err := resolveBrowserSourceWithDependencies( + context.Background(), + StagehandClientInitParams{ + APIKey: &apiKey, + Browser: BrowserbaseClientBrowserSource{ + KeepAlive: &keepAlive, + }, + }, + browserSourceResolverDependencies{ + createBrowserbaseClient: func( + apiKey string, + ) (browserbaseSessionCreator, error) { + factoryAPIKey = apiKey + return creator, nil + }, + materializeExtension: func() (string, func() error, error) { + materializeCalls++ + return "", nil, errors.New("must not materialize") + }, + }, + ) + if err != nil { + t.Fatalf("resolveBrowserSourceWithDependencies() error = %v", err) + } + if factoryAPIKey != apiKey { + t.Fatalf("factory API key = %q, want %q", factoryAPIKey, apiKey) + } + if creator.params.KeepAlive != &keepAlive { + t.Fatalf("Browserbase params = %#v", creator.params) + } + if !resolved.preloadedExtension { + t.Fatal("preloadedExtension = false, want true") + } + if materializeCalls != 0 { + t.Fatalf("materialize calls = %d, want 0", materializeCalls) + } + }) + + t.Run("local", func(t *testing.T) { + cleanupCalls := 0 + launchCalls := 0 + closeCalls := 0 + source := LocalBrowserSource{ + Headless: true, + ConnectTimeoutMs: 2_500, + } + + resolved, err := resolveBrowserSourceWithDependencies( + context.Background(), + StagehandClientInitParams{Browser: source}, + browserSourceResolverDependencies{ + launchLocal: func( + _ context.Context, + got LocalBrowserSource, + ) (resolvedBrowserSource, error) { + launchCalls++ + if !reflect.DeepEqual(got, source) { + t.Fatalf("local source = %#v, want %#v", got, source) + } + return resolvedBrowserSource{ + cdpURL: "http://127.0.0.1:9222", + close: func(context.Context) error { + closeCalls++ + return nil + }, + }, nil + }, + materializeExtension: func() (string, func() error, error) { + return "/tmp/stagehand-extension", func() error { + cleanupCalls++ + return nil + }, nil + }, + }, + ) + if err != nil { + t.Fatalf("resolveBrowserSourceWithDependencies() error = %v", err) + } + if launchCalls != 1 { + t.Fatalf("launch calls = %d, want 1", launchCalls) + } + if resolved.extensionDir != "/tmp/stagehand-extension" { + t.Fatalf("extensionDir = %q", resolved.extensionDir) + } + if resolved.connectTimeout != 2_500*time.Millisecond { + t.Fatalf("connectTimeout = %s", resolved.connectTimeout) + } + if err := resolved.close(context.Background()); err != nil { + t.Fatalf("close() error = %v", err) + } + if err := resolved.cleanup(); err != nil { + t.Fatalf("cleanup() error = %v", err) + } + if closeCalls != 1 || cleanupCalls != 1 { + t.Fatalf( + "close calls = %d, cleanup calls = %d; want 1 and 1", + closeCalls, + cleanupCalls, + ) + } + }) + + t.Run("existing CDP", func(t *testing.T) { + cleanupCalls := 0 + resolved, err := resolveBrowserSourceWithDependencies( + context.Background(), + StagehandClientInitParams{Browser: CDPBrowserSource{ + CDPURL: "http://browser.test:9222", + Headers: map[string]string{ + "X-Browser-Token": "secret", + }, + }}, + browserSourceResolverDependencies{ + materializeExtension: func() (string, func() error, error) { + return "/tmp/stagehand-extension", func() error { + cleanupCalls++ + return nil + }, nil + }, + }, + ) + if err != nil { + t.Fatalf("resolveBrowserSourceWithDependencies() error = %v", err) + } + if resolved.cdpURL != "http://browser.test:9222" { + t.Fatalf("cdpURL = %q", resolved.cdpURL) + } + if !reflect.DeepEqual( + resolved.cdpHeaders, + http.Header{"X-Browser-Token": []string{"secret"}}, + ) { + t.Fatalf("cdpHeaders = %#v", resolved.cdpHeaders) + } + if !resolved.keepAlive || resolved.close != nil { + t.Fatalf("existing CDP ownership = %#v", resolved) + } + if err := resolved.cleanup(); err != nil { + t.Fatalf("cleanup() error = %v", err) + } + if cleanupCalls != 1 { + t.Fatalf("cleanup calls = %d, want 1", cleanupCalls) + } + }) +} + +func TestResolveBrowserSourceCleansPartialFailures(t *testing.T) { + cleanupErr := errors.New("cleanup failed") + launchErr := errors.New("launch failed") + cleanupCalls := 0 + _, err := resolveBrowserSourceWithDependencies( + context.Background(), + StagehandClientInitParams{Browser: LocalBrowserSource{}}, + browserSourceResolverDependencies{ + launchLocal: func( + context.Context, + LocalBrowserSource, + ) (resolvedBrowserSource, error) { + return resolvedBrowserSource{}, launchErr + }, + materializeExtension: func() (string, func() error, error) { + return "/tmp/stagehand-extension", func() error { + cleanupCalls++ + return cleanupErr + }, nil + }, + }, + ) + if !errors.Is(err, launchErr) || !errors.Is(err, cleanupErr) { + t.Fatalf("resolve error = %v, want launch and cleanup errors", err) + } + if cleanupCalls != 1 { + t.Fatalf("cleanup calls = %d, want 1", cleanupCalls) + } +} + +func TestResolveBrowserSourceValidatesClientInputs(t *testing.T) { + tests := []struct { + name string + params StagehandClientInitParams + want string + }{ + { + name: "default Browserbase API key", + params: StagehandClientInitParams{}, + want: "Browserbase API key is required", + }, + { + name: "empty CDP URL", + params: StagehandClientInitParams{ + Browser: CDPBrowserSource{}, + }, + want: "CDP URL is required", + }, + { + name: "nil local source", + params: StagehandClientInitParams{ + Browser: (*LocalBrowserSource)(nil), + }, + want: "local browser source is nil", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := resolveBrowserSourceWithDependencies( + context.Background(), + test.params, + browserSourceResolverDependencies{ + materializeExtension: func() (string, func() error, error) { + return "/tmp/stagehand-extension", func() error { return nil }, nil + }, + }, + ) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("resolve error = %v, want containing %q", err, test.want) + } + }) + } +} + +func TestStagehandReleaseCleansSDKResourcesForKeepAliveBrowser(t *testing.T) { + closeCalls := 0 + cleanupCalls := 0 + client := New(StagehandClientInitParams{}) + client.browser = &resolvedBrowserSource{ + keepAlive: true, + close: func(context.Context) error { + closeCalls++ + return nil + }, + cleanup: func() error { + cleanupCalls++ + return nil + }, + } + + if err := client.releaseBrowser(context.Background()); err != nil { + t.Fatalf("releaseBrowser() error = %v", err) + } + if closeCalls != 0 { + t.Fatalf("browser close calls = %d, want 0", closeCalls) + } + if cleanupCalls != 1 { + t.Fatalf("SDK cleanup calls = %d, want 1", cleanupCalls) + } +} diff --git a/packages/sdk-go/browserbase_client.go b/packages/sdk-go/browserbase_client.go new file mode 100644 index 000000000..590ddd412 --- /dev/null +++ b/packages/sdk-go/browserbase_client.go @@ -0,0 +1,1033 @@ +package stagehand + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "mime/multipart" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +const ( + defaultBrowserbaseBaseURL = "https://api.browserbase.com" + defaultBrowserbaseHTTPTimeout = 60 * time.Second + defaultBrowserbaseMaxRetries = 2 + maxBrowserbaseAPIResponseBytes = 4 << 20 + stagehandExtensionUploadName = "stagehand-extension.zip" +) + +// BrowserbaseAPIError is a non-successful response from the Browserbase API. +type BrowserbaseAPIError struct { + Method string + Path string + StatusCode int + RequestID string + Body string +} + +func (err *BrowserbaseAPIError) Error() string { + message := browserbaseErrorMessage([]byte(err.Body)) + if message == "" { + message = http.StatusText(err.StatusCode) + } + if message == "" { + message = "request failed" + } + return fmt.Sprintf( + "Browserbase %s %s returned %d: %s", + err.Method, + err.Path, + err.StatusCode, + message, + ) +} + +type browserbaseHTTPClientOptions struct { + baseURL string + httpClient *http.Client + maxRetries *int + sleep func(context.Context, time.Duration) error +} + +type browserbaseHTTPClient struct { + baseURL string + apiKey string + httpClient *http.Client + maxRetries int + sleep func(context.Context, time.Duration) error +} + +type browserbaseAPI interface { + uploadExtension(context.Context, []byte) (browserbaseExtensionResponse, error) + deleteExtension(context.Context, string) error + createSession( + context.Context, + browserbaseCreateSessionRequest, + ) (browserbaseCreateSessionResponse, error) + releaseSession(context.Context, string) (browserbaseSessionResponse, error) +} + +func newBrowserbaseHTTPClient( + apiKey string, + options browserbaseHTTPClientOptions, +) (*browserbaseHTTPClient, error) { + if strings.TrimSpace(apiKey) == "" { + return nil, errors.New("stagehand Browserbase API key is required") + } + + baseURL := options.baseURL + if baseURL == "" { + baseURL = os.Getenv("BROWSERBASE_BASE_URL") + } + if baseURL == "" { + baseURL = defaultBrowserbaseBaseURL + } + parsedBaseURL, err := url.Parse(baseURL) + if err != nil { + return nil, fmt.Errorf("parse Browserbase base URL: %w", err) + } + if (parsedBaseURL.Scheme != "http" && parsedBaseURL.Scheme != "https") || + parsedBaseURL.Host == "" || + parsedBaseURL.RawQuery != "" || + parsedBaseURL.Fragment != "" { + return nil, fmt.Errorf("invalid Browserbase base URL %q", baseURL) + } + + httpClient := options.httpClient + if httpClient == nil { + httpClient = &http.Client{Timeout: defaultBrowserbaseHTTPTimeout} + } + maxRetries := defaultBrowserbaseMaxRetries + if options.maxRetries != nil { + maxRetries = *options.maxRetries + } + if maxRetries < 0 { + return nil, errors.New("stagehand Browserbase max retries cannot be negative") + } + sleep := options.sleep + if sleep == nil { + sleep = sleepWithContext + } + + return &browserbaseHTTPClient{ + baseURL: strings.TrimRight(parsedBaseURL.String(), "/"), + apiKey: apiKey, + httpClient: httpClient, + maxRetries: maxRetries, + sleep: sleep, + }, nil +} + +func (client *browserbaseHTTPClient) uploadExtension( + ctx context.Context, + archive []byte, +) (browserbaseExtensionResponse, error) { + return sendBrowserbaseRequest[browserbaseExtensionResponse]( + ctx, + client, + browserbaseUploadExtensionRequest{ + Archive: archive, + FileName: stagehandExtensionUploadName, + }, + ) +} + +func (client *browserbaseHTTPClient) deleteExtension( + ctx context.Context, + extensionID string, +) error { + _, err := sendBrowserbaseRequest[browserbaseNoContentResponse]( + ctx, + client, + browserbaseDeleteExtensionRequest{ExtensionID: extensionID}, + ) + return err +} + +func (client *browserbaseHTTPClient) createSession( + ctx context.Context, + request browserbaseCreateSessionRequest, +) (browserbaseCreateSessionResponse, error) { + return sendBrowserbaseRequest[browserbaseCreateSessionResponse](ctx, client, request) +} + +func (client *browserbaseHTTPClient) releaseSession( + ctx context.Context, + sessionID string, +) (browserbaseSessionResponse, error) { + return sendBrowserbaseRequest[browserbaseSessionResponse]( + ctx, + client, + browserbaseReleaseSessionRequest{ + SessionID: sessionID, + Status: browserbaseSessionReleaseStatus, + }, + ) +} + +type browserbaseEncodedRequest struct { + method string + path string + body []byte + contentType string + accept string +} + +type browserbaseEndpointRequest interface { + encode() (browserbaseEncodedRequest, error) +} + +type browserbaseEndpointResponse interface { + validate() error +} + +func sendBrowserbaseRequest[Response browserbaseEndpointResponse]( + ctx context.Context, + client *browserbaseHTTPClient, + request browserbaseEndpointRequest, +) (Response, error) { + var zero Response + if ctx == nil { + return zero, errors.New("stagehand Browserbase request context is required") + } + if client == nil { + return zero, errors.New("stagehand Browserbase HTTP client is required") + } + + encoded, err := request.encode() + if err != nil { + return zero, fmt.Errorf("validate Browserbase %s request: %w", encoded.path, err) + } + for attempt := 0; ; attempt++ { + httpRequest, err := http.NewRequestWithContext( + ctx, + encoded.method, + client.baseURL+encoded.path, + bytes.NewReader(encoded.body), + ) + if err != nil { + return zero, fmt.Errorf("create Browserbase request: %w", err) + } + httpRequest.Header.Set("X-BB-API-Key", client.apiKey) + httpRequest.Header.Set("User-Agent", stagehandSDKClientName+"/"+stagehandSDKVersion) + if encoded.contentType != "" { + httpRequest.Header.Set("Content-Type", encoded.contentType) + } + if encoded.accept != "" { + httpRequest.Header.Set("Accept", encoded.accept) + } else { + httpRequest.Header.Set("Accept", "application/json") + } + + httpResponse, requestErr := client.httpClient.Do(httpRequest) + if requestErr != nil { + if ctx.Err() != nil { + return zero, ctx.Err() + } + if attempt >= client.maxRetries { + return zero, fmt.Errorf("send Browserbase request: %w", requestErr) + } + if err := client.sleep(ctx, browserbaseDefaultRetryDelay(attempt)); err != nil { + return zero, err + } + continue + } + + responseBody, readErr := readBrowserbaseResponse(httpResponse.Body) + closeErr := httpResponse.Body.Close() + if readErr != nil || closeErr != nil { + return zero, errors.Join(readErr, closeErr) + } + if browserbaseShouldRetry(httpResponse) && attempt < client.maxRetries { + if err := client.sleep( + ctx, + browserbaseRetryDelay(httpResponse.Header, attempt), + ); err != nil { + return zero, err + } + continue + } + if httpResponse.StatusCode < http.StatusOK || + httpResponse.StatusCode >= http.StatusMultipleChoices { + return zero, &BrowserbaseAPIError{ + Method: encoded.method, + Path: encoded.path, + StatusCode: httpResponse.StatusCode, + RequestID: httpResponse.Header.Get("x-request-id"), + Body: string(responseBody), + } + } + + var response Response + if len(bytes.TrimSpace(responseBody)) != 0 { + decoder := json.NewDecoder(bytes.NewReader(responseBody)) + if err := decoder.Decode(&response); err != nil { + return zero, fmt.Errorf( + "decode Browserbase %s response: %w", + encoded.path, + err, + ) + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + if err == nil { + err = errors.New("multiple JSON values") + } + return zero, fmt.Errorf( + "decode Browserbase %s response: %w", + encoded.path, + err, + ) + } + } + if err := response.validate(); err != nil { + return zero, fmt.Errorf( + "validate Browserbase %s response: %w", + encoded.path, + err, + ) + } + return response, nil + } +} + +func readBrowserbaseResponse(body io.Reader) ([]byte, error) { + responseBody, err := io.ReadAll(io.LimitReader(body, maxBrowserbaseAPIResponseBytes+1)) + if err != nil { + return nil, fmt.Errorf("read Browserbase response: %w", err) + } + if len(responseBody) > maxBrowserbaseAPIResponseBytes { + return nil, fmt.Errorf( + "Browserbase response exceeds %d bytes", + maxBrowserbaseAPIResponseBytes, + ) + } + return responseBody, nil +} + +func browserbaseShouldRetry(response *http.Response) bool { + switch strings.ToLower(strings.TrimSpace(response.Header.Get("x-should-retry"))) { + case "true": + return true + case "false": + return false + } + return response.StatusCode == http.StatusRequestTimeout || + response.StatusCode == http.StatusConflict || + response.StatusCode == http.StatusTooManyRequests || + response.StatusCode >= http.StatusInternalServerError +} + +func browserbaseRetryDelay(headers http.Header, attempt int) time.Duration { + if milliseconds, err := strconv.ParseFloat( + strings.TrimSpace(headers.Get("retry-after-ms")), + 64, + ); err == nil && milliseconds >= 0 { + return time.Duration(milliseconds * float64(time.Millisecond)) + } + + retryAfter := strings.TrimSpace(headers.Get("Retry-After")) + if seconds, err := strconv.ParseFloat(retryAfter, 64); err == nil && seconds >= 0 { + return time.Duration(seconds * float64(time.Second)) + } + if retryTime, err := http.ParseTime(retryAfter); err == nil { + if delay := time.Until(retryTime); delay > 0 { + return delay + } + return 0 + } + return browserbaseDefaultRetryDelay(attempt) +} + +func browserbaseDefaultRetryDelay(attempt int) time.Duration { + delay := 500 * time.Millisecond * time.Duration(1< 21600) { + return errors.New("timeout must be between 60 and 21600 seconds") + } + if request.BrowserSettings != nil { + if err := request.BrowserSettings.validate(); err != nil { + return fmt.Errorf("browserSettings: %w", err) + } + } + if request.Proxies != nil { + if err := request.Proxies.validate(); err != nil { + return fmt.Errorf("proxies: %w", err) + } + } + for key, value := range request.UserMetadata { + if !json.Valid(value) { + return fmt.Errorf("userMetadata[%q] is not valid JSON", key) + } + } + return nil +} + +type browserbaseBrowserSettingsRequest struct { + AdvancedStealth *bool `json:"advancedStealth,omitempty"` + BlockAds *bool `json:"blockAds,omitempty"` + CaptchaImageSelector *string `json:"captchaImageSelector,omitempty"` + CaptchaInputSelector *string `json:"captchaInputSelector,omitempty"` + Context *browserbaseContextRequest `json:"context,omitempty"` + ExtensionID *string `json:"extensionId,omitempty"` + Fingerprint *browserbaseFingerprintRequest `json:"fingerprint,omitempty"` + LogSession *bool `json:"logSession,omitempty"` + OS *BrowserbaseBrowserSettingsOS `json:"os,omitempty"` + RecordSession *bool `json:"recordSession,omitempty"` + SolveCaptchas *bool `json:"solveCaptchas,omitempty"` + Verified *bool `json:"verified,omitempty"` + Viewport *browserbaseViewportRequest `json:"viewport,omitempty"` +} + +func (settings browserbaseBrowserSettingsRequest) validate() error { + if settings.ExtensionID != nil && strings.TrimSpace(*settings.ExtensionID) == "" { + return errors.New("extensionId cannot be empty") + } + if settings.OS != nil && !isBrowserbaseOS(*settings.OS) { + return fmt.Errorf("invalid os %q", *settings.OS) + } + if settings.Fingerprint != nil { + if err := settings.Fingerprint.validate(); err != nil { + return fmt.Errorf("fingerprint: %w", err) + } + } + return nil +} + +type browserbaseContextRequest struct { + ID string `json:"id"` + Persist *bool `json:"persist,omitempty"` +} + +type browserbaseViewportRequest struct { + Height *float64 `json:"height,omitempty"` + Width *float64 `json:"width,omitempty"` +} + +type browserbaseFingerprintRequest struct { + Browsers []BrowserbaseFingerprintBrowsersElem `json:"browsers,omitempty"` + Devices []BrowserbaseFingerprintDevicesElem `json:"devices,omitempty"` + HTTPVersion *BrowserbaseFingerprintHTTPVersion `json:"httpVersion,omitempty"` + Locales []string `json:"locales,omitempty"` + OperatingSystems []BrowserbaseFingerprintOperatingSystemsElem `json:"operatingSystems,omitempty"` + Screen *browserbaseFingerprintScreenRequest `json:"screen,omitempty"` +} + +func (fingerprint browserbaseFingerprintRequest) validate() error { + for _, browser := range fingerprint.Browsers { + switch browser { + case BrowserbaseFingerprintBrowsersElemChrome, + BrowserbaseFingerprintBrowsersElemEdge, + BrowserbaseFingerprintBrowsersElemFirefox, + BrowserbaseFingerprintBrowsersElemSafari: + default: + return fmt.Errorf("invalid browser %q", browser) + } + } + for _, device := range fingerprint.Devices { + switch device { + case BrowserbaseFingerprintDevicesElemDesktop, + BrowserbaseFingerprintDevicesElemMobile: + default: + return fmt.Errorf("invalid device %q", device) + } + } + if fingerprint.HTTPVersion != nil { + switch *fingerprint.HTTPVersion { + case BrowserbaseFingerprintHTTPVersionA1, BrowserbaseFingerprintHTTPVersionA2: + default: + return fmt.Errorf("invalid HTTP version %q", *fingerprint.HTTPVersion) + } + } + for _, operatingSystem := range fingerprint.OperatingSystems { + switch operatingSystem { + case BrowserbaseFingerprintOperatingSystemsElemAndroid, + BrowserbaseFingerprintOperatingSystemsElemIOS, + BrowserbaseFingerprintOperatingSystemsElemLinux, + BrowserbaseFingerprintOperatingSystemsElemMacos, + BrowserbaseFingerprintOperatingSystemsElemWindows: + default: + return fmt.Errorf("invalid operating system %q", operatingSystem) + } + } + return nil +} + +type browserbaseFingerprintScreenRequest struct { + MaxHeight *float64 `json:"maxHeight,omitempty"` + MaxWidth *float64 `json:"maxWidth,omitempty"` + MinHeight *float64 `json:"minHeight,omitempty"` + MinWidth *float64 `json:"minWidth,omitempty"` +} + +type browserbaseProxyGeolocationRequest struct { + Country string `json:"country"` + City *string `json:"city,omitempty"` + State *string `json:"state,omitempty"` +} + +type browserbaseProxyRequest struct { + Type string `json:"type"` + DomainPattern *string `json:"domainPattern,omitempty"` + Geolocation *browserbaseProxyGeolocationRequest `json:"geolocation,omitempty"` + Server *string `json:"server,omitempty"` + Username *string `json:"username,omitempty"` + Password *string `json:"password,omitempty"` +} + +func (proxy browserbaseProxyRequest) validate() error { + switch proxy.Type { + case proxyTypeBrowserbase: + if proxy.Geolocation != nil && strings.TrimSpace(proxy.Geolocation.Country) == "" { + return errors.New("Browserbase proxy geolocation country is required") + } + if proxy.Server != nil || proxy.Username != nil || proxy.Password != nil { + return errors.New("Browserbase proxy cannot contain external proxy credentials") + } + case proxyTypeExternal: + if proxy.Server == nil || strings.TrimSpace(*proxy.Server) == "" { + return errors.New("external proxy server is required") + } + if proxy.Geolocation != nil { + return errors.New("external proxy cannot contain Browserbase geolocation") + } + default: + return fmt.Errorf("invalid proxy type %q", proxy.Type) + } + return nil +} + +type browserbaseProxiesRequest struct { + enabled *bool + list []browserbaseProxyRequest +} + +func (proxies browserbaseProxiesRequest) validate() error { + if proxies.enabled != nil { + if proxies.list != nil { + return errors.New("boolean and list proxy variants are mutually exclusive") + } + return nil + } + if proxies.list == nil { + return errors.New("proxy configuration is unset") + } + for index, proxy := range proxies.list { + if err := proxy.validate(); err != nil { + return fmt.Errorf("proxy %d: %w", index, err) + } + } + return nil +} + +func (proxies browserbaseProxiesRequest) MarshalJSON() ([]byte, error) { + if err := proxies.validate(); err != nil { + return nil, err + } + if proxies.enabled != nil { + return json.Marshal(*proxies.enabled) + } + return json.Marshal(proxies.list) +} + +type browserbaseExtensionResponse struct { + ID *string `json:"id"` + CreatedAt *string `json:"createdAt"` + FileName *string `json:"fileName"` + ProjectID *string `json:"projectId"` + UpdatedAt *string `json:"updatedAt"` +} + +func (response browserbaseExtensionResponse) validate() error { + if err := requireBrowserbaseResponseFields(map[string]bool{ + "id": response.ID != nil, + "createdAt": response.CreatedAt != nil, + "fileName": response.FileName != nil, + "projectId": response.ProjectID != nil, + "updatedAt": response.UpdatedAt != nil, + }); err != nil { + return err + } + if strings.TrimSpace(*response.FileName) == "" { + return errors.New("fileName cannot be empty") + } + if err := validateBrowserbaseDateTime("createdAt", *response.CreatedAt); err != nil { + return err + } + return validateBrowserbaseDateTime("updatedAt", *response.UpdatedAt) +} + +type browserbaseSessionResponseFields struct { + ID *string `json:"id"` + CreatedAt *string `json:"createdAt"` + ExpiresAt *string `json:"expiresAt"` + KeepAlive *bool `json:"keepAlive"` + ProjectID *string `json:"projectId"` + ProxyBytes *int64 `json:"proxyBytes"` + Region *BrowserbaseRegion `json:"region"` + StartedAt *string `json:"startedAt"` + Status *browserbaseSessionStatus `json:"status"` + UpdatedAt *string `json:"updatedAt"` + ContextID *string `json:"contextId,omitempty"` + EndedAt *string `json:"endedAt,omitempty"` + UserMetadata map[string]json.RawMessage `json:"userMetadata,omitempty"` +} + +func (response browserbaseSessionResponseFields) validate() error { + if err := requireBrowserbaseResponseFields(map[string]bool{ + "id": response.ID != nil, + "createdAt": response.CreatedAt != nil, + "expiresAt": response.ExpiresAt != nil, + "keepAlive": response.KeepAlive != nil, + "projectId": response.ProjectID != nil, + "proxyBytes": response.ProxyBytes != nil, + "region": response.Region != nil, + "startedAt": response.StartedAt != nil, + "status": response.Status != nil, + "updatedAt": response.UpdatedAt != nil, + }); err != nil { + return err + } + if !isBrowserbaseRegion(*response.Region) { + return fmt.Errorf("invalid region %q", *response.Region) + } + if !isBrowserbaseSessionStatus(*response.Status) { + return fmt.Errorf("invalid status %q", *response.Status) + } + for name, value := range map[string]*string{ + "createdAt": response.CreatedAt, + "expiresAt": response.ExpiresAt, + "startedAt": response.StartedAt, + "updatedAt": response.UpdatedAt, + } { + if err := validateBrowserbaseDateTime(name, *value); err != nil { + return err + } + } + if response.EndedAt != nil { + if err := validateBrowserbaseDateTime("endedAt", *response.EndedAt); err != nil { + return err + } + } + return nil +} + +type browserbaseSessionResponse struct { + browserbaseSessionResponseFields +} + +func (response browserbaseSessionResponse) validate() error { + return response.browserbaseSessionResponseFields.validate() +} + +type browserbaseCreateSessionResponse struct { + browserbaseSessionResponseFields + ConnectURL *string `json:"connectUrl"` + SeleniumRemoteURL *string `json:"seleniumRemoteUrl"` + SigningKey *string `json:"signingKey"` +} + +func (response browserbaseCreateSessionResponse) validate() error { + if err := response.browserbaseSessionResponseFields.validate(); err != nil { + return err + } + if err := requireBrowserbaseResponseFields(map[string]bool{ + "connectUrl": response.ConnectURL != nil, + "seleniumRemoteUrl": response.SeleniumRemoteURL != nil, + "signingKey": response.SigningKey != nil, + }); err != nil { + return err + } + if err := validateBrowserbaseURL("connectUrl", *response.ConnectURL, "ws", "wss"); err != nil { + return err + } + return validateBrowserbaseURL( + "seleniumRemoteUrl", + *response.SeleniumRemoteURL, + "http", + "https", + ) +} + +type browserbaseNoContentResponse struct{} + +func (browserbaseNoContentResponse) validate() error { + return nil +} + +type browserbaseSessionStatus string + +const ( + browserbaseSessionStatusPending browserbaseSessionStatus = "PENDING" + browserbaseSessionStatusRunning browserbaseSessionStatus = "RUNNING" + browserbaseSessionStatusError browserbaseSessionStatus = "ERROR" + browserbaseSessionStatusTimedOut browserbaseSessionStatus = "TIMED_OUT" + browserbaseSessionStatusCompleted browserbaseSessionStatus = "COMPLETED" +) + +func isBrowserbaseSessionStatus(status browserbaseSessionStatus) bool { + switch status { + case browserbaseSessionStatusPending, + browserbaseSessionStatusRunning, + browserbaseSessionStatusError, + browserbaseSessionStatusTimedOut, + browserbaseSessionStatusCompleted: + return true + default: + return false + } +} + +func isBrowserbaseRegion(region BrowserbaseRegion) bool { + switch region { + case BrowserbaseRegionAPSoutheast1, + BrowserbaseRegionEUCentral1, + BrowserbaseRegionUSEast1, + BrowserbaseRegionUSWest2: + return true + default: + return false + } +} + +func isBrowserbaseOS(value BrowserbaseBrowserSettingsOS) bool { + switch value { + case BrowserbaseBrowserSettingsOSLinux, + BrowserbaseBrowserSettingsOSMac, + BrowserbaseBrowserSettingsOSMobile, + BrowserbaseBrowserSettingsOSTablet, + BrowserbaseBrowserSettingsOSWindows: + return true + default: + return false + } +} + +func requireBrowserbaseResponseFields(fields map[string]bool) error { + for name, present := range fields { + if !present { + return fmt.Errorf("required field %s is missing", name) + } + } + return nil +} + +func validateBrowserbaseDateTime(name string, value string) error { + if _, err := time.Parse(time.RFC3339Nano, value); err != nil { + return fmt.Errorf("%s must be an RFC 3339 date-time: %w", name, err) + } + return nil +} + +func validateBrowserbaseURL(name string, value string, schemes ...string) error { + parsed, err := url.Parse(value) + if err != nil || parsed.Host == "" { + return fmt.Errorf("%s must be an absolute URL", name) + } + for _, scheme := range schemes { + if parsed.Scheme == scheme { + return nil + } + } + return fmt.Errorf("%s must use one of these schemes: %s", name, strings.Join(schemes, ", ")) +} + +func newBrowserbaseCreateSessionRequest( + params BrowserbaseClientBrowserSource, + extensionID string, +) (browserbaseCreateSessionRequest, error) { + request := browserbaseCreateSessionRequest{ + ExtensionID: &extensionID, + KeepAlive: params.KeepAlive, + Region: params.Region, + UserMetadata: cloneRawMessageMap(params.UserMetadata), + } + if params.Timeout != nil { + timeout := *params.Timeout + if math.IsNaN(timeout) || math.IsInf(timeout, 0) || math.Trunc(timeout) != timeout { + return browserbaseCreateSessionRequest{}, errors.New( + "Browserbase timeout must be a whole number of seconds", + ) + } + timeoutSeconds := int64(timeout) + request.Timeout = &timeoutSeconds + } + if params.BrowserSettings != nil { + request.BrowserSettings = convertBrowserbaseBrowserSettings(*params.BrowserSettings) + } + if params.Proxies != nil { + proxies, err := convertBrowserbaseProxies(*params.Proxies) + if err != nil { + return browserbaseCreateSessionRequest{}, err + } + request.Proxies = &proxies + } + if err := request.validate(); err != nil { + return browserbaseCreateSessionRequest{}, err + } + return request, nil +} + +func convertBrowserbaseBrowserSettings( + settings BrowserbaseBrowserSettings, +) *browserbaseBrowserSettingsRequest { + converted := &browserbaseBrowserSettingsRequest{ + AdvancedStealth: settings.AdvancedStealth, + BlockAds: settings.BlockAds, + CaptchaImageSelector: settings.CaptchaImageSelector, + CaptchaInputSelector: settings.CaptchaInputSelector, + ExtensionID: settings.ExtensionID, + LogSession: settings.LogSession, + OS: settings.OS, + RecordSession: settings.RecordSession, + SolveCaptchas: settings.SolveCaptchas, + Verified: settings.Verified, + } + if settings.Context != nil { + converted.Context = &browserbaseContextRequest{ + ID: settings.Context.ID, + Persist: settings.Context.Persist, + } + } + if settings.Viewport != nil { + converted.Viewport = &browserbaseViewportRequest{ + Height: settings.Viewport.Height, + Width: settings.Viewport.Width, + } + } + if settings.Fingerprint != nil { + converted.Fingerprint = convertBrowserbaseFingerprint(*settings.Fingerprint) + } + return converted +} + +func convertBrowserbaseFingerprint( + fingerprint BrowserbaseFingerprint, +) *browserbaseFingerprintRequest { + converted := &browserbaseFingerprintRequest{ + Browsers: append([]BrowserbaseFingerprintBrowsersElem(nil), fingerprint.Browsers...), + Devices: append([]BrowserbaseFingerprintDevicesElem(nil), fingerprint.Devices...), + HTTPVersion: fingerprint.HTTPVersion, + Locales: append([]string(nil), fingerprint.Locales...), + OperatingSystems: append( + []BrowserbaseFingerprintOperatingSystemsElem(nil), + fingerprint.OperatingSystems..., + ), + } + if fingerprint.Screen != nil { + converted.Screen = &browserbaseFingerprintScreenRequest{ + MaxHeight: fingerprint.Screen.MaxHeight, + MaxWidth: fingerprint.Screen.MaxWidth, + MinHeight: fingerprint.Screen.MinHeight, + MinWidth: fingerprint.Screen.MinWidth, + } + } + return converted +} + +func convertBrowserbaseProxies( + proxies BrowserbaseProxies, +) (browserbaseProxiesRequest, error) { + if enabled, ok := proxies.AsEnabled(); ok { + return browserbaseProxiesRequest{enabled: &enabled}, nil + } + values, ok := proxies.AsList() + if !ok { + return browserbaseProxiesRequest{}, errors.New("Browserbase proxies are unset") + } + converted := make([]browserbaseProxyRequest, 0, len(values)) + for index, value := range values { + if managed, ok := value.AsBrowserbase(); ok { + proxy := browserbaseProxyRequest{ + Type: proxyTypeBrowserbase, + DomainPattern: managed.DomainPattern, + } + if managed.Geolocation != nil { + proxy.Geolocation = &browserbaseProxyGeolocationRequest{ + Country: managed.Geolocation.Country, + City: managed.Geolocation.City, + State: managed.Geolocation.State, + } + } + converted = append(converted, proxy) + continue + } + if external, ok := value.AsExternal(); ok { + server := external.Server + converted = append(converted, browserbaseProxyRequest{ + Type: proxyTypeExternal, + DomainPattern: external.DomainPattern, + Server: &server, + Username: external.Username, + Password: external.Password, + }) + continue + } + return browserbaseProxiesRequest{}, fmt.Errorf("Browserbase proxy %d is unset", index) + } + result := browserbaseProxiesRequest{list: converted} + if err := result.validate(); err != nil { + return browserbaseProxiesRequest{}, err + } + return result, nil +} + +func cloneRawMessageMap( + source map[string]json.RawMessage, +) map[string]json.RawMessage { + if source == nil { + return nil + } + result := make(map[string]json.RawMessage, len(source)) + for key, value := range source { + result[key] = bytes.Clone(value) + } + return result +} diff --git a/packages/sdk-go/browserbase_client_test.go b/packages/sdk-go/browserbase_client_test.go new file mode 100644 index 000000000..1172155de --- /dev/null +++ b/packages/sdk-go/browserbase_client_test.go @@ -0,0 +1,520 @@ +package stagehand + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + "time" +) + +func TestBrowserbaseHTTPClientUsesTypedEndpointSchemas(t *testing.T) { + archive := []byte("test-stagehand-extension") + calls := make([]string, 0, 4) + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func( + writer http.ResponseWriter, + request *http.Request, + ) { + calls = append(calls, request.Method+" "+request.URL.EscapedPath()) + if request.Header.Get("X-BB-API-Key") != "bb_test" { + t.Errorf("X-BB-API-Key = %q", request.Header.Get("X-BB-API-Key")) + } + if request.Header.Get("User-Agent") != stagehandSDKClientName+"/"+stagehandSDKVersion { + t.Errorf("User-Agent = %q", request.Header.Get("User-Agent")) + } + + switch { + case request.Method == http.MethodPost && request.URL.Path == "/v1/extensions": + if err := request.ParseMultipartForm(1 << 20); err != nil { + t.Errorf("parse extension multipart body: %v", err) + writer.WriteHeader(http.StatusBadRequest) + return + } + file, header, err := request.FormFile("file") + if err != nil { + t.Errorf("read extension multipart file: %v", err) + writer.WriteHeader(http.StatusBadRequest) + return + } + defer file.Close() + body, err := io.ReadAll(file) + if err != nil { + t.Errorf("read extension body: %v", err) + } + if !bytes.Equal(body, archive) { + t.Errorf("extension body = %q, want %q", body, archive) + } + if header.Filename != stagehandExtensionUploadName { + t.Errorf( + "extension filename = %q, want %q", + header.Filename, + stagehandExtensionUploadName, + ) + } + writeBrowserbaseTestJSON(writer, browserbaseTestExtensionResponse("ext_stagehand")) + + case request.Method == http.MethodPost && request.URL.Path == "/v1/sessions": + var got map[string]any + if err := json.NewDecoder(request.Body).Decode(&got); err != nil { + t.Errorf("decode session request: %v", err) + writer.WriteHeader(http.StatusBadRequest) + return + } + want := browserbaseExpectedSessionRequest() + if !reflect.DeepEqual(got, want) { + t.Errorf("session request = %#v, want %#v", got, want) + } + response := browserbaseTestCreateSessionResponse("session_123") + response["futureField"] = "accepted like a Zod object" + writeBrowserbaseTestJSON(writer, response) + + case request.Method == http.MethodPost && + request.URL.Path == "/v1/sessions/session_123": + var got map[string]any + if err := json.NewDecoder(request.Body).Decode(&got); err != nil { + t.Errorf("decode session release request: %v", err) + } + want := map[string]any{"status": browserbaseSessionReleaseStatus} + if !reflect.DeepEqual(got, want) { + t.Errorf("release request = %#v, want %#v", got, want) + } + writeBrowserbaseTestJSON( + writer, + browserbaseTestSessionResponse("session_123", "COMPLETED"), + ) + + case request.Method == http.MethodDelete && + request.URL.Path == "/v1/extensions/ext_stagehand": + if request.Header.Get("Content-Type") != "" { + t.Errorf("extension DELETE Content-Type = %q", request.Header.Get("Content-Type")) + } + if request.Header.Get("Accept") != "*/*" { + t.Errorf("extension DELETE Accept = %q", request.Header.Get("Accept")) + } + writer.WriteHeader(http.StatusNoContent) + + default: + http.Error(writer, "unexpected endpoint", http.StatusNotFound) + } + })) + defer server.Close() + + api, err := newBrowserbaseHTTPClient("bb_test", browserbaseHTTPClientOptions{ + baseURL: server.URL, + httpClient: server.Client(), + }) + if err != nil { + t.Fatalf("newBrowserbaseHTTPClient() error = %v", err) + } + client, err := newBrowserbaseSessionClient("bb_test", browserbaseSessionClientOptions{ + api: api, + archive: func() []byte { return bytes.Clone(archive) }, + }) + if err != nil { + t.Fatalf("newBrowserbaseSessionClient() error = %v", err) + } + + browser, err := client.createSession(context.Background(), browserbaseTestSessionParams()) + if err != nil { + t.Fatalf("createSession() error = %v", err) + } + if browser.cdpURL != + "wss://connect.browserbase.com/devtools/browser/session_123" { + t.Fatalf("cdpURL = %q", browser.cdpURL) + } + if browser.browserbaseSessionID != "session_123" { + t.Fatalf("browserbaseSessionID = %q", browser.browserbaseSessionID) + } + if !browser.keepAlive { + t.Fatal("keepAlive = false, want true") + } + if err := browser.close(context.Background()); err != nil { + t.Fatalf("close() error = %v", err) + } + if err := browser.close(context.Background()); err != nil { + t.Fatalf("second close() error = %v", err) + } + + wantCalls := []string{ + "POST /v1/extensions", + "POST /v1/sessions", + "POST /v1/sessions/session_123", + "DELETE /v1/extensions/ext_stagehand", + } + if !reflect.DeepEqual(calls, wantCalls) { + t.Fatalf("calls = %#v, want %#v", calls, wantCalls) + } +} + +func TestBrowserbaseHTTPClientRetriesReplayableRequests(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func( + writer http.ResponseWriter, + request *http.Request, + ) { + requests++ + if requests == 1 { + writer.Header().Set("retry-after-ms", "0") + http.Error(writer, "rate limited", http.StatusTooManyRequests) + return + } + writeBrowserbaseTestJSON(writer, browserbaseTestCreateSessionResponse("session_retry")) + })) + defer server.Close() + + var sleeps []time.Duration + client, err := newBrowserbaseHTTPClient("bb_test", browserbaseHTTPClientOptions{ + baseURL: server.URL, + httpClient: server.Client(), + sleep: func(_ context.Context, duration time.Duration) error { + sleeps = append(sleeps, duration) + return nil + }, + }) + if err != nil { + t.Fatalf("newBrowserbaseHTTPClient() error = %v", err) + } + response, err := client.createSession( + context.Background(), + browserbaseCreateSessionRequest{}, + ) + if err != nil { + t.Fatalf("createSession() error = %v", err) + } + if response.ID == nil || *response.ID != "session_retry" { + t.Fatalf("response ID = %#v", response.ID) + } + if requests != 2 { + t.Fatalf("requests = %d, want 2", requests) + } + if !reflect.DeepEqual(sleeps, []time.Duration{0}) { + t.Fatalf("sleeps = %#v, want [0]", sleeps) + } +} + +func TestBrowserbaseHTTPClientReturnsTypedAPIErrors(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func( + writer http.ResponseWriter, + request *http.Request, + ) { + requests++ + writer.Header().Set("x-request-id", "request_123") + writer.Header().Set("x-should-retry", "false") + writer.WriteHeader(http.StatusInternalServerError) + _, _ = writer.Write([]byte(`{"message":"Browserbase unavailable"}`)) + })) + defer server.Close() + + client, err := newBrowserbaseHTTPClient("bb_test", browserbaseHTTPClientOptions{ + baseURL: server.URL, + httpClient: server.Client(), + }) + if err != nil { + t.Fatalf("newBrowserbaseHTTPClient() error = %v", err) + } + _, err = client.createSession(context.Background(), browserbaseCreateSessionRequest{}) + var apiErr *BrowserbaseAPIError + if !errors.As(err, &apiErr) { + t.Fatalf("createSession() error = %v, want BrowserbaseAPIError", err) + } + if apiErr.StatusCode != http.StatusInternalServerError || + apiErr.RequestID != "request_123" || + !strings.Contains(apiErr.Error(), "Browserbase unavailable") { + t.Fatalf("BrowserbaseAPIError = %#v (%v)", apiErr, apiErr) + } + if requests != 1 { + t.Fatalf("requests = %d, want 1", requests) + } +} + +func TestBrowserbaseHTTPClientRejectsInvalidResponses(t *testing.T) { + tests := []struct { + name string + body string + want string + }{ + { + name: "malformed JSON", + body: `{"id":`, + want: "decode Browserbase", + }, + { + name: "missing required field", + body: `{"id":"session_123"}`, + want: "required field", + }, + { + name: "wrong field type", + body: `{"id":123}`, + want: "cannot unmarshal", + }, + { + name: "multiple JSON values", + body: `{} {}`, + want: "multiple JSON values", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func( + writer http.ResponseWriter, + request *http.Request, + ) { + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(test.body)) + })) + defer server.Close() + client, err := newBrowserbaseHTTPClient("bb_test", browserbaseHTTPClientOptions{ + baseURL: server.URL, + httpClient: server.Client(), + }) + if err != nil { + t.Fatalf("newBrowserbaseHTTPClient() error = %v", err) + } + _, err = client.createSession( + context.Background(), + browserbaseCreateSessionRequest{}, + ) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("createSession() error = %v, want containing %q", err, test.want) + } + }) + } +} + +func TestBrowserbaseHTTPClientValidatesBeforeSending(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func( + writer http.ResponseWriter, + request *http.Request, + ) { + requests++ + writer.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + client, err := newBrowserbaseHTTPClient("bb_test", browserbaseHTTPClientOptions{ + baseURL: server.URL, + httpClient: server.Client(), + }) + if err != nil { + t.Fatalf("newBrowserbaseHTTPClient() error = %v", err) + } + + invalidRegion := BrowserbaseRegion("moon-1") + _, err = client.createSession(context.Background(), browserbaseCreateSessionRequest{ + Region: &invalidRegion, + }) + if err == nil || !strings.Contains(err.Error(), "invalid region") { + t.Fatalf("createSession() error = %v, want invalid region", err) + } + if requests != 0 { + t.Fatalf("requests = %d, want 0", requests) + } + + _, err = client.createSession(context.Background(), browserbaseCreateSessionRequest{ + Timeout: testPointer(int64(59)), + }) + if err == nil || !strings.Contains(err.Error(), "between 60 and 21600") { + t.Fatalf("createSession() error = %v, want invalid timeout", err) + } + if requests != 0 { + t.Fatalf("requests = %d, want 0", requests) + } +} + +func TestNewBrowserbaseHTTPClientValidatesConfiguration(t *testing.T) { + if _, err := newBrowserbaseHTTPClient("", browserbaseHTTPClientOptions{}); err == nil { + t.Fatal("empty API key error = nil") + } + if _, err := newBrowserbaseHTTPClient("bb_test", browserbaseHTTPClientOptions{ + baseURL: "file:///tmp/browserbase", + }); err == nil { + t.Fatal("invalid base URL error = nil") + } + if _, err := newBrowserbaseHTTPClient("bb_test", browserbaseHTTPClientOptions{ + maxRetries: testPointer(-1), + }); err == nil { + t.Fatal("negative retries error = nil") + } +} + +func browserbaseTestSessionParams() BrowserbaseClientBrowserSource { + keepAlive := true + advancedStealth := true + blockAds := false + persist := true + logSession := false + recordSession := true + solveCaptchas := true + verified := false + width := 1280.0 + height := 800.0 + httpVersion := BrowserbaseFingerprintHTTPVersionA2 + minWidth := 1024.0 + city := "Zurich" + username := "proxy-user" + password := "proxy-password" + domainPattern := "*.example.com" + oldExtensionID := "ext_caller" + settingsExtensionID := "ext_settings" + return BrowserbaseClientBrowserSource{ + BrowserSettings: &BrowserbaseBrowserSettings{ + AdvancedStealth: &advancedStealth, + BlockAds: &blockAds, + Context: &BrowserbaseContext{ + ID: "context_123", + Persist: &persist, + }, + ExtensionID: &settingsExtensionID, + LogSession: &logSession, + OS: testPointer(BrowserbaseBrowserSettingsOSMac), + RecordSession: &recordSession, + SolveCaptchas: &solveCaptchas, + Verified: &verified, + Viewport: &BrowserbaseViewport{Width: &width, Height: &height}, + Fingerprint: &BrowserbaseFingerprint{ + Browsers: []BrowserbaseFingerprintBrowsersElem{BrowserbaseFingerprintBrowsersElemChrome}, + Devices: []BrowserbaseFingerprintDevicesElem{BrowserbaseFingerprintDevicesElemDesktop}, + HTTPVersion: &httpVersion, + Locales: []string{"de-CH", "en-US"}, + OperatingSystems: []BrowserbaseFingerprintOperatingSystemsElem{ + BrowserbaseFingerprintOperatingSystemsElemMacos, + }, + Screen: &BrowserbaseFingerprintScreen{MinWidth: &minWidth}, + }, + }, + ExtensionID: &oldExtensionID, + KeepAlive: &keepAlive, + Proxies: testPointer(BrowserbaseProxyList( + BrowserbaseProxy(BrowserbaseProxyConfig{ + DomainPattern: &domainPattern, + Geolocation: &BrowserbaseProxyGeolocation{ + Country: "CH", + City: &city, + }, + }), + ExternalProxy(ExternalProxyConfig{ + Server: "http://proxy.example:8080", + Username: &username, + Password: &password, + }), + )), + Region: testPointer(BrowserbaseRegionEUCentral1), + Timeout: testPointer(300.0), + UserMetadata: map[string]json.RawMessage{ + "suite": json.RawMessage(`"go-browserbase-client"`), + "attempt": json.RawMessage(`3`), + }, + } +} + +func browserbaseExpectedSessionRequest() map[string]any { + return map[string]any{ + "browserSettings": map[string]any{ + "advancedStealth": true, + "blockAds": false, + "context": map[string]any{ + "id": "context_123", + "persist": true, + }, + "extensionId": "ext_settings", + "logSession": false, + "os": "mac", + "recordSession": true, + "solveCaptchas": true, + "verified": false, + "viewport": map[string]any{ + "width": 1280.0, + "height": 800.0, + }, + "fingerprint": map[string]any{ + "browsers": []any{"chrome"}, + "devices": []any{"desktop"}, + "httpVersion": "2", + "locales": []any{"de-CH", "en-US"}, + "operatingSystems": []any{"macos"}, + "screen": map[string]any{"minWidth": 1024.0}, + }, + }, + "extensionId": "ext_stagehand", + "keepAlive": true, + "proxies": []any{ + map[string]any{ + "type": "browserbase", + "domainPattern": "*.example.com", + "geolocation": map[string]any{ + "country": "CH", + "city": "Zurich", + }, + }, + map[string]any{ + "type": "external", + "server": "http://proxy.example:8080", + "username": "proxy-user", + "password": "proxy-password", + }, + }, + "region": "eu-central-1", + "timeout": 300.0, + "userMetadata": map[string]any{ + "suite": "go-browserbase-client", + "attempt": 3.0, + }, + } +} + +func browserbaseTestExtensionResponse(extensionID string) map[string]any { + return map[string]any{ + "id": extensionID, + "createdAt": "2026-07-23T10:00:00.000Z", + "fileName": stagehandExtensionUploadName, + "projectId": "project_123", + "updatedAt": "2026-07-23T10:00:00.000Z", + } +} + +func browserbaseTestSessionResponse(sessionID string, status string) map[string]any { + return map[string]any{ + "id": sessionID, + "createdAt": "2026-07-23T10:00:00.000Z", + "expiresAt": "2026-07-23T10:05:00.000Z", + "keepAlive": true, + "projectId": "project_123", + "proxyBytes": 0, + "region": "eu-central-1", + "startedAt": "2026-07-23T10:00:01.000Z", + "status": status, + "updatedAt": "2026-07-23T10:00:01.000Z", + "userMetadata": map[string]any{ + "suite": "go-browserbase-client", + }, + } +} + +func browserbaseTestCreateSessionResponse(sessionID string) map[string]any { + response := browserbaseTestSessionResponse(sessionID, "RUNNING") + response["connectUrl"] = + "wss://connect.browserbase.com/devtools/browser/" + sessionID + response["seleniumRemoteUrl"] = "https://connect.browserbase.com/selenium/" + sessionID + response["signingKey"] = "signing_key" + return response +} + +func writeBrowserbaseTestJSON(writer http.ResponseWriter, value any) { + writer.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(writer).Encode(value); err != nil { + panic(err) + } +} + +func testPointer[Value any](value Value) *Value { + return &value +} diff --git a/packages/sdk-go/browserbase_session.go b/packages/sdk-go/browserbase_session.go new file mode 100644 index 000000000..0d8c518c2 --- /dev/null +++ b/packages/sdk-go/browserbase_session.go @@ -0,0 +1,191 @@ +package stagehand + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + + "github.com/browserbase/stagehand/packages/sdk-go/internal/extensionassets" +) + +type browserbaseSessionClientOptions struct { + api browserbaseAPI + archive func() []byte + http browserbaseHTTPClientOptions +} + +type browserbaseSessionClient struct { + api browserbaseAPI + archive func() []byte +} + +func newBrowserbaseSessionClient( + apiKey string, + options browserbaseSessionClientOptions, +) (*browserbaseSessionClient, error) { + api := options.api + if api == nil { + httpClient, err := newBrowserbaseHTTPClient(apiKey, options.http) + if err != nil { + return nil, err + } + api = httpClient + } + archive := options.archive + if archive == nil { + archive = extensionassets.Archive + } + return &browserbaseSessionClient{api: api, archive: archive}, nil +} + +func (client *browserbaseSessionClient) createSession( + ctx context.Context, + params BrowserbaseClientBrowserSource, +) (resolvedBrowserSource, error) { + if ctx == nil { + return resolvedBrowserSource{}, errors.New( + "stagehand Browserbase session context is required", + ) + } + + request, err := newBrowserbaseCreateSessionRequest(params, "pending-stagehand-extension") + if err != nil { + return resolvedBrowserSource{}, fmt.Errorf("build Browserbase session request: %w", err) + } + archive := client.archive() + if len(archive) == 0 { + return resolvedBrowserSource{}, errors.New( + "bundled Stagehand extension archive is empty", + ) + } + extension, err := client.api.uploadExtension(ctx, archive) + if err != nil { + return resolvedBrowserSource{}, fmt.Errorf( + "upload Stagehand extension to Browserbase: %w", + err, + ) + } + if err := extension.validate(); err != nil { + return resolvedBrowserSource{}, fmt.Errorf( + "validate Browserbase extension upload: %w", + err, + ) + } + extensionID := strings.TrimSpace(*extension.ID) + if extensionID == "" { + return resolvedBrowserSource{}, errors.New( + "Browserbase extension upload returned an empty extension ID", + ) + } + + request.ExtensionID = &extensionID + session, err := client.api.createSession(ctx, request) + if err != nil { + return resolvedBrowserSource{}, errors.Join( + fmt.Errorf("create Browserbase session: %w", err), + client.deleteExtensionBestEffort(ctx, extensionID), + ) + } + if err := session.validate(); err != nil { + sessionID := "" + if session.ID != nil { + sessionID = strings.TrimSpace(*session.ID) + } + return resolvedBrowserSource{}, errors.Join( + fmt.Errorf("validate Browserbase session: %w", err), + client.cleanupInvalidSession(ctx, sessionID, extensionID), + ) + } + + sessionID := strings.TrimSpace(*session.ID) + cdpURL := strings.TrimSpace(*session.ConnectURL) + if sessionID == "" || cdpURL == "" { + cleanupErr := client.cleanupInvalidSession(ctx, sessionID, extensionID) + if sessionID == "" { + return resolvedBrowserSource{}, errors.Join( + errors.New("Browserbase session creation returned an empty session ID"), + cleanupErr, + ) + } + return resolvedBrowserSource{}, errors.Join( + errors.New("Browserbase session creation returned an empty connection URL"), + cleanupErr, + ) + } + + resources := &browserbaseSessionResources{ + api: client.api, + sessionID: sessionID, + extensionID: extensionID, + } + keepAlive := params.KeepAlive != nil && *params.KeepAlive + return resolvedBrowserSource{ + cdpURL: cdpURL, + browserbaseSessionID: sessionID, + keepAlive: keepAlive, + close: resources.close, + }, nil +} + +func (client *browserbaseSessionClient) cleanupInvalidSession( + ctx context.Context, + sessionID string, + extensionID string, +) error { + var releaseErr error + if sessionID != "" { + _, releaseErr = client.api.releaseSession(ctx, sessionID) + } + deleteErr := client.api.deleteExtension(ctx, extensionID) + return errors.Join(releaseErr, deleteErr) +} + +func (client *browserbaseSessionClient) deleteExtensionBestEffort( + ctx context.Context, + extensionID string, +) error { + if err := client.api.deleteExtension(ctx, extensionID); err != nil { + return fmt.Errorf("delete Browserbase extension after failure: %w", err) + } + return nil +} + +type browserbaseSessionResources struct { + api browserbaseAPI + sessionID string + extensionID string + + mu sync.Mutex + sessionReleased bool + extensionDeleted bool +} + +func (resources *browserbaseSessionResources) close(ctx context.Context) error { + if ctx == nil { + return errors.New("stagehand Browserbase close context is required") + } + + resources.mu.Lock() + defer resources.mu.Unlock() + + var releaseErr error + if !resources.sessionReleased { + if _, err := resources.api.releaseSession(ctx, resources.sessionID); err != nil { + releaseErr = err + } else { + resources.sessionReleased = true + } + } + + var extensionErr error + if !resources.extensionDeleted { + if err := resources.api.deleteExtension(ctx, resources.extensionID); err != nil { + extensionErr = err + } else { + resources.extensionDeleted = true + } + } + return errors.Join(releaseErr, extensionErr) +} diff --git a/packages/sdk-go/browserbase_session_test.go b/packages/sdk-go/browserbase_session_test.go new file mode 100644 index 000000000..6656cba51 --- /dev/null +++ b/packages/sdk-go/browserbase_session_test.go @@ -0,0 +1,320 @@ +package stagehand + +import ( + "context" + "errors" + "strings" + "testing" +) + +type fakeBrowserbaseAPI struct { + uploadExtensionFunc func( + context.Context, + []byte, + ) (browserbaseExtensionResponse, error) + deleteExtensionFunc func(context.Context, string) error + createSessionFunc func( + context.Context, + browserbaseCreateSessionRequest, + ) (browserbaseCreateSessionResponse, error) + releaseSessionFunc func(context.Context, string) (browserbaseSessionResponse, error) +} + +func (api *fakeBrowserbaseAPI) uploadExtension( + ctx context.Context, + archive []byte, +) (browserbaseExtensionResponse, error) { + if api.uploadExtensionFunc != nil { + return api.uploadExtensionFunc(ctx, archive) + } + return validBrowserbaseExtensionResponse("ext_stagehand"), nil +} + +func (api *fakeBrowserbaseAPI) deleteExtension( + ctx context.Context, + extensionID string, +) error { + if api.deleteExtensionFunc != nil { + return api.deleteExtensionFunc(ctx, extensionID) + } + return nil +} + +func (api *fakeBrowserbaseAPI) createSession( + ctx context.Context, + request browserbaseCreateSessionRequest, +) (browserbaseCreateSessionResponse, error) { + if api.createSessionFunc != nil { + return api.createSessionFunc(ctx, request) + } + return validBrowserbaseCreateSessionResponse("session_123"), nil +} + +func (api *fakeBrowserbaseAPI) releaseSession( + ctx context.Context, + sessionID string, +) (browserbaseSessionResponse, error) { + if api.releaseSessionFunc != nil { + return api.releaseSessionFunc(ctx, sessionID) + } + return validBrowserbaseSessionResponse(sessionID), nil +} + +func TestBrowserbaseSessionClientCleansExtensionAfterCreateFailure(t *testing.T) { + createErr := errors.New("concurrency limit reached") + deletedExtensionID := "" + api := &fakeBrowserbaseAPI{ + createSessionFunc: func( + context.Context, + browserbaseCreateSessionRequest, + ) (browserbaseCreateSessionResponse, error) { + return browserbaseCreateSessionResponse{}, createErr + }, + deleteExtensionFunc: func(_ context.Context, extensionID string) error { + deletedExtensionID = extensionID + return nil + }, + } + client := newBrowserbaseTestSessionClient(t, api) + + _, err := client.createSession(context.Background(), BrowserbaseClientBrowserSource{}) + if !errors.Is(err, createErr) { + t.Fatalf("createSession() error = %v, want create error", err) + } + if deletedExtensionID != "ext_stagehand" { + t.Fatalf("deleted extension = %q, want ext_stagehand", deletedExtensionID) + } +} + +func TestBrowserbaseSessionClientCleansInvalidSession(t *testing.T) { + releasedSessionID := "" + deletedExtensionID := "" + response := validBrowserbaseCreateSessionResponse("session_123") + emptyConnectionURL := " " + response.ConnectURL = &emptyConnectionURL + api := &fakeBrowserbaseAPI{ + createSessionFunc: func( + context.Context, + browserbaseCreateSessionRequest, + ) (browserbaseCreateSessionResponse, error) { + return response, nil + }, + releaseSessionFunc: func( + _ context.Context, + sessionID string, + ) (browserbaseSessionResponse, error) { + releasedSessionID = sessionID + return validBrowserbaseSessionResponse(sessionID), nil + }, + deleteExtensionFunc: func(_ context.Context, extensionID string) error { + deletedExtensionID = extensionID + return nil + }, + } + client := newBrowserbaseTestSessionClient(t, api) + + _, err := client.createSession(context.Background(), BrowserbaseClientBrowserSource{}) + if err == nil || !strings.Contains(err.Error(), "connectUrl must be an absolute URL") { + t.Fatalf("createSession() error = %v, want invalid connectUrl", err) + } + if releasedSessionID != "session_123" { + t.Fatalf("released session = %q, want session_123", releasedSessionID) + } + if deletedExtensionID != "ext_stagehand" { + t.Fatalf("deleted extension = %q, want ext_stagehand", deletedExtensionID) + } +} + +func TestBrowserbaseSessionCloseRetriesOnlyFailedSteps(t *testing.T) { + t.Run("release", func(t *testing.T) { + releaseErr := errors.New("release failed") + releaseCalls := 0 + deleteCalls := 0 + api := &fakeBrowserbaseAPI{ + releaseSessionFunc: func( + _ context.Context, + sessionID string, + ) (browserbaseSessionResponse, error) { + releaseCalls++ + if releaseCalls == 1 { + return browserbaseSessionResponse{}, releaseErr + } + return validBrowserbaseSessionResponse(sessionID), nil + }, + deleteExtensionFunc: func(context.Context, string) error { + deleteCalls++ + return nil + }, + } + client := newBrowserbaseTestSessionClient(t, api) + browser, err := client.createSession( + context.Background(), + BrowserbaseClientBrowserSource{}, + ) + if err != nil { + t.Fatalf("createSession() error = %v", err) + } + + if err := browser.close(context.Background()); !errors.Is(err, releaseErr) { + t.Fatalf("first close() error = %v, want release error", err) + } + if err := browser.close(context.Background()); err != nil { + t.Fatalf("second close() error = %v", err) + } + if releaseCalls != 2 || deleteCalls != 1 { + t.Fatalf( + "release calls = %d, delete calls = %d; want 2 and 1", + releaseCalls, + deleteCalls, + ) + } + }) + + t.Run("extension deletion", func(t *testing.T) { + deleteErr := errors.New("extension deletion failed") + releaseCalls := 0 + deleteCalls := 0 + api := &fakeBrowserbaseAPI{ + releaseSessionFunc: func( + _ context.Context, + sessionID string, + ) (browserbaseSessionResponse, error) { + releaseCalls++ + return validBrowserbaseSessionResponse(sessionID), nil + }, + deleteExtensionFunc: func(context.Context, string) error { + deleteCalls++ + if deleteCalls == 1 { + return deleteErr + } + return nil + }, + } + client := newBrowserbaseTestSessionClient(t, api) + browser, err := client.createSession( + context.Background(), + BrowserbaseClientBrowserSource{}, + ) + if err != nil { + t.Fatalf("createSession() error = %v", err) + } + + if err := browser.close(context.Background()); !errors.Is(err, deleteErr) { + t.Fatalf("first close() error = %v, want delete error", err) + } + if err := browser.close(context.Background()); err != nil { + t.Fatalf("second close() error = %v", err) + } + if releaseCalls != 1 || deleteCalls != 2 { + t.Fatalf( + "release calls = %d, delete calls = %d; want 1 and 2", + releaseCalls, + deleteCalls, + ) + } + }) +} + +func TestBrowserbaseSessionClientRejectsInvalidUploadResponse(t *testing.T) { + api := &fakeBrowserbaseAPI{ + uploadExtensionFunc: func( + context.Context, + []byte, + ) (browserbaseExtensionResponse, error) { + response := validBrowserbaseExtensionResponse("") + return response, nil + }, + } + client := newBrowserbaseTestSessionClient(t, api) + + _, err := client.createSession(context.Background(), BrowserbaseClientBrowserSource{}) + if err == nil || !strings.Contains(err.Error(), "empty extension ID") { + t.Fatalf("createSession() error = %v, want empty extension ID", err) + } +} + +func TestBrowserbaseSessionClientValidatesBeforeUploadingExtension(t *testing.T) { + uploads := 0 + api := &fakeBrowserbaseAPI{ + uploadExtensionFunc: func( + context.Context, + []byte, + ) (browserbaseExtensionResponse, error) { + uploads++ + return validBrowserbaseExtensionResponse("ext_stagehand"), nil + }, + } + client := newBrowserbaseTestSessionClient(t, api) + + _, err := client.createSession(context.Background(), BrowserbaseClientBrowserSource{ + Timeout: testPointer(60.5), + }) + if err == nil || !strings.Contains(err.Error(), "whole number") { + t.Fatalf("createSession() error = %v, want whole-number timeout error", err) + } + if uploads != 0 { + t.Fatalf("extension uploads = %d, want 0", uploads) + } +} + +func newBrowserbaseTestSessionClient( + t *testing.T, + api browserbaseAPI, +) *browserbaseSessionClient { + t.Helper() + client, err := newBrowserbaseSessionClient("", browserbaseSessionClientOptions{ + api: api, + archive: func() []byte { return []byte("test-extension") }, + }) + if err != nil { + t.Fatalf("newBrowserbaseSessionClient() error = %v", err) + } + return client +} + +func validBrowserbaseExtensionResponse(extensionID string) browserbaseExtensionResponse { + return browserbaseExtensionResponse{ + ID: testPointer(extensionID), + CreatedAt: testPointer("2026-07-23T10:00:00.000Z"), + FileName: testPointer(stagehandExtensionUploadName), + ProjectID: testPointer("project_123"), + UpdatedAt: testPointer("2026-07-23T10:00:00.000Z"), + } +} + +func validBrowserbaseSessionResponse(sessionID string) browserbaseSessionResponse { + fields := validBrowserbaseSessionResponseFields(sessionID) + return browserbaseSessionResponse{browserbaseSessionResponseFields: fields} +} + +func validBrowserbaseCreateSessionResponse( + sessionID string, +) browserbaseCreateSessionResponse { + return browserbaseCreateSessionResponse{ + browserbaseSessionResponseFields: validBrowserbaseSessionResponseFields(sessionID), + ConnectURL: testPointer( + "wss://connect.browserbase.com/devtools/browser/" + sessionID, + ), + SeleniumRemoteURL: testPointer( + "https://connect.browserbase.com/selenium/" + sessionID, + ), + SigningKey: testPointer("signing_key"), + } +} + +func validBrowserbaseSessionResponseFields( + sessionID string, +) browserbaseSessionResponseFields { + return browserbaseSessionResponseFields{ + ID: testPointer(sessionID), + CreatedAt: testPointer("2026-07-23T10:00:00.000Z"), + ExpiresAt: testPointer("2026-07-23T10:05:00.000Z"), + KeepAlive: testPointer(false), + ProjectID: testPointer("project_123"), + ProxyBytes: testPointer(int64(0)), + Region: testPointer(BrowserbaseRegionUSWest2), + StartedAt: testPointer("2026-07-23T10:00:01.000Z"), + Status: testPointer(browserbaseSessionStatusRunning), + UpdatedAt: testPointer("2026-07-23T10:00:01.000Z"), + } +} diff --git a/packages/sdk-go/client.go b/packages/sdk-go/client.go index 1137b3338..c9634b02b 100644 --- a/packages/sdk-go/client.go +++ b/packages/sdk-go/client.go @@ -4,13 +4,13 @@ import ( "context" "encoding/json" "errors" + "net/http" + "time" ) var ( // ErrNotInitialized is returned when an operation needs an initialized client. ErrNotInitialized = errors.New("stagehand is not initialized; call Init first") - // ErrBrowserSourceNotImplemented marks the intentionally deferred browser bootstrap. - ErrBrowserSourceNotImplemented = errors.New("stagehand Go browser source setup is not implemented") ) type requestHandler struct { @@ -30,9 +30,14 @@ type protocolClient interface { type resolvedBrowserSource struct { cdpURL string + cdpHeaders http.Header browserbaseSessionID string + extensionDir string + preloadedExtension bool + connectTimeout time.Duration keepAlive bool close func(context.Context) error + cleanup func() error } type clientAdapters struct { @@ -46,18 +51,8 @@ type clientAdapters struct { func defaultClientAdapters() clientAdapters { return clientAdapters{ - resolveBrowserSource: func(context.Context, StagehandClientInitParams) (resolvedBrowserSource, error) { - // TODO(go-client): launch/connect local, CDP, and Browserbase sources. - return resolvedBrowserSource{}, ErrBrowserSourceNotImplemented - }, - connectProtocol: func( - context.Context, - resolvedBrowserSource, - TelemetryConfig, - ) (protocolClient, error) { - // Browser setup will also construct the CDP-backed JSON-RPC transport. - return nil, ErrBrowserSourceNotImplemented - }, + resolveBrowserSource: resolveBrowserSource, + connectProtocol: connectResolvedBrowser, } } diff --git a/packages/sdk-go/client_test.go b/packages/sdk-go/client_test.go index 3422e8664..ee85d561c 100644 --- a/packages/sdk-go/client_test.go +++ b/packages/sdk-go/client_test.go @@ -3,8 +3,8 @@ package stagehand import ( "context" "encoding/json" - "errors" "reflect" + "strings" "testing" ) @@ -55,13 +55,13 @@ func (c *recordingProtocolClient) close() error { return nil } -func TestDefaultInitStopsAtExplicitBrowserTODO(t *testing.T) { +func TestDefaultInitRequiresBrowserbaseAPIKey(t *testing.T) { t.Parallel() - client := New(StagehandClientInitParams{Browser: LocalBrowserSource{Headless: true}}) + client := New(StagehandClientInitParams{}) err := client.Init(context.Background()) - if !errors.Is(err, ErrBrowserSourceNotImplemented) { - t.Fatalf("Init() error = %v, want ErrBrowserSourceNotImplemented", err) + if err == nil || !strings.Contains(err.Error(), "Browserbase API key is required") { + t.Fatalf("Init() error = %v, want Browserbase API key error", err) } } diff --git a/packages/sdk-go/stagehand.go b/packages/sdk-go/stagehand.go index 3a3334528..6a75ec709 100644 --- a/packages/sdk-go/stagehand.go +++ b/packages/sdk-go/stagehand.go @@ -197,8 +197,10 @@ func (s *Stagehand) Init(ctx context.Context) error { rpc, err := s.adapters.connectProtocol(ctx, browser, s.initParams.Telemetry) if err != nil { - _ = s.releaseBrowser(ctx) - return fmt.Errorf("connect protocol: %w", err) + return errors.Join( + fmt.Errorf("connect protocol: %w", err), + s.releaseBrowser(ctx), + ) } s.rpc = rpc onLog := func(StagehandLog) {} @@ -255,7 +257,16 @@ func (s *Stagehand) workerInitParams(browser resolvedBrowserSource) StagehandIni model := ServerModel(*s.initParams.Model) params.Model = &model } - if source, ok := s.initParams.Browser.(BrowserbaseClientBrowserSource); ok { + var source *BrowserbaseClientBrowserSource + switch browser := s.initParams.Browser.(type) { + case BrowserbaseClientBrowserSource: + source = &browser + case *BrowserbaseClientBrowserSource: + source = browser + case nil: + source = &BrowserbaseClientBrowserSource{} + } + if source != nil { params.Browser = &BrowserbaseBrowserSource{ BrowserSettings: source.BrowserSettings, ExtensionID: source.ExtensionID, @@ -344,10 +355,16 @@ func (s *Stagehand) releaseBrowser(ctx context.Context) error { } browser := s.browser s.browser = nil - if browser.keepAlive || browser.close == nil { - return nil + + var browserErr error + if !browser.keepAlive && browser.close != nil { + browserErr = browser.close(ctx) + } + var cleanupErr error + if browser.cleanup != nil { + cleanupErr = browser.cleanup() } - return browser.close(ctx) + return errors.Join(browserErr, cleanupErr) } func newStagehandWithClient(initParams StagehandClientInitParams, rpc protocolClient) *Stagehand { diff --git a/packages/sdk-go/stagehand_live_test.go b/packages/sdk-go/stagehand_live_test.go new file mode 100644 index 000000000..f1d862569 --- /dev/null +++ b/packages/sdk-go/stagehand_live_test.go @@ -0,0 +1,173 @@ +package stagehand + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "os" + "testing" + "time" +) + +func TestStagehandLocalBrowserIntegration(t *testing.T) { + chromePath, err := findChromePath("") + if err != nil { + t.Skipf("Chrome is not installed: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + client := New(StagehandClientInitParams{ + Browser: LocalBrowserSource{ + ExecutablePath: chromePath, + Headless: true, + ConnectTimeoutMs: 15_000, + }, + }) + closeStagehandAfterTest(t, client) + + if err := client.Init(ctx); err != nil { + t.Fatalf("Stagehand.Init() with local browser error = %v", err) + } + extensionDir := client.browser.extensionDir + assertLiveStagehand(t, ctx, client) + if err := client.Close(ctx); err != nil { + t.Fatalf("Stagehand.Close() with local browser error = %v", err) + } + assertExtensionDirectoryRemoved(t, extensionDir) +} + +func TestStagehandExistingCDPBrowserIntegration(t *testing.T) { + chromePath, err := findChromePath("") + if err != nil { + t.Skipf("Chrome is not installed: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + launched, err := launchChrome(ctx, LocalBrowserSource{ + ExecutablePath: chromePath, + Headless: true, + ConnectTimeoutMs: 15_000, + }) + if err != nil { + t.Fatalf("launch Chrome for existing CDP source: %v", err) + } + t.Cleanup(func() { + closeContext, closeCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer closeCancel() + if err := launched.close(closeContext); err != nil { + t.Errorf("close existing CDP Chrome: %v", err) + } + }) + + client := New(StagehandClientInitParams{ + Browser: CDPBrowserSource{CDPURL: launched.cdpURL}, + }) + closeStagehandAfterTest(t, client) + if err := client.Init(ctx); err != nil { + t.Fatalf("Stagehand.Init() with existing CDP error = %v", err) + } + extensionDir := client.browser.extensionDir + assertLiveStagehand(t, ctx, client) + if err := client.Close(ctx); err != nil { + t.Fatalf("Stagehand.Close() with existing CDP error = %v", err) + } + assertExtensionDirectoryRemoved(t, extensionDir) + + response, err := http.Get(launched.cdpURL + "/json/version") + if err != nil { + t.Fatalf("kept-alive existing CDP browser is unavailable: %v", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf( + "kept-alive existing CDP browser status = %d, want 200", + response.StatusCode, + ) + } +} + +func TestStagehandBrowserbaseIntegration(t *testing.T) { + if os.Getenv("BROWSERBASE_SMOKE") != "1" { + t.Skip("set BROWSERBASE_SMOKE=1 to run the Browserbase integration test") + } + apiKey := os.Getenv("BROWSERBASE_API_KEY") + if apiKey == "" { + t.Fatal("BROWSERBASE_API_KEY is required when BROWSERBASE_SMOKE=1") + } + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + client := New(StagehandClientInitParams{ + APIKey: &apiKey, + Browser: BrowserbaseClientBrowserSource{ + KeepAlive: testPointer(false), + Timeout: testPointer(300.0), + UserMetadata: map[string]json.RawMessage{ + "suite": json.RawMessage(`"stagehand-v4-go-public-smoke"`), + }, + }, + }) + closeStagehandAfterTest(t, client) + + if err := client.Init(ctx); err != nil { + t.Fatalf("Stagehand.Init() with Browserbase error = %v", err) + } + sessionID := client.browser.browserbaseSessionID + assertLiveStagehand(t, ctx, client) + if err := client.Close(ctx); err != nil { + t.Fatalf("Stagehand.Close() with Browserbase error = %v", err) + } + t.Logf("created and released Browserbase session %s", sessionID) +} + +func assertLiveStagehand( + t *testing.T, + ctx context.Context, + client *Stagehand, +) { + t.Helper() + if !client.Initialized() { + t.Fatal("Stagehand.Initialized() = false after Init") + } + ping, err := client.Ping(ctx) + if err != nil { + t.Fatalf("Stagehand.Ping() error = %v", err) + } + if !ping.Ok || ping.Runtime != "service_worker" { + t.Fatalf("Stagehand.Ping() = %#v", ping) + } + browserContext, err := client.Context() + if err != nil { + t.Fatalf("Stagehand.Context() error = %v", err) + } + page, err := browserContext.ActivePage(ctx) + if err != nil { + t.Fatalf("BrowserContext.ActivePage() error = %v", err) + } + if page == nil || page.PageID() == "" { + t.Fatalf("BrowserContext.ActivePage() = %#v", page) + } +} + +func closeStagehandAfterTest(t *testing.T, client *Stagehand) { + t.Helper() + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := client.Close(ctx); err != nil && + !errors.Is(err, ErrCDPClientClosed) && + !errors.Is(err, ErrCDPConnectionClosed) { + t.Errorf("clean up Stagehand client: %v", err) + } + }) +} + +func assertExtensionDirectoryRemoved(t *testing.T, directory string) { + t.Helper() + if _, err := os.Stat(directory); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("temporary extension directory still exists after close: %v", err) + } +} From 5309fbfdaa50c66a0db7f07e8f0807da86d55e7a Mon Sep 17 00:00:00 2001 From: Sam F <43347795+monadoid@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:18:29 +0200 Subject: [PATCH 2/3] Address Go Browserbase review feedback --- packages/sdk-go/browser_source.go | 5 +- packages/sdk-go/browser_source_test.go | 52 ++++++--- packages/sdk-go/browserbase_client.go | 44 ++++++-- packages/sdk-go/browserbase_client_test.go | 122 ++++++++++++++++++++- packages/sdk-go/stagehand.go | 17 +-- packages/sdk-go/stagehand_live_test.go | 20 +++- 6 files changed, 213 insertions(+), 47 deletions(-) diff --git a/packages/sdk-go/browser_source.go b/packages/sdk-go/browser_source.go index f72186901..b76a8016f 100644 --- a/packages/sdk-go/browser_source.go +++ b/packages/sdk-go/browser_source.go @@ -151,7 +151,8 @@ func resolveCDPSource( source CDPBrowserSource, dependencies browserSourceResolverDependencies, ) (resolvedBrowserSource, error) { - if strings.TrimSpace(source.CDPURL) == "" { + cdpURL := strings.TrimSpace(source.CDPURL) + if cdpURL == "" { return resolvedBrowserSource{}, errors.New("stagehand CDP URL is required") } extensionDir, cleanup, err := materializeStagehandExtension(dependencies) @@ -170,7 +171,7 @@ func resolveCDPSource( headers.Set(name, value) } return resolvedBrowserSource{ - cdpURL: source.CDPURL, + cdpURL: cdpURL, cdpHeaders: headers, extensionDir: extensionDir, keepAlive: true, diff --git a/packages/sdk-go/browser_source_test.go b/packages/sdk-go/browser_source_test.go index c66846cb7..8d4ff7ad6 100644 --- a/packages/sdk-go/browser_source_test.go +++ b/packages/sdk-go/browser_source_test.go @@ -145,7 +145,7 @@ func TestResolveBrowserSourceSupportsEveryClientMode(t *testing.T) { resolved, err := resolveBrowserSourceWithDependencies( context.Background(), StagehandClientInitParams{Browser: CDPBrowserSource{ - CDPURL: "http://browser.test:9222", + CDPURL: " http://browser.test:9222 ", Headers: map[string]string{ "X-Browser-Token": "secret", }, @@ -257,29 +257,45 @@ func TestResolveBrowserSourceValidatesClientInputs(t *testing.T) { } } -func TestStagehandReleaseCleansSDKResourcesForKeepAliveBrowser(t *testing.T) { +func TestStagehandReleasePreservesSuccessfulKeepAliveBrowser(t *testing.T) { closeCalls := 0 cleanupCalls := 0 client := New(StagehandClientInitParams{}) - client.browser = &resolvedBrowserSource{ - keepAlive: true, - close: func(context.Context) error { - closeCalls++ - return nil - }, - cleanup: func() error { - cleanupCalls++ - return nil - }, + newBrowser := func() *resolvedBrowserSource { + return &resolvedBrowserSource{ + keepAlive: true, + close: func(context.Context) error { + closeCalls++ + return nil + }, + cleanup: func() error { + cleanupCalls++ + return nil + }, + } } - if err := client.releaseBrowser(context.Background()); err != nil { - t.Fatalf("releaseBrowser() error = %v", err) + client.browser = newBrowser() + if err := client.releaseBrowser(context.Background(), true); err != nil { + t.Fatalf("releaseBrowser(preserve keepAlive) error = %v", err) } - if closeCalls != 0 { - t.Fatalf("browser close calls = %d, want 0", closeCalls) + if closeCalls != 0 || cleanupCalls != 0 { + t.Fatalf( + "preserved browser close calls = %d, cleanup calls = %d; want 0 and 0", + closeCalls, + cleanupCalls, + ) } - if cleanupCalls != 1 { - t.Fatalf("SDK cleanup calls = %d, want 1", cleanupCalls) + + client.browser = newBrowser() + if err := client.releaseBrowser(context.Background(), false); err != nil { + t.Fatalf("releaseBrowser(after failed init) error = %v", err) + } + if closeCalls != 1 || cleanupCalls != 1 { + t.Fatalf( + "failed-init close calls = %d, cleanup calls = %d; want 1 and 1", + closeCalls, + cleanupCalls, + ) } } diff --git a/packages/sdk-go/browserbase_client.go b/packages/sdk-go/browserbase_client.go index 590ddd412..f059774ce 100644 --- a/packages/sdk-go/browserbase_client.go +++ b/packages/sdk-go/browserbase_client.go @@ -21,6 +21,7 @@ const ( defaultBrowserbaseBaseURL = "https://api.browserbase.com" defaultBrowserbaseHTTPTimeout = 60 * time.Second defaultBrowserbaseMaxRetries = 2 + maxBrowserbaseRetryDelay = defaultBrowserbaseHTTPTimeout maxBrowserbaseAPIResponseBytes = 4 << 20 stagehandExtensionUploadName = "stagehand-extension.zip" ) @@ -180,6 +181,7 @@ type browserbaseEncodedRequest struct { body []byte contentType string accept string + replaySafe bool } type browserbaseEndpointRequest interface { @@ -233,7 +235,7 @@ func sendBrowserbaseRequest[Response browserbaseEndpointResponse]( if ctx.Err() != nil { return zero, ctx.Err() } - if attempt >= client.maxRetries { + if !encoded.replaySafe || attempt >= client.maxRetries { return zero, fmt.Errorf("send Browserbase request: %w", requestErr) } if err := client.sleep(ctx, browserbaseDefaultRetryDelay(attempt)); err != nil { @@ -247,7 +249,9 @@ func sendBrowserbaseRequest[Response browserbaseEndpointResponse]( if readErr != nil || closeErr != nil { return zero, errors.Join(readErr, closeErr) } - if browserbaseShouldRetry(httpResponse) && attempt < client.maxRetries { + if encoded.replaySafe && + browserbaseShouldRetry(httpResponse) && + attempt < client.maxRetries { if err := client.sleep( ctx, browserbaseRetryDelay(httpResponse.Header, attempt), @@ -331,23 +335,37 @@ func browserbaseRetryDelay(headers http.Header, attempt int) time.Duration { if milliseconds, err := strconv.ParseFloat( strings.TrimSpace(headers.Get("retry-after-ms")), 64, - ); err == nil && milliseconds >= 0 { - return time.Duration(milliseconds * float64(time.Millisecond)) + ); err == nil { + if delay, ok := browserbaseRetryDuration(milliseconds, time.Millisecond); ok { + return delay + } } retryAfter := strings.TrimSpace(headers.Get("Retry-After")) - if seconds, err := strconv.ParseFloat(retryAfter, 64); err == nil && seconds >= 0 { - return time.Duration(seconds * float64(time.Second)) + if seconds, err := strconv.ParseFloat(retryAfter, 64); err == nil { + if delay, ok := browserbaseRetryDuration(seconds, time.Second); ok { + return delay + } } if retryTime, err := http.ParseTime(retryAfter); err == nil { if delay := time.Until(retryTime); delay > 0 { - return delay + return min(delay, maxBrowserbaseRetryDelay) } return 0 } return browserbaseDefaultRetryDelay(attempt) } +func browserbaseRetryDuration(value float64, unit time.Duration) (time.Duration, bool) { + if value < 0 || math.IsNaN(value) || math.IsInf(value, 0) { + return 0, false + } + if value >= float64(maxBrowserbaseRetryDelay)/float64(unit) { + return maxBrowserbaseRetryDelay, true + } + return time.Duration(value * float64(unit)), true +} + func browserbaseDefaultRetryDelay(attempt int) time.Duration { delay := 500 * time.Millisecond * time.Duration(1< Date: Mon, 27 Jul 2026 19:25:23 +0200 Subject: [PATCH 3/3] Align Go runtime protocol version --- packages/sdk-go/runtime_compatibility.go | 2 +- packages/sdk-go/runtime_compatibility_test.go | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/sdk-go/runtime_compatibility.go b/packages/sdk-go/runtime_compatibility.go index ffa3ef66f..cdb31383a 100644 --- a/packages/sdk-go/runtime_compatibility.go +++ b/packages/sdk-go/runtime_compatibility.go @@ -7,7 +7,7 @@ import ( ) const ( - stagehandProtocolVersion = 4 + stagehandProtocolVersion = 1 stagehandRuntimeName = "stagehand" stagehandSDKClientName = "stagehand-sdk-go" stagehandSDKVersion = "4.0.0" diff --git a/packages/sdk-go/runtime_compatibility_test.go b/packages/sdk-go/runtime_compatibility_test.go index 6d1383c39..7357a02b8 100644 --- a/packages/sdk-go/runtime_compatibility_test.go +++ b/packages/sdk-go/runtime_compatibility_test.go @@ -18,11 +18,11 @@ func TestNegotiateRuntimeCompatibility(t *testing.T) { { name: "compatible", marker: `{ - "protocolVersion": 4, + "protocolVersion": 1, "serverInfo": {"name": "stagehand", "version": "4.0.0"} }`, compatible: true, - detail: "protocolVersion=4", + detail: "protocolVersion=1", }, { name: "missing marker", @@ -33,8 +33,8 @@ func TestNegotiateRuntimeCompatibility(t *testing.T) { { name: "below range", marker: `{ - "protocolVersion": 3, - "serverInfo": {"name": "stagehand", "version": "3.0.0"} + "protocolVersion": 0, + "serverInfo": {"name": "stagehand", "version": "4.0.0"} }`, compatible: false, detail: "below", @@ -42,8 +42,8 @@ func TestNegotiateRuntimeCompatibility(t *testing.T) { { name: "above range", marker: `{ - "protocolVersion": 5, - "serverInfo": {"name": "stagehand", "version": "5.0.0"} + "protocolVersion": 2, + "serverInfo": {"name": "stagehand", "version": "4.0.0"} }`, compatible: false, detail: "above", @@ -51,7 +51,7 @@ func TestNegotiateRuntimeCompatibility(t *testing.T) { { name: "wrong runtime", marker: `{ - "protocolVersion": 4, + "protocolVersion": 1, "serverInfo": {"name": "other", "version": "4.0.0"} }`, compatible: false, @@ -60,11 +60,11 @@ func TestNegotiateRuntimeCompatibility(t *testing.T) { { name: "invalid protocol version", marker: `{ - "protocolVersion": "4", + "protocolVersion": "1", "serverInfo": {"name": "stagehand", "version": "4.0.0"} }`, compatible: false, - detail: "protocolVersion=4", + detail: "protocolVersion=1", }, }