This commit is contained in:
Josh at WLTechBlog 2025-08-12 10:19:13 -05:00
parent 70d9ed30de
commit d6209cd34f
16 changed files with 4118 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
steps.sh
/cremote
/cremotedaemon

627
LLM_CODING_AGENT_GUIDE.md Normal file
View File

@ -0,0 +1,627 @@
# LLM Agent Guide: Using cremote for Web Application Testing
This document provides comprehensive guidance for LLM coding agents on how to use **cremote** (Chrome Remote Daemon) as a testing tool for web applications. cremote enables automated browser testing of public web interfaces through programmatic control of Chrome browser tabs.
## What is cremote?
**cremote** is a browser automation tool that allows you to:
- Control Chromium browser tabs programmatically
- Fill forms and interact with web elements
- Navigate web pages and wait for content to load
- Extract page content and element HTML
- Test user workflows end-to-end
It uses a daemon-client architecture where a background daemon maintains persistent connections to Chromium, and a command-line client sends testing commands.
## Prerequisites for Testing
Before using cremote for web application testing, ensure:
0. **Check to see if everything is already running, if so you can skip the steps to start it:**
```bash
cremote status
```
**Note**: `cremote status` always exits with code 0, whether the daemon is running or not. Check the output message to determine status.
1. **Chromium/Chrome is running with remote debugging enabled:**
```bash
# Create a temporary user data directory for the debug instance
chromium --remote-debugging-port=9222 --user-data-dir=/tmp/chromium-debug &
# or
google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug &
# Alternative: Use a random temporary directory
chromium --remote-debugging-port=9222 --user-data-dir=$(mktemp -d) &
```
**Important**: The `--user-data-dir` flag is required to prevent Chromium from trying to use an existing window. Without it, Chromium will attempt to connect to an already running instance instead of starting a new debug-enabled instance.
2. **cremote daemon is running:**
```bash
cremotedaemon &
```
**Note**: The daemon will automatically check if Chromium is running and provide helpful error messages if:
- Chromium is not running on port 9222
- Something else is using port 9222 (not Chromium DevTools)
- Chromium is running but not accepting connections
3. **Verify connectivity:**
```bash
cremote status
```
## Core Testing Workflow
### 1. Basic Test Session Setup
Every testing session follows this pattern:
```bash
# 1. Open a new browser tab for testing
TAB_ID=$(cremote open-tab)
# 2. Navigate to the application under test
cremote load-url --url="https://your-app.com"
# 3. Perform test actions (forms, clicks, navigation)
# ... testing commands ...
# 4. Clean up (optional)
cremote close-tab --tab="$TAB_ID"
```
### 2. Current Tab Feature
cremote automatically tracks the "current tab" - the most recently used tab. This means you can omit the `--tab` flag in most commands:
```bash
# Open tab (becomes current tab)
cremote open-tab
# All subsequent commands use current tab automatically
cremote load-url --url="https://example.com"
cremote fill-form --selector="#username" --value="testuser"
cremote click-element --selector="#login-btn"
```
## Essential Testing Commands
### Navigation and Page Loading
```bash
# Load a specific URL
cremote load-url --url="https://your-app.com/login"
# Wait for navigation to complete (useful after form submissions)
cremote wait-navigation --timeout=10
# Note: wait-navigation is smart - it returns immediately if no navigation is happening
# Get current page source for verification
cremote get-source
```
### Form Testing
```bash
# Fill text inputs
cremote fill-form --selector="#username" --value="testuser"
cremote fill-form --selector="#password" --value="testpass123"
# Handle checkboxes (check)
cremote fill-form --selector="#remember-me" --value="true"
cremote fill-form --selector="#terms-agreed" --value="checked"
# Handle checkboxes (uncheck)
cremote fill-form --selector="#newsletter" --value="false"
# Handle radio buttons
cremote fill-form --selector="#payment-credit" --value="true"
# Submit forms
cremote submit-form --selector="form#login-form"
```
**Checkbox/Radio Button Values:**
- To check/select: `true`, `1`, `yes`, `on`, `checked`
- To uncheck/deselect: `false`, `0`, `no`, `off`, or any other value
### File Upload Testing
```bash
# Upload files to file inputs
cremote upload-file --selector="input[type=file]" --file="/path/to/test-file.pdf"
cremote upload-file --selector="#profile-photo" --file="/tmp/test-image.jpg"
```
### Element Interaction
```bash
# Click buttons, links, or any clickable elements
cremote click-element --selector="button.submit"
cremote click-element --selector="a[href='/dashboard']"
cremote click-element --selector="#save-changes"
# Get HTML content of specific elements for verification
cremote get-element --selector=".error-message"
cremote get-element --selector="#user-profile"
```
### JavaScript Execution
```bash
# Execute JavaScript code directly in the page (default 5 second timeout)
cremote eval-js --code="document.getElementById('tinymce').innerHTML='Foo!'"
# Get values from the page
cremote eval-js --code="document.querySelector('#result').textContent"
# Manipulate page elements
cremote eval-js --code="document.body.style.backgroundColor = 'red'"
# Trigger JavaScript events
cremote eval-js --code="document.getElementById('submit-btn').click()"
# Work with complex objects (returns JSON string)
cremote eval-js --code="document.querySelectorAll('.item').length"
# Set form values programmatically (statement - returns "undefined")
cremote eval-js --code="document.getElementById('hidden-field').value = 'secret-value'"
# Get form values (expression - returns the value)
cremote eval-js --code="document.getElementById('hidden-field').value"
# Use custom timeout for long-running JavaScript
cremote eval-js --code="await new Promise(resolve => setTimeout(resolve, 8000))" --timeout=10
```
### Screenshots
```bash
# Take a viewport screenshot (default)
cremote screenshot --output="/tmp/page-screenshot.png"
# Take a full page screenshot
cremote screenshot --output="/tmp/full-page.png" --full-page
# Screenshot with custom timeout
cremote screenshot --output="/tmp/slow-page.png" --timeout=10
# Screenshot of specific tab
cremote screenshot --tab="$TAB_ID" --output="/tmp/tab-screenshot.png"
```
**Use Cases for JavaScript Execution:**
- Interact with rich text editors (TinyMCE, CKEditor, etc.)
- Trigger JavaScript events that aren't accessible via normal clicks
- Extract computed values or complex data structures
- Manipulate hidden form fields
- Test JavaScript functionality directly
- Set up test data or page state
**Expression vs Statement Handling:**
- **Expressions** return values: `document.title`, `element.textContent`, `array.length`
- **Statements** perform actions and return "undefined": `element.click()`, `variable = value`
- Both types are supported seamlessly in the same command
### Working with Iframes
```bash
# Switch to iframe context
cremote switch-iframe --selector="iframe#payment-form"
# All subsequent commands now operate within the iframe
cremote fill-form --selector="#card-number" --value="4111111111111111"
cremote fill-form --selector="#cvv" --value="123"
cremote click-element --selector="#submit-payment"
# Switch back to main page context
cremote switch-main
# Commands now operate on the main page again
cremote get-element --selector=".payment-success"
```
**Common Iframe Scenarios:**
- **Payment Forms**: Credit card processing iframes
- **Embedded Widgets**: Social media, maps, chat widgets
- **Third-party Content**: Ads, analytics, external forms
- **Security Contexts**: Sandboxed content, cross-origin frames
**Important Notes:**
- Iframe context is maintained per tab
- All commands (fill-form, click-element, eval-js, etc.) work within iframe context
- Must explicitly switch back to main context with `switch-main`
- Iframe context persists until switched back or tab is closed
### Tab Management
```bash
# List all open tabs (current tab marked with *)
cremote list-tabs
# Open multiple tabs for complex testing
TAB1=$(cremote open-tab)
TAB2=$(cremote open-tab)
# Work with specific tabs
cremote load-url --tab="$TAB1" --url="https://app.com/admin"
cremote load-url --tab="$TAB2" --url="https://app.com/user"
# Close specific tabs
cremote close-tab --tab="$TAB1"
```
## Timeout Configuration
Many commands support timeout parameters for robust testing:
```bash
# Wait up to 10 seconds for element to appear, then 5 seconds for action
cremote fill-form --selector="#slow-loading-field" --value="test" \
--selection-timeout=10 --action-timeout=5
# Wait for elements that load dynamically
cremote click-element --selector=".ajax-button" \
--selection-timeout=15 --action-timeout=10
# Get elements that may take time to render
cremote get-element --selector=".dynamic-content" --selection-timeout=20
```
**Timeout Parameters:**
- `--selection-timeout`: Seconds to wait for element to appear in DOM (default: 5 seconds)
- `--action-timeout`: Seconds to wait for action to complete (default: 5 seconds)
- `--timeout`: General timeout for operations (default: 5 seconds)
**Smart Navigation Waiting:**
The `wait-navigation` command intelligently detects if navigation is actually happening:
- Returns immediately if the page is already stable and loaded
- Monitors for 2 seconds to detect if navigation starts
- Only waits for the full timeout if navigation is actually in progress
- This prevents hanging when no navigation occurs
## Common Testing Patterns
### 1. Login Flow Testing
```bash
#!/bin/bash
# Test user login functionality
# Setup
cremote open-tab
cremote load-url --url="https://myapp.com/login"
# Test valid login
cremote fill-form --selector="#email" --value="user@example.com"
cremote fill-form --selector="#password" --value="validpassword"
cremote click-element --selector="#login-button"
# Wait for redirect and verify success
cremote wait-navigation --timeout=10
PAGE_SOURCE=$(cremote get-source)
if echo "$PAGE_SOURCE" | grep -q "Welcome"; then
echo "✓ Login successful"
else
echo "✗ Login failed"
exit 1
fi
```
### 2. Form Validation Testing
```bash
#!/bin/bash
# Test form validation
cremote open-tab
cremote load-url --url="https://myapp.com/register"
# Test empty form submission
cremote click-element --selector="#submit-btn"
# Check for validation errors
ERROR_MSG=$(cremote get-element --selector=".error-message" --selection-timeout=5)
if [ -n "$ERROR_MSG" ]; then
echo "✓ Validation working: $ERROR_MSG"
else
echo "✗ No validation error shown"
fi
# Test invalid email format
cremote fill-form --selector="#email" --value="invalid-email"
cremote click-element --selector="#submit-btn"
# Verify email validation
EMAIL_ERROR=$(cremote get-element --selector="#email-error" --selection-timeout=5)
if echo "$EMAIL_ERROR" | grep -q "valid email"; then
echo "✓ Email validation working"
fi
# Test JavaScript validation directly
JS_VALIDATION=$(cremote eval-js --code="document.getElementById('email').validity.valid")
if [ "$JS_VALIDATION" = "false" ]; then
echo "✓ JavaScript validation also working"
fi
```
### 3. Multi-Step Workflow Testing
```bash
#!/bin/bash
# Test complete user workflow
# Step 1: Registration
cremote open-tab
cremote load-url --url="https://myapp.com/register"
cremote fill-form --selector="#username" --value="newuser123"
cremote fill-form --selector="#email" --value="newuser@test.com"
cremote fill-form --selector="#password" --value="securepass123"
cremote fill-form --selector="#confirm-password" --value="securepass123"
cremote click-element --selector="#register-btn"
cremote wait-navigation --timeout=15
# Step 2: Email verification simulation
cremote load-url --url="https://myapp.com/verify?token=test-token"
cremote wait-navigation --timeout=10
# Step 3: Profile setup
cremote fill-form --selector="#first-name" --value="Test"
cremote fill-form --selector="#last-name" --value="User"
cremote upload-file --selector="#profile-photo" --file="/tmp/avatar.jpg"
cremote click-element --selector="#save-profile"
# Step 4: Verify completion
cremote wait-navigation --timeout=10
PROFILE_PAGE=$(cremote get-source)
if echo "$PROFILE_PAGE" | grep -q "Profile completed"; then
echo "✓ Complete workflow successful"
fi
```
### 4. Error Handling and Edge Cases
```bash
#!/bin/bash
# Test error scenarios
# Test network timeout handling
cremote open-tab
cremote load-url --url="https://httpbin.org/delay/30"
# This should timeout - test how app handles it
# Test invalid form data
cremote load-url --url="https://myapp.com/contact"
cremote fill-form --selector="#phone" --value="invalid-phone-123abc"
cremote submit-form --selector="#contact-form"
# Check error handling
ERROR_RESPONSE=$(cremote get-element --selector=".validation-error")
echo "Error handling: $ERROR_RESPONSE"
# Test file upload limits
cremote upload-file --selector="#file-upload" --file="/path/to/large-file.zip"
UPLOAD_ERROR=$(cremote get-element --selector=".upload-error" --selection-timeout=10)
# Test iframe interaction (e.g., payment form)
cremote switch-iframe --selector="iframe.payment-widget"
cremote fill-form --selector="#card-number" --value="4111111111111111"
cremote fill-form --selector="#expiry" --value="12/25"
cremote click-element --selector="#pay-now"
# Check for payment processing within iframe
PAYMENT_STATUS=$(cremote get-element --selector=".payment-status" --selection-timeout=10)
echo "Payment status: $PAYMENT_STATUS"
# Switch back to main page to check results
cremote switch-main
MAIN_STATUS=$(cremote get-element --selector=".order-confirmation" --selection-timeout=10)
```
## Testing Best Practices
### 1. Robust Element Selection
Use specific, stable selectors:
```bash
# Good - specific and stable
cremote click-element --selector="#submit-button"
cremote click-element --selector="button[data-testid='login-submit']"
cremote fill-form --selector="input[name='username']" --value="test"
# Avoid - fragile selectors
cremote click-element --selector="div > div:nth-child(3) > button"
cremote click-element --selector=".btn.btn-primary.mt-2"
```
### 2. Wait for Dynamic Content
Always use appropriate timeouts for dynamic content:
```bash
# Wait for AJAX content to load
cremote get-element --selector=".search-results" --selection-timeout=15
# Wait for form submission to complete
cremote submit-form --selector="#payment-form" --action-timeout=30
cremote wait-navigation --timeout=20
```
### 3. Verify Test Results
Always verify that actions had the expected effect:
```bash
# After login, verify we're on the dashboard
cremote click-element --selector="#login-btn"
cremote wait-navigation --timeout=10
CURRENT_URL=$(cremote get-source | grep -o 'https://[^"]*dashboard[^"]*')
if [ -n "$CURRENT_URL" ]; then
echo "✓ Successfully redirected to dashboard"
fi
# After form submission, check for success message
cremote submit-form --selector="#contact-form"
SUCCESS_MSG=$(cremote get-element --selector=".success-message" --selection-timeout=10)
if echo "$SUCCESS_MSG" | grep -q "Thank you"; then
echo "✓ Form submitted successfully"
fi
```
### 4. Clean Test Environment
```bash
# Start each test with a fresh tab
cremote open-tab
# Clear any existing state if needed
cremote load-url --url="https://myapp.com/logout"
cremote wait-navigation --timeout=5
# Begin actual test
cremote load-url --url="https://myapp.com/test-page"
```
### 5. Iframe Context Management
Always manage iframe context properly:
```bash
# Good - explicit context management
cremote switch-iframe --selector="iframe.payment-form"
cremote fill-form --selector="#card-number" --value="4111111111111111"
cremote switch-main # Always switch back
# Good - verify iframe exists before switching
IFRAME_EXISTS=$(cremote get-element --selector="iframe.payment-form" --selection-timeout=5)
if [ -n "$IFRAME_EXISTS" ]; then
cremote switch-iframe --selector="iframe.payment-form"
# ... iframe operations ...
cremote switch-main
fi
# Avoid - forgetting to switch back to main context
cremote switch-iframe --selector="iframe.widget"
cremote fill-form --selector="#field" --value="test"
# Missing: cremote switch-main
```
## Debugging Failed Tests
### 1. Inspect Current State
```bash
# Check what's currently on the page
cremote get-source > debug-page-source.html
# Check specific elements
cremote get-element --selector=".error-message"
cremote get-element --selector="form"
# List all tabs to verify state
cremote list-tabs
```
### 2. Verify Element Selectors
```bash
# Test if element exists before interacting
ELEMENT=$(cremote get-element --selector="#target-button" --selection-timeout=5)
if [ -n "$ELEMENT" ]; then
cremote click-element --selector="#target-button"
else
echo "Element not found - check selector"
fi
```
### 3. Increase Timeouts for Slow Pages
```bash
# For slow-loading applications
cremote fill-form --selector="#username" --value="test" \
--selection-timeout=30 --action-timeout=15
cremote wait-navigation --timeout=60
```
## Troubleshooting Chromium Connection Issues
### Chromium Not Running
If you see: "Chromium is not running with remote debugging enabled on port 9222"
**Solution**: Start Chromium with the correct flags:
```bash
chromium --remote-debugging-port=9222 --user-data-dir=/tmp/chromium-debug &
```
### Port Conflict
If you see: "Something is listening on port 9222 but it's not Chromium DevTools protocol"
**Cause**: Another application is using port 9222
**Solution**:
1. Find what's using the port: `netstat -tlnp | grep 9222`
2. Stop the conflicting process
3. Start Chromium with the correct flags
### Chromium Running But Not Connecting
If Chromium appears to be running but cremotedaemon can't connect:
**Possible causes**:
- Chromium started without `--remote-debugging-port=9222`
- Chromium started with a different port
- Firewall blocking connections
**Solution**: Restart Chromium with the correct command:
```bash
pkill -f chromium # Stop existing Chromium
chromium --remote-debugging-port=9222 --user-data-dir=/tmp/chromium-debug &
```
### Verify Chromium DevTools is Working
You can manually check if Chromium DevTools is responding:
```bash
curl http://localhost:9222/json/version
```
This should return JSON with Chromium version information.
## Integration with Test Suites
cremote can be integrated into larger test suites:
```bash
#!/bin/bash
# test-suite.sh
# Setup
echo "Starting Chromium with remote debugging..."
chromium --remote-debugging-port=9222 --user-data-dir=$(mktemp -d) &
CHROMIUM_PID=$!
sleep 3
echo "Starting cremote daemon..."
cremotedaemon &
DAEMON_PID=$!
sleep 2
# Run tests
echo "Running login tests..."
./test-login.sh
echo "Running form tests..."
./test-forms.sh
echo "Running workflow tests..."
./test-workflows.sh
# Cleanup
echo "Stopping daemon..."
kill $DAEMON_PID
echo "Stopping Chromium..."
kill $CHROMIUM_PID
```
This guide provides the foundation for using cremote as a comprehensive web application testing tool. Focus on testing real user workflows, handling edge cases, and verifying expected behaviors through the browser interface.

