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:
commit
45f119b9ac
8 changed files with 3176 additions and 0 deletions
36
CLAUDE.md
Normal file
36
CLAUDE.md
Normal 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
547
actions.go
Normal 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
626
angular.go
Normal 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
387
cdp.go
Normal 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
509
console.go
Normal 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
3
go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module forge.lthn.ai/core/go-webview
|
||||
|
||||
go 1.25.5
|
||||
733
webview.go
Normal file
733
webview.go
Normal 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
335
webview_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue