diff --git a/cmd/browsers.go b/cmd/browsers.go index de60548..3a5d5b1 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -29,6 +29,7 @@ import ( // BrowsersService defines the subset of the Kernel SDK browser client that we use. // See https://github.com/onkernel/kernel-go-sdk/blob/main/browser.go type BrowsersService interface { + Get(ctx context.Context, id string, opts ...option.RequestOption) (res *kernel.BrowserGetResponse, err error) List(ctx context.Context, query kernel.BrowserListParams, opts ...option.RequestOption) (res *pagination.OffsetPagination[kernel.BrowserListResponse], err error) New(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (res *kernel.BrowserNewResponse, err error) Delete(ctx context.Context, body kernel.BrowserDeleteParams, opts ...option.RequestOption) (err error) @@ -173,6 +174,11 @@ type BrowsersViewInput struct { Identifier string } +type BrowsersGetInput struct { + Identifier string + Output string +} + // BrowsersCmd is a cobra-independent command handler for browsers operations. type BrowsersCmd struct { browsers BrowsersService @@ -359,6 +365,12 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { } func printBrowserSessionResult(sessionID, cdpURL, liveViewURL string, persistence kernel.BrowserPersistence, profile kernel.Profile) { + tableData := buildBrowserTableData(sessionID, cdpURL, liveViewURL, persistence, profile) + PrintTableNoPad(tableData, true) +} + +// buildBrowserTableData creates a base table with common browser session fields. +func buildBrowserTableData(sessionID, cdpURL, liveViewURL string, persistence kernel.BrowserPersistence, profile kernel.Profile) pterm.TableData { tableData := pterm.TableData{ {"Property", "Value"}, {"Session ID", sessionID}, @@ -377,29 +389,15 @@ func printBrowserSessionResult(sessionID, cdpURL, liveViewURL string, persistenc } tableData = append(tableData, []string{"Profile", profVal}) } - - PrintTableNoPad(tableData, true) + return tableData } func (b BrowsersCmd) Delete(ctx context.Context, in BrowsersDeleteInput) error { if !in.SkipConfirm { - page, err := b.browsers.List(ctx, kernel.BrowserListParams{}) + found, err := b.resolveBrowserByIdentifier(ctx, in.Identifier) if err != nil { return util.CleanedUpSdkError{Err: err} } - if page == nil || page.Items == nil || len(page.Items) == 0 { - pterm.Error.Println("No browsers found") - return nil - } - - var found *kernel.BrowserListResponse - for _, br := range page.Items { - if br.SessionID == in.Identifier || br.Persistence.ID == in.Identifier { - bCopy := br - found = &bCopy - break - } - } if found == nil { pterm.Error.Printf("Browser '%s' not found\n", in.Identifier) return nil @@ -459,31 +457,81 @@ func (b BrowsersCmd) Delete(ctx context.Context, in BrowsersDeleteInput) error { } func (b BrowsersCmd) View(ctx context.Context, in BrowsersViewInput) error { - page, err := b.browsers.List(ctx, kernel.BrowserListParams{}) + browser, err := b.browsers.Get(ctx, in.Identifier) if err != nil { return util.CleanedUpSdkError{Err: err} } - - if page == nil || page.Items == nil || len(page.Items) == 0 { - pterm.Error.Println("No browsers found") + if browser == nil { + pterm.Error.Printf("Browser '%s' not found\n", in.Identifier) return nil } - - var foundBrowser *kernel.BrowserListResponse - for _, browser := range page.Items { - if browser.Persistence.ID == in.Identifier || browser.SessionID == in.Identifier { - foundBrowser = &browser - break + if browser.BrowserLiveViewURL == "" { + if browser.Headless { + pterm.Warning.Println("This browser is running in headless mode and does not have a live view URL") + } else { + pterm.Warning.Println("No live view URL available for this browser") } + return nil + } + + fmt.Println(browser.BrowserLiveViewURL) + return nil +} + +func (b BrowsersCmd) Get(ctx context.Context, in BrowsersGetInput) error { + if in.Output != "" && in.Output != "json" { + pterm.Error.Println("unsupported --output value: use 'json'") + return nil } - if foundBrowser == nil { + browser, err := b.browsers.Get(ctx, in.Identifier) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + if browser == nil { pterm.Error.Printf("Browser '%s' not found\n", in.Identifier) return nil } - // Output just the URL - pterm.Info.Println(foundBrowser.BrowserLiveViewURL) + if in.Output == "json" { + bs, err := json.MarshalIndent(browser, "", " ") + if err != nil { + return err + } + fmt.Println(string(bs)) + return nil + } + + // Build table starting with common browser fields + tableData := buildBrowserTableData( + browser.SessionID, + browser.CdpWsURL, + browser.BrowserLiveViewURL, + browser.Persistence, + browser.Profile, + ) + + // Append additional detailed fields + tableData = append(tableData, []string{"Created At", util.FormatLocal(browser.CreatedAt)}) + tableData = append(tableData, []string{"Timeout (seconds)", fmt.Sprintf("%d", browser.TimeoutSeconds)}) + tableData = append(tableData, []string{"Headless", fmt.Sprintf("%t", browser.Headless)}) + tableData = append(tableData, []string{"Stealth", fmt.Sprintf("%t", browser.Stealth)}) + tableData = append(tableData, []string{"Kiosk Mode", fmt.Sprintf("%t", browser.KioskMode)}) + if browser.Viewport.Width > 0 && browser.Viewport.Height > 0 { + viewportStr := fmt.Sprintf("%dx%d", browser.Viewport.Width, browser.Viewport.Height) + if browser.Viewport.RefreshRate > 0 { + viewportStr = fmt.Sprintf("%s@%d", viewportStr, browser.Viewport.RefreshRate) + } + tableData = append(tableData, []string{"Viewport", viewportStr}) + } + if browser.ProxyID != "" { + tableData = append(tableData, []string{"Proxy ID", browser.ProxyID}) + } + if !browser.DeletedAt.IsZero() { + tableData = append(tableData, []string{"Deleted At", util.FormatLocal(browser.DeletedAt)}) + } + + PrintTableNoPad(tableData, true) return nil } @@ -1831,6 +1879,14 @@ var browsersViewCmd = &cobra.Command{ RunE: runBrowsersView, } +var browsersGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get detailed information about a browser session", + Long: "Retrieve and display detailed information about a specific browser session including configuration, URLs, and status.", + Args: cobra.ExactArgs(1), + RunE: runBrowsersGet, +} + func init() { // list flags browsersListCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") @@ -1838,10 +1894,14 @@ func init() { browsersListCmd.Flags().Int("limit", 0, "Maximum number of results to return (default 20, max 100)") browsersListCmd.Flags().Int("offset", 0, "Number of results to skip (for pagination)") + // get flags + browsersGetCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + browsersCmd.AddCommand(browsersListCmd) browsersCmd.AddCommand(browsersCreateCmd) browsersCmd.AddCommand(browsersDeleteCmd) browsersCmd.AddCommand(browsersViewCmd) + browsersCmd.AddCommand(browsersGetCmd) // logs logsRoot := &cobra.Command{Use: "logs", Short: "Browser logs operations"} @@ -2226,6 +2286,18 @@ func runBrowsersView(cmd *cobra.Command, args []string) error { return b.View(cmd.Context(), in) } +func runBrowsersGet(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + out, _ := cmd.Flags().GetString("output") + + svc := client.Browsers + b := BrowsersCmd{browsers: &svc} + return b.Get(cmd.Context(), BrowsersGetInput{ + Identifier: args[0], + Output: out, + }) +} + func runBrowsersLogsStream(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) svc := client.Browsers @@ -2643,16 +2715,37 @@ func truncateURL(url string, maxLen int) string { return url[:maxLen-3] + "..." } -// resolveBrowserByIdentifier finds a browser by session ID or persistent ID (backward compatibility). -func (b BrowsersCmd) resolveBrowserByIdentifier(ctx context.Context, identifier string) (*kernel.BrowserListResponse, error) { +// listAllBrowsers fetches all browsers by paginating through all pages. +func (b BrowsersCmd) listAllBrowsers(ctx context.Context) ([]kernel.BrowserListResponse, error) { + var allBrowsers []kernel.BrowserListResponse page, err := b.browsers.List(ctx, kernel.BrowserListParams{}) if err != nil { return nil, err } - if page == nil || page.Items == nil { - return nil, nil + for page != nil && len(page.Items) > 0 { + allBrowsers = append(allBrowsers, page.Items...) + page = safeGetNextPage(page) + } + return allBrowsers, nil +} + +// safeGetNextPage attempts to get the next page, returning nil if unavailable or on error. +func safeGetNextPage(page *pagination.OffsetPagination[kernel.BrowserListResponse]) *pagination.OffsetPagination[kernel.BrowserListResponse] { + defer func() { recover() }() + nextPage, err := page.GetNextPage() + if err != nil { + return nil + } + return nextPage +} + +// resolveBrowserByIdentifier finds a browser by session ID or persistent ID (backward compatibility). +func (b BrowsersCmd) resolveBrowserByIdentifier(ctx context.Context, identifier string) (*kernel.BrowserListResponse, error) { + browsers, err := b.listAllBrowsers(ctx) + if err != nil { + return nil, err } - for _, br := range page.Items { + for _, br := range browsers { if br.SessionID == identifier || br.Persistence.ID == identifier { bCopy := br return &bCopy, nil diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index cadddd6..d3c2ec0 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -54,6 +54,7 @@ func setupStdoutCapture(t *testing.T) { // FakeBrowsersService is a configurable fake implementing BrowsersService. type FakeBrowsersService struct { + GetFunc func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) ListFunc func(ctx context.Context, query kernel.BrowserListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.BrowserListResponse], error) NewFunc func(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (*kernel.BrowserNewResponse, error) DeleteFunc func(ctx context.Context, body kernel.BrowserDeleteParams, opts ...option.RequestOption) error @@ -61,6 +62,13 @@ type FakeBrowsersService struct { LoadExtensionsFunc func(ctx context.Context, id string, body kernel.BrowserLoadExtensionsParams, opts ...option.RequestOption) error } +func (f *FakeBrowsersService) Get(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { + if f.GetFunc != nil { + return f.GetFunc(ctx, id, opts...) + } + return &kernel.BrowserGetResponse{}, nil +} + func (f *FakeBrowsersService) List(ctx context.Context, query kernel.BrowserListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.BrowserListResponse], error) { if f.ListFunc != nil { return f.ListFunc(ctx, query, opts...) @@ -266,31 +274,40 @@ func TestBrowsersDelete_WithConfirm_NotFound(t *testing.T) { } func TestBrowsersView_ByID_PrintsURL(t *testing.T) { + // Capture both pterm output and raw stdout setupStdoutCapture(t) + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + t.Cleanup(func() { + os.Stdout = oldStdout + }) - list := []kernel.BrowserListResponse{{ - SessionID: "abc", - BrowserLiveViewURL: "http://live-url", - }} fake := &FakeBrowsersService{ - ListFunc: func(ctx context.Context, query kernel.BrowserListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.BrowserListResponse], error) { - return &pagination.OffsetPagination[kernel.BrowserListResponse]{Items: list}, nil + GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { + return &kernel.BrowserGetResponse{ + SessionID: "abc", + BrowserLiveViewURL: "http://live-url", + }, nil }, } b := BrowsersCmd{browsers: fake} _ = b.View(context.Background(), BrowsersViewInput{Identifier: "abc"}) - out := outBuf.String() - assert.Contains(t, out, "http://live-url") + // Capture stdout + w.Close() + var stdoutBuf bytes.Buffer + io.Copy(&stdoutBuf, r) + + assert.Contains(t, stdoutBuf.String(), "http://live-url") } -func TestBrowsersView_NotFound_ByEither(t *testing.T) { +func TestBrowsersView_NotFound(t *testing.T) { setupStdoutCapture(t) - list := []kernel.BrowserListResponse{{SessionID: "abc", Persistence: kernel.BrowserPersistence{ID: "pid-xyz"}}} fake := &FakeBrowsersService{ - ListFunc: func(ctx context.Context, query kernel.BrowserListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.BrowserListResponse], error) { - return &pagination.OffsetPagination[kernel.BrowserListResponse]{Items: list}, nil + GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { + return nil, nil }, } b := BrowsersCmd{browsers: fake} @@ -300,19 +317,137 @@ func TestBrowsersView_NotFound_ByEither(t *testing.T) { assert.Contains(t, out, "Browser 'missing' not found") } -func TestBrowsersView_PrintsErrorOnListFailure(t *testing.T) { +func TestBrowsersView_HeadlessBrowser_ShowsWarning(t *testing.T) { setupStdoutCapture(t) fake := &FakeBrowsersService{ - ListFunc: func(ctx context.Context, query kernel.BrowserListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.BrowserListResponse], error) { - return nil, errors.New("list error") + GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { + return &kernel.BrowserGetResponse{ + SessionID: "abc", + Headless: true, + BrowserLiveViewURL: "", + }, nil + }, + } + b := BrowsersCmd{browsers: fake} + _ = b.View(context.Background(), BrowsersViewInput{Identifier: "abc"}) + + out := outBuf.String() + assert.Contains(t, out, "headless mode") +} + +func TestBrowsersView_PrintsErrorOnGetFailure(t *testing.T) { + setupStdoutCapture(t) + + fake := &FakeBrowsersService{ + GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { + return nil, errors.New("get error") }, } b := BrowsersCmd{browsers: fake} err := b.View(context.Background(), BrowsersViewInput{Identifier: "any"}) assert.Error(t, err) - assert.Contains(t, err.Error(), "list error") + assert.Contains(t, err.Error(), "get error") +} + +func TestBrowsersGet_PrintsDetails(t *testing.T) { + setupStdoutCapture(t) + + created := time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC) + fake := &FakeBrowsersService{ + GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { + return &kernel.BrowserGetResponse{ + SessionID: "sess-123", + CdpWsURL: "ws://cdp-url", + BrowserLiveViewURL: "http://live-view", + CreatedAt: created, + TimeoutSeconds: 300, + Headless: false, + Stealth: true, + KioskMode: false, + Viewport: shared.BrowserViewport{Width: 1920, Height: 1080, RefreshRate: 25}, + Persistence: kernel.BrowserPersistence{ID: "persist-id"}, + Profile: kernel.Profile{ID: "prof-id", Name: "my-profile"}, + ProxyID: "proxy-123", + }, nil + }, + } + b := BrowsersCmd{browsers: fake} + _ = b.Get(context.Background(), BrowsersGetInput{Identifier: "sess-123"}) + + out := outBuf.String() + assert.Contains(t, out, "sess-123") + assert.Contains(t, out, "ws://cdp-url") + assert.Contains(t, out, "http://live-view") + assert.Contains(t, out, "300") + assert.Contains(t, out, "false") // Headless + assert.Contains(t, out, "true") // Stealth + assert.Contains(t, out, "1920x1080@25") + assert.Contains(t, out, "persist-id") + assert.Contains(t, out, "my-profile") + assert.Contains(t, out, "proxy-123") +} + +func TestBrowsersGet_JSONOutput(t *testing.T) { + // Capture both pterm output and raw stdout + setupStdoutCapture(t) + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + t.Cleanup(func() { + os.Stdout = oldStdout + }) + + fake := &FakeBrowsersService{ + GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { + return &kernel.BrowserGetResponse{ + SessionID: "sess-json", + CdpWsURL: "ws://cdp", + }, nil + }, + } + b := BrowsersCmd{browsers: fake} + _ = b.Get(context.Background(), BrowsersGetInput{Identifier: "sess-json", Output: "json"}) + + // Capture stdout + w.Close() + var stdoutBuf bytes.Buffer + io.Copy(&stdoutBuf, r) + + out := stdoutBuf.String() + assert.Contains(t, out, "\"session_id\"") + assert.Contains(t, out, "sess-json") +} + +func TestBrowsersGet_NotFound(t *testing.T) { + setupStdoutCapture(t) + + fake := &FakeBrowsersService{ + GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { + return nil, nil + }, + } + b := BrowsersCmd{browsers: fake} + _ = b.Get(context.Background(), BrowsersGetInput{Identifier: "missing"}) + + out := outBuf.String() + assert.Contains(t, out, "Browser 'missing' not found") +} + +func TestBrowsersGet_Error(t *testing.T) { + setupStdoutCapture(t) + + fake := &FakeBrowsersService{ + GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { + return nil, errors.New("get failed") + }, + } + b := BrowsersCmd{browsers: fake} + err := b.Get(context.Background(), BrowsersGetInput{Identifier: "any"}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "get failed") } // --- Fakes for sub-services ---