diff --git a/cmd/browsers.go b/cmd/browsers.go index f6c927b..af78289 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -12,6 +12,7 @@ import ( "os" "path/filepath" "regexp" + "strconv" "strings" "github.com/onkernel/cli/pkg/util" @@ -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. @@ -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 @@ -92,6 +140,7 @@ type BrowsersCreateInput struct { ProfileSaveChanges BoolFlag ProxyID string Extensions []string + Viewport string } type BrowsersDeleteInput struct { @@ -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} @@ -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 @@ -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} @@ -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") @@ -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, @@ -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 diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index 11c2d07..0f9c4d3 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -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) { @@ -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) @@ -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") +} diff --git a/go.mod b/go.mod index d3c1670..2646552 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 diff --git a/go.sum b/go.sum index dd9cf02..bfef52d 100644 --- a/go.sum +++ b/go.sum @@ -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= @@ -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=