26
Makefile Normal file
View File

@ -0,0 +1,26 @@
.PHONY: all build clean daemon client install
all: build
build: daemon client
daemon:
go build -o cremotedaemon ./daemon/cmd/cremotedaemon
client:
go build -o cremote .
clean:
rm -f cremote cremotedaemon
install: build
@if [ -n "$(GOPATH)" ] && [ -d "$(GOPATH)/bin" ]; then \
echo "Installing to $(GOPATH)/bin/"; \
cp cremote $(GOPATH)/bin/; \
cp cremotedaemon $(GOPATH)/bin/; \
else \
echo "GOPATH not set or $(GOPATH)/bin doesn't exist, installing to ~/.local/bin/"; \
mkdir -p ~/.local/bin; \
cp cremote ~/.local/bin/; \
cp cremotedaemon ~/.local/bin/; \
fi

264
browser/browser.go Normal file
View File

@ -0,0 +1,264 @@
package browser
import (
"errors"
"fmt"
"sync"
"time"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/launcher"
"github.com/go-rod/rod/lib/proto"
)
// Manager handles the connection to a Chrome browser instance
type Manager struct {
browser *rod.Browser
tabs map[string]*rod.Page
mu sync.Mutex
isNewBrowser bool // Tracks if we launched a new browser or connected to existing one
storage *TabStorage // Persistent storage for tab IDs
}
// NewManager creates a new browser manager
// If launchNew is true, it will launch a new browser instance
// Otherwise, it will try to connect to an existing browser instance
func NewManager(launchNew bool) (*Manager, error) {
var browser *rod.Browser
var isNewBrowser bool
// Initialize tab storage
storage, err := NewTabStorage()
if err != nil {
return nil, fmt.Errorf("failed to initialize tab storage: %w", err)
}
if launchNew {
// Launch a new browser instance
u := launcher.New().MustLaunch()
browser = rod.New().ControlURL(u).MustConnect()
isNewBrowser = true
} else {
// Connect to an existing browser instance
// This assumes Chrome is running with --remote-debugging-port=9222
u := launcher.MustResolveURL("")
browser = rod.New().ControlURL(u)
err := browser.Connect()
if err != nil {
return nil, fmt.Errorf("failed to connect to browser: %w\nMake sure Chrome is running with --remote-debugging-port=9222", err)
}
isNewBrowser = false
}
return &Manager{
browser: browser,
tabs: make(map[string]*rod.Page),
isNewBrowser: isNewBrowser,
storage: storage,
}, nil
}
// OpenTab opens a new tab and returns its ID
func (m *Manager) OpenTab() (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
page, err := m.browser.Page(proto.TargetCreateTarget{URL: "about:blank"})
if err != nil {
return "", fmt.Errorf("failed to create new tab: %w", err)
}
// Use the page ID as the tab ID
tabID := string(page.TargetID)
m.tabs[tabID] = page
// Save the tab ID to persistent storage
err = m.storage.SaveTab(tabID)
if err != nil {
return "", fmt.Errorf("failed to save tab ID: %w", err)
}
return tabID, nil
}
// GetTab returns a tab by its ID
func (m *Manager) GetTab(tabID string) (*rod.Page, error) {
m.mu.Lock()
defer m.mu.Unlock()
// First check in-memory cache
page, exists := m.tabs[tabID]
if exists {
return page, nil
}
// If not in memory, check persistent storage
storedID, exists := m.storage.GetTab(tabID)
if !exists {
return nil, errors.New("tab not found")
}
// Try to get the page from the browser
pages, err := m.browser.Pages()
if err != nil {
return nil, fmt.Errorf("failed to get browser pages: %w", err)
}
// Find the page with the matching ID
for _, p := range pages {
if string(p.TargetID) == storedID {
// Cache it for future use
m.tabs[tabID] = p
return p, nil
}
}
// If we get here, the tab no longer exists
m.storage.RemoveTab(tabID)
return nil, errors.New("tab not found or was closed")
}
// CloseTab closes a tab by its ID
func (m *Manager) CloseTab(tabID string) error {
m.mu.Lock()
defer m.mu.Unlock()
// First check in-memory cache
page, exists := m.tabs[tabID]
if !exists {
// If not in memory, check persistent storage
storedID, exists := m.storage.GetTab(tabID)
if !exists {
return errors.New("tab not found")
}
// Try to get the page from the browser
pages, err := m.browser.Pages()
if err != nil {
return fmt.Errorf("failed to get browser pages: %w", err)
}
// Find the page with the matching ID
for _, p := range pages {
if string(p.TargetID) == storedID {
page = p
exists = true
break
}
}
if !exists {
// If we get here, the tab no longer exists, so just remove it from storage
m.storage.RemoveTab(tabID)
return errors.New("tab not found or was already closed")
}
}
err := page.Close()
if err != nil {
return fmt.Errorf("failed to close tab: %w", err)
}
// Remove from in-memory cache and persistent storage
delete(m.tabs, tabID)
m.storage.RemoveTab(tabID)
return nil
}
// Close closes the browser connection and all tabs
func (m *Manager) Close() error {
m.mu.Lock()
defer m.mu.Unlock()
// Clear the tabs map
m.tabs = make(map[string]*rod.Page)
// Only close the browser if we launched it
if m.isNewBrowser {
return m.browser.Close()
}
// For existing browsers, just disconnect without closing
return nil
}
// LoadURL loads a URL in a tab
func (m *Manager) LoadURL(tabID, url string) error {
page, err := m.GetTab(tabID)
if err != nil {
return err
}
err = page.Navigate(url)
if err != nil {
return fmt.Errorf("failed to navigate to URL: %w", err)
}
// Wait for the page to be loaded
err = page.WaitLoad()
if err != nil {
return fmt.Errorf("failed to wait for page load: %w", err)
}
return nil
}
// WaitNavigation waits for a navigation event to happen
func (m *Manager) WaitNavigation(tabID string, timeout int) error {
page, err := m.GetTab(tabID)
if err != nil {
return err
}
// Set a timeout for the navigation wait
page = page.Timeout(time.Duration(timeout) * time.Second)
// Wait for navigation
page.WaitNavigation(proto.PageLifecycleEventNameLoad)()
// Wait for the page to be fully loaded
err = page.WaitLoad()
if err != nil {
return fmt.Errorf("navigation wait failed: %w", err)
}
return nil
}
// GetPageSource returns the entire source code of a page
func (m *Manager) GetPageSource(tabID string) (string, error) {
page, err := m.GetTab(tabID)
if err != nil {
return "", err
}
html, err := page.HTML()
if err != nil {
return "", fmt.Errorf("failed to get page HTML: %w", err)
}
return html, nil
}
// GetElementHTML returns the HTML of an element at the specified selector
func (m *Manager) GetElementHTML(tabID, selector string) (string, error) {
page, err := m.GetTab(tabID)
if err != nil {
return "", err
}
// Find the element
element, err := page.Element(selector)
if err != nil {
return "", fmt.Errorf("failed to find element: %w", err)
}
// Get the HTML of the element
html, err := element.HTML()
if err != nil {
return "", fmt.Errorf("failed to get element HTML: %w", err)
}
return html, nil
}

