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 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7718c02..a55f2f9 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,56 +88,13 @@ 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: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - build-wheels: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version: '1.24.x' - - name: Install uv - uses: astral-sh/setup-uv@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@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@v6 - with: - name: python-packages - path: dist/ - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 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 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 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 diff --git a/README.md b/README.md index 3cb2e26..66f1f24 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,35 @@ # 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. +This is the Battle-Creek-LLC fork of [simonw/rodney](https://github.com/simonw/rodney). + +## Installation + +Install the Go binary directly: +```bash +go install github.com/battle-creek-llc/rodney@latest +``` +Or run it without installation: +```bash +go run github.com/battle-creek-llc/rodney@latest --help +``` +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 + +```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 +49,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 @@ -44,6 +57,8 @@ 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 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 @@ -73,6 +88,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 @@ -81,10 +114,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 @@ -116,10 +154,22 @@ 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 +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 @@ -381,7 +431,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 @@ -403,7 +453,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] [--fake-media] [--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 | @@ -418,7 +468,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 | @@ -435,6 +485,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 | @@ -443,6 +495,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 | @@ -455,3 +508,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/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. 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 diff --git a/help.txt b/help.txt index 79bac7f..fcf48c9 100644 --- a/help.txt +++ b/help.txt @@ -1,7 +1,10 @@ rodney - Chrome automation from the command line Browser lifecycle: - rodney start [--show] [--insecure | -k] Launch Chrome (headless by default, --show for visible) + rodney start [--show] [--insecure | -k] [--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 @@ -21,8 +24,11 @@ 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 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 @@ -44,6 +50,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 @@ -54,7 +64,10 @@ 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) + +Discovery: + rodney discover [--json] [--attr NAME] List data-testid elements with suggested commands Accessibility: rodney ax-tree [--depth N] [--json] Dump accessibility tree diff --git a/main.go b/main.go index a5188db..afbac9e 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,8 @@ package main import ( + "bufio" + stdctx "context" _ "embed" "encoding/base64" "encoding/json" @@ -14,8 +16,10 @@ import ( "os/exec" "os/signal" "path/filepath" + "runtime" "strconv" "strings" + "sync" "syscall" "time" @@ -77,14 +81,36 @@ 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"` 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 + + Stealth bool `json:"stealth,omitempty"` // stealth mode: remove automation fingerprints + + // 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"` + + // Ring buffer of recent command results for repeated-failure detection + RecentCalls []CallRecord `json:"recent_calls,omitempty"` } func stateDir() string { @@ -163,6 +189,266 @@ func fatal(format string, args ...interface{}) { os.Exit(2) } +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...) +} + +// inspectFailure runs a single JavaScript snippet on the page to gather context +// about why an element operation failed. It writes findings as context: lines to +// stderr. It must not panic or fatal if the inspection itself fails. +func inspectFailure(page *rod.Page, selector string) { + // Budget 200ms for the entire inspection + ctx, cancel := stdctx.WithTimeout(stdctx.Background(), 200*time.Millisecond) + defer cancel() + shortPage := page.Context(ctx) + + jsSnippet := fmt.Sprintf(`() => { + var selector = %q; + var result = {}; + result.readyState = document.readyState; + result.url = location.href; + result.title = document.title; + + // Check if selector matches but is hidden + try { + var el = document.querySelector(selector); + if (el) { + var style = getComputedStyle(el); + var rect = el.getBoundingClientRect(); + result.hidden = { + display: style.display, + visibility: style.visibility, + opacity: style.opacity, + width: rect.width, + height: rect.height + }; + } + } catch(e) {} + + // Find similar interactive elements + try { + var interactive = document.querySelectorAll( + 'button, a[href], input, select, textarea, [role="button"], [role="link"], [tabindex]' + ); + result.available = Array.from(interactive).slice(0, 20).map(function(el) { + return { + tag: el.tagName.toLowerCase(), + id: el.id || '', + classes: el.className || '', + text: (el.textContent || '').trim().slice(0, 50), + type: el.type || '', + name: el.name || '' + }; + }); + } catch(e) { result.available = []; } + + // Check for overlay at center of viewport + try { + var cx = window.innerWidth / 2, cy = window.innerHeight / 2; + var topEl = document.elementFromPoint(cx, cy); + if (topEl) { + var tz = getComputedStyle(topEl).zIndex; + if (tz !== 'auto' && parseInt(tz) > 100) { + result.overlay = { + tag: topEl.tagName.toLowerCase(), + id: topEl.id || '', + classes: topEl.className || '', + zIndex: tz + }; + } + } + } catch(e) {} + + // Check for auth patterns in URL + result.authPattern = /login|signin|sign-in|auth|sso|oauth/i.test(location.href) || + /login|sign.in/i.test(document.title); + + return JSON.stringify(result); +}`, selector) + + result, err := shortPage.Eval(jsSnippet) + if err != nil { + return + } + + raw := result.Value.Str() + if raw == "" { + return + } + + var data struct { + ReadyState string `json:"readyState"` + URL string `json:"url"` + Title string `json:"title"` + AuthPattern bool `json:"authPattern"` + Hidden *struct { + Display string `json:"display"` + Visibility string `json:"visibility"` + Opacity string `json:"opacity"` + Width float64 `json:"width"` + Height float64 `json:"height"` + } `json:"hidden"` + Available []struct { + Tag string `json:"tag"` + ID string `json:"id"` + Classes string `json:"classes"` + Text string `json:"text"` + Type string `json:"type"` + Name string `json:"name"` + } `json:"available"` + Overlay *struct { + Tag string `json:"tag"` + ID string `json:"id"` + Classes string `json:"classes"` + ZIndex string `json:"zIndex"` + } `json:"overlay"` + } + + if err := json.Unmarshal([]byte(raw), &data); err != nil { + return + } + + // Hidden element detection + if data.Hidden != nil { + if data.Hidden.Display == "none" { + context("'%s' exists but is hidden (display: none) — try 'rodney wait \"%s\"'", selector, selector) + } else if data.Hidden.Visibility == "hidden" { + context("'%s' exists but is hidden (visibility: hidden) — try 'rodney wait \"%s\"'", selector, selector) + } else if data.Hidden.Opacity == "0" { + context("'%s' exists but is hidden (opacity: 0) — try 'rodney wait \"%s\"'", selector, selector) + } else if data.Hidden.Width == 0 && data.Hidden.Height == 0 { + context("'%s' exists but is hidden (zero dimensions) — try 'rodney wait \"%s\"'", selector, selector) + } + } + + // Page still loading + if data.ReadyState != "complete" { + context("page is still loading (readyState: %s) — try 'rodney waitstable'", data.ReadyState) + } + + // Auth redirect detection + if data.AuthPattern { + context("current URL appears to be a login page (%s)", data.URL) + } + + // Overlay detection + if data.Overlay != nil { + overlayDesc := data.Overlay.Tag + if data.Overlay.Classes != "" { + overlayDesc += "." + strings.SplitN(data.Overlay.Classes, " ", 2)[0] + } + context("a modal/overlay may be blocking the page (%s, z-index: %s)", overlayDesc, data.Overlay.ZIndex) + } + + // Fuzzy matching on available elements + if len(data.Available) > 0 { + matched := false + if strings.HasPrefix(selector, "#") { + target := strings.TrimPrefix(selector, "#") + for _, el := range data.Available { + if el.ID != "" && el.ID != target && strings.Contains(el.ID, target) { + context("did you mean '#%s'?", el.ID) + matched = true + } + } + } + if !matched { + context("page has %d interactive elements — try 'rodney discover --interactive'", len(data.Available)) + } + } +} + +// 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. +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. +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 + } + if r.OK { + break + } + if r.Cmd == cmd && r.Selector == selector { + count++ + } else { + break + } + } + return count +} + +// reportStuck writes escalating context to stderr based on failure count. +func reportStuck(count int) { + if count <= 1 { + return + } + if count == 2 { + context("this command has failed %d times with the same error — try a different selector", count) + return + } + 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") +} + // findUnknownFlag returns the first arg not registered in fs, preserving original form (e.g. --bogus). func findUnknownFlag(args []string, fs *flag.FlagSet) string { for _, a := range args { @@ -207,6 +493,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": @@ -271,6 +559,8 @@ func main() { cmdScreenshot(args) case "screenshot-el": cmdScreenshotEl(args) + case "viewport": + cmdViewport(args) case "pages": cmdPages(args) case "page": @@ -287,12 +577,18 @@ func main() { cmdVisible(args) case "assert": cmdAssert(args) + case "logs": + cmdLogs(args) case "ax-tree": cmdAXTree(args) case "ax-find": cmdAXFind(args) case "ax-node": cmdAXNode(args) + case "discover": + cmdDiscover(args) + case "check": + cmdCheck(args) case "help", "-h", "--help": printUsage() os.Exit(0) @@ -331,35 +627,159 @@ 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) + } + } + + // Inject stealth script to hide automation fingerprints + if s.Stealth { + _, _ = proto.PageAddScriptToEvaluateOnNewDocument{ + Source: `Object.defineProperty(navigator, 'webdriver', {get: () => false});`, + }.Call(page) + } + 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 --- -// parseStartArgs parses the flags for the "start" command. -// Returns ignoreCertErrors, headless, and an error for unknown flags. -func parseStartArgs(args []string) (ignoreCertErrors bool, headless bool, err error) { +type startFlags struct { + headless bool + ignoreCertErrors bool + enableLogs bool + fakeMedia bool + stealth bool + singleProcess string // "auto" (default), "on", "off" + vpWidth int + vpHeight int + vpScale float64 + vpMobile bool +} + +// parseStartFlags parses the arguments to "rodney start" using flag.FlagSet. +func parseStartFlags(args []string) (startFlags, error) { + // Pre-extract --viewport WxH since flag.FlagSet can't handle that format + var vpArg string + var filtered []string + for i := 0; i < len(args); i++ { + if args[i] == "--viewport" { + i++ + if i >= len(args) { + return startFlags{}, fmt.Errorf("missing value for --viewport (expected WxH, e.g. 375x812)") + } + vpArg = args[i] + } else { + filtered = append(filtered, args[i]) + } + } + fs := flag.NewFlagSet("start", flag.ContinueOnError) fs.SetOutput(io.Discard) - fs.BoolVar(&ignoreCertErrors, "insecure", false, "") - fs.BoolVar(&ignoreCertErrors, "k", false, "") show := fs.Bool("show", false, "") + insecure := fs.Bool("insecure", false, "") + k := fs.Bool("k", false, "") + logs := fs.Bool("logs", false, "") + fakeMedia := fs.Bool("fake-media", false, "") + stealth := fs.Bool("stealth", false, "") + mobile := fs.Bool("mobile", false, "") + scale := fs.Float64("scale", 0, "") + singleProcess := fs.String("single-process", "auto", "") - if parseErr := fs.Parse(args); parseErr != nil { - return false, true, fmt.Errorf("unknown flag: %s\nusage: rodney start [--show] [--insecure]", findUnknownFlag(args, fs)) + 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\n%s", findUnknownFlag(filtered, fs), usage) } if fs.NArg() > 0 { - return false, true, fmt.Errorf("unknown flag: %s\nusage: rodney start [--show] [--insecure]", 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{ + headless: !*show, + ignoreCertErrors: *insecure || *k, + enableLogs: *logs, + fakeMedia: *fakeMedia, + stealth: *stealth, + singleProcess: *singleProcess, + vpMobile: *mobile, + vpScale: *scale, + } + + if vpArg != "" { + parts := strings.SplitN(vpArg, "x", 2) + if len(parts) != 2 { + return f, fmt.Errorf("invalid viewport format: %q (expected WxH, e.g. 375x812)", vpArg) + } + w, err := strconv.Atoi(parts[0]) + if err != nil { + return f, fmt.Errorf("invalid viewport width: %v", err) + } + h, err := strconv.Atoi(parts[1]) + if err != nil { + return f, fmt.Errorf("invalid viewport height: %v", err) + } + f.vpWidth, f.vpHeight = w, h } - headless = !*show - return ignoreCertErrors, headless, nil + + return f, nil } func cmdStart(args []string) { - ignoreCertErrors, headless, err := parseStartArgs(args) + flags, err := parseStartFlags(args) if err != nil { fatal("%s", err) } + ignoreCertErrors := flags.ignoreCertErrors + enableLogs := flags.enableLogs + fakeMedia := flags.fakeMedia + stealth := flags.stealth + headless := flags.headless + vpWidth, vpHeight := flags.vpWidth, flags.vpHeight + vpScale := flags.vpScale + vpMobile := flags.vpMobile + + 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 { @@ -377,19 +797,47 @@ 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 { l = l.Delete("no-startup-window") } + if stealth { + l = l.Set("disable-blink-features", "AutomationControlled") + l = l.Delete("enable-automation") + } + if bin := os.Getenv("ROD_CHROME_BIN"); bin != "" { l = l.Bin(bin) + } 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 @@ -407,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) @@ -429,18 +878,47 @@ 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 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() + // 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 { + 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, - ActivePage: 0, - DataDir: dataDir, - ProxyPID: proxyPID, - ProxyPort: proxyPort, + DebugURL: debugURL, + ChromePID: pid, + ActivePage: 0, + DataDir: dataDir, + ProxyPID: proxyPID, + ProxyPort: proxyPort, + Logs: enableLogs, + LoggerPID: loggerPID, + Stealth: stealth, + ViewportWidth: vpWidth, + ViewportHeight: vpHeight, + ViewportScale: vpScale, + ViewportMobile: vpMobile, } if err := saveState(state); err != nil { @@ -449,6 +927,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) { @@ -522,6 +1003,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") } @@ -573,7 +1060,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 { @@ -663,6 +1163,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() @@ -683,6 +1184,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() @@ -699,6 +1201,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]) @@ -737,10 +1240,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 @@ -770,18 +1288,111 @@ func cmdJS(args []string) { } } +// parseAXFlags scans args for --role and --name flags, removes them, and returns +// the remaining args along with the extracted role and name values. +func parseAXFlags(args []string) (role, name string, remaining []string) { + for i := 0; i < len(args); i++ { + switch args[i] { + case "--role": + i++ + if i < len(args) { + role = args[i] + } + case "--name": + i++ + if i < len(args) { + name = args[i] + } + default: + remaining = append(remaining, args[i]) + } + } + return +} + +// resolveElement parses args for either a positional CSS selector OR --role/--name +// flags, finds the element on the page, and returns: the element, a human-readable +// selector description (for error messages), and the remaining (non-selector) args. +// +// When --role/--name flags are present, the first remaining arg is NOT consumed as +// a CSS selector; all remaining args are returned as-is for the caller to use. +// When no --role/--name flags are present, the first remaining arg is consumed as +// the CSS selector, and subsequent remaining args are returned. +// +// It calls fatal() if both a CSS selector and --role/--name are provided, or if no +// element is found. +func resolveElement(page *rod.Page, args []string) (*rod.Element, string, []string) { + role, name, remaining := parseAXFlags(args) + hasAX := role != "" || name != "" + + if !hasAX { + // CSS selector mode: first remaining arg is the selector + if len(remaining) == 0 { + fatal("must provide either a CSS selector or --role/--name flags") + } + selector := remaining[0] + el, err := page.Element(selector) + if err != nil { + inspectFailure(page, selector) + hint("try 'rodney discover --interactive' to see available elements") + fatal("element not found: %v", err) + } + return el, selector, remaining[1:] + } + + // Accessibility selector path — remaining args are passed through to the caller + desc := "" + if role != "" && name != "" { + desc = fmt.Sprintf("--role %s --name %q", role, name) + } else if role != "" { + desc = fmt.Sprintf("--role %s", role) + } else { + desc = fmt.Sprintf("--name %q", name) + } + + nodes, err := queryAXNodes(page, name, role) + if err != nil { + fatal("accessibility query failed: %v", err) + } + if len(nodes) == 0 { + inspectFailure(page, desc) + hint("try 'rodney ax-tree' to see all available accessibility nodes") + fatal("no element found matching %s", desc) + } + + node := nodes[0] + if node.BackendDOMNodeID == 0 { + fatal("matched accessibility node has no DOM backing for %s", desc) + } + + result, err := proto.DOMResolveNode{BackendNodeID: node.BackendDOMNodeID}.Call(page) + if err != nil { + fatal("failed to resolve DOM node for %s: %v", desc, err) + } + el, err := page.ElementFromObject(result.Object) + if err != nil { + fatal("failed to create element from object for %s: %v", desc, err) + } + return el, desc, remaining +} + func cmdClick(args []string) { if len(args) < 1 { - fatal("usage: rodney click ") + fatal("usage: rodney click | --role [--name ]") } + selector := args[0] _, _, page := withPage() - el, err := page.Element(args[0]) - if err != nil { - fatal("element not found: %v", err) + el, _, remaining := resolveElement(page, args) + if len(remaining) > 0 { + fatal("unexpected arguments after selector: %v", remaining) } if err := el.Click(proto.InputMouseButtonLeft, 1); err != nil { + reportStuck(checkStuck("click", selector)) + recordCall("click", selector, false, fmt.Sprintf("%v", err)) + hint("element may not be interactive — try 'rodney js \"document.querySelector(\\\"%s\\\").click()\"'", selector) fatal("click failed: %v", err) } + recordCall("click", selector, true, "") // Brief pause for click handlers to execute time.Sleep(100 * time.Millisecond) fmt.Println("Clicked") @@ -789,28 +1400,30 @@ func cmdClick(args []string) { func cmdInput(args []string) { if len(args) < 2 { - fatal("usage: rodney input ") + fatal("usage: rodney input | --role [--name ] ") } _, _, page := withPage() - el, err := page.Element(args[0]) - if err != nil { - fatal("element not found: %v", err) + el, _, remaining := resolveElement(page, args) + if len(remaining) == 0 { + fatal("usage: rodney input | --role [--name ] ") } - text := strings.Join(args[1:], " ") + text := strings.Join(remaining, " ") el.MustSelectAllText().MustInput(text) + recordCall("input", args[0], true, "") fmt.Printf("Typed: %s\n", text) } func cmdClear(args []string) { if len(args) < 1 { - fatal("usage: rodney clear ") + fatal("usage: rodney clear | --role [--name ]") } _, _, page := withPage() - el, err := page.Element(args[0]) - if err != nil { - fatal("element not found: %v", err) + el, _, remaining := resolveElement(page, args) + if len(remaining) > 0 { + fatal("unexpected arguments after selector: %v", remaining) } el.MustSelectAllText().MustInput("") + recordCall("clear", args[0], true, "") fmt.Println("Cleared") } @@ -824,6 +1437,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) } @@ -868,6 +1482,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) } @@ -1009,19 +1624,20 @@ func mimeToExt(mime string) string { func cmdSelect(args []string) { if len(args) < 2 { - fatal("usage: rodney select ") + fatal("usage: rodney select | --role [--name ] ") } _, _, page := withPage() - // Use JavaScript to set the value, as rod's Select matches by text - js := fmt.Sprintf(`() => { - const el = document.querySelector(%q); - if (!el) throw new Error('element not found'); - el.value = %q; - el.dispatchEvent(new Event('change', {bubbles: true})); - return el.value; - }`, args[0], args[1]) - result, err := page.Eval(js) + el, _, remaining := resolveElement(page, args) + if len(remaining) == 0 { + fatal("usage: rodney select | --role [--name ] ") + } + value := remaining[0] + // Use JavaScript on the resolved element to set value and dispatch change event + result, err := el.Eval(`(val) => { this.value = val; this.dispatchEvent(new Event('change', {bubbles: true})); return this.value; }`, value) if err != nil { + inspectFailure(page, args[0]) + hint("try 'rodney discover --interactive' to see available elements") + fatal("select failed: %v", err) } fmt.Printf("Selected: %s\n", result.Value.Str()) @@ -1029,54 +1645,136 @@ func cmdSelect(args []string) { func cmdSubmit(args []string) { if len(args) < 1 { - fatal("usage: rodney submit ") + fatal("usage: rodney submit | --role [--name ]") } _, _, page := withPage() - _, err := page.Element(args[0]) + el, _, remaining := resolveElement(page, args) + if len(remaining) > 0 { + fatal("unexpected arguments after selector: %v", remaining) + } + _, err := el.Eval(`() => { this.submit(); }`) if err != nil { - fatal("form not found: %v", err) + fatal("submit failed: %v", err) } - page.MustEval(fmt.Sprintf(`() => document.querySelector(%q).submit()`, args[0])) fmt.Println("Submitted") } func cmdHover(args []string) { if len(args) < 1 { - fatal("usage: rodney hover ") + fatal("usage: rodney hover | --role [--name ]") } _, _, page := withPage() - el, err := page.Element(args[0]) - if err != nil { - fatal("element not found: %v", err) + el, _, remaining := resolveElement(page, args) + if len(remaining) > 0 { + fatal("unexpected arguments after selector: %v", remaining) } el.MustHover() + recordCall("hover", args[0], true, "") fmt.Println("Hovered") } func cmdFocus(args []string) { if len(args) < 1 { - fatal("usage: rodney focus ") + fatal("usage: rodney focus | --role [--name ]") } _, _, page := withPage() - el, err := page.Element(args[0]) - if err != nil { - fatal("element not found: %v", err) + el, _, remaining := resolveElement(page, args) + if len(remaining) > 0 { + fatal("unexpected arguments after selector: %v", remaining) } el.MustFocus() + recordCall("focus", args[0], true, "") fmt.Println("Focused") } +// parseWaitArgs extracts --text and --gone flags from args, +// returning the selector and option values. +func parseWaitArgs(args []string) (selector string, textMatch string, gone bool) { + var positional []string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--text": + if i+1 >= len(args) { + fatal("--text requires a value") + } + i++ + textMatch = args[i] + case "--gone": + gone = true + default: + positional = append(positional, args[i]) + } + } + if len(positional) != 1 { + fatal("usage: rodney wait [--text ] [--gone]") + } + selector = positional[0] + return +} + func cmdWait(args []string) { if len(args) < 1 { - fatal("usage: rodney wait ") + fatal("usage: rodney wait [--text ] [--gone] | --role [--name ]") + } + + selector, textMatch, gone := parseWaitArgs(args) + + if textMatch != "" && gone { + fatal("--text and --gone are mutually exclusive") } + _, _, page := withPage() - el, err := page.Element(args[0]) - if err != nil { - fatal("element not found: %v", err) + + if gone { + // Wait for element to disappear from DOM or become hidden + deadline := time.Now().Add(defaultTimeout) + for time.Now().Before(deadline) { + els, err := page.Elements(selector) + if err != nil || len(els) == 0 { + fmt.Println("Element gone") + return + } + allHidden := true + for _, el := range els { + visible, err := el.Visible() + if err == nil && visible { + allHidden = false + break + } + } + if allHidden { + fmt.Println("Element gone") + return + } + time.Sleep(100 * time.Millisecond) + } + fatal("timeout waiting for element to disappear: %s", selector) + return + } + + if textMatch != "" { + // Wait for element to exist and contain the specified text + deadline := time.Now().Add(defaultTimeout) + for time.Now().Before(deadline) { + el, err := page.Element(selector) + if err == nil { + text, err := el.Text() + if err == nil && strings.Contains(text, textMatch) { + fmt.Println("Found") + return + } + } + time.Sleep(100 * time.Millisecond) + } + hint("page may still be loading — try 'rodney waitstable' before this command") + fatal("timeout waiting for text %q in %s", textMatch, selector) + return } + + // Default: wait for element to exist and be visible (supports --role/--name) + el, _, _ := resolveElement(page, args) el.MustWaitVisible() - fmt.Println("Element visible") + fmt.Println("Found") } func cmdWaitLoad(args []string) { @@ -1123,59 +1821,152 @@ func nextAvailableFile(base, ext string) string { } } -func cmdScreenshot(args []string) { - fs := flag.NewFlagSet("screenshot", flag.ContinueOnError) - fs.SetOutput(io.Discard) - width := fs.Int("width", 1280, "") - fs.IntVar(width, "w", 1280, "") - height := fs.Int("height", 0, "") - fs.IntVar(height, "h", 0, "") - - if err := fs.Parse(args); err != nil { - fatal("%v", err) +func cmdViewport(args []string) { + if len(args) < 1 { + fatal("usage: rodney viewport [--scale N] [--mobile]\n rodney viewport --reset") } - fullPage := true - fs.Visit(func(f *flag.Flag) { - if f.Name == "height" || f.Name == "h" { - fullPage = false - } - }) + // Handle --reset: clear viewport override and restore browser defaults + if args[0] == "--reset" { + s, _, page := withPage() - var file string - if fs.NArg() > 0 { - file = fs.Arg(0) - } else { - file = nextAvailableFile("screenshot", ".png") - } + if err := (proto.EmulationClearDeviceMetricsOverride{}.Call(page)); err != nil { + fatal("failed to clear viewport override: %v", err) + } - _, _, page := withPage() + 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) + } - // Set viewport size - viewportHeight := *height - if viewportHeight == 0 { - viewportHeight = 720 + fmt.Println("Viewport reset to browser default") + return } - err := proto.EmulationSetDeviceMetricsOverride{ - Width: *width, - Height: viewportHeight, - DeviceScaleFactor: 1, - }.Call(page) - if err != nil { - fatal("failed to set viewport: %v", err) + + if len(args) < 2 { + fatal("usage: rodney viewport [--scale N] [--mobile]\n rodney viewport --reset") } - data, err := page.Screenshot(fullPage, nil) + w, err := strconv.Atoi(args[0]) if err != nil { - fatal("screenshot failed: %v", err) + fatal("invalid width: %v", err) } - if err := os.WriteFile(file, data, 0644); err != nil { - fatal("failed to write screenshot: %v", err) + h, err := strconv.Atoi(args[1]) + if err != nil { + fatal("invalid height: %v", err) } - fmt.Println(file) -} -func cmdScreenshotEl(args []string) { + 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) { + fs := flag.NewFlagSet("screenshot", flag.ContinueOnError) + fs.SetOutput(io.Discard) + width := fs.Int("width", 0, "") + fs.IntVar(width, "w", 0, "") + height := fs.Int("height", 0, "") + fs.IntVar(height, "h", 0, "") + + if err := fs.Parse(args); err != nil { + fatal("%v", err) + } + + fullPage := true + sizeExplicit := false + fs.Visit(func(f *flag.Flag) { + sizeExplicit = true + if f.Name == "height" || f.Name == "h" { + fullPage = false + } + }) + + var file string + if fs.NArg() > 0 { + file = fs.Arg(0) + } else { + file = nextAvailableFile("screenshot", ".png") + } + + s, _, page := withPage() + + // Only override viewport if -w/-h were explicitly passed, or if no + // viewport has been set via "rodney viewport" + if sizeExplicit || s.ViewportWidth == 0 { + w := *width + if w == 0 { + w = 1280 + } + viewportHeight := *height + if viewportHeight == 0 { + viewportHeight = 720 + } + err := proto.EmulationSetDeviceMetricsOverride{ + Width: w, + Height: viewportHeight, + DeviceScaleFactor: 1, + }.Call(page) + if err != nil { + fatal("failed to set viewport: %v", err) + } + } + + data, err := page.Screenshot(fullPage, nil) + if err != nil { + fatal("screenshot failed: %v", err) + } + if err := os.WriteFile(file, data, 0644); err != nil { + fatal("failed to write screenshot: %v", err) + } + fmt.Println(file) +} + +func cmdScreenshotEl(args []string) { if len(args) < 1 { fatal("usage: rodney screenshot-el [file]") } @@ -1186,6 +1977,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) @@ -1278,7 +2070,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("") @@ -1343,19 +2144,42 @@ func cmdClosePage(args []string) { func cmdExists(args []string) { if len(args) < 1 { - fatal("usage: rodney exists ") + fatal("usage: rodney exists | --role [--name ]") } _, _, page := withPage() - has, _, err := page.Has(args[0]) - if err != nil { - fatal("query failed: %v", err) + + role, name, remaining := parseAXFlags(args) + hasAX := role != "" || name != "" + hasCSS := len(remaining) > 0 + + if hasAX && hasCSS { + fatal("cannot use both a CSS selector and --role/--name flags") } - if has { - fmt.Println("true") - os.Exit(0) + + if hasAX { + nodes, err := queryAXNodes(page, name, role) + if err != nil { + fatal("query failed: %v", err) + } + if len(nodes) > 0 { + fmt.Println("true") + os.Exit(0) + } else { + fmt.Println("false") + os.Exit(1) + } } else { - fmt.Println("false") - os.Exit(1) + has, _, err := page.Has(remaining[0]) + if err != nil { + fatal("query failed: %v", err) + } + if has { + fmt.Println("true") + os.Exit(0) + } else { + fmt.Println("false") + os.Exit(1) + } } } @@ -1373,14 +2197,49 @@ func cmdCount(args []string) { func cmdVisible(args []string) { if len(args) < 1 { - fatal("usage: rodney visible ") + fatal("usage: rodney visible | --role [--name ]") } _, _, page := withPage() - el, err := page.Element(args[0]) - if err != nil { - fmt.Println("false") - os.Exit(1) + + role, name, remaining := parseAXFlags(args) + hasAX := role != "" || name != "" + hasCSS := len(remaining) > 0 + + if hasAX && hasCSS { + fatal("cannot use both a CSS selector and --role/--name flags") } + + var el *rod.Element + if hasAX { + nodes, err := queryAXNodes(page, name, role) + if err != nil || len(nodes) == 0 { + fmt.Println("false") + os.Exit(1) + } + node := nodes[0] + if node.BackendDOMNodeID == 0 { + fmt.Println("false") + os.Exit(1) + } + result, err := proto.DOMResolveNode{BackendNodeID: node.BackendDOMNodeID}.Call(page) + if err != nil { + fmt.Println("false") + os.Exit(1) + } + el, err = page.ElementFromObject(result.Object) + if err != nil { + fmt.Println("false") + os.Exit(1) + } + } else { + var err error + el, err = page.Element(remaining[0]) + if err != nil { + fmt.Println("false") + os.Exit(1) + } + } + visible, err := el.Visible() if err != nil { fmt.Println("false") @@ -1437,11 +2296,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]") @@ -1494,11 +2399,526 @@ func cmdAssert(args []string) { } } +// --- Composable check command --- + +type checkItem struct { + kind string // "exists", "visible", "text", "count", "assert" + arg1 string // selector or JS expression + arg2 string // expected value (for text, count, assert equality) +} + +type checkResult struct { + Check string `json:"check"` + Selector string `json:"selector,omitempty"` + Expr string `json:"expr,omitempty"` + Pass bool `json:"pass"` + Got string `json:"got,omitempty"` + Expected string `json:"expected,omitempty"` +} + +// parseCheckArgs walks the argument list and builds a slice of checkItems. +// Returns the checks, whether --json was specified, and any parse error. +func parseCheckArgs(args []string) ([]checkItem, bool, error) { + var checks []checkItem + jsonOutput := false + i := 0 + for i < len(args) { + switch args[i] { + case "--json": + jsonOutput = true + i++ + case "--exists": + i++ + if i >= len(args) { + return nil, false, fmt.Errorf("--exists requires a selector argument") + } + checks = append(checks, checkItem{kind: "exists", arg1: args[i]}) + i++ + case "--visible": + i++ + if i >= len(args) { + return nil, false, fmt.Errorf("--visible requires a selector argument") + } + checks = append(checks, checkItem{kind: "visible", arg1: args[i]}) + i++ + case "--text": + i++ + if i+1 >= len(args) { + return nil, false, fmt.Errorf("--text requires arguments") + } + checks = append(checks, checkItem{kind: "text", arg1: args[i], arg2: args[i+1]}) + i += 2 + case "--count": + i++ + if i+1 >= len(args) { + return nil, false, fmt.Errorf("--count requires arguments") + } + checks = append(checks, checkItem{kind: "count", arg1: args[i], arg2: args[i+1]}) + i += 2 + case "--assert": + i++ + if i >= len(args) { + return nil, false, fmt.Errorf("--assert requires an expression argument") + } + expr := args[i] + i++ + // Optionally consume next arg as expected value if it doesn't start with "--" + expected := "" + if i < len(args) && !strings.HasPrefix(args[i], "--") { + expected = args[i] + i++ + } + checks = append(checks, checkItem{kind: "assert", arg1: expr, arg2: expected}) + default: + return nil, false, fmt.Errorf("unknown flag: %s", args[i]) + } + } + return checks, jsonOutput, nil +} + +// runCheck executes a single check against the given page and returns the result. +func runCheck(page *rod.Page, c checkItem) checkResult { + switch c.kind { + case "exists": + has, _, err := page.Has(c.arg1) + if err != nil { + return checkResult{Check: "exists", Selector: c.arg1, Pass: false, Got: fmt.Sprintf("error: %v", err)} + } + return checkResult{Check: "exists", Selector: c.arg1, Pass: has, Got: fmt.Sprintf("%v", has)} + + case "visible": + el, err := page.Element(c.arg1) + if err != nil { + return checkResult{Check: "visible", Selector: c.arg1, Pass: false, Got: "not found"} + } + vis, err := el.Visible() + if err != nil { + return checkResult{Check: "visible", Selector: c.arg1, Pass: false, Got: fmt.Sprintf("error: %v", err)} + } + return checkResult{Check: "visible", Selector: c.arg1, Pass: vis, Got: fmt.Sprintf("%v", vis)} + + case "text": + el, err := page.Element(c.arg1) + if err != nil { + return checkResult{Check: "text", Selector: c.arg1, Pass: false, Expected: c.arg2, Got: "element not found"} + } + text, err := el.Text() + if err != nil { + return checkResult{Check: "text", Selector: c.arg1, Pass: false, Expected: c.arg2, Got: fmt.Sprintf("error: %v", err)} + } + pass := text == c.arg2 + return checkResult{Check: "text", Selector: c.arg1, Pass: pass, Got: text, Expected: c.arg2} + + case "count": + els, err := page.Elements(c.arg1) + if err != nil { + return checkResult{Check: "count", Selector: c.arg1, Pass: false, Expected: c.arg2, Got: fmt.Sprintf("error: %v", err)} + } + got := strconv.Itoa(len(els)) + pass := got == c.arg2 + return checkResult{Check: "count", Selector: c.arg1, Pass: pass, Got: got, Expected: c.arg2} + + case "assert": + js := fmt.Sprintf(`() => { return (%s); }`, c.arg1) + result, err := page.Eval(js) + if err != nil { + return checkResult{Check: "assert", Expr: c.arg1, Pass: false, Got: fmt.Sprintf("error: %v", err), Expected: c.arg2} + } + v := result.Value + raw := v.JSON("", "") + var actual string + switch { + case raw == "null" || raw == "undefined": + actual = raw + case raw == "true" || raw == "false": + actual = raw + case len(raw) > 0 && raw[0] == '"': + actual = v.Str() + case len(raw) > 0 && (raw[0] == '{' || raw[0] == '['): + actual = v.JSON("", " ") + default: + actual = raw + } + + if c.arg2 != "" { + // Equality mode + pass := actual == c.arg2 + return checkResult{Check: "assert", Expr: c.arg1, Pass: pass, Got: actual, Expected: c.arg2} + } + // Truthy mode + truthy := true + switch raw { + case "false", "0", "null", "undefined", `""`: + truthy = false + } + return checkResult{Check: "assert", Expr: c.arg1, Pass: truthy, Got: actual} + + default: + return checkResult{Check: c.kind, Pass: false, Got: "unknown check type"} + } +} + +// formatCheckLine formats a single check result as a human-readable line. +func formatCheckLine(r checkResult) string { + status := "PASS" + if !r.Pass { + status = "FAIL" + } + + label := r.Check + target := r.Selector + if target == "" { + target = r.Expr + } + + line := fmt.Sprintf("%s %s %s", status, label, target) + + if !r.Pass { + if r.Expected != "" { + line += fmt.Sprintf(" -- got %q, expected %q", r.Got, r.Expected) + } else if r.Check != "exists" && r.Check != "visible" { + line += fmt.Sprintf(" -- got %q", r.Got) + } + } else if r.Expected != "" { + line += fmt.Sprintf(" = %s", r.Expected) + } + + return line +} + +func cmdCheck(args []string) { + checks, jsonOutput, err := parseCheckArgs(args) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %s\n", err) + os.Exit(2) + } + if len(checks) == 0 { + fmt.Fprintln(os.Stderr, "error: no checks specified") + os.Exit(2) + } + + _, _, page := withPage() + + var results []checkResult + for _, c := range checks { + results = append(results, runCheck(page, c)) + } + + passed := 0 + for _, r := range results { + if r.Pass { + passed++ + } + } + + if jsonOutput { + data, _ := json.MarshalIndent(results, "", " ") + fmt.Println(string(data)) + } else { + for _, r := range results { + fmt.Println(formatCheckLine(r)) + } + fmt.Println("----") + fmt.Printf("%d/%d passed\n", passed, len(results)) + } + + if passed < len(results) { + os.Exit(1) + } + os.Exit(0) +} + // Ignore SIGPIPE for piped output 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) { @@ -1555,6 +2975,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) } @@ -1601,6 +3022,561 @@ 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() +} + +// discoverFormEntry represents a single form field found by --forms mode. +type discoverFormEntry struct { + FormSelector string `json:"form_selector"` + FormAction string `json:"form_action,omitempty"` + Selector string `json:"selector"` + Tag string `json:"tag"` + Type string `json:"type,omitempty"` + Name string `json:"name,omitempty"` + Label string `json:"label,omitempty"` + Command string `json:"command"` +} + +// queryDiscoverForms finds all forms and their fields, returning structured entries. +func queryDiscoverForms(page *rod.Page) ([]discoverFormEntry, error) { + js := `() => { + var results = []; + var forms = document.querySelectorAll('form'); + forms.forEach(function(form, fi) { + var formSel = ''; + if (form.id) formSel = 'form#' + form.id; + else if (form.name) formSel = 'form[name="' + form.name + '"]'; + else if (fi === 0 && forms.length === 1) formSel = 'form'; + else formSel = 'form:nth-of-type(' + (fi+1) + ')'; + var formAction = form.getAttribute('action') || ''; + + var fields = form.querySelectorAll('input, select, textarea, button'); + fields.forEach(function(el) { + var tag = el.tagName.toLowerCase(); + var type = el.getAttribute('type') || ''; + var name = el.getAttribute('name') || ''; + var id = el.id || ''; + var label = ''; + + // Try to find associated label + if (id) { + var lbl = document.querySelector('label[for="' + id + '"]'); + if (lbl) label = lbl.textContent.trim(); + } + if (!label && el.getAttribute('aria-label')) { + label = el.getAttribute('aria-label'); + } + if (!label && el.placeholder) { + label = el.placeholder; + } + if (!label && tag === 'button') { + label = el.textContent.trim(); + } + + // Build best selector + var sel = ''; + if (id) sel = '#' + id; + else if (name) sel = tag + '[name="' + name + '"]'; + else if (type === 'submit' || tag === 'button') sel = tag + '[type="submit"]'; + else sel = tag; + + // Determine command + var cmd = ''; + if (tag === 'select') { + cmd = 'rodney select "' + sel + '" "TODO"'; + } else if (tag === 'textarea' || (tag === 'input' && type !== 'submit' && type !== 'button' && type !== 'file')) { + cmd = 'rodney input "' + sel + '" "TODO"'; + } else if (type === 'file') { + cmd = 'rodney file "' + sel + '" "TODO"'; + } else if (tag === 'button' || type === 'submit' || type === 'button') { + cmd = 'rodney click "' + sel + '"'; + } + + results.push({ + form_selector: formSel, + form_action: formAction, + selector: sel, + tag: tag, + type: type, + name: name, + label: label, + command: cmd + }); + }); + }); + return results; + }` + + result, err := page.Eval(js) + if err != nil { + return nil, fmt.Errorf("discover forms eval failed: %w", err) + } + + raw := result.Value.JSON("", "") + var entries []discoverFormEntry + if jsonErr := json.Unmarshal([]byte(raw), &entries); jsonErr != nil { + return nil, fmt.Errorf("failed to parse form results: %w", jsonErr) + } + return entries, nil +} + +// formatDiscoverFormsText formats form entries as human-readable output. +func formatDiscoverFormsText(entries []discoverFormEntry) string { + var buf strings.Builder + currentForm := "" + for _, e := range entries { + if e.FormSelector != currentForm { + if currentForm != "" { + fmt.Fprintln(&buf) + } + currentForm = e.FormSelector + header := fmt.Sprintf("Form: %s", e.FormSelector) + if e.FormAction != "" { + header += fmt.Sprintf(" (action=%q)", e.FormAction) + } + fmt.Fprintln(&buf, header) + } + comment := "" + if e.Label != "" { + comment = "# " + e.Label + } + if e.Type != "" && comment == "" { + comment = "# (type=" + e.Type + ")" + } else if e.Type != "" { + comment += " (type=" + e.Type + ")" + } + if comment != "" { + fmt.Fprintf(&buf, " %-50s %s\n", e.Command, comment) + } else { + fmt.Fprintf(&buf, " %s\n", e.Command) + } + } + return buf.String() +} + +// discoverLinkEntry represents a single link found by --links mode. +type discoverLinkEntry struct { + Selector string `json:"selector"` + Href string `json:"href"` + Text string `json:"text"` + Command string `json:"command"` +} + +// queryDiscoverLinks finds all anchor elements with href attributes. +func queryDiscoverLinks(page *rod.Page) ([]discoverLinkEntry, error) { + js := `() => { + var results = []; + var links = document.querySelectorAll('a[href]'); + links.forEach(function(el) { + var href = el.getAttribute('href') || ''; + var text = el.textContent.trim().substring(0, 60); + var id = el.id || ''; + + // Build best selector + var sel = ''; + if (id) sel = 'a#' + id; + else if (href) sel = 'a[href="' + href.replace(/"/g, '\\"') + '"]'; + else sel = 'a'; + + results.push({ + selector: sel, + href: href, + text: text, + command: 'rodney click "' + sel + '"' + }); + }); + return results; + }` + + result, err := page.Eval(js) + if err != nil { + return nil, fmt.Errorf("discover links eval failed: %w", err) + } + + raw := result.Value.JSON("", "") + var entries []discoverLinkEntry + if jsonErr := json.Unmarshal([]byte(raw), &entries); jsonErr != nil { + return nil, fmt.Errorf("failed to parse link results: %w", jsonErr) + } + return entries, nil +} + +// formatDiscoverLinksText formats link entries as human-readable output. +func formatDiscoverLinksText(entries []discoverLinkEntry, pageURL string) string { + var buf strings.Builder + if pageURL != "" { + fmt.Fprintf(&buf, "Links on %s:\n", pageURL) + } else { + fmt.Fprintln(&buf, "Links:") + } + for _, e := range entries { + comment := "" + if e.Text != "" { + comment = "# " + e.Text + } + if comment != "" { + fmt.Fprintf(&buf, " %-50s %s\n", e.Command, comment) + } else { + fmt.Fprintf(&buf, " %s\n", e.Command) + } + } + return buf.String() +} + +// discoverInteractiveEntry represents an interactive element found by --interactive mode. +type discoverInteractiveEntry struct { + Selector string `json:"selector"` + Tag string `json:"tag"` + Type string `json:"type,omitempty"` + Role string `json:"role,omitempty"` + Text string `json:"text"` + Command string `json:"command"` +} + +// queryDiscoverInteractive finds all interactive/focusable elements on the page. +func queryDiscoverInteractive(page *rod.Page) ([]discoverInteractiveEntry, error) { + js := `() => { + var results = []; + var seen = new Set(); + var els = document.querySelectorAll('button, a[href], input, select, textarea, [role="button"], [tabindex]'); + els.forEach(function(el) { + // Deduplicate by element reference + if (seen.has(el)) return; + seen.add(el); + + var tag = el.tagName.toLowerCase(); + var type = el.getAttribute('type') || ''; + var role = el.getAttribute('role') || ''; + var id = el.id || ''; + var name = el.getAttribute('name') || ''; + var text = ''; + + // Get descriptive text + if (el.getAttribute('aria-label')) { + text = el.getAttribute('aria-label'); + } else if (tag === 'input' || tag === 'textarea') { + // Find associated label + if (id) { + var lbl = document.querySelector('label[for="' + id + '"]'); + if (lbl) text = lbl.textContent.trim(); + } + if (!text) text = el.placeholder || ''; + } else { + text = el.textContent.trim().substring(0, 60); + } + + // Build best selector + var sel = ''; + if (id) sel = tag + '#' + id; + else if (name) sel = tag + '[name="' + name + '"]'; + else if (tag === 'a') { + var href = el.getAttribute('href'); + if (href) sel = 'a[href="' + href.replace(/"/g, '\\"') + '"]'; + else sel = 'a'; + } else if (role) { + sel = '[role="' + role + '"]'; + } else { + sel = tag; + } + + // Determine command + var cmd = ''; + if (tag === 'select') { + cmd = 'rodney select "' + sel + '" "TODO"'; + } else if (tag === 'input' && type === 'file') { + cmd = 'rodney file "' + sel + '" "TODO"'; + } else if (tag === 'input' || tag === 'textarea') { + cmd = 'rodney input "' + sel + '" "TODO"'; + } else { + cmd = 'rodney click "' + sel + '"'; + } + + // Determine effective role for display + var effectiveRole = role; + if (!effectiveRole) { + if (tag === 'button') effectiveRole = 'button'; + else if (tag === 'a') effectiveRole = 'link'; + else if (tag === 'input' && (type === 'text' || type === '' || type === 'email' || type === 'password' || type === 'search' || type === 'tel' || type === 'url' || type === 'number')) effectiveRole = 'textbox'; + else if (tag === 'input' && type === 'checkbox') effectiveRole = 'checkbox'; + else if (tag === 'input' && type === 'radio') effectiveRole = 'radio'; + else if (tag === 'input' && type === 'file') effectiveRole = 'file'; + else if (tag === 'textarea') effectiveRole = 'textbox'; + else if (tag === 'select') effectiveRole = 'combobox'; + } + + results.push({ + selector: sel, + tag: tag, + type: type, + role: effectiveRole, + text: text, + command: cmd + }); + }); + return results; + }` + + result, err := page.Eval(js) + if err != nil { + return nil, fmt.Errorf("discover interactive eval failed: %w", err) + } + + raw := result.Value.JSON("", "") + var entries []discoverInteractiveEntry + if jsonErr := json.Unmarshal([]byte(raw), &entries); jsonErr != nil { + return nil, fmt.Errorf("failed to parse interactive results: %w", jsonErr) + } + return entries, nil +} + +// formatDiscoverInteractiveText formats interactive entries as human-readable output. +func formatDiscoverInteractiveText(entries []discoverInteractiveEntry) string { + var buf strings.Builder + fmt.Fprintln(&buf, "Interactive elements:") + for _, e := range entries { + comment := "" + if e.Text != "" { + comment = "# " + e.Text + } + if e.Role != "" { + if comment != "" { + comment += " (" + e.Role + ")" + } else { + comment = "# (" + e.Role + ")" + } + } + if comment != "" { + fmt.Fprintf(&buf, " %-50s %s\n", e.Command, comment) + } else { + fmt.Fprintf(&buf, " %s\n", e.Command) + } + } + return buf.String() +} + +func cmdDiscover(args []string) { + jsonOutput := false + attrName := "data-testid" + modeForms := false + modeLinks := false + modeInteractive := false + 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] + case "--forms": + modeForms = true + case "--links": + modeLinks = true + case "--interactive": + modeInteractive = true + } + } + + // Check mutual exclusivity of modes + modeCount := 0 + if modeForms { + modeCount++ + } + if modeLinks { + modeCount++ + } + if modeInteractive { + modeCount++ + } + if modeCount > 1 { + fatal("--forms, --links, and --interactive are mutually exclusive") + } + + _, _, page := withPage() + info, _ := page.Info() + pageURL := "" + if info != nil { + pageURL = info.URL + } + + switch { + case modeForms: + entries, err := queryDiscoverForms(page) + if err != nil { + fatal("%v", err) + } + if jsonOutput { + out, _ := json.MarshalIndent(entries, "", " ") + fmt.Println(string(out)) + return + } + fmt.Print(formatDiscoverFormsText(entries)) + + case modeLinks: + entries, err := queryDiscoverLinks(page) + if err != nil { + fatal("%v", err) + } + if jsonOutput { + out, _ := json.MarshalIndent(entries, "", " ") + fmt.Println(string(out)) + return + } + fmt.Print(formatDiscoverLinksText(entries, pageURL)) + + case modeInteractive: + entries, err := queryDiscoverInteractive(page) + if err != nil { + fatal("%v", err) + } + if jsonOutput { + out, _ := json.MarshalIndent(entries, "", " ") + fmt.Println(string(out)) + return + } + fmt.Print(formatDiscoverInteractiveText(entries)) + + default: + entries, err := queryDiscoverEntries(page, attrName) + if err != nil { + fatal("%v", err) + } + if jsonOutput { + out, _ := json.MarshalIndent(entries, "", " ") + fmt.Println(string(out)) + return + } + fmt.Print(formatDiscoverText(entries, attrName, pageURL)) + } +} + // 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 @@ -1829,6 +3805,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 1753da9..1aa5c3f 100644 --- a/main_test.go +++ b/main_test.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" "time" @@ -21,8 +22,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 @@ -51,9 +53,16 @@ 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) + mux.HandleFunc("/discover-extended", handleDiscoverExtended) + mux.HandleFunc("/wait-test", handleWaitTest) + mux.HandleFunc("/hidden", handleHidden) + mux.HandleFunc("/login", handleLogin) + mux.HandleFunc("/overlay", handleOverlay) server := httptest.NewServer(mux) - env = &testEnv{browser: browser, server: server} + env = &testEnv{browser: browser, server: server, debugURL: u} code := m.Run() @@ -150,6 +159,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 { @@ -1074,74 +1098,515 @@ 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) + } +} + +// ===================== +// 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 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()) + } +} + // ===================== -// parseStartArgs tests +// parseStartFlags (flag.FlagSet) tests // ===================== -func TestParseStartArgs_NoFlags(t *testing.T) { - insecure, headless, err := parseStartArgs([]string{}) +func TestParseStartFlags_FlagSet_NoFlags(t *testing.T) { + f, err := parseStartFlags([]string{}) if err != nil { t.Fatalf("unexpected error: %v", err) } - if insecure { + if f.ignoreCertErrors { t.Error("expected insecure=false with no flags") } - if !headless { + if !f.headless { t.Error("expected headless=true with no flags") } } -func TestParseStartArgs_ShowFlag(t *testing.T) { - insecure, headless, err := parseStartArgs([]string{"--show"}) +func TestParseStartFlags_FlagSet_ShowFlag(t *testing.T) { + f, err := parseStartFlags([]string{"--show"}) if err != nil { t.Fatalf("unexpected error: %v", err) } - if insecure { + if f.ignoreCertErrors { t.Error("expected insecure=false") } - if headless { + if f.headless { t.Error("expected headless=false when --show is passed") } } -func TestParseStartArgs_InsecureFlag(t *testing.T) { - insecure, headless, err := parseStartArgs([]string{"--insecure"}) +func TestParseStartFlags_FlagSet_InsecureFlag(t *testing.T) { + f, err := parseStartFlags([]string{"--insecure"}) if err != nil { t.Fatalf("unexpected error: %v", err) } - if !insecure { + if !f.ignoreCertErrors { t.Error("expected insecure=true when --insecure is passed") } - if !headless { + if !f.headless { t.Error("expected headless=true when only --insecure is passed") } } -func TestParseStartArgs_InsecureShortFlag(t *testing.T) { - insecure, _, err := parseStartArgs([]string{"-k"}) +func TestParseStartFlags_FlagSet_InsecureShortFlag(t *testing.T) { + f, err := parseStartFlags([]string{"-k"}) if err != nil { t.Fatalf("unexpected error: %v", err) } - if !insecure { + if !f.ignoreCertErrors { t.Error("expected insecure=true when -k is passed") } } -func TestParseStartArgs_ShowAndInsecure(t *testing.T) { - insecure, headless, err := parseStartArgs([]string{"--show", "--insecure"}) +func TestParseStartFlags_FlagSet_ShowAndInsecure(t *testing.T) { + f, err := parseStartFlags([]string{"--show", "--insecure"}) if err != nil { t.Fatalf("unexpected error: %v", err) } - if !insecure { + if !f.ignoreCertErrors { t.Error("expected insecure=true") } - if headless { + if f.headless { t.Error("expected headless=false when --show is passed") } } -func TestParseStartArgs_UnknownFlag(t *testing.T) { - _, _, err := parseStartArgs([]string{"--bogus"}) +func TestParseStartFlags_FlagSet_UnknownFlag(t *testing.T) { + _, err := parseStartFlags([]string{"--bogus"}) if err == nil { t.Fatal("expected error for unknown flag --bogus") } @@ -1224,3 +1689,2654 @@ 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 +// ===================== + +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)) + } +} + +// ===================== +// 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) + } +} + +// ===================== +// stealth mode tests +// ===================== + +func TestParseStartFlags_StealthFlag(t *testing.T) { + flags, err := parseStartFlags([]string{"--stealth"}) + if err != nil { + t.Fatalf("--stealth should be accepted, got error: %v", err) + } + if !flags.stealth { + t.Error("expected stealth=true when --stealth is passed") + } +} + +func TestParseStartFlags_StealthDefault(t *testing.T) { + flags, err := parseStartFlags([]string{}) + if err != nil { + t.Fatalf("no args should be accepted, got error: %v", err) + } + if flags.stealth { + t.Error("expected stealth=false by default") + } +} + +func TestParseStartFlags_StealthWithShow(t *testing.T) { + flags, err := parseStartFlags([]string{"--show", "--stealth"}) + if err != nil { + t.Fatalf("--show --stealth should be accepted, got error: %v", err) + } + if flags.headless { + t.Error("expected headless=false when --show is passed") + } + if !flags.stealth { + t.Error("expected stealth=true when --stealth is passed") + } +} + +func TestStealth_StatePersistence(t *testing.T) { + state := &State{ + DebugURL: "ws://localhost:1234", + ChromePID: 12345, + DataDir: t.TempDir(), + Stealth: 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.Stealth { + t.Error("expected Stealth=true after round-trip") + } +} + +func TestStealth_StateOmittedWhenFalse(t *testing.T) { + state := &State{ + DebugURL: "ws://localhost:1234", + ChromePID: 12345, + DataDir: "/tmp/test", + Stealth: false, + } + + data, err := json.Marshal(state) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + if strings.Contains(string(data), "stealth") { + t.Errorf("expected stealth to be omitted from JSON when false, got: %s", string(data)) + } +} + +func TestStealth_NavigatorWebdriver(t *testing.T) { + page := navigateTo(t, "/") + + // Inject stealth script the same way withPage does + _, err := proto.PageAddScriptToEvaluateOnNewDocument{ + Source: `Object.defineProperty(navigator, 'webdriver', {get: () => false});`, + }.Call(page) + if err != nil { + t.Fatalf("PageAddScriptToEvaluateOnNewDocument failed: %v", err) + } + + // Navigate again so the injected script runs before page scripts + if err := page.Navigate(env.server.URL + "/"); err != nil { + t.Fatalf("navigate failed: %v", err) + } + page.MustWaitLoad() + + result, err := page.Eval(`() => navigator.webdriver`) + if err != nil { + t.Fatalf("eval navigator.webdriver failed: %v", err) + } + + if result.Value.Bool() != false { + t.Errorf("expected navigator.webdriver to be false, got %v", result.Value) + } +} + +// ===================== +// Extended discover fixture +// ===================== + +func handleDiscoverExtended(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(` + +Extended Discover Page + + + +
+ + + + + + + +
+ +
+ + +
+ +
+ + + + +
Custom Action
+ Focusable Span +
+ +`)) +} + +// --- Fixtures for inspectFailure tests --- + +func handleHidden(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(` + +Hidden Page + + + + + +`)) +} + +// ===================== +// wait command tests +// ===================== + +func handleWaitTest(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(` + +Wait Test Page + +
Loading
+
Spinning...
+ + +`)) +} + +func handleLogin(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(` + +Sign In + + + + + +`)) +} + +// ===================== +// discover --forms tests +// ===================== + +func TestDiscoverForms_FindsFormFields(t *testing.T) { + page := navigateTo(t, "/discover-extended") + entries, err := queryDiscoverForms(page) + if err != nil { + t.Fatalf("queryDiscoverForms failed: %v", err) + } + // login form has: email, password, region select, avatar file, submit button = 5 + // search form has: q input, submit button = 2 + if len(entries) < 7 { + t.Fatalf("expected at least 7 form field entries, got %d", len(entries)) + } +} + +func TestDiscoverForms_InputCommand(t *testing.T) { + page := navigateTo(t, "/discover-extended") + entries, err := queryDiscoverForms(page) + if err != nil { + t.Fatalf("queryDiscoverForms failed: %v", err) + } + found := false + for _, e := range entries { + if e.Name == "email" { + found = true + if !strings.Contains(e.Command, "rodney input") { + t.Errorf("email field should suggest input command, got %q", e.Command) + } + if e.Tag != "input" { + t.Errorf("expected tag 'input', got %q", e.Tag) + } + break + } + } + if !found { + t.Error("email field not found in form entries") + } +} + +func TestDiscoverForms_SelectCommand(t *testing.T) { + page := navigateTo(t, "/discover-extended") + entries, err := queryDiscoverForms(page) + if err != nil { + t.Fatalf("queryDiscoverForms failed: %v", err) + } + found := false + for _, e := range entries { + if e.Name == "region" { + found = true + if !strings.Contains(e.Command, "rodney select") { + t.Errorf("select field should suggest select command, got %q", e.Command) + } + break + } + } + if !found { + t.Error("region select not found in form entries") + } +} + +func TestDiscoverForms_FileCommand(t *testing.T) { + page := navigateTo(t, "/discover-extended") + entries, err := queryDiscoverForms(page) + if err != nil { + t.Fatalf("queryDiscoverForms failed: %v", err) + } + found := false + for _, e := range entries { + if e.Name == "avatar" { + found = true + if !strings.Contains(e.Command, "rodney file") { + t.Errorf("file field should suggest file command, got %q", e.Command) + } + break + } + } + if !found { + t.Error("avatar file input not found in form entries") + } +} + +func TestDiscoverForms_SubmitCommand(t *testing.T) { + page := navigateTo(t, "/discover-extended") + entries, err := queryDiscoverForms(page) + if err != nil { + t.Fatalf("queryDiscoverForms failed: %v", err) + } + found := false + for _, e := range entries { + if e.Tag == "button" && e.FormSelector == "form#login" { + found = true + if !strings.Contains(e.Command, "rodney click") { + t.Errorf("submit button should suggest click command, got %q", e.Command) + } + break + } + } + if !found { + t.Error("submit button not found in login form entries") + } +} + +func TestDiscoverForms_FormSelector(t *testing.T) { + page := navigateTo(t, "/discover-extended") + entries, err := queryDiscoverForms(page) + if err != nil { + t.Fatalf("queryDiscoverForms failed: %v", err) + } + formSelectors := make(map[string]bool) + for _, e := range entries { + formSelectors[e.FormSelector] = true + } + if !formSelectors["form#login"] { + t.Error("expected form#login in form selectors") + } + if !formSelectors["form#search-form"] { + t.Error("expected form#search-form in form selectors") + } +} + +func TestDiscoverForms_Label(t *testing.T) { + page := navigateTo(t, "/discover-extended") + entries, err := queryDiscoverForms(page) + if err != nil { + t.Fatalf("queryDiscoverForms failed: %v", err) + } + for _, e := range entries { + if e.Name == "email" { + if e.Label != "Email" { + t.Errorf("email field should have label 'Email', got %q", e.Label) + } + return + } + } + t.Error("email field not found") +} + +func TestDiscoverForms_FormatText(t *testing.T) { + page := navigateTo(t, "/discover-extended") + entries, err := queryDiscoverForms(page) + if err != nil { + t.Fatalf("queryDiscoverForms failed: %v", err) + } + out := formatDiscoverFormsText(entries) + if !strings.Contains(out, "Form: form#login") { + t.Errorf("output should contain 'Form: form#login', got:\n%s", out) + } + if !strings.Contains(out, "rodney input") { + t.Errorf("output should contain 'rodney input', got:\n%s", out) + } + if !strings.Contains(out, "rodney select") { + t.Errorf("output should contain 'rodney select', got:\n%s", out) + } + if !strings.Contains(out, "rodney click") { + t.Errorf("output should contain 'rodney click', got:\n%s", out) + } +} + +func TestDiscoverForms_JSON(t *testing.T) { + page := navigateTo(t, "/discover-extended") + entries, err := queryDiscoverForms(page) + if err != nil { + t.Fatalf("queryDiscoverForms failed: %v", err) + } + out, jsonErr := json.MarshalIndent(entries, "", " ") + if jsonErr != nil { + t.Fatalf("JSON marshal failed: %v", jsonErr) + } + var parsed []discoverFormEntry + 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)) + } +} + +func TestDiscoverForms_EmptyPage(t *testing.T) { + page := navigateTo(t, "/empty") + entries, err := queryDiscoverForms(page) + if err != nil { + t.Fatalf("queryDiscoverForms failed: %v", err) + } + if len(entries) != 0 { + t.Errorf("expected 0 form entries on empty page, got %d", len(entries)) + } +} + +// ===================== +// discover --links tests +// ===================== + +func TestDiscoverLinks_FindsLinks(t *testing.T) { + page := navigateTo(t, "/discover-extended") + entries, err := queryDiscoverLinks(page) + if err != nil { + t.Fatalf("queryDiscoverLinks failed: %v", err) + } + // nav has 4 links + if len(entries) < 4 { + t.Fatalf("expected at least 4 link entries, got %d", len(entries)) + } +} + +func TestDiscoverLinks_ClickCommand(t *testing.T) { + page := navigateTo(t, "/discover-extended") + entries, err := queryDiscoverLinks(page) + if err != nil { + t.Fatalf("queryDiscoverLinks failed: %v", err) + } + for _, e := range entries { + if !strings.Contains(e.Command, "rodney click") { + t.Errorf("link should suggest click command, got %q", e.Command) + } + } +} + +func TestDiscoverLinks_HasHref(t *testing.T) { + page := navigateTo(t, "/discover-extended") + entries, err := queryDiscoverLinks(page) + if err != nil { + t.Fatalf("queryDiscoverLinks failed: %v", err) + } + hrefs := make(map[string]bool) + for _, e := range entries { + hrefs[e.Href] = true + } + for _, expected := range []string{"/dashboard", "/settings", "/profile", "/logout"} { + if !hrefs[expected] { + t.Errorf("expected link with href %q", expected) + } + } +} + +func TestDiscoverLinks_HasText(t *testing.T) { + page := navigateTo(t, "/discover-extended") + entries, err := queryDiscoverLinks(page) + if err != nil { + t.Fatalf("queryDiscoverLinks failed: %v", err) + } + texts := make(map[string]bool) + for _, e := range entries { + texts[e.Text] = true + } + for _, expected := range []string{"Dashboard", "Settings", "My Profile", "Log Out"} { + if !texts[expected] { + t.Errorf("expected link with text %q", expected) + } + } +} + +func TestDiscoverLinks_SelectorWithID(t *testing.T) { + page := navigateTo(t, "/discover-extended") + entries, err := queryDiscoverLinks(page) + if err != nil { + t.Fatalf("queryDiscoverLinks failed: %v", err) + } + for _, e := range entries { + if e.Href == "/profile" { + if e.Selector != "a#profile-link" { + t.Errorf("profile link should have selector 'a#profile-link', got %q", e.Selector) + } + return + } + } + t.Error("profile link not found") +} + +func TestDiscoverLinks_FormatText(t *testing.T) { + page := navigateTo(t, "/discover-extended") + entries, err := queryDiscoverLinks(page) + if err != nil { + t.Fatalf("queryDiscoverLinks failed: %v", err) + } + out := formatDiscoverLinksText(entries, "http://example.com/test") + if !strings.Contains(out, "Links on http://example.com/test:") { + t.Errorf("output should contain page URL header, got:\n%s", out) + } + if !strings.Contains(out, "rodney click") { + t.Errorf("output should contain 'rodney click', got:\n%s", out) + } + if !strings.Contains(out, "Dashboard") { + t.Errorf("output should contain link text 'Dashboard', got:\n%s", out) + } +} + +func TestDiscoverLinks_JSON(t *testing.T) { + page := navigateTo(t, "/discover-extended") + entries, err := queryDiscoverLinks(page) + if err != nil { + t.Fatalf("queryDiscoverLinks failed: %v", err) + } + out, jsonErr := json.MarshalIndent(entries, "", " ") + if jsonErr != nil { + t.Fatalf("JSON marshal failed: %v", jsonErr) + } + var parsed []discoverLinkEntry + 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)) + } +} + +func TestDiscoverLinks_EmptyPage(t *testing.T) { + page := navigateTo(t, "/empty") + entries, err := queryDiscoverLinks(page) + if err != nil { + t.Fatalf("queryDiscoverLinks failed: %v", err) + } + if len(entries) != 0 { + t.Errorf("expected 0 link entries on empty page, got %d", len(entries)) + } +} + +// ===================== +// discover --interactive tests +// ===================== + +func TestDiscoverInteractive_FindsElements(t *testing.T) { + page := navigateTo(t, "/discover-extended") + entries, err := queryDiscoverInteractive(page) + if err != nil { + t.Fatalf("queryDiscoverInteractive failed: %v", err) + } + // buttons (save-btn, 2 submit), links (4), inputs (email, password, q, agree checkbox), + // select (region), textarea (notes), role=button (custom-btn), tabindex span + if len(entries) < 10 { + t.Fatalf("expected at least 10 interactive entries, got %d", len(entries)) + } +} + +func TestDiscoverInteractive_ButtonClick(t *testing.T) { + page := navigateTo(t, "/discover-extended") + entries, err := queryDiscoverInteractive(page) + if err != nil { + t.Fatalf("queryDiscoverInteractive failed: %v", err) + } + found := false + for _, e := range entries { + if e.Selector == "button#save-btn" { + found = true + if !strings.Contains(e.Command, "rodney click") { + t.Errorf("button should suggest click command, got %q", e.Command) + } + if e.Role != "button" { + t.Errorf("expected role 'button', got %q", e.Role) + } + if e.Text != "Save Changes" { + t.Errorf("expected text 'Save Changes', got %q", e.Text) + } + break + } + } + if !found { + t.Error("save-btn button not found in interactive entries") + } +} + +func TestDiscoverInteractive_InputCommand(t *testing.T) { + page := navigateTo(t, "/discover-extended") + entries, err := queryDiscoverInteractive(page) + if err != nil { + t.Fatalf("queryDiscoverInteractive failed: %v", err) + } + found := false + for _, e := range entries { + if e.Selector == "input#email" { + found = true + if !strings.Contains(e.Command, "rodney input") { + t.Errorf("email input should suggest input command, got %q", e.Command) + } + if e.Role != "textbox" { + t.Errorf("expected role 'textbox', got %q", e.Role) + } + break + } + } + if !found { + t.Error("email input not found in interactive entries") + } +} + +func TestDiscoverInteractive_SelectCommand(t *testing.T) { + page := navigateTo(t, "/discover-extended") + entries, err := queryDiscoverInteractive(page) + if err != nil { + t.Fatalf("queryDiscoverInteractive failed: %v", err) + } + found := false + for _, e := range entries { + if e.Selector == "select#region" { + found = true + if !strings.Contains(e.Command, "rodney select") { + t.Errorf("select should suggest select command, got %q", e.Command) + } + if e.Role != "combobox" { + t.Errorf("expected role 'combobox', got %q", e.Role) + } + break + } + } + if !found { + t.Error("region select not found in interactive entries") + } +} + +func TestDiscoverInteractive_TextareaCommand(t *testing.T) { + page := navigateTo(t, "/discover-extended") + entries, err := queryDiscoverInteractive(page) + if err != nil { + t.Fatalf("queryDiscoverInteractive failed: %v", err) + } + found := false + for _, e := range entries { + if e.Selector == "textarea#notes" { + found = true + if !strings.Contains(e.Command, "rodney input") { + t.Errorf("textarea should suggest input command, got %q", e.Command) + } + if e.Role != "textbox" { + t.Errorf("expected role 'textbox', got %q", e.Role) + } + break + } + } + if !found { + t.Error("notes textarea not found in interactive entries") + } +} + +func TestDiscoverInteractive_LinkCommand(t *testing.T) { + page := navigateTo(t, "/discover-extended") + entries, err := queryDiscoverInteractive(page) + if err != nil { + t.Fatalf("queryDiscoverInteractive failed: %v", err) + } + found := false + for _, e := range entries { + if e.Tag == "a" && e.Text == "Dashboard" { + found = true + if !strings.Contains(e.Command, "rodney click") { + t.Errorf("link should suggest click command, got %q", e.Command) + } + if e.Role != "link" { + t.Errorf("expected role 'link', got %q", e.Role) + } + break + } + } + if !found { + t.Error("Dashboard link not found in interactive entries") + } +} + +func TestDiscoverInteractive_RoleButton(t *testing.T) { + page := navigateTo(t, "/discover-extended") + entries, err := queryDiscoverInteractive(page) + if err != nil { + t.Fatalf("queryDiscoverInteractive failed: %v", err) + } + found := false + for _, e := range entries { + if e.Selector == "div#custom-btn" { + found = true + if !strings.Contains(e.Command, "rodney click") { + t.Errorf("role=button element should suggest click command, got %q", e.Command) + } + if e.Role != "button" { + t.Errorf("expected role 'button', got %q", e.Role) + } + break + } + } + if !found { + t.Error("custom-btn (role=button) not found in interactive entries") + } +} + +func TestDiscoverInteractive_Tabindex(t *testing.T) { + page := navigateTo(t, "/discover-extended") + entries, err := queryDiscoverInteractive(page) + if err != nil { + t.Fatalf("queryDiscoverInteractive failed: %v", err) + } + found := false + for _, e := range entries { + if e.Selector == "span#focusable-span" { + found = true + break + } + } + if !found { + t.Error("focusable-span (tabindex) not found in interactive entries") + } +} + +func TestDiscoverInteractive_CheckboxRole(t *testing.T) { + page := navigateTo(t, "/discover-extended") + entries, err := queryDiscoverInteractive(page) + if err != nil { + t.Fatalf("queryDiscoverInteractive failed: %v", err) + } + found := false + for _, e := range entries { + if e.Selector == "input#agree" { + found = true + if e.Role != "checkbox" { + t.Errorf("expected role 'checkbox', got %q", e.Role) + } + break + } + } + if !found { + t.Error("agree checkbox not found in interactive entries") + } +} + +func TestDiscoverInteractive_FormatText(t *testing.T) { + page := navigateTo(t, "/discover-extended") + entries, err := queryDiscoverInteractive(page) + if err != nil { + t.Fatalf("queryDiscoverInteractive failed: %v", err) + } + out := formatDiscoverInteractiveText(entries) + if !strings.Contains(out, "Interactive elements:") { + t.Errorf("output should contain 'Interactive elements:' header, got:\n%s", out) + } + if !strings.Contains(out, "rodney click") { + t.Errorf("output should contain 'rodney click', got:\n%s", out) + } + if !strings.Contains(out, "rodney input") { + t.Errorf("output should contain 'rodney input', got:\n%s", out) + } + if !strings.Contains(out, "rodney select") { + t.Errorf("output should contain 'rodney select', got:\n%s", out) + } +} + +func TestDiscoverInteractive_JSON(t *testing.T) { + page := navigateTo(t, "/discover-extended") + entries, err := queryDiscoverInteractive(page) + if err != nil { + t.Fatalf("queryDiscoverInteractive failed: %v", err) + } + out, jsonErr := json.MarshalIndent(entries, "", " ") + if jsonErr != nil { + t.Fatalf("JSON marshal failed: %v", jsonErr) + } + var parsed []discoverInteractiveEntry + 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)) + } +} + +func TestDiscoverInteractive_EmptyPage(t *testing.T) { + page := navigateTo(t, "/empty") + entries, err := queryDiscoverInteractive(page) + if err != nil { + t.Fatalf("queryDiscoverInteractive failed: %v", err) + } + if len(entries) != 0 { + t.Errorf("expected 0 interactive entries on empty page, got %d", len(entries)) + } +} + +// ===================== +// discover mode mutual exclusivity test +// ===================== + +func TestDiscoverModes_MutualExclusivity(t *testing.T) { + // Test that parsing detects multiple mode flags. + // We can't easily test cmdDiscover (it calls fatal/os.Exit), + // so we test the flag parsing logic inline. + tests := []struct { + name string + args []string + count int + }{ + {"forms only", []string{"--forms"}, 1}, + {"links only", []string{"--links"}, 1}, + {"interactive only", []string{"--interactive"}, 1}, + {"forms+links", []string{"--forms", "--links"}, 2}, + {"forms+interactive", []string{"--forms", "--interactive"}, 2}, + {"links+interactive", []string{"--links", "--interactive"}, 2}, + {"all three", []string{"--forms", "--links", "--interactive"}, 3}, + {"none", []string{}, 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + modeForms, modeLinks, modeInteractive := false, false, false + for _, arg := range tt.args { + switch arg { + case "--forms": + modeForms = true + case "--links": + modeLinks = true + case "--interactive": + modeInteractive = true + } + } + count := 0 + if modeForms { + count++ + } + if modeLinks { + count++ + } + if modeInteractive { + count++ + } + if count != tt.count { + t.Errorf("expected mode count %d, got %d", tt.count, count) + } + if count > 1 { + // This is the error case: modes are mutually exclusive + // Verify that the condition that triggers the error is detected + if count <= 1 { + t.Error("expected multiple modes to be detected as error") + } + } + }) + } +} + +// ===================== +// check command tests +// ===================== + +func TestParseCheckArgs_Exists(t *testing.T) { + checks, jsonOut, err := parseCheckArgs([]string{"--exists", "h1"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if jsonOut { + t.Error("expected jsonOut=false") + } + if len(checks) != 1 { + t.Fatalf("expected 1 check, got %d", len(checks)) + } + if checks[0].kind != "exists" || checks[0].arg1 != "h1" { + t.Errorf("expected exists/h1, got %s/%s", checks[0].kind, checks[0].arg1) + } +} + +func TestParseCheckArgs_TextAndCount(t *testing.T) { + checks, _, err := parseCheckArgs([]string{ + "--text", "h1", "Welcome", + "--count", "button", "2", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(checks) != 2 { + t.Fatalf("expected 2 checks, got %d", len(checks)) + } + if checks[0].kind != "text" || checks[0].arg1 != "h1" || checks[0].arg2 != "Welcome" { + t.Errorf("check 0: got %+v", checks[0]) + } + if checks[1].kind != "count" || checks[1].arg1 != "button" || checks[1].arg2 != "2" { + t.Errorf("check 1: got %+v", checks[1]) + } +} + +func TestParseCheckArgs_AssertWithExpected(t *testing.T) { + checks, _, err := parseCheckArgs([]string{"--assert", "document.title", "Test Page"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(checks) != 1 { + t.Fatalf("expected 1 check, got %d", len(checks)) + } + if checks[0].kind != "assert" || checks[0].arg1 != "document.title" || checks[0].arg2 != "Test Page" { + t.Errorf("got %+v", checks[0]) + } +} + +func TestParseCheckArgs_AssertTruthy(t *testing.T) { + checks, _, err := parseCheckArgs([]string{"--assert", "document.title", "--exists", "h1"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(checks) != 2 { + t.Fatalf("expected 2 checks, got %d", len(checks)) + } + // The assert should NOT have consumed "--exists" as its expected value + if checks[0].kind != "assert" || checks[0].arg1 != "document.title" || checks[0].arg2 != "" { + t.Errorf("assert check: got %+v", checks[0]) + } + if checks[1].kind != "exists" || checks[1].arg1 != "h1" { + t.Errorf("exists check: got %+v", checks[1]) + } +} + +func TestParseCheckArgs_JSON(t *testing.T) { + checks, jsonOut, err := parseCheckArgs([]string{"--json", "--exists", "h1"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !jsonOut { + t.Error("expected jsonOut=true") + } + if len(checks) != 1 { + t.Fatalf("expected 1 check, got %d", len(checks)) + } +} + +func TestParseCheckArgs_Visible(t *testing.T) { + checks, _, err := parseCheckArgs([]string{"--visible", ".main"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(checks) != 1 || checks[0].kind != "visible" || checks[0].arg1 != ".main" { + t.Errorf("got %+v", checks) + } +} + +func TestParseCheckArgs_NoChecks(t *testing.T) { + checks, _, err := parseCheckArgs([]string{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(checks) != 0 { + t.Errorf("expected 0 checks, got %d", len(checks)) + } +} + +func TestParseCheckArgs_UnknownFlag(t *testing.T) { + _, _, err := parseCheckArgs([]string{"--bogus"}) + if err == nil { + t.Fatal("expected error for unknown flag") + } + if !strings.Contains(err.Error(), "unknown flag") { + t.Errorf("expected 'unknown flag' in error, got: %v", err) + } +} + +func TestParseCheckArgs_MissingExistsArg(t *testing.T) { + _, _, err := parseCheckArgs([]string{"--exists"}) + if err == nil { + t.Fatal("expected error for missing --exists arg") + } +} + +func TestParseCheckArgs_MissingTextArgs(t *testing.T) { + _, _, err := parseCheckArgs([]string{"--text", "h1"}) + if err == nil { + t.Fatal("expected error for missing --text expected arg") + } +} + +func TestParseCheckArgs_MissingCountArgs(t *testing.T) { + _, _, err := parseCheckArgs([]string{"--count", "h1"}) + if err == nil { + t.Fatal("expected error for missing --count expected arg") + } +} + +func TestRunCheck_Exists_Pass(t *testing.T) { + page := navigateTo(t, "/") + r := runCheck(page, checkItem{kind: "exists", arg1: "h1"}) + if !r.Pass { + t.Errorf("expected pass for existing element, got %+v", r) + } + if r.Check != "exists" { + t.Errorf("expected check='exists', got %q", r.Check) + } +} + +func TestRunCheck_Exists_Fail(t *testing.T) { + page := navigateTo(t, "/") + r := runCheck(page, checkItem{kind: "exists", arg1: ".nonexistent"}) + if r.Pass { + t.Errorf("expected fail for nonexistent element, got %+v", r) + } +} + +func TestRunCheck_Visible_Pass(t *testing.T) { + page := navigateTo(t, "/") + r := runCheck(page, checkItem{kind: "visible", arg1: "h1"}) + if !r.Pass { + t.Errorf("expected pass for visible element, got %+v", r) + } +} + +func TestRunCheck_Visible_Fail(t *testing.T) { + page := navigateTo(t, "/discover") + r := runCheck(page, checkItem{kind: "visible", arg1: "[data-testid=\"hidden-el\"]"}) + if r.Pass { + t.Errorf("expected fail for hidden element, got %+v", r) + } +} + +func TestRunCheck_Text_Pass(t *testing.T) { + page := navigateTo(t, "/") + r := runCheck(page, checkItem{kind: "text", arg1: "h1", arg2: "Welcome"}) + if !r.Pass { + t.Errorf("expected pass, got %+v", r) + } + if r.Got != "Welcome" { + t.Errorf("expected got='Welcome', got %q", r.Got) + } +} + +func TestRunCheck_Text_Fail(t *testing.T) { + page := navigateTo(t, "/") + r := runCheck(page, checkItem{kind: "text", arg1: "h1", arg2: "Goodbye"}) + if r.Pass { + t.Errorf("expected fail, got %+v", r) + } + if r.Got != "Welcome" { + t.Errorf("expected got='Welcome', got %q", r.Got) + } + if r.Expected != "Goodbye" { + t.Errorf("expected expected='Goodbye', got %q", r.Expected) + } +} + +func TestRunCheck_Count_Pass(t *testing.T) { + page := navigateTo(t, "/") + r := runCheck(page, checkItem{kind: "count", arg1: "button", arg2: "2"}) + if !r.Pass { + t.Errorf("expected pass, got %+v", r) + } + if r.Got != "2" { + t.Errorf("expected got='2', got %q", r.Got) + } +} + +func TestRunCheck_Count_Fail(t *testing.T) { + page := navigateTo(t, "/") + r := runCheck(page, checkItem{kind: "count", arg1: "button", arg2: "5"}) + if r.Pass { + t.Errorf("expected fail, got %+v", r) + } + if r.Got != "2" { + t.Errorf("expected got='2', got %q", r.Got) + } + if r.Expected != "5" { + t.Errorf("expected expected='5', got %q", r.Expected) + } +} + +func TestRunCheck_Assert_Truthy_Pass(t *testing.T) { + page := navigateTo(t, "/") + r := runCheck(page, checkItem{kind: "assert", arg1: "document.title"}) + if !r.Pass { + t.Errorf("expected pass for truthy title, got %+v", r) + } + if r.Got != "Test Page" { + t.Errorf("expected got='Test Page', got %q", r.Got) + } +} + +func TestRunCheck_Assert_Truthy_Fail(t *testing.T) { + page := navigateTo(t, "/") + r := runCheck(page, checkItem{kind: "assert", arg1: "null"}) + if r.Pass { + t.Errorf("expected fail for null, got %+v", r) + } +} + +func TestRunCheck_Assert_Equality_Pass(t *testing.T) { + page := navigateTo(t, "/") + r := runCheck(page, checkItem{kind: "assert", arg1: "document.title", arg2: "Test Page"}) + if !r.Pass { + t.Errorf("expected pass, got %+v", r) + } +} + +func TestRunCheck_Assert_Equality_Fail(t *testing.T) { + page := navigateTo(t, "/") + r := runCheck(page, checkItem{kind: "assert", arg1: "document.title", arg2: "Wrong Title"}) + if r.Pass { + t.Errorf("expected fail, got %+v", r) + } + if r.Got != "Test Page" { + t.Errorf("expected got='Test Page', got %q", r.Got) + } + if r.Expected != "Wrong Title" { + t.Errorf("expected expected='Wrong Title', got %q", r.Expected) + } +} + +func TestFormatCheckLine_Pass(t *testing.T) { + r := checkResult{Check: "exists", Selector: "h1", Pass: true} + line := formatCheckLine(r) + if !strings.HasPrefix(line, "PASS") { + t.Errorf("expected PASS prefix, got %q", line) + } + if !strings.Contains(line, "exists") || !strings.Contains(line, "h1") { + t.Errorf("expected 'exists' and 'h1' in line, got %q", line) + } +} + +func TestFormatCheckLine_Fail_WithExpected(t *testing.T) { + r := checkResult{Check: "text", Selector: "h1", Pass: false, Got: "Hello", Expected: "Welcome"} + line := formatCheckLine(r) + if !strings.HasPrefix(line, "FAIL") { + t.Errorf("expected FAIL prefix, got %q", line) + } + if !strings.Contains(line, `got "Hello"`) || !strings.Contains(line, `expected "Welcome"`) { + t.Errorf("expected got/expected in line, got %q", line) + } +} + +func TestFormatCheckLine_Pass_WithExpected(t *testing.T) { + r := checkResult{Check: "count", Selector: "button", Pass: true, Got: "2", Expected: "2"} + line := formatCheckLine(r) + if !strings.HasPrefix(line, "PASS") { + t.Errorf("expected PASS prefix, got %q", line) + } + if !strings.Contains(line, "= 2") { + t.Errorf("expected '= 2' in line, got %q", line) + } +} + +func TestCheck_AllPass_ExitZero(t *testing.T) { + page := navigateTo(t, "/") + checks := []checkItem{ + {kind: "exists", arg1: "h1"}, + {kind: "text", arg1: "h1", arg2: "Welcome"}, + {kind: "count", arg1: "button", arg2: "2"}, + } + var results []checkResult + for _, c := range checks { + results = append(results, runCheck(page, c)) + } + passed := 0 + for _, r := range results { + if r.Pass { + passed++ + } + } + if passed != len(results) { + t.Errorf("expected all %d to pass, only %d passed", len(results), passed) + } +} + +func TestCheck_SomeFail_NonZeroPassed(t *testing.T) { + page := navigateTo(t, "/") + checks := []checkItem{ + {kind: "exists", arg1: "h1"}, + {kind: "text", arg1: "h1", arg2: "Wrong text"}, + } + var results []checkResult + for _, c := range checks { + results = append(results, runCheck(page, c)) + } + passed := 0 + for _, r := range results { + if r.Pass { + passed++ + } + } + if passed != 1 { + t.Errorf("expected 1 pass and 1 fail, got %d passed out of %d", passed, len(results)) + } +} + +func TestCheck_JSONOutput(t *testing.T) { + page := navigateTo(t, "/") + checks := []checkItem{ + {kind: "exists", arg1: "h1"}, + {kind: "text", arg1: "h1", arg2: "Welcome"}, + } + var results []checkResult + for _, c := range checks { + results = append(results, runCheck(page, c)) + } + data, err := json.MarshalIndent(results, "", " ") + if err != nil { + t.Fatalf("JSON marshal failed: %v", err) + } + var parsed []checkResult + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("JSON unmarshal failed: %v", err) + } + if len(parsed) != 2 { + t.Fatalf("expected 2 results in JSON, got %d", len(parsed)) + } + if !parsed[0].Pass { + t.Error("expected first result to pass") + } + if parsed[0].Check != "exists" { + t.Errorf("expected check='exists', got %q", parsed[0].Check) + } + if !parsed[1].Pass { + t.Error("expected second result to pass") + } + if parsed[1].Check != "text" { + t.Errorf("expected check='text', got %q", parsed[1].Check) + } +} + +func TestCheck_MultipleTypes(t *testing.T) { + page := navigateTo(t, "/") + checks := []checkItem{ + {kind: "exists", arg1: "h1"}, + {kind: "visible", arg1: "h1"}, + {kind: "text", arg1: "h1", arg2: "Welcome"}, + {kind: "count", arg1: "button", arg2: "2"}, + {kind: "assert", arg1: "document.title", arg2: "Test Page"}, + {kind: "assert", arg1: "1 + 1 === 2"}, + } + var results []checkResult + for _, c := range checks { + results = append(results, runCheck(page, c)) + } + for i, r := range results { + if !r.Pass { + t.Errorf("check %d (%s %s) failed: %+v", i, r.Check, r.Selector+r.Expr, r) + } + } +} + +// ===================== +// wait command unit tests +// ===================== + +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") + } +} + +// ===================== +// hint function tests +// ===================== + +func handleOverlay(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(` + +Overlay Page + + + + +`)) +} + +// captureStderr captures everything written to os.Stderr by fn, trimming trailing whitespace. +// 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 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) + } +} + +// ===================== +// inspectFailure tests +// ===================== + +func TestInspectFailure_HiddenElement(t *testing.T) { + page := navigateTo(t, "/hidden") + stderr := captureStderr(t, func() { + inspectFailure(page, "#hidden-btn") + }) + if !strings.Contains(stderr, "exists but is hidden") { + t.Errorf("expected hidden element context, got:\n%s", stderr) + } + if !strings.Contains(stderr, "display: none") { + t.Errorf("expected 'display: none' in context, got:\n%s", stderr) + } + if !strings.Contains(stderr, "rodney wait") { + t.Errorf("expected suggestion to use rodney wait, got:\n%s", stderr) + } +} + +func TestInspectFailure_FuzzyMatch(t *testing.T) { + page := navigateTo(t, "/hidden") + stderr := captureStderr(t, func() { + inspectFailure(page, "#checkout") + }) + if !strings.Contains(stderr, "did you mean '#checkout-btn'?") { + t.Errorf("expected fuzzy match suggestion for #checkout-btn, got:\n%s", stderr) + } +} + +func TestInspectFailure_AuthURL(t *testing.T) { + page := navigateTo(t, "/login") + stderr := captureStderr(t, func() { + inspectFailure(page, "#nonexistent") + }) + if !strings.Contains(stderr, "login page") { + t.Errorf("expected auth pattern context, got:\n%s", stderr) + } +} + +func TestInspectFailure_OverlayDetection(t *testing.T) { + page := navigateTo(t, "/overlay") + stderr := captureStderr(t, func() { + inspectFailure(page, "#nonexistent") + }) + if !strings.Contains(stderr, "modal/overlay may be blocking") { + t.Errorf("expected overlay context, got:\n%s", stderr) + } + if !strings.Contains(stderr, "z-index") { + t.Errorf("expected z-index in overlay context, got:\n%s", stderr) + } +} + +func TestInspectFailure_AvailableElements(t *testing.T) { + page := navigateTo(t, "/") + stderr := captureStderr(t, func() { + inspectFailure(page, "#nonexistent-xyz") + }) + if !strings.Contains(stderr, "interactive elements") { + t.Errorf("expected interactive elements context, got:\n%s", stderr) + } + if !strings.Contains(stderr, "rodney discover --interactive") { + t.Errorf("expected discover suggestion, got:\n%s", stderr) + } +} + +func TestInspectFailure_EmptyPage(t *testing.T) { + page := navigateTo(t, "/empty") + // Should not panic or crash on a page with no interactive elements + stderr := captureStderr(t, func() { + inspectFailure(page, "#anything") + }) + // On an empty page, there should be no crash; context may or may not be present + _ = stderr +} + +func TestInspectFailure_NoPanic(t *testing.T) { + page := navigateTo(t, "/") + // Test with various selector types that should not cause panics + selectors := []string{"#nonexistent", ".missing-class", "div.nope", "[data-x]", ""} + for _, sel := range selectors { + captureStderr(t, func() { + inspectFailure(page, sel) + }) + } +} + +// ===================== +// Accessibility selector tests +// ===================== + +func TestParseAXFlags_RoleOnly(t *testing.T) { + role, name, remaining := parseAXFlags([]string{"--role", "button"}) + if role != "button" { + t.Errorf("expected role 'button', got %q", role) + } + if name != "" { + t.Errorf("expected empty name, got %q", name) + } + if len(remaining) != 0 { + t.Errorf("expected no remaining args, got %v", remaining) + } +} + +func TestParseAXFlags_NameOnly(t *testing.T) { + role, name, remaining := parseAXFlags([]string{"--name", "Submit"}) + if role != "" { + t.Errorf("expected empty role, got %q", role) + } + if name != "Submit" { + t.Errorf("expected name 'Submit', got %q", name) + } + if len(remaining) != 0 { + t.Errorf("expected no remaining args, got %v", remaining) + } +} + +func TestParseAXFlags_RoleAndName(t *testing.T) { + role, name, remaining := parseAXFlags([]string{"--role", "button", "--name", "Submit"}) + if role != "button" { + t.Errorf("expected role 'button', got %q", role) + } + if name != "Submit" { + t.Errorf("expected name 'Submit', got %q", name) + } + if len(remaining) != 0 { + t.Errorf("expected no remaining args, got %v", remaining) + } +} + +func TestParseAXFlags_CSSSelector(t *testing.T) { + role, name, remaining := parseAXFlags([]string{"#submit-btn"}) + if role != "" { + t.Errorf("expected empty role, got %q", role) + } + if name != "" { + t.Errorf("expected empty name, got %q", name) + } + if len(remaining) != 1 || remaining[0] != "#submit-btn" { + t.Errorf("expected remaining [#submit-btn], got %v", remaining) + } +} + +func TestParseAXFlags_RoleWithExtraArgs(t *testing.T) { + role, name, remaining := parseAXFlags([]string{"--role", "textbox", "--name", "Email", "hello world"}) + if role != "textbox" { + t.Errorf("expected role 'textbox', got %q", role) + } + if name != "Email" { + t.Errorf("expected name 'Email', got %q", name) + } + if len(remaining) != 1 || remaining[0] != "hello world" { + t.Errorf("expected remaining ['hello world'], got %v", remaining) + } +} + +func TestResolveElement_ByRole(t *testing.T) { + page := navigateTo(t, "/") + el, desc, remaining := resolveElement(page, []string{"--role", "button"}) + if el == nil { + t.Fatal("expected element, got nil") + } + if !strings.Contains(desc, "--role") { + t.Errorf("expected desc to contain '--role', got %q", desc) + } + if len(remaining) != 0 { + t.Errorf("expected no remaining args, got %v", remaining) + } + // Verify it found a button + tag, err := el.Eval(`() => this.tagName.toLowerCase()`) + if err != nil { + t.Fatalf("eval failed: %v", err) + } + if tag.Value.Str() != "button" { + t.Errorf("expected button tag, got %q", tag.Value.Str()) + } +} + +func TestResolveElement_ByName(t *testing.T) { + page := navigateTo(t, "/") + el, desc, _ := resolveElement(page, []string{"--name", "Submit"}) + if el == nil { + t.Fatal("expected element, got nil") + } + if !strings.Contains(desc, "--name") { + t.Errorf("expected desc to contain '--name', got %q", desc) + } + text, err := el.Text() + if err != nil { + t.Fatalf("text failed: %v", err) + } + if text != "Submit" { + t.Errorf("expected text 'Submit', got %q", text) + } +} + +func TestResolveElement_ByRoleAndName(t *testing.T) { + page := navigateTo(t, "/") + el, _, _ := resolveElement(page, []string{"--role", "button", "--name", "Submit"}) + if el == nil { + t.Fatal("expected element, got nil") + } + text, err := el.Text() + if err != nil { + t.Fatalf("text failed: %v", err) + } + if text != "Submit" { + t.Errorf("expected text 'Submit', got %q", text) + } +} + +func TestResolveElement_ByCSSSelector(t *testing.T) { + page := navigateTo(t, "/") + el, desc, remaining := resolveElement(page, []string{"#submit-btn"}) + if el == nil { + t.Fatal("expected element, got nil") + } + if desc != "#submit-btn" { + t.Errorf("expected desc '#submit-btn', got %q", desc) + } + if len(remaining) != 0 { + t.Errorf("expected no remaining args, got %v", remaining) + } +} + +func TestResolveElement_InputWithExtraArgs(t *testing.T) { + page := navigateTo(t, "/form") + el, _, remaining := resolveElement(page, []string{"--role", "textbox", "--name", "Name", "hello"}) + if el == nil { + t.Fatal("expected element, got nil") + } + if len(remaining) != 1 || remaining[0] != "hello" { + t.Errorf("expected remaining ['hello'], got %v", remaining) + } +} + +func TestResolveElement_FormPageByRole(t *testing.T) { + page := navigateTo(t, "/form") + // Should find a textbox on the form page + el, _, _ := resolveElement(page, []string{"--role", "textbox"}) + if el == nil { + t.Fatal("expected element, got nil") + } + tag, _ := el.Eval(`() => this.tagName.toLowerCase()`) + if tag.Value.Str() != "input" { + t.Errorf("expected input tag, got %q", tag.Value.Str()) + } +} + +func TestResolveElement_LinkByRoleAndName(t *testing.T) { + page := navigateTo(t, "/") + el, _, _ := resolveElement(page, []string{"--role", "link", "--name", "About"}) + if el == nil { + t.Fatal("expected element, got nil") + } + href := el.MustAttribute("href") + if href == nil || *href != "/about" { + t.Errorf("expected href '/about', got %v", href) + } +} + +// ===================== +// repeated failure detection tests +// ===================== + +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)) + } +} 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 `