diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea37417 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +steps.sh +/cremote +/cremotedaemon diff --git a/LLM_CODING_AGENT_GUIDE.md b/LLM_CODING_AGENT_GUIDE.md new file mode 100644 index 0000000..cb7f176 --- /dev/null +++ b/LLM_CODING_AGENT_GUIDE.md @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5cb1494 --- /dev/null +++ b/Makefile @@ -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 diff --git a/browser/browser.go b/browser/browser.go new file mode 100644 index 0000000..2c1b9f5 --- /dev/null +++ b/browser/browser.go @@ -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 +} diff --git a/browser/form.go b/browser/form.go new file mode 100644 index 0000000..3c16850 --- /dev/null +++ b/browser/form.go @@ -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 +} diff --git a/browser/storage.go b/browser/storage.go new file mode 100644 index 0000000..72b812c --- /dev/null +++ b/browser/storage.go @@ -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) +} diff --git a/client/client.go b/client/client.go new file mode 100644 index 0000000..0d940a6 --- /dev/null +++ b/client/client.go @@ -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 = "" + } + + // 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 +} diff --git a/daemon/cmd/cremotedaemon/main.go b/daemon/cmd/cremotedaemon/main.go new file mode 100644 index 0000000..06b02f5 --- /dev/null +++ b/daemon/cmd/cremotedaemon/main.go @@ -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) + } +} diff --git a/daemon/daemon.go b/daemon/daemon.go new file mode 100644 index 0000000..57ecade --- /dev/null +++ b/daemon/daemon.go @@ -0,0 +1,1639 @@ +package daemon + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "os" + "strconv" + "sync" + "time" + + "github.com/go-rod/rod" + "github.com/go-rod/rod/lib/launcher" + "github.com/go-rod/rod/lib/proto" +) + +// Daemon is the main server that manages browser connections +type Daemon struct { + browser *rod.Browser + tabs map[string]*rod.Page + iframePages map[string]*rod.Page // Maps tab ID to iframe page context + currentTab string // ID of the current/last used tab + tabHistory []string // Stack of tab IDs in order of activation (LIFO) + mu sync.Mutex + server *http.Server +} + +// 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"` +} + +// checkChromeRunning checks if Chrome is running on the debug port +func checkChromeRunning(port int) bool { + conn, err := net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", port), 2*time.Second) + if err != nil { + return false + } + conn.Close() + return true +} + +// checkChromeDevTools checks if Chrome DevTools protocol is responding +func checkChromeDevTools(port int) bool { + resp, err := http.Get(fmt.Sprintf("http://localhost:%d/json/version", port)) + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == 200 +} + +// NewDaemon creates a new daemon instance +func NewDaemon(host string, port int) (*Daemon, error) { + // Check if Chrome is running on the debug port + chromePort := 9222 // Default Chrome debug port + + if !checkChromeRunning(chromePort) { + return nil, fmt.Errorf("Chromium is not running with remote debugging enabled on port %d.\n\nTo start Chromium with remote debugging:\n chromium --remote-debugging-port=%d --user-data-dir=/tmp/chromium-debug &\n # or\n google-chrome --remote-debugging-port=%d --user-data-dir=/tmp/chrome-debug &\n\nNote: The --user-data-dir flag is required to avoid conflicts with existing browser instances.", chromePort, chromePort, chromePort) + } + + // Check if Chromium DevTools protocol is responding + if !checkChromeDevTools(chromePort) { + return nil, fmt.Errorf("Something is listening on port %d but it's not Chromium DevTools protocol.\n\nThis might be:\n1. Chromium running without --remote-debugging-port=%d\n2. Another application using port %d\n\nTry stopping the process on port %d and starting Chromium with:\n chromium --remote-debugging-port=%d --user-data-dir=/tmp/chromium-debug &", chromePort, chromePort, chromePort, chromePort, chromePort) + } + + // Connect to the existing browser instance + u := launcher.MustResolveURL("") + browser := rod.New().ControlURL(u) + + err := browser.Connect() + if err != nil { + return nil, fmt.Errorf("Chromium DevTools is responding on port %d but rod connection failed: %w\n\nThis is unexpected. Try restarting Chromium with:\n chromium --remote-debugging-port=%d --user-data-dir=/tmp/chromium-debug &", chromePort, err, chromePort) + } + + daemon := &Daemon{ + browser: browser, + tabs: make(map[string]*rod.Page), + iframePages: make(map[string]*rod.Page), + tabHistory: make([]string, 0), + } + + // Create HTTP server + mux := http.NewServeMux() + mux.HandleFunc("/command", daemon.handleCommand) + mux.HandleFunc("/status", daemon.handleStatus) + + daemon.server = &http.Server{ + Addr: fmt.Sprintf("%s:%d", host, port), + Handler: mux, + } + + return daemon, nil +} + +// Start starts the daemon server +func (d *Daemon) Start() error { + log.Printf("Starting daemon server on %s", d.server.Addr) + return d.server.ListenAndServe() +} + +// Stop stops the daemon server +func (d *Daemon) Stop() error { + log.Println("Stopping daemon server") + return d.server.Close() +} + +// handleStatus handles status requests +func (d *Daemon) handleStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + d.mu.Lock() + tabCount := len(d.tabs) + tabs := make(map[string]string) + currentTab := d.currentTab + tabHistory := make([]string, len(d.tabHistory)) + copy(tabHistory, d.tabHistory) + + // Get info about each tab + for id, page := range d.tabs { + try := func() string { + info, err := page.Info() + if err != nil { + return "" + } + return info.URL + } + tabs[id] = try() + } + d.mu.Unlock() + + response := Response{ + Success: true, + Data: map[string]interface{}{ + "status": "running", + "tab_count": tabCount, + "tabs": tabs, + "current_tab": currentTab, + "tab_history": tabHistory, + }, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// handleCommand handles command requests +func (d *Daemon) handleCommand(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var cmd Command + err := json.NewDecoder(r.Body).Decode(&cmd) + if err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + var response Response + + switch cmd.Action { + case "open-tab": + timeoutStr := cmd.Params["timeout"] + + // Parse timeout (default to 5 seconds if not specified) + timeout := 5 + if timeoutStr != "" { + if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { + timeout = parsedTimeout + } + } + + tabID, err := d.openTab(timeout) + if err != nil { + response = Response{Success: false, Error: err.Error()} + } else { + response = Response{Success: true, Data: tabID} + } + + case "load-url": + tabID := cmd.Params["tab"] + url := cmd.Params["url"] + timeoutStr := cmd.Params["timeout"] + + // Parse timeout (default to 5 seconds if not specified) + timeout := 5 + if timeoutStr != "" { + if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { + timeout = parsedTimeout + } + } + + err := d.loadURL(tabID, url, timeout) + if err != nil { + response = Response{Success: false, Error: err.Error()} + } else { + response = Response{Success: true} + } + + case "fill-form": + tabID := cmd.Params["tab"] + selector := cmd.Params["selector"] + value := cmd.Params["value"] + + // Parse timeouts + selectionTimeout := 5 // Default: 5 seconds + if timeoutStr, ok := cmd.Params["selection-timeout"]; ok { + if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { + selectionTimeout = parsedTimeout + } + } + + actionTimeout := 5 // Default: 5 seconds + if timeoutStr, ok := cmd.Params["action-timeout"]; ok { + if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { + actionTimeout = parsedTimeout + } + } + + err := d.fillFormField(tabID, selector, value, selectionTimeout, actionTimeout) + if err != nil { + response = Response{Success: false, Error: err.Error()} + } else { + response = Response{Success: true} + } + + case "upload-file": + tabID := cmd.Params["tab"] + selector := cmd.Params["selector"] + filePath := cmd.Params["file"] + + // Parse timeouts + selectionTimeout := 5 // Default: 5 seconds + if timeoutStr, ok := cmd.Params["selection-timeout"]; ok { + if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { + selectionTimeout = parsedTimeout + } + } + + actionTimeout := 5 // Default: 5 seconds + if timeoutStr, ok := cmd.Params["action-timeout"]; ok { + if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { + actionTimeout = parsedTimeout + } + } + + err := d.uploadFile(tabID, selector, filePath, selectionTimeout, actionTimeout) + if err != nil { + response = Response{Success: false, Error: err.Error()} + } else { + response = Response{Success: true} + } + + case "submit-form": + tabID := cmd.Params["tab"] + selector := cmd.Params["selector"] + + // Parse timeouts + selectionTimeout := 5 // Default: 5 seconds + if timeoutStr, ok := cmd.Params["selection-timeout"]; ok { + if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { + selectionTimeout = parsedTimeout + } + } + + actionTimeout := 5 // Default: 5 seconds + if timeoutStr, ok := cmd.Params["action-timeout"]; ok { + if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { + actionTimeout = parsedTimeout + } + } + + err := d.submitForm(tabID, selector, selectionTimeout, actionTimeout) + if err != nil { + response = Response{Success: false, Error: err.Error()} + } else { + response = Response{Success: true} + } + + case "get-source": + tabID := cmd.Params["tab"] + timeoutStr := cmd.Params["timeout"] + + // Parse timeout (default to 5 seconds if not specified) + timeout := 5 + if timeoutStr != "" { + if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { + timeout = parsedTimeout + } + } + + source, err := d.getPageSource(tabID, timeout) + if err != nil { + response = Response{Success: false, Error: err.Error()} + } else { + response = Response{Success: true, Data: source} + } + + case "get-element": + tabID := cmd.Params["tab"] + selector := cmd.Params["selector"] + + // Parse timeouts + selectionTimeout := 5 // Default: 5 seconds + if timeoutStr, ok := cmd.Params["selection-timeout"]; ok { + if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { + selectionTimeout = parsedTimeout + } + } + + html, err := d.getElementHTML(tabID, selector, selectionTimeout) + if err != nil { + response = Response{Success: false, Error: err.Error()} + } else { + response = Response{Success: true, Data: html} + } + + case "close-tab": + tabID := cmd.Params["tab"] + timeoutStr := cmd.Params["timeout"] + + // Parse timeout (default to 5 seconds if not specified) + timeout := 5 + if timeoutStr != "" { + if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { + timeout = parsedTimeout + } + } + + err := d.closeTab(tabID, timeout) + if err != nil { + response = Response{Success: false, Error: err.Error()} + } else { + response = Response{Success: true} + } + + case "wait-navigation": + tabID := cmd.Params["tab"] + timeout := 5 // Default timeout + if timeoutStr, ok := cmd.Params["timeout"]; ok { + if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { + timeout = parsedTimeout + } + } + err := d.waitNavigation(tabID, timeout) + if err != nil { + response = Response{Success: false, Error: err.Error()} + } else { + response = Response{Success: true} + } + + case "click-element": + tabID := cmd.Params["tab"] + selector := cmd.Params["selector"] + + // Parse timeouts + selectionTimeout := 5 // Default: 5 seconds + if timeoutStr, ok := cmd.Params["selection-timeout"]; ok { + if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { + selectionTimeout = parsedTimeout + } + } + + actionTimeout := 5 // Default: 5 seconds + if timeoutStr, ok := cmd.Params["action-timeout"]; ok { + if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { + actionTimeout = parsedTimeout + } + } + + err := d.clickElement(tabID, selector, selectionTimeout, actionTimeout) + if err != nil { + response = Response{Success: false, Error: err.Error()} + } else { + response = Response{Success: true} + } + + case "eval-js": + tabID := cmd.Params["tab"] + jsCode := cmd.Params["code"] + timeoutStr := cmd.Params["timeout"] + + // Parse timeout (default to 5 seconds if not specified) + timeout := 5 + if timeoutStr != "" { + if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { + timeout = parsedTimeout + } + } + + result, err := d.evalJS(tabID, jsCode, timeout) + if err != nil { + response = Response{Success: false, Error: err.Error()} + } else { + response = Response{Success: true, Data: result} + } + + case "switch-iframe": + tabID := cmd.Params["tab"] + selector := cmd.Params["selector"] + + err := d.switchToIframe(tabID, selector) + if err != nil { + response = Response{Success: false, Error: err.Error()} + } else { + response = Response{Success: true} + } + + case "switch-main": + tabID := cmd.Params["tab"] + + err := d.switchToMain(tabID) + if err != nil { + response = Response{Success: false, Error: err.Error()} + } else { + response = Response{Success: true} + } + + case "screenshot": + tabID := cmd.Params["tab"] + outputPath := cmd.Params["output"] + fullPageStr := cmd.Params["full-page"] + timeoutStr := cmd.Params["timeout"] + + // Parse full-page flag + fullPage := false + if fullPageStr == "true" { + fullPage = true + } + + // Parse timeout (default to 5 seconds if not specified) + timeout := 5 + if timeoutStr != "" { + if parsedTimeout, err := strconv.Atoi(timeoutStr); err == nil && parsedTimeout > 0 { + timeout = parsedTimeout + } + } + + err := d.takeScreenshot(tabID, outputPath, fullPage, timeout) + if err != nil { + response = Response{Success: false, Error: err.Error()} + } else { + response = Response{Success: true} + } + + default: + response = Response{Success: false, Error: "Unknown action"} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// openTab opens a new tab and returns its ID +func (d *Daemon) openTab(timeout int) (string, error) { + d.mu.Lock() + defer d.mu.Unlock() + + // Create a context with timeout if specified + if timeout > 0 { + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) + defer cancel() + + // Create a channel to signal completion + done := make(chan struct { + page *rod.Page + tabID string + err error + }, 1) + + // Execute the tab creation in a goroutine + go func() { + page, err := d.browser.Page(proto.TargetCreateTarget{URL: "about:blank"}) + var tabID string + if err == nil { + tabID = string(page.TargetID) + } + done <- struct { + page *rod.Page + tabID string + err error + }{page, tabID, err} + }() + + // Wait for either completion or timeout + select { + case res := <-done: + if res.err != nil { + return "", fmt.Errorf("failed to create new tab: %w", res.err) + } + + // Store the tab and update history + d.tabs[res.tabID] = res.page + d.tabHistory = append(d.tabHistory, res.tabID) + d.currentTab = res.tabID + + return res.tabID, nil + case <-ctx.Done(): + return "", fmt.Errorf("opening tab timed out after %d seconds", timeout) + } + } else { + // No timeout + page, err := d.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) + d.tabs[tabID] = page + + // Add to tab history stack and set as current tab + d.tabHistory = append(d.tabHistory, tabID) + d.currentTab = tabID + + return tabID, nil + } +} + +// getTabID returns the tab ID to use, falling back to the current tab if none is provided +func (d *Daemon) getTabID(tabID string) (string, error) { + d.mu.Lock() + defer d.mu.Unlock() + + // If no tab ID is provided, use the current tab + if tabID == "" { + if d.currentTab == "" { + return "", fmt.Errorf("no current tab available, please open a tab first") + } + return d.currentTab, nil + } + + // Otherwise, use the provided tab ID + return tabID, nil +} + +// updateTabHistory updates the tab history stack when a tab is activated +func (d *Daemon) updateTabHistory(tabID string) { + // Set as current tab + d.currentTab = tabID + + // Remove the tab from history if it's already there + for i, id := range d.tabHistory { + if id == tabID { + // Remove this tab from history + d.tabHistory = append(d.tabHistory[:i], d.tabHistory[i+1:]...) + break + } + } + + // Add the tab to the end of history (most recent) + d.tabHistory = append(d.tabHistory, tabID) +} + +// findPageByID finds a page by its ID without updating the current tab or cache +func (d *Daemon) findPageByID(tabID string) (*rod.Page, error) { + // If not in memory, try to get the page from the browser + pages, err := d.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) == tabID { + return p, nil + } + } + + return nil, fmt.Errorf("tab not found: %s", tabID) +} + +// getTab returns a tab by its ID, checking for iframe context first +func (d *Daemon) getTab(tabID string) (*rod.Page, error) { + // Get the tab ID to use (may be the current tab) + actualTabID, err := d.getTabID(tabID) + if err != nil { + return nil, err + } + + d.mu.Lock() + defer d.mu.Unlock() + + // First check if we have an iframe context for this tab + if iframePage, exists := d.iframePages[actualTabID]; exists { + // Update tab history and current tab + d.updateTabHistory(actualTabID) + return iframePage, nil + } + + // Check in-memory cache for main page + page, exists := d.tabs[actualTabID] + if exists { + // Update tab history and current tab + d.updateTabHistory(actualTabID) + return page, nil + } + + // If not in memory, try to find it + page, err = d.findPageByID(actualTabID) + if err != nil { + return nil, err + } + + // If found, cache it for future use + if page != nil { + d.tabs[actualTabID] = page + // Update tab history and current tab + d.updateTabHistory(actualTabID) + return page, nil + } + + return nil, fmt.Errorf("tab not found: %s", actualTabID) +} + +// closeTab closes a tab by its ID +func (d *Daemon) closeTab(tabID string, timeout int) error { + // Get the tab ID to use (may be the current tab) + actualTabID, err := d.getTabID(tabID) + if err != nil { + return err + } + + // First remove from our internal map to avoid future references + d.mu.Lock() + page, exists := d.tabs[actualTabID] + delete(d.tabs, actualTabID) + + // Remove the tab from history + for i, id := range d.tabHistory { + if id == actualTabID { + // Remove this tab from history + d.tabHistory = append(d.tabHistory[:i], d.tabHistory[i+1:]...) + break + } + } + + // If we closed the current tab, set it to the previous tab in history + if d.currentTab == actualTabID { + if len(d.tabHistory) > 0 { + // Set current tab to the most recent tab in history + d.currentTab = d.tabHistory[len(d.tabHistory)-1] + } else { + // No tabs left in history, clear the current tab + d.currentTab = "" + } + } + d.mu.Unlock() + + // If the page doesn't exist in our cache, try to find it + if !exists { + var err error + page, err = d.findPageByID(actualTabID) + if err != nil { + // If we can't find the page, it might already be closed + return nil + } + } + + if timeout > 0 { + // Use timeout for closing the tab + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) + defer cancel() + + // Create a channel to signal completion + done := make(chan error, 1) + + // Execute the close in a goroutine + go func() { + err := page.Close() + done <- err + }() + + // Wait for either completion or timeout + select { + case err := <-done: + if err != nil { + // Log the error but don't return it, as we've already removed it from our map + fmt.Printf("Warning: failed to close tab %s: %v\n", actualTabID, err) + } + return nil + case <-ctx.Done(): + return fmt.Errorf("closing tab timed out after %d seconds", timeout) + } + } else { + // No timeout - try to close the page, but don't fail if it's already closed + err = page.Close() + if err != nil { + // Log the error but don't return it, as we've already removed it from our map + fmt.Printf("Warning: failed to close tab %s: %v\n", actualTabID, err) + } + + return nil + } +} + +// loadURL loads a URL in a tab +func (d *Daemon) loadURL(tabID, url string, timeout int) error { + page, err := d.getTab(tabID) + if err != nil { + return err + } + + if timeout > 0 { + // Use timeout for the URL loading + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) + defer cancel() + + // Create a channel to signal completion + done := make(chan error, 1) + + // Execute the navigation in a goroutine + go func() { + err := page.Navigate(url) + if err != nil { + done <- fmt.Errorf("failed to navigate to URL: %w", err) + return + } + + // Wait for the page to be loaded + err = page.WaitLoad() + if err != nil { + done <- fmt.Errorf("failed to wait for page load: %w", err) + return + } + + done <- nil + }() + + // Wait for either completion or timeout + select { + case err := <-done: + return err + case <-ctx.Done(): + return fmt.Errorf("loading URL timed out after %d seconds", timeout) + } + } else { + // No timeout + 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 + } +} + +// isPageStable checks if a page is stable and not currently loading +func (d *Daemon) isPageStable(page *rod.Page) (bool, error) { + // Check if page is loading + result, err := page.Eval(`() => document.readyState === 'complete'`) + if err != nil { + return false, err + } + + isComplete := result.Value.Bool() + + if !isComplete { + return false, nil + } + + // Additional check: ensure no pending network requests + // This is a simple heuristic - if the page has been stable for a short time + err = page.WaitStable(500 * time.Millisecond) + if err != nil { + return false, nil // Page is not stable + } + + return true, nil +} + +// detectNavigationInProgress monitors the page for a short period to detect if navigation starts +func (d *Daemon) detectNavigationInProgress(page *rod.Page, monitorDuration time.Duration) (bool, error) { + // Get current URL and readyState + currentURL, err := page.Eval(`() => window.location.href`) + if err != nil { + return false, err + } + + currentReadyState, err := page.Eval(`() => document.readyState`) + if err != nil { + return false, err + } + + startURL := currentURL.Value.Str() + startReadyState := currentReadyState.Value.Str() + + // Monitor for changes over the specified duration + ctx, cancel := context.WithTimeout(context.Background(), monitorDuration) + defer cancel() + + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + // No navigation detected during monitoring period + return false, nil + case <-ticker.C: + // Check if URL or readyState changed + newURL, err := page.Eval(`() => window.location.href`) + if err != nil { + continue // Ignore errors during monitoring + } + + newReadyState, err := page.Eval(`() => document.readyState`) + if err != nil { + continue // Ignore errors during monitoring + } + + if newURL.Value.Str() != startURL { + // URL changed, navigation is happening + return true, nil + } + + if newReadyState.Value.Str() != startReadyState && newReadyState.Value.Str() == "loading" { + // Page started loading + return true, nil + } + } + } +} + +// waitNavigation waits for a navigation event to happen +func (d *Daemon) waitNavigation(tabID string, timeout int) error { + page, err := d.getTab(tabID) + if err != nil { + return err + } + + // First, check if the page is already stable and loaded + // If so, we don't need to wait for navigation + isStable, err := d.isPageStable(page) + if err == nil && isStable { + // Page is already stable, no navigation happening + return nil + } + + // Check if navigation is actually in progress by monitoring for a short period + navigationDetected, err := d.detectNavigationInProgress(page, 2*time.Second) + if err != nil { + return fmt.Errorf("failed to detect navigation state: %w", err) + } + + if !navigationDetected { + // No navigation detected, check if page is stable now + isStable, err := d.isPageStable(page) + if err == nil && isStable { + return nil + } + // If we can't determine stability, proceed with waiting + } + + // Navigation is in progress, wait for it to complete + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) + defer cancel() + + // Wait for navigation with timeout + done := make(chan error, 1) + go func() { + defer func() { + if r := recover(); r != nil { + done <- fmt.Errorf("navigation wait panicked: %v", r) + } + }() + + // Wait for navigation event + page.WaitNavigation(proto.PageLifecycleEventNameLoad)() + + // Wait for the page to be fully loaded + err := page.WaitLoad() + done <- err + }() + + select { + case err := <-done: + if err != nil { + return fmt.Errorf("navigation wait failed: %w", err) + } + return nil + case <-ctx.Done(): + // Timeout occurred, check if page is now stable + isStable, err := d.isPageStable(page) + if err == nil && isStable { + // Page is stable, consider navigation complete + return nil + } + return fmt.Errorf("navigation wait timed out after %d seconds", timeout) + } +} + +// getPageSource returns the entire source code of a page +func (d *Daemon) getPageSource(tabID string, timeout int) (string, error) { + page, err := d.getTab(tabID) + if err != nil { + return "", err + } + + if timeout > 0 { + // Use timeout for getting page source + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) + defer cancel() + + // Create a channel to signal completion + done := make(chan struct { + html string + err error + }, 1) + + // Execute the HTML retrieval in a goroutine + go func() { + html, err := page.HTML() + done <- struct { + html string + err error + }{html, err} + }() + + // Wait for either completion or timeout + select { + case res := <-done: + if res.err != nil { + return "", fmt.Errorf("failed to get page HTML: %w", res.err) + } + return res.html, nil + case <-ctx.Done(): + return "", fmt.Errorf("getting page source timed out after %d seconds", timeout) + } + } else { + // No timeout + 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 (d *Daemon) getElementHTML(tabID, selector string, selectionTimeout int) (string, error) { + page, err := d.getTab(tabID) + if err != nil { + return "", err + } + + // Find the element with optional timeout + var element *rod.Element + if selectionTimeout > 0 { + // Use timeout if specified + element, err = page.Timeout(time.Duration(selectionTimeout) * time.Second).Element(selector) + if err != nil { + return "", fmt.Errorf("failed to find element (timeout after %ds): %w", selectionTimeout, err) + } + } else { + // No timeout + 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 +} + +// fillFormField fills a form field with the specified value +func (d *Daemon) fillFormField(tabID, selector, value string, selectionTimeout, actionTimeout int) error { + page, err := d.getTab(tabID) + if err != nil { + return err + } + + // Find the element with optional timeout + var element *rod.Element + if selectionTimeout > 0 { + // Use timeout if specified + element, err = page.Timeout(time.Duration(selectionTimeout) * time.Second).Element(selector) + if err != nil { + return fmt.Errorf("failed to find element (timeout after %ds): %w", selectionTimeout, err) + } + } else { + // No timeout + element, err = page.Element(selector) + if err != nil { + return fmt.Errorf("failed to find element: %w", err) + } + } + + // Get the element type + tagName, err := element.Eval(`() => this.tagName.toLowerCase()`) + if err != nil { + return fmt.Errorf("failed to get element type: %w", err) + } + + // Get the element type attribute + inputType, err := element.Eval(`() => this.type ? this.type.toLowerCase() : ''`) + if err != nil { + return fmt.Errorf("failed to get element type attribute: %w", err) + } + + // Handle different input types + tagNameStr := tagName.Value.String() + typeStr := inputType.Value.String() + + // Handle checkbox and radio inputs + if tagNameStr == "input" && (typeStr == "checkbox" || typeStr == "radio") { + // Convert value to boolean + checked := false + if value == "true" || value == "1" || value == "yes" || value == "on" || value == "checked" { + checked = true + } + + // Set the checked state with optional timeout + if actionTimeout > 0 { + // Use timeout for the action + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(actionTimeout)*time.Second) + defer cancel() + + // Create a channel to signal completion + done := make(chan error, 1) + + // Execute the action in a goroutine + go func() { + _, err := element.Eval(fmt.Sprintf(`() => { this.checked = %t; return true; }`, checked)) + done <- err + }() + + // Wait for either completion or timeout + select { + case err := <-done: + if err != nil { + return fmt.Errorf("failed to set checkbox state: %w", err) + } + case <-ctx.Done(): + return fmt.Errorf("setting checkbox state timed out after %d seconds", actionTimeout) + } + + // Create a channel for the event trigger + done = make(chan error, 1) + + // Trigger change event with timeout + go func() { + _, err := element.Eval(`() => { + const event = new Event('change', { bubbles: true }); + this.dispatchEvent(event); + return true; + }`) + done <- err + }() + + // Wait for either completion or timeout + select { + case err := <-done: + if err != nil { + return fmt.Errorf("failed to trigger change event: %w", err) + } + case <-ctx.Done(): + return fmt.Errorf("triggering change event timed out after %d seconds", actionTimeout) + } + } else { + // No timeout + _, err := element.Eval(fmt.Sprintf(`() => { this.checked = %t; return true; }`, checked)) + if err != nil { + return fmt.Errorf("failed to set checkbox state: %w", err) + } + + // Trigger change event + _, err = element.Eval(`() => { + const event = new Event('change', { bubbles: true }); + this.dispatchEvent(event); + return true; + }`) + if err != nil { + return fmt.Errorf("failed to trigger change event: %w", err) + } + } + + return nil + } + + // For regular text inputs + if actionTimeout > 0 { + // Use timeout for the action + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(actionTimeout)*time.Second) + defer cancel() + + // Create a channel to signal completion + done := make(chan error, 1) + + // Clear the field first with timeout + go func() { + _ = element.SelectAllText() + err := element.Input("") + done <- err + }() + + // Wait for either completion or timeout + select { + case err := <-done: + if err != nil { + return fmt.Errorf("failed to clear field: %w", err) + } + case <-ctx.Done(): + return fmt.Errorf("clearing field timed out after %d seconds", actionTimeout) + } + + // Create a channel for the input action + done = make(chan error, 1) + + // Input the value with timeout + go func() { + err := element.Input(value) + done <- err + }() + + // Wait for either completion or timeout + select { + case err := <-done: + if err != nil { + return fmt.Errorf("failed to input value: %w", err) + } + case <-ctx.Done(): + return fmt.Errorf("inputting value timed out after %d seconds", actionTimeout) + } + } else { + // No timeout + // 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 (d *Daemon) uploadFile(tabID, selector, filePath string, selectionTimeout, actionTimeout int) error { + page, err := d.getTab(tabID) + if err != nil { + return err + } + + // Find the element with optional timeout + var element *rod.Element + if selectionTimeout > 0 { + // Use timeout if specified + element, err = page.Timeout(time.Duration(selectionTimeout) * time.Second).Element(selector) + if err != nil { + return fmt.Errorf("failed to find file input element (timeout after %ds): %w", selectionTimeout, err) + } + } else { + // No timeout + element, err = page.Element(selector) + if err != nil { + return fmt.Errorf("failed to find file input element: %w", err) + } + } + + // Set the file with optional timeout + if actionTimeout > 0 { + // Use timeout for the action + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(actionTimeout)*time.Second) + defer cancel() + + // Create a channel to signal completion + done := make(chan error, 1) + + // Execute the action in a goroutine + go func() { + err := element.SetFiles([]string{filePath}) + done <- err + }() + + // Wait for either completion or timeout + select { + case err := <-done: + if err != nil { + return fmt.Errorf("failed to set file: %w", err) + } + case <-ctx.Done(): + return fmt.Errorf("setting file timed out after %d seconds", actionTimeout) + } + } else { + // No timeout + err = element.SetFiles([]string{filePath}) + if err != nil { + return fmt.Errorf("failed to set file: %w", err) + } + } + + return nil +} + +// submitForm submits a form +func (d *Daemon) submitForm(tabID, selector string, selectionTimeout, actionTimeout int) error { + page, err := d.getTab(tabID) + if err != nil { + return err + } + + // Find the element with optional timeout + var form *rod.Element + if selectionTimeout > 0 { + // Use timeout if specified + form, err = page.Timeout(time.Duration(selectionTimeout) * time.Second).Element(selector) + if err != nil { + return fmt.Errorf("failed to find form element (timeout after %ds): %w", selectionTimeout, err) + } + } else { + // No timeout + form, err = page.Element(selector) + if err != nil { + return fmt.Errorf("failed to find form element: %w", err) + } + } + + // Get the current URL to detect navigation + currentURL := page.MustInfo().URL + + // Create a context for navigation timeout + var ctx context.Context + var cancel context.CancelFunc + + // Submit the form with optional timeout + if actionTimeout > 0 { + // Use timeout for the action + submitCtx, submitCancel := context.WithTimeout(context.Background(), time.Duration(actionTimeout)*time.Second) + defer submitCancel() + + // Create a channel to signal completion + done := make(chan error, 1) + + // Execute the action in a goroutine + go func() { + _, err := form.Eval(`() => { try { this.submit(); return true; } catch(e) { return false; } }`) + done <- err + }() + + // Wait for either completion or timeout + select { + case err := <-done: + if err != nil { + // Log the error but continue + fmt.Printf("Warning: error during form submission: %v\n", err) + } + case <-submitCtx.Done(): + return fmt.Errorf("form submission timed out after %d seconds", actionTimeout) + } + + // Wait for navigation to complete (with the same timeout) + ctx, cancel = context.WithTimeout(context.Background(), time.Duration(actionTimeout)*time.Second) + } else { + // No timeout for submission + try := func() (bool, error) { + _, err := form.Eval(`() => { try { this.submit(); return true; } catch(e) { return false; } }`) + return err == nil, err + } + + // Try to submit the form, but don't fail if it's already been submitted + _, err = try() + if err != nil { + // Log the error but continue + fmt.Printf("Warning: error during form submission: %v\n", err) + } + + // Wait for navigation to complete (with default timeout) + ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second) + } + defer cancel() + + // Wait for the page to navigate away from the current URL + waitNav := func() error { + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(100 * time.Millisecond): + // Check if the page has navigated + try := func() (string, error) { + info, err := page.Info() + if err != nil { + return "", err + } + return info.URL, nil + } + + newURL, err := try() + if err != nil { + // Page might be navigating, wait a bit more + continue + } + + if newURL != currentURL { + // Navigation completed + return nil + } + } + } + } + + // Wait for navigation but don't fail if it times out + err = waitNav() + if err != nil { + // Log the error but don't fail + fmt.Printf("Warning: navigation after form submission may not have completed: %v\n", err) + } + + return nil +} + +// clickElement clicks on an element +func (d *Daemon) clickElement(tabID, selector string, selectionTimeout, actionTimeout int) error { + page, err := d.getTab(tabID) + if err != nil { + return err + } + + // Find the element with optional timeout + var element *rod.Element + if selectionTimeout > 0 { + // Use timeout if specified + element, err = page.Timeout(time.Duration(selectionTimeout) * time.Second).Element(selector) + if err != nil { + return fmt.Errorf("failed to find element (timeout after %ds): %w", selectionTimeout, err) + } + } else { + // No timeout + element, err = page.Element(selector) + if err != nil { + return fmt.Errorf("failed to find element: %w", err) + } + } + + // Make sure the element is visible and scrolled into view + err = element.ScrollIntoView() + if err != nil { + return fmt.Errorf("failed to scroll element into view: %w", err) + } + + // Click the element with optional timeout + if actionTimeout > 0 { + // Use timeout for the click action + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(actionTimeout)*time.Second) + defer cancel() + + // Create a channel to signal completion + done := make(chan error, 1) + + // Execute the click in a goroutine + go func() { + err := element.Click(proto.InputMouseButtonLeft, 1) // 1 click + done <- err + }() + + // Wait for either completion or timeout + select { + case err := <-done: + if err != nil { + return fmt.Errorf("failed to click element: %w", err) + } + case <-ctx.Done(): + return fmt.Errorf("click action timed out after %d seconds", actionTimeout) + } + } else { + // No timeout + err = element.Click(proto.InputMouseButtonLeft, 1) // 1 click + if err != nil { + return fmt.Errorf("failed to click element: %w", err) + } + } + + // Wait a moment for any navigation to start + time.Sleep(100 * time.Millisecond) + + // Wait for any potential page load or DOM changes + err = page.WaitStable(1 * time.Second) + if err != nil { + // This is not a critical error, so we'll just log it + log.Printf("Warning: page not stable after click: %v", err) + } + + return nil +} + +// evalJS executes JavaScript code in a tab and returns the result +func (d *Daemon) evalJS(tabID, jsCode string, timeout int) (string, error) { + page, err := d.getTab(tabID) + if err != nil { + return "", err + } + + // Create a comprehensive wrapper that handles both expressions and statements + // and properly formats the result + wrappedCode := `() => { + var result; + try { + // Try to evaluate as an expression first + result = eval(` + "`" + jsCode + "`" + `); + } catch(e) { + // If that fails, try to execute as statements + try { + eval(` + "`" + jsCode + "`" + `); + result = undefined; + } catch(e2) { + throw e; // Re-throw the original error + } + } + + // Format the result for return + if (typeof result === 'undefined') return 'undefined'; + if (result === null) return 'null'; + if (typeof result === 'string') return result; + if (typeof result === 'number' || typeof result === 'boolean') return String(result); + try { + return JSON.stringify(result); + } catch(e) { + return String(result); + } + }` + + // Execute the wrapped JavaScript code with timeout + if timeout > 0 { + // Use timeout for the JavaScript execution + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) + defer cancel() + + // Create a channel to signal completion + done := make(chan struct { + result string + err error + }, 1) + + // Execute the JavaScript in a goroutine + go func() { + result, err := page.Eval(wrappedCode) + var resultStr string + if err == nil { + // Convert the result to a string representation + if result.Value.Nil() { + resultStr = "null" + } else { + resultStr = result.Value.String() + } + } + done <- struct { + result string + err error + }{resultStr, err} + }() + + // Wait for either completion or timeout + select { + case res := <-done: + if res.err != nil { + return "", fmt.Errorf("failed to execute JavaScript: %w", res.err) + } + return res.result, nil + case <-ctx.Done(): + return "", fmt.Errorf("JavaScript execution timed out after %d seconds", timeout) + } + } else { + // No timeout + result, err := page.Eval(wrappedCode) + if err != nil { + return "", fmt.Errorf("failed to execute JavaScript: %w", err) + } + + // Convert the result to a string representation + if result.Value.Nil() { + return "null", nil + } + + return result.Value.String(), nil + } +} + +// takeScreenshot takes a screenshot of a tab and saves it to a file +func (d *Daemon) takeScreenshot(tabID, outputPath string, fullPage bool, timeout int) error { + page, err := d.getTab(tabID) + if err != nil { + return err + } + + if timeout > 0 { + // Use timeout for taking screenshot + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) + defer cancel() + + // Create a channel to signal completion + done := make(chan error, 1) + + // Execute the screenshot in a goroutine + go func() { + // Take screenshot and save it + screenshotBytes, err := page.Screenshot(fullPage, &proto.PageCaptureScreenshot{ + Format: proto.PageCaptureScreenshotFormatPng, + }) + + if err != nil { + done <- fmt.Errorf("failed to capture screenshot: %w", err) + return + } + + // Write the screenshot to file + err = os.WriteFile(outputPath, screenshotBytes, 0644) + if err != nil { + done <- fmt.Errorf("failed to save screenshot to %s: %w", outputPath, err) + return + } + + done <- nil + }() + + // Wait for either completion or timeout + select { + case err := <-done: + if err != nil { + return fmt.Errorf("failed to save screenshot: %w", err) + } + return nil + case <-ctx.Done(): + return fmt.Errorf("taking screenshot timed out after %d seconds", timeout) + } + } else { + // No timeout - take screenshot directly + screenshotBytes, err := page.Screenshot(fullPage, &proto.PageCaptureScreenshot{ + Format: proto.PageCaptureScreenshotFormatPng, + }) + + if err != nil { + return fmt.Errorf("failed to capture screenshot: %w", err) + } + + // Write the screenshot to file + err = os.WriteFile(outputPath, screenshotBytes, 0644) + if err != nil { + return fmt.Errorf("failed to save screenshot to %s: %w", outputPath, err) + } + + return nil + } +} + +// switchToIframe switches the context to an iframe for subsequent commands +func (d *Daemon) switchToIframe(tabID, selector string) error { + // Get the main page first (not iframe context) + actualTabID, err := d.getTabID(tabID) + if err != nil { + return err + } + + d.mu.Lock() + defer d.mu.Unlock() + + // Get the main page (bypass iframe context) + mainPage, exists := d.tabs[actualTabID] + if !exists { + // Try to find it + mainPage, err = d.findPageByID(actualTabID) + if err != nil { + return err + } + if mainPage == nil { + return fmt.Errorf("tab not found: %s", actualTabID) + } + d.tabs[actualTabID] = mainPage + } + + // Find the iframe element + iframeElement, err := mainPage.Element(selector) + if err != nil { + return fmt.Errorf("failed to find iframe element: %w", err) + } + + // Get the iframe's page context + iframePage, err := iframeElement.Frame() + if err != nil { + return fmt.Errorf("failed to get iframe context: %w", err) + } + + // Store the iframe page context + d.iframePages[actualTabID] = iframePage + + return nil +} + +// switchToMain switches back to the main page context +func (d *Daemon) switchToMain(tabID string) error { + // Get the tab ID to use (may be the current tab) + actualTabID, err := d.getTabID(tabID) + if err != nil { + return err + } + + d.mu.Lock() + defer d.mu.Unlock() + + // Remove the iframe context for this tab + delete(d.iframePages, actualTabID) + + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c5eb72a --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ab43cb5 --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..9238f6a --- /dev/null +++ b/main.go @@ -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 [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 -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.") +} diff --git a/test-checkbox.html b/test-checkbox.html new file mode 100644 index 0000000..d6ac4e1 --- /dev/null +++ b/test-checkbox.html @@ -0,0 +1,53 @@ + + + + Checkbox Test + + +

Checkbox Test

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+
+ + + + diff --git a/test-checkbox2.html b/test-checkbox2.html new file mode 100644 index 0000000..ce79bf4 --- /dev/null +++ b/test-checkbox2.html @@ -0,0 +1,58 @@ + + + + Checkbox Test + + +

Checkbox Test

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+

Checkbox 1: false

+

Checkbox 2: false

+

Radio 1: false

+

Radio 2: false

+

Text Input:

+
+
+ + + + diff --git a/test-timeout.html b/test-timeout.html new file mode 100644 index 0000000..37fa828 --- /dev/null +++ b/test-timeout.html @@ -0,0 +1,36 @@ + + + + Timeout Test + + + +

Timeout Test

+
This element is immediately available
+
This element appears after 3 seconds
+ +
+ + + + diff --git a/test-timeout2.html b/test-timeout2.html new file mode 100644 index 0000000..cd3e14a --- /dev/null +++ b/test-timeout2.html @@ -0,0 +1,36 @@ + + + + Timeout Test + + +

Timeout Test

+
This element is immediately available
+
+ + + +