97
browser/form.go Normal file
View File

@ -0,0 +1,97 @@
package browser
import (
"fmt"
"os"
"path/filepath"
)
// FillFormField fills a form field with the specified value
func (m *Manager) FillFormField(tabID, selector, value string) error {
page, err := m.GetTab(tabID)
if err != nil {
return err
}
// Find the element
element, err := page.Element(selector)
if err != nil {
return fmt.Errorf("failed to find element: %w", err)
}
// Clear the field first
_ = element.SelectAllText()
err = element.Input("")
if err != nil {
return fmt.Errorf("failed to clear field: %w", err)
}
// Input the value
err = element.Input(value)
if err != nil {
return fmt.Errorf("failed to input value: %w", err)
}
return nil
}
// UploadFile uploads a file to a file input element
func (m *Manager) UploadFile(tabID, selector, filePath string) error {
page, err := m.GetTab(tabID)
if err != nil {
return err
}
// Check if the file exists
absPath, err := filepath.Abs(filePath)
if err != nil {
return fmt.Errorf("failed to get absolute path: %w", err)
}
_, err = os.Stat(absPath)
if err != nil {
return fmt.Errorf("file not found: %w", err)
}
// Find the file input element
element, err := page.Element(selector)
if err != nil {
return fmt.Errorf("failed to find file input element: %w", err)
}
// Set the file
err = element.SetFiles([]string{absPath})
if err != nil {
return fmt.Errorf("failed to set file: %w", err)
}
return nil
}
// SubmitForm submits a form
func (m *Manager) SubmitForm(tabID, selector string) error {
page, err := m.GetTab(tabID)
if err != nil {
return err
}
// Find the form element
form, err := page.Element(selector)
if err != nil {
return fmt.Errorf("failed to find form element: %w", err)
}
// Submit the form
_, err = form.Eval(`() => this.submit()`)
if err != nil {
return fmt.Errorf("failed to submit form: %w", err)
}
// Wait for the page to load after form submission
err = page.WaitLoad()
if err != nil {
return fmt.Errorf("failed to wait for page load after form submission: %w", err)
}
return nil
}

