Skip to content
Open
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
8 changes: 7 additions & 1 deletion help.txt
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
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] [--label=NAME]
Launch Chrome (headless by default, --show for visible)
rodney connect <host:port> Connect to existing Chrome on remote debug port
rodney stop Shut down Chrome
rodney status Show browser status

Instance management:
rodney list List all discovered rodney instances
rodney cleanup Remove stale state for dead instances
rodney cleanup --all Stop all instances and remove state

Navigation:
rodney open <url> Navigate to URL
rodney back Go back in history
Expand Down
139 changes: 133 additions & 6 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ 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
Label string `json:"label,omitempty"` // optional label to identify this instance
}

func stateDir() string {
Expand Down Expand Up @@ -154,6 +155,123 @@ func getActivePage(browser *rod.Browser, s *State) (*rod.Page, error) {
return pages[idx], nil
}

// pidAlive checks whether a process with the given PID is still running.
func pidAlive(pid int) bool {
return syscall.Kill(pid, 0) == nil
}

// listEntry represents a discovered rodney instance.
type listEntry struct {
Dir string
State *State
Alive bool
}

// discoverInstances scans known locations for rodney state files.
func discoverInstances() []listEntry {
var entries []listEntry
seen := map[string]bool{}

tryDir := func(dir string) {
sp := filepath.Join(dir, "state.json")
if seen[sp] {
return
}
seen[sp] = true
data, err := os.ReadFile(sp)
if err != nil {
return
}
var s State
if err := json.Unmarshal(data, &s); err != nil {
return
}
entries = append(entries, listEntry{
Dir: dir,
State: &s,
Alive: s.ChromePID > 0 && pidAlive(s.ChromePID),
})
}

// Global default
home, _ := os.UserHomeDir()
tryDir(filepath.Join(home, ".rodney"))

// RODNEY_HOME override
if rh := os.Getenv("RODNEY_HOME"); rh != "" {
tryDir(rh)
}

// Local session in cwd
if cwd, err := os.Getwd(); err == nil {
tryDir(filepath.Join(cwd, ".rodney"))
}

// Scan /tmp/rodney-* for RODNEY_HOME-based sessions
matches, _ := filepath.Glob("/tmp/rodney-*")
for _, m := range matches {
tryDir(m)
}

return entries
}

func cmdList(args []string) {
entries := discoverInstances()
if len(entries) == 0 {
fmt.Println("No rodney instances found")
return
}
for _, e := range entries {
status := "alive"
if !e.Alive {
status = "dead"
}
label := ""
if e.State.Label != "" {
label = fmt.Sprintf(" label=%s", e.State.Label)
}
fmt.Printf("%-30s pid=%-8d %s%s\n", e.Dir, e.State.ChromePID, status, label)
}
}

func cmdCleanup(args []string) {
all := false
for _, a := range args {
if a == "--all" {
all = true
}
}

entries := discoverInstances()
if len(entries) == 0 {
fmt.Println("No rodney instances found")
return
}

cleaned := 0
for _, e := range entries {
if e.Alive && !all {
continue
}
if e.Alive {
// --all: kill the Chrome process
syscall.Kill(e.State.ChromePID, syscall.SIGTERM)
if e.State.ProxyPID > 0 {
syscall.Kill(e.State.ProxyPID, syscall.SIGTERM)
}
fmt.Printf("Stopped: %s (pid=%d label=%s)\n", e.Dir, e.State.ChromePID, e.State.Label)
} else {
fmt.Printf("Cleaned: %s (pid=%d label=%s)\n", e.Dir, e.State.ChromePID, e.State.Label)
}
os.Remove(filepath.Join(e.Dir, "state.json"))
cleaned++
}
if cleaned == 0 {
fmt.Println("Nothing to clean up")
}
}

func printUsage() {
fmt.Print(helpText)
}
Expand Down Expand Up @@ -293,6 +411,10 @@ func main() {
cmdAXFind(args)
case "ax-node":
cmdAXNode(args)
case "list":
cmdList(args)
case "cleanup":
cmdCleanup(args)
case "help", "-h", "--help":
printUsage()
os.Exit(0)
Expand Down Expand Up @@ -337,26 +459,27 @@ func withPage() (*State, *rod.Browser, *rod.Page) {
// --- Commands ---

// 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) {
// Returns ignoreCertErrors, headless, label, and an error for unknown flags.
func parseStartArgs(args []string) (ignoreCertErrors bool, headless bool, label string, 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.StringVar(&label, "label", "", "")

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, "", fmt.Errorf("unknown flag: %s\nusage: rodney start [--show] [--insecure] [--label=NAME]", 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, "", fmt.Errorf("unknown flag: %s\nusage: rodney start [--show] [--insecure] [--label=NAME]", fs.Arg(0))
}
headless = !*show
return ignoreCertErrors, headless, nil
return ignoreCertErrors, headless, label, nil
}

func cmdStart(args []string) {
ignoreCertErrors, headless, err := parseStartArgs(args)
ignoreCertErrors, headless, label, err := parseStartArgs(args)
if err != nil {
fatal("%s", err)
}
Expand Down Expand Up @@ -441,6 +564,7 @@ func cmdStart(args []string) {
DataDir: dataDir,
ProxyPID: proxyPID,
ProxyPort: proxyPort,
Label: label,
}

if err := saveState(state); err != nil {
Expand Down Expand Up @@ -540,6 +664,9 @@ func cmdStatus(args []string) {
pages, _ := browser.Pages()
fmt.Printf("Browser running (PID %d)\n", s.ChromePID)
fmt.Printf("Debug URL: %s\n", s.DebugURL)
if s.Label != "" {
fmt.Printf("Label: %s\n", s.Label)
}
fmt.Printf("Pages: %d\n", len(pages))
fmt.Printf("Active page: %d\n", s.ActivePage)
if page, err := getActivePage(browser, s); err == nil {
Expand Down
112 changes: 106 additions & 6 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1079,7 +1079,7 @@ func TestFormatAssertFail_EqualityWithMessage(t *testing.T) {
// =====================

func TestParseStartArgs_NoFlags(t *testing.T) {
insecure, headless, err := parseStartArgs([]string{})
insecure, headless, label, err := parseStartArgs([]string{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand All @@ -1089,10 +1089,13 @@ func TestParseStartArgs_NoFlags(t *testing.T) {
if !headless {
t.Error("expected headless=true with no flags")
}
if label != "" {
t.Errorf("expected empty label, got %q", label)
}
}

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)
}
Expand All @@ -1105,7 +1108,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)
}
Expand All @@ -1118,7 +1121,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)
}
Expand All @@ -1128,7 +1131,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)
}
Expand All @@ -1141,7 +1144,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")
}
Expand All @@ -1150,6 +1153,103 @@ func TestParseStartArgs_UnknownFlag(t *testing.T) {
}
}

func TestParseStartArgs_LabelFlag(t *testing.T) {
_, _, label, err := parseStartArgs([]string{"--label=my-session"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if label != "my-session" {
t.Errorf("expected label 'my-session', got %q", label)
}
}

func TestParseStartArgs_LabelFlagSeparate(t *testing.T) {
_, _, label, err := parseStartArgs([]string{"--label", "my-session"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if label != "my-session" {
t.Errorf("expected label 'my-session', got %q", label)
}
}

func TestParseStartArgs_LabelWithOtherFlags(t *testing.T) {
insecure, headless, label, err := parseStartArgs([]string{"--show", "--label=test", "--insecure"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !insecure {
t.Error("expected insecure=true")
}
if headless {
t.Error("expected headless=false")
}
if label != "test" {
t.Errorf("expected label 'test', got %q", label)
}
}

func TestStateLabelSerialization(t *testing.T) {
s := &State{
DebugURL: "ws://127.0.0.1:9222",
ChromePID: 12345,
ActivePage: 0,
DataDir: "/tmp/test",
Label: "my-label",
}
data, err := json.Marshal(s)
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.Label != "my-label" {
t.Errorf("expected label 'my-label', got %q", loaded.Label)
}
}

func TestStateLabelOmittedWhenEmpty(t *testing.T) {
s := &State{
DebugURL: "ws://127.0.0.1:9222",
ChromePID: 12345,
ActivePage: 0,
DataDir: "/tmp/test",
}
data, err := json.Marshal(s)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
if strings.Contains(string(data), "label") {
t.Errorf("label should be omitted when empty, got: %s", data)
}
}

func TestDiscoverInstances_FindsTempDirs(t *testing.T) {
dir := t.TempDir()
stateFile := filepath.Join(dir, "state.json")
data := `{"debug_url":"ws://127.0.0.1:9222","chrome_pid":99999,"active_page":0,"data_dir":"/tmp","label":"test-discover"}`
os.WriteFile(stateFile, []byte(data), 0644)

// Set RODNEY_HOME to point to our temp dir
t.Setenv("RODNEY_HOME", dir)

entries := discoverInstances()
found := false
for _, e := range entries {
if e.Dir == dir && e.State.Label == "test-discover" {
found = true
if e.Alive {
t.Error("expected dead instance (pid 99999 should not exist)")
}
}
}
if !found {
t.Errorf("expected to discover instance in %s", dir)
}
}

func TestInsecureFlag_WithSelfSignedCert(t *testing.T) {
// Create HTTPS server with self-signed certificate
mux := http.NewServeMux()
Expand Down