From 733e09aea105170190f49428816f15a7ae4a7024 Mon Sep 17 00:00:00 2001 From: Gunnlaugur Thor Briem Date: Tue, 17 Feb 2026 23:37:09 +0000 Subject: [PATCH 01/30] Fix broken link in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3cb2e26..040b7fe 100644 --- a/README.md +++ b/README.md @@ -381,7 +381,7 @@ In environments with authenticated HTTP proxies (e.g., `HTTPS_PROXY=http://user: This is necessary because Chrome cannot natively authenticate to proxies during HTTPS tunnel (CONNECT) establishment. The local proxy runs as a background process and is automatically cleaned up by `rodney stop`. -See [claude-code-chrome-proxy.md](claude-code-chrome-proxy.md) for detailed technical notes. +See [notes/claude-chrome-proxy](notes/claude-chrome-proxy) for detailed technical notes. ## How it works From 67a831f03f4ce15ec9b75b484fccbb4fd92cb8d1 Mon Sep 17 00:00:00 2001 From: Jacob Burch-Hill Date: Tue, 17 Feb 2026 21:20:58 -0600 Subject: [PATCH 02/30] Fix `rodney start --show` flag parsing bug The --show flag was documented in help.txt but rejected at runtime. Two sequential arg-parsing loops meant the first loop called fatal() on --show before the second loop (which handled it) could run. Extract parseStartFlags() with a single merged loop and add 6 tests. Fixes #23 Co-Authored-By: Claude Opus 4.6 --- main.go | 37 ++++++++++++++++---------- main_test.go | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 13 deletions(-) diff --git a/main.go b/main.go index 0b2531a..79bb8a6 100644 --- a/main.go +++ b/main.go @@ -318,16 +318,33 @@ func withPage() (*State, *rod.Browser, *rod.Page) { // --- Commands --- -func cmdStart(args []string) { - ignoreCertErrors := false - for i := 0; i < len(args); i++ { - switch args[i] { +type startFlags struct { + headless bool + ignoreCertErrors bool +} + +// parseStartFlags parses the arguments to "rodney start". +func parseStartFlags(args []string) (startFlags, error) { + f := startFlags{headless: true} + for _, arg := range args { + switch arg { + case "--show": + f.headless = false case "--insecure", "-k": - ignoreCertErrors = true + f.ignoreCertErrors = true default: - fatal("unknown flag: %s\nusage: rodney start [--insecure]", args[i]) + return f, fmt.Errorf("unknown flag: %s\nusage: rodney start [--show] [--insecure]", arg) } } + return f, nil +} + +func cmdStart(args []string) { + flags, err := parseStartFlags(args) + if err != nil { + fatal("%s", err) + } + ignoreCertErrors := flags.ignoreCertErrors // Check if already running if s, err := loadState(); err == nil { @@ -339,13 +356,7 @@ func cmdStart(args []string) { } } - // Parse flags - headless := true - for _, arg := range args { - if arg == "--show" { - headless = false - } - } + headless := flags.headless dataDir := filepath.Join(stateDir(), "chrome-data") os.MkdirAll(dataDir, 0755) diff --git a/main_test.go b/main_test.go index 79ee87c..1de1b12 100644 --- a/main_test.go +++ b/main_test.go @@ -1148,3 +1148,76 @@ func TestInsecureFlag_WithSelfSignedCert(t *testing.T) { } }) } + +// ===================== +// parseStartFlags tests +// ===================== + +func TestParseStartFlags_ShowFlag(t *testing.T) { + flags, err := parseStartFlags([]string{"--show"}) + if err != nil { + t.Fatalf("--show should be accepted, got error: %v", err) + } + if flags.headless { + t.Error("expected headless=false when --show is passed") + } +} + +func TestParseStartFlags_ShowAndInsecure(t *testing.T) { + flags, err := parseStartFlags([]string{"--show", "--insecure"}) + if err != nil { + t.Fatalf("--show --insecure should be accepted, got error: %v", err) + } + if flags.headless { + t.Error("expected headless=false when --show is passed") + } + if !flags.ignoreCertErrors { + t.Error("expected ignoreCertErrors=true when --insecure is passed") + } +} + +func TestParseStartFlags_InsecureOnly(t *testing.T) { + flags, err := parseStartFlags([]string{"--insecure"}) + if err != nil { + t.Fatalf("--insecure should be accepted, got error: %v", err) + } + if !flags.headless { + t.Error("expected headless=true (default) when --show is not passed") + } + if !flags.ignoreCertErrors { + t.Error("expected ignoreCertErrors=true when --insecure is passed") + } +} + +func TestParseStartFlags_KShorthand(t *testing.T) { + flags, err := parseStartFlags([]string{"-k"}) + if err != nil { + t.Fatalf("-k should be accepted, got error: %v", err) + } + if !flags.ignoreCertErrors { + t.Error("expected ignoreCertErrors=true when -k is passed") + } +} + +func TestParseStartFlags_NoArgs(t *testing.T) { + flags, err := parseStartFlags([]string{}) + if err != nil { + t.Fatalf("no args should be accepted, got error: %v", err) + } + if !flags.headless { + t.Error("expected headless=true by default") + } + if flags.ignoreCertErrors { + t.Error("expected ignoreCertErrors=false by default") + } +} + +func TestParseStartFlags_UnknownFlag(t *testing.T) { + _, err := parseStartFlags([]string{"--bogus"}) + if err == nil { + t.Fatal("expected error for unknown flag --bogus") + } + if !strings.Contains(err.Error(), "unknown flag: --bogus") { + t.Errorf("expected 'unknown flag: --bogus' in error, got: %v", err) + } +} From 2d23ae055d8909df93f80313d8410998bb709ad4 Mon Sep 17 00:00:00 2001 From: Ben Phillips Date: Wed, 18 Feb 2026 04:23:02 +0000 Subject: [PATCH 03/30] Add installation instructions --- README.md | 46 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 3cb2e26..4802982 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,42 @@ A Go CLI tool that drives a persistent headless Chrome instance using the [rod](https://github.com/go-rod/rod) browser automation library. Each command connects to the same long-running Chrome process, making it easy to script multi-step browser interactions from shell scripts or interactive use. +## Installation + +This Go tool can be installed directly [from PyPI](https://pypi.org/project/rodney/) using `pip` or `uv`. + +You can run it without installing it first using `uvx`: + +```bash +uvx rodney --help +``` +Or install it like this, then run `rodney --help`: +```bash +uv tool install rodney +# or +pip install rodney +``` + +You can also install the Go binary directly: +```bash +go install github.com/simonw/rodney@latest +``` +Or run it without installation like this: +```bash +go run github.com/simonw/rodney@latest --help +``` +Compiled binaries are available [on the releases page](https://github.com/simonw/rodney/releases). On macOS you may need to [follow these extra steps](https://support.apple.com/en-us/102445) to use those. + +## Building + +```bash +go build -o rodney . +``` + +Requires: +- Go 1.21+ +- Google Chrome or Chromium installed (or set `ROD_CHROME_BIN=/path/to/chrome`) + ## Architecture ``` @@ -26,16 +62,6 @@ rodney stop → connects and shuts down Chrome, cleans up state Each CLI invocation is a short-lived process. Chrome runs independently and tabs persist between commands. -## Building - -```bash -go build -o rodney . -``` - -Requires: -- Go 1.21+ -- Google Chrome or Chromium installed (or set `ROD_CHROME_BIN=/path/to/chrome`) - ## Usage ### Start/stop the browser From a2bb3d7246ee87c710fd445b77dd1a8b537d8c0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sk=C3=B6ld?= Date: Wed, 18 Feb 2026 20:35:27 +0100 Subject: [PATCH 04/30] Add stdin support for `rodney js` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pipe a JS expression directly via stdin instead of passing it as a shell argument — useful for complex expressions, multi-line scripts, and heredocs. echo 'document.title' | rodney js cat script.js | rodney js rodney js - < expr.js Pass `-` as the argument to read from stdin explicitly (consistent with `rodney file`). When no argument is given, stdin is read only if it is a pipe; an interactive terminal still shows the usage message. Adds four integration tests that exercise no-arg piping, the `-` flag, multi-line / heredoc-style input, and whitespace trimming. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 7 ++- help.txt | 2 +- main.go | 21 +++++++-- main_test.go | 124 +++++++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 146 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 3cb2e26..90a56cc 100644 --- a/README.md +++ b/README.md @@ -81,10 +81,15 @@ rodney js "1 + 2" # Math rodney js 'document.querySelector("h1").textContent' # DOM queries rodney js '[1,2,3].map(x => x * 2)' # Returns pretty-printed JSON rodney js 'document.querySelectorAll("a").length' # Count elements +echo 'document.title' | rodney js # Read expression from stdin +cat script.js | rodney js # Execute a JS file via stdin +rodney js - # Read from stdin explicitly ``` The expression is automatically wrapped in `() => { return (expr); }`. +When no argument is given and stdin is piped, the expression is read from stdin. Pass `-` as the argument to read from stdin explicitly. + ### Interact with elements ```bash @@ -418,7 +423,7 @@ The tool uses the [rod](https://github.com/go-rod/rod) Go library which communic | `text` | `` | Print element text content | | `attr` | ` ` | Print attribute value | | `pdf` | `[file]` | Save page as PDF | -| `js` | `` | Evaluate JavaScript | +| `js` | `\|-` | Evaluate JavaScript (`-` or no arg reads from stdin) | | `click` | `` | Click element | | `input` | ` ` | Type into input | | `clear` | `` | Clear input | diff --git a/help.txt b/help.txt index 79bac7f..9c1f2ce 100644 --- a/help.txt +++ b/help.txt @@ -22,7 +22,7 @@ Page info: rodney pdf [file] Save page as PDF Interaction: - rodney js Evaluate JavaScript expression + rodney js |- Evaluate JavaScript expression (- reads from stdin) rodney click Click an element rodney input Type text into an input field rodney clear Clear an input field diff --git a/main.go b/main.go index 0b2531a..41735ca 100644 --- a/main.go +++ b/main.go @@ -715,10 +715,25 @@ func cmdPDF(args []string) { } func cmdJS(args []string) { - if len(args) < 1 { - fatal("usage: rodney js ") + var expr string + if len(args) == 0 || (len(args) == 1 && args[0] == "-") { + if len(args) == 0 { + // Only read from stdin automatically if it's piped (not a terminal) + if stat, err := os.Stdin.Stat(); err != nil || (stat.Mode()&os.ModeCharDevice) != 0 { + fatal("usage: rodney js ") + } + } + data, err := io.ReadAll(os.Stdin) + if err != nil { + fatal("failed to read stdin: %v", err) + } + expr = strings.TrimSpace(string(data)) + if expr == "" { + fatal("empty expression from stdin") + } + } else { + expr = strings.Join(args, " ") } - expr := strings.Join(args, " ") _, _, page := withPage() // Wrap bare expressions in a function diff --git a/main_test.go b/main_test.go index 79ee87c..3478ffa 100644 --- a/main_test.go +++ b/main_test.go @@ -21,8 +21,9 @@ import ( // testEnv holds a shared browser and test HTTP server for all tests. type testEnv struct { - browser *rod.Browser - server *httptest.Server + browser *rod.Browser + server *httptest.Server + debugURL string // WebSocket debug URL for setting up state in cmdXxx tests } var env *testEnv @@ -53,7 +54,7 @@ func TestMain(m *testing.M) { mux.HandleFunc("/empty", handleEmpty) server := httptest.NewServer(mux) - env = &testEnv{browser: browser, server: server} + env = &testEnv{browser: browser, server: server, debugURL: u} code := m.Run() @@ -1074,6 +1075,123 @@ func TestFormatAssertFail_EqualityWithMessage(t *testing.T) { } } +// ====================== +// cmdJS stdin tests +// ====================== + +// setupCmdJSState navigates to path on the test server, writes a state.json +// in a temp dir pointing at that page, and restores activeStateDir on cleanup. +func setupCmdJSState(t *testing.T, path string) { + t.Helper() + + tmpDir := t.TempDir() + oldStateDir := activeStateDir + activeStateDir = tmpDir + t.Cleanup(func() { activeStateDir = oldStateDir }) + + page := env.browser.MustPage(env.server.URL + path) + page.MustWaitLoad() + t.Cleanup(func() { page.MustClose() }) + + // Find the page's index in the browser's page list. + pages, err := env.browser.Pages() + if err != nil { + t.Fatalf("failed to list pages: %v", err) + } + idx := 0 + for i, p := range pages { + if p.TargetID == page.TargetID { + idx = i + break + } + } + + if err := saveState(&State{DebugURL: env.debugURL, ActivePage: idx}); err != nil { + t.Fatalf("saveState: %v", err) + } +} + +// pipeStdin replaces os.Stdin with a pipe containing content and restores it on cleanup. +func pipeStdin(t *testing.T, content string) { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + if _, err := w.WriteString(content); err != nil { + t.Fatalf("pipeStdin write: %v", err) + } + w.Close() + oldStdin := os.Stdin + os.Stdin = r + t.Cleanup(func() { os.Stdin = oldStdin }) +} + +// captureStdout captures everything written to os.Stdout by fn, trimming trailing whitespace. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + oldStdout := os.Stdout + os.Stdout = w + fn() + w.Close() + os.Stdout = oldStdout + out, err := io.ReadAll(r) + if err != nil { + t.Fatalf("captureStdout read: %v", err) + } + return strings.TrimSpace(string(out)) +} + +// TestCmdJS_Stdin_NoArgs verifies that piping an expression to `rodney js` +// with no arguments reads and evaluates it from stdin. +func TestCmdJS_Stdin_NoArgs(t *testing.T) { + setupCmdJSState(t, "/") + pipeStdin(t, "document.title\n") + got := captureStdout(t, func() { cmdJS([]string{}) }) + if got != "Test Page" { + t.Errorf("expected 'Test Page', got %q", got) + } +} + +// TestCmdJS_Stdin_DashArg verifies that `rodney js -` reads the expression from stdin, +// consistent with the `-` convention used by `rodney file`. +func TestCmdJS_Stdin_DashArg(t *testing.T) { + setupCmdJSState(t, "/") + pipeStdin(t, "document.title\n") + got := captureStdout(t, func() { cmdJS([]string{"-"}) }) + if got != "Test Page" { + t.Errorf("expected 'Test Page', got %q", got) + } +} + +// TestCmdJS_Stdin_MultiLine verifies multi-line input works — this is exactly what +// a heredoc produces (bash sends the lines as a single stdin stream with newlines). +func TestCmdJS_Stdin_MultiLine(t *testing.T) { + setupCmdJSState(t, "/") + // Heredoc-style: expression split across lines with trailing newline. + // `1 +\n2\n` is trimmed to `1 +\n2` and wrapped in `() => { return (1 +\n2); }`. + pipeStdin(t, "1 +\n2\n") + got := captureStdout(t, func() { cmdJS([]string{}) }) + if got != "3" { + t.Errorf("expected '3', got %q", got) + } +} + +// TestCmdJS_Stdin_TrimsWhitespace verifies that leading/trailing whitespace +// (including the trailing newline added by echo or heredoc) is stripped. +func TestCmdJS_Stdin_TrimsWhitespace(t *testing.T) { + setupCmdJSState(t, "/") + pipeStdin(t, " 1 + 2 \n") + got := captureStdout(t, func() { cmdJS([]string{}) }) + if got != "3" { + t.Errorf("expected '3', got %q", got) + } +} + func TestInsecureFlag_WithSelfSignedCert(t *testing.T) { // Create HTTPS server with self-signed certificate mux := http.NewServeMux() From 8a018f961e2e36919f2e50c81f897a2d26e6df89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sk=C3=B6ld?= Date: Thu, 19 Feb 2026 09:58:15 +0100 Subject: [PATCH 05/30] Add stdin support for `rodney assert` --- help.txt | 2 +- main.go | 52 +++++++++++++++++++++++++++++++++--- main_test.go | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 4 deletions(-) diff --git a/help.txt b/help.txt index 9c1f2ce..c67fe9c 100644 --- a/help.txt +++ b/help.txt @@ -54,7 +54,7 @@ Element checks: rodney exists Check if element exists (exit 1 if not) rodney count Count matching elements rodney visible Check if element is visible (exit 1 if not) - rodney assert [expected] [-m msg] Assert JS expression (truthy or equality) + rodney assert |- [expected] [-m msg] Assert JS expression (truthy or equality; - reads expr from stdin) Accessibility: rodney ax-tree [--depth N] [--json] Dump accessibility tree diff --git a/main.go b/main.go index 41735ca..6528cf1 100644 --- a/main.go +++ b/main.go @@ -1446,11 +1446,57 @@ func formatAssertFail(actual string, expected *string, message string) string { return fmt.Sprintf("fail: got %s", actual) } -func cmdAssert(args []string) { - if len(args) < 1 { - fatal("usage: rodney assert [expected] [--message msg]") +// resolveAssertArgs resolves stdin for `rodney assert`, returning a normalized args +// slice where the first positional is always the JS expression string (never "-"). +// It mirrors the stdin-detection logic from cmdJS: "-" reads stdin explicitly; +// no positional args auto-reads stdin when piped; otherwise falls through unchanged. +func resolveAssertArgs(args []string) []string { + // Find index of first positional arg (skipping -m/--message pairs). + firstPosIdx := -1 + for i := 0; i < len(args); i++ { + if args[i] == "--message" || args[i] == "-m" { + i++ // skip flag value + continue + } + firstPosIdx = i + break } + if firstPosIdx >= 0 && args[firstPosIdx] == "-" { + // Explicit stdin: replace "-" with expression read from stdin. + data, err := io.ReadAll(os.Stdin) + if err != nil { + fatal("failed to read stdin: %v", err) + } + expr := strings.TrimSpace(string(data)) + if expr == "" { + fatal("empty expression from stdin") + } + newArgs := make([]string, len(args)) + copy(newArgs, args) + newArgs[firstPosIdx] = expr + return newArgs + } else if firstPosIdx == -1 { + // No positional args — auto-read from stdin only if it's piped. + if stat, err := os.Stdin.Stat(); err != nil || (stat.Mode()&os.ModeCharDevice) != 0 { + fatal("usage: rodney assert [expected] [--message msg]") + } + data, err := io.ReadAll(os.Stdin) + if err != nil { + fatal("failed to read stdin: %v", err) + } + expr := strings.TrimSpace(string(data)) + if expr == "" { + fatal("empty expression from stdin") + } + return append([]string{expr}, args...) + } + return args +} + +func cmdAssert(args []string) { + args = resolveAssertArgs(args) + expr, expected, message := parseAssertArgs(args) if expr == "" { fatal("usage: rodney assert [expected] [--message msg]") diff --git a/main_test.go b/main_test.go index 3478ffa..31ce6c4 100644 --- a/main_test.go +++ b/main_test.go @@ -1192,6 +1192,80 @@ func TestCmdJS_Stdin_TrimsWhitespace(t *testing.T) { } } +// ====================== +// cmdAssert stdin tests +// ====================== + +// TestCmdAssert_Stdin_NoArgs verifies that piping a JS expression to `rodney assert` +// with no other args reads the expression from stdin. +func TestCmdAssert_Stdin_NoArgs(t *testing.T) { + pipeStdin(t, "document.title\n") + got := resolveAssertArgs([]string{}) + if len(got) == 0 || got[0] != "document.title" { + t.Errorf("expected args[0] == 'document.title', got %v", got) + } +} + +// TestCmdAssert_Stdin_DashArg verifies that `rodney assert -` reads the expression +// from stdin explicitly, matching the `-` convention used by `rodney js` and `rodney file`. +func TestCmdAssert_Stdin_DashArg(t *testing.T) { + pipeStdin(t, "document.title\n") + got := resolveAssertArgs([]string{"-"}) + if len(got) == 0 || got[0] != "document.title" { + t.Errorf("expected args[0] == 'document.title', got %v", got) + } +} + +// TestCmdAssert_Stdin_WithExpected verifies that the expression comes from stdin +// while the expected value still comes from command-line args. +// Equivalent to: echo "document.title" | rodney assert - "Test Page" +func TestCmdAssert_Stdin_WithExpected(t *testing.T) { + pipeStdin(t, "document.title\n") + got := resolveAssertArgs([]string{"-", "Test Page"}) + if len(got) != 2 || got[0] != "document.title" || got[1] != "Test Page" { + t.Errorf("expected [document.title Test Page], got %v", got) + } +} + +// TestCmdAssert_Stdin_WithMessage verifies that the expression comes from stdin +// while the -m flag still comes from command-line args. +// Equivalent to: echo "document.title" | rodney assert - -m "page title" +func TestCmdAssert_Stdin_WithMessage(t *testing.T) { + pipeStdin(t, "document.title\n") + got := resolveAssertArgs([]string{"-", "-m", "page title"}) + if len(got) != 3 || got[0] != "document.title" || got[1] != "-m" || got[2] != "page title" { + t.Errorf("expected [document.title -m page title], got %v", got) + } +} + +// TestCmdAssert_Stdin_FlagsOnly verifies that when only flags are given (no positional) +// and stdin is piped, the expression is prepended from stdin. +func TestCmdAssert_Stdin_FlagsOnly(t *testing.T) { + pipeStdin(t, "document.title\n") + got := resolveAssertArgs([]string{"-m", "check"}) + if len(got) != 3 || got[0] != "document.title" || got[1] != "-m" || got[2] != "check" { + t.Errorf("expected [document.title -m check], got %v", got) + } +} + +// TestCmdAssert_Stdin_Passthrough verifies that normal (non-stdin) args are unchanged. +func TestCmdAssert_Stdin_Passthrough(t *testing.T) { + got := resolveAssertArgs([]string{"document.title", "Test Page"}) + if len(got) != 2 || got[0] != "document.title" || got[1] != "Test Page" { + t.Errorf("expected [document.title Test Page], got %v", got) + } +} + +// TestCmdAssert_Stdin_TrimsWhitespace verifies leading/trailing whitespace is stripped +// from the stdin expression (consistent with cmdJS behavior). +func TestCmdAssert_Stdin_TrimsWhitespace(t *testing.T) { + pipeStdin(t, " 1 + 2 \n") + got := resolveAssertArgs([]string{"-"}) + if len(got) == 0 || got[0] != "1 + 2" { + t.Errorf("expected args[0] == '1 + 2', got %v", got) + } +} + func TestInsecureFlag_WithSelfSignedCert(t *testing.T) { // Create HTTPS server with self-signed certificate mux := http.NewServeMux() From 76e1a060d3ba10ccb0a319be4d43f26916315f71 Mon Sep 17 00:00:00 2001 From: Jacob Burch-Hill Date: Thu, 19 Feb 2026 08:37:19 -0600 Subject: [PATCH 06/30] Fix usage string to show -k shorthand, matching help.txt Co-Authored-By: Claude Opus 4.6 --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index 79bb8a6..fbb121e 100644 --- a/main.go +++ b/main.go @@ -333,7 +333,7 @@ func parseStartFlags(args []string) (startFlags, error) { case "--insecure", "-k": f.ignoreCertErrors = true default: - return f, fmt.Errorf("unknown flag: %s\nusage: rodney start [--show] [--insecure]", arg) + return f, fmt.Errorf("unknown flag: %s\nusage: rodney start [--show] [--insecure | -k]", arg) } } return f, nil From 8595de88243aa13c40be296bb098c4f75eac2dcd Mon Sep 17 00:00:00 2001 From: Ed Salkeld Date: Mon, 23 Feb 2026 17:33:13 +0000 Subject: [PATCH 07/30] Add --fake-media flag to prevent crashes on pages that request microphone/camera Passes --use-fake-device-for-media-stream and --use-fake-ui-for-media-stream to Chrome, which provides synthetic media streams instead of prompting for real device access. Without this, headless Chrome crashes when a page calls getUserMedia(). --- main.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index 0b2531a..be209db 100644 --- a/main.go +++ b/main.go @@ -320,12 +320,15 @@ func withPage() (*State, *rod.Browser, *rod.Page) { func cmdStart(args []string) { ignoreCertErrors := false + fakeMedia := false for i := 0; i < len(args); i++ { switch args[i] { case "--insecure", "-k": ignoreCertErrors = true + case "--fake-media": + fakeMedia = true default: - fatal("unknown flag: %s\nusage: rodney start [--insecure]", args[i]) + fatal("unknown flag: %s\nusage: rodney start [--insecure] [--fake-media]", args[i]) } } @@ -405,6 +408,11 @@ func cmdStart(args []string) { l.Set("ignore-certificate-errors") } + if fakeMedia { + l.Set("use-fake-device-for-media-stream") + l.Set("use-fake-ui-for-media-stream") + } + debugURL := l.MustLaunch() // Get Chrome PID from the launcher From 072bd70767a0595c3f8dee5e3e4117c88dea8b3b Mon Sep 17 00:00:00 2001 From: Ed Salkeld Date: Tue, 24 Feb 2026 09:56:02 +0000 Subject: [PATCH 08/30] Document --fake-media flag in README --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3cb2e26..36b1f09 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 --fake-media # Launch with fake media devices (avoids getUserMedia crashes) 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 @@ -403,7 +404,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] [--fake-media]` | 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 | From 85dc389a0114a4570c75690dacf92c1e95878985 Mon Sep 17 00:00:00 2001 From: John Wards Date: Sat, 28 Feb 2026 14:13:08 +0000 Subject: [PATCH 09/30] Add `rodney viewport` command and `--viewport` flag for mobile emulation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds viewport/mobile emulation via Chrome's EmulationSetDeviceMetricsOverride CDP call: - `rodney viewport [--scale N] [--mobile]` — set viewport size - `rodney viewport --reset` — restore browser defaults - `rodney start --viewport WxH [--mobile] [--scale N]` — configure viewport at launch Viewport settings are persisted in state.json and re-applied on each subsequent command, matching rodney's ephemeral-process architecture. Also fixes a pre-existing bug where `rodney start --show` would hit the unknown flag error before reaching the headless flag parser. All start flags are now parsed in a single pass. Co-Authored-By: Claude Opus 4.6 --- README.md | 15 +++- help.txt | 7 +- main.go | 201 ++++++++++++++++++++++++++++++++++++++++++++++---- main_test.go | 205 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 410 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 3cb2e26..61b6661 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 @@ -120,6 +121,16 @@ rodney screenshot -w 1280 -h 720 out.png # Set viewport width/height rodney screenshot-el ".chart" chart.png # Screenshot specific element ``` +### 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 +414,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 +446,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..77d7403 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,6 +1186,90 @@ 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 @@ -1145,7 +1314,7 @@ func cmdScreenshot(args []string) { _, _, page := withPage() - // Set viewport size + // Set viewport size for screenshot viewportHeight := height if viewportHeight == 0 { viewportHeight = 720 diff --git a/main_test.go b/main_test.go index 79ee87c..a273fa7 100644 --- a/main_test.go +++ b/main_test.go @@ -1074,6 +1074,211 @@ 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 TestInsecureFlag_WithSelfSignedCert(t *testing.T) { // Create HTTPS server with self-signed certificate mux := http.NewServeMux() From 7f3453817e77b761199b99d9732042ae1746e34d Mon Sep 17 00:00:00 2001 From: John Wards Date: Sat, 28 Feb 2026 14:19:06 +0000 Subject: [PATCH 10/30] Make screenshot respect active viewport set via `rodney viewport` Previously, `rodney screenshot` always overrode the viewport to 1280x720 (scale 1), clobbering any viewport set via `rodney viewport`. Now it only applies its default viewport when no viewport override is active or when -w/-h are explicitly passed. Co-Authored-By: Claude Opus 4.6 --- README.md | 4 +++- main.go | 37 +++++++++++++++++++++++-------------- main_test.go | 45 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 61b6661..ff0d288 100644 --- a/README.md +++ b/README.md @@ -117,10 +117,12 @@ 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 diff --git a/main.go b/main.go index 77d7403..76f8a65 100644 --- a/main.go +++ b/main.go @@ -1272,9 +1272,10 @@ func cmdViewport(args []string) { 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 @@ -1290,6 +1291,7 @@ func cmdScreenshot(args []string) { fatal("invalid width: %v", err) } width = v + sizeExplicit = true case "-h", "--height": i++ if i >= len(args) { @@ -1301,6 +1303,7 @@ func cmdScreenshot(args []string) { } height = v fullPage = false + sizeExplicit = true default: positional = append(positional, args[i]) } @@ -1312,20 +1315,26 @@ func cmdScreenshot(args []string) { file = nextAvailableFile("screenshot", ".png") } - _, _, page := withPage() + s, _, page := withPage() - // Set viewport size for screenshot - 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 a273fa7..4fff1df 100644 --- a/main_test.go +++ b/main_test.go @@ -1279,6 +1279,51 @@ func TestViewport_ResetClearsState(t *testing.T) { } } +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() From 3c5fc6050d8523ed905bf2074486021cd10797a2 Mon Sep 17 00:00:00 2001 From: Jonathan Stockdill Date: Mon, 9 Mar 2026 13:28:58 -0400 Subject: [PATCH 11/30] Add `rodney discover` command for listing data-testid elements Scans the current page for elements with a test attribute (default: data-testid) and outputs them grouped by type (Readable, Interactive, Hidden) with suggested rodney commands. Supports --json for structured output and --attr to use a custom attribute name. --- help.txt | 3 + main.go | 167 +++++++++++++++++++++++++++++++ main_test.go | 271 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 441 insertions(+) diff --git a/help.txt b/help.txt index 79bac7f..094f725 100644 --- a/help.txt +++ b/help.txt @@ -56,6 +56,9 @@ Element checks: rodney visible Check if element is visible (exit 1 if not) rodney assert [expected] [-m msg] Assert JS expression (truthy or equality) +Discovery: + rodney discover [--json] [--attr NAME] List data-testid elements with suggested commands + Accessibility: rodney ax-tree [--depth N] [--json] Dump accessibility tree rodney ax-find [--name N] [--role R] [--json] Find accessible nodes diff --git a/main.go b/main.go index 0b2531a..44f1f5c 100644 --- a/main.go +++ b/main.go @@ -275,6 +275,8 @@ func main() { cmdAXFind(args) case "ax-node": cmdAXNode(args) + case "discover": + cmdDiscover(args) case "help", "-h", "--help": printUsage() os.Exit(0) @@ -1606,6 +1608,171 @@ func cmdAXNode(args []string) { } } +type discoverEntry struct { + ID string `json:"id"` + Tag string `json:"tag"` + Action string `json:"action"` + Text string `json:"text"` + Visible bool `json:"visible"` +} + +// queryDiscoverEntries finds all elements with the given attribute and returns structured entries. +func queryDiscoverEntries(page *rod.Page, attrName string) ([]discoverEntry, error) { + js := fmt.Sprintf(`() => { + var results = []; + var els = document.querySelectorAll('[%s]'); + for (var i = 0; i < els.length; i++) { + var el = els[i]; + var id = el.getAttribute('%s'); + var tag = el.tagName.toLowerCase(); + var type = el.getAttribute('type') || ''; + var text = ''; + var visible = el.offsetParent !== null || el.style.display !== 'none'; + var action = 'text'; + + if (tag === 'input' || tag === 'textarea') { + action = 'input'; + text = el.placeholder || el.value || ''; + } else if (tag === 'select') { + action = 'select'; + var opts = []; + for (var j = 0; j < el.options.length; j++) opts.push(el.options[j].text); + text = opts.join(', '); + } else if (tag === 'button' || type === 'submit') { + action = 'click'; + text = el.textContent.trim().substring(0, 60); + } else if (tag === 'a') { + action = 'click'; + text = el.textContent.trim().substring(0, 40); + var href = el.getAttribute('href'); + if (href) text = text + ' -> ' + href; + } else if (tag === 'table') { + action = 'text'; + var headers = []; + el.querySelectorAll('th').forEach(function(th) { headers.push(th.textContent.trim()); }); + var rows = el.querySelectorAll('tbody tr').length; + text = headers.join(', ') + ' (' + rows + ' rows)'; + } else { + text = el.textContent.trim().substring(0, 60); + } + + results.push({ + id: id, + tag: tag, + action: action, + text: text, + visible: visible + }); + } + return results; + }`, attrName, attrName) + + result, err := page.Eval(js) + if err != nil { + return nil, fmt.Errorf("discover eval failed: %w", err) + } + + raw := result.Value.JSON("", "") + var entries []discoverEntry + if jsonErr := json.Unmarshal([]byte(raw), &entries); jsonErr != nil { + return nil, fmt.Errorf("failed to parse discover results: %w", jsonErr) + } + return entries, nil +} + +// formatDiscoverText formats discover entries as human-readable grouped output. +func formatDiscoverText(entries []discoverEntry, attrName, pageURL string) string { + var buf strings.Builder + fmt.Fprintf(&buf, "Page: %s\n\n", pageURL) + + type group struct { + label string + entries []discoverEntry + } + groups := []group{ + {"Readable", nil}, + {"Interactive", nil}, + {"Hidden", nil}, + } + for _, e := range entries { + if !e.Visible { + groups[2].entries = append(groups[2].entries, e) + } else if e.Action == "text" { + groups[0].entries = append(groups[0].entries, e) + } else { + groups[1].entries = append(groups[1].entries, e) + } + } + + sel := func(id string) string { + return fmt.Sprintf(`[%s="%s"]`, attrName, id) + } + + for _, g := range groups { + if len(g.entries) == 0 { + continue + } + fmt.Fprintf(&buf, "%s:\n", g.label) + for _, e := range g.entries { + display := e.Text + if len(display) > 40 { + display = display[:37] + "..." + } + cmd := "" + switch e.Action { + case "text": + cmd = fmt.Sprintf("rodney text '%s'", sel(e.ID)) + case "input": + cmd = fmt.Sprintf("rodney input '%s' \"\"", sel(e.ID)) + case "click": + cmd = fmt.Sprintf("rodney click '%s'", sel(e.ID)) + case "select": + cmd = fmt.Sprintf("rodney select '%s' \"\"", sel(e.ID)) + } + fmt.Fprintf(&buf, " %-22s %-42s %s\n", e.ID, display, cmd) + } + fmt.Fprintln(&buf) + } + return buf.String() +} + +func cmdDiscover(args []string) { + jsonOutput := false + attrName := "data-testid" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--json": + jsonOutput = true + case "--attr": + if i+1 >= len(args) { + fatal("--attr requires a value") + } + i++ + attrName = args[i] + } + } + + _, _, page := withPage() + info, _ := page.Info() + + entries, err := queryDiscoverEntries(page, attrName) + if err != nil { + fatal("%v", err) + } + + if jsonOutput { + out, _ := json.MarshalIndent(entries, "", " ") + fmt.Println(string(out)) + return + } + + url := "" + if info != nil { + url = info.URL + } + fmt.Print(formatDiscoverText(entries, attrName, url)) +} + // queryAXNodes uses Accessibility.queryAXTree to find nodes by name and/or role. func queryAXNodes(page *rod.Page, name, role string) ([]*proto.AccessibilityAXNode, error) { // Get the document node to use as query root diff --git a/main_test.go b/main_test.go index 79ee87c..5de20ec 100644 --- a/main_test.go +++ b/main_test.go @@ -51,6 +51,7 @@ func TestMain(m *testing.M) { mux.HandleFunc("/download", handleDownload) mux.HandleFunc("/testfile.txt", handleTestFile) mux.HandleFunc("/empty", handleEmpty) + mux.HandleFunc("/discover", handleDiscover) server := httptest.NewServer(mux) env = &testEnv{browser: browser, server: server} @@ -1148,3 +1149,273 @@ func TestInsecureFlag_WithSelfSignedCert(t *testing.T) { } }) } + +// ===================== +// discover command tests +// ===================== + +func handleDiscover(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(` + +Discover Page + +

Dashboard

+

All systems operational

+ + + + Help + +
Hidden content
+ + + +
NameStatus
Item 1OK
Item 2Fail
+ Custom attr element + +`)) +} + +func TestDiscover_FindsTestIDElements(t *testing.T) { + page := navigateTo(t, "/discover") + entries, err := queryDiscoverEntries(page, "data-testid") + if err != nil { + t.Fatalf("queryDiscoverEntries failed: %v", err) + } + if len(entries) < 8 { + t.Fatalf("expected at least 8 entries, got %d", len(entries)) + } +} + +func TestDiscover_ButtonAction(t *testing.T) { + page := navigateTo(t, "/discover") + entries, err := queryDiscoverEntries(page, "data-testid") + if err != nil { + t.Fatalf("queryDiscoverEntries failed: %v", err) + } + found := false + for _, e := range entries { + if e.ID == "submit-btn" { + found = true + if e.Action != "click" { + t.Errorf("button action should be 'click', got %q", e.Action) + } + if e.Tag != "button" { + t.Errorf("button tag should be 'button', got %q", e.Tag) + } + if e.Text != "Submit" { + t.Errorf("button text should be 'Submit', got %q", e.Text) + } + } + } + if !found { + t.Error("submit-btn not found in discover entries") + } +} + +func TestDiscover_InputAction(t *testing.T) { + page := navigateTo(t, "/discover") + entries, err := queryDiscoverEntries(page, "data-testid") + if err != nil { + t.Fatalf("queryDiscoverEntries failed: %v", err) + } + for _, e := range entries { + if e.ID == "search" { + if e.Action != "input" { + t.Errorf("input action should be 'input', got %q", e.Action) + } + if e.Text != "Search..." { + t.Errorf("input text should be placeholder 'Search...', got %q", e.Text) + } + return + } + } + t.Error("search input not found in discover entries") +} + +func TestDiscover_TextareaAction(t *testing.T) { + page := navigateTo(t, "/discover") + entries, err := queryDiscoverEntries(page, "data-testid") + if err != nil { + t.Fatalf("queryDiscoverEntries failed: %v", err) + } + for _, e := range entries { + if e.ID == "notes" { + if e.Action != "input" { + t.Errorf("textarea action should be 'input', got %q", e.Action) + } + return + } + } + t.Error("notes textarea not found in discover entries") +} + +func TestDiscover_LinkAction(t *testing.T) { + page := navigateTo(t, "/discover") + entries, err := queryDiscoverEntries(page, "data-testid") + if err != nil { + t.Fatalf("queryDiscoverEntries failed: %v", err) + } + for _, e := range entries { + if e.ID == "help-link" { + if e.Action != "click" { + t.Errorf("link action should be 'click', got %q", e.Action) + } + if !strings.Contains(e.Text, "/help") { + t.Errorf("link text should contain href '/help', got %q", e.Text) + } + return + } + } + t.Error("help-link not found in discover entries") +} + +func TestDiscover_SelectAction(t *testing.T) { + page := navigateTo(t, "/discover") + entries, err := queryDiscoverEntries(page, "data-testid") + if err != nil { + t.Fatalf("queryDiscoverEntries failed: %v", err) + } + for _, e := range entries { + if e.ID == "filter" { + if e.Action != "select" { + t.Errorf("select action should be 'select', got %q", e.Action) + } + if !strings.Contains(e.Text, "All") || !strings.Contains(e.Text, "Active") { + t.Errorf("select text should list options, got %q", e.Text) + } + return + } + } + t.Error("filter select not found in discover entries") +} + +func TestDiscover_TableAction(t *testing.T) { + page := navigateTo(t, "/discover") + entries, err := queryDiscoverEntries(page, "data-testid") + if err != nil { + t.Fatalf("queryDiscoverEntries failed: %v", err) + } + for _, e := range entries { + if e.ID == "results-table" { + if e.Action != "text" { + t.Errorf("table action should be 'text', got %q", e.Action) + } + if !strings.Contains(e.Text, "Name") || !strings.Contains(e.Text, "Status") { + t.Errorf("table text should contain headers, got %q", e.Text) + } + if !strings.Contains(e.Text, "2 rows") { + t.Errorf("table text should contain row count, got %q", e.Text) + } + return + } + } + t.Error("results-table not found in discover entries") +} + +func TestDiscover_HiddenElement(t *testing.T) { + page := navigateTo(t, "/discover") + entries, err := queryDiscoverEntries(page, "data-testid") + if err != nil { + t.Fatalf("queryDiscoverEntries failed: %v", err) + } + for _, e := range entries { + if e.ID == "hidden-el" { + if e.Visible { + t.Error("hidden element should have Visible=false") + } + return + } + } + t.Error("hidden-el not found in discover entries") +} + +func TestDiscover_CustomAttr(t *testing.T) { + page := navigateTo(t, "/discover") + entries, err := queryDiscoverEntries(page, "data-custom") + if err != nil { + t.Fatalf("queryDiscoverEntries failed: %v", err) + } + if len(entries) != 1 { + t.Fatalf("expected 1 entry with data-custom, got %d", len(entries)) + } + if entries[0].ID != "custom-val" { + t.Errorf("expected id 'custom-val', got %q", entries[0].ID) + } +} + +func TestDiscover_EmptyPage(t *testing.T) { + page := navigateTo(t, "/empty") + entries, err := queryDiscoverEntries(page, "data-testid") + if err != nil { + t.Fatalf("queryDiscoverEntries failed: %v", err) + } + if len(entries) != 0 { + t.Errorf("expected 0 entries on empty page, got %d", len(entries)) + } +} + +func TestDiscover_FormatTextGrouping(t *testing.T) { + page := navigateTo(t, "/discover") + entries, err := queryDiscoverEntries(page, "data-testid") + if err != nil { + t.Fatalf("queryDiscoverEntries failed: %v", err) + } + out := formatDiscoverText(entries, "data-testid", "http://example.com/discover") + if !strings.Contains(out, "Readable:") { + t.Error("output should contain 'Readable:' group") + } + if !strings.Contains(out, "Interactive:") { + t.Error("output should contain 'Interactive:' group") + } + if !strings.Contains(out, "Hidden:") { + t.Error("output should contain 'Hidden:' group") + } + if !strings.Contains(out, "Page: http://example.com/discover") { + t.Error("output should contain page URL") + } +} + +func TestDiscover_FormatTextCommands(t *testing.T) { + page := navigateTo(t, "/discover") + entries, err := queryDiscoverEntries(page, "data-testid") + if err != nil { + t.Fatalf("queryDiscoverEntries failed: %v", err) + } + out := formatDiscoverText(entries, "data-testid", "") + if !strings.Contains(out, `rodney click '[data-testid="submit-btn"]'`) { + t.Errorf("output should suggest click command for button, got:\n%s", out) + } + if !strings.Contains(out, `rodney input '[data-testid="search"]'`) { + t.Errorf("output should suggest input command for text input, got:\n%s", out) + } + if !strings.Contains(out, `rodney select '[data-testid="filter"]'`) { + t.Errorf("output should suggest select command for dropdown, got:\n%s", out) + } + if !strings.Contains(out, `rodney text '[data-testid="heading"]'`) { + t.Errorf("output should suggest text command for heading, got:\n%s", out) + } +} + +func TestDiscover_JSONOutput(t *testing.T) { + page := navigateTo(t, "/discover") + entries, err := queryDiscoverEntries(page, "data-testid") + if err != nil { + t.Fatalf("queryDiscoverEntries failed: %v", err) + } + out, jsonErr := json.MarshalIndent(entries, "", " ") + if jsonErr != nil { + t.Fatalf("JSON marshal failed: %v", jsonErr) + } + var parsed []discoverEntry + if err := json.Unmarshal(out, &parsed); err != nil { + t.Fatalf("JSON round-trip failed: %v", err) + } + if len(parsed) != len(entries) { + t.Errorf("JSON round-trip: expected %d entries, got %d", len(entries), len(parsed)) + } +} From 519dfaa1ba4a88f48bdb28374348a1ddec6f5814 Mon Sep 17 00:00:00 2001 From: Jonathan Stockdill Date: Mon, 9 Mar 2026 16:53:58 -0400 Subject: [PATCH 12/30] Add `rodney logs` command for persistent console log capture (#1) Integrate PR #34 (slaskis/rodney#logs) onto main. Adds a `_logger` subprocess spawned by `rodney start --logs` that captures browser console output to per-page NDJSON files via Runtime.consoleAPICalled. - `rodney logs` prints buffered console entries (snapshot mode) - `rodney logs -f` streams new entries in real time - `rodney logs -n N` limits to last N entries - `rodney logs --json` outputs raw NDJSON Uses a blank-page-first strategy in cmdOpen/cmdNewPage when logs are enabled to ensure RuntimeEnable is called before inline scripts execute. --- README.md | 21 +- help.txt | 5 +- main.go | 463 +++++++++++++++++++++++++++++++++++++- main_test.go | 179 +++++++++++++++ notes/cdp-console-logs.md | 135 +++++++++++ 5 files changed, 796 insertions(+), 7 deletions(-) create mode 100644 notes/cdp-console-logs.md diff --git a/README.md b/README.md index 3cb2e26..eb97cd6 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,24 @@ rodney attr "a#link" href # Print attribute value rodney pdf output.pdf # Save page as PDF ``` +### Console logs + +```bash +rodney logs # Print all buffered console logs and exit +rodney logs -n 5 # Print last 5 buffered log entries +rodney logs -f # Print buffered logs, then stream new ones (Ctrl+C to stop) +rodney logs -f -n 5 # Print last 5 buffered logs, then stream new ones +rodney logs --json # JSON output (one object per line) +``` + +Text output format: `[level] message` (e.g. `[error] Uncaught TypeError: ...`). + +JSON output format (one object per line): +```json +{"level":"info","source":"javascript","text":"Page initialized","timestamp":"2024-01-01T12:00:00.123Z"} +{"level":"error","source":"javascript","text":"Uncaught TypeError: ...","timestamp":"2024-01-01T12:00:00.456Z","url":"https://example.com/app.js","line":42} +``` + ### Run JavaScript ```bash @@ -403,7 +421,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] [--logs]` | 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 | @@ -443,6 +461,7 @@ The tool uses the [rod](https://github.com/go-rod/rod) Go library which communic | `count` | `` | Count matching elements | | `visible` | `` | Check element visible (exit 1 if not) | | `assert` | ` [expected] [-m msg]` | Assert JS expression is truthy or equals expected (exit 1 if not) | +| `logs` | `[-f] [-n N] [--json]` | Print console logs (snapshot or stream with `-f`) | | `ax-tree` | `[--depth N] [--json]` | Dump accessibility tree | | `ax-find` | `[--name N] [--role R] [--json]` | Find accessible nodes | | `ax-node` | ` [--json]` | Show element accessibility info | diff --git a/help.txt b/help.txt index 094f725..1783194 100644 --- a/help.txt +++ b/help.txt @@ -1,7 +1,7 @@ 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] [--logs] 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 @@ -21,6 +21,9 @@ Page info: rodney attr Print attribute value rodney pdf [file] Save page as PDF +Console: + rodney logs [-f] [-n N] [--json] Print console logs (default: snapshot, -f to stream) + Interaction: rodney js Evaluate JavaScript expression rodney click Click an element diff --git a/main.go b/main.go index 44f1f5c..8f84b80 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "bufio" _ "embed" "encoding/base64" "encoding/json" @@ -15,6 +16,7 @@ import ( "path/filepath" "strconv" "strings" + "sync" "syscall" "time" @@ -82,8 +84,10 @@ type State struct { 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 + ProxyPID int `json:"proxy_pid,omitempty"` // PID of auth proxy helper + ProxyPort int `json:"proxy_port,omitempty"` // local port of auth proxy + Logs bool `json:"logs,omitempty"` // console log capture enabled + LoggerPID int `json:"logger_pid,omitempty"` // PID of _logger subprocess } func stateDir() string { @@ -189,6 +193,8 @@ func main() { switch cmd { case "_proxy": cmdInternalProxy(args) // hidden: runs the auth proxy helper + case "_logger": + cmdInternalLogger(args) // hidden: runs the browser console logger case "start": cmdStart(args) case "connect": @@ -269,6 +275,8 @@ func main() { cmdVisible(args) case "assert": cmdAssert(args) + case "logs": + cmdLogs(args) case "ax-tree": cmdAXTree(args) case "ax-find": @@ -322,12 +330,15 @@ func withPage() (*State, *rod.Browser, *rod.Page) { func cmdStart(args []string) { ignoreCertErrors := false + enableLogs := false for i := 0; i < len(args); i++ { switch args[i] { case "--insecure", "-k": ignoreCertErrors = true + case "--logs": + enableLogs = true default: - fatal("unknown flag: %s\nusage: rodney start [--insecure]", args[i]) + fatal("unknown flag: %s\nusage: rodney start [--insecure] [--logs]", args[i]) } } @@ -412,6 +423,22 @@ func cmdStart(args []string) { // Get Chrome PID from the launcher pid := l.PID() + // Launch logger subprocess if --logs was specified + var loggerPID int + if enableLogs { + logsDir := filepath.Join(stateDir(), "logs") + os.MkdirAll(logsDir, 0755) + exe, _ := os.Executable() + cmd := exec.Command(exe, "_logger", debugURL, logsDir) + setSysProcAttr(cmd) + if err := cmd.Start(); err != nil { + fatal("failed to start logger: %v", err) + } + loggerPID = cmd.Process.Pid + cmd.Process.Release() + fmt.Printf("Logger started (PID %d)\n", loggerPID) + } + state := &State{ DebugURL: debugURL, ChromePID: pid, @@ -419,6 +446,8 @@ func cmdStart(args []string) { DataDir: dataDir, ProxyPID: proxyPID, ProxyPort: proxyPort, + Logs: enableLogs, + LoggerPID: loggerPID, } if err := saveState(state); err != nil { @@ -500,6 +529,12 @@ func cmdStop(args []string) { proc.Signal(syscall.SIGTERM) } } + // Kill the logger subprocess if running + if s.LoggerPID > 0 { + if proc, err := os.FindProcess(s.LoggerPID); err == nil { + proc.Signal(syscall.SIGTERM) + } + } removeState() fmt.Println("Chrome stopped") } @@ -551,7 +586,20 @@ func cmdOpen(args []string) { pages, _ := browser.Pages() var page *rod.Page if len(pages) == 0 { - page = browser.MustPage(url) + if s.Logs { + // Create a blank page first so _logger receives TargetTargetCreated and + // calls RuntimeEnable before any scripts execute. RuntimeEnable persists + // across same-target navigations, so inline scripts on the real URL are + // captured. Poll for the log file: trackPage creates it only after + // RuntimeEnable returns, so its existence is an exact ready signal. + page = browser.MustPage("") + waitForLogger(page) + if err := page.Navigate(url); err != nil { + fatal("navigation failed: %v", err) + } + } else { + page = browser.MustPage(url) + } s.ActivePage = 0 saveState(s) } else { @@ -1274,7 +1322,16 @@ func cmdNewPage(args []string) { var page *rod.Page if url != "" { - page = browser.MustPage(url) + if s.Logs { + // Same blank-page-first strategy as cmdOpen. + page = browser.MustPage("") + waitForLogger(page) + if err := page.Navigate(url); err != nil { + fatal("navigation failed: %v", err) + } + } else { + page = browser.MustPage(url) + } page.MustWaitLoad() } else { page = browser.MustPage("") @@ -1495,6 +1552,292 @@ func init() { signal.Ignore(syscall.SIGPIPE) } +// --- Console log commands --- + +type consoleEntry struct { + level string + source string + text string + timestamp float64 // Unix milliseconds + url string + line *int +} + +// formatLogLevel formats a proto.LogLogEntryLevel value as a string. +// Kept for compatibility and unit-testing. +func formatLogLevel(level proto.LogLogEntryLevel) string { + switch level { + case proto.LogLogEntryLevelVerbose: + return "verbose" + case proto.LogLogEntryLevelInfo: + return "info" + case proto.LogLogEntryLevelWarning: + return "warning" + case proto.LogLogEntryLevelError: + return "error" + default: + return string(level) + } +} + +// consoleTypeToLevel maps a Runtime.consoleAPICalled type to a log level string. +func consoleTypeToLevel(t proto.RuntimeConsoleAPICalledType) string { + switch t { + case proto.RuntimeConsoleAPICalledTypeDebug: + return "verbose" + case proto.RuntimeConsoleAPICalledTypeLog, proto.RuntimeConsoleAPICalledTypeInfo, + proto.RuntimeConsoleAPICalledTypeDir, proto.RuntimeConsoleAPICalledTypeDirxml, + proto.RuntimeConsoleAPICalledTypeTable, proto.RuntimeConsoleAPICalledTypeTrace, + proto.RuntimeConsoleAPICalledTypeStartGroup, proto.RuntimeConsoleAPICalledTypeStartGroupCollapsed, + proto.RuntimeConsoleAPICalledTypeEndGroup, proto.RuntimeConsoleAPICalledTypeClear, + proto.RuntimeConsoleAPICalledTypeCount, proto.RuntimeConsoleAPICalledTypeTimeEnd, + proto.RuntimeConsoleAPICalledTypeProfile, proto.RuntimeConsoleAPICalledTypeProfileEnd: + return "info" + case proto.RuntimeConsoleAPICalledTypeWarning: + return "warning" + case proto.RuntimeConsoleAPICalledTypeError, proto.RuntimeConsoleAPICalledTypeAssert: + return "error" + default: + return string(t) + } +} + +// formatConsoleArgs converts Runtime RemoteObjects to a human-readable string. +func formatConsoleArgs(args []*proto.RuntimeRemoteObject) string { + var parts []string + for _, arg := range args { + switch string(arg.Type) { + case "string": + parts = append(parts, arg.Value.Str()) + case "number", "boolean": + parts = append(parts, arg.Value.JSON("", "")) + case "undefined": + parts = append(parts, "undefined") + case "null": + parts = append(parts, "null") + default: + if arg.Description != "" { + parts = append(parts, arg.Description) + } else { + parts = append(parts, arg.Value.JSON("", "")) + } + } + } + return strings.Join(parts, " ") +} + +func cmdLogs(args []string) { + followMode := false + jsonOutput := false + limitN := -1 + + for i := 0; i < len(args); i++ { + switch args[i] { + case "-f", "--follow": + followMode = true + case "--json": + jsonOutput = true + case "-n": + i++ + if i >= len(args) { + fatal("missing value for -n") + } + n, err := strconv.Atoi(args[i]) + if err != nil || n < 1 { + fatal("invalid value for -n: %s", args[i]) + } + limitN = n + default: + fatal("unknown flag: %s\nusage: rodney logs [-f] [-n N] [--json]", args[i]) + } + } + + s, err := loadState() + if err != nil { + fatal("%v", err) + } + if !s.Logs { + fmt.Fprintln(os.Stderr, "logs not enabled (run: rodney start --logs)") + os.Exit(1) + } + browser, err := connectBrowser(s) + if err != nil { + fatal("%v", err) + } + page, err := getActivePage(browser, s) + if err != nil { + fatal("%v", err) + } + + logFile := filepath.Join(stateDir(), "logs", string(page.TargetID)+".ndjson") + + if _, err := os.Stat(logFile); os.IsNotExist(err) { + fmt.Fprintln(os.Stderr, "no console log recorded for this page yet") + os.Exit(0) + } + + if followMode { + fmt.Fprintln(os.Stderr, "Streaming console logs (Ctrl+C to stop)...") + tailLogFile(logFile, limitN, jsonOutput) + return + } + + // Snapshot mode: stream the file to avoid loading it all into memory. + if limitN > 0 { + // Ring buffer: O(limitN) memory regardless of file size. + ring := make([]string, limitN) + count := 0 + scanLogFile(logFile, func(line string) { + ring[count%limitN] = line + count++ + }) + start, n := 0, count + if count > limitN { + start = count % limitN + n = limitN + } + for i := 0; i < n; i++ { + printNDJSONLine(ring[(start+i)%limitN], jsonOutput) + } + } else { + scanLogFile(logFile, func(line string) { + printNDJSONLine(line, jsonOutput) + }) + } +} + +// scanLogFile opens logFile and calls fn for each non-empty line using a +// streaming bufio.Scanner — no whole-file read into memory. +func scanLogFile(logFile string, fn func(string)) { + f, err := os.Open(logFile) + if err != nil { + return + } + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + if line := scanner.Text(); line != "" { + fn(line) + } + } +} + +// printNDJSONLine prints a single NDJSON log line. +// In JSON mode it prints verbatim; otherwise it formats as "[level] text". +func printNDJSONLine(line string, jsonOutput bool) { + if jsonOutput { + fmt.Println(line) + return + } + var obj struct { + Level string `json:"level"` + Text string `json:"text"` + } + if err := json.Unmarshal([]byte(line), &obj); err == nil { + fmt.Printf("[%s] %s\n", obj.Level, obj.Text) + } +} + +// tailLogFile follows a log file, printing new lines as they are appended. +// If limitN > 0, prints the last N existing lines first, then follows new content. +// If limitN <= 0, seeks to the end immediately and only shows new entries. +func tailLogFile(logFile string, limitN int, jsonOutput bool) { + f, err := os.Open(logFile) + if err != nil { + fatal("failed to open log file: %v", err) + } + defer f.Close() + + if limitN > 0 { + // Ring buffer: stream last N lines without loading the whole file. + ring := make([]string, limitN) + count := 0 + scanner := bufio.NewScanner(f) + for scanner.Scan() { + if line := scanner.Text(); line != "" { + ring[count%limitN] = line + count++ + } + } + start, n := 0, count + if count > limitN { + start = count % limitN + n = limitN + } + for i := 0; i < n; i++ { + printNDJSONLine(ring[(start+i)%limitN], jsonOutput) + } + } + // Seek to end to tail only new content (scanner may have over-read into + // a bufio buffer, but explicit SeekEnd corrects the OS file position). + f.Seek(0, io.SeekEnd) + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + buf := make([]byte, 4096) + var partial string + for { + select { + case <-sigCh: + return + default: + } + n, _ := f.Read(buf) + if n > 0 { + partial += string(buf[:n]) + for { + idx := strings.Index(partial, "\n") + if idx < 0 { + break + } + line := partial[:idx] + partial = partial[idx+1:] + if line != "" { + printNDJSONLine(line, jsonOutput) + } + } + } else { + time.Sleep(50 * time.Millisecond) + } + } +} + +func makeConsoleEntry(e *proto.RuntimeConsoleAPICalled) consoleEntry { + entry := consoleEntry{ + level: consoleTypeToLevel(e.Type), + source: "javascript", + text: formatConsoleArgs(e.Args), + timestamp: float64(e.Timestamp), + } + if e.StackTrace != nil && len(e.StackTrace.CallFrames) > 0 { + frame := e.StackTrace.CallFrames[0] + entry.url = frame.URL + line := frame.LineNumber + entry.line = &line + } + return entry +} + +// marshalConsoleEntry serializes a consoleEntry to a JSON line for the NDJSON log file. +func marshalConsoleEntry(entry consoleEntry) string { + ts := time.UnixMilli(int64(entry.timestamp)).UTC() + obj := map[string]interface{}{ + "level": entry.level, + "source": entry.source, + "text": entry.text, + "timestamp": ts.Format("2006-01-02T15:04:05.000Z07:00"), + } + if entry.url != "" { + obj["url"] = entry.url + } + if entry.line != nil { + obj["line"] = *entry.line + } + data, _ := json.Marshal(obj) + return string(data) +} + // --- Accessibility commands --- func cmdAXTree(args []string) { @@ -2001,6 +2344,116 @@ func formatAXNodeDetailJSON(node *proto.AccessibilityAXNode) string { return string(data) } +// --- Console logger subprocess --- + +func cmdInternalLogger(args []string) { + if len(args) < 2 { + fatal("usage: rodney _logger ") + } + debugURL := args[0] + logsDir := args[1] + + browser := rod.New().ControlURL(debugURL).MustConnect() + os.MkdirAll(logsDir, 0755) + + var mu sync.Mutex + tracking := map[proto.TargetTargetID]bool{} + + // subscribeToPage marks the target as tracked and starts trackPage in a + // goroutine. It looks up the *rod.Page by target ID; retries briefly in + // case GetTargets lags slightly behind the TargetCreated event. + subscribeToPage := func(targetID proto.TargetTargetID) { + mu.Lock() + already := tracking[targetID] + if !already { + tracking[targetID] = true + } + mu.Unlock() + if already { + return + } + go func() { + for i := 0; i < 10; i++ { + pages, _ := browser.Pages() + for _, p := range pages { + if p.TargetID == targetID { + trackPage(p, logsDir) + return + } + } + time.Sleep(10 * time.Millisecond) + } + }() + } + + // TargetSetDiscoverTargets causes Chrome to fire TargetTargetCreated for + // all existing targets immediately, and for every new target thereafter. + // Set up the listener first so we don't miss events. + wait := browser.EachEvent(func(e *proto.TargetTargetCreated) bool { + if e.TargetInfo.Type == "page" { + subscribeToPage(e.TargetInfo.TargetID) + } + return false + }) + proto.TargetSetDiscoverTargets{Discover: true}.Call(browser) + go wait() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + <-sigCh +} + +// trackPage subscribes to console events for a single page and writes them to +// a per-page NDJSON file. Blocks until the page is closed or context cancelled. +// +// The log file is opened *after* RuntimeEnable returns (which blocks until +// Chrome acks the command). This means the file's creation on disk is an exact +// signal that Chrome is ready to send events — waitForLogger relies on this. +func trackPage(page *rod.Page, logsDir string) { + logFile := filepath.Join(logsDir, string(page.TargetID)+".ndjson") + + // Register the listener before enabling so no events are missed. + // f starts nil; callback skips writes until f is set below. + var f *os.File + wait := page.EachEvent(func(e *proto.RuntimeConsoleAPICalled) bool { + if f != nil { + fmt.Fprintln(f, marshalConsoleEntry(makeConsoleEntry(e))) + f.Sync() + } + return false + }) + + // Enable runtime; blocks until Chrome acknowledges. + if err := (proto.RuntimeEnable{}).Call(page); err != nil { + return + } + + // Open the log file now. Its appearance on disk is the ready signal + // consumed by waitForLogger in cmdOpen/cmdNewPage. + var err error + f, err = os.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return + } + defer f.Close() + + wait() // blocks until page closed or context cancelled; f is non-nil +} + +// waitForLogger polls until _logger has subscribed to page and called +// RuntimeEnable (signalled by the log file appearing on disk), or until a +// 500ms timeout expires. Called before navigating a freshly-created blank page. +func waitForLogger(page *rod.Page) { + logFile := filepath.Join(stateDir(), "logs", string(page.TargetID)+".ndjson") + deadline := time.Now().Add(500 * time.Millisecond) + for time.Now().Before(deadline) { + if _, err := os.Stat(logFile); err == nil { + return + } + time.Sleep(5 * time.Millisecond) + } +} + // --- Auth proxy for environments with authenticated HTTP proxies --- // detectProxy checks for HTTPS_PROXY/HTTP_PROXY with credentials. diff --git a/main_test.go b/main_test.go index 5de20ec..5d7d9be 100644 --- a/main_test.go +++ b/main_test.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" "time" @@ -51,6 +52,7 @@ func TestMain(m *testing.M) { mux.HandleFunc("/download", handleDownload) mux.HandleFunc("/testfile.txt", handleTestFile) mux.HandleFunc("/empty", handleEmpty) + mux.HandleFunc("/logs", handleLogs) mux.HandleFunc("/discover", handleDiscover) server := httptest.NewServer(mux) @@ -151,6 +153,21 @@ func handleEmpty(w http.ResponseWriter, r *http.Request) { `)) } +func handleLogs(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(` + +Logs Test Page + + + +`)) +} + // --- Helper: navigate to a fixture and return the page --- func navigateTo(t *testing.T, path string) *rod.Page { @@ -1150,6 +1167,168 @@ func TestInsecureFlag_WithSelfSignedCert(t *testing.T) { }) } +// ===================== +// logs command tests +// ===================== + +// collectConsoleMsgs enables the Runtime domain, emits js, collects up to +// maxCount events (or waits timeout), and returns the collected entries. +func collectConsoleMsgs(page *rod.Page, js string, maxCount int, timeout time.Duration) (texts []string, levels []string) { + var mu sync.Mutex + done := make(chan struct{}) + var once sync.Once + closeDone := func() { once.Do(func() { close(done) }) } + + wait := page.EachEvent(func(e *proto.RuntimeConsoleAPICalled) bool { + mu.Lock() + texts = append(texts, formatConsoleArgs(e.Args)) + levels = append(levels, consoleTypeToLevel(e.Type)) + n := len(texts) + mu.Unlock() + if n >= maxCount { + closeDone() + return true // stop + } + return false + }) + + (proto.RuntimeEnable{}).Call(page) //nolint + page.MustEval(js) + + go func() { + wait() + closeDone() + }() + + select { + case <-done: + case <-time.After(timeout): + } + return +} + +func TestLogs_SnapshotCapture(t *testing.T) { + page := navigateTo(t, "/") + + texts, _ := collectConsoleMsgs(page, `() => { + console.log("info message from logs test"); + console.warn("warning message from logs test"); + console.error("error message from logs test"); + }`, 3, 3*time.Second) + + if len(texts) < 3 { + t.Fatalf("expected at least 3 log entries, got %d: %v", len(texts), texts) + } + + found := false + for _, text := range texts { + if strings.Contains(text, "info message from logs test") { + found = true + break + } + } + if !found { + t.Errorf("expected 'info message from logs test' in entries, got: %v", texts) + } +} + +func TestLogs_ConsoleTypes(t *testing.T) { + page := navigateTo(t, "/") + + _, levels := collectConsoleMsgs(page, `() => { + console.warn("warning entry for level test"); + console.error("error entry for level test"); + }`, 2, 3*time.Second) + + levelSet := make(map[string]bool) + for _, l := range levels { + levelSet[l] = true + } + + if !levelSet["warning"] { + t.Errorf("expected a warning-level entry, got levels: %v", levels) + } + if !levelSet["error"] { + t.Errorf("expected an error-level entry, got levels: %v", levels) + } +} + +func TestLogs_FormatLogLevel(t *testing.T) { + tests := []struct { + level proto.LogLogEntryLevel + expected string + }{ + {proto.LogLogEntryLevelVerbose, "verbose"}, + {proto.LogLogEntryLevelInfo, "info"}, + {proto.LogLogEntryLevelWarning, "warning"}, + {proto.LogLogEntryLevelError, "error"}, + {proto.LogLogEntryLevel("custom"), "custom"}, + } + for _, tt := range tests { + got := formatLogLevel(tt.level) + if got != tt.expected { + t.Errorf("formatLogLevel(%q) = %q, want %q", tt.level, got, tt.expected) + } + } +} + +func TestLogs_ConsoleTypeToLevel(t *testing.T) { + tests := []struct { + ct proto.RuntimeConsoleAPICalledType + expected string + }{ + {proto.RuntimeConsoleAPICalledTypeDebug, "verbose"}, + {proto.RuntimeConsoleAPICalledTypeLog, "info"}, + {proto.RuntimeConsoleAPICalledTypeInfo, "info"}, + {proto.RuntimeConsoleAPICalledTypeWarning, "warning"}, + {proto.RuntimeConsoleAPICalledTypeError, "error"}, + {proto.RuntimeConsoleAPICalledTypeAssert, "error"}, + {proto.RuntimeConsoleAPICalledTypeDir, "info"}, + } + for _, tt := range tests { + got := consoleTypeToLevel(tt.ct) + if got != tt.expected { + t.Errorf("consoleTypeToLevel(%q) = %q, want %q", tt.ct, got, tt.expected) + } + } +} + +func TestLogs_ScanLogFile(t *testing.T) { + dir := t.TempDir() + logFile := filepath.Join(dir, "test.ndjson") + + content := `{"level":"info","source":"javascript","text":"hello","timestamp":"2024-01-01T12:00:00.000Z"} +{"level":"warning","source":"javascript","text":"world","timestamp":"2024-01-01T12:00:01.000Z"} +` + if err := os.WriteFile(logFile, []byte(content), 0644); err != nil { + t.Fatalf("failed to write log file: %v", err) + } + + var lines []string + scanLogFile(logFile, func(line string) { lines = append(lines, line) }) + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d: %v", len(lines), lines) + } + + var obj struct { + Level string `json:"level"` + Text string `json:"text"` + } + if err := json.Unmarshal([]byte(lines[0]), &obj); err != nil { + t.Fatalf("failed to unmarshal line 0: %v", err) + } + if obj.Level != "info" || obj.Text != "hello" { + t.Errorf("line 0: got level=%q text=%q, want level=%q text=%q", obj.Level, obj.Text, "info", "hello") + } + + if err := json.Unmarshal([]byte(lines[1]), &obj); err != nil { + t.Fatalf("failed to unmarshal line 1: %v", err) + } + if obj.Level != "warning" || obj.Text != "world" { + t.Errorf("line 1: got level=%q text=%q, want level=%q text=%q", obj.Level, obj.Text, "warning", "world") + } +} + // ===================== // discover command tests // ===================== diff --git a/notes/cdp-console-logs.md b/notes/cdp-console-logs.md new file mode 100644 index 0000000..298c03b --- /dev/null +++ b/notes/cdp-console-logs.md @@ -0,0 +1,135 @@ +# CDP console log capture — findings + +Research and empirical testing done while implementing `rodney logs`. + +## The three CDP mechanisms + +### 1. `Runtime.consoleAPICalled` (Runtime domain) + +- Fires for every `console.log/warn/error/debug/info/...` call made by JavaScript + running in the page, including calls made via `Runtime.evaluate` +- **Live only** — no replay of past events +- When `Runtime.enable` is called in a new CDP session, Chrome does *not* replay + previous `consoleAPICalled` events to that session +- This is what rodney uses for all console capture + +### 2. `Log.entryAdded` (Log domain) + +- Fires for **browser-generated** log entries: network errors, CSP violations, + mixed-content warnings, deprecation notices, intervention messages, etc. +- Does **not** fire for JavaScript `console.*` API calls (those go exclusively + to `Runtime.consoleAPICalled`) +- `Log.enable` does replay buffered browser-level entries to new sessions + +### 3. `Console.messageAdded` (Console domain — deprecated) + +- The older, deprecated predecessor to the Runtime + Log split +- Replays collected messages on `Console.enable`, per the CDP spec +- In practice (Chrome ~124+) empirically found to replay 0 messages +- Playwright never uses this domain + +## Key discovery: `Runtime.consoleAPICalled` IS broadcast cross-session + +Empirically confirmed: `Runtime.consoleAPICalled` events are broadcast to **all** +CDP sessions that have called `Runtime.enable` on the same target, regardless of +which session triggered the event. This includes events triggered by +`Runtime.evaluate`. + +This was confirmed by observing double-writes to the NDJSON log file when both +a `_logger` subprocess and an in-process EachEvent handler were listening +simultaneously — both received the same event. + +Consequence: the background `_logger` subprocess **can** capture console calls +made through `rodney js`, as long as it has called `Runtime.enable` before those +calls happen. + +## Timing constraint: inline scripts race against subscription + +`Runtime.consoleAPICalled` is live-only — events fired before a session calls +`Runtime.enable` are not replayed. This creates a race for pages that call +`console.*` synchronously in inline scripts: + +```html + +``` + +If the `_logger` process is polling for new pages (100ms intervals), it will +almost always miss inline-script console calls because the page loads before +`_logger` subscribes. + +## Current implementation: `_logger` subprocess with `Target.targetCreated` + +`rodney start --logs` spawns a `_logger` subprocess immediately. It uses +`Target.setDiscoverTargets` + `EachEvent(TargetTargetCreated)` to detect new +pages the moment Chrome creates their target — microseconds after creation, +before navigation begins. + +To close the remaining race for inline scripts, `cmdOpen` and `cmdNewPage` +use a blank-page-first strategy when `--logs` is active: + +1. Create a blank page (`browser.MustPage("")`) — triggers `TargetTargetCreated` + in `_logger` immediately +2. `_logger` calls `RuntimeEnable` on the blank page; `RuntimeEnable` is a + synchronous CDP call that blocks until Chrome acknowledges — after it returns, + Chrome will send events for any subsequent console call on this target +3. `_logger`'s `trackPage` opens the `.ndjson` log file *after* `RuntimeEnable` + returns — the file's appearance on disk is an exact ready signal +4. `cmdOpen` polls (`waitForLogger`) for the log file to appear (5ms intervals, + 500ms timeout) — typically resolves in ~10–15ms +5. `cmdOpen` navigates to the real URL — `RuntimeEnable` persists across + same-target navigations, so all inline scripts are captured + +`rodney logs` requires `rodney start --logs`; it errors with a helpful message +otherwise. + +### What `_logger` captures + +| Source | Captured | +|--------|----------| +| Inline ` + +`)) +} + +func TestParseWaitArgs_SelectorOnly(t *testing.T) { + selector, textMatch, gone := parseWaitArgs([]string{".foo"}) + if selector != ".foo" { + t.Errorf("expected selector '.foo', got %q", selector) + } + if textMatch != "" { + t.Errorf("expected empty textMatch, got %q", textMatch) + } + if gone { + t.Error("expected gone=false") + } +} + +func TestParseWaitArgs_TextFlag(t *testing.T) { + selector, textMatch, gone := parseWaitArgs([]string{".status", "--text", "Ready"}) + if selector != ".status" { + t.Errorf("expected selector '.status', got %q", selector) + } + if textMatch != "Ready" { + t.Errorf("expected textMatch 'Ready', got %q", textMatch) + } + if gone { + t.Error("expected gone=false") + } +} + +func TestParseWaitArgs_GoneFlag(t *testing.T) { + selector, textMatch, gone := parseWaitArgs([]string{"--gone", ".spinner"}) + if selector != ".spinner" { + t.Errorf("expected selector '.spinner', got %q", selector) + } + if textMatch != "" { + t.Errorf("expected empty textMatch, got %q", textMatch) + } + if !gone { + t.Error("expected gone=true") + } +} + +func TestParseWaitArgs_GoneAfterSelector(t *testing.T) { + selector, _, gone := parseWaitArgs([]string{".spinner", "--gone"}) + if selector != ".spinner" { + t.Errorf("expected selector '.spinner', got %q", selector) + } + if !gone { + t.Error("expected gone=true") + } +} + +func TestParseWaitArgs_TextBeforeSelector(t *testing.T) { + selector, textMatch, _ := parseWaitArgs([]string{"--text", "Ready", ".status"}) + if selector != ".status" { + t.Errorf("expected selector '.status', got %q", selector) + } + if textMatch != "Ready" { + t.Errorf("expected textMatch 'Ready', got %q", textMatch) + } +} + +func TestCmdWait_BasicWait(t *testing.T) { + // Basic wait on an element that exists — should succeed immediately + page := navigateTo(t, "/") + el, err := page.Element("h1") + if err != nil { + t.Fatalf("element not found: %v", err) + } + el.MustWaitVisible() + // If we got here, the basic wait mechanism works +} + +func TestCmdWait_TextMatch(t *testing.T) { + // Create a page where text changes after a delay + page := env.browser.MustPage("") + t.Cleanup(func() { page.MustClose() }) + + page.MustNavigate(env.server.URL + "/wait-test") + page.MustWaitLoad() + + // Poll for the text to appear (simulating what cmdWait --text does) + deadline := time.Now().Add(5 * time.Second) + found := false + for time.Now().Before(deadline) { + el, err := page.Element("#status") + if err == nil { + text, err := el.Text() + if err == nil && strings.Contains(text, "Ready") { + found = true + break + } + } + time.Sleep(100 * time.Millisecond) + } + if !found { + t.Fatal("timed out waiting for text 'Ready' in #status") + } +} + +func TestCmdWait_GoneNonexistent(t *testing.T) { + // --gone with a selector that doesn't exist should succeed immediately + page := navigateTo(t, "/") + els, err := page.Elements("#does-not-exist") + if err != nil || len(els) == 0 { + // Element doesn't exist — this is the success condition for --gone + } else { + t.Error("expected #does-not-exist to not be found") + } +} + +func TestCmdWait_GoneDisappearing(t *testing.T) { + // Test that --gone logic detects when an element is removed from the DOM + page := env.browser.MustPage("") + t.Cleanup(func() { page.MustClose() }) + + page.MustNavigate(env.server.URL + "/wait-test") + page.MustWaitLoad() + + // Poll for the spinner to disappear (simulating what cmdWait --gone does) + deadline := time.Now().Add(5 * time.Second) + gone := false + for time.Now().Before(deadline) { + els, err := page.Elements("#spinner") + if err != nil || len(els) == 0 { + gone = true + break + } + allHidden := true + for _, el := range els { + visible, err := el.Visible() + if err == nil && visible { + allHidden = false + break + } + } + if allHidden { + gone = true + break + } + time.Sleep(100 * time.Millisecond) + } + if !gone { + t.Fatal("timed out waiting for #spinner to disappear") + } +} + +func TestCmdWait_MutualExclusivity(t *testing.T) { + // --text and --gone together should be detected by parseWaitArgs callers + selector, textMatch, gone := parseWaitArgs([]string{"--gone", "--text", "Ready", ".status"}) + if selector != ".status" { + t.Errorf("expected selector '.status', got %q", selector) + } + if textMatch != "Ready" { + t.Errorf("expected textMatch 'Ready', got %q", textMatch) + } + if !gone { + t.Error("expected gone=true") + } + // Both are set — cmdWait would call fatal("--text and --gone are mutually exclusive") + if textMatch != "" && gone { + // This is the mutual exclusivity condition — confirmed + } else { + t.Error("expected both textMatch and gone to be set for mutual exclusivity test") + } +} From 6d8cc994ae8f7ffb2fdaaa50cb95fabf31b7c339 Mon Sep 17 00:00:00 2001 From: Jonathan Stockdill Date: Fri, 3 Apr 2026 15:32:16 -0400 Subject: [PATCH 19/30] Add contextual error suggestions for common failures --- main.go | 24 ++++++++++++++++++- main_test.go | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index 00a74d3..32194ad 100644 --- a/main.go +++ b/main.go @@ -172,6 +172,10 @@ func fatal(format string, args ...interface{}) { os.Exit(2) } +func hint(format string, args ...interface{}) { + fmt.Fprintf(os.Stderr, "hint: "+format+"\n", args...) +} + func main() { if len(os.Args) < 2 { printUsage() @@ -804,6 +808,7 @@ func cmdHTML(args []string) { if len(args) > 0 { el, err := page.Element(args[0]) if err != nil { + hint("try 'rodney discover --interactive' to see available elements") fatal("element not found: %v", err) } html, err := el.HTML() @@ -824,6 +829,7 @@ func cmdText(args []string) { _, _, page := withPage() el, err := page.Element(args[0]) if err != nil { + hint("try 'rodney discover --interactive' to see available elements") fatal("element not found: %v", err) } text, err := el.Text() @@ -840,6 +846,7 @@ func cmdAttr(args []string) { _, _, page := withPage() el, err := page.Element(args[0]) if err != nil { + hint("try 'rodney discover --interactive' to see available elements") fatal("element not found: %v", err) } val := el.MustAttribute(args[1]) @@ -930,12 +937,15 @@ func cmdClick(args []string) { if len(args) < 1 { fatal("usage: rodney click ") } + selector := args[0] _, _, page := withPage() - el, err := page.Element(args[0]) + el, err := page.Element(selector) if err != nil { + hint("try 'rodney discover --interactive' to see available elements") fatal("element not found: %v", err) } if err := el.Click(proto.InputMouseButtonLeft, 1); err != nil { + hint("element may not be interactive — try 'rodney js \"document.querySelector(\\\"%s\\\").click()\"'", selector) fatal("click failed: %v", err) } // Brief pause for click handlers to execute @@ -950,6 +960,7 @@ func cmdInput(args []string) { _, _, page := withPage() el, err := page.Element(args[0]) if err != nil { + hint("try 'rodney discover --interactive' to see available elements") fatal("element not found: %v", err) } text := strings.Join(args[1:], " ") @@ -964,6 +975,7 @@ func cmdClear(args []string) { _, _, page := withPage() el, err := page.Element(args[0]) if err != nil { + hint("try 'rodney discover --interactive' to see available elements") fatal("element not found: %v", err) } el.MustSelectAllText().MustInput("") @@ -980,6 +992,7 @@ func cmdFile(args []string) { _, _, page := withPage() el, err := page.Element(selector) if err != nil { + hint("try 'rodney discover --interactive' to see available elements") fatal("element not found: %v", err) } @@ -1024,6 +1037,7 @@ func cmdDownload(args []string) { _, _, page := withPage() el, err := page.Element(selector) if err != nil { + hint("try 'rodney discover --interactive' to see available elements") fatal("element not found: %v", err) } @@ -1178,6 +1192,7 @@ func cmdSelect(args []string) { }`, args[0], args[1]) result, err := page.Eval(js) if err != nil { + hint("try 'rodney discover --interactive' to see available elements") fatal("select failed: %v", err) } fmt.Printf("Selected: %s\n", result.Value.Str()) @@ -1190,6 +1205,7 @@ func cmdSubmit(args []string) { _, _, page := withPage() _, err := page.Element(args[0]) if err != nil { + hint("try 'rodney discover --interactive' to see available elements") fatal("form not found: %v", err) } page.MustEval(fmt.Sprintf(`() => document.querySelector(%q).submit()`, args[0])) @@ -1203,6 +1219,7 @@ func cmdHover(args []string) { _, _, page := withPage() el, err := page.Element(args[0]) if err != nil { + hint("try 'rodney discover --interactive' to see available elements") fatal("element not found: %v", err) } el.MustHover() @@ -1216,6 +1233,7 @@ func cmdFocus(args []string) { _, _, page := withPage() el, err := page.Element(args[0]) if err != nil { + hint("try 'rodney discover --interactive' to see available elements") fatal("element not found: %v", err) } el.MustFocus() @@ -1229,6 +1247,8 @@ func cmdWait(args []string) { _, _, page := withPage() el, err := page.Element(args[0]) if err != nil { + hint("page may still be loading — try 'rodney waitstable' before this command") + hint("try 'rodney discover --interactive' to see available elements") fatal("element not found: %v", err) } el.MustWaitVisible() @@ -1451,6 +1471,7 @@ func cmdScreenshotEl(args []string) { _, _, page := withPage() el, err := page.Element(args[0]) if err != nil { + hint("try 'rodney discover --interactive' to see available elements") fatal("element not found: %v", err) } data, err := el.Screenshot(proto.PageCaptureScreenshotFormatPng, 0) @@ -2175,6 +2196,7 @@ func cmdAXFind(args []string) { } if len(nodes) == 0 { + hint("try broader criteria — 'rodney ax-tree' shows all available nodes") fmt.Fprintln(os.Stderr, "No matching nodes") os.Exit(1) } diff --git a/main_test.go b/main_test.go index c3db273..f7e85fb 100644 --- a/main_test.go +++ b/main_test.go @@ -2113,3 +2113,71 @@ func TestParseStartFlags_UnknownFlag(t *testing.T) { t.Errorf("expected 'unknown flag: --bogus' in error, got: %v", err) } } + +// ===================== +// hint function tests +// ===================== + +// captureStderr captures everything written to os.Stderr by fn, trimming trailing whitespace. +func captureStderr(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + oldStderr := os.Stderr + os.Stderr = w + fn() + w.Close() + os.Stderr = oldStderr + out, err := io.ReadAll(r) + if err != nil { + t.Fatalf("captureStderr read: %v", err) + } + return strings.TrimSpace(string(out)) +} + +func TestHint_BasicMessage(t *testing.T) { + got := captureStderr(t, func() { + hint("try 'rodney discover --interactive' to see available elements") + }) + expected := "hint: try 'rodney discover --interactive' to see available elements" + if got != expected { + t.Errorf("expected %q, got %q", expected, got) + } +} + +func TestHint_FormattedMessage(t *testing.T) { + got := captureStderr(t, func() { + hint("element may not be interactive — try 'rodney js \"document.querySelector(\\\"%s\\\").click()\"'", "#btn") + }) + if !strings.Contains(got, "hint:") { + t.Errorf("expected output to start with 'hint:', got %q", got) + } + if !strings.Contains(got, "#btn") { + t.Errorf("expected output to contain selector '#btn', got %q", got) + } +} + +func TestHint_WritesToStderr(t *testing.T) { + // Verify hint writes to stderr, not stdout + stdout := captureStdout(t, func() { + hint("this should not appear on stdout") + }) + if stdout != "" { + t.Errorf("hint should not write to stdout, got %q", stdout) + } +} + +func TestHint_MultipleHints(t *testing.T) { + got := captureStderr(t, func() { + hint("first hint") + hint("second hint") + }) + if !strings.Contains(got, "hint: first hint") { + t.Errorf("expected 'hint: first hint' in output, got %q", got) + } + if !strings.Contains(got, "hint: second hint") { + t.Errorf("expected 'hint: second hint' in output, got %q", got) + } +} From b545521bbc18351a42524ecefc413c934b01f4a4 Mon Sep 17 00:00:00 2001 From: Jonathan Stockdill Date: Fri, 3 Apr 2026 15:41:29 -0400 Subject: [PATCH 20/30] Add repeated failure detection via state ring buffer --- main.go | 113 ++++++++++++++ main_test.go | 421 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 534 insertions(+) diff --git a/main.go b/main.go index 00a74d3..49433b5 100644 --- a/main.go +++ b/main.go @@ -78,6 +78,15 @@ func resolveStateDir(mode scopeMode, workingDir string) string { } } +// CallRecord tracks a single command invocation for repeated-failure detection. +type CallRecord struct { + Cmd string `json:"cmd"` + Selector string `json:"sel,omitempty"` + OK bool `json:"ok"` + Error string `json:"err,omitempty"` + TS int64 `json:"ts"` +} + // State persisted between CLI invocations type State struct { DebugURL string `json:"debug_url"` @@ -94,6 +103,9 @@ type State struct { ViewportHeight int `json:"viewport_height,omitempty"` ViewportScale float64 `json:"viewport_scale,omitempty"` ViewportMobile bool `json:"viewport_mobile,omitempty"` + + // Ring buffer of recent command results for repeated-failure detection + RecentCalls []CallRecord `json:"recent_calls,omitempty"` } func stateDir() string { @@ -172,6 +184,81 @@ func fatal(format string, args ...interface{}) { os.Exit(2) } +func context(format string, args ...interface{}) { + fmt.Fprintf(os.Stderr, "context: "+format+"\n", args...) +} + +// observationCmds lists commands that don't break failure streaks. +var observationCmds = map[string]bool{ + "url": true, "title": true, "text": true, "html": true, + "screenshot": true, "screenshot-el": true, "exists": true, + "visible": true, "count": true, "ax-tree": true, "ax-find": true, + "ax-node": true, "discover": true, "pages": true, "status": true, + "logs": true, "pdf": true, "attr": true, +} + +// recordCall appends a CallRecord to state and trims to the last 10 entries. +// Silently returns on any error. +func recordCall(cmd, selector string, ok bool, errMsg string) { + s, err := loadState() + if err != nil { + return + } + s.RecentCalls = append(s.RecentCalls, CallRecord{ + Cmd: cmd, + Selector: selector, + OK: ok, + Error: errMsg, + TS: time.Now().Unix(), + }) + if len(s.RecentCalls) > 10 { + s.RecentCalls = s.RecentCalls[len(s.RecentCalls)-10:] + } + _ = saveState(s) +} + +// checkStuck walks RecentCalls backwards counting consecutive identical failures +// (same cmd + same selector + not ok). Observation commands are skipped. +// A successful non-observation command breaks the streak. +func checkStuck(cmd, selector string) int { + s, err := loadState() + if err != nil { + return 0 + } + count := 0 + for i := len(s.RecentCalls) - 1; i >= 0; i-- { + r := s.RecentCalls[i] + if observationCmds[r.Cmd] { + continue // skip observation commands + } + if r.OK { + break // successful non-observation command breaks streak + } + if r.Cmd == cmd && r.Selector == selector { + count++ + } else { + break // different command/selector breaks streak + } + } + return count +} + +// reportStuck writes escalating context to stderr based on the failure count. +func reportStuck(count int) { + switch { + case count <= 1: + // nothing + case count == 2: + context("this command has failed %d times with the same error — try a different selector", count) + default: + context("STUCK — this command has failed %d times in a row", count) + context("stop retrying and try a different approach:") + context(" rodney discover --interactive") + context(" rodney ax-find --role button") + context(" rodney waitstable && rodney click \"\"") + } +} + func main() { if len(os.Args) < 2 { printUsage() @@ -933,11 +1020,16 @@ func cmdClick(args []string) { _, _, page := withPage() el, err := page.Element(args[0]) if err != nil { + reportStuck(checkStuck("click", args[0])) + recordCall("click", args[0], false, fmt.Sprintf("%v", err)) fatal("element not found: %v", err) } if err := el.Click(proto.InputMouseButtonLeft, 1); err != nil { + reportStuck(checkStuck("click", args[0])) + recordCall("click", args[0], false, fmt.Sprintf("%v", err)) fatal("click failed: %v", err) } + recordCall("click", args[0], true, "") // Brief pause for click handlers to execute time.Sleep(100 * time.Millisecond) fmt.Println("Clicked") @@ -950,10 +1042,13 @@ func cmdInput(args []string) { _, _, page := withPage() el, err := page.Element(args[0]) if err != nil { + reportStuck(checkStuck("input", args[0])) + recordCall("input", args[0], false, fmt.Sprintf("%v", err)) fatal("element not found: %v", err) } text := strings.Join(args[1:], " ") el.MustSelectAllText().MustInput(text) + recordCall("input", args[0], true, "") fmt.Printf("Typed: %s\n", text) } @@ -964,9 +1059,12 @@ func cmdClear(args []string) { _, _, page := withPage() el, err := page.Element(args[0]) if err != nil { + reportStuck(checkStuck("clear", args[0])) + recordCall("clear", args[0], false, fmt.Sprintf("%v", err)) fatal("element not found: %v", err) } el.MustSelectAllText().MustInput("") + recordCall("clear", args[0], true, "") fmt.Println("Cleared") } @@ -1178,8 +1276,11 @@ func cmdSelect(args []string) { }`, args[0], args[1]) result, err := page.Eval(js) if err != nil { + reportStuck(checkStuck("select", args[0])) + recordCall("select", args[0], false, fmt.Sprintf("%v", err)) fatal("select failed: %v", err) } + recordCall("select", args[0], true, "") fmt.Printf("Selected: %s\n", result.Value.Str()) } @@ -1190,9 +1291,12 @@ func cmdSubmit(args []string) { _, _, page := withPage() _, err := page.Element(args[0]) if err != nil { + reportStuck(checkStuck("submit", args[0])) + recordCall("submit", args[0], false, fmt.Sprintf("%v", err)) fatal("form not found: %v", err) } page.MustEval(fmt.Sprintf(`() => document.querySelector(%q).submit()`, args[0])) + recordCall("submit", args[0], true, "") fmt.Println("Submitted") } @@ -1203,9 +1307,12 @@ func cmdHover(args []string) { _, _, page := withPage() el, err := page.Element(args[0]) if err != nil { + reportStuck(checkStuck("hover", args[0])) + recordCall("hover", args[0], false, fmt.Sprintf("%v", err)) fatal("element not found: %v", err) } el.MustHover() + recordCall("hover", args[0], true, "") fmt.Println("Hovered") } @@ -1216,9 +1323,12 @@ func cmdFocus(args []string) { _, _, page := withPage() el, err := page.Element(args[0]) if err != nil { + reportStuck(checkStuck("focus", args[0])) + recordCall("focus", args[0], false, fmt.Sprintf("%v", err)) fatal("element not found: %v", err) } el.MustFocus() + recordCall("focus", args[0], true, "") fmt.Println("Focused") } @@ -1229,9 +1339,12 @@ func cmdWait(args []string) { _, _, page := withPage() el, err := page.Element(args[0]) if err != nil { + reportStuck(checkStuck("wait", args[0])) + recordCall("wait", args[0], false, fmt.Sprintf("%v", err)) fatal("element not found: %v", err) } el.MustWaitVisible() + recordCall("wait", args[0], true, "") fmt.Println("Element visible") } diff --git a/main_test.go b/main_test.go index c3db273..5fa069c 100644 --- a/main_test.go +++ b/main_test.go @@ -2113,3 +2113,424 @@ func TestParseStartFlags_UnknownFlag(t *testing.T) { t.Errorf("expected 'unknown flag: --bogus' in error, got: %v", err) } } + +// ===================== +// Repeated failure detection tests +// ===================== + +// captureStderr captures everything written to os.Stderr by fn. +func captureStderr(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + oldStderr := os.Stderr + os.Stderr = w + fn() + w.Close() + os.Stderr = oldStderr + out, err := io.ReadAll(r) + if err != nil { + t.Fatalf("captureStderr read: %v", err) + } + return strings.TrimSpace(string(out)) +} + +func TestCallRecord_JSONRoundTrip(t *testing.T) { + rec := CallRecord{ + Cmd: "click", + Selector: "#btn", + OK: false, + Error: "element not found", + TS: 1712160000, + } + data, err := json.Marshal(rec) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + var loaded CallRecord + if err := json.Unmarshal(data, &loaded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + if loaded.Cmd != rec.Cmd { + t.Errorf("Cmd: got %q, want %q", loaded.Cmd, rec.Cmd) + } + if loaded.Selector != rec.Selector { + t.Errorf("Selector: got %q, want %q", loaded.Selector, rec.Selector) + } + if loaded.OK != rec.OK { + t.Errorf("OK: got %v, want %v", loaded.OK, rec.OK) + } + if loaded.Error != rec.Error { + t.Errorf("Error: got %q, want %q", loaded.Error, rec.Error) + } + if loaded.TS != rec.TS { + t.Errorf("TS: got %d, want %d", loaded.TS, rec.TS) + } +} + +func TestCallRecord_JSONOmitsEmptySelector(t *testing.T) { + rec := CallRecord{Cmd: "click", OK: true, TS: 1} + data, err := json.Marshal(rec) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + if strings.Contains(string(data), `"sel"`) { + t.Errorf("empty selector should be omitted, got: %s", string(data)) + } +} + +func TestCallRecord_JSONOmitsEmptyError(t *testing.T) { + rec := CallRecord{Cmd: "click", OK: true, TS: 1} + data, err := json.Marshal(rec) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + if strings.Contains(string(data), `"err"`) { + t.Errorf("empty error should be omitted, got: %s", string(data)) + } +} + +func TestRecentCalls_StatePersistence(t *testing.T) { + state := &State{ + DebugURL: "ws://localhost:1234", + ChromePID: 12345, + DataDir: t.TempDir(), + RecentCalls: []CallRecord{ + {Cmd: "click", Selector: "#btn", OK: false, Error: "not found", TS: 1}, + {Cmd: "click", Selector: "#btn", OK: true, TS: 2}, + }, + } + 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 len(loaded.RecentCalls) != 2 { + t.Fatalf("expected 2 RecentCalls, got %d", len(loaded.RecentCalls)) + } + if loaded.RecentCalls[0].Cmd != "click" { + t.Errorf("RecentCalls[0].Cmd: got %q, want %q", loaded.RecentCalls[0].Cmd, "click") + } +} + +func TestRecentCalls_OmittedWhenEmpty(t *testing.T) { + 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) + } + if strings.Contains(string(data), "recent_calls") { + t.Errorf("empty RecentCalls should be omitted, got: %s", string(data)) + } +} + +func TestRecordCall_AddsEntries(t *testing.T) { + tmpDir := t.TempDir() + oldStateDir := activeStateDir + activeStateDir = tmpDir + t.Cleanup(func() { activeStateDir = oldStateDir }) + + // Create initial state + if err := saveState(&State{DebugURL: "ws://test", ChromePID: 1, DataDir: tmpDir}); err != nil { + t.Fatalf("saveState: %v", err) + } + + recordCall("click", "#btn", false, "not found") + recordCall("click", "#btn", false, "not found") + + s, err := loadState() + if err != nil { + t.Fatalf("loadState: %v", err) + } + if len(s.RecentCalls) != 2 { + t.Fatalf("expected 2 calls, got %d", len(s.RecentCalls)) + } + if s.RecentCalls[0].Cmd != "click" { + t.Errorf("expected cmd 'click', got %q", s.RecentCalls[0].Cmd) + } +} + +func TestRecordCall_TrimsTo10(t *testing.T) { + tmpDir := t.TempDir() + oldStateDir := activeStateDir + activeStateDir = tmpDir + t.Cleanup(func() { activeStateDir = oldStateDir }) + + if err := saveState(&State{DebugURL: "ws://test", ChromePID: 1, DataDir: tmpDir}); err != nil { + t.Fatalf("saveState: %v", err) + } + + for i := 0; i < 15; i++ { + recordCall("click", fmt.Sprintf("#btn%d", i), false, "not found") + } + + s, err := loadState() + if err != nil { + t.Fatalf("loadState: %v", err) + } + if len(s.RecentCalls) != 10 { + t.Fatalf("expected 10 calls after trimming, got %d", len(s.RecentCalls)) + } + // First entry should be btn5 (entries 0-4 trimmed away) + if s.RecentCalls[0].Selector != "#btn5" { + t.Errorf("expected first entry selector '#btn5', got %q", s.RecentCalls[0].Selector) + } +} + +func TestCheckStuck_NoHistory(t *testing.T) { + tmpDir := t.TempDir() + oldStateDir := activeStateDir + activeStateDir = tmpDir + t.Cleanup(func() { activeStateDir = oldStateDir }) + + if err := saveState(&State{DebugURL: "ws://test", ChromePID: 1, DataDir: tmpDir}); err != nil { + t.Fatalf("saveState: %v", err) + } + + count := checkStuck("click", "#btn") + if count != 0 { + t.Errorf("expected 0, got %d", count) + } +} + +func TestCheckStuck_TwoIdenticalFailures(t *testing.T) { + tmpDir := t.TempDir() + oldStateDir := activeStateDir + activeStateDir = tmpDir + t.Cleanup(func() { activeStateDir = oldStateDir }) + + if err := saveState(&State{ + DebugURL: "ws://test", + ChromePID: 1, + DataDir: tmpDir, + RecentCalls: []CallRecord{ + {Cmd: "click", Selector: "#btn", OK: false, Error: "not found", TS: 1}, + {Cmd: "click", Selector: "#btn", OK: false, Error: "not found", TS: 2}, + }, + }); err != nil { + t.Fatalf("saveState: %v", err) + } + + count := checkStuck("click", "#btn") + if count != 2 { + t.Errorf("expected 2, got %d", count) + } +} + +func TestCheckStuck_ThreeIdenticalFailures(t *testing.T) { + tmpDir := t.TempDir() + oldStateDir := activeStateDir + activeStateDir = tmpDir + t.Cleanup(func() { activeStateDir = oldStateDir }) + + if err := saveState(&State{ + DebugURL: "ws://test", + ChromePID: 1, + DataDir: tmpDir, + RecentCalls: []CallRecord{ + {Cmd: "click", Selector: "#btn", OK: false, Error: "not found", TS: 1}, + {Cmd: "click", Selector: "#btn", OK: false, Error: "not found", TS: 2}, + {Cmd: "click", Selector: "#btn", OK: false, Error: "not found", TS: 3}, + }, + }); err != nil { + t.Fatalf("saveState: %v", err) + } + + count := checkStuck("click", "#btn") + if count != 3 { + t.Errorf("expected 3, got %d", count) + } +} + +func TestCheckStuck_ObservationsDontBreakStreak(t *testing.T) { + tmpDir := t.TempDir() + oldStateDir := activeStateDir + activeStateDir = tmpDir + t.Cleanup(func() { activeStateDir = oldStateDir }) + + if err := saveState(&State{ + DebugURL: "ws://test", + ChromePID: 1, + DataDir: tmpDir, + RecentCalls: []CallRecord{ + {Cmd: "click", Selector: "#btn", OK: false, Error: "not found", TS: 1}, + {Cmd: "screenshot", OK: true, TS: 2}, + {Cmd: "click", Selector: "#btn", OK: false, Error: "not found", TS: 3}, + {Cmd: "exists", Selector: "#btn", OK: true, TS: 4}, + {Cmd: "click", Selector: "#btn", OK: false, Error: "not found", TS: 5}, + }, + }); err != nil { + t.Fatalf("saveState: %v", err) + } + + count := checkStuck("click", "#btn") + if count != 3 { + t.Errorf("expected 3 (observations skipped), got %d", count) + } +} + +func TestCheckStuck_SuccessBreaksStreak(t *testing.T) { + tmpDir := t.TempDir() + oldStateDir := activeStateDir + activeStateDir = tmpDir + t.Cleanup(func() { activeStateDir = oldStateDir }) + + if err := saveState(&State{ + DebugURL: "ws://test", + ChromePID: 1, + DataDir: tmpDir, + RecentCalls: []CallRecord{ + {Cmd: "click", Selector: "#btn", OK: false, Error: "not found", TS: 1}, + {Cmd: "click", Selector: "#btn", OK: false, Error: "not found", TS: 2}, + {Cmd: "click", Selector: "#other", OK: true, TS: 3}, + {Cmd: "click", Selector: "#btn", OK: false, Error: "not found", TS: 4}, + }, + }); err != nil { + t.Fatalf("saveState: %v", err) + } + + count := checkStuck("click", "#btn") + if count != 1 { + t.Errorf("expected 1 (success broke streak), got %d", count) + } +} + +func TestCheckStuck_DifferentCmdBreaksStreak(t *testing.T) { + tmpDir := t.TempDir() + oldStateDir := activeStateDir + activeStateDir = tmpDir + t.Cleanup(func() { activeStateDir = oldStateDir }) + + if err := saveState(&State{ + DebugURL: "ws://test", + ChromePID: 1, + DataDir: tmpDir, + RecentCalls: []CallRecord{ + {Cmd: "hover", Selector: "#btn", OK: false, Error: "not found", TS: 1}, + {Cmd: "click", Selector: "#btn", OK: false, Error: "not found", TS: 2}, + }, + }); err != nil { + t.Fatalf("saveState: %v", err) + } + + count := checkStuck("click", "#btn") + if count != 1 { + t.Errorf("expected 1 (different cmd broke streak), got %d", count) + } +} + +func TestReportStuck_NoOutput_Count0(t *testing.T) { + out := captureStderr(t, func() { reportStuck(0) }) + if out != "" { + t.Errorf("expected no output for count 0, got %q", out) + } +} + +func TestReportStuck_NoOutput_Count1(t *testing.T) { + out := captureStderr(t, func() { reportStuck(1) }) + if out != "" { + t.Errorf("expected no output for count 1, got %q", out) + } +} + +func TestReportStuck_Count2(t *testing.T) { + out := captureStderr(t, func() { reportStuck(2) }) + if !strings.Contains(out, "failed 2 times") { + t.Errorf("expected 'failed 2 times' message, got %q", out) + } + if !strings.Contains(out, "try a different selector") { + t.Errorf("expected 'try a different selector' suggestion, got %q", out) + } +} + +func TestReportStuck_Count3(t *testing.T) { + out := captureStderr(t, func() { reportStuck(3) }) + if !strings.Contains(out, "STUCK") { + t.Errorf("expected 'STUCK' message, got %q", out) + } + if !strings.Contains(out, "failed 3 times") { + t.Errorf("expected 'failed 3 times' message, got %q", out) + } + if !strings.Contains(out, "discover --interactive") { + t.Errorf("expected 'discover --interactive' suggestion, got %q", out) + } + if !strings.Contains(out, "ax-find") { + t.Errorf("expected 'ax-find' suggestion, got %q", out) + } + if !strings.Contains(out, "waitstable") { + t.Errorf("expected 'waitstable' suggestion, got %q", out) + } +} + +func TestReportStuck_Count5(t *testing.T) { + out := captureStderr(t, func() { reportStuck(5) }) + if !strings.Contains(out, "STUCK") { + t.Errorf("expected 'STUCK' message for count 5, got %q", out) + } + if !strings.Contains(out, "failed 5 times") { + t.Errorf("expected 'failed 5 times' message, got %q", out) + } +} + +func TestCheckStuck_AllObservationCmds(t *testing.T) { + // Verify all observation commands are properly skipped + obsCmds := []string{"url", "title", "text", "html", "screenshot", "screenshot-el", + "exists", "visible", "count", "ax-tree", "ax-find", "ax-node", + "discover", "pages", "status", "logs", "pdf", "attr"} + + for _, obs := range obsCmds { + tmpDir := t.TempDir() + oldStateDir := activeStateDir + activeStateDir = tmpDir + + if err := saveState(&State{ + DebugURL: "ws://test", + ChromePID: 1, + DataDir: tmpDir, + RecentCalls: []CallRecord{ + {Cmd: "click", Selector: "#btn", OK: false, Error: "not found", TS: 1}, + {Cmd: obs, OK: true, TS: 2}, + {Cmd: "click", Selector: "#btn", OK: false, Error: "not found", TS: 3}, + }, + }); err != nil { + activeStateDir = oldStateDir + t.Fatalf("saveState for obs %q: %v", obs, err) + } + + count := checkStuck("click", "#btn") + if count != 2 { + t.Errorf("observation cmd %q should not break streak: expected 2, got %d", obs, count) + } + activeStateDir = oldStateDir + } +} + +func TestStartClearsRecentCalls(t *testing.T) { + // When cmdStart creates a fresh State, RecentCalls should be nil/empty + state := &State{ + DebugURL: "ws://localhost:1234", + ChromePID: 12345, + DataDir: t.TempDir(), + } + if len(state.RecentCalls) != 0 { + t.Errorf("fresh State should have no RecentCalls, got %d", len(state.RecentCalls)) + } + + // Verify it's omitted in JSON + data, err := json.Marshal(state) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + if strings.Contains(string(data), "recent_calls") { + t.Errorf("fresh state JSON should not contain recent_calls, got: %s", string(data)) + } +} From 8bb96a8468d0d485022f86d93582915506f5e39b Mon Sep 17 00:00:00 2001 From: Jonathan Stockdill Date: Fri, 3 Apr 2026 16:10:17 -0400 Subject: [PATCH 21/30] Add upstream comparison script and README section --- README.md | 10 +++++++ scripts/compare-upstream.sh | 58 +++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100755 scripts/compare-upstream.sh diff --git a/README.md b/README.md index 8f1bfb6..dd79ed1 100644 --- a/README.md +++ b/README.md @@ -521,3 +521,13 @@ The tool uses the [rod](https://github.com/go-rod/rod) Go library which communic | `--global` | Use global session (`~/.rodney/`) | | `--version` | Print version and exit | | `--help`, `-h`, `help` | Show help message | + +## Comparing with upstream + +This fork adds features on top of [simonw/rodney](https://github.com/simonw/rodney). To see how this fork diverges from upstream: + +```bash +./scripts/compare-upstream.sh +``` + +The script fetches both remotes, shows commit divergence in each direction, and prints a file-level diff summary. If upstream has commits this fork is missing, it will suggest the merge command. diff --git a/scripts/compare-upstream.sh b/scripts/compare-upstream.sh new file mode 100755 index 0000000..6378dc6 --- /dev/null +++ b/scripts/compare-upstream.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Compare origin (fork) against upstream (simonw/rodney). +# Fetches latest from both remotes and shows divergence. + +UPSTREAM="simonw" +UPSTREAM_BRANCH="main" +ORIGIN_BRANCH="main" + +# Ensure the upstream remote exists +if ! git remote get-url "$UPSTREAM" &>/dev/null; then + echo "Adding upstream remote..." + git remote add "$UPSTREAM" "https://github.com/simonw/rodney.git" +fi + +echo "Fetching latest from $UPSTREAM and origin..." +git fetch "$UPSTREAM" --quiet +git fetch origin --quiet + +echo "" +echo "=== Upstream ($UPSTREAM/$UPSTREAM_BRANCH) ===" +echo "Latest: $(git log --oneline -1 $UPSTREAM/$UPSTREAM_BRANCH)" +echo "" +echo "=== Origin (origin/$ORIGIN_BRANCH) ===" +echo "Latest: $(git log --oneline -1 origin/$ORIGIN_BRANCH)" + +# Commits in origin not in upstream (our additions) +AHEAD=$(git rev-list --count "$UPSTREAM/$UPSTREAM_BRANCH..origin/$ORIGIN_BRANCH") +# Commits in upstream not in origin (upstream additions we're missing) +BEHIND=$(git rev-list --count "origin/$ORIGIN_BRANCH..$UPSTREAM/$UPSTREAM_BRANCH") + +echo "" +echo "=== Divergence ===" +echo "Origin is $AHEAD commits ahead of upstream" +echo "Origin is $BEHIND commits behind upstream" + +if [ "$AHEAD" -gt 0 ]; then + echo "" + echo "--- Commits in origin not in upstream ($AHEAD) ---" + git log --oneline "$UPSTREAM/$UPSTREAM_BRANCH..origin/$ORIGIN_BRANCH" +fi + +if [ "$BEHIND" -gt 0 ]; then + echo "" + echo "--- Commits in upstream not in origin ($BEHIND) ---" + git log --oneline "origin/$ORIGIN_BRANCH..$UPSTREAM/$UPSTREAM_BRANCH" +fi + +# File-level diff summary +echo "" +echo "=== File diff summary (origin vs upstream) ===" +git diff --stat "$UPSTREAM/$UPSTREAM_BRANCH..origin/$ORIGIN_BRANCH" + +if [ "$BEHIND" -gt 0 ]; then + echo "" + echo "NOTE: Run 'git merge $UPSTREAM/$UPSTREAM_BRANCH' to pull in upstream changes." +fi From 781bd1cbbc63dfdbb7e8fc8a0a9d51fe42ff4ab1 Mon Sep 17 00:00:00 2001 From: jstockdi Date: Thu, 9 Apr 2026 09:24:33 -0400 Subject: [PATCH 22/30] Use system-installed Chrome before falling back to auto-download (#2) launcher.New() skips rod's own LookPath() and tries to download Chromium, even when a system browser (e.g. /usr/bin/chromium) is already installed. Add a LookPath() fallback between the explicit ROD_CHROME_BIN env var check and rod's default auto-download. --- main.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/main.go b/main.go index cf6d5c7..8bcc813 100644 --- a/main.go +++ b/main.go @@ -785,6 +785,8 @@ func cmdStart(args []string) { if bin := os.Getenv("ROD_CHROME_BIN"); bin != "" { l = l.Bin(bin) + } else if found, ok := launcher.LookPath(); ok { + l = l.Bin(found) } // Detect authenticated proxy and launch helper if needed From ec215510a8a11159ab7c99a901a310a6d8a0034b Mon Sep 17 00:00:00 2001 From: Jonathan Stockdill Date: Mon, 13 Apr 2026 15:25:48 -0400 Subject: [PATCH 23/30] Fix Chrome launch crashes on macOS desktop Two regressions made `rodney start` unusable on macOS when a regular Chrome was already running: 1. Commit 781bd1c preferred system Chrome via launcher.LookPath(), but Google Chrome.app enforces single-instance per bundle on macOS. When the user's regular Chrome was running, the rodney-launched process was absorbed and exited immediately. Skip LookPath on darwin so rodney falls back to go-rod's auto-downloaded Chromium, which has its own bundle and coexists cleanly. Other platforms keep the LookPath behavior for /usr/bin/chromium reuse. 2. --single-process was hardcoded since c164016 as a workaround for Chrome's compositor hanging under gVisor (Claude's sandbox), but it causes frequent crashes on real sites in normal desktop Chrome. Replace the hardcoded flag with --single-process auto|on|off, where auto (the default) enables it only when isGVisor() detects gVisor via /proc/version. Desktop gets stable multi-process Chrome; gVisor environments still get the screenshot fix automatically. --- help.txt | 4 +++- main.go | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/help.txt b/help.txt index 4e45961..fcf48c9 100644 --- a/help.txt +++ b/help.txt @@ -1,8 +1,10 @@ rodney - Chrome automation from the command line Browser lifecycle: - rodney start [--show] [--insecure | -k] [--logs] [--fake-media] [--viewport WxH] [--mobile] [--scale N] + rodney start [--show] [--insecure | -k] [--logs] [--fake-media] [--stealth] + [--single-process auto|on|off] [--viewport WxH] [--mobile] [--scale N] Launch Chrome (headless by default, --show for visible) + --single-process defaults to "auto" (on under gVisor, off elsewhere) rodney connect Connect to existing Chrome on remote debug port rodney stop Shut down Chrome rodney status Show browser status diff --git a/main.go b/main.go index 8bcc813..2542d64 100644 --- a/main.go +++ b/main.go @@ -16,6 +16,7 @@ import ( "os/exec" "os/signal" "path/filepath" + "runtime" "strconv" "strings" "sync" @@ -192,6 +193,24 @@ func hint(format string, args ...interface{}) { fmt.Fprintf(os.Stderr, "hint: "+format+"\n", args...) } +// isGVisor reports whether the current process is running under gVisor. +// Chrome's multi-process compositor hangs under gVisor's seccomp+ptrace +// syscall interception, so screenshots require --single-process there. +// Detection reads /proc/version, which gVisor populates with a signature +// like "Linux version 4.4.0 ... #1 SMP Sun Jan 10 15:06:54 PST 2016". +// The reliable marker is the kernel release string "gvisor" shown by +// uname -a under runsc. Returns false on non-Linux. +func isGVisor() bool { + if runtime.GOOS != "linux" { + return false + } + data, err := os.ReadFile("/proc/version") + if err != nil { + return false + } + return strings.Contains(strings.ToLower(string(data)), "gvisor") +} + // context writes a contextual hint to stderr to help agents self-correct. func context(format string, args ...interface{}) { fmt.Fprintf(os.Stderr, "context: "+format+"\n", args...) @@ -659,6 +678,7 @@ type startFlags struct { enableLogs bool fakeMedia bool stealth bool + singleProcess string // "auto" (default), "on", "off" vpWidth int vpHeight int vpScale float64 @@ -692,12 +712,21 @@ func parseStartFlags(args []string) (startFlags, error) { stealth := fs.Bool("stealth", false, "") mobile := fs.Bool("mobile", false, "") scale := fs.Float64("scale", 0, "") + singleProcess := fs.String("single-process", "auto", "") + + usage := "usage: rodney start [--show] [--insecure | -k] [--logs] [--fake-media] [--stealth] [--single-process auto|on|off] [--viewport WxH] [--mobile] [--scale N]" if err := fs.Parse(filtered); err != nil { - return startFlags{}, fmt.Errorf("unknown flag: %s\nusage: rodney start [--show] [--insecure | -k] [--logs] [--fake-media] [--stealth] [--viewport WxH] [--mobile] [--scale N]", findUnknownFlag(filtered, fs)) + return startFlags{}, fmt.Errorf("unknown flag: %s\n%s", findUnknownFlag(filtered, fs), usage) } if fs.NArg() > 0 { - return startFlags{}, fmt.Errorf("unknown flag: %s\nusage: rodney start [--show] [--insecure | -k] [--logs] [--fake-media] [--stealth] [--viewport WxH] [--mobile] [--scale N]", fs.Arg(0)) + return startFlags{}, fmt.Errorf("unknown flag: %s\n%s", fs.Arg(0), usage) + } + + switch *singleProcess { + case "auto", "on", "off": + default: + return startFlags{}, fmt.Errorf("invalid --single-process value %q (expected auto, on, or off)", *singleProcess) } f := startFlags{ @@ -706,6 +735,7 @@ func parseStartFlags(args []string) (startFlags, error) { enableLogs: *logs, fakeMedia: *fakeMedia, stealth: *stealth, + singleProcess: *singleProcess, vpMobile: *mobile, vpScale: *scale, } @@ -767,11 +797,26 @@ 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) + // --single-process is required for screenshots under gVisor (its seccomp + // shim breaks Chrome's multi-process compositor) but causes frequent + // crashes on desktop. Auto-detect gVisor; allow explicit override. + useSingleProcess := false + switch flags.singleProcess { + case "on": + useSingleProcess = true + case "off": + useSingleProcess = false + default: // "auto" + useSingleProcess = isGVisor() + } + if useSingleProcess { + l = l.Set("single-process") + } + // 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 { @@ -785,8 +830,14 @@ func cmdStart(args []string) { if bin := os.Getenv("ROD_CHROME_BIN"); bin != "" { l = l.Bin(bin) - } else if found, ok := launcher.LookPath(); ok { - l = l.Bin(found) + } else if runtime.GOOS != "darwin" { + // On macOS, Google Chrome.app enforces single-instance per bundle, so + // launching it while the user's regular Chrome is running causes the + // new process to be absorbed and exit. Prefer go-rod's auto-downloaded + // Chromium there. On other platforms, reuse system Chrome/Chromium. + if found, ok := launcher.LookPath(); ok { + l = l.Bin(found) + } } // Detect authenticated proxy and launch helper if needed From 7beaabeba3a7c2c39c66bee7765d4729b8f3abdb Mon Sep 17 00:00:00 2001 From: jstockdi Date: Wed, 6 May 2026 16:00:16 -0400 Subject: [PATCH 24/30] Add dependency-review workflow (scaffolded by repocat) --- .github/workflows/dependency-review.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .github/workflows/dependency-review.yml diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..dab3cd6 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,14 @@ +name: Dependency Review + +on: + pull_request: + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 From 1e039ad0c95b8de4f2c1f0e17f0aa6fc4323364e Mon Sep 17 00:00:00 2001 From: jstockdi Date: Wed, 6 May 2026 16:04:32 -0400 Subject: [PATCH 25/30] Pin GitHub Actions to commit SHAs (#4) All workflow actions now reference immutable commit SHAs with the original tag preserved as a trailing comment. Required by the Battle-Creek-LLC repocat baseline (AC-6, SR-3) to prevent silent upstream changes via tag re-pointing. Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/publish.yml | 22 +++++++++++----------- .github/workflows/test.yml | 4 ++-- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7718c02..01fe9bd 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -28,9 +28,9 @@ jobs: # goarch: arm64 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version: '1.24.x' - name: Build binaries @@ -76,7 +76,7 @@ jobs: dist_bin="dist/rodney-linux-amd64/rodney" "$dist_bin" --version | grep -Fx "$version" - name: Upload build artifacts - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: rodney-${{ matrix.goos }}-${{ matrix.goarch }} path: | @@ -88,12 +88,12 @@ jobs: needs: build-binaries steps: - name: Download build artifacts - uses: actions/download-artifact@v6 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 with: path: dist merge-multiple: true - name: Upload release assets - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: files: dist/* env: @@ -102,13 +102,13 @@ jobs: build-wheels: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version: '1.24.x' - name: Install uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@d0d8abe699bfb85fec6de9f7adb5ae17292296ff # v6 - name: Build wheels run: | version="${GITHUB_REF_NAME#v}" @@ -121,7 +121,7 @@ jobs: --set-version-var main.version \ --version "$version" - name: Store the distribution packages - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: python-packages path: dist/ @@ -135,9 +135,9 @@ jobs: id-token: write steps: - name: Download distribution packages - uses: actions/download-artifact@v6 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 with: name: python-packages path: dist/ - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b6d55e3..504f227 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,9 +11,9 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version: '1.24' - name: Build From 1352acb4cf4a3ad2d28a10609814f19f0549584c Mon Sep 17 00:00:00 2001 From: jstockdi Date: Wed, 6 May 2026 16:13:04 -0400 Subject: [PATCH 26/30] Add SECURITY.md (#3) Standard private vulnerability reporting policy. Required by repocat baseline (CM-2). Co-authored-by: Claude Opus 4.7 (1M context) --- SECURITY.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..dd5a68d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,16 @@ +# Security Policy + +## Reporting a Vulnerability + +Please report security vulnerabilities privately via GitHub's +[private vulnerability reporting](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability) +feature on this repository's **Security** tab. + +We aim to acknowledge reports within 5 business days and provide an +initial assessment within 10 business days. Please do not file public +issues for security-sensitive reports. + +## Supported Versions + +This project follows a rolling-release model — only the current `main` +branch is supported. From 73e9c89f0833ac0d6042b357d4f4aed803ff8dea Mon Sep 17 00:00:00 2001 From: jstockdi Date: Wed, 6 May 2026 16:13:12 -0400 Subject: [PATCH 27/30] Add CHANGELOG.md (#5) Adopts the Keep a Changelog format with an initial 0.1.0 entry summarizing the Battle-Creek-LLC fork's divergence from upstream simonw/rodney. Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..082c718 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.0] - 2026-05-06 + +Initial release of the Battle-Creek-LLC fork of [simonw/rodney](https://github.com/simonw/rodney). + +### Added +- Forked upstream `simonw/rodney` and merged through commit `8325836`. +- Use system-installed Chrome before falling back to auto-download (#2). +- Crash-loop detection via state ring buffer. +- Contextual error suggestions for common failures. +- `--text` and `--gone` options on the `wait` command. +- `--stealth` flag to remove automation fingerprints. +- `--forms`, `--links`, `--interactive` modes on `discover`. +- Accessibility selectors (`--role`, `--name`) on action commands. +- Composable `check` command for batched assertions. +- Upstream-comparison helper script and README section. +- Repository-policy baseline via repocat (branch protection, signed commits, + secret scanning, Dependabot, dependency-review workflow, pinned action SHAs). + +### Fixed +- Chrome launch crashes on macOS desktop. + +[Unreleased]: https://github.com/Battle-Creek-LLC/rodney/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/Battle-Creek-LLC/rodney/releases/tag/v0.1.0 From 0ada274094141948ae8519ab16a3fca76f125810 Mon Sep 17 00:00:00 2001 From: jstockdi Date: Wed, 6 May 2026 16:13:21 -0400 Subject: [PATCH 28/30] Drop PyPI publish path and rebrand to Battle-Creek-LLC (#6) - Remove build-wheels and PyPI publish jobs from publish.yml; this fork doesn't own the rodney name on PyPI and the trusted publisher binding belongs to upstream. - Rename module to github.com/battle-creek-llc/rodney so \`go install github.com/battle-creek-llc/rodney@latest\` resolves to this fork via the Go module proxy. (Lowercase per Go module convention to avoid case-escaping in the proxy.) - Update README badges and install instructions: drop PyPI/uv sections, point Changelog/Tests/License/releases links at Battle-Creek-LLC, call out the upstream attribution. Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/publish.yml | 43 ----------------------------------- README.md | 33 ++++++++------------------- go.mod | 2 +- 3 files changed, 11 insertions(+), 67 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 01fe9bd..a55f2f9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -98,46 +98,3 @@ jobs: files: dist/* env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - build-wheels: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 - with: - go-version: '1.24.x' - - name: Install uv - uses: astral-sh/setup-uv@d0d8abe699bfb85fec6de9f7adb5ae17292296ff # v6 - - name: Build wheels - run: | - version="${GITHUB_REF_NAME#v}" - uvx go-to-wheel . \ - --readme README.md \ - --description "Chrome automation from the command line" \ - --author 'Simon Willison' \ - --license Apache-2.0 \ - --url https://github.com/simonw/rodney \ - --set-version-var main.version \ - --version "$version" - - name: Store the distribution packages - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: python-packages - path: dist/ - - publish: - name: Publish to PyPI - runs-on: ubuntu-latest - needs: [build-wheels] - environment: release - permissions: - id-token: write - steps: - - name: Download distribution packages - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 - with: - name: python-packages - path: dist/ - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 diff --git a/README.md b/README.md index dd79ed1..66f1f24 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,24 @@ # Rodney: Chrome automation from the command line -[![PyPI](https://img.shields.io/pypi/v/rodney.svg)](https://pypi.org/project/rodney/) -[![Changelog](https://img.shields.io/github/v/release/simonw/rodney?include_prereleases&label=changelog)](https://github.com/simonw/rodney/releases) -[![Tests](https://github.com/simonw/rodney/actions/workflows/test.yml/badge.svg)](https://github.com/simonw/rodney/actions/workflows/test.yml) -[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/simonw/rodney/blob/main/LICENSE) +[![Changelog](https://img.shields.io/github/v/release/Battle-Creek-LLC/rodney?include_prereleases&label=changelog)](https://github.com/Battle-Creek-LLC/rodney/releases) +[![Tests](https://github.com/Battle-Creek-LLC/rodney/actions/workflows/test.yml/badge.svg)](https://github.com/Battle-Creek-LLC/rodney/actions/workflows/test.yml) +[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/Battle-Creek-LLC/rodney/blob/main/LICENSE) A Go CLI tool that drives a persistent headless Chrome instance using the [rod](https://github.com/go-rod/rod) browser automation library. Each command connects to the same long-running Chrome process, making it easy to script multi-step browser interactions from shell scripts or interactive use. -## Installation - -This Go tool can be installed directly [from PyPI](https://pypi.org/project/rodney/) using `pip` or `uv`. +This is the Battle-Creek-LLC fork of [simonw/rodney](https://github.com/simonw/rodney). -You can run it without installing it first using `uvx`: - -```bash -uvx rodney --help -``` -Or install it like this, then run `rodney --help`: -```bash -uv tool install rodney -# or -pip install rodney -``` +## Installation -You can also install the Go binary directly: +Install the Go binary directly: ```bash -go install github.com/simonw/rodney@latest +go install github.com/battle-creek-llc/rodney@latest ``` -Or run it without installation like this: +Or run it without installation: ```bash -go run github.com/simonw/rodney@latest --help +go run github.com/battle-creek-llc/rodney@latest --help ``` -Compiled binaries are available [on the releases page](https://github.com/simonw/rodney/releases). On macOS you may need to [follow these extra steps](https://support.apple.com/en-us/102445) to use those. +Compiled binaries are available [on the releases page](https://github.com/Battle-Creek-LLC/rodney/releases). On macOS you may need to [follow these extra steps](https://support.apple.com/en-us/102445) to use those. ## Building diff --git a/go.mod b/go.mod index ccf932a..576d636 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/simonw/rodney +module github.com/battle-creek-llc/rodney go 1.24.7 From 3c2c02bc853b6f23b2a6b81db61a0901a14aa60e Mon Sep 17 00:00:00 2001 From: jstockdi Date: Sat, 23 May 2026 09:56:55 -0400 Subject: [PATCH 29/30] Add Semgrep code-scanning workflow (scaffolded by repocat) --- .github/workflows/semgrep.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/semgrep.yml diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml new file mode 100644 index 0000000..a8ee246 --- /dev/null +++ b/.github/workflows/semgrep.yml @@ -0,0 +1,24 @@ +name: Semgrep + +on: + pull_request: + push: + branches: [main] + schedule: + - cron: "0 6 * * 1" + +permissions: + contents: read + security-events: write + +jobs: + semgrep: + name: semgrep + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - run: pipx run semgrep scan --config p/default --config p/secrets --sarif --output semgrep.sarif + - uses: github/codeql-action/upload-sarif@84498526a009a99c875e83ef4821a8ba52de7c22 + if: always() + with: + sarif_file: semgrep.sarif From 53e03bd16a4ef2552867995853a27098bdd4b5ee Mon Sep 17 00:00:00 2001 From: jstockdi Date: Sat, 23 May 2026 10:32:21 -0400 Subject: [PATCH 30/30] Suppress false-positive Semgrep dangerous-exec-command findings (#7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both exec.Command calls in main.go (the _proxy and _logger helper launches) re-invoke our own binary via os.Executable(). They use no shell, and every argument is a literal subcommand, a locally-bound port, the tool's own DevTools URL, or operator-supplied local config (HTTP(S)_PROXY / state dir) — none of which is attacker-controlled. Add inline `// nosemgrep` comments with justifications. No runtime behavior change. Verified with `go build ./...` and `go vet ./...`. Co-authored-by: Claude Opus 4.7 (1M context) --- main.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/main.go b/main.go index 2542d64..afbac9e 100644 --- a/main.go +++ b/main.go @@ -855,6 +855,7 @@ func cmdStart(args []string) { // Launch ourselves as the proxy helper in the background exe, _ := os.Executable() + // nosemgrep: go.lang.security.audit.dangerous-exec-command -- exe is os.Executable() (our own binary); no shell, args are a literal subcommand, a locally-bound port, and operator HTTP(S)_PROXY config — not attacker input. cmd := exec.Command(exe, "_proxy", strconv.Itoa(proxyPort), server, authHeader) setSysProcAttr(cmd) @@ -893,6 +894,7 @@ func cmdStart(args []string) { logsDir := filepath.Join(stateDir(), "logs") os.MkdirAll(logsDir, 0755) exe, _ := os.Executable() + // nosemgrep: go.lang.security.audit.dangerous-exec-command -- exe is os.Executable() (our own binary); no shell, args are a literal subcommand, the locally-generated DevTools URL, and our own state dir — not attacker input. cmd := exec.Command(exe, "_logger", debugURL, logsDir) setSysProcAttr(cmd) if err := cmd.Start(); err != nil {