From 45f119b9ac0e0ebe34f5c8387a070a5b8bd2de6b Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 19 Feb 2026 16:09:11 +0000 Subject: [PATCH] feat: extract go-webview from core/go pkg/webview Chrome DevTools Protocol client for browser automation. Zero external dependencies (stdlib only). Module: forge.lthn.ai/core/go-webview Co-Authored-By: Virgil --- CLAUDE.md | 36 +++ actions.go | 547 ++++++++++++++++++++++++++++++++++++ angular.go | 626 +++++++++++++++++++++++++++++++++++++++++ cdp.go | 387 +++++++++++++++++++++++++ console.go | 509 +++++++++++++++++++++++++++++++++ go.mod | 3 + webview.go | 733 ++++++++++++++++++++++++++++++++++++++++++++++++ webview_test.go | 335 ++++++++++++++++++++++ 8 files changed, 3176 insertions(+) create mode 100644 CLAUDE.md create mode 100644 actions.go create mode 100644 angular.go create mode 100644 cdp.go create mode 100644 console.go create mode 100644 go.mod create mode 100644 webview.go create mode 100644 webview_test.go diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ca65f54 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,36 @@ +# CLAUDE.md + +## What This Is + +Chrome DevTools Protocol (CDP) client for browser automation, testing, and scraping. Module: `forge.lthn.ai/core/go-webview` + +## Commands + +```bash +go test ./... # Run all tests +go test -v -run Name # Run single test +``` + +## Architecture + +- `webview.New(webview.WithDebugURL("http://localhost:9222"))` connects to Chrome +- Navigation, DOM queries, console capture, screenshots, JS evaluation +- Angular-specific helpers for SPA testing + +## Key API + +```go +wv, _ := webview.New(webview.WithDebugURL("http://localhost:9222")) +defer wv.Close() +wv.Navigate("https://example.com") +wv.Click("#submit") +wv.Type("#input", "text") +screenshot, _ := wv.Screenshot() +``` + +## Coding Standards + +- UK English +- `go test ./...` must pass before commit +- Conventional commits: `type(scope): description` +- Co-Author: `Co-Authored-By: Virgil ` diff --git a/actions.go b/actions.go new file mode 100644 index 0000000..4dcc0ab --- /dev/null +++ b/actions.go @@ -0,0 +1,547 @@ +package webview + +import ( + "context" + "fmt" + "time" +) + +// Action represents a browser action that can be performed. +type Action interface { + Execute(ctx context.Context, wv *Webview) error +} + +// ClickAction represents a click action. +type ClickAction struct { + Selector string +} + +// Execute performs the click action. +func (a ClickAction) Execute(ctx context.Context, wv *Webview) error { + return wv.click(ctx, a.Selector) +} + +// TypeAction represents a typing action. +type TypeAction struct { + Selector string + Text string +} + +// Execute performs the type action. +func (a TypeAction) Execute(ctx context.Context, wv *Webview) error { + return wv.typeText(ctx, a.Selector, a.Text) +} + +// NavigateAction represents a navigation action. +type NavigateAction struct { + URL string +} + +// Execute performs the navigate action. +func (a NavigateAction) Execute(ctx context.Context, wv *Webview) error { + _, err := wv.client.Call(ctx, "Page.navigate", map[string]any{ + "url": a.URL, + }) + if err != nil { + return fmt.Errorf("failed to navigate: %w", err) + } + return wv.waitForLoad(ctx) +} + +// WaitAction represents a wait action. +type WaitAction struct { + Duration time.Duration +} + +// Execute performs the wait action. +func (a WaitAction) Execute(ctx context.Context, wv *Webview) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(a.Duration): + return nil + } +} + +// WaitForSelectorAction represents waiting for a selector. +type WaitForSelectorAction struct { + Selector string +} + +// Execute waits for the selector to appear. +func (a WaitForSelectorAction) Execute(ctx context.Context, wv *Webview) error { + return wv.waitForSelector(ctx, a.Selector) +} + +// ScrollAction represents a scroll action. +type ScrollAction struct { + X int + Y int +} + +// Execute performs the scroll action. +func (a ScrollAction) Execute(ctx context.Context, wv *Webview) error { + script := fmt.Sprintf("window.scrollTo(%d, %d)", a.X, a.Y) + _, err := wv.evaluate(ctx, script) + return err +} + +// ScrollIntoViewAction scrolls an element into view. +type ScrollIntoViewAction struct { + Selector string +} + +// Execute scrolls the element into view. +func (a ScrollIntoViewAction) Execute(ctx context.Context, wv *Webview) error { + script := fmt.Sprintf("document.querySelector(%q)?.scrollIntoView({behavior: 'smooth', block: 'center'})", a.Selector) + _, err := wv.evaluate(ctx, script) + return err +} + +// FocusAction focuses an element. +type FocusAction struct { + Selector string +} + +// Execute focuses the element. +func (a FocusAction) Execute(ctx context.Context, wv *Webview) error { + script := fmt.Sprintf("document.querySelector(%q)?.focus()", a.Selector) + _, err := wv.evaluate(ctx, script) + return err +} + +// BlurAction removes focus from an element. +type BlurAction struct { + Selector string +} + +// Execute removes focus from the element. +func (a BlurAction) Execute(ctx context.Context, wv *Webview) error { + script := fmt.Sprintf("document.querySelector(%q)?.blur()", a.Selector) + _, err := wv.evaluate(ctx, script) + return err +} + +// ClearAction clears the value of an input element. +type ClearAction struct { + Selector string +} + +// Execute clears the input value. +func (a ClearAction) Execute(ctx context.Context, wv *Webview) error { + script := fmt.Sprintf(` + const el = document.querySelector(%q); + if (el) { + el.value = ''; + el.dispatchEvent(new Event('input', {bubbles: true})); + el.dispatchEvent(new Event('change', {bubbles: true})); + } + `, a.Selector) + _, err := wv.evaluate(ctx, script) + return err +} + +// SelectAction selects an option in a select element. +type SelectAction struct { + Selector string + Value string +} + +// Execute selects the option. +func (a SelectAction) Execute(ctx context.Context, wv *Webview) error { + script := fmt.Sprintf(` + const el = document.querySelector(%q); + if (el) { + el.value = %q; + el.dispatchEvent(new Event('change', {bubbles: true})); + } + `, a.Selector, a.Value) + _, err := wv.evaluate(ctx, script) + return err +} + +// CheckAction checks or unchecks a checkbox. +type CheckAction struct { + Selector string + Checked bool +} + +// Execute checks/unchecks the checkbox. +func (a CheckAction) Execute(ctx context.Context, wv *Webview) error { + script := fmt.Sprintf(` + const el = document.querySelector(%q); + if (el && el.checked !== %t) { + el.click(); + } + `, a.Selector, a.Checked) + _, err := wv.evaluate(ctx, script) + return err +} + +// HoverAction hovers over an element. +type HoverAction struct { + Selector string +} + +// Execute hovers over the element. +func (a HoverAction) Execute(ctx context.Context, wv *Webview) error { + elem, err := wv.querySelector(ctx, a.Selector) + if err != nil { + return err + } + + if elem.BoundingBox == nil { + return fmt.Errorf("element has no bounding box") + } + + x := elem.BoundingBox.X + elem.BoundingBox.Width/2 + y := elem.BoundingBox.Y + elem.BoundingBox.Height/2 + + _, err = wv.client.Call(ctx, "Input.dispatchMouseEvent", map[string]any{ + "type": "mouseMoved", + "x": x, + "y": y, + }) + return err +} + +// DoubleClickAction double-clicks an element. +type DoubleClickAction struct { + Selector string +} + +// Execute double-clicks the element. +func (a DoubleClickAction) Execute(ctx context.Context, wv *Webview) error { + elem, err := wv.querySelector(ctx, a.Selector) + if err != nil { + return err + } + + if elem.BoundingBox == nil { + // Fallback to JavaScript + script := fmt.Sprintf(` + const el = document.querySelector(%q); + if (el) { + const event = new MouseEvent('dblclick', {bubbles: true, cancelable: true, view: window}); + el.dispatchEvent(event); + } + `, a.Selector) + _, err := wv.evaluate(ctx, script) + return err + } + + x := elem.BoundingBox.X + elem.BoundingBox.Width/2 + y := elem.BoundingBox.Y + elem.BoundingBox.Height/2 + + // Double click sequence + for i := 0; i < 2; i++ { + for _, eventType := range []string{"mousePressed", "mouseReleased"} { + _, err := wv.client.Call(ctx, "Input.dispatchMouseEvent", map[string]any{ + "type": eventType, + "x": x, + "y": y, + "button": "left", + "clickCount": i + 1, + }) + if err != nil { + return err + } + } + } + + return nil +} + +// RightClickAction right-clicks an element. +type RightClickAction struct { + Selector string +} + +// Execute right-clicks the element. +func (a RightClickAction) Execute(ctx context.Context, wv *Webview) error { + elem, err := wv.querySelector(ctx, a.Selector) + if err != nil { + return err + } + + if elem.BoundingBox == nil { + // Fallback to JavaScript + script := fmt.Sprintf(` + const el = document.querySelector(%q); + if (el) { + const event = new MouseEvent('contextmenu', {bubbles: true, cancelable: true, view: window}); + el.dispatchEvent(event); + } + `, a.Selector) + _, err := wv.evaluate(ctx, script) + return err + } + + x := elem.BoundingBox.X + elem.BoundingBox.Width/2 + y := elem.BoundingBox.Y + elem.BoundingBox.Height/2 + + for _, eventType := range []string{"mousePressed", "mouseReleased"} { + _, err := wv.client.Call(ctx, "Input.dispatchMouseEvent", map[string]any{ + "type": eventType, + "x": x, + "y": y, + "button": "right", + "clickCount": 1, + }) + if err != nil { + return err + } + } + + return nil +} + +// PressKeyAction presses a key. +type PressKeyAction struct { + Key string // e.g., "Enter", "Tab", "Escape" +} + +// Execute presses the key. +func (a PressKeyAction) Execute(ctx context.Context, wv *Webview) error { + // Map common key names to CDP key codes + keyMap := map[string]struct { + code string + keyCode int + text string + unmodified string + }{ + "Enter": {"Enter", 13, "\r", "\r"}, + "Tab": {"Tab", 9, "", ""}, + "Escape": {"Escape", 27, "", ""}, + "Backspace": {"Backspace", 8, "", ""}, + "Delete": {"Delete", 46, "", ""}, + "ArrowUp": {"ArrowUp", 38, "", ""}, + "ArrowDown": {"ArrowDown", 40, "", ""}, + "ArrowLeft": {"ArrowLeft", 37, "", ""}, + "ArrowRight": {"ArrowRight", 39, "", ""}, + "Home": {"Home", 36, "", ""}, + "End": {"End", 35, "", ""}, + "PageUp": {"PageUp", 33, "", ""}, + "PageDown": {"PageDown", 34, "", ""}, + } + + keyInfo, ok := keyMap[a.Key] + if !ok { + // For simple characters, just send key events + _, err := wv.client.Call(ctx, "Input.dispatchKeyEvent", map[string]any{ + "type": "keyDown", + "text": a.Key, + }) + if err != nil { + return err + } + _, err = wv.client.Call(ctx, "Input.dispatchKeyEvent", map[string]any{ + "type": "keyUp", + }) + return err + } + + params := map[string]any{ + "type": "keyDown", + "code": keyInfo.code, + "key": a.Key, + "windowsVirtualKeyCode": keyInfo.keyCode, + "nativeVirtualKeyCode": keyInfo.keyCode, + } + if keyInfo.text != "" { + params["text"] = keyInfo.text + params["unmodifiedText"] = keyInfo.unmodified + } + + _, err := wv.client.Call(ctx, "Input.dispatchKeyEvent", params) + if err != nil { + return err + } + + params["type"] = "keyUp" + delete(params, "text") + delete(params, "unmodifiedText") + _, err = wv.client.Call(ctx, "Input.dispatchKeyEvent", params) + return err +} + +// SetAttributeAction sets an attribute on an element. +type SetAttributeAction struct { + Selector string + Attribute string + Value string +} + +// Execute sets the attribute. +func (a SetAttributeAction) Execute(ctx context.Context, wv *Webview) error { + script := fmt.Sprintf("document.querySelector(%q)?.setAttribute(%q, %q)", a.Selector, a.Attribute, a.Value) + _, err := wv.evaluate(ctx, script) + return err +} + +// RemoveAttributeAction removes an attribute from an element. +type RemoveAttributeAction struct { + Selector string + Attribute string +} + +// Execute removes the attribute. +func (a RemoveAttributeAction) Execute(ctx context.Context, wv *Webview) error { + script := fmt.Sprintf("document.querySelector(%q)?.removeAttribute(%q)", a.Selector, a.Attribute) + _, err := wv.evaluate(ctx, script) + return err +} + +// SetValueAction sets the value of an input element. +type SetValueAction struct { + Selector string + Value string +} + +// Execute sets the value. +func (a SetValueAction) Execute(ctx context.Context, wv *Webview) error { + script := fmt.Sprintf(` + const el = document.querySelector(%q); + if (el) { + el.value = %q; + el.dispatchEvent(new Event('input', {bubbles: true})); + el.dispatchEvent(new Event('change', {bubbles: true})); + } + `, a.Selector, a.Value) + _, err := wv.evaluate(ctx, script) + return err +} + +// ActionSequence represents a sequence of actions to execute. +type ActionSequence struct { + actions []Action +} + +// NewActionSequence creates a new action sequence. +func NewActionSequence() *ActionSequence { + return &ActionSequence{ + actions: make([]Action, 0), + } +} + +// Add adds an action to the sequence. +func (s *ActionSequence) Add(action Action) *ActionSequence { + s.actions = append(s.actions, action) + return s +} + +// Click adds a click action. +func (s *ActionSequence) Click(selector string) *ActionSequence { + return s.Add(ClickAction{Selector: selector}) +} + +// Type adds a type action. +func (s *ActionSequence) Type(selector, text string) *ActionSequence { + return s.Add(TypeAction{Selector: selector, Text: text}) +} + +// Navigate adds a navigate action. +func (s *ActionSequence) Navigate(url string) *ActionSequence { + return s.Add(NavigateAction{URL: url}) +} + +// Wait adds a wait action. +func (s *ActionSequence) Wait(d time.Duration) *ActionSequence { + return s.Add(WaitAction{Duration: d}) +} + +// WaitForSelector adds a wait for selector action. +func (s *ActionSequence) WaitForSelector(selector string) *ActionSequence { + return s.Add(WaitForSelectorAction{Selector: selector}) +} + +// Execute executes all actions in the sequence. +func (s *ActionSequence) Execute(ctx context.Context, wv *Webview) error { + for i, action := range s.actions { + if err := action.Execute(ctx, wv); err != nil { + return fmt.Errorf("action %d failed: %w", i, err) + } + } + return nil +} + +// UploadFile uploads a file to a file input element. +func (wv *Webview) UploadFile(selector string, filePaths []string) error { + ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout) + defer cancel() + + // Get the element's node ID + elem, err := wv.querySelector(ctx, selector) + if err != nil { + return err + } + + // Use DOM.setFileInputFiles to set the files + _, err = wv.client.Call(ctx, "DOM.setFileInputFiles", map[string]any{ + "nodeId": elem.NodeID, + "files": filePaths, + }) + return err +} + +// DragAndDrop performs a drag and drop operation. +func (wv *Webview) DragAndDrop(sourceSelector, targetSelector string) error { + ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout) + defer cancel() + + // Get source and target elements + source, err := wv.querySelector(ctx, sourceSelector) + if err != nil { + return fmt.Errorf("source element not found: %w", err) + } + if source.BoundingBox == nil { + return fmt.Errorf("source element has no bounding box") + } + + target, err := wv.querySelector(ctx, targetSelector) + if err != nil { + return fmt.Errorf("target element not found: %w", err) + } + if target.BoundingBox == nil { + return fmt.Errorf("target element has no bounding box") + } + + // Calculate center points + sourceX := source.BoundingBox.X + source.BoundingBox.Width/2 + sourceY := source.BoundingBox.Y + source.BoundingBox.Height/2 + targetX := target.BoundingBox.X + target.BoundingBox.Width/2 + targetY := target.BoundingBox.Y + target.BoundingBox.Height/2 + + // Mouse down on source + _, err = wv.client.Call(ctx, "Input.dispatchMouseEvent", map[string]any{ + "type": "mousePressed", + "x": sourceX, + "y": sourceY, + "button": "left", + "clickCount": 1, + }) + if err != nil { + return err + } + + // Move to target + _, err = wv.client.Call(ctx, "Input.dispatchMouseEvent", map[string]any{ + "type": "mouseMoved", + "x": targetX, + "y": targetY, + "button": "left", + }) + if err != nil { + return err + } + + // Mouse up on target + _, err = wv.client.Call(ctx, "Input.dispatchMouseEvent", map[string]any{ + "type": "mouseReleased", + "x": targetX, + "y": targetY, + "button": "left", + "clickCount": 1, + }) + return err +} diff --git a/angular.go b/angular.go new file mode 100644 index 0000000..0a842c7 --- /dev/null +++ b/angular.go @@ -0,0 +1,626 @@ +package webview + +import ( + "context" + "fmt" + "time" +) + +// AngularHelper provides Angular-specific testing utilities. +type AngularHelper struct { + wv *Webview + timeout time.Duration +} + +// NewAngularHelper creates a new Angular helper for the webview. +func NewAngularHelper(wv *Webview) *AngularHelper { + return &AngularHelper{ + wv: wv, + timeout: 30 * time.Second, + } +} + +// SetTimeout sets the default timeout for Angular operations. +func (ah *AngularHelper) SetTimeout(d time.Duration) { + ah.timeout = d +} + +// WaitForAngular waits for Angular to finish all pending operations. +// This includes HTTP requests, timers, and change detection. +func (ah *AngularHelper) WaitForAngular() error { + ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout) + defer cancel() + + return ah.waitForAngular(ctx) +} + +// waitForAngular implements the Angular wait logic. +func (ah *AngularHelper) waitForAngular(ctx context.Context) error { + // Check if Angular is present + isAngular, err := ah.isAngularApp(ctx) + if err != nil { + return err + } + if !isAngular { + return fmt.Errorf("not an Angular application") + } + + // Wait for Zone.js stability + return ah.waitForZoneStability(ctx) +} + +// isAngularApp checks if the current page is an Angular application. +func (ah *AngularHelper) isAngularApp(ctx context.Context) (bool, error) { + script := ` + (function() { + // Check for Angular 2+ + if (window.getAllAngularRootElements && window.getAllAngularRootElements().length > 0) { + return true; + } + // Check for Angular CLI generated apps + if (document.querySelector('[ng-version]')) { + return true; + } + // Check for Angular elements + if (window.ng && typeof window.ng.probe === 'function') { + return true; + } + // Check for AngularJS (1.x) + if (window.angular && window.angular.element) { + return true; + } + return false; + })() + ` + + result, err := ah.wv.evaluate(ctx, script) + if err != nil { + return false, err + } + + isAngular, ok := result.(bool) + if !ok { + return false, nil + } + + return isAngular, nil +} + +// waitForZoneStability waits for Zone.js to become stable. +func (ah *AngularHelper) waitForZoneStability(ctx context.Context) error { + script := ` + new Promise((resolve, reject) => { + // Get the root elements + const roots = window.getAllAngularRootElements ? window.getAllAngularRootElements() : []; + if (roots.length === 0) { + // Try to find root element directly + const appRoot = document.querySelector('[ng-version]'); + if (appRoot) { + roots.push(appRoot); + } + } + + if (roots.length === 0) { + resolve(true); // No Angular roots found, nothing to wait for + return; + } + + // Get the Zone from any root element + let zone = null; + for (const root of roots) { + try { + const injector = window.ng.probe(root).injector; + zone = injector.get(window.ng.coreTokens.NgZone || 'NgZone'); + if (zone) break; + } catch (e) { + // Continue to next root + } + } + + if (!zone) { + // Fallback: check window.Zone + if (window.Zone && window.Zone.current && window.Zone.current._inner) { + const isStable = !window.Zone.current._inner._hasPendingMicrotasks && + !window.Zone.current._inner._hasPendingMacrotasks; + if (isStable) { + resolve(true); + } else { + // Poll for stability + let attempts = 0; + const poll = setInterval(() => { + attempts++; + const stable = !window.Zone.current._inner._hasPendingMicrotasks && + !window.Zone.current._inner._hasPendingMacrotasks; + if (stable || attempts > 100) { + clearInterval(poll); + resolve(stable); + } + }, 50); + } + } else { + resolve(true); + } + return; + } + + // Use Angular's zone stability + if (zone.isStable) { + resolve(true); + return; + } + + // Wait for stability + const sub = zone.onStable.subscribe(() => { + sub.unsubscribe(); + resolve(true); + }); + + // Timeout fallback + setTimeout(() => { + sub.unsubscribe(); + resolve(zone.isStable); + }, 5000); + }) + ` + + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + // First evaluate the promise + _, err := ah.wv.evaluate(ctx, script) + if err != nil { + // If the script fails, fall back to simple polling + return ah.pollForStability(ctx) + } + + return nil +} + +// pollForStability polls for Angular stability as a fallback. +func (ah *AngularHelper) pollForStability(ctx context.Context) error { + script := ` + (function() { + if (window.Zone && window.Zone.current) { + const inner = window.Zone.current._inner || window.Zone.current; + return !inner._hasPendingMicrotasks && !inner._hasPendingMacrotasks; + } + return true; + })() + ` + + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + result, err := ah.wv.evaluate(ctx, script) + if err != nil { + continue + } + if stable, ok := result.(bool); ok && stable { + return nil + } + } + } +} + +// NavigateByRouter navigates using Angular Router. +func (ah *AngularHelper) NavigateByRouter(path string) error { + ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout) + defer cancel() + + script := fmt.Sprintf(` + (function() { + const roots = window.getAllAngularRootElements ? window.getAllAngularRootElements() : []; + if (roots.length === 0) { + throw new Error('No Angular root elements found'); + } + + for (const root of roots) { + try { + const injector = window.ng.probe(root).injector; + const router = injector.get(window.ng.coreTokens.Router || 'Router'); + if (router) { + router.navigateByUrl(%q); + return true; + } + } catch (e) { + continue; + } + } + throw new Error('Could not find Angular Router'); + })() + `, path) + + _, err := ah.wv.evaluate(ctx, script) + if err != nil { + return fmt.Errorf("failed to navigate: %w", err) + } + + // Wait for navigation to complete + return ah.waitForZoneStability(ctx) +} + +// GetRouterState returns the current Angular router state. +func (ah *AngularHelper) GetRouterState() (*AngularRouterState, error) { + ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout) + defer cancel() + + script := ` + (function() { + const roots = window.getAllAngularRootElements ? window.getAllAngularRootElements() : []; + for (const root of roots) { + try { + const injector = window.ng.probe(root).injector; + const router = injector.get(window.ng.coreTokens.Router || 'Router'); + if (router) { + return { + url: router.url, + fragment: router.routerState.root.fragment, + params: router.routerState.root.params, + queryParams: router.routerState.root.queryParams + }; + } + } catch (e) { + continue; + } + } + return null; + })() + ` + + result, err := ah.wv.evaluate(ctx, script) + if err != nil { + return nil, err + } + + if result == nil { + return nil, fmt.Errorf("could not get router state") + } + + // Parse result + resultMap, ok := result.(map[string]any) + if !ok { + return nil, fmt.Errorf("invalid router state format") + } + + state := &AngularRouterState{ + URL: getString(resultMap, "url"), + } + + if fragment, ok := resultMap["fragment"].(string); ok { + state.Fragment = fragment + } + + if params, ok := resultMap["params"].(map[string]any); ok { + state.Params = make(map[string]string) + for k, v := range params { + if s, ok := v.(string); ok { + state.Params[k] = s + } + } + } + + if queryParams, ok := resultMap["queryParams"].(map[string]any); ok { + state.QueryParams = make(map[string]string) + for k, v := range queryParams { + if s, ok := v.(string); ok { + state.QueryParams[k] = s + } + } + } + + return state, nil +} + +// AngularRouterState represents Angular router state. +type AngularRouterState struct { + URL string `json:"url"` + Fragment string `json:"fragment,omitempty"` + Params map[string]string `json:"params,omitempty"` + QueryParams map[string]string `json:"queryParams,omitempty"` +} + +// GetComponentProperty gets a property from an Angular component. +func (ah *AngularHelper) GetComponentProperty(selector, propertyName string) (any, error) { + ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout) + defer cancel() + + script := fmt.Sprintf(` + (function() { + const element = document.querySelector(%q); + if (!element) { + throw new Error('Element not found: %s'); + } + const component = window.ng.probe(element).componentInstance; + if (!component) { + throw new Error('No Angular component found on element'); + } + return component[%q]; + })() + `, selector, selector, propertyName) + + return ah.wv.evaluate(ctx, script) +} + +// SetComponentProperty sets a property on an Angular component. +func (ah *AngularHelper) SetComponentProperty(selector, propertyName string, value any) error { + ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout) + defer cancel() + + script := fmt.Sprintf(` + (function() { + const element = document.querySelector(%q); + if (!element) { + throw new Error('Element not found: %s'); + } + const component = window.ng.probe(element).componentInstance; + if (!component) { + throw new Error('No Angular component found on element'); + } + component[%q] = %v; + + // Trigger change detection + const injector = window.ng.probe(element).injector; + const appRef = injector.get(window.ng.coreTokens.ApplicationRef || 'ApplicationRef'); + if (appRef) { + appRef.tick(); + } + return true; + })() + `, selector, selector, propertyName, formatJSValue(value)) + + _, err := ah.wv.evaluate(ctx, script) + return err +} + +// CallComponentMethod calls a method on an Angular component. +func (ah *AngularHelper) CallComponentMethod(selector, methodName string, args ...any) (any, error) { + ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout) + defer cancel() + + argsStr := "" + for i, arg := range args { + if i > 0 { + argsStr += ", " + } + argsStr += formatJSValue(arg) + } + + script := fmt.Sprintf(` + (function() { + const element = document.querySelector(%q); + if (!element) { + throw new Error('Element not found: %s'); + } + const component = window.ng.probe(element).componentInstance; + if (!component) { + throw new Error('No Angular component found on element'); + } + if (typeof component[%q] !== 'function') { + throw new Error('Method not found: %s'); + } + const result = component[%q](%s); + + // Trigger change detection + const injector = window.ng.probe(element).injector; + const appRef = injector.get(window.ng.coreTokens.ApplicationRef || 'ApplicationRef'); + if (appRef) { + appRef.tick(); + } + return result; + })() + `, selector, selector, methodName, methodName, methodName, argsStr) + + return ah.wv.evaluate(ctx, script) +} + +// TriggerChangeDetection manually triggers Angular change detection. +func (ah *AngularHelper) TriggerChangeDetection() error { + ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout) + defer cancel() + + script := ` + (function() { + const roots = window.getAllAngularRootElements ? window.getAllAngularRootElements() : []; + for (const root of roots) { + try { + const injector = window.ng.probe(root).injector; + const appRef = injector.get(window.ng.coreTokens.ApplicationRef || 'ApplicationRef'); + if (appRef) { + appRef.tick(); + return true; + } + } catch (e) { + continue; + } + } + return false; + })() + ` + + _, err := ah.wv.evaluate(ctx, script) + return err +} + +// GetService gets an Angular service by token name. +func (ah *AngularHelper) GetService(serviceName string) (any, error) { + ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout) + defer cancel() + + script := fmt.Sprintf(` + (function() { + const roots = window.getAllAngularRootElements ? window.getAllAngularRootElements() : []; + for (const root of roots) { + try { + const injector = window.ng.probe(root).injector; + const service = injector.get(%q); + if (service) { + // Return a serializable representation + return JSON.parse(JSON.stringify(service)); + } + } catch (e) { + continue; + } + } + return null; + })() + `, serviceName) + + return ah.wv.evaluate(ctx, script) +} + +// WaitForComponent waits for an Angular component to be present. +func (ah *AngularHelper) WaitForComponent(selector string) error { + ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout) + defer cancel() + + script := fmt.Sprintf(` + (function() { + const element = document.querySelector(%q); + if (!element) return false; + try { + const component = window.ng.probe(element).componentInstance; + return !!component; + } catch (e) { + return false; + } + })() + `, selector) + + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + result, err := ah.wv.evaluate(ctx, script) + if err != nil { + continue + } + if found, ok := result.(bool); ok && found { + return nil + } + } + } +} + +// DispatchEvent dispatches a custom event on an element. +func (ah *AngularHelper) DispatchEvent(selector, eventName string, detail any) error { + ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout) + defer cancel() + + detailStr := "null" + if detail != nil { + detailStr = formatJSValue(detail) + } + + script := fmt.Sprintf(` + (function() { + const element = document.querySelector(%q); + if (!element) { + throw new Error('Element not found: %s'); + } + const event = new CustomEvent(%q, { bubbles: true, detail: %s }); + element.dispatchEvent(event); + return true; + })() + `, selector, selector, eventName, detailStr) + + _, err := ah.wv.evaluate(ctx, script) + return err +} + +// GetNgModel gets the value of an ngModel-bound input. +func (ah *AngularHelper) GetNgModel(selector string) (any, error) { + ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout) + defer cancel() + + script := fmt.Sprintf(` + (function() { + const element = document.querySelector(%q); + if (!element) return null; + + // Try to get from component + try { + const debug = window.ng.probe(element); + const component = debug.componentInstance; + // Look for common ngModel patterns + if (element.tagName === 'INPUT' || element.tagName === 'SELECT' || element.tagName === 'TEXTAREA') { + return element.value; + } + } catch (e) {} + + return element.value || element.textContent; + })() + `, selector) + + return ah.wv.evaluate(ctx, script) +} + +// SetNgModel sets the value of an ngModel-bound input. +func (ah *AngularHelper) SetNgModel(selector string, value any) error { + ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout) + defer cancel() + + script := fmt.Sprintf(` + (function() { + const element = document.querySelector(%q); + if (!element) { + throw new Error('Element not found: %s'); + } + + element.value = %v; + element.dispatchEvent(new Event('input', { bubbles: true })); + element.dispatchEvent(new Event('change', { bubbles: true })); + + // Trigger change detection + const roots = window.getAllAngularRootElements ? window.getAllAngularRootElements() : []; + for (const root of roots) { + try { + const injector = window.ng.probe(root).injector; + const appRef = injector.get(window.ng.coreTokens.ApplicationRef || 'ApplicationRef'); + if (appRef) { + appRef.tick(); + break; + } + } catch (e) {} + } + + return true; + })() + `, selector, selector, formatJSValue(value)) + + _, err := ah.wv.evaluate(ctx, script) + return err +} + +// Helper functions + +func getString(m map[string]any, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} + +func formatJSValue(v any) string { + switch val := v.(type) { + case string: + return fmt.Sprintf("%q", val) + case bool: + if val { + return "true" + } + return "false" + case nil: + return "null" + default: + return fmt.Sprintf("%v", val) + } +} diff --git a/cdp.go b/cdp.go new file mode 100644 index 0000000..f00d1f1 --- /dev/null +++ b/cdp.go @@ -0,0 +1,387 @@ +package webview + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sync" + "sync/atomic" + + "github.com/gorilla/websocket" +) + +// CDPClient handles communication with Chrome DevTools Protocol via WebSocket. +type CDPClient struct { + mu sync.RWMutex + conn *websocket.Conn + debugURL string + wsURL string + + // Message tracking + msgID atomic.Int64 + pending map[int64]chan *cdpResponse + pendMu sync.Mutex + + // Event handlers + handlers map[string][]func(map[string]any) + handMu sync.RWMutex + + // Lifecycle + ctx context.Context + cancel context.CancelFunc + done chan struct{} +} + +// cdpMessage represents a CDP protocol message. +type cdpMessage struct { + ID int64 `json:"id,omitempty"` + Method string `json:"method"` + Params map[string]any `json:"params,omitempty"` +} + +// cdpResponse represents a CDP protocol response. +type cdpResponse struct { + ID int64 `json:"id"` + Result map[string]any `json:"result,omitempty"` + Error *cdpError `json:"error,omitempty"` +} + +// cdpEvent represents a CDP event. +type cdpEvent struct { + Method string `json:"method"` + Params map[string]any `json:"params,omitempty"` +} + +// cdpError represents a CDP error. +type cdpError struct { + Code int `json:"code"` + Message string `json:"message"` + Data string `json:"data,omitempty"` +} + +// targetInfo represents Chrome DevTools target information. +type targetInfo struct { + ID string `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + URL string `json:"url"` + WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"` +} + +// NewCDPClient creates a new CDP client connected to the given debug URL. +// The debug URL should be the Chrome DevTools HTTP endpoint (e.g., http://localhost:9222). +func NewCDPClient(debugURL string) (*CDPClient, error) { + // Get available targets + resp, err := http.Get(debugURL + "/json") + if err != nil { + return nil, fmt.Errorf("failed to get targets: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read targets: %w", err) + } + + var targets []targetInfo + if err := json.Unmarshal(body, &targets); err != nil { + return nil, fmt.Errorf("failed to parse targets: %w", err) + } + + // Find a page target + var wsURL string + for _, t := range targets { + if t.Type == "page" && t.WebSocketDebuggerURL != "" { + wsURL = t.WebSocketDebuggerURL + break + } + } + + if wsURL == "" { + // Try to create a new target + resp, err := http.Get(debugURL + "/json/new") + if err != nil { + return nil, fmt.Errorf("no page targets found and failed to create new: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read new target: %w", err) + } + + var newTarget targetInfo + if err := json.Unmarshal(body, &newTarget); err != nil { + return nil, fmt.Errorf("failed to parse new target: %w", err) + } + + wsURL = newTarget.WebSocketDebuggerURL + } + + if wsURL == "" { + return nil, fmt.Errorf("no WebSocket URL available") + } + + // Connect to WebSocket + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to connect to WebSocket: %w", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + + client := &CDPClient{ + conn: conn, + debugURL: debugURL, + wsURL: wsURL, + pending: make(map[int64]chan *cdpResponse), + handlers: make(map[string][]func(map[string]any)), + ctx: ctx, + cancel: cancel, + done: make(chan struct{}), + } + + // Start message reader + go client.readLoop() + + return client, nil +} + +// Close closes the CDP connection. +func (c *CDPClient) Close() error { + c.cancel() + <-c.done // Wait for read loop to finish + return c.conn.Close() +} + +// Call sends a CDP method call and waits for the response. +func (c *CDPClient) Call(ctx context.Context, method string, params map[string]any) (map[string]any, error) { + id := c.msgID.Add(1) + + msg := cdpMessage{ + ID: id, + Method: method, + Params: params, + } + + // Register response channel + respCh := make(chan *cdpResponse, 1) + c.pendMu.Lock() + c.pending[id] = respCh + c.pendMu.Unlock() + + defer func() { + c.pendMu.Lock() + delete(c.pending, id) + c.pendMu.Unlock() + }() + + // Send message + c.mu.Lock() + err := c.conn.WriteJSON(msg) + c.mu.Unlock() + if err != nil { + return nil, fmt.Errorf("failed to send message: %w", err) + } + + // Wait for response + select { + case <-ctx.Done(): + return nil, ctx.Err() + case resp := <-respCh: + if resp.Error != nil { + return nil, fmt.Errorf("CDP error %d: %s", resp.Error.Code, resp.Error.Message) + } + return resp.Result, nil + } +} + +// OnEvent registers a handler for CDP events. +func (c *CDPClient) OnEvent(method string, handler func(map[string]any)) { + c.handMu.Lock() + defer c.handMu.Unlock() + c.handlers[method] = append(c.handlers[method], handler) +} + +// readLoop reads messages from the WebSocket connection. +func (c *CDPClient) readLoop() { + defer close(c.done) + + for { + select { + case <-c.ctx.Done(): + return + default: + } + + _, data, err := c.conn.ReadMessage() + if err != nil { + // Check if context was cancelled + select { + case <-c.ctx.Done(): + return + default: + // Log error but continue (could be temporary) + continue + } + } + + // Try to parse as response + var resp cdpResponse + if err := json.Unmarshal(data, &resp); err == nil && resp.ID > 0 { + c.pendMu.Lock() + if ch, ok := c.pending[resp.ID]; ok { + respCopy := resp + ch <- &respCopy + } + c.pendMu.Unlock() + continue + } + + // Try to parse as event + var event cdpEvent + if err := json.Unmarshal(data, &event); err == nil && event.Method != "" { + c.dispatchEvent(event.Method, event.Params) + } + } +} + +// dispatchEvent dispatches an event to registered handlers. +func (c *CDPClient) dispatchEvent(method string, params map[string]any) { + c.handMu.RLock() + handlers := c.handlers[method] + c.handMu.RUnlock() + + for _, handler := range handlers { + // Call handler in goroutine to avoid blocking + go handler(params) + } +} + +// Send sends a fire-and-forget CDP message (no response expected). +func (c *CDPClient) Send(method string, params map[string]any) error { + msg := cdpMessage{ + Method: method, + Params: params, + } + + c.mu.Lock() + defer c.mu.Unlock() + return c.conn.WriteJSON(msg) +} + +// DebugURL returns the debug HTTP URL. +func (c *CDPClient) DebugURL() string { + return c.debugURL +} + +// WebSocketURL returns the WebSocket URL being used. +func (c *CDPClient) WebSocketURL() string { + return c.wsURL +} + +// NewTab creates a new browser tab and returns a new CDPClient connected to it. +func (c *CDPClient) NewTab(url string) (*CDPClient, error) { + endpoint := c.debugURL + "/json/new" + if url != "" { + endpoint += "?" + url + } + + resp, err := http.Get(endpoint) + if err != nil { + return nil, fmt.Errorf("failed to create new tab: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + var target targetInfo + if err := json.Unmarshal(body, &target); err != nil { + return nil, fmt.Errorf("failed to parse target: %w", err) + } + + if target.WebSocketDebuggerURL == "" { + return nil, fmt.Errorf("no WebSocket URL for new tab") + } + + // Connect to new tab + conn, _, err := websocket.DefaultDialer.Dial(target.WebSocketDebuggerURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to connect to new tab: %w", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + + client := &CDPClient{ + conn: conn, + debugURL: c.debugURL, + wsURL: target.WebSocketDebuggerURL, + pending: make(map[int64]chan *cdpResponse), + handlers: make(map[string][]func(map[string]any)), + ctx: ctx, + cancel: cancel, + done: make(chan struct{}), + } + + go client.readLoop() + + return client, nil +} + +// CloseTab closes the current tab (target). +func (c *CDPClient) CloseTab() error { + // Extract target ID from WebSocket URL + // Format: ws://host:port/devtools/page/TARGET_ID + // We'll use the Browser.close target API + + ctx := context.Background() + _, err := c.Call(ctx, "Browser.close", nil) + return err +} + +// ListTargets returns all available targets. +func ListTargets(debugURL string) ([]targetInfo, error) { + resp, err := http.Get(debugURL + "/json") + if err != nil { + return nil, fmt.Errorf("failed to get targets: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read targets: %w", err) + } + + var targets []targetInfo + if err := json.Unmarshal(body, &targets); err != nil { + return nil, fmt.Errorf("failed to parse targets: %w", err) + } + + return targets, nil +} + +// GetVersion returns Chrome version information. +func GetVersion(debugURL string) (map[string]string, error) { + resp, err := http.Get(debugURL + "/json/version") + if err != nil { + return nil, fmt.Errorf("failed to get version: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read version: %w", err) + } + + var version map[string]string + if err := json.Unmarshal(body, &version); err != nil { + return nil, fmt.Errorf("failed to parse version: %w", err) + } + + return version, nil +} diff --git a/console.go b/console.go new file mode 100644 index 0000000..5ff1530 --- /dev/null +++ b/console.go @@ -0,0 +1,509 @@ +package webview + +import ( + "context" + "fmt" + "sync" + "time" +) + +// ConsoleWatcher provides advanced console message watching capabilities. +type ConsoleWatcher struct { + mu sync.RWMutex + wv *Webview + messages []ConsoleMessage + filters []ConsoleFilter + limit int + handlers []ConsoleHandler +} + +// ConsoleFilter filters console messages. +type ConsoleFilter struct { + Type string // Filter by type (log, warn, error, info, debug), empty for all + Pattern string // Filter by text pattern (substring match) +} + +// ConsoleHandler is called when a matching console message is received. +type ConsoleHandler func(msg ConsoleMessage) + +// NewConsoleWatcher creates a new console watcher for the webview. +func NewConsoleWatcher(wv *Webview) *ConsoleWatcher { + cw := &ConsoleWatcher{ + wv: wv, + messages: make([]ConsoleMessage, 0, 100), + filters: make([]ConsoleFilter, 0), + limit: 1000, + handlers: make([]ConsoleHandler, 0), + } + + // Subscribe to console events from the webview's client + wv.client.OnEvent("Runtime.consoleAPICalled", func(params map[string]any) { + cw.handleConsoleEvent(params) + }) + + return cw +} + +// AddFilter adds a filter to the watcher. +func (cw *ConsoleWatcher) AddFilter(filter ConsoleFilter) { + cw.mu.Lock() + defer cw.mu.Unlock() + cw.filters = append(cw.filters, filter) +} + +// ClearFilters removes all filters. +func (cw *ConsoleWatcher) ClearFilters() { + cw.mu.Lock() + defer cw.mu.Unlock() + cw.filters = cw.filters[:0] +} + +// AddHandler adds a handler for console messages. +func (cw *ConsoleWatcher) AddHandler(handler ConsoleHandler) { + cw.mu.Lock() + defer cw.mu.Unlock() + cw.handlers = append(cw.handlers, handler) +} + +// SetLimit sets the maximum number of messages to retain. +func (cw *ConsoleWatcher) SetLimit(limit int) { + cw.mu.Lock() + defer cw.mu.Unlock() + cw.limit = limit +} + +// Messages returns all captured messages. +func (cw *ConsoleWatcher) Messages() []ConsoleMessage { + cw.mu.RLock() + defer cw.mu.RUnlock() + + result := make([]ConsoleMessage, len(cw.messages)) + copy(result, cw.messages) + return result +} + +// FilteredMessages returns messages matching the current filters. +func (cw *ConsoleWatcher) FilteredMessages() []ConsoleMessage { + cw.mu.RLock() + defer cw.mu.RUnlock() + + if len(cw.filters) == 0 { + result := make([]ConsoleMessage, len(cw.messages)) + copy(result, cw.messages) + return result + } + + result := make([]ConsoleMessage, 0) + for _, msg := range cw.messages { + if cw.matchesFilter(msg) { + result = append(result, msg) + } + } + return result +} + +// Errors returns all error messages. +func (cw *ConsoleWatcher) Errors() []ConsoleMessage { + cw.mu.RLock() + defer cw.mu.RUnlock() + + result := make([]ConsoleMessage, 0) + for _, msg := range cw.messages { + if msg.Type == "error" { + result = append(result, msg) + } + } + return result +} + +// Warnings returns all warning messages. +func (cw *ConsoleWatcher) Warnings() []ConsoleMessage { + cw.mu.RLock() + defer cw.mu.RUnlock() + + result := make([]ConsoleMessage, 0) + for _, msg := range cw.messages { + if msg.Type == "warning" { + result = append(result, msg) + } + } + return result +} + +// Clear clears all captured messages. +func (cw *ConsoleWatcher) Clear() { + cw.mu.Lock() + defer cw.mu.Unlock() + cw.messages = cw.messages[:0] +} + +// WaitForMessage waits for a message matching the filter. +func (cw *ConsoleWatcher) WaitForMessage(ctx context.Context, filter ConsoleFilter) (*ConsoleMessage, error) { + // First check existing messages + cw.mu.RLock() + for _, msg := range cw.messages { + if cw.matchesSingleFilter(msg, filter) { + cw.mu.RUnlock() + return &msg, nil + } + } + cw.mu.RUnlock() + + // Set up a channel for new messages + msgCh := make(chan ConsoleMessage, 1) + handler := func(msg ConsoleMessage) { + if cw.matchesSingleFilter(msg, filter) { + select { + case msgCh <- msg: + default: + } + } + } + + cw.AddHandler(handler) + defer func() { + cw.mu.Lock() + // Remove handler (simple implementation - in production you'd want a handle-based removal) + cw.handlers = cw.handlers[:len(cw.handlers)-1] + cw.mu.Unlock() + }() + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case msg := <-msgCh: + return &msg, nil + } +} + +// WaitForError waits for an error message. +func (cw *ConsoleWatcher) WaitForError(ctx context.Context) (*ConsoleMessage, error) { + return cw.WaitForMessage(ctx, ConsoleFilter{Type: "error"}) +} + +// HasErrors returns true if there are any error messages. +func (cw *ConsoleWatcher) HasErrors() bool { + cw.mu.RLock() + defer cw.mu.RUnlock() + + for _, msg := range cw.messages { + if msg.Type == "error" { + return true + } + } + return false +} + +// Count returns the number of captured messages. +func (cw *ConsoleWatcher) Count() int { + cw.mu.RLock() + defer cw.mu.RUnlock() + return len(cw.messages) +} + +// ErrorCount returns the number of error messages. +func (cw *ConsoleWatcher) ErrorCount() int { + cw.mu.RLock() + defer cw.mu.RUnlock() + + count := 0 + for _, msg := range cw.messages { + if msg.Type == "error" { + count++ + } + } + return count +} + +// handleConsoleEvent processes incoming console events. +func (cw *ConsoleWatcher) handleConsoleEvent(params map[string]any) { + msgType, _ := params["type"].(string) + + // Extract args + args, _ := params["args"].([]any) + var text string + for i, arg := range args { + if argMap, ok := arg.(map[string]any); ok { + if val, ok := argMap["value"]; ok { + if i > 0 { + text += " " + } + text += fmt.Sprint(val) + } + } + } + + // Extract stack trace info + stackTrace, _ := params["stackTrace"].(map[string]any) + var url string + var line, column int + if callFrames, ok := stackTrace["callFrames"].([]any); ok && len(callFrames) > 0 { + if frame, ok := callFrames[0].(map[string]any); ok { + url, _ = frame["url"].(string) + lineFloat, _ := frame["lineNumber"].(float64) + colFloat, _ := frame["columnNumber"].(float64) + line = int(lineFloat) + column = int(colFloat) + } + } + + msg := ConsoleMessage{ + Type: msgType, + Text: text, + Timestamp: time.Now(), + URL: url, + Line: line, + Column: column, + } + + cw.addMessage(msg) +} + +// addMessage adds a message to the store and notifies handlers. +func (cw *ConsoleWatcher) addMessage(msg ConsoleMessage) { + cw.mu.Lock() + + // Enforce limit + if len(cw.messages) >= cw.limit { + cw.messages = cw.messages[len(cw.messages)-cw.limit+100:] + } + cw.messages = append(cw.messages, msg) + + // Copy handlers to call outside lock + handlers := make([]ConsoleHandler, len(cw.handlers)) + copy(handlers, cw.handlers) + cw.mu.Unlock() + + // Call handlers + for _, handler := range handlers { + handler(msg) + } +} + +// matchesFilter checks if a message matches any filter. +func (cw *ConsoleWatcher) matchesFilter(msg ConsoleMessage) bool { + if len(cw.filters) == 0 { + return true + } + for _, filter := range cw.filters { + if cw.matchesSingleFilter(msg, filter) { + return true + } + } + return false +} + +// matchesSingleFilter checks if a message matches a specific filter. +func (cw *ConsoleWatcher) matchesSingleFilter(msg ConsoleMessage, filter ConsoleFilter) bool { + if filter.Type != "" && msg.Type != filter.Type { + return false + } + if filter.Pattern != "" { + // Simple substring match + if !containsString(msg.Text, filter.Pattern) { + return false + } + } + return true +} + +// containsString checks if s contains substr (case-sensitive). +func containsString(s, substr string) bool { + return len(substr) == 0 || (len(s) >= len(substr) && findString(s, substr) >= 0) +} + +// findString finds substr in s, returns -1 if not found. +func findString(s, substr string) int { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} + +// ExceptionInfo represents information about a JavaScript exception. +type ExceptionInfo struct { + Text string `json:"text"` + LineNumber int `json:"lineNumber"` + ColumnNumber int `json:"columnNumber"` + URL string `json:"url"` + StackTrace string `json:"stackTrace"` + Timestamp time.Time `json:"timestamp"` +} + +// ExceptionWatcher watches for JavaScript exceptions. +type ExceptionWatcher struct { + mu sync.RWMutex + wv *Webview + exceptions []ExceptionInfo + handlers []func(ExceptionInfo) +} + +// NewExceptionWatcher creates a new exception watcher. +func NewExceptionWatcher(wv *Webview) *ExceptionWatcher { + ew := &ExceptionWatcher{ + wv: wv, + exceptions: make([]ExceptionInfo, 0), + handlers: make([]func(ExceptionInfo), 0), + } + + // Subscribe to exception events + wv.client.OnEvent("Runtime.exceptionThrown", func(params map[string]any) { + ew.handleException(params) + }) + + return ew +} + +// Exceptions returns all captured exceptions. +func (ew *ExceptionWatcher) Exceptions() []ExceptionInfo { + ew.mu.RLock() + defer ew.mu.RUnlock() + + result := make([]ExceptionInfo, len(ew.exceptions)) + copy(result, ew.exceptions) + return result +} + +// Clear clears all captured exceptions. +func (ew *ExceptionWatcher) Clear() { + ew.mu.Lock() + defer ew.mu.Unlock() + ew.exceptions = ew.exceptions[:0] +} + +// HasExceptions returns true if there are any exceptions. +func (ew *ExceptionWatcher) HasExceptions() bool { + ew.mu.RLock() + defer ew.mu.RUnlock() + return len(ew.exceptions) > 0 +} + +// Count returns the number of exceptions. +func (ew *ExceptionWatcher) Count() int { + ew.mu.RLock() + defer ew.mu.RUnlock() + return len(ew.exceptions) +} + +// AddHandler adds a handler for exceptions. +func (ew *ExceptionWatcher) AddHandler(handler func(ExceptionInfo)) { + ew.mu.Lock() + defer ew.mu.Unlock() + ew.handlers = append(ew.handlers, handler) +} + +// WaitForException waits for an exception to be thrown. +func (ew *ExceptionWatcher) WaitForException(ctx context.Context) (*ExceptionInfo, error) { + // Check existing exceptions first + ew.mu.RLock() + if len(ew.exceptions) > 0 { + exc := ew.exceptions[len(ew.exceptions)-1] + ew.mu.RUnlock() + return &exc, nil + } + ew.mu.RUnlock() + + // Set up a channel for new exceptions + excCh := make(chan ExceptionInfo, 1) + handler := func(exc ExceptionInfo) { + select { + case excCh <- exc: + default: + } + } + + ew.AddHandler(handler) + defer func() { + ew.mu.Lock() + ew.handlers = ew.handlers[:len(ew.handlers)-1] + ew.mu.Unlock() + }() + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case exc := <-excCh: + return &exc, nil + } +} + +// handleException processes exception events. +func (ew *ExceptionWatcher) handleException(params map[string]any) { + exceptionDetails, ok := params["exceptionDetails"].(map[string]any) + if !ok { + return + } + + text, _ := exceptionDetails["text"].(string) + lineNum, _ := exceptionDetails["lineNumber"].(float64) + colNum, _ := exceptionDetails["columnNumber"].(float64) + url, _ := exceptionDetails["url"].(string) + + // Extract stack trace + var stackTrace string + if st, ok := exceptionDetails["stackTrace"].(map[string]any); ok { + if frames, ok := st["callFrames"].([]any); ok { + for _, f := range frames { + if frame, ok := f.(map[string]any); ok { + funcName, _ := frame["functionName"].(string) + frameURL, _ := frame["url"].(string) + frameLine, _ := frame["lineNumber"].(float64) + frameCol, _ := frame["columnNumber"].(float64) + stackTrace += fmt.Sprintf(" at %s (%s:%d:%d)\n", funcName, frameURL, int(frameLine), int(frameCol)) + } + } + } + } + + // Try to get exception value description + if exc, ok := exceptionDetails["exception"].(map[string]any); ok { + if desc, ok := exc["description"].(string); ok && desc != "" { + text = desc + } + } + + info := ExceptionInfo{ + Text: text, + LineNumber: int(lineNum), + ColumnNumber: int(colNum), + URL: url, + StackTrace: stackTrace, + Timestamp: time.Now(), + } + + ew.mu.Lock() + ew.exceptions = append(ew.exceptions, info) + handlers := make([]func(ExceptionInfo), len(ew.handlers)) + copy(handlers, ew.handlers) + ew.mu.Unlock() + + // Call handlers + for _, handler := range handlers { + handler(info) + } +} + +// FormatConsoleOutput formats console messages for display. +func FormatConsoleOutput(messages []ConsoleMessage) string { + var output string + for _, msg := range messages { + prefix := "" + switch msg.Type { + case "error": + prefix = "[ERROR]" + case "warning": + prefix = "[WARN]" + case "info": + prefix = "[INFO]" + case "debug": + prefix = "[DEBUG]" + default: + prefix = "[LOG]" + } + timestamp := msg.Timestamp.Format("15:04:05.000") + output += fmt.Sprintf("%s %s %s\n", timestamp, prefix, msg.Text) + } + return output +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..64df53b --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module forge.lthn.ai/core/go-webview + +go 1.25.5 diff --git a/webview.go b/webview.go new file mode 100644 index 0000000..d18bf6e --- /dev/null +++ b/webview.go @@ -0,0 +1,733 @@ +// Package webview provides browser automation via Chrome DevTools Protocol (CDP). +// +// The package allows controlling Chrome/Chromium browsers for automated testing, +// web scraping, and GUI automation. Start Chrome with --remote-debugging-port=9222 +// to enable the DevTools protocol. +// +// Example usage: +// +// wv, err := webview.New(webview.WithDebugURL("http://localhost:9222")) +// if err != nil { +// log.Fatal(err) +// } +// defer wv.Close() +// +// if err := wv.Navigate("https://example.com"); err != nil { +// log.Fatal(err) +// } +// +// if err := wv.Click("#submit-button"); err != nil { +// log.Fatal(err) +// } +package webview + +import ( + "context" + "encoding/base64" + "fmt" + "sync" + "time" +) + +// Webview represents a connection to a Chrome DevTools Protocol endpoint. +type Webview struct { + mu sync.RWMutex + client *CDPClient + ctx context.Context + cancel context.CancelFunc + timeout time.Duration + consoleLogs []ConsoleMessage + consoleLimit int +} + +// ConsoleMessage represents a captured console log message. +type ConsoleMessage struct { + Type string `json:"type"` // log, warn, error, info, debug + Text string `json:"text"` // Message text + Timestamp time.Time `json:"timestamp"` // When the message was logged + URL string `json:"url"` // Source URL + Line int `json:"line"` // Source line number + Column int `json:"column"` // Source column number +} + +// ElementInfo represents information about a DOM element. +type ElementInfo struct { + NodeID int `json:"nodeId"` + TagName string `json:"tagName"` + Attributes map[string]string `json:"attributes"` + InnerHTML string `json:"innerHTML,omitempty"` + InnerText string `json:"innerText,omitempty"` + BoundingBox *BoundingBox `json:"boundingBox,omitempty"` +} + +// BoundingBox represents the bounding rectangle of an element. +type BoundingBox struct { + X float64 `json:"x"` + Y float64 `json:"y"` + Width float64 `json:"width"` + Height float64 `json:"height"` +} + +// Option configures a Webview instance. +type Option func(*Webview) error + +// WithDebugURL sets the Chrome DevTools debugging URL. +// Example: http://localhost:9222 +func WithDebugURL(url string) Option { + return func(wv *Webview) error { + client, err := NewCDPClient(url) + if err != nil { + return fmt.Errorf("failed to connect to Chrome DevTools: %w", err) + } + wv.client = client + return nil + } +} + +// WithTimeout sets the default timeout for operations. +func WithTimeout(d time.Duration) Option { + return func(wv *Webview) error { + wv.timeout = d + return nil + } +} + +// WithConsoleLimit sets the maximum number of console messages to retain. +// Default is 1000. +func WithConsoleLimit(limit int) Option { + return func(wv *Webview) error { + wv.consoleLimit = limit + return nil + } +} + +// New creates a new Webview instance with the given options. +func New(opts ...Option) (*Webview, error) { + ctx, cancel := context.WithCancel(context.Background()) + + wv := &Webview{ + ctx: ctx, + cancel: cancel, + timeout: 30 * time.Second, + consoleLogs: make([]ConsoleMessage, 0, 100), + consoleLimit: 1000, + } + + for _, opt := range opts { + if err := opt(wv); err != nil { + cancel() + return nil, err + } + } + + if wv.client == nil { + cancel() + return nil, fmt.Errorf("no debug URL provided; use WithDebugURL option") + } + + // Enable console capture + if err := wv.enableConsole(); err != nil { + cancel() + return nil, fmt.Errorf("failed to enable console capture: %w", err) + } + + return wv, nil +} + +// Close closes the Webview connection. +func (wv *Webview) Close() error { + wv.cancel() + if wv.client != nil { + return wv.client.Close() + } + return nil +} + +// Navigate navigates to the specified URL. +func (wv *Webview) Navigate(url string) error { + ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout) + defer cancel() + + _, err := wv.client.Call(ctx, "Page.navigate", map[string]any{ + "url": url, + }) + if err != nil { + return fmt.Errorf("failed to navigate: %w", err) + } + + // Wait for page load + return wv.waitForLoad(ctx) +} + +// Click clicks on an element matching the selector. +func (wv *Webview) Click(selector string) error { + ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout) + defer cancel() + + return wv.click(ctx, selector) +} + +// Type types text into an element matching the selector. +func (wv *Webview) Type(selector, text string) error { + ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout) + defer cancel() + + return wv.typeText(ctx, selector, text) +} + +// QuerySelector finds an element by CSS selector and returns its information. +func (wv *Webview) QuerySelector(selector string) (*ElementInfo, error) { + ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout) + defer cancel() + + return wv.querySelector(ctx, selector) +} + +// QuerySelectorAll finds all elements matching the selector. +func (wv *Webview) QuerySelectorAll(selector string) ([]*ElementInfo, error) { + ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout) + defer cancel() + + return wv.querySelectorAll(ctx, selector) +} + +// GetConsole returns captured console messages. +func (wv *Webview) GetConsole() []ConsoleMessage { + wv.mu.RLock() + defer wv.mu.RUnlock() + + result := make([]ConsoleMessage, len(wv.consoleLogs)) + copy(result, wv.consoleLogs) + return result +} + +// ClearConsole clears captured console messages. +func (wv *Webview) ClearConsole() { + wv.mu.Lock() + defer wv.mu.Unlock() + wv.consoleLogs = wv.consoleLogs[:0] +} + +// Screenshot captures a screenshot and returns it as PNG bytes. +func (wv *Webview) Screenshot() ([]byte, error) { + ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout) + defer cancel() + + result, err := wv.client.Call(ctx, "Page.captureScreenshot", map[string]any{ + "format": "png", + }) + if err != nil { + return nil, fmt.Errorf("failed to capture screenshot: %w", err) + } + + dataStr, ok := result["data"].(string) + if !ok { + return nil, fmt.Errorf("invalid screenshot data") + } + + data, err := base64.StdEncoding.DecodeString(dataStr) + if err != nil { + return nil, fmt.Errorf("failed to decode screenshot: %w", err) + } + + return data, nil +} + +// Evaluate executes JavaScript and returns the result. +// Note: This intentionally executes arbitrary JavaScript in the browser context +// for browser automation purposes. The script runs in the sandboxed browser environment. +func (wv *Webview) Evaluate(script string) (any, error) { + ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout) + defer cancel() + + return wv.evaluate(ctx, script) +} + +// WaitForSelector waits for an element matching the selector to appear. +func (wv *Webview) WaitForSelector(selector string) error { + ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout) + defer cancel() + + return wv.waitForSelector(ctx, selector) +} + +// GetURL returns the current page URL. +func (wv *Webview) GetURL() (string, error) { + ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout) + defer cancel() + + result, err := wv.evaluate(ctx, "window.location.href") + if err != nil { + return "", err + } + + url, ok := result.(string) + if !ok { + return "", fmt.Errorf("invalid URL result") + } + + return url, nil +} + +// GetTitle returns the current page title. +func (wv *Webview) GetTitle() (string, error) { + ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout) + defer cancel() + + result, err := wv.evaluate(ctx, "document.title") + if err != nil { + return "", err + } + + title, ok := result.(string) + if !ok { + return "", fmt.Errorf("invalid title result") + } + + return title, nil +} + +// GetHTML returns the outer HTML of an element or the whole document. +func (wv *Webview) GetHTML(selector string) (string, error) { + ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout) + defer cancel() + + var script string + if selector == "" { + script = "document.documentElement.outerHTML" + } else { + script = fmt.Sprintf("document.querySelector(%q)?.outerHTML || ''", selector) + } + + result, err := wv.evaluate(ctx, script) + if err != nil { + return "", err + } + + html, ok := result.(string) + if !ok { + return "", fmt.Errorf("invalid HTML result") + } + + return html, nil +} + +// SetViewport sets the viewport size. +func (wv *Webview) SetViewport(width, height int) error { + ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout) + defer cancel() + + _, err := wv.client.Call(ctx, "Emulation.setDeviceMetricsOverride", map[string]any{ + "width": width, + "height": height, + "deviceScaleFactor": 1, + "mobile": false, + }) + return err +} + +// SetUserAgent sets the user agent string. +func (wv *Webview) SetUserAgent(userAgent string) error { + ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout) + defer cancel() + + _, err := wv.client.Call(ctx, "Emulation.setUserAgentOverride", map[string]any{ + "userAgent": userAgent, + }) + return err +} + +// Reload reloads the current page. +func (wv *Webview) Reload() error { + ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout) + defer cancel() + + _, err := wv.client.Call(ctx, "Page.reload", nil) + if err != nil { + return fmt.Errorf("failed to reload: %w", err) + } + + return wv.waitForLoad(ctx) +} + +// GoBack navigates back in history. +func (wv *Webview) GoBack() error { + ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout) + defer cancel() + + _, err := wv.client.Call(ctx, "Page.goBackOrForward", map[string]any{ + "delta": -1, + }) + return err +} + +// GoForward navigates forward in history. +func (wv *Webview) GoForward() error { + ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout) + defer cancel() + + _, err := wv.client.Call(ctx, "Page.goBackOrForward", map[string]any{ + "delta": 1, + }) + return err +} + +// addConsoleMessage adds a console message to the log. +func (wv *Webview) addConsoleMessage(msg ConsoleMessage) { + wv.mu.Lock() + defer wv.mu.Unlock() + + if len(wv.consoleLogs) >= wv.consoleLimit { + // Remove oldest messages + wv.consoleLogs = wv.consoleLogs[len(wv.consoleLogs)-wv.consoleLimit+100:] + } + wv.consoleLogs = append(wv.consoleLogs, msg) +} + +// enableConsole enables console message capture. +func (wv *Webview) enableConsole() error { + ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout) + defer cancel() + + // Enable Runtime domain for console events + _, err := wv.client.Call(ctx, "Runtime.enable", nil) + if err != nil { + return err + } + + // Enable Page domain for navigation events + _, err = wv.client.Call(ctx, "Page.enable", nil) + if err != nil { + return err + } + + // Enable DOM domain + _, err = wv.client.Call(ctx, "DOM.enable", nil) + if err != nil { + return err + } + + // Subscribe to console events + wv.client.OnEvent("Runtime.consoleAPICalled", func(params map[string]any) { + wv.handleConsoleEvent(params) + }) + + return nil +} + +// handleConsoleEvent processes console API events. +func (wv *Webview) handleConsoleEvent(params map[string]any) { + msgType, _ := params["type"].(string) + + // Extract args + args, _ := params["args"].([]any) + var text string + for i, arg := range args { + if argMap, ok := arg.(map[string]any); ok { + if val, ok := argMap["value"]; ok { + if i > 0 { + text += " " + } + text += fmt.Sprint(val) + } + } + } + + // Extract stack trace info + stackTrace, _ := params["stackTrace"].(map[string]any) + var url string + var line, column int + if callFrames, ok := stackTrace["callFrames"].([]any); ok && len(callFrames) > 0 { + if frame, ok := callFrames[0].(map[string]any); ok { + url, _ = frame["url"].(string) + lineFloat, _ := frame["lineNumber"].(float64) + colFloat, _ := frame["columnNumber"].(float64) + line = int(lineFloat) + column = int(colFloat) + } + } + + wv.addConsoleMessage(ConsoleMessage{ + Type: msgType, + Text: text, + Timestamp: time.Now(), + URL: url, + Line: line, + Column: column, + }) +} + +// waitForLoad waits for the page to finish loading. +func (wv *Webview) waitForLoad(ctx context.Context) error { + // Use Page.loadEventFired event or poll document.readyState + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + result, err := wv.evaluate(ctx, "document.readyState") + if err != nil { + continue + } + if state, ok := result.(string); ok && state == "complete" { + return nil + } + } + } +} + +// waitForSelector waits for an element to appear. +func (wv *Webview) waitForSelector(ctx context.Context, selector string) error { + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + script := fmt.Sprintf("!!document.querySelector(%q)", selector) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + result, err := wv.evaluate(ctx, script) + if err != nil { + continue + } + if found, ok := result.(bool); ok && found { + return nil + } + } + } +} + +// evaluate evaluates JavaScript in the page context via CDP Runtime.evaluate. +// This is the core method for executing JavaScript in the browser. +func (wv *Webview) evaluate(ctx context.Context, script string) (any, error) { + result, err := wv.client.Call(ctx, "Runtime.evaluate", map[string]any{ + "expression": script, + "returnByValue": true, + }) + if err != nil { + return nil, fmt.Errorf("failed to evaluate script: %w", err) + } + + // Check for exception + if exceptionDetails, ok := result["exceptionDetails"].(map[string]any); ok { + if exception, ok := exceptionDetails["exception"].(map[string]any); ok { + if description, ok := exception["description"].(string); ok { + return nil, fmt.Errorf("JavaScript error: %s", description) + } + } + return nil, fmt.Errorf("JavaScript error") + } + + // Extract result value + if resultObj, ok := result["result"].(map[string]any); ok { + return resultObj["value"], nil + } + + return nil, nil +} + +// querySelector finds an element by selector. +func (wv *Webview) querySelector(ctx context.Context, selector string) (*ElementInfo, error) { + // Get document root + docResult, err := wv.client.Call(ctx, "DOM.getDocument", nil) + if err != nil { + return nil, fmt.Errorf("failed to get document: %w", err) + } + + root, ok := docResult["root"].(map[string]any) + if !ok { + return nil, fmt.Errorf("invalid document root") + } + + rootID, ok := root["nodeId"].(float64) + if !ok { + return nil, fmt.Errorf("invalid root node ID") + } + + // Query selector + queryResult, err := wv.client.Call(ctx, "DOM.querySelector", map[string]any{ + "nodeId": int(rootID), + "selector": selector, + }) + if err != nil { + return nil, fmt.Errorf("failed to query selector: %w", err) + } + + nodeID, ok := queryResult["nodeId"].(float64) + if !ok || nodeID == 0 { + return nil, fmt.Errorf("element not found: %s", selector) + } + + return wv.getElementInfo(ctx, int(nodeID)) +} + +// querySelectorAll finds all elements matching the selector. +func (wv *Webview) querySelectorAll(ctx context.Context, selector string) ([]*ElementInfo, error) { + // Get document root + docResult, err := wv.client.Call(ctx, "DOM.getDocument", nil) + if err != nil { + return nil, fmt.Errorf("failed to get document: %w", err) + } + + root, ok := docResult["root"].(map[string]any) + if !ok { + return nil, fmt.Errorf("invalid document root") + } + + rootID, ok := root["nodeId"].(float64) + if !ok { + return nil, fmt.Errorf("invalid root node ID") + } + + // Query selector all + queryResult, err := wv.client.Call(ctx, "DOM.querySelectorAll", map[string]any{ + "nodeId": int(rootID), + "selector": selector, + }) + if err != nil { + return nil, fmt.Errorf("failed to query selector all: %w", err) + } + + nodeIDs, ok := queryResult["nodeIds"].([]any) + if !ok { + return nil, fmt.Errorf("invalid node IDs") + } + + elements := make([]*ElementInfo, 0, len(nodeIDs)) + for _, id := range nodeIDs { + if nodeID, ok := id.(float64); ok { + if elem, err := wv.getElementInfo(ctx, int(nodeID)); err == nil { + elements = append(elements, elem) + } + } + } + + return elements, nil +} + +// getElementInfo retrieves information about a DOM node. +func (wv *Webview) getElementInfo(ctx context.Context, nodeID int) (*ElementInfo, error) { + // Describe node to get attributes + descResult, err := wv.client.Call(ctx, "DOM.describeNode", map[string]any{ + "nodeId": nodeID, + }) + if err != nil { + return nil, err + } + + node, ok := descResult["node"].(map[string]any) + if !ok { + return nil, fmt.Errorf("invalid node description") + } + + tagName, _ := node["nodeName"].(string) + + // Parse attributes + attrs := make(map[string]string) + if attrList, ok := node["attributes"].([]any); ok { + for i := 0; i < len(attrList)-1; i += 2 { + key, _ := attrList[i].(string) + val, _ := attrList[i+1].(string) + attrs[key] = val + } + } + + // Get bounding box + var box *BoundingBox + if boxResult, err := wv.client.Call(ctx, "DOM.getBoxModel", map[string]any{ + "nodeId": nodeID, + }); err == nil { + if model, ok := boxResult["model"].(map[string]any); ok { + if content, ok := model["content"].([]any); ok && len(content) >= 8 { + x, _ := content[0].(float64) + y, _ := content[1].(float64) + x2, _ := content[2].(float64) + y2, _ := content[5].(float64) + box = &BoundingBox{ + X: x, + Y: y, + Width: x2 - x, + Height: y2 - y, + } + } + } + } + + return &ElementInfo{ + NodeID: nodeID, + TagName: tagName, + Attributes: attrs, + BoundingBox: box, + }, nil +} + +// click performs a click on an element. +func (wv *Webview) click(ctx context.Context, selector string) error { + // Find element and get its center coordinates + elem, err := wv.querySelector(ctx, selector) + if err != nil { + return err + } + + if elem.BoundingBox == nil { + // Fallback to JavaScript click + script := fmt.Sprintf("document.querySelector(%q)?.click()", selector) + _, err := wv.evaluate(ctx, script) + return err + } + + // Calculate center point + x := elem.BoundingBox.X + elem.BoundingBox.Width/2 + y := elem.BoundingBox.Y + elem.BoundingBox.Height/2 + + // Dispatch mouse events + for _, eventType := range []string{"mousePressed", "mouseReleased"} { + _, err := wv.client.Call(ctx, "Input.dispatchMouseEvent", map[string]any{ + "type": eventType, + "x": x, + "y": y, + "button": "left", + "clickCount": 1, + }) + if err != nil { + return fmt.Errorf("failed to dispatch %s: %w", eventType, err) + } + } + + return nil +} + +// typeText types text into an element. +func (wv *Webview) typeText(ctx context.Context, selector, text string) error { + // Focus the element first + script := fmt.Sprintf("document.querySelector(%q)?.focus()", selector) + _, err := wv.evaluate(ctx, script) + if err != nil { + return fmt.Errorf("failed to focus element: %w", err) + } + + // Type each character + for _, char := range text { + _, err := wv.client.Call(ctx, "Input.dispatchKeyEvent", map[string]any{ + "type": "keyDown", + "text": string(char), + }) + if err != nil { + return fmt.Errorf("failed to dispatch keyDown: %w", err) + } + + _, err = wv.client.Call(ctx, "Input.dispatchKeyEvent", map[string]any{ + "type": "keyUp", + }) + if err != nil { + return fmt.Errorf("failed to dispatch keyUp: %w", err) + } + } + + return nil +} diff --git a/webview_test.go b/webview_test.go new file mode 100644 index 0000000..df3ae61 --- /dev/null +++ b/webview_test.go @@ -0,0 +1,335 @@ +package webview + +import ( + "testing" + "time" +) + +// TestConsoleMessage_Good verifies the ConsoleMessage struct has expected fields. +func TestConsoleMessage_Good(t *testing.T) { + msg := ConsoleMessage{ + Type: "error", + Text: "Test error message", + Timestamp: time.Now(), + URL: "https://example.com/script.js", + Line: 42, + Column: 10, + } + + if msg.Type != "error" { + t.Errorf("Expected type 'error', got %q", msg.Type) + } + if msg.Text != "Test error message" { + t.Errorf("Expected text 'Test error message', got %q", msg.Text) + } + if msg.Line != 42 { + t.Errorf("Expected line 42, got %d", msg.Line) + } +} + +// TestElementInfo_Good verifies the ElementInfo struct has expected fields. +func TestElementInfo_Good(t *testing.T) { + elem := ElementInfo{ + NodeID: 123, + TagName: "DIV", + Attributes: map[string]string{ + "id": "container", + "class": "main-content", + }, + InnerHTML: "Hello", + InnerText: "Hello", + BoundingBox: &BoundingBox{ + X: 100, + Y: 200, + Width: 300, + Height: 400, + }, + } + + if elem.NodeID != 123 { + t.Errorf("Expected nodeId 123, got %d", elem.NodeID) + } + if elem.TagName != "DIV" { + t.Errorf("Expected tagName 'DIV', got %q", elem.TagName) + } + if elem.Attributes["id"] != "container" { + t.Errorf("Expected id 'container', got %q", elem.Attributes["id"]) + } + if elem.BoundingBox == nil { + t.Fatal("Expected bounding box to be set") + } + if elem.BoundingBox.Width != 300 { + t.Errorf("Expected width 300, got %f", elem.BoundingBox.Width) + } +} + +// TestBoundingBox_Good verifies the BoundingBox struct has expected fields. +func TestBoundingBox_Good(t *testing.T) { + box := BoundingBox{ + X: 10.5, + Y: 20.5, + Width: 100.0, + Height: 50.0, + } + + if box.X != 10.5 { + t.Errorf("Expected X 10.5, got %f", box.X) + } + if box.Y != 20.5 { + t.Errorf("Expected Y 20.5, got %f", box.Y) + } + if box.Width != 100.0 { + t.Errorf("Expected width 100.0, got %f", box.Width) + } + if box.Height != 50.0 { + t.Errorf("Expected height 50.0, got %f", box.Height) + } +} + +// TestWithTimeout_Good verifies the WithTimeout option sets timeout correctly. +func TestWithTimeout_Good(t *testing.T) { + // We can't fully test without a real Chrome connection, + // but we can verify the option function works + wv := &Webview{} + opt := WithTimeout(60 * time.Second) + + err := opt(wv) + if err != nil { + t.Fatalf("WithTimeout returned error: %v", err) + } + + if wv.timeout != 60*time.Second { + t.Errorf("Expected timeout 60s, got %v", wv.timeout) + } +} + +// TestWithConsoleLimit_Good verifies the WithConsoleLimit option sets limit correctly. +func TestWithConsoleLimit_Good(t *testing.T) { + wv := &Webview{} + opt := WithConsoleLimit(500) + + err := opt(wv) + if err != nil { + t.Fatalf("WithConsoleLimit returned error: %v", err) + } + + if wv.consoleLimit != 500 { + t.Errorf("Expected consoleLimit 500, got %d", wv.consoleLimit) + } +} + +// TestNew_Bad_NoDebugURL verifies New fails without a debug URL. +func TestNew_Bad_NoDebugURL(t *testing.T) { + _, err := New() + if err == nil { + t.Error("Expected error when creating Webview without debug URL") + } +} + +// TestNew_Bad_InvalidDebugURL verifies New fails with invalid debug URL. +func TestNew_Bad_InvalidDebugURL(t *testing.T) { + _, err := New(WithDebugURL("http://localhost:99999")) + if err == nil { + t.Error("Expected error when connecting to invalid debug URL") + } +} + +// TestActionSequence_Good verifies action sequence building works. +func TestActionSequence_Good(t *testing.T) { + seq := NewActionSequence(). + Navigate("https://example.com"). + WaitForSelector("#main"). + Click("#button"). + Type("#input", "hello"). + Wait(100 * time.Millisecond) + + if len(seq.actions) != 5 { + t.Errorf("Expected 5 actions, got %d", len(seq.actions)) + } +} + +// TestClickAction_Good verifies ClickAction struct. +func TestClickAction_Good(t *testing.T) { + action := ClickAction{Selector: "#submit"} + if action.Selector != "#submit" { + t.Errorf("Expected selector '#submit', got %q", action.Selector) + } +} + +// TestTypeAction_Good verifies TypeAction struct. +func TestTypeAction_Good(t *testing.T) { + action := TypeAction{Selector: "#email", Text: "test@example.com"} + if action.Selector != "#email" { + t.Errorf("Expected selector '#email', got %q", action.Selector) + } + if action.Text != "test@example.com" { + t.Errorf("Expected text 'test@example.com', got %q", action.Text) + } +} + +// TestNavigateAction_Good verifies NavigateAction struct. +func TestNavigateAction_Good(t *testing.T) { + action := NavigateAction{URL: "https://example.com"} + if action.URL != "https://example.com" { + t.Errorf("Expected URL 'https://example.com', got %q", action.URL) + } +} + +// TestWaitAction_Good verifies WaitAction struct. +func TestWaitAction_Good(t *testing.T) { + action := WaitAction{Duration: 5 * time.Second} + if action.Duration != 5*time.Second { + t.Errorf("Expected duration 5s, got %v", action.Duration) + } +} + +// TestWaitForSelectorAction_Good verifies WaitForSelectorAction struct. +func TestWaitForSelectorAction_Good(t *testing.T) { + action := WaitForSelectorAction{Selector: ".loading"} + if action.Selector != ".loading" { + t.Errorf("Expected selector '.loading', got %q", action.Selector) + } +} + +// TestScrollAction_Good verifies ScrollAction struct. +func TestScrollAction_Good(t *testing.T) { + action := ScrollAction{X: 0, Y: 500} + if action.X != 0 { + t.Errorf("Expected X 0, got %d", action.X) + } + if action.Y != 500 { + t.Errorf("Expected Y 500, got %d", action.Y) + } +} + +// TestFocusAction_Good verifies FocusAction struct. +func TestFocusAction_Good(t *testing.T) { + action := FocusAction{Selector: "#input"} + if action.Selector != "#input" { + t.Errorf("Expected selector '#input', got %q", action.Selector) + } +} + +// TestBlurAction_Good verifies BlurAction struct. +func TestBlurAction_Good(t *testing.T) { + action := BlurAction{Selector: "#input"} + if action.Selector != "#input" { + t.Errorf("Expected selector '#input', got %q", action.Selector) + } +} + +// TestClearAction_Good verifies ClearAction struct. +func TestClearAction_Good(t *testing.T) { + action := ClearAction{Selector: "#input"} + if action.Selector != "#input" { + t.Errorf("Expected selector '#input', got %q", action.Selector) + } +} + +// TestSelectAction_Good verifies SelectAction struct. +func TestSelectAction_Good(t *testing.T) { + action := SelectAction{Selector: "#dropdown", Value: "option1"} + if action.Selector != "#dropdown" { + t.Errorf("Expected selector '#dropdown', got %q", action.Selector) + } + if action.Value != "option1" { + t.Errorf("Expected value 'option1', got %q", action.Value) + } +} + +// TestCheckAction_Good verifies CheckAction struct. +func TestCheckAction_Good(t *testing.T) { + action := CheckAction{Selector: "#checkbox", Checked: true} + if action.Selector != "#checkbox" { + t.Errorf("Expected selector '#checkbox', got %q", action.Selector) + } + if !action.Checked { + t.Error("Expected checked to be true") + } +} + +// TestHoverAction_Good verifies HoverAction struct. +func TestHoverAction_Good(t *testing.T) { + action := HoverAction{Selector: "#menu-item"} + if action.Selector != "#menu-item" { + t.Errorf("Expected selector '#menu-item', got %q", action.Selector) + } +} + +// TestDoubleClickAction_Good verifies DoubleClickAction struct. +func TestDoubleClickAction_Good(t *testing.T) { + action := DoubleClickAction{Selector: "#editable"} + if action.Selector != "#editable" { + t.Errorf("Expected selector '#editable', got %q", action.Selector) + } +} + +// TestRightClickAction_Good verifies RightClickAction struct. +func TestRightClickAction_Good(t *testing.T) { + action := RightClickAction{Selector: "#context-menu-trigger"} + if action.Selector != "#context-menu-trigger" { + t.Errorf("Expected selector '#context-menu-trigger', got %q", action.Selector) + } +} + +// TestPressKeyAction_Good verifies PressKeyAction struct. +func TestPressKeyAction_Good(t *testing.T) { + action := PressKeyAction{Key: "Enter"} + if action.Key != "Enter" { + t.Errorf("Expected key 'Enter', got %q", action.Key) + } +} + +// TestSetAttributeAction_Good verifies SetAttributeAction struct. +func TestSetAttributeAction_Good(t *testing.T) { + action := SetAttributeAction{ + Selector: "#element", + Attribute: "data-value", + Value: "test", + } + if action.Selector != "#element" { + t.Errorf("Expected selector '#element', got %q", action.Selector) + } + if action.Attribute != "data-value" { + t.Errorf("Expected attribute 'data-value', got %q", action.Attribute) + } + if action.Value != "test" { + t.Errorf("Expected value 'test', got %q", action.Value) + } +} + +// TestRemoveAttributeAction_Good verifies RemoveAttributeAction struct. +func TestRemoveAttributeAction_Good(t *testing.T) { + action := RemoveAttributeAction{ + Selector: "#element", + Attribute: "disabled", + } + if action.Selector != "#element" { + t.Errorf("Expected selector '#element', got %q", action.Selector) + } + if action.Attribute != "disabled" { + t.Errorf("Expected attribute 'disabled', got %q", action.Attribute) + } +} + +// TestSetValueAction_Good verifies SetValueAction struct. +func TestSetValueAction_Good(t *testing.T) { + action := SetValueAction{ + Selector: "#input", + Value: "new value", + } + if action.Selector != "#input" { + t.Errorf("Expected selector '#input', got %q", action.Selector) + } + if action.Value != "new value" { + t.Errorf("Expected value 'new value', got %q", action.Value) + } +} + +// TestScrollIntoViewAction_Good verifies ScrollIntoViewAction struct. +func TestScrollIntoViewAction_Good(t *testing.T) { + action := ScrollIntoViewAction{Selector: "#target"} + if action.Selector != "#target" { + t.Errorf("Expected selector '#target', got %q", action.Selector) + } +}