From 7b4579d4d79047a09edd5696b18596a5d8b012a1 Mon Sep 17 00:00:00 2001 From: Simon Massey <322608+simbo1905@users.noreply.github.com> Date: Thu, 19 Mar 2026 23:00:17 +0000 Subject: [PATCH 1/3] Add Rodney cookie management commands --- README.md | 5 + help.txt | 6 + main.go | 331 +++++++++++++++++++++++++++++++++++++++++++++++++-- main_test.go | 262 +++++++++++++++++++++++++++++++++++++++- 4 files changed, 592 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 3cb2e26..175ce4e 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,11 @@ rodney select "#dropdown" "value" # Select dropdown by value rodney submit "form#login" # Submit a form rodney hover ".menu-item" # Hover over element rodney focus "#email" # Focus element +rodney cookie set session abc123 --domain example.com +rodney cookie set prefs dark --domain example.com --path /app --http-only --same-site lax +rodney cookie list # List cookies for current page URL +rodney cookie get session # Print just the cookie value +rodney cookie delete session --domain example.com ``` ### Wait for conditions diff --git a/help.txt b/help.txt index 79bac7f..24393ba 100644 --- a/help.txt +++ b/help.txt @@ -32,6 +32,12 @@ Interaction: rodney submit Submit a form rodney hover Hover over an element rodney focus Focus an element + rodney cookie set --domain [--path ] [--secure] [--http-only] [--same-site ] + Set a browser cookie for a domain + rodney cookie list List cookies for the current page URL + rodney cookie get Print a cookie value for the current page URL + rodney cookie delete --domain [--path ] + Delete a browser cookie by domain/path Waiting: rodney wait Wait for element to appear diff --git a/main.go b/main.go index a5188db..d72b766 100644 --- a/main.go +++ b/main.go @@ -14,6 +14,7 @@ import ( "os/exec" "os/signal" "path/filepath" + "sort" "strconv" "strings" "syscall" @@ -79,12 +80,12 @@ func resolveStateDir(mode scopeMode, workingDir string) string { // State persisted between CLI invocations type State struct { - DebugURL string `json:"debug_url"` - ChromePID int `json:"chrome_pid"` - ActivePage int `json:"active_page"` // index into pages list - 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 + DebugURL string `json:"debug_url"` + ChromePID int `json:"chrome_pid"` + ActivePage int `json:"active_page"` // index into pages list + 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 } func stateDir() string { @@ -257,6 +258,8 @@ func main() { cmdDownload(args) case "focus": cmdFocus(args) + case "cookie": + cmdCookie(args) case "wait": cmdWait(args) case "waitload": @@ -306,6 +309,9 @@ func main() { // Default timeout for element queries (seconds) var defaultTimeout = 30 * time.Second +const cookieSetUsage = "usage: rodney cookie set --domain [--path ] [--secure] [--http-only] [--same-site ]" +const cookieDeleteUsage = "usage: rodney cookie delete --domain [--path ]" + func init() { if t := os.Getenv("ROD_TIMEOUT"); t != "" { if secs, err := strconv.ParseFloat(t, 64); err == nil { @@ -378,7 +384,7 @@ func cmdStart(args []string) { Set("no-sandbox"). Set("disable-gpu"). Set("single-process"). // Required for screenshots in gVisor/container environments - Leakless(false). // Keep Chrome alive after CLI exits + Leakless(false). // Keep Chrome alive after CLI exits UserDataDir(dataDir). Headless(headless) @@ -1066,6 +1072,315 @@ func cmdFocus(args []string) { fmt.Println("Focused") } +type cookieSetOptions struct { + Name string + Value string + Domain string + Path string + Secure bool + HTTPOnly bool + SameSite proto.NetworkCookieSameSite +} + +type cookieDeleteOptions struct { + Name string + Domain string + Path string +} + +func cmdCookie(args []string) { + if len(args) == 0 { + fatal("usage: rodney cookie ") + } + + switch args[0] { + case "set": + cmdCookieSet(args[1:]) + case "list": + cmdCookieList(args[1:]) + case "get": + cmdCookieGet(args[1:]) + case "delete": + cmdCookieDelete(args[1:]) + default: + fatal("unknown cookie subcommand: %s\nusage: rodney cookie ", args[0]) + } +} + +func cmdCookieSet(args []string) { + opts, err := parseCookieSetArgs(args) + if err != nil { + fatal("%s", err) + } + + _, _, page := withPage() + if err := setCookie(page, opts); err != nil { + fatal("failed to set cookie: %v", err) + } + + fmt.Printf("Set cookie %s for %s\n", opts.Name, opts.Domain) +} + +func cmdCookieList(args []string) { + if len(args) > 0 { + fatal("usage: rodney cookie list") + } + + _, _, page := withPage() + cookies, err := listCookies(page) + if err != nil { + fatal("failed to list cookies: %v", err) + } + if len(cookies) == 0 { + return + } + fmt.Print(formatCookieList(cookies)) +} + +func cmdCookieGet(args []string) { + if len(args) != 1 { + fatal("usage: rodney cookie get ") + } + + _, _, page := withPage() + cookie, err := getCookie(page, args[0]) + if err != nil { + fatal("failed to get cookie: %v", err) + } + if cookie == nil { + fmt.Fprintf(os.Stderr, "cookie not found: %s\n", args[0]) + os.Exit(1) + } + fmt.Println(cookie.Value) +} + +func cmdCookieDelete(args []string) { + opts, err := parseCookieDeleteArgs(args) + if err != nil { + fatal("%s", err) + } + + _, _, page := withPage() + cookie, err := getCookie(page, opts.Name) + if err != nil { + fatal("failed to check cookie: %v", err) + } + if cookie == nil || cookie.Domain != opts.Domain || cookie.Path != opts.Path { + fmt.Fprintf(os.Stderr, "cookie not found: %s\n", opts.Name) + os.Exit(1) + } + + if err := deleteCookie(page, opts); err != nil { + fatal("failed to delete cookie: %v", err) + } + + fmt.Printf("Deleted cookie %s for %s\n", opts.Name, opts.Domain) +} + +func parseCookieSetArgs(args []string) (cookieSetOptions, error) { + opts := cookieSetOptions{Path: "/"} + positionals := make([]string, 0, 2) + + for i := 0; i < len(args); i++ { + arg := args[i] + + switch { + case arg == "--domain": + if i+1 >= len(args) { + return cookieSetOptions{}, fmt.Errorf("missing value for --domain\n%s", cookieSetUsage) + } + i++ + opts.Domain = args[i] + case strings.HasPrefix(arg, "--domain="): + opts.Domain = strings.TrimPrefix(arg, "--domain=") + case arg == "--path": + if i+1 >= len(args) { + return cookieSetOptions{}, fmt.Errorf("missing value for --path\n%s", cookieSetUsage) + } + i++ + opts.Path = args[i] + case strings.HasPrefix(arg, "--path="): + opts.Path = strings.TrimPrefix(arg, "--path=") + case arg == "--secure": + opts.Secure = true + case arg == "--http-only": + opts.HTTPOnly = true + case arg == "--same-site": + if i+1 >= len(args) { + return cookieSetOptions{}, fmt.Errorf("missing value for --same-site\n%s", cookieSetUsage) + } + i++ + sameSite, err := parseCookieSameSite(args[i]) + if err != nil { + return cookieSetOptions{}, fmt.Errorf("%w\n%s", err, cookieSetUsage) + } + opts.SameSite = sameSite + case strings.HasPrefix(arg, "--same-site="): + sameSite, err := parseCookieSameSite(strings.TrimPrefix(arg, "--same-site=")) + if err != nil { + return cookieSetOptions{}, fmt.Errorf("%w\n%s", err, cookieSetUsage) + } + opts.SameSite = sameSite + case strings.HasPrefix(arg, "-"): + return cookieSetOptions{}, fmt.Errorf("unknown flag: %s\n%s", arg, cookieSetUsage) + default: + positionals = append(positionals, arg) + } + } + + if len(positionals) < 2 { + return cookieSetOptions{}, fmt.Errorf("missing cookie name or value\n%s", cookieSetUsage) + } + if len(positionals) > 2 { + return cookieSetOptions{}, fmt.Errorf("too many arguments: %s\n%s", positionals[2], cookieSetUsage) + } + if opts.Domain == "" { + return cookieSetOptions{}, fmt.Errorf("missing required flag: --domain\n%s", cookieSetUsage) + } + + opts.Name = positionals[0] + opts.Value = positionals[1] + return opts, nil +} + +func parseCookieSameSite(value string) (proto.NetworkCookieSameSite, error) { + switch strings.ToLower(value) { + case "strict": + return proto.NetworkCookieSameSiteStrict, nil + case "lax": + return proto.NetworkCookieSameSiteLax, nil + case "none": + return proto.NetworkCookieSameSiteNone, nil + default: + return "", fmt.Errorf("invalid --same-site value: %s", value) + } +} + +func parseCookieDeleteArgs(args []string) (cookieDeleteOptions, error) { + opts := cookieDeleteOptions{Path: "/"} + positionals := make([]string, 0, 1) + + for i := 0; i < len(args); i++ { + arg := args[i] + + switch { + case arg == "--domain": + if i+1 >= len(args) { + return cookieDeleteOptions{}, fmt.Errorf("missing value for --domain\n%s", cookieDeleteUsage) + } + i++ + opts.Domain = args[i] + case strings.HasPrefix(arg, "--domain="): + opts.Domain = strings.TrimPrefix(arg, "--domain=") + case arg == "--path": + if i+1 >= len(args) { + return cookieDeleteOptions{}, fmt.Errorf("missing value for --path\n%s", cookieDeleteUsage) + } + i++ + opts.Path = args[i] + case strings.HasPrefix(arg, "--path="): + opts.Path = strings.TrimPrefix(arg, "--path=") + case strings.HasPrefix(arg, "-"): + return cookieDeleteOptions{}, fmt.Errorf("unknown flag: %s\n%s", arg, cookieDeleteUsage) + default: + positionals = append(positionals, arg) + } + } + + if len(positionals) < 1 { + return cookieDeleteOptions{}, fmt.Errorf("missing cookie name\n%s", cookieDeleteUsage) + } + if len(positionals) > 1 { + return cookieDeleteOptions{}, fmt.Errorf("too many arguments: %s\n%s", positionals[1], cookieDeleteUsage) + } + if opts.Domain == "" { + return cookieDeleteOptions{}, fmt.Errorf("missing required flag: --domain\n%s", cookieDeleteUsage) + } + + opts.Name = positionals[0] + return opts, nil +} + +func setCookie(page *rod.Page, opts cookieSetOptions) error { + params := proto.NetworkSetCookie{ + Name: opts.Name, + Value: opts.Value, + Domain: opts.Domain, + Path: opts.Path, + Secure: opts.Secure, + HTTPOnly: opts.HTTPOnly, + SameSite: opts.SameSite, + } + + if _, err := params.Call(page); err != nil { + return err + } + return nil +} + +func listCookies(page *rod.Page) ([]*proto.NetworkCookie, error) { + info, err := page.Info() + if err != nil { + return nil, err + } + result, err := proto.NetworkGetCookies{Urls: []string{info.URL}}.Call(page) + if err != nil { + return nil, err + } + return result.Cookies, nil +} + +func getCookie(page *rod.Page, name string) (*proto.NetworkCookie, error) { + cookies, err := listCookies(page) + if err != nil { + return nil, err + } + return findCookie(cookies, name), nil +} + +func deleteCookie(page *rod.Page, opts cookieDeleteOptions) error { + return proto.NetworkDeleteCookies{ + Name: opts.Name, + Domain: opts.Domain, + Path: opts.Path, + }.Call(page) +} + +func formatCookieList(cookies []*proto.NetworkCookie) string { + if len(cookies) == 0 { + return "" + } + + sorted := append([]*proto.NetworkCookie(nil), cookies...) + sort.Slice(sorted, func(i, j int) bool { + if sorted[i].Name == sorted[j].Name { + if sorted[i].Domain == sorted[j].Domain { + return sorted[i].Path < sorted[j].Path + } + return sorted[i].Domain < sorted[j].Domain + } + return sorted[i].Name < sorted[j].Name + }) + + var sb strings.Builder + for _, cookie := range sorted { + sb.WriteString(cookie.Name) + sb.WriteString("=") + sb.WriteString(cookie.Value) + sb.WriteString("\n") + } + return sb.String() +} + +func findCookie(cookies []*proto.NetworkCookie, name string) *proto.NetworkCookie { + for _, cookie := range cookies { + if cookie.Name == name { + return cookie + } + } + return nil +} + func cmdWait(args []string) { if len(args) < 1 { fatal("usage: rodney wait ") @@ -1611,7 +1926,7 @@ func queryAXNodes(page *rod.Page, name, role string) ([]*proto.AccessibilityAXNo } result, err := proto.AccessibilityQueryAXTree{ - BackendNodeID: doc.Root.BackendNodeID, + BackendNodeID: doc.Root.BackendNodeID, AccessibleName: name, Role: role, }.Call(page) diff --git a/main_test.go b/main_test.go index 1753da9..c931934 100644 --- a/main_test.go +++ b/main_test.go @@ -8,6 +8,7 @@ import ( "log" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" "strings" @@ -773,6 +774,259 @@ func TestMimeToExt(t *testing.T) { } } +func TestParseCookieSetArgs_Minimal(t *testing.T) { + opts, err := parseCookieSetArgs([]string{"session", "abc123", "--domain", "example.com"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.Name != "session" { + t.Errorf("Name = %q, want %q", opts.Name, "session") + } + if opts.Value != "abc123" { + t.Errorf("Value = %q, want %q", opts.Value, "abc123") + } + if opts.Domain != "example.com" { + t.Errorf("Domain = %q, want %q", opts.Domain, "example.com") + } + if opts.Path != "/" { + t.Errorf("Path = %q, want %q", opts.Path, "/") + } + if opts.Secure { + t.Error("expected Secure=false by default") + } + if opts.HTTPOnly { + t.Error("expected HTTPOnly=false by default") + } + if opts.SameSite != "" { + t.Errorf("SameSite = %q, want empty", opts.SameSite) + } +} + +func TestParseCookieSetArgs_WithOptionalFlags(t *testing.T) { + opts, err := parseCookieSetArgs([]string{"session", "abc123", "--domain", "example.com", "--path", "/app", "--secure", "--http-only", "--same-site", "strict"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.Path != "/app" { + t.Errorf("Path = %q, want %q", opts.Path, "/app") + } + if !opts.Secure { + t.Error("expected Secure=true when --secure is passed") + } + if !opts.HTTPOnly { + t.Error("expected HTTPOnly=true when --http-only is passed") + } + if opts.SameSite != proto.NetworkCookieSameSiteStrict { + t.Errorf("SameSite = %q, want %q", opts.SameSite, proto.NetworkCookieSameSiteStrict) + } +} + +func TestParseCookieSetArgs_RequiresDomain(t *testing.T) { + _, err := parseCookieSetArgs([]string{"session", "abc123"}) + if err == nil { + t.Fatal("expected error when --domain is missing") + } + if !strings.Contains(err.Error(), "--domain") { + t.Errorf("error should mention --domain, got %v", err) + } +} + +func TestSetCookie_SetsCookieForDomain(t *testing.T) { + page := navigateTo(t, "/empty") + serverURL, err := url.Parse(env.server.URL) + if err != nil { + t.Fatalf("parse server URL: %v", err) + } + + name := strings.ReplaceAll(strings.ToLower(t.Name()), "/", "-") + opts := cookieSetOptions{ + Name: name, + Value: "blue", + Domain: serverURL.Hostname(), + Path: "/", + } + + if err := setCookie(page, opts); err != nil { + t.Fatalf("setCookie failed: %v", err) + } + + cookie := findCookieByName(page.MustCookies(env.server.URL), name) + if cookie == nil { + t.Fatalf("expected cookie %q to be present", name) + } + if cookie.Value != "blue" { + t.Errorf("Value = %q, want %q", cookie.Value, "blue") + } + if cookie.Domain != serverURL.Hostname() { + t.Errorf("Domain = %q, want %q", cookie.Domain, serverURL.Hostname()) + } + if cookie.Path != "/" { + t.Errorf("Path = %q, want %q", cookie.Path, "/") + } +} + +func TestSetCookie_AppliesOptionalAttributes(t *testing.T) { + page := navigateTo(t, "/empty") + serverURL, err := url.Parse(env.server.URL) + if err != nil { + t.Fatalf("parse server URL: %v", err) + } + + name := strings.ReplaceAll(strings.ToLower(t.Name()), "/", "-") + opts := cookieSetOptions{ + Name: name, + Value: "value", + Domain: serverURL.Hostname(), + Path: "/", + HTTPOnly: true, + SameSite: proto.NetworkCookieSameSiteLax, + } + + if err := setCookie(page, opts); err != nil { + t.Fatalf("setCookie failed: %v", err) + } + + cookie := findCookieByName(page.MustCookies(env.server.URL), name) + if cookie == nil { + t.Fatalf("expected cookie %q to be present", name) + } + if !cookie.HTTPOnly { + t.Error("expected cookie to be HTTPOnly") + } + if cookie.SameSite != proto.NetworkCookieSameSiteLax { + t.Errorf("SameSite = %q, want %q", cookie.SameSite, proto.NetworkCookieSameSiteLax) + } +} + +func TestParseCookieDeleteArgs_Minimal(t *testing.T) { + opts, err := parseCookieDeleteArgs([]string{"session", "--domain", "example.com"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.Name != "session" { + t.Errorf("Name = %q, want %q", opts.Name, "session") + } + if opts.Domain != "example.com" { + t.Errorf("Domain = %q, want %q", opts.Domain, "example.com") + } + if opts.Path != "/" { + t.Errorf("Path = %q, want %q", opts.Path, "/") + } +} + +func TestParseCookieDeleteArgs_RequiresDomain(t *testing.T) { + _, err := parseCookieDeleteArgs([]string{"session"}) + if err == nil { + t.Fatal("expected error when --domain is missing") + } + if !strings.Contains(err.Error(), "--domain") { + t.Errorf("error should mention --domain, got %v", err) + } +} + +func TestListCookies_ReturnsCurrentPageCookies(t *testing.T) { + page := navigateTo(t, "/empty") + serverURL, err := url.Parse(env.server.URL) + if err != nil { + t.Fatalf("parse server URL: %v", err) + } + + mustSetCookie(t, page, cookieSetOptions{Name: "alpha", Value: "1", Domain: serverURL.Hostname(), Path: "/"}) + mustSetCookie(t, page, cookieSetOptions{Name: "beta", Value: "2", Domain: serverURL.Hostname(), Path: "/"}) + + cookies, err := listCookies(page) + if err != nil { + t.Fatalf("listCookies failed: %v", err) + } + if findCookieByName(cookies, "alpha") == nil { + t.Fatal("expected cookie alpha in list") + } + if findCookieByName(cookies, "beta") == nil { + t.Fatal("expected cookie beta in list") + } +} + +func TestGetCookie_ReturnsMatchingCookie(t *testing.T) { + page := navigateTo(t, "/empty") + serverURL, err := url.Parse(env.server.URL) + if err != nil { + t.Fatalf("parse server URL: %v", err) + } + + mustSetCookie(t, page, cookieSetOptions{Name: "theme", Value: "dark", Domain: serverURL.Hostname(), Path: "/"}) + + cookie, err := getCookie(page, "theme") + if err != nil { + t.Fatalf("getCookie failed: %v", err) + } + if cookie == nil { + t.Fatal("expected cookie to be found") + } + if cookie.Value != "dark" { + t.Errorf("Value = %q, want %q", cookie.Value, "dark") + } +} + +func TestGetCookie_ReturnsNilWhenMissing(t *testing.T) { + page := navigateTo(t, "/empty") + cookie, err := getCookie(page, "missing") + if err != nil { + t.Fatalf("getCookie failed: %v", err) + } + if cookie != nil { + t.Fatalf("expected nil cookie, got %+v", cookie) + } +} + +func TestDeleteCookie_RemovesCookie(t *testing.T) { + page := navigateTo(t, "/empty") + serverURL, err := url.Parse(env.server.URL) + if err != nil { + t.Fatalf("parse server URL: %v", err) + } + + opts := cookieSetOptions{Name: "remove-me", Value: "gone", Domain: serverURL.Hostname(), Path: "/"} + mustSetCookie(t, page, opts) + + if err := deleteCookie(page, cookieDeleteOptions{Name: opts.Name, Domain: opts.Domain, Path: opts.Path}); err != nil { + t.Fatalf("deleteCookie failed: %v", err) + } + + cookie, err := getCookie(page, opts.Name) + if err != nil { + t.Fatalf("getCookie failed: %v", err) + } + if cookie != nil { + t.Fatalf("expected cookie to be deleted, got %+v", cookie) + } +} + +func TestFormatCookieList_SortsByName(t *testing.T) { + formatted := formatCookieList([]*proto.NetworkCookie{ + {Name: "beta", Value: "2"}, + {Name: "alpha", Value: "1"}, + }) + if formatted != "alpha=1\nbeta=2\n" { + t.Fatalf("unexpected formatted output: %q", formatted) + } +} + +func mustSetCookie(t *testing.T, page *rod.Page, opts cookieSetOptions) { + t.Helper() + if err := setCookie(page, opts); err != nil { + t.Fatalf("setCookie failed: %v", err) + } +} + +func findCookieByName(cookies []*proto.NetworkCookie, name string) *proto.NetworkCookie { + for _, cookie := range cookies { + if cookie.Name == name { + return cookie + } + } + return nil +} + // ===================== // assert command tests // ===================== @@ -923,10 +1177,10 @@ func TestAssert_ValueFormatting_MatchesJSCommand(t *testing.T) { expr string expected string }{ - {`document.title`, "Test Page"}, // string unquoted - {`1 + 2`, "3"}, // number - {`true`, "true"}, // boolean - {`null`, "null"}, // null + {`document.title`, "Test Page"}, // string unquoted + {`1 + 2`, "3"}, // number + {`true`, "true"}, // boolean + {`null`, "null"}, // null {`document.querySelectorAll("button").length`, "2"}, // number from DOM } From a9fc24b4af8c4b01e15adf0bf71a6a175b4ae7cd Mon Sep 17 00:00:00 2001 From: Simon Massey <322608+simbo1905@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:26:54 +0100 Subject: [PATCH 2/3] .env ignored and .tmp/keep with .tmp in .gitignored --- .gitignore | 2 ++ .tmp/keep | 0 2 files changed, 2 insertions(+) create mode 100644 .tmp/keep diff --git a/.gitignore b/.gitignore index 482052f..45ea8a6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ rodney dist .DS_Store +.env +.tmp/ diff --git a/.tmp/keep b/.tmp/keep new file mode 100644 index 0000000..e69de29 From 8daa87ed9a3b26fd3485fb0e13c8b890ed8046f3 Mon Sep 17 00:00:00 2001 From: Simon Massey <322608+simbo1905@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:34:27 +0100 Subject: [PATCH 3/3] Enable GPU by default, add --no-gpu, document ROD_CHROME_BIN Chrome was launched with disable-gpu unconditionally, so WebGL never worked on any platform. Verified against Apache echarts-gl: the 3D surface example rendered "Sorry, your browser does not support WebGL" before, and now renders on the real GPU (ANGLE Metal). The flag is still needed in containers and VMs with no usable GPU, so it moves behind an opt-in --no-gpu flag rather than being the default. Also documents ROD_CHROME_BIN in help.txt: it was already honoured in cmdStart but was not discoverable from --help. Fixes #1 Fixes #3 --- help.txt | 7 ++++++- main.go | 18 ++++++++++++------ main_test.go | 44 ++++++++++++++++++++++++++++++++++++++------ 3 files changed, 56 insertions(+), 13 deletions(-) diff --git a/help.txt b/help.txt index 24393ba..60e3a62 100644 --- a/help.txt +++ b/help.txt @@ -1,7 +1,10 @@ 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] [--no-gpu] + Launch Chrome (headless by default, --show for visible) + --no-gpu disables GPU acceleration; needed in containers + and VMs (Docker, Colima, Lima) with no usable GPU rodney connect Connect to existing Chrome on remote debug port rodney stop Shut down Chrome rodney status Show browser status @@ -79,6 +82,8 @@ Use "rodney start --local" to create a directory-scoped session. Environment: RODNEY_HOME Override data directory (default: ~/.rodney) + ROD_CHROME_BIN Use a specific Chrome/Chromium binary instead + of the downloaded one (e.g. your installed Chrome) Exit codes: 0 Success diff --git a/main.go b/main.go index d72b766..bd49a72 100644 --- a/main.go +++ b/main.go @@ -344,25 +344,26 @@ func withPage() (*State, *rod.Browser, *rod.Page) { // parseStartArgs parses the flags for the "start" command. // Returns ignoreCertErrors, headless, and an error for unknown flags. -func parseStartArgs(args []string) (ignoreCertErrors bool, headless bool, err error) { +func parseStartArgs(args []string) (ignoreCertErrors bool, headless bool, noGPU bool, err error) { fs := flag.NewFlagSet("start", flag.ContinueOnError) fs.SetOutput(io.Discard) fs.BoolVar(&ignoreCertErrors, "insecure", false, "") fs.BoolVar(&ignoreCertErrors, "k", false, "") show := fs.Bool("show", false, "") + fs.BoolVar(&noGPU, "no-gpu", false, "") if parseErr := fs.Parse(args); parseErr != nil { - return false, true, fmt.Errorf("unknown flag: %s\nusage: rodney start [--show] [--insecure]", findUnknownFlag(args, fs)) + return false, true, false, fmt.Errorf("unknown flag: %s\nusage: rodney start [--show] [--insecure] [--no-gpu]", findUnknownFlag(args, fs)) } if fs.NArg() > 0 { - return false, true, fmt.Errorf("unknown flag: %s\nusage: rodney start [--show] [--insecure]", fs.Arg(0)) + return false, true, false, fmt.Errorf("unknown flag: %s\nusage: rodney start [--show] [--insecure] [--no-gpu]", fs.Arg(0)) } headless = !*show - return ignoreCertErrors, headless, nil + return ignoreCertErrors, headless, noGPU, nil } func cmdStart(args []string) { - ignoreCertErrors, headless, err := parseStartArgs(args) + ignoreCertErrors, headless, noGPU, err := parseStartArgs(args) if err != nil { fatal("%s", err) } @@ -382,12 +383,17 @@ func cmdStart(args []string) { l := launcher.New(). Set("no-sandbox"). - Set("disable-gpu"). Set("single-process"). // Required for screenshots in gVisor/container environments Leakless(false). // Keep Chrome alive after CLI exits UserDataDir(dataDir). Headless(headless) + // GPU stays on by default so WebGL and 3D content render. Containers and VMs + // (Docker, Colima, Lima, gVisor) often have no usable GPU: --no-gpu for those. + if noGPU { + l = l.Set("disable-gpu") + } + // When in non-headless mode, make sure that we show the startup window immediately // (instead of showing a window only after calling "rodney open") if !headless { diff --git a/main_test.go b/main_test.go index c931934..cbe5eba 100644 --- a/main_test.go +++ b/main_test.go @@ -1333,7 +1333,7 @@ func TestFormatAssertFail_EqualityWithMessage(t *testing.T) { // ===================== func TestParseStartArgs_NoFlags(t *testing.T) { - insecure, headless, err := parseStartArgs([]string{}) + insecure, headless, noGPU, err := parseStartArgs([]string{}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1343,10 +1343,42 @@ func TestParseStartArgs_NoFlags(t *testing.T) { if !headless { t.Error("expected headless=true with no flags") } + if noGPU { + t.Error("expected noGPU=false with no flags: GPU is enabled by default") + } +} + +func TestParseStartArgs_NoGPUFlag(t *testing.T) { + insecure, headless, noGPU, err := parseStartArgs([]string{"--no-gpu"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !noGPU { + t.Error("expected noGPU=true when --no-gpu is passed") + } + if insecure { + t.Error("expected insecure=false") + } + if !headless { + t.Error("expected headless=true when only --no-gpu is passed") + } +} + +func TestParseStartArgs_ShowAndNoGPU(t *testing.T) { + _, headless, noGPU, err := parseStartArgs([]string{"--show", "--no-gpu"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !noGPU { + t.Error("expected noGPU=true") + } + if headless { + t.Error("expected headless=false when --show is passed") + } } func TestParseStartArgs_ShowFlag(t *testing.T) { - insecure, headless, err := parseStartArgs([]string{"--show"}) + insecure, headless, _, err := parseStartArgs([]string{"--show"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1359,7 +1391,7 @@ func TestParseStartArgs_ShowFlag(t *testing.T) { } func TestParseStartArgs_InsecureFlag(t *testing.T) { - insecure, headless, err := parseStartArgs([]string{"--insecure"}) + insecure, headless, _, err := parseStartArgs([]string{"--insecure"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1372,7 +1404,7 @@ func TestParseStartArgs_InsecureFlag(t *testing.T) { } func TestParseStartArgs_InsecureShortFlag(t *testing.T) { - insecure, _, err := parseStartArgs([]string{"-k"}) + insecure, _, _, err := parseStartArgs([]string{"-k"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1382,7 +1414,7 @@ func TestParseStartArgs_InsecureShortFlag(t *testing.T) { } func TestParseStartArgs_ShowAndInsecure(t *testing.T) { - insecure, headless, err := parseStartArgs([]string{"--show", "--insecure"}) + insecure, headless, _, err := parseStartArgs([]string{"--show", "--insecure"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1395,7 +1427,7 @@ func TestParseStartArgs_ShowAndInsecure(t *testing.T) { } func TestParseStartArgs_UnknownFlag(t *testing.T) { - _, _, err := parseStartArgs([]string{"--bogus"}) + _, _, _, err := parseStartArgs([]string{"--bogus"}) if err == nil { t.Fatal("expected error for unknown flag --bogus") }