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