commit 45f119b9ac0e0ebe34f5c8387a070a5b8bd2de6b Author: Snider Date: Thu Feb 19 16:09:11 2026 +0000 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 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) + } +}