Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -418,7 +423,7 @@ The tool uses the [rod](https://github.com/go-rod/rod) Go library which communic
| `text` | `<selector>` | Print element text content |
| `attr` | `<selector> <name>` | Print attribute value |
| `pdf` | `[file]` | Save page as PDF |
| `js` | `<expression>` | Evaluate JavaScript |
| `js` | `<expression>\|-` | Evaluate JavaScript (`-` or no arg reads from stdin) |
| `click` | `<selector>` | Click element |
| `input` | `<selector> <text>` | Type into input |
| `clear` | `<selector>` | Clear input |
Expand Down
4 changes: 2 additions & 2 deletions help.txt
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Page info:
rodney pdf [file] Save page as PDF

Interaction:
rodney js <expression> Evaluate JavaScript expression
rodney js <expression>|- Evaluate JavaScript expression (- reads from stdin)
rodney click <selector> Click an element
rodney input <selector> <text> Type text into an input field
rodney clear <selector> Clear an input field
Expand Down Expand Up @@ -54,7 +54,7 @@ Element checks:
rodney exists <selector> Check if element exists (exit 1 if not)
rodney count <selector> Count matching elements
rodney visible <selector> Check if element is visible (exit 1 if not)
rodney assert <expr> [expected] [-m msg] Assert JS expression (truthy or equality)
rodney assert <expr>|- [expected] [-m msg] Assert JS expression (truthy or equality; - reads expr from stdin)

Accessibility:
rodney ax-tree [--depth N] [--json] Dump accessibility tree
Expand Down
73 changes: 67 additions & 6 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -715,10 +715,25 @@ func cmdPDF(args []string) {
}

func cmdJS(args []string) {
if len(args) < 1 {
fatal("usage: rodney js <expression>")
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 <expression>")
}
}
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
Expand Down Expand Up @@ -1431,10 +1446,56 @@ 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 <js-expression> [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 <js-expression> [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 == "" {
Expand Down
198 changes: 195 additions & 3 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -1074,6 +1075,197 @@ 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)
}
}

// ======================
// 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()
Expand Down