107
browser/storage.go Normal file
View File

@ -0,0 +1,107 @@
package browser
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
)
// TabStorage manages persistent storage of tab IDs
type TabStorage struct {
Tabs map[string]string // Maps tab IDs to their internal IDs
mu sync.Mutex
}
// NewTabStorage creates a new tab storage
func NewTabStorage() (*TabStorage, error) {
storage := &TabStorage{
Tabs: make(map[string]string),
}
// Load existing tabs from storage
err := storage.load()
if err != nil {
// If the file doesn't exist, that's fine - we'll create it
if !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to load tab storage: %w", err)
}
}
return storage, nil
}
// SaveTab saves a tab ID to storage
func (s *TabStorage) SaveTab(tabID string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.Tabs[tabID] = tabID
return s.save()
}
// GetTab gets a tab ID from storage
func (s *TabStorage) GetTab(tabID string) (string, bool) {
s.mu.Lock()
defer s.mu.Unlock()
internalID, exists := s.Tabs[tabID]
return internalID, exists
}
// RemoveTab removes a tab ID from storage
func (s *TabStorage) RemoveTab(tabID string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.Tabs, tabID)
return s.save()
}
// getStoragePath returns the path to the storage file
func getStoragePath() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get user home directory: %w", err)
}
// Create .cremote directory if it doesn't exist
storageDir := filepath.Join(homeDir, ".cremote")
err = os.MkdirAll(storageDir, 0755)
if err != nil {
return "", fmt.Errorf("failed to create storage directory: %w", err)
}
return filepath.Join(storageDir, "tabs.json"), nil
}
// load loads the tab storage from disk
func (s *TabStorage) load() error {
path, err := getStoragePath()
if err != nil {
return err
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
return json.Unmarshal(data, &s.Tabs)
}
// save saves the tab storage to disk
func (s *TabStorage) save() error {
path, err := getStoragePath()
if err != nil {
return err
}
data, err := json.MarshalIndent(s.Tabs, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal tab storage: %w", err)
}
return os.WriteFile(path, data, 0644)
}

604
client/client.go Normal file
View File

@ -0,0 +1,604 @@
package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
)
// Client is the client for communicating with the daemon
type Client struct {
serverURL string
}
// Command represents a command sent from the client to the daemon
type Command struct {
Action string `json:"action"`
Params map[string]string `json:"params"`
}
// Response represents a response from the daemon to the client
type Response struct {
Success bool `json:"success"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
// NewClient creates a new client
func NewClient(host string, port int) *Client {
return &Client{
serverURL: fmt.Sprintf("http://%s:%d", host, port),
}
}
// CheckStatus checks if the daemon is running
func (c *Client) CheckStatus() (bool, error) {
resp, err := http.Get(c.serverURL + "/status")
if err != nil {
return false, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
var response Response
err = json.NewDecoder(resp.Body).Decode(&response)
if err != nil {
return false, err
}
return response.Success, nil
}
// TabInfo contains information about a tab
type TabInfo struct {
ID string `json:"id"`
URL string `json:"url"`
IsCurrent bool `json:"is_current"`
HistoryIndex int `json:"history_index"` // Position in tab history (higher = more recent)
}
// ListTabs returns a list of all open tabs
func (c *Client) ListTabs() ([]TabInfo, error) {
resp, err := http.Get(c.serverURL + "/status")
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
var response Response
err = json.NewDecoder(resp.Body).Decode(&response)
if err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
if !response.Success {
return nil, fmt.Errorf("daemon returned error: %s", response.Error)
}
// Extract the data
data, ok := response.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("unexpected response format")
}
// Get the tabs
tabsData, ok := data["tabs"].(map[string]interface{})
if !ok {
return nil, fmt.Errorf("unexpected tabs format")
}
// Get the current tab
currentTab, _ := data["current_tab"].(string)
// Get the tab history
tabHistoryData, ok := data["tab_history"].([]interface{})
if !ok {
tabHistoryData = []interface{}{}
}
// Create a map of tab history indices
tabHistoryIndices := make(map[string]int)
for i, idInterface := range tabHistoryData {
id, ok := idInterface.(string)
if ok {
tabHistoryIndices[id] = i
}
}
// Convert to TabInfo
tabs := make([]TabInfo, 0, len(tabsData))
for id, urlInterface := range tabsData {
url, ok := urlInterface.(string)
if !ok {
url = "<unknown>"
}
// Get the history index (default to -1 if not in history)
historyIndex, inHistory := tabHistoryIndices[id]
if !inHistory {
historyIndex = -1
}
tabs = append(tabs, TabInfo{
ID: id,
URL: url,
IsCurrent: id == currentTab,
HistoryIndex: historyIndex,
})
}
return tabs, nil
}
// SendCommand sends a command to the daemon
func (c *Client) SendCommand(action string, params map[string]string) (*Response, error) {
cmd := Command{
Action: action,
Params: params,
}
jsonData, err := json.Marshal(cmd)
if err != nil {
return nil, err
}
resp, err := http.Post(c.serverURL+"/command", "application/json", bytes.NewBuffer(jsonData))
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, body)
}
var response Response
err = json.NewDecoder(resp.Body).Decode(&response)
if err != nil {
return nil, err
}
return &response, nil
}
// OpenTab opens a new tab
// timeout is in seconds, 0 means no timeout
func (c *Client) OpenTab(timeout int) (string, error) {
params := map[string]string{}
// Add timeout if specified
if timeout > 0 {
params["timeout"] = strconv.Itoa(timeout)
}
resp, err := c.SendCommand("open-tab", params)
if err != nil {
return "", err
}
if !resp.Success {
return "", fmt.Errorf("failed to open tab: %s", resp.Error)
}
tabID, ok := resp.Data.(string)
if !ok {
return "", fmt.Errorf("unexpected response data type")
}
return tabID, nil
}
// LoadURL loads a URL in a tab
// If tabID is empty, the current tab will be used
// timeout is in seconds, 0 means no timeout
func (c *Client) LoadURL(tabID, url string, timeout int) error {
params := map[string]string{
"url": url,
}
// Only include tab ID if it's provided
if tabID != "" {
params["tab"] = tabID
}
// Add timeout if specified
if timeout > 0 {
params["timeout"] = strconv.Itoa(timeout)
}
resp, err := c.SendCommand("load-url", params)
if err != nil {
return err
}
if !resp.Success {
return fmt.Errorf("failed to load URL: %s", resp.Error)
}
return nil
}
// FillFormField fills a form field with a value
// If tabID is empty, the current tab will be used
// selectionTimeout and actionTimeout are in seconds, 0 means no timeout
func (c *Client) FillFormField(tabID, selector, value string, selectionTimeout, actionTimeout int) error {
params := map[string]string{
"selector": selector,
"value": value,
}
// Only include tab ID if it's provided
if tabID != "" {
params["tab"] = tabID
}
// Add timeouts if specified
if selectionTimeout > 0 {
params["selection-timeout"] = strconv.Itoa(selectionTimeout)
}
if actionTimeout > 0 {
params["action-timeout"] = strconv.Itoa(actionTimeout)
}
resp, err := c.SendCommand("fill-form", params)
if err != nil {
return err
}
if !resp.Success {
return fmt.Errorf("failed to fill form field: %s", resp.Error)
}
return nil
}
// UploadFile uploads a file to a file input
// If tabID is empty, the current tab will be used
// selectionTimeout and actionTimeout are in seconds, 0 means no timeout
func (c *Client) UploadFile(tabID, selector, filePath string, selectionTimeout, actionTimeout int) error {
params := map[string]string{
"selector": selector,
"file": filePath,
}
// Only include tab ID if it's provided
if tabID != "" {
params["tab"] = tabID
}
// Add timeouts if specified
if selectionTimeout > 0 {
params["selection-timeout"] = strconv.Itoa(selectionTimeout)
}
if actionTimeout > 0 {
params["action-timeout"] = strconv.Itoa(actionTimeout)
}
resp, err := c.SendCommand("upload-file", params)
if err != nil {
return err
}
if !resp.Success {
return fmt.Errorf("failed to upload file: %s", resp.Error)
}
return nil
}
// SubmitForm submits a form
// If tabID is empty, the current tab will be used
// selectionTimeout and actionTimeout are in seconds, 0 means no timeout
func (c *Client) SubmitForm(tabID, selector string, selectionTimeout, actionTimeout int) error {
params := map[string]string{
"selector": selector,
}
// Only include tab ID if it's provided
if tabID != "" {
params["tab"] = tabID
}
// Add timeouts if specified
if selectionTimeout > 0 {
params["selection-timeout"] = strconv.Itoa(selectionTimeout)
}
if actionTimeout > 0 {
params["action-timeout"] = strconv.Itoa(actionTimeout)
}
resp, err := c.SendCommand("submit-form", params)
if err != nil {
return err
}
if !resp.Success {
return fmt.Errorf("failed to submit form: %s", resp.Error)
}
return nil
}
// GetPageSource gets the source code of a page
// If tabID is empty, the current tab will be used
// timeout is in seconds, 0 means no timeout
func (c *Client) GetPageSource(tabID string, timeout int) (string, error) {
params := map[string]string{}
// Only include tab ID if it's provided
if tabID != "" {
params["tab"] = tabID
}
// Add timeout if specified
if timeout > 0 {
params["timeout"] = strconv.Itoa(timeout)
}
resp, err := c.SendCommand("get-source", params)
if err != nil {
return "", err
}
if !resp.Success {
return "", fmt.Errorf("failed to get page source: %s", resp.Error)
}
source, ok := resp.Data.(string)
if !ok {
return "", fmt.Errorf("unexpected response data type")
}
return source, nil
}
// GetElementHTML gets the HTML of an element
// If tabID is empty, the current tab will be used
// selectionTimeout is in seconds, 0 means no timeout
func (c *Client) GetElementHTML(tabID, selector string, selectionTimeout int) (string, error) {
params := map[string]string{
"selector": selector,
}
// Only include tab ID if it's provided
if tabID != "" {
params["tab"] = tabID
}
// Add timeout if specified
if selectionTimeout > 0 {
params["selection-timeout"] = strconv.Itoa(selectionTimeout)
}
resp, err := c.SendCommand("get-element", params)
if err != nil {
return "", err
}
if !resp.Success {
return "", fmt.Errorf("failed to get element HTML: %s", resp.Error)
}
html, ok := resp.Data.(string)
if !ok {
return "", fmt.Errorf("unexpected response data type")
}
return html, nil
}
// CloseTab closes a tab
// If tabID is empty, the current tab will be used
// timeout is in seconds, 0 means no timeout
func (c *Client) CloseTab(tabID string, timeout int) error {
params := map[string]string{}
// Only include tab ID if it's provided
if tabID != "" {
params["tab"] = tabID
}
// Add timeout if specified
if timeout > 0 {
params["timeout"] = strconv.Itoa(timeout)
}
resp, err := c.SendCommand("close-tab", params)
if err != nil {
return err
}
if !resp.Success {
return fmt.Errorf("failed to close tab: %s", resp.Error)
}
return nil
}
// WaitNavigation waits for a navigation event
// If tabID is empty, the current tab will be used
func (c *Client) WaitNavigation(tabID string, timeout int) error {
params := map[string]string{
"timeout": fmt.Sprintf("%d", timeout),
}
// Only include tab ID if it's provided
if tabID != "" {
params["tab"] = tabID
}
resp, err := c.SendCommand("wait-navigation", params)
if err != nil {
return err
}
if !resp.Success {
return fmt.Errorf("failed to wait for navigation: %s", resp.Error)
}
return nil
}
// EvalJS executes JavaScript code in a tab and returns the result
// If tabID is empty, the current tab will be used
// timeout is in seconds, 0 means no timeout
func (c *Client) EvalJS(tabID, jsCode string, timeout int) (string, error) {
params := map[string]string{
"code": jsCode,
}
// Only include tab ID if it's provided
if tabID != "" {
params["tab"] = tabID
}
// Add timeout if specified
if timeout > 0 {
params["timeout"] = strconv.Itoa(timeout)
}
resp, err := c.SendCommand("eval-js", params)
if err != nil {
return "", err
}
if !resp.Success {
return "", fmt.Errorf("failed to execute JavaScript: %s", resp.Error)
}
// Convert result to string if it exists
if resp.Data != nil {
if result, ok := resp.Data.(string); ok {
return result, nil
}
// If it's not a string, convert it to string representation
return fmt.Sprintf("%v", resp.Data), nil
}
return "", nil
}
// TakeScreenshot takes a screenshot of a tab and saves it to a file
// If tabID is empty, the current tab will be used
// timeout is in seconds, 0 means no timeout
func (c *Client) TakeScreenshot(tabID, outputPath string, fullPage bool, timeout int) error {
params := map[string]string{
"output": outputPath,
"full-page": strconv.FormatBool(fullPage),
}
// Only include tab ID if it's provided
if tabID != "" {
params["tab"] = tabID
}
// Add timeout if specified
if timeout > 0 {
params["timeout"] = strconv.Itoa(timeout)
}
resp, err := c.SendCommand("screenshot", params)
if err != nil {
return err
}
if !resp.Success {
return fmt.Errorf("failed to take screenshot: %s", resp.Error)
}
return nil
}
// SwitchToIframe switches the context to an iframe for subsequent commands
// If tabID is empty, the current tab will be used
func (c *Client) SwitchToIframe(tabID, selector string) error {
params := map[string]string{
"selector": selector,
}
// Only include tab ID if it's provided
if tabID != "" {
params["tab"] = tabID
}
resp, err := c.SendCommand("switch-iframe", params)
if err != nil {
return err
}
if !resp.Success {
return fmt.Errorf("failed to switch to iframe: %s", resp.Error)
}
return nil
}
// SwitchToMain switches the context back to the main page
// If tabID is empty, the current tab will be used
func (c *Client) SwitchToMain(tabID string) error {
params := map[string]string{}
// Only include tab ID if it's provided
if tabID != "" {
params["tab"] = tabID
}
resp, err := c.SendCommand("switch-main", params)
if err != nil {
return err
}
if !resp.Success {
return fmt.Errorf("failed to switch to main context: %s", resp.Error)
}
return nil
}
// ClickElement clicks on an element
// If tabID is empty, the current tab will be used
// selectionTimeout and actionTimeout are in seconds, 0 means no timeout
func (c *Client) ClickElement(tabID, selector string, selectionTimeout, actionTimeout int) error {
params := map[string]string{
"selector": selector,
}
// Only include tab ID if it's provided
if tabID != "" {
params["tab"] = tabID
}
// Add timeouts if specified
if selectionTimeout > 0 {
params["selection-timeout"] = strconv.Itoa(selectionTimeout)
}
if actionTimeout > 0 {
params["action-timeout"] = strconv.Itoa(actionTimeout)
}
resp, err := c.SendCommand("click-element", params)
if err != nil {
return err
}
if !resp.Success {
return fmt.Errorf("failed to click element: %s", resp.Error)
}
return nil
}

View File

@ -0,0 +1,43 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"git.teamworkapps.com/shortcut/cremote/daemon"
)
var (
daemonHost = flag.String("listen", "localhost", "Listen address")
port = flag.Int("port", 8989, "Listen port")
)
func main() {
flag.Parse()
// Create and start the daemon
d, err := daemon.NewDaemon(*daemonHost, *port)
if err != nil {
log.Fatalf("Failed to create daemon: %v", err)
}
// Handle graceful shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigChan
fmt.Println("Shutting down daemon...")
d.Stop()
}()
// Start the daemon (this blocks until the server is stopped)
log.Printf("Starting daemon on port %d", *port)
if err := d.Start(); err != nil {
log.Fatalf("Failed to start daemon: %v", err)
}
}

1639
daemon/daemon.go Normal file

File diff suppressed because it is too large Load Diff

13
go.mod Normal file
View File

@ -0,0 +1,13 @@
module git.teamworkapps.com/shortcut/cremote
go 1.24.1
require github.com/go-rod/rod v0.116.2
require (
github.com/ysmood/fetchup v0.2.3 // indirect
github.com/ysmood/goob v0.4.0 // indirect
github.com/ysmood/got v0.40.0 // indirect
github.com/ysmood/gson v0.7.3 // indirect
github.com/ysmood/leakless v0.9.0 // indirect
)

16
go.sum Normal file
View File

@ -0,0 +1,16 @@
github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA=
github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg=
github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ=
github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns=
github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ=
github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18=
github.com/ysmood/gop v0.2.0 h1:+tFrG0TWPxT6p9ZaZs+VY+opCvHU8/3Fk6BaNv6kqKg=
github.com/ysmood/gop v0.2.0/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk=
github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q=
github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg=
github.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY=
github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM=
github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE=
github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg=
github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU=
github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=

496
main.go Normal file
View File

@ -0,0 +1,496 @@
package main
import (
"flag"
"fmt"
"os"
"sort"
"git.teamworkapps.com/shortcut/cremote/client"
)
var (
// Global flags
daemonHost = flag.String("host", "localhost", "Daemon host")
daemonPort = flag.Int("port", 8989, "Daemon port")
)
func main() {
// Define subcommands
openTabCmd := flag.NewFlagSet("open-tab", flag.ExitOnError)
loadURLCmd := flag.NewFlagSet("load-url", flag.ExitOnError)
fillFormCmd := flag.NewFlagSet("fill-form", flag.ExitOnError)
uploadFileCmd := flag.NewFlagSet("upload-file", flag.ExitOnError)
submitFormCmd := flag.NewFlagSet("submit-form", flag.ExitOnError)
getSourceCmd := flag.NewFlagSet("get-source", flag.ExitOnError)
getElementCmd := flag.NewFlagSet("get-element", flag.ExitOnError)
clickElementCmd := flag.NewFlagSet("click-element", flag.ExitOnError)
closeTabCmd := flag.NewFlagSet("close-tab", flag.ExitOnError)
waitNavCmd := flag.NewFlagSet("wait-navigation", flag.ExitOnError)
evalJsCmd := flag.NewFlagSet("eval-js", flag.ExitOnError)
switchIframeCmd := flag.NewFlagSet("switch-iframe", flag.ExitOnError)
switchMainCmd := flag.NewFlagSet("switch-main", flag.ExitOnError)
screenshotCmd := flag.NewFlagSet("screenshot", flag.ExitOnError)
statusCmd := flag.NewFlagSet("status", flag.ExitOnError)
listTabsCmd := flag.NewFlagSet("list-tabs", flag.ExitOnError)
// Define flags for each subcommand
// open-tab flags
openTabTimeout := openTabCmd.Int("timeout", 5, "Timeout in seconds for opening the tab")
openTabHost := openTabCmd.String("host", "localhost", "Daemon host")
openTabPort := openTabCmd.Int("port", 8989, "Daemon port")
// load-url flags
loadURLTabID := loadURLCmd.String("tab", "", "Tab ID to load URL in (optional, uses current tab if not specified)")
loadURLTarget := loadURLCmd.String("url", "", "URL to load")
loadURLTimeout := loadURLCmd.Int("timeout", 5, "Timeout in seconds for loading the URL")
loadURLHost := loadURLCmd.String("host", "localhost", "Daemon host")
loadURLPort := loadURLCmd.Int("port", 8989, "Daemon port")
// fill-form flags
fillFormTabID := fillFormCmd.String("tab", "", "Tab ID to fill form in (optional, uses current tab if not specified)")
fillFormSelector := fillFormCmd.String("selector", "", "CSS selector for the input field")
fillFormValue := fillFormCmd.String("value", "", "Value to fill in the form field")
fillFormSelectionTimeout := fillFormCmd.Int("selection-timeout", 5, "Timeout in seconds for finding the element")
fillFormActionTimeout := fillFormCmd.Int("action-timeout", 5, "Timeout in seconds for the fill action")
fillFormHost := fillFormCmd.String("host", "localhost", "Daemon host")
fillFormPort := fillFormCmd.Int("port", 8989, "Daemon port")
// upload-file flags
uploadFileTabID := uploadFileCmd.String("tab", "", "Tab ID to upload file in (optional, uses current tab if not specified)")
uploadFileSelector := uploadFileCmd.String("selector", "", "CSS selector for the file input")
uploadFilePath := uploadFileCmd.String("file", "", "Path to the file to upload")
uploadFileSelectionTimeout := uploadFileCmd.Int("selection-timeout", 5, "Timeout in seconds for finding the element")
uploadFileActionTimeout := uploadFileCmd.Int("action-timeout", 5, "Timeout in seconds for the upload action")
uploadFileHost := uploadFileCmd.String("host", "localhost", "Daemon host")
uploadFilePort := uploadFileCmd.Int("port", 8989, "Daemon port")
// submit-form flags
submitFormTabID := submitFormCmd.String("tab", "", "Tab ID to submit form in (optional, uses current tab if not specified)")
submitFormSelector := submitFormCmd.String("selector", "", "CSS selector for the form")
submitFormSelectionTimeout := submitFormCmd.Int("selection-timeout", 5, "Timeout in seconds for finding the element")
submitFormActionTimeout := submitFormCmd.Int("action-timeout", 5, "Timeout in seconds for the submit action")
submitFormHost := submitFormCmd.String("host", "localhost", "Daemon host")
submitFormPort := submitFormCmd.Int("port", 8989, "Daemon port")
// get-source flags
getSourceTabID := getSourceCmd.String("tab", "", "Tab ID to get source from (optional, uses current tab if not specified)")
getSourceTimeout := getSourceCmd.Int("timeout", 5, "Timeout in seconds for getting the page source")
getSourceHost := getSourceCmd.String("host", "localhost", "Daemon host")
getSourcePort := getSourceCmd.Int("port", 8989, "Daemon port")
// get-element flags
getElementTabID := getElementCmd.String("tab", "", "Tab ID to get element from (optional, uses current tab if not specified)")
getElementSelector := getElementCmd.String("selector", "", "CSS selector for the element")
getElementSelectionTimeout := getElementCmd.Int("selection-timeout", 5, "Timeout in seconds for finding the element")
getElementHost := getElementCmd.String("host", "localhost", "Daemon host")
getElementPort := getElementCmd.Int("port", 8989, "Daemon port")
// click-element flags
clickElementTabID := clickElementCmd.String("tab", "", "Tab ID to click element in (optional, uses current tab if not specified)")
clickElementSelector := clickElementCmd.String("selector", "", "CSS selector for the element to click")
clickElementSelectionTimeout := clickElementCmd.Int("selection-timeout", 5, "Timeout in seconds for finding the element")
clickElementActionTimeout := clickElementCmd.Int("action-timeout", 5, "Timeout in seconds for the click action")
clickElementHost := clickElementCmd.String("host", "localhost", "Daemon host")
clickElementPort := clickElementCmd.Int("port", 8989, "Daemon port")
// close-tab flags
closeTabID := closeTabCmd.String("tab", "", "Tab ID to close (optional, uses current tab if not specified)")
closeTabTimeout := closeTabCmd.Int("timeout", 5, "Timeout in seconds for closing the tab")
closeTabHost := closeTabCmd.String("host", "localhost", "Daemon host")
closeTabPort := closeTabCmd.Int("port", 8989, "Daemon port")
// wait-navigation flags
waitNavTabID := waitNavCmd.String("tab", "", "Tab ID to wait for navigation (optional, uses current tab if not specified)")
waitNavTimeout := waitNavCmd.Int("timeout", 5, "Timeout in seconds")
waitNavHost := waitNavCmd.String("host", "localhost", "Daemon host")
waitNavPort := waitNavCmd.Int("port", 8989, "Daemon port")
// eval-js flags
evalJsTabID := evalJsCmd.String("tab", "", "Tab ID to execute JavaScript in (optional, uses current tab if not specified)")
evalJsCode := evalJsCmd.String("code", "", "JavaScript code to execute")
evalJsTimeout := evalJsCmd.Int("timeout", 5, "Timeout in seconds for JavaScript execution")
evalJsHost := evalJsCmd.String("host", "localhost", "Daemon host")
evalJsPort := evalJsCmd.Int("port", 8989, "Daemon port")
// switch-iframe flags
switchIframeTabID := switchIframeCmd.String("tab", "", "Tab ID to switch iframe context in (optional, uses current tab if not specified)")
switchIframeSelector := switchIframeCmd.String("selector", "", "CSS selector for the iframe element")
switchIframeHost := switchIframeCmd.String("host", "localhost", "Daemon host")
switchIframePort := switchIframeCmd.Int("port", 8989, "Daemon port")
// switch-main flags
switchMainTabID := switchMainCmd.String("tab", "", "Tab ID to switch back to main context (optional, uses current tab if not specified)")
switchMainHost := switchMainCmd.String("host", "localhost", "Daemon host")
switchMainPort := switchMainCmd.Int("port", 8989, "Daemon port")
// screenshot flags
screenshotTabID := screenshotCmd.String("tab", "", "Tab ID to take screenshot of (optional, uses current tab if not specified)")
screenshotOutput := screenshotCmd.String("output", "", "Output file path for the screenshot (PNG format)")
screenshotFullPage := screenshotCmd.Bool("full-page", false, "Capture full page screenshot (default: viewport only)")
screenshotTimeout := screenshotCmd.Int("timeout", 5, "Timeout in seconds for taking the screenshot")
screenshotHost := screenshotCmd.String("host", "localhost", "Daemon host")
screenshotPort := screenshotCmd.Int("port", 8989, "Daemon port")
// status flags
statusHost := statusCmd.String("host", "localhost", "Daemon host")
statusPort := statusCmd.Int("port", 8989, "Daemon port")
// list-tabs flags
listTabsHost := listTabsCmd.String("host", "localhost", "Daemon host")
listTabsPort := listTabsCmd.Int("port", 8989, "Daemon port")
// Check if a subcommand is provided
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
// Parse the appropriate subcommand
switch os.Args[1] {
case "open-tab":
openTabCmd.Parse(os.Args[2:])
// Create a client
c := client.NewClient(*openTabHost, *openTabPort)
// Open a new tab
tabID, err := c.OpenTab(*openTabTimeout)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Print the tab ID
fmt.Println(tabID)
case "load-url":
loadURLCmd.Parse(os.Args[2:])
if *loadURLTarget == "" {
fmt.Println("Error: url flag is required")
loadURLCmd.PrintDefaults()
os.Exit(1)
}
// Create a client
c := client.NewClient(*loadURLHost, *loadURLPort)
// Load the URL
err := c.LoadURL(*loadURLTabID, *loadURLTarget, *loadURLTimeout)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Println("URL loaded successfully")
case "fill-form":
fillFormCmd.Parse(os.Args[2:])
if *fillFormSelector == "" || *fillFormValue == "" {
fmt.Println("Error: selector and value flags are required")
fillFormCmd.PrintDefaults()
os.Exit(1)
}
// Create a client
c := client.NewClient(*fillFormHost, *fillFormPort)
// Fill the form field
err := c.FillFormField(*fillFormTabID, *fillFormSelector, *fillFormValue, *fillFormSelectionTimeout, *fillFormActionTimeout)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Println("Form field filled successfully")
case "upload-file":
uploadFileCmd.Parse(os.Args[2:])
if *uploadFileSelector == "" || *uploadFilePath == "" {
fmt.Println("Error: selector and file flags are required")
uploadFileCmd.PrintDefaults()
os.Exit(1)
}
// Create a client
c := client.NewClient(*uploadFileHost, *uploadFilePort)
// Upload the file
err := c.UploadFile(*uploadFileTabID, *uploadFileSelector, *uploadFilePath, *uploadFileSelectionTimeout, *uploadFileActionTimeout)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Println("File uploaded successfully")
case "submit-form":
submitFormCmd.Parse(os.Args[2:])
if *submitFormSelector == "" {
fmt.Println("Error: selector flag is required")
submitFormCmd.PrintDefaults()
os.Exit(1)
}
// Create a client
c := client.NewClient(*submitFormHost, *submitFormPort)
// Submit the form
err := c.SubmitForm(*submitFormTabID, *submitFormSelector, *submitFormSelectionTimeout, *submitFormActionTimeout)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Println("Form submitted successfully")
case "get-source":
getSourceCmd.Parse(os.Args[2:])
// Create a client
c := client.NewClient(*getSourceHost, *getSourcePort)
// Get the page source
source, err := c.GetPageSource(*getSourceTabID, *getSourceTimeout)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Print the source
fmt.Println(source)
case "get-element":
getElementCmd.Parse(os.Args[2:])
if *getElementSelector == "" {
fmt.Println("Error: selector flag is required")
getElementCmd.PrintDefaults()
os.Exit(1)
}
// Create a client
c := client.NewClient(*getElementHost, *getElementPort)
// Get the element HTML
html, err := c.GetElementHTML(*getElementTabID, *getElementSelector, *getElementSelectionTimeout)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Print the HTML
fmt.Println(html)
case "click-element":
clickElementCmd.Parse(os.Args[2:])
if *clickElementSelector == "" {
fmt.Println("Error: selector flag is required")
clickElementCmd.PrintDefaults()
os.Exit(1)
}
// Create a client
c := client.NewClient(*clickElementHost, *clickElementPort)
// Click the element
err := c.ClickElement(*clickElementTabID, *clickElementSelector, *clickElementSelectionTimeout, *clickElementActionTimeout)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Println("Element clicked successfully")
case "close-tab":
closeTabCmd.Parse(os.Args[2:])
// Create a client
c := client.NewClient(*closeTabHost, *closeTabPort)
// Close the tab
err := c.CloseTab(*closeTabID, *closeTabTimeout)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Println("Tab closed successfully")
case "wait-navigation":
waitNavCmd.Parse(os.Args[2:])
// Create a client
c := client.NewClient(*waitNavHost, *waitNavPort)
// Wait for navigation
err := c.WaitNavigation(*waitNavTabID, *waitNavTimeout)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Println("Navigation completed")
case "eval-js":
evalJsCmd.Parse(os.Args[2:])
if *evalJsCode == "" {
fmt.Println("Error: code flag is required")
evalJsCmd.PrintDefaults()
os.Exit(1)
}
// Create a client
c := client.NewClient(*evalJsHost, *evalJsPort)
// Execute JavaScript
result, err := c.EvalJS(*evalJsTabID, *evalJsCode, *evalJsTimeout)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Print the result if there is one
if result != "" {
fmt.Println(result)
}
case "switch-iframe":
switchIframeCmd.Parse(os.Args[2:])
if *switchIframeSelector == "" {
fmt.Println("Error: selector flag is required")
switchIframeCmd.PrintDefaults()
os.Exit(1)
}
// Create a client
c := client.NewClient(*switchIframeHost, *switchIframePort)
// Switch to iframe
err := c.SwitchToIframe(*switchIframeTabID, *switchIframeSelector)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Println("Switched to iframe context")
case "switch-main":
switchMainCmd.Parse(os.Args[2:])
// Create a client
c := client.NewClient(*switchMainHost, *switchMainPort)
// Switch back to main page
err := c.SwitchToMain(*switchMainTabID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Println("Switched to main page context")
case "screenshot":
screenshotCmd.Parse(os.Args[2:])
if *screenshotOutput == "" {
fmt.Println("Error: output flag is required")
screenshotCmd.PrintDefaults()
os.Exit(1)
}
// Create a client
c := client.NewClient(*screenshotHost, *screenshotPort)
// Take screenshot
err := c.TakeScreenshot(*screenshotTabID, *screenshotOutput, *screenshotFullPage, *screenshotTimeout)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Screenshot saved to: %s\n", *screenshotOutput)
case "status":
statusCmd.Parse(os.Args[2:])
// Create a client
c := client.NewClient(*statusHost, *statusPort)
// Check daemon status
running, err := c.CheckStatus()
if err != nil {
// If we can't connect, the daemon is not running
fmt.Println("Daemon is not running")
os.Exit(0)
}
if running {
fmt.Println("Daemon is running")
} else {
fmt.Println("Daemon is not running")
os.Exit(0)
}
case "list-tabs":
listTabsCmd.Parse(os.Args[2:])
// Create a client
c := client.NewClient(*listTabsHost, *listTabsPort)
// List tabs
tabs, err := c.ListTabs()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Sort tabs by history index (most recent first)
sort.Slice(tabs, func(i, j int) bool {
return tabs[i].HistoryIndex > tabs[j].HistoryIndex
})
// Print the tabs
if len(tabs) == 0 {
fmt.Println("No tabs open")
} else {
fmt.Println("Open tabs (in order of recent use):")
for _, tab := range tabs {
currentMarker := " "
if tab.IsCurrent {
currentMarker = "*"
}
fmt.Printf("%s %s: %s\n", currentMarker, tab.ID, tab.URL)
}
}
default:
printUsage()
os.Exit(1)
}
}
func printUsage() {
fmt.Println("Usage: cremote <command> [options]")
fmt.Println("Commands:")
fmt.Println(" open-tab Open a new tab and return its ID")
fmt.Println(" load-url Load a URL in a tab")
fmt.Println(" fill-form Fill a form field with a value")
fmt.Println(" upload-file Upload a file to a file input")
fmt.Println(" submit-form Submit a form")
fmt.Println(" get-source Get the source code of a page")
fmt.Println(" get-element Get the HTML of an element")
fmt.Println(" click-element Click on an element")
fmt.Println(" close-tab Close a tab")
fmt.Println(" wait-navigation Wait for a navigation event")
fmt.Println(" eval-js Execute JavaScript code in a tab")
fmt.Println(" switch-iframe Switch to iframe context for subsequent commands")
fmt.Println(" switch-main Switch back to main page context")
fmt.Println(" screenshot Take a screenshot of the current page")
fmt.Println(" list-tabs List all open tabs")
fmt.Println(" status Check if the daemon is running")
fmt.Println("\nRun 'cremote <command> -h' for more information on a command")
fmt.Println("\nBefore using this tool, make sure the daemon is running:")
fmt.Println(" cremotedaemon")
fmt.Println("\nThe daemon requires Chromium/Chrome to be running with remote debugging enabled:")
fmt.Println(" chromium --remote-debugging-port=9222 --user-data-dir=/tmp/chromium-debug")
fmt.Println("\nNote: Most commands can use the current tab if you don't specify a tab ID.")
}

53
test-checkbox.html Normal file
View File

@ -0,0 +1,53 @@
<!DOCTYPE html>
<html>
<head>
<title>Checkbox Test</title>
</head>
<body>
<h1>Checkbox Test</h1>
<form id="testForm">
<div>
<input type="checkbox" id="checkbox1" name="checkbox1">
<label for="checkbox1">Checkbox 1</label>
</div>
<div>
<input type="checkbox" id="checkbox2" name="checkbox2">
<label for="checkbox2">Checkbox 2</label>
</div>
<div>
<input type="radio" id="radio1" name="radioGroup" value="option1">
<label for="radio1">Radio 1</label>
</div>
<div>
<input type="radio" id="radio2" name="radioGroup" value="option2">
<label for="radio2">Radio 2</label>
</div>
<div>
<input type="text" id="textInput" name="textInput" placeholder="Text input">
</div>
<div>
<button type="button" id="showValues" onclick="showValues()">Show Values</button>
</div>
<div id="result"></div>
</form>
<script>
function showValues() {
const checkbox1 = document.getElementById('checkbox1').checked;
const checkbox2 = document.getElementById('checkbox2').checked;
const radio1 = document.getElementById('radio1').checked;
const radio2 = document.getElementById('radio2').checked;
const textInput = document.getElementById('textInput').value;
const result = document.getElementById('result');
result.innerHTML = `
<p>Checkbox 1: ${checkbox1}</p>
<p>Checkbox 2: ${checkbox2}</p>
<p>Radio 1: ${radio1}</p>
<p>Radio 2: ${radio2}</p>
<p>Text Input: ${textInput}</p>
`;
}
</script>
</body>
</html>

58
test-checkbox2.html Normal file
View File

@ -0,0 +1,58 @@
<!DOCTYPE html>
<html>
<head>
<title>Checkbox Test</title>
</head>
<body>
<h1>Checkbox Test</h1>
<form id="testForm">
<div>
<input type="checkbox" id="checkbox1" name="checkbox1" onchange="showValues()">
<label for="checkbox1">Checkbox 1</label>
</div>
<div>
<input type="checkbox" id="checkbox2" name="checkbox2" onchange="showValues()">
<label for="checkbox2">Checkbox 2</label>
</div>
<div>
<input type="radio" id="radio1" name="radioGroup" value="option1" onchange="showValues()">
<label for="radio1">Radio 1</label>
</div>
<div>
<input type="radio" id="radio2" name="radioGroup" value="option2" onchange="showValues()">
<label for="radio2">Radio 2</label>
</div>
<div>
<input type="text" id="textInput" name="textInput" placeholder="Text input" oninput="showValues()">
</div>
<div id="result">
<p>Checkbox 1: false</p>
<p>Checkbox 2: false</p>
<p>Radio 1: false</p>
<p>Radio 2: false</p>
<p>Text Input: </p>
</div>
</form>
<script>
function showValues() {
const checkbox1 = document.getElementById('checkbox1').checked;
const checkbox2 = document.getElementById('checkbox2').checked;
const radio1 = document.getElementById('radio1').checked;
const radio2 = document.getElementById('radio2').checked;
const textInput = document.getElementById('textInput').value;
const result = document.getElementById('result');
result.innerHTML = `
<p>Checkbox 1: ${checkbox1}</p>
<p>Checkbox 2: ${checkbox2}</p>
<p>Radio 1: ${radio1}</p>
<p>Radio 2: ${radio2}</p>
<p>Text Input: ${textInput}</p>
`;
console.log('Values updated:', { checkbox1, checkbox2, radio1, radio2, textInput });
}
</script>
</body>
</html>

36
test-timeout.html Normal file
View File

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html>
<head>
<title>Timeout Test</title>
<style>
.delayed-element {
display: none;
}
</style>
</head>
<body>
<h1>Timeout Test</h1>
<div id="immediate">This element is immediately available</div>
<div id="delayed" class="delayed-element">This element appears after 3 seconds</div>
<button id="slow-button">Slow Button (3s delay)</button>
<div id="result"></div>
<script>
// Show the delayed element after 3 seconds
setTimeout(() => {
document.getElementById('delayed').style.display = 'block';
}, 3000);
// Add a click handler to the slow button that takes 3 seconds to complete
document.getElementById('slow-button').addEventListener('click', function() {
const result = document.getElementById('result');
result.textContent = 'Processing...';
// Simulate a slow operation
setTimeout(() => {
result.textContent = 'Button click processed!';
}, 3000);
});
</script>
</body>
</html>

36
test-timeout2.html Normal file
View File

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html>
<head>
<title>Timeout Test</title>
</head>
<body>
<h1>Timeout Test</h1>
<div id="immediate">This element is immediately available</div>
<div id="result"></div>
<script>
// Create the delayed element after 3 seconds
setTimeout(() => {
const delayed = document.createElement('div');
delayed.id = 'delayed';
delayed.textContent = 'This element appears after 3 seconds';
document.body.appendChild(delayed);
}, 3000);
// Create the slow button
const button = document.createElement('button');
button.id = 'slow-button';
button.textContent = 'Slow Button (3s delay)';
button.addEventListener('click', function() {
const result = document.getElementById('result');
result.textContent = 'Processing...';
// Simulate a slow operation
setTimeout(() => {
result.textContent = 'Button click processed!';
}, 3000);
});
document.body.appendChild(button);
</script>
</body>
</html>