Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 91 additions & 4 deletions cmd/browsers.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"os"
"path/filepath"
"regexp"
"strconv"
"strings"

"github.com/onkernel/cli/pkg/util"
Expand All @@ -30,7 +31,7 @@ type BrowsersService interface {
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)
DeleteByID(ctx context.Context, id string, opts ...option.RequestOption) (err error)
UploadExtensions(ctx context.Context, id string, body kernel.BrowserUploadExtensionsParams, opts ...option.RequestOption) (err error)
LoadExtensions(ctx context.Context, id string, body kernel.BrowserLoadExtensionsParams, opts ...option.RequestOption) (err error)
}

// BrowserReplaysService defines the subset we use for browser replays.
Expand Down Expand Up @@ -81,6 +82,53 @@ type BoolFlag struct {
// Regular expression to validate CUID2 identifiers (24 lowercase alphanumeric characters).
var cuidRegex = regexp.MustCompile(`^[a-z0-9]{24}$`)

// getAvailableViewports returns the list of supported viewport configurations.
func getAvailableViewports() []string {
return []string{
"2560x1440@10",
"1920x1080@25",
"1920x1200@25",
"1440x900@25",
"1024x768@60",
}
}

// parseViewport parses a viewport string (e.g., "1920x1080@25") and returns width, height, and refresh rate.
// Returns error if the format is invalid.
func parseViewport(viewport string) (width, height, refreshRate int64, err error) {
parts := strings.Split(viewport, "@")
var dimStr string
if len(parts) == 1 {
dimStr = parts[0]
refreshRate = 0
} else if len(parts) == 2 {
dimStr = parts[0]
rr, parseErr := strconv.ParseInt(parts[1], 10, 64)
if parseErr != nil {
return 0, 0, 0, fmt.Errorf("invalid refresh rate: %v", parseErr)
}
refreshRate = rr
} else {
return 0, 0, 0, fmt.Errorf("invalid viewport format")
}

dims := strings.Split(dimStr, "x")
if len(dims) != 2 {
return 0, 0, 0, fmt.Errorf("invalid viewport format, expected WIDTHxHEIGHT[@RATE]")
}

w, err := strconv.ParseInt(dims[0], 10, 64)
if err != nil {
return 0, 0, 0, fmt.Errorf("invalid width: %v", err)
}
h, err := strconv.ParseInt(dims[1], 10, 64)
if err != nil {
return 0, 0, 0, fmt.Errorf("invalid height: %v", err)
}

return w, h, refreshRate, nil
}

// Inputs for each command
type BrowsersCreateInput struct {
PersistenceID string
Expand All @@ -92,6 +140,7 @@ type BrowsersCreateInput struct {
ProfileSaveChanges BoolFlag
ProxyID string
Extensions []string
Viewport string
}

type BrowsersDeleteInput struct {
Expand Down Expand Up @@ -230,6 +279,22 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error {
}
}

// Add viewport if specified
if in.Viewport != "" {
width, height, refreshRate, err := parseViewport(in.Viewport)
if err != nil {
pterm.Error.Printf("Invalid viewport format: %v\n", err)
return nil
}
params.Viewport = kernel.BrowserNewParamsViewport{
Width: width,
Height: height,
}
if refreshRate > 0 {
params.Viewport.RefreshRate = kernel.Opt(refreshRate)
}
}

browser, err := b.browsers.New(ctx, params)
if err != nil {
return util.CleanedUpSdkError{Err: err}
Expand Down Expand Up @@ -1239,7 +1304,7 @@ func (b BrowsersCmd) ExtensionsUpload(ctx context.Context, in BrowsersExtensions
return nil
}

var extensions []kernel.BrowserUploadExtensionsParamsExtension
var extensions []kernel.BrowserLoadExtensionsParamsExtension
var tempZipFiles []string
var openFiles []*os.File

Expand Down Expand Up @@ -1280,14 +1345,14 @@ func (b BrowsersCmd) ExtensionsUpload(ctx context.Context, in BrowsersExtensions
}
openFiles = append(openFiles, zipFile)

extensions = append(extensions, kernel.BrowserUploadExtensionsParamsExtension{
extensions = append(extensions, kernel.BrowserLoadExtensionsParamsExtension{
Name: extName,
ZipFile: zipFile,
})
}

pterm.Info.Printf("Uploading %d extension(s) to browser %s...\n", len(extensions), br.SessionID)
if err := b.browsers.UploadExtensions(ctx, br.SessionID, kernel.BrowserUploadExtensionsParams{
if err := b.browsers.LoadExtensions(ctx, br.SessionID, kernel.BrowserLoadExtensionsParams{
Extensions: extensions,
}); err != nil {
return util.CleanedUpSdkError{Err: err}
Expand Down Expand Up @@ -1482,6 +1547,8 @@ func init() {
browsersCreateCmd.Flags().Bool("save-changes", false, "If set, save changes back to the profile when the session ends")
browsersCreateCmd.Flags().String("proxy-id", "", "Proxy ID to use for the browser session")
browsersCreateCmd.Flags().StringSlice("extension", []string{}, "Extension IDs or names to load (repeatable; may be passed multiple times or comma-separated)")
browsersCreateCmd.Flags().String("viewport", "", "Browser viewport size (e.g., 1920x1080@25). Supported: 2560x1440@10, 1920x1080@25, 1920x1200@25, 1440x900@25, 1024x768@60")
browsersCreateCmd.Flags().Bool("viewport-interactive", false, "Interactively select viewport size from list")

// Add flags for delete command
browsersDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
Expand Down Expand Up @@ -1510,6 +1577,25 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error {
saveChanges, _ := cmd.Flags().GetBool("save-changes")
proxyID, _ := cmd.Flags().GetString("proxy-id")
extensions, _ := cmd.Flags().GetStringSlice("extension")
viewport, _ := cmd.Flags().GetString("viewport")
viewportInteractive, _ := cmd.Flags().GetBool("viewport-interactive")

// Handle interactive viewport selection
if viewportInteractive {
if viewport != "" {
pterm.Warning.Println("Both --viewport and --viewport-interactive specified; using interactive mode")
}
options := getAvailableViewports()
selectedViewport, err := pterm.DefaultInteractiveSelect.
WithOptions(options).
WithDefaultText("Select a viewport size:").
Show()
if err != nil {
pterm.Error.Printf("Failed to select viewport: %v\n", err)
return nil
}
viewport = selectedViewport
}

in := BrowsersCreateInput{
PersistenceID: persistenceID,
Expand All @@ -1521,6 +1607,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error {
ProfileSaveChanges: BoolFlag{Set: cmd.Flags().Changed("save-changes"), Value: saveChanges},
ProxyID: proxyID,
Extensions: extensions,
Viewport: viewport,
}

svc := client.Browsers
Expand Down
125 changes: 121 additions & 4 deletions cmd/browsers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,11 @@ func setupStdoutCapture(t *testing.T) {

// FakeBrowsersService is a configurable fake implementing BrowsersService.
type FakeBrowsersService struct {
ListFunc func(ctx context.Context, opts ...option.RequestOption) (*[]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
DeleteByIDFunc func(ctx context.Context, id string, opts ...option.RequestOption) error
ListFunc func(ctx context.Context, opts ...option.RequestOption) (*[]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
DeleteByIDFunc func(ctx context.Context, id string, opts ...option.RequestOption) error
LoadExtensionsFunc func(ctx context.Context, id string, body kernel.BrowserLoadExtensionsParams, opts ...option.RequestOption) error
}

func (f *FakeBrowsersService) List(ctx context.Context, opts ...option.RequestOption) (*[]kernel.BrowserListResponse, error) {
Expand Down Expand Up @@ -87,6 +88,13 @@ func (f *FakeBrowsersService) DeleteByID(ctx context.Context, id string, opts ..
return nil
}

func (f *FakeBrowsersService) LoadExtensions(ctx context.Context, id string, body kernel.BrowserLoadExtensionsParams, opts ...option.RequestOption) error {
if f.LoadExtensionsFunc != nil {
return f.LoadExtensionsFunc(ctx, id, body, opts...)
}
return nil
}

func TestBrowsersList_PrintsEmptyMessage(t *testing.T) {
setupStdoutCapture(t)

Expand Down Expand Up @@ -873,3 +881,112 @@ func __writeTempFile(t *testing.T, data string) string {
_ = f.Close()
return f.Name()
}

func TestParseViewport_ValidFormats(t *testing.T) {
tests := []struct {
input string
wantWidth int64
wantHeight int64
wantRefresh int64
}{
{"1920x1080@25", 1920, 1080, 25},
{"2560x1440@10", 2560, 1440, 10},
{"1024x768@60", 1024, 768, 60},
{"1920x1080", 1920, 1080, 0},
}

for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
w, h, r, err := parseViewport(tt.input)
assert.NoError(t, err)
assert.Equal(t, tt.wantWidth, w)
assert.Equal(t, tt.wantHeight, h)
assert.Equal(t, tt.wantRefresh, r)
})
}
}

func TestParseViewport_InvalidFormats(t *testing.T) {
tests := []struct {
input string
desc string
}{
{"1920", "missing height"},
{"1920x", "incomplete dimension"},
{"x1080", "missing width"},
{"1920x1080@", "missing refresh rate"},
{"1920x1080@abc", "non-numeric refresh rate"},
{"abcxdef", "non-numeric dimensions"},
{"1920x1080@25@30", "too many @ signs"},
}

for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
_, _, _, err := parseViewport(tt.input)
assert.Error(t, err)
})
}
}

func TestGetAvailableViewports_ReturnsExpectedOptions(t *testing.T) {
viewports := getAvailableViewports()
assert.Len(t, viewports, 5)
assert.Contains(t, viewports, "2560x1440@10")
assert.Contains(t, viewports, "1920x1080@25")
assert.Contains(t, viewports, "1920x1200@25")
assert.Contains(t, viewports, "1440x900@25")
assert.Contains(t, viewports, "1024x768@60")
}

func TestBrowsersCreate_WithViewport(t *testing.T) {
setupStdoutCapture(t)
var captured kernel.BrowserNewParams
fake := &FakeBrowsersService{NewFunc: func(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (*kernel.BrowserNewResponse, error) {
captured = body
return &kernel.BrowserNewResponse{SessionID: "session123", CdpWsURL: "ws://example"}, nil
}}
b := BrowsersCmd{browsers: fake}

err := b.Create(context.Background(), BrowsersCreateInput{
Viewport: "1920x1080@25",
})

assert.NoError(t, err)
assert.Equal(t, int64(1920), captured.Viewport.Width)
assert.Equal(t, int64(1080), captured.Viewport.Height)
assert.True(t, captured.Viewport.RefreshRate.Valid())
assert.Equal(t, int64(25), captured.Viewport.RefreshRate.Value)
}

func TestBrowsersCreate_WithViewportNoRefreshRate(t *testing.T) {
setupStdoutCapture(t)
var captured kernel.BrowserNewParams
fake := &FakeBrowsersService{NewFunc: func(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (*kernel.BrowserNewResponse, error) {
captured = body
return &kernel.BrowserNewResponse{SessionID: "session123", CdpWsURL: "ws://example"}, nil
}}
b := BrowsersCmd{browsers: fake}

err := b.Create(context.Background(), BrowsersCreateInput{
Viewport: "1920x1080",
})

assert.NoError(t, err)
assert.Equal(t, int64(1920), captured.Viewport.Width)
assert.Equal(t, int64(1080), captured.Viewport.Height)
assert.False(t, captured.Viewport.RefreshRate.Valid())
}

func TestBrowsersCreate_WithInvalidViewport(t *testing.T) {
setupStdoutCapture(t)
fake := &FakeBrowsersService{}
b := BrowsersCmd{browsers: fake}

err := b.Create(context.Background(), BrowsersCreateInput{
Viewport: "invalid",
})

assert.NoError(t, err)
out := outBuf.String()
assert.Contains(t, out, "Invalid viewport format")
}
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ require (
github.com/charmbracelet/fang v0.2.0
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/joho/godotenv v1.5.1
github.com/onkernel/kernel-go-sdk v0.14.1
github.com/onkernel/kernel-go-sdk v0.14.2-0.20251013154713-58a9d56ff62f
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
github.com/pterm/pterm v0.12.80
github.com/samber/lo v1.51.0
Expand Down Expand Up @@ -48,7 +48,7 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
github.com/onkernel/kernel-go-sdk v0.14.1 h1:r4drk5uM1phiXl0dZXhnH1zz5iTmApPC0cGSSiNKbVk=
github.com/onkernel/kernel-go-sdk v0.14.1/go.mod h1:MjUR92i8UPqjrmneyVykae6GuB3GGSmnQtnjf1v74Dc=
github.com/onkernel/kernel-go-sdk v0.14.2-0.20251013154713-58a9d56ff62f h1:/cXzVNPxWryqNsIo2Kvc5fYLBlk7CHus3JZIv1JVoU4=
github.com/onkernel/kernel-go-sdk v0.14.2-0.20251013154713-58a9d56ff62f/go.mod h1:MjUR92i8UPqjrmneyVykae6GuB3GGSmnQtnjf1v74Dc=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
Expand Down Expand Up @@ -131,6 +133,8 @@ github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
Expand Down