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 1/2] 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 2/2] 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()