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 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..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 @@ -32,6 +35,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 @@ -73,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 a5188db..bd49a72 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 { @@ -338,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) } @@ -376,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 + 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 { @@ -1066,6 +1078,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 +1932,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..cbe5eba 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 } @@ -1079,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) } @@ -1089,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) } @@ -1105,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) } @@ -1118,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) } @@ -1128,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) } @@ -1141,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") }