diff --git a/README.md b/README.md index 3cb2e26..ff0d288 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ Requires: rodney start # Launch headless Chrome rodney start --show # Launch with visible browser window rodney start --insecure # Launch with TLS errors ignored (-k shorthand) +rodney start --viewport 375x812 --mobile --scale 2 # Start with mobile viewport rodney connect host:9222 # Connect to existing Chrome on remote debug port rodney status # Show browser info and active page rodney stop # Shut down Chrome @@ -116,10 +117,22 @@ rodney sleep 2.5 # Sleep for N seconds ```bash rodney screenshot # Save as screenshot.png rodney screenshot page.png # Save to specific file -rodney screenshot -w 1280 -h 720 out.png # Set viewport width/height +rodney screenshot -w 1280 -h 720 out.png # Override viewport width/height rodney screenshot-el ".chart" chart.png # Screenshot specific element ``` +When a viewport has been set via `rodney viewport`, screenshots use that viewport by default. Pass `-w`/`-h` to override. + +### Viewport / mobile emulation + +```bash +rodney viewport 375 812 # iPhone-sized viewport +rodney viewport 375 812 --mobile # With mobile emulation (viewport meta, etc.) +rodney viewport 375 812 --mobile --scale 3 # Retina-class device pixel ratio +rodney viewport 1280 720 # Desktop viewport +rodney viewport --reset # Reset to browser default +``` + ### Manage tabs ```bash @@ -403,7 +416,7 @@ The tool uses the [rod](https://github.com/go-rod/rod) Go library which communic | Command | Arguments | Description | |---|---|---| -| `start` | `[--show] [--insecure\|-k]` | Launch Chrome (headless by default, `--show` for visible) | +| `start` | `[--show] [--insecure\|-k] [--viewport WxH] [--mobile] [--scale N]` | Launch Chrome (headless by default, `--show` for visible) | | `connect` | `` | Connect to existing Chrome on remote debug port | | `stop` | | Shut down Chrome | | `status` | | Show browser status | @@ -435,6 +448,8 @@ The tool uses the [rod](https://github.com/go-rod/rod) Go library which communic | `sleep` | `` | Sleep N seconds | | `screenshot` | `[-w N] [-h N] [file]` | Page screenshot (optional viewport size) | | `screenshot-el` | ` [file]` | Element screenshot | +| `viewport` | ` [--scale N] [--mobile]` | Set browser viewport size | +| `viewport` | `--reset` | Reset viewport to browser default | | `pages` | | List tabs | | `page` | `` | Switch tab | | `newpage` | `[url]` | Open new tab | diff --git a/help.txt b/help.txt index 79bac7f..8a6eb3e 100644 --- a/help.txt +++ b/help.txt @@ -1,7 +1,8 @@ rodney - Chrome automation from the command line Browser lifecycle: - rodney start [--show] [--insecure | -k] Launch Chrome (headless by default, --show for visible) + rodney start [--show] [--insecure | -k] [--viewport WxH] [--mobile] [--scale N] + Launch Chrome (headless by default, --show for visible) rodney connect Connect to existing Chrome on remote debug port rodney stop Shut down Chrome rodney status Show browser status @@ -44,6 +45,10 @@ Screenshots: rodney screenshot [-w N] [-h N] [file] Take page screenshot rodney screenshot-el [f] Screenshot an element +Viewport: + rodney viewport [--scale N] [--mobile] Set viewport size + rodney viewport --reset Reset to browser default + Tabs: rodney pages List all pages/tabs rodney page Switch to page by index diff --git a/main.go b/main.go index 0b2531a..76f8a65 100644 --- a/main.go +++ b/main.go @@ -84,6 +84,12 @@ type State struct { DataDir string `json:"data_dir"` ProxyPID int `json:"proxy_pid,omitempty"` // PID of auth proxy helper ProxyPort int `json:"proxy_port,omitempty"` // local port of auth proxy + + // Viewport overrides (set by "rodney viewport", re-applied on each connection) + ViewportWidth int `json:"viewport_width,omitempty"` + ViewportHeight int `json:"viewport_height,omitempty"` + ViewportScale float64 `json:"viewport_scale,omitempty"` + ViewportMobile bool `json:"viewport_mobile,omitempty"` } func stateDir() string { @@ -253,6 +259,8 @@ func main() { cmdScreenshot(args) case "screenshot-el": cmdScreenshotEl(args) + case "viewport": + cmdViewport(args) case "pages": cmdPages(args) case "page": @@ -313,22 +321,100 @@ func withPage() (*State, *rod.Browser, *rod.Page) { } // Apply default timeout so element queries don't hang forever page = page.Timeout(defaultTimeout) + + // Re-apply viewport override if set via "rodney viewport" + if s.ViewportWidth > 0 && s.ViewportHeight > 0 { + scale := s.ViewportScale + if scale == 0 { + scale = 1 + } + if err := (proto.EmulationSetDeviceMetricsOverride{ + Width: s.ViewportWidth, + Height: s.ViewportHeight, + DeviceScaleFactor: scale, + Mobile: s.ViewportMobile, + }.Call(page)); err != nil { + fmt.Fprintf(os.Stderr, "warning: failed to re-apply viewport: %v\n", err) + } + } + return s, browser, page } +// formatViewportDesc returns a human-readable description of viewport settings. +func formatViewportDesc(prefix string, w, h int, mobile bool, scale float64) string { + desc := fmt.Sprintf("%s %dx%d", prefix, w, h) + var extras []string + if mobile { + extras = append(extras, "mobile") + } + if scale != 0 && scale != 1 { + extras = append(extras, fmt.Sprintf("scale %g", scale)) + } + if len(extras) > 0 { + desc += " (" + strings.Join(extras, ", ") + ")" + } + return desc +} + // --- Commands --- func cmdStart(args []string) { ignoreCertErrors := false + headless := true + vpWidth, vpHeight := 0, 0 + vpScale := 0.0 + vpMobile := false + for i := 0; i < len(args); i++ { switch args[i] { case "--insecure", "-k": ignoreCertErrors = true + case "--show": + headless = false + case "--mobile": + vpMobile = true + case "--scale": + i++ + if i >= len(args) { + fatal("missing value for --scale") + } + v, err := strconv.ParseFloat(args[i], 64) + if err != nil { + fatal("invalid scale: %v", err) + } + vpScale = v + case "--viewport": + i++ + if i >= len(args) { + fatal("missing value for --viewport (expected WxH, e.g. 375x812)") + } + parts := strings.SplitN(args[i], "x", 2) + if len(parts) != 2 { + fatal("invalid viewport format: %q (expected WxH, e.g. 375x812)", args[i]) + } + w, err := strconv.Atoi(parts[0]) + if err != nil { + fatal("invalid viewport width: %v", err) + } + h, err := strconv.Atoi(parts[1]) + if err != nil { + fatal("invalid viewport height: %v", err) + } + vpWidth, vpHeight = w, h default: - fatal("unknown flag: %s\nusage: rodney start [--insecure]", args[i]) + fatal("unknown flag: %s\nusage: rodney start [--show] [--insecure|-k] [--viewport WxH] [--mobile] [--scale N]", args[i]) } } + if (vpMobile || vpScale != 0) && vpWidth == 0 { + fatal("--mobile and --scale require --viewport") + } + + if vpWidth > 0 && vpScale == 0 { + vpScale = 1 + } + // Check if already running if s, err := loadState(); err == nil { // Try connecting @@ -339,14 +425,6 @@ func cmdStart(args []string) { } } - // Parse flags - headless := true - for _, arg := range args { - if arg == "--show" { - headless = false - } - } - dataDir := filepath.Join(stateDir(), "chrome-data") os.MkdirAll(dataDir, 0755) @@ -411,12 +489,16 @@ func cmdStart(args []string) { pid := l.PID() state := &State{ - DebugURL: debugURL, - ChromePID: pid, - ActivePage: 0, - DataDir: dataDir, - ProxyPID: proxyPID, - ProxyPort: proxyPort, + DebugURL: debugURL, + ChromePID: pid, + ActivePage: 0, + DataDir: dataDir, + ProxyPID: proxyPID, + ProxyPort: proxyPort, + ViewportWidth: vpWidth, + ViewportHeight: vpHeight, + ViewportScale: vpScale, + ViewportMobile: vpMobile, } if err := saveState(state); err != nil { @@ -425,6 +507,9 @@ func cmdStart(args []string) { fmt.Printf("Chrome started (PID %d)\n", pid) fmt.Printf("Debug URL: %s\n", debugURL) + if vpWidth > 0 && vpHeight > 0 { + fmt.Println(formatViewportDesc("Viewport:", vpWidth, vpHeight, vpMobile, vpScale)) + } } func cmdConnect(args []string) { @@ -1101,11 +1186,96 @@ func nextAvailableFile(base, ext string) string { } } +func cmdViewport(args []string) { + if len(args) < 1 { + fatal("usage: rodney viewport [--scale N] [--mobile]\n rodney viewport --reset") + } + + // Handle --reset: clear viewport override and restore browser defaults + if args[0] == "--reset" { + s, _, page := withPage() + + if err := (proto.EmulationClearDeviceMetricsOverride{}.Call(page)); err != nil { + fatal("failed to clear viewport override: %v", err) + } + + s.ViewportWidth = 0 + s.ViewportHeight = 0 + s.ViewportScale = 0 + s.ViewportMobile = false + if err := saveState(s); err != nil { + fatal("failed to save state: %v", err) + } + + fmt.Println("Viewport reset to browser default") + return + } + + if len(args) < 2 { + fatal("usage: rodney viewport [--scale N] [--mobile]\n rodney viewport --reset") + } + + w, err := strconv.Atoi(args[0]) + if err != nil { + fatal("invalid width: %v", err) + } + h, err := strconv.Atoi(args[1]) + if err != nil { + fatal("invalid height: %v", err) + } + + scale := 1.0 + mobile := false + + for i := 2; i < len(args); i++ { + switch args[i] { + case "--scale": + i++ + if i >= len(args) { + fatal("missing value for --scale") + } + v, err := strconv.ParseFloat(args[i], 64) + if err != nil { + fatal("invalid scale: %v", err) + } + scale = v + case "--mobile": + mobile = true + default: + fatal("unknown flag: %s", args[i]) + } + } + + s, _, page := withPage() + + err = proto.EmulationSetDeviceMetricsOverride{ + Width: w, + Height: h, + DeviceScaleFactor: scale, + Mobile: mobile, + }.Call(page) + if err != nil { + fatal("failed to set viewport: %v", err) + } + + // Persist viewport settings so they are re-applied on each subsequent command + s.ViewportWidth = w + s.ViewportHeight = h + s.ViewportScale = scale + s.ViewportMobile = mobile + if err := saveState(s); err != nil { + fatal("failed to save state: %v", err) + } + + fmt.Println(formatViewportDesc("Viewport set to", w, h, mobile, scale)) +} + func cmdScreenshot(args []string) { var file string - width := 1280 + width := 0 height := 0 fullPage := true + sizeExplicit := false // Parse flags and positional args var positional []string @@ -1121,6 +1291,7 @@ func cmdScreenshot(args []string) { fatal("invalid width: %v", err) } width = v + sizeExplicit = true case "-h", "--height": i++ if i >= len(args) { @@ -1132,6 +1303,7 @@ func cmdScreenshot(args []string) { } height = v fullPage = false + sizeExplicit = true default: positional = append(positional, args[i]) } @@ -1143,20 +1315,26 @@ func cmdScreenshot(args []string) { file = nextAvailableFile("screenshot", ".png") } - _, _, page := withPage() + s, _, page := withPage() - // Set viewport size - viewportHeight := height - if viewportHeight == 0 { - viewportHeight = 720 - } - err := proto.EmulationSetDeviceMetricsOverride{ - Width: width, - Height: viewportHeight, - DeviceScaleFactor: 1, - }.Call(page) - if err != nil { - fatal("failed to set viewport: %v", err) + // Only override viewport if -w/-h were explicitly passed, or if no + // viewport has been set via "rodney viewport" + if sizeExplicit || s.ViewportWidth == 0 { + if width == 0 { + width = 1280 + } + viewportHeight := height + if viewportHeight == 0 { + viewportHeight = 720 + } + err := proto.EmulationSetDeviceMetricsOverride{ + Width: width, + Height: viewportHeight, + DeviceScaleFactor: 1, + }.Call(page) + if err != nil { + fatal("failed to set viewport: %v", err) + } } data, err := page.Screenshot(fullPage, nil) diff --git a/main_test.go b/main_test.go index 79ee87c..4fff1df 100644 --- a/main_test.go +++ b/main_test.go @@ -1074,6 +1074,256 @@ func TestFormatAssertFail_EqualityWithMessage(t *testing.T) { } } +// ===================== +// viewport tests +// ===================== + +func TestFormatViewportDesc_Basic(t *testing.T) { + got := formatViewportDesc("Viewport:", 375, 812, false, 1) + expected := "Viewport: 375x812" + if got != expected { + t.Errorf("expected %q, got %q", expected, got) + } +} + +func TestFormatViewportDesc_Mobile(t *testing.T) { + got := formatViewportDesc("Viewport set to", 375, 812, true, 1) + expected := "Viewport set to 375x812 (mobile)" + if got != expected { + t.Errorf("expected %q, got %q", expected, got) + } +} + +func TestFormatViewportDesc_Scale(t *testing.T) { + got := formatViewportDesc("Viewport:", 390, 844, false, 3) + expected := "Viewport: 390x844 (scale 3)" + if got != expected { + t.Errorf("expected %q, got %q", expected, got) + } +} + +func TestFormatViewportDesc_MobileAndScale(t *testing.T) { + got := formatViewportDesc("Viewport set to", 375, 812, true, 2) + expected := "Viewport set to 375x812 (mobile, scale 2)" + if got != expected { + t.Errorf("expected %q, got %q", expected, got) + } +} + +func TestFormatViewportDesc_ScaleOne_Omitted(t *testing.T) { + got := formatViewportDesc("Viewport:", 1280, 720, false, 1) + if strings.Contains(got, "scale") { + t.Errorf("scale 1 should be omitted, got %q", got) + } +} + +func TestFormatViewportDesc_ScaleZero_Omitted(t *testing.T) { + got := formatViewportDesc("Viewport:", 1280, 720, false, 0) + if strings.Contains(got, "scale") { + t.Errorf("scale 0 should be omitted, got %q", got) + } +} + +func TestViewport_StatePersistence(t *testing.T) { + // Verify that viewport settings round-trip through state serialization + dir := t.TempDir() + state := &State{ + DebugURL: "ws://localhost:1234", + ChromePID: 12345, + DataDir: dir, + ViewportWidth: 375, + ViewportHeight: 812, + ViewportScale: 2, + ViewportMobile: true, + } + + data, err := json.Marshal(state) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var loaded State + if err := json.Unmarshal(data, &loaded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if loaded.ViewportWidth != 375 { + t.Errorf("expected ViewportWidth 375, got %d", loaded.ViewportWidth) + } + if loaded.ViewportHeight != 812 { + t.Errorf("expected ViewportHeight 812, got %d", loaded.ViewportHeight) + } + if loaded.ViewportScale != 2 { + t.Errorf("expected ViewportScale 2, got %g", loaded.ViewportScale) + } + if !loaded.ViewportMobile { + t.Error("expected ViewportMobile true") + } +} + +func TestViewport_StateOmitsZeroValues(t *testing.T) { + // Verify that empty viewport fields are omitted from JSON (omitempty) + state := &State{ + DebugURL: "ws://localhost:1234", + ChromePID: 12345, + DataDir: "/tmp/test", + } + + data, err := json.Marshal(state) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + raw := string(data) + for _, key := range []string{"viewport_width", "viewport_height", "viewport_scale", "viewport_mobile"} { + if strings.Contains(raw, key) { + t.Errorf("expected %q to be omitted from JSON, got: %s", key, raw) + } + } +} + +func TestViewport_EmulationApplied(t *testing.T) { + // Verify the CDP emulation call works end-to-end via rod + page := navigateTo(t, "/") + + err := proto.EmulationSetDeviceMetricsOverride{ + Width: 375, + Height: 812, + DeviceScaleFactor: 2, + }.Call(page) + if err != nil { + t.Fatalf("EmulationSetDeviceMetricsOverride failed: %v", err) + } + + w, err := page.Eval(`() => { return window.innerWidth; }`) + if err != nil { + t.Fatalf("eval innerWidth failed: %v", err) + } + if w.Value.Int() != 375 { + t.Errorf("expected innerWidth 375, got %d", w.Value.Int()) + } + + dpr, err := page.Eval(`() => { return window.devicePixelRatio; }`) + if err != nil { + t.Fatalf("eval devicePixelRatio failed: %v", err) + } + if dpr.Value.Int() != 2 { + t.Errorf("expected devicePixelRatio 2, got %d", dpr.Value.Int()) + } +} + +func TestViewport_EmulationReset(t *testing.T) { + // Verify that clearing device metrics override restores defaults + page := navigateTo(t, "/") + + // Set a custom viewport + err := proto.EmulationSetDeviceMetricsOverride{ + Width: 375, + Height: 812, + DeviceScaleFactor: 2, + }.Call(page) + if err != nil { + t.Fatalf("EmulationSetDeviceMetricsOverride failed: %v", err) + } + + w, err := page.Eval(`() => { return window.innerWidth; }`) + if err != nil { + t.Fatalf("eval innerWidth failed: %v", err) + } + if w.Value.Int() != 375 { + t.Fatalf("expected innerWidth 375 after override, got %d", w.Value.Int()) + } + + // Clear the override + if err := (proto.EmulationClearDeviceMetricsOverride{}.Call(page)); err != nil { + t.Fatalf("EmulationClearDeviceMetricsOverride failed: %v", err) + } + + w2, err := page.Eval(`() => { return window.innerWidth; }`) + if err != nil { + t.Fatalf("eval innerWidth after reset failed: %v", err) + } + if w2.Value.Int() == 375 { + t.Errorf("expected innerWidth to change after reset, still 375") + } +} + +func TestViewport_ResetClearsState(t *testing.T) { + // Verify that resetting viewport clears persisted state fields + state := &State{ + DebugURL: "ws://localhost:1234", + ChromePID: 12345, + DataDir: t.TempDir(), + ViewportWidth: 375, + ViewportHeight: 812, + ViewportScale: 2, + ViewportMobile: true, + } + + // Simulate what cmdViewport --reset does to state + state.ViewportWidth = 0 + state.ViewportHeight = 0 + state.ViewportScale = 0 + state.ViewportMobile = false + + data, err := json.Marshal(state) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + raw := string(data) + for _, key := range []string{"viewport_width", "viewport_height", "viewport_scale", "viewport_mobile"} { + if strings.Contains(raw, key) { + t.Errorf("expected %q to be omitted after reset, got: %s", key, raw) + } + } +} + +func TestViewport_ScreenshotSkipsOverrideWhenViewportSet(t *testing.T) { + // When viewport is persisted in state, cmdScreenshot should skip its + // default 1280x720 override so the active viewport is used instead. + page := navigateTo(t, "/") + + // Set a custom viewport via CDP (simulating what "rodney viewport" does) + err := proto.EmulationSetDeviceMetricsOverride{ + Width: 375, + Height: 812, + DeviceScaleFactor: 2, + }.Call(page) + if err != nil { + t.Fatalf("EmulationSetDeviceMetricsOverride failed: %v", err) + } + + w, err := page.Eval(`() => { return window.innerWidth; }`) + if err != nil { + t.Fatalf("eval innerWidth failed: %v", err) + } + if w.Value.Int() != 375 { + t.Errorf("expected innerWidth 375, got %d", w.Value.Int()) + } + + // If screenshot were to call EmulationSetDeviceMetricsOverride with + // 1280x720 here, innerWidth would change. Verify that re-applying the + // same custom viewport keeps the size — this is the path screenshot + // takes when it skips its default override. + err = proto.EmulationSetDeviceMetricsOverride{ + Width: 375, + Height: 812, + DeviceScaleFactor: 2, + }.Call(page) + if err != nil { + t.Fatalf("re-apply viewport failed: %v", err) + } + + w2, err := page.Eval(`() => { return window.innerWidth; }`) + if err != nil { + t.Fatalf("eval innerWidth after re-apply failed: %v", err) + } + if w2.Value.Int() != 375 { + t.Errorf("expected innerWidth 375 after re-apply, got %d", w2.Value.Int()) + } +} + func TestInsecureFlag_WithSelfSignedCert(t *testing.T) { // Create HTTPS server with self-signed certificate mux := http.NewServeMux()