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 <virgil@lethean.io>
This commit is contained in:
Snider 2026-02-19 16:09:11 +00:00
commit 45f119b9ac
8 changed files with 3176 additions and 0 deletions

36
CLAUDE.md Normal file
View file

@ -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 <virgil@lethean.io>`

547
actions.go Normal file
View file

@ -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
}

626
angular.go Normal file
View file

@ -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)
}
}

387
cdp.go Normal file
View file

@ -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
}

509
console.go Normal file
View file

@ -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
}

3
go.mod Normal file
View file

@ -0,0 +1,3 @@
module forge.lthn.ai/core/go-webview
go 1.25.5

733
webview.go Normal file
View file

@ -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
}

335
webview_test.go Normal file
View file

@ -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: "<span>Hello</span>",
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)
}
}