Compare commits
47 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a2d5579e1 | ||
|
|
de13a29d79 | ||
|
|
8a116ca78d | ||
|
|
fd15f95ca9 | ||
|
|
f38ceb3bd6 | ||
|
|
40d2e584aa | ||
|
|
82eec17dfe | ||
|
|
0c6c53e189 | ||
|
|
63a3a39916 | ||
|
|
5836e1236c | ||
|
|
a67fe9e1c3 | ||
|
|
6759f42a4c | ||
|
|
4069bbe263 | ||
|
|
8a72b3ebc6 | ||
|
|
7b12244813 | ||
|
|
3df32be96f | ||
|
|
29e5d1bd37 | ||
|
|
7de347e5f7 | ||
|
|
d5d3525602 | ||
|
|
284d39de18 | ||
|
|
75ed16743a | ||
|
|
f5d14bb39d | ||
|
|
d673ce14be | ||
|
|
f6e235d83e | ||
|
|
d6fcd6a2f4 | ||
|
|
f4a094e87c | ||
|
|
3002b4801d | ||
|
|
7a4450cf9f | ||
|
|
a6304b8e29 | ||
| 6adaeaeec2 | |||
|
|
e6a7ecf4f5 | ||
|
|
b993fc18f3 | ||
| e35de439e1 | |||
|
|
950b02fd97 | ||
|
|
96f18346d9 | ||
| ab48e6c373 | |||
|
|
df0d10b880 | ||
| 674864217e | |||
|
|
dce6f0e788 | ||
| 4004fe4484 | |||
|
|
6a261bdf16 | ||
| 2725661046 | |||
|
|
dff3d576fa | ||
|
|
c6d1ccba7d | ||
|
|
2f9ff11204 | ||
| e677d877eb | |||
|
|
900cb750cf |
23 changed files with 6007 additions and 519 deletions
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
Module: `forge.lthn.ai/core/go-webview` — Chrome DevTools Protocol client for browser automation.
|
Module: `dappco.re/go/core/webview` — Chrome DevTools Protocol client for browser automation.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
|
|
@ -56,7 +56,7 @@ Key patterns:
|
||||||
- Co-author trailer on every commit: `Co-Authored-By: Virgil <virgil@lethean.io>`
|
- Co-author trailer on every commit: `Co-Authored-By: Virgil <virgil@lethean.io>`
|
||||||
- Test naming: `_Good` (happy path), `_Bad` (expected errors), `_Ugly` (panics/edge cases)
|
- Test naming: `_Good` (happy path), `_Bad` (expected errors), `_Ugly` (panics/edge cases)
|
||||||
- Standard `testing.T` only — no test frameworks
|
- Standard `testing.T` only — no test frameworks
|
||||||
- Wrap errors with `fmt.Errorf("context: %w", err)`
|
- Wrap errors with `coreerr.E("Scope.Method", "description", err)` from `go-log`, never `fmt.Errorf`
|
||||||
- Protect shared state with `sync.RWMutex`; copy handler slices before calling outside lock
|
- Protect shared state with `sync.RWMutex`; copy handler slices before calling outside lock
|
||||||
|
|
||||||
## Docs
|
## Docs
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
[](https://pkg.go.dev/forge.lthn.ai/core/go-webview)
|
[](https://pkg.go.dev/dappco.re/go/core/webview)
|
||||||
[](LICENSE.md)
|
[](LICENSE.md)
|
||||||
[](go.mod)
|
[](go.mod)
|
||||||
|
|
||||||
|
|
@ -6,14 +6,14 @@
|
||||||
|
|
||||||
Chrome DevTools Protocol (CDP) client for browser automation, testing, and scraping. Connects to an externally managed Chrome or Chromium instance running with `--remote-debugging-port=9222`, providing navigation, DOM queries, click and type actions, console capture, JavaScript evaluation, screenshots, multi-tab support, viewport emulation, and a fluent `ActionSequence` builder. Includes Angular-specific helpers for Zone.js stability, router navigation, component introspection, and ngModel access.
|
Chrome DevTools Protocol (CDP) client for browser automation, testing, and scraping. Connects to an externally managed Chrome or Chromium instance running with `--remote-debugging-port=9222`, providing navigation, DOM queries, click and type actions, console capture, JavaScript evaluation, screenshots, multi-tab support, viewport emulation, and a fluent `ActionSequence` builder. Includes Angular-specific helpers for Zone.js stability, router navigation, component introspection, and ngModel access.
|
||||||
|
|
||||||
**Module**: `forge.lthn.ai/core/go-webview`
|
**Module**: `dappco.re/go/core/webview`
|
||||||
**Licence**: EUPL-1.2
|
**Licence**: EUPL-1.2
|
||||||
**Language**: Go 1.25
|
**Language**: Go 1.25
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```go
|
```go
|
||||||
import "forge.lthn.ai/core/go-webview"
|
import "dappco.re/go/core/webview"
|
||||||
|
|
||||||
wv, err := webview.New(webview.WithDebugURL("http://localhost:9222"))
|
wv, err := webview.New(webview.WithDebugURL("http://localhost:9222"))
|
||||||
defer wv.Close()
|
defer wv.Close()
|
||||||
|
|
@ -34,6 +34,7 @@ err = webview.NewActionSequence().
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
- [API Contract](docs/api-contract.md) — exported API inventory with signatures and current test coverage
|
||||||
- [Architecture](docs/architecture.md) — CDP connection, DOM queries, console capture, Angular helpers, action system
|
- [Architecture](docs/architecture.md) — CDP connection, DOM queries, console capture, Angular helpers, action system
|
||||||
- [Development Guide](docs/development.md) — prerequisites, build, test patterns, adding actions
|
- [Development Guide](docs/development.md) — prerequisites, build, test patterns, adding actions
|
||||||
- [Project History](docs/history.md) — completed phases, known limitations, future considerations
|
- [Project History](docs/history.md) — completed phases, known limitations, future considerations
|
||||||
|
|
|
||||||
189
actions.go
189
actions.go
|
|
@ -1,11 +1,12 @@
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
package webview
|
package webview
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
core "dappco.re/go/core"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Action represents a browser action that can be performed.
|
// Action represents a browser action that can be performed.
|
||||||
|
|
@ -41,13 +42,7 @@ type NavigateAction struct {
|
||||||
|
|
||||||
// Execute performs the navigate action.
|
// Execute performs the navigate action.
|
||||||
func (a NavigateAction) Execute(ctx context.Context, wv *Webview) error {
|
func (a NavigateAction) Execute(ctx context.Context, wv *Webview) error {
|
||||||
_, err := wv.client.Call(ctx, "Page.navigate", map[string]any{
|
return wv.navigate(ctx, a.URL, "NavigateAction.Execute")
|
||||||
"url": a.URL,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return coreerr.E("NavigateAction.Execute", "failed to navigate", err)
|
|
||||||
}
|
|
||||||
return wv.waitForLoad(ctx)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WaitAction represents a wait action.
|
// WaitAction represents a wait action.
|
||||||
|
|
@ -57,10 +52,13 @@ type WaitAction struct {
|
||||||
|
|
||||||
// Execute performs the wait action.
|
// Execute performs the wait action.
|
||||||
func (a WaitAction) Execute(ctx context.Context, wv *Webview) error {
|
func (a WaitAction) Execute(ctx context.Context, wv *Webview) error {
|
||||||
|
timer := time.NewTimer(a.Duration)
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
case <-time.After(a.Duration):
|
case <-timer.C:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -83,7 +81,7 @@ type ScrollAction struct {
|
||||||
|
|
||||||
// Execute performs the scroll action.
|
// Execute performs the scroll action.
|
||||||
func (a ScrollAction) Execute(ctx context.Context, wv *Webview) error {
|
func (a ScrollAction) Execute(ctx context.Context, wv *Webview) error {
|
||||||
script := fmt.Sprintf("window.scrollTo(%d, %d)", a.X, a.Y)
|
script := core.Sprintf("window.scrollTo(%d, %d)", a.X, a.Y)
|
||||||
_, err := wv.evaluate(ctx, script)
|
_, err := wv.evaluate(ctx, script)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -95,7 +93,7 @@ type ScrollIntoViewAction struct {
|
||||||
|
|
||||||
// Execute scrolls the element into view.
|
// Execute scrolls the element into view.
|
||||||
func (a ScrollIntoViewAction) Execute(ctx context.Context, wv *Webview) error {
|
func (a ScrollIntoViewAction) Execute(ctx context.Context, wv *Webview) error {
|
||||||
script := fmt.Sprintf("document.querySelector(%q)?.scrollIntoView({behavior: 'smooth', block: 'center'})", a.Selector)
|
script := core.Sprintf("document.querySelector(%q)?.scrollIntoView({behavior: 'smooth', block: 'center'})", a.Selector)
|
||||||
_, err := wv.evaluate(ctx, script)
|
_, err := wv.evaluate(ctx, script)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -107,7 +105,7 @@ type FocusAction struct {
|
||||||
|
|
||||||
// Execute focuses the element.
|
// Execute focuses the element.
|
||||||
func (a FocusAction) Execute(ctx context.Context, wv *Webview) error {
|
func (a FocusAction) Execute(ctx context.Context, wv *Webview) error {
|
||||||
script := fmt.Sprintf("document.querySelector(%q)?.focus()", a.Selector)
|
script := core.Sprintf("document.querySelector(%q)?.focus()", a.Selector)
|
||||||
_, err := wv.evaluate(ctx, script)
|
_, err := wv.evaluate(ctx, script)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -119,7 +117,7 @@ type BlurAction struct {
|
||||||
|
|
||||||
// Execute removes focus from the element.
|
// Execute removes focus from the element.
|
||||||
func (a BlurAction) Execute(ctx context.Context, wv *Webview) error {
|
func (a BlurAction) Execute(ctx context.Context, wv *Webview) error {
|
||||||
script := fmt.Sprintf("document.querySelector(%q)?.blur()", a.Selector)
|
script := core.Sprintf("document.querySelector(%q)?.blur()", a.Selector)
|
||||||
_, err := wv.evaluate(ctx, script)
|
_, err := wv.evaluate(ctx, script)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -131,7 +129,7 @@ type ClearAction struct {
|
||||||
|
|
||||||
// Execute clears the input value.
|
// Execute clears the input value.
|
||||||
func (a ClearAction) Execute(ctx context.Context, wv *Webview) error {
|
func (a ClearAction) Execute(ctx context.Context, wv *Webview) error {
|
||||||
script := fmt.Sprintf(`
|
script := core.Sprintf(`
|
||||||
const el = document.querySelector(%q);
|
const el = document.querySelector(%q);
|
||||||
if (el) {
|
if (el) {
|
||||||
el.value = '';
|
el.value = '';
|
||||||
|
|
@ -151,7 +149,7 @@ type SelectAction struct {
|
||||||
|
|
||||||
// Execute selects the option.
|
// Execute selects the option.
|
||||||
func (a SelectAction) Execute(ctx context.Context, wv *Webview) error {
|
func (a SelectAction) Execute(ctx context.Context, wv *Webview) error {
|
||||||
script := fmt.Sprintf(`
|
script := core.Sprintf(`
|
||||||
const el = document.querySelector(%q);
|
const el = document.querySelector(%q);
|
||||||
if (el) {
|
if (el) {
|
||||||
el.value = %q;
|
el.value = %q;
|
||||||
|
|
@ -170,7 +168,7 @@ type CheckAction struct {
|
||||||
|
|
||||||
// Execute checks/unchecks the checkbox.
|
// Execute checks/unchecks the checkbox.
|
||||||
func (a CheckAction) Execute(ctx context.Context, wv *Webview) error {
|
func (a CheckAction) Execute(ctx context.Context, wv *Webview) error {
|
||||||
script := fmt.Sprintf(`
|
script := core.Sprintf(`
|
||||||
const el = document.querySelector(%q);
|
const el = document.querySelector(%q);
|
||||||
if (el && el.checked !== %t) {
|
if (el && el.checked !== %t) {
|
||||||
el.click();
|
el.click();
|
||||||
|
|
@ -221,7 +219,7 @@ func (a DoubleClickAction) Execute(ctx context.Context, wv *Webview) error {
|
||||||
|
|
||||||
if elem.BoundingBox == nil {
|
if elem.BoundingBox == nil {
|
||||||
// Fallback to JavaScript
|
// Fallback to JavaScript
|
||||||
script := fmt.Sprintf(`
|
script := core.Sprintf(`
|
||||||
const el = document.querySelector(%q);
|
const el = document.querySelector(%q);
|
||||||
if (el) {
|
if (el) {
|
||||||
const event = new MouseEvent('dblclick', {bubbles: true, cancelable: true, view: window});
|
const event = new MouseEvent('dblclick', {bubbles: true, cancelable: true, view: window});
|
||||||
|
|
@ -268,7 +266,7 @@ func (a RightClickAction) Execute(ctx context.Context, wv *Webview) error {
|
||||||
|
|
||||||
if elem.BoundingBox == nil {
|
if elem.BoundingBox == nil {
|
||||||
// Fallback to JavaScript
|
// Fallback to JavaScript
|
||||||
script := fmt.Sprintf(`
|
script := core.Sprintf(`
|
||||||
const el = document.querySelector(%q);
|
const el = document.querySelector(%q);
|
||||||
if (el) {
|
if (el) {
|
||||||
const event = new MouseEvent('contextmenu', {bubbles: true, cancelable: true, view: window});
|
const event = new MouseEvent('contextmenu', {bubbles: true, cancelable: true, view: window});
|
||||||
|
|
@ -376,7 +374,7 @@ type SetAttributeAction struct {
|
||||||
|
|
||||||
// Execute sets the attribute.
|
// Execute sets the attribute.
|
||||||
func (a SetAttributeAction) Execute(ctx context.Context, wv *Webview) error {
|
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)
|
script := core.Sprintf("document.querySelector(%q)?.setAttribute(%q, %q)", a.Selector, a.Attribute, a.Value)
|
||||||
_, err := wv.evaluate(ctx, script)
|
_, err := wv.evaluate(ctx, script)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -389,7 +387,7 @@ type RemoveAttributeAction struct {
|
||||||
|
|
||||||
// Execute removes the attribute.
|
// Execute removes the attribute.
|
||||||
func (a RemoveAttributeAction) Execute(ctx context.Context, wv *Webview) error {
|
func (a RemoveAttributeAction) Execute(ctx context.Context, wv *Webview) error {
|
||||||
script := fmt.Sprintf("document.querySelector(%q)?.removeAttribute(%q)", a.Selector, a.Attribute)
|
script := core.Sprintf("document.querySelector(%q)?.removeAttribute(%q)", a.Selector, a.Attribute)
|
||||||
_, err := wv.evaluate(ctx, script)
|
_, err := wv.evaluate(ctx, script)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -402,7 +400,7 @@ type SetValueAction struct {
|
||||||
|
|
||||||
// Execute sets the value.
|
// Execute sets the value.
|
||||||
func (a SetValueAction) Execute(ctx context.Context, wv *Webview) error {
|
func (a SetValueAction) Execute(ctx context.Context, wv *Webview) error {
|
||||||
script := fmt.Sprintf(`
|
script := core.Sprintf(`
|
||||||
const el = document.querySelector(%q);
|
const el = document.querySelector(%q);
|
||||||
if (el) {
|
if (el) {
|
||||||
el.value = %q;
|
el.value = %q;
|
||||||
|
|
@ -414,12 +412,45 @@ func (a SetValueAction) Execute(ctx context.Context, wv *Webview) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UploadFileAction uploads files into a file input resolved by selector.
|
||||||
|
type UploadFileAction struct {
|
||||||
|
Selector string
|
||||||
|
FilePaths []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute uploads files into the matching file input.
|
||||||
|
func (a UploadFileAction) Execute(ctx context.Context, wv *Webview) error {
|
||||||
|
if wv == nil {
|
||||||
|
return coreerr.E("UploadFileAction.Execute", "webview is required", nil)
|
||||||
|
}
|
||||||
|
return wv.uploadFile(ctx, a.Selector, a.FilePaths)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DragAndDropAction drags one element onto another.
|
||||||
|
type DragAndDropAction struct {
|
||||||
|
SourceSelector string
|
||||||
|
TargetSelector string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute drags the source element onto the target element.
|
||||||
|
func (a DragAndDropAction) Execute(ctx context.Context, wv *Webview) error {
|
||||||
|
if wv == nil {
|
||||||
|
return coreerr.E("DragAndDropAction.Execute", "webview is required", nil)
|
||||||
|
}
|
||||||
|
return wv.dragAndDrop(ctx, a.SourceSelector, a.TargetSelector)
|
||||||
|
}
|
||||||
|
|
||||||
// ActionSequence represents a sequence of actions to execute.
|
// ActionSequence represents a sequence of actions to execute.
|
||||||
type ActionSequence struct {
|
type ActionSequence struct {
|
||||||
actions []Action
|
actions []Action
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewActionSequence creates a new action sequence.
|
// Build a reusable action pipeline before executing it against a Webview.
|
||||||
|
//
|
||||||
|
// sequence := webview.NewActionSequence().
|
||||||
|
// Navigate("https://example.com").
|
||||||
|
// WaitForSelector("form").
|
||||||
|
// Click("button")
|
||||||
func NewActionSequence() *ActionSequence {
|
func NewActionSequence() *ActionSequence {
|
||||||
return &ActionSequence{
|
return &ActionSequence{
|
||||||
actions: make([]Action, 0),
|
actions: make([]Action, 0),
|
||||||
|
|
@ -457,11 +488,97 @@ func (s *ActionSequence) WaitForSelector(selector string) *ActionSequence {
|
||||||
return s.Add(WaitForSelectorAction{Selector: selector})
|
return s.Add(WaitForSelectorAction{Selector: selector})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scroll adds a scroll action.
|
||||||
|
func (s *ActionSequence) Scroll(x, y int) *ActionSequence {
|
||||||
|
return s.Add(ScrollAction{X: x, Y: y})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScrollIntoView adds a scroll-into-view action.
|
||||||
|
func (s *ActionSequence) ScrollIntoView(selector string) *ActionSequence {
|
||||||
|
return s.Add(ScrollIntoViewAction{Selector: selector})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus adds a focus action.
|
||||||
|
func (s *ActionSequence) Focus(selector string) *ActionSequence {
|
||||||
|
return s.Add(FocusAction{Selector: selector})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blur adds a blur action.
|
||||||
|
func (s *ActionSequence) Blur(selector string) *ActionSequence {
|
||||||
|
return s.Add(BlurAction{Selector: selector})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear adds a clear action.
|
||||||
|
func (s *ActionSequence) Clear(selector string) *ActionSequence {
|
||||||
|
return s.Add(ClearAction{Selector: selector})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select adds a select action.
|
||||||
|
func (s *ActionSequence) Select(selector, value string) *ActionSequence {
|
||||||
|
return s.Add(SelectAction{Selector: selector, Value: value})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check adds a check action.
|
||||||
|
func (s *ActionSequence) Check(selector string, checked bool) *ActionSequence {
|
||||||
|
return s.Add(CheckAction{Selector: selector, Checked: checked})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hover adds a hover action.
|
||||||
|
func (s *ActionSequence) Hover(selector string) *ActionSequence {
|
||||||
|
return s.Add(HoverAction{Selector: selector})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DoubleClick adds a double-click action.
|
||||||
|
func (s *ActionSequence) DoubleClick(selector string) *ActionSequence {
|
||||||
|
return s.Add(DoubleClickAction{Selector: selector})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RightClick adds a right-click action.
|
||||||
|
func (s *ActionSequence) RightClick(selector string) *ActionSequence {
|
||||||
|
return s.Add(RightClickAction{Selector: selector})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PressKey adds a key press action.
|
||||||
|
func (s *ActionSequence) PressKey(key string) *ActionSequence {
|
||||||
|
return s.Add(PressKeyAction{Key: key})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAttribute adds a set-attribute action.
|
||||||
|
func (s *ActionSequence) SetAttribute(selector, attribute, value string) *ActionSequence {
|
||||||
|
return s.Add(SetAttributeAction{Selector: selector, Attribute: attribute, Value: value})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveAttribute adds a remove-attribute action.
|
||||||
|
func (s *ActionSequence) RemoveAttribute(selector, attribute string) *ActionSequence {
|
||||||
|
return s.Add(RemoveAttributeAction{Selector: selector, Attribute: attribute})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetValue adds a set-value action.
|
||||||
|
func (s *ActionSequence) SetValue(selector, value string) *ActionSequence {
|
||||||
|
return s.Add(SetValueAction{Selector: selector, Value: value})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadFile adds a file-upload action.
|
||||||
|
func (s *ActionSequence) UploadFile(selector string, filePaths []string) *ActionSequence {
|
||||||
|
return s.Add(UploadFileAction{
|
||||||
|
Selector: selector,
|
||||||
|
FilePaths: append([]string(nil), filePaths...),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DragAndDrop adds a drag-and-drop action.
|
||||||
|
func (s *ActionSequence) DragAndDrop(sourceSelector, targetSelector string) *ActionSequence {
|
||||||
|
return s.Add(DragAndDropAction{
|
||||||
|
SourceSelector: sourceSelector,
|
||||||
|
TargetSelector: targetSelector,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Execute executes all actions in the sequence.
|
// Execute executes all actions in the sequence.
|
||||||
func (s *ActionSequence) Execute(ctx context.Context, wv *Webview) error {
|
func (s *ActionSequence) Execute(ctx context.Context, wv *Webview) error {
|
||||||
for i, action := range s.actions {
|
for i, action := range s.actions {
|
||||||
if err := action.Execute(ctx, wv); err != nil {
|
if err := action.Execute(ctx, wv); err != nil {
|
||||||
return coreerr.E("ActionSequence.Execute", fmt.Sprintf("action %d failed", i), err)
|
return coreerr.E("ActionSequence.Execute", core.Sprintf("action index %d failed", i), err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -472,10 +589,14 @@ func (wv *Webview) UploadFile(selector string, filePaths []string) error {
|
||||||
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
|
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
return wv.uploadFile(ctx, selector, filePaths)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wv *Webview) uploadFile(ctx context.Context, selector string, filePaths []string) error {
|
||||||
// Get the element's node ID
|
// Get the element's node ID
|
||||||
elem, err := wv.querySelector(ctx, selector)
|
elem, err := wv.querySelector(ctx, selector)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return coreerr.E("Webview.UploadFile", "failed to find file input", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use DOM.setFileInputFiles to set the files
|
// Use DOM.setFileInputFiles to set the files
|
||||||
|
|
@ -483,7 +604,10 @@ func (wv *Webview) UploadFile(selector string, filePaths []string) error {
|
||||||
"nodeId": elem.NodeID,
|
"nodeId": elem.NodeID,
|
||||||
"files": filePaths,
|
"files": filePaths,
|
||||||
})
|
})
|
||||||
return err
|
if err != nil {
|
||||||
|
return coreerr.E("Webview.UploadFile", "failed to upload file", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DragAndDrop performs a drag and drop operation.
|
// DragAndDrop performs a drag and drop operation.
|
||||||
|
|
@ -491,6 +615,10 @@ func (wv *Webview) DragAndDrop(sourceSelector, targetSelector string) error {
|
||||||
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
|
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
return wv.dragAndDrop(ctx, sourceSelector, targetSelector)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wv *Webview) dragAndDrop(ctx context.Context, sourceSelector, targetSelector string) error {
|
||||||
// Get source and target elements
|
// Get source and target elements
|
||||||
source, err := wv.querySelector(ctx, sourceSelector)
|
source, err := wv.querySelector(ctx, sourceSelector)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -523,7 +651,7 @@ func (wv *Webview) DragAndDrop(sourceSelector, targetSelector string) error {
|
||||||
"clickCount": 1,
|
"clickCount": 1,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return coreerr.E("Webview.DragAndDrop", "failed to press source element", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move to target
|
// Move to target
|
||||||
|
|
@ -534,7 +662,7 @@ func (wv *Webview) DragAndDrop(sourceSelector, targetSelector string) error {
|
||||||
"button": "left",
|
"button": "left",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return coreerr.E("Webview.DragAndDrop", "failed to move to target element", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mouse up on target
|
// Mouse up on target
|
||||||
|
|
@ -545,5 +673,8 @@ func (wv *Webview) DragAndDrop(sourceSelector, targetSelector string) error {
|
||||||
"button": "left",
|
"button": "left",
|
||||||
"clickCount": 1,
|
"clickCount": 1,
|
||||||
})
|
})
|
||||||
return err
|
if err != nil {
|
||||||
|
return coreerr.E("Webview.DragAndDrop", "failed to release target element", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
571
actions_test.go
Normal file
571
actions_test.go
Normal file
|
|
@ -0,0 +1,571 @@
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
|
package webview
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newActionHarness(t *testing.T, onMessage func(*fakeCDPTarget, cdpMessage)) (*Webview, *fakeCDPTarget) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
server := newFakeCDPServer(t)
|
||||||
|
target := server.primaryTarget()
|
||||||
|
target.onMessage = onMessage
|
||||||
|
|
||||||
|
client := newConnectedCDPClient(t, target)
|
||||||
|
wv := &Webview{
|
||||||
|
client: client,
|
||||||
|
ctx: context.Background(),
|
||||||
|
timeout: time.Second,
|
||||||
|
consoleLogs: make([]ConsoleMessage, 0),
|
||||||
|
consoleLimit: 10,
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = client.Close()
|
||||||
|
})
|
||||||
|
return wv, target
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActions_ActionSequence_Good(t *testing.T) {
|
||||||
|
var methods []string
|
||||||
|
wv, _ := newActionHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
methods = append(methods, msg.Method)
|
||||||
|
switch msg.Method {
|
||||||
|
case "Runtime.evaluate":
|
||||||
|
expr, _ := msg.Params["expression"].(string)
|
||||||
|
if expr == "document.readyState" {
|
||||||
|
target.replyValue(msg.ID, "complete")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target.replyValue(msg.ID, true)
|
||||||
|
case "Page.navigate":
|
||||||
|
target.reply(msg.ID, map[string]any{})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
seq := NewActionSequence().
|
||||||
|
Scroll(10, 20).
|
||||||
|
Focus("#input").
|
||||||
|
Navigate("https://example.com")
|
||||||
|
|
||||||
|
if err := seq.Execute(context.Background(), wv); err != nil {
|
||||||
|
t.Fatalf("ActionSequence.Execute returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(methods) != 4 {
|
||||||
|
t.Fatalf("ActionSequence.Execute methods = %v, want 4 calls", methods)
|
||||||
|
}
|
||||||
|
if methods[0] != "Runtime.evaluate" || methods[1] != "Runtime.evaluate" || methods[2] != "Page.navigate" || methods[3] != "Runtime.evaluate" {
|
||||||
|
t.Fatalf("ActionSequence.Execute call order = %v", methods)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActions_EvaluateActions_Good(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
action Action
|
||||||
|
wantSub string
|
||||||
|
}{
|
||||||
|
{name: "scroll", action: ScrollAction{X: 10, Y: 20}, wantSub: "window.scrollTo(10, 20)"},
|
||||||
|
{name: "scroll into view", action: ScrollIntoViewAction{Selector: "#target"}, wantSub: `scrollIntoView({behavior: 'smooth', block: 'center'})`},
|
||||||
|
{name: "focus", action: FocusAction{Selector: "#input"}, wantSub: `?.focus()`},
|
||||||
|
{name: "blur", action: BlurAction{Selector: "#input"}, wantSub: `?.blur()`},
|
||||||
|
{name: "clear", action: ClearAction{Selector: "#input"}, wantSub: `el.value = '';`},
|
||||||
|
{name: "select", action: SelectAction{Selector: "#dropdown", Value: "option1"}, wantSub: `el.value = "option1";`},
|
||||||
|
{name: "check", action: CheckAction{Selector: "#checkbox", Checked: true}, wantSub: `el && el.checked !== true`},
|
||||||
|
{name: "set attribute", action: SetAttributeAction{Selector: "#element", Attribute: "data-value", Value: "test"}, wantSub: `setAttribute("data-value", "test")`},
|
||||||
|
{name: "remove attribute", action: RemoveAttributeAction{Selector: "#element", Attribute: "disabled"}, wantSub: `removeAttribute("disabled")`},
|
||||||
|
{name: "set value", action: SetValueAction{Selector: "#input", Value: "new value"}, wantSub: `el.value = "new value";`},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
wv, _ := newActionHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
if msg.Method != "Runtime.evaluate" {
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
expr, _ := msg.Params["expression"].(string)
|
||||||
|
if !strings.Contains(expr, tc.wantSub) {
|
||||||
|
t.Fatalf("expression %q does not contain %q", expr, tc.wantSub)
|
||||||
|
}
|
||||||
|
target.replyValue(msg.ID, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := tc.action.Execute(context.Background(), wv); err != nil {
|
||||||
|
t.Fatalf("%T.Execute returned error: %v", tc.action, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActions_TypeAction_Good(t *testing.T) {
|
||||||
|
var methods []string
|
||||||
|
wv, _ := newActionHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
methods = append(methods, msg.Method)
|
||||||
|
switch msg.Method {
|
||||||
|
case "Runtime.evaluate":
|
||||||
|
expr, _ := msg.Params["expression"].(string)
|
||||||
|
if !strings.Contains(expr, `document.querySelector("#email")?.focus()`) {
|
||||||
|
t.Fatalf("focus expression = %q", expr)
|
||||||
|
}
|
||||||
|
target.replyValue(msg.ID, true)
|
||||||
|
case "Input.dispatchKeyEvent":
|
||||||
|
target.reply(msg.ID, map[string]any{})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := (TypeAction{Selector: "#email", Text: "ab"}).Execute(context.Background(), wv); err != nil {
|
||||||
|
t.Fatalf("TypeAction.Execute returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(methods) != 5 {
|
||||||
|
t.Fatalf("TypeAction made %d CDP calls, want 5", len(methods))
|
||||||
|
}
|
||||||
|
if methods[0] != "Runtime.evaluate" || methods[1] != "Input.dispatchKeyEvent" || methods[2] != "Input.dispatchKeyEvent" || methods[3] != "Input.dispatchKeyEvent" || methods[4] != "Input.dispatchKeyEvent" {
|
||||||
|
t.Fatalf("TypeAction call order = %v", methods)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActions_DomActions_Good(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
action Action
|
||||||
|
handler func(*testing.T, *fakeCDPTarget, cdpMessage)
|
||||||
|
check func(*testing.T, []cdpMessage)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "click",
|
||||||
|
action: ClickAction{Selector: "#button"},
|
||||||
|
handler: func(t *testing.T, target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
switch msg.Method {
|
||||||
|
case "DOM.getDocument":
|
||||||
|
target.reply(msg.ID, map[string]any{"root": map[string]any{"nodeId": float64(1)}})
|
||||||
|
case "DOM.querySelector":
|
||||||
|
target.reply(msg.ID, map[string]any{"nodeId": float64(10)})
|
||||||
|
case "DOM.describeNode":
|
||||||
|
target.reply(msg.ID, map[string]any{"node": map[string]any{"nodeName": "BUTTON", "attributes": []any{"id", "button"}}})
|
||||||
|
case "DOM.resolveNode":
|
||||||
|
target.reply(msg.ID, map[string]any{"object": map[string]any{"objectId": "obj-1"}})
|
||||||
|
case "Runtime.callFunctionOn":
|
||||||
|
target.reply(msg.ID, map[string]any{"result": map[string]any{"value": map[string]any{"innerHTML": "<span>ok</span>", "innerText": "ok"}}})
|
||||||
|
case "DOM.getBoxModel":
|
||||||
|
target.reply(msg.ID, map[string]any{"model": map[string]any{"content": []any{float64(10), float64(20), float64(30), float64(20), float64(30), float64(40), float64(10), float64(40)}}})
|
||||||
|
case "Input.dispatchMouseEvent":
|
||||||
|
target.reply(msg.ID, map[string]any{})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
check: func(t *testing.T, msgs []cdpMessage) {
|
||||||
|
if len(msgs) != 8 {
|
||||||
|
t.Fatalf("click made %d CDP calls, want 8", len(msgs))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hover",
|
||||||
|
action: HoverAction{Selector: "#menu"},
|
||||||
|
handler: func(t *testing.T, target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
switch msg.Method {
|
||||||
|
case "DOM.getDocument":
|
||||||
|
target.reply(msg.ID, map[string]any{"root": map[string]any{"nodeId": float64(1)}})
|
||||||
|
case "DOM.querySelector":
|
||||||
|
target.reply(msg.ID, map[string]any{"nodeId": float64(11)})
|
||||||
|
case "DOM.describeNode":
|
||||||
|
target.reply(msg.ID, map[string]any{"node": map[string]any{"nodeName": "DIV"}})
|
||||||
|
case "DOM.resolveNode":
|
||||||
|
target.reply(msg.ID, map[string]any{"object": map[string]any{"objectId": "obj-2"}})
|
||||||
|
case "Runtime.callFunctionOn":
|
||||||
|
target.reply(msg.ID, map[string]any{"result": map[string]any{"value": map[string]any{"innerHTML": "", "innerText": ""}}})
|
||||||
|
case "DOM.getBoxModel":
|
||||||
|
target.reply(msg.ID, map[string]any{"model": map[string]any{"content": []any{float64(0), float64(0), float64(20), float64(0), float64(20), float64(20), float64(0), float64(20)}}})
|
||||||
|
case "Input.dispatchMouseEvent":
|
||||||
|
target.reply(msg.ID, map[string]any{})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
check: func(t *testing.T, msgs []cdpMessage) {
|
||||||
|
if len(msgs) != 7 {
|
||||||
|
t.Fatalf("hover made %d CDP calls, want 7", len(msgs))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "double click",
|
||||||
|
action: DoubleClickAction{Selector: "#editable"},
|
||||||
|
handler: func(t *testing.T, target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
switch msg.Method {
|
||||||
|
case "DOM.getDocument":
|
||||||
|
target.reply(msg.ID, map[string]any{"root": map[string]any{"nodeId": float64(1)}})
|
||||||
|
case "DOM.querySelector":
|
||||||
|
target.reply(msg.ID, map[string]any{"nodeId": float64(12)})
|
||||||
|
case "DOM.describeNode":
|
||||||
|
target.reply(msg.ID, map[string]any{"node": map[string]any{"nodeName": "DIV"}})
|
||||||
|
case "DOM.resolveNode":
|
||||||
|
target.reply(msg.ID, map[string]any{"object": map[string]any{"objectId": "obj-3"}})
|
||||||
|
case "Runtime.callFunctionOn":
|
||||||
|
target.reply(msg.ID, map[string]any{"result": map[string]any{"value": map[string]any{"innerHTML": "", "innerText": ""}}})
|
||||||
|
case "DOM.getBoxModel":
|
||||||
|
target.reply(msg.ID, map[string]any{"model": map[string]any{"content": []any{float64(0), float64(0), float64(10), float64(0), float64(10), float64(10), float64(0), float64(10)}}})
|
||||||
|
case "Input.dispatchMouseEvent":
|
||||||
|
target.reply(msg.ID, map[string]any{})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
check: func(t *testing.T, msgs []cdpMessage) {
|
||||||
|
if len(msgs) != 10 {
|
||||||
|
t.Fatalf("double click made %d CDP calls, want 10", len(msgs))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "right click",
|
||||||
|
action: RightClickAction{Selector: "#context"},
|
||||||
|
handler: func(t *testing.T, target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
switch msg.Method {
|
||||||
|
case "DOM.getDocument":
|
||||||
|
target.reply(msg.ID, map[string]any{"root": map[string]any{"nodeId": float64(1)}})
|
||||||
|
case "DOM.querySelector":
|
||||||
|
target.reply(msg.ID, map[string]any{"nodeId": float64(13)})
|
||||||
|
case "DOM.describeNode":
|
||||||
|
target.reply(msg.ID, map[string]any{"node": map[string]any{"nodeName": "DIV"}})
|
||||||
|
case "DOM.resolveNode":
|
||||||
|
target.reply(msg.ID, map[string]any{"object": map[string]any{"objectId": "obj-4"}})
|
||||||
|
case "Runtime.callFunctionOn":
|
||||||
|
target.reply(msg.ID, map[string]any{"result": map[string]any{"value": map[string]any{"innerHTML": "", "innerText": ""}}})
|
||||||
|
case "DOM.getBoxModel":
|
||||||
|
target.reply(msg.ID, map[string]any{"model": map[string]any{"content": []any{float64(0), float64(0), float64(10), float64(0), float64(10), float64(10), float64(0), float64(10)}}})
|
||||||
|
case "Input.dispatchMouseEvent":
|
||||||
|
target.reply(msg.ID, map[string]any{})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
check: func(t *testing.T, msgs []cdpMessage) {
|
||||||
|
if len(msgs) != 8 {
|
||||||
|
t.Fatalf("right click made %d CDP calls, want 8", len(msgs))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "press key",
|
||||||
|
action: PressKeyAction{Key: "Enter"},
|
||||||
|
handler: func(t *testing.T, target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
if msg.Method != "Input.dispatchKeyEvent" {
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
target.reply(msg.ID, map[string]any{})
|
||||||
|
},
|
||||||
|
check: func(t *testing.T, msgs []cdpMessage) {
|
||||||
|
if len(msgs) != 2 {
|
||||||
|
t.Fatalf("press key made %d CDP calls, want 2", len(msgs))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upload file",
|
||||||
|
action: &uploadFileAction{selector: "#file", files: []string{"/tmp/a.txt"}},
|
||||||
|
handler: func(t *testing.T, target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
switch msg.Method {
|
||||||
|
case "DOM.getDocument":
|
||||||
|
target.reply(msg.ID, map[string]any{"root": map[string]any{"nodeId": float64(1)}})
|
||||||
|
case "DOM.querySelector":
|
||||||
|
target.reply(msg.ID, map[string]any{"nodeId": float64(22)})
|
||||||
|
case "DOM.describeNode":
|
||||||
|
target.reply(msg.ID, map[string]any{"node": map[string]any{"nodeName": "INPUT"}})
|
||||||
|
case "DOM.resolveNode":
|
||||||
|
target.reply(msg.ID, map[string]any{"object": map[string]any{"objectId": "obj-5"}})
|
||||||
|
case "Runtime.callFunctionOn":
|
||||||
|
target.reply(msg.ID, map[string]any{"result": map[string]any{"value": map[string]any{"innerHTML": "", "innerText": ""}}})
|
||||||
|
case "DOM.getBoxModel":
|
||||||
|
target.reply(msg.ID, map[string]any{"model": map[string]any{"content": []any{float64(0), float64(0), float64(1), float64(0), float64(1), float64(1), float64(0), float64(1)}}})
|
||||||
|
case "DOM.setFileInputFiles":
|
||||||
|
target.reply(msg.ID, map[string]any{})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
check: func(t *testing.T, msgs []cdpMessage) {
|
||||||
|
if len(msgs) != 7 {
|
||||||
|
t.Fatalf("upload file made %d CDP calls, want 7", len(msgs))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "drag and drop",
|
||||||
|
action: &dragDropAction{source: "#source", target: "#target"},
|
||||||
|
handler: func(t *testing.T, target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
switch msg.Method {
|
||||||
|
case "DOM.getDocument":
|
||||||
|
target.reply(msg.ID, map[string]any{"root": map[string]any{"nodeId": float64(1)}})
|
||||||
|
case "DOM.querySelector":
|
||||||
|
sel, _ := msg.Params["selector"].(string)
|
||||||
|
switch sel {
|
||||||
|
case "#source":
|
||||||
|
target.reply(msg.ID, map[string]any{"nodeId": float64(31)})
|
||||||
|
case "#target":
|
||||||
|
target.reply(msg.ID, map[string]any{"nodeId": float64(32)})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected selector %q", sel)
|
||||||
|
}
|
||||||
|
case "DOM.describeNode":
|
||||||
|
target.reply(msg.ID, map[string]any{"node": map[string]any{"nodeName": "DIV"}})
|
||||||
|
case "DOM.resolveNode":
|
||||||
|
target.reply(msg.ID, map[string]any{"object": map[string]any{"objectId": "obj-6"}})
|
||||||
|
case "Runtime.callFunctionOn":
|
||||||
|
target.reply(msg.ID, map[string]any{"result": map[string]any{"value": map[string]any{"innerHTML": "", "innerText": ""}}})
|
||||||
|
case "DOM.getBoxModel":
|
||||||
|
nodeID := int(msg.Params["nodeId"].(float64))
|
||||||
|
box := []any{float64(nodeID), float64(nodeID), float64(nodeID + 10), float64(nodeID), float64(nodeID + 10), float64(nodeID + 10), float64(nodeID), float64(nodeID + 10)}
|
||||||
|
target.reply(msg.ID, map[string]any{"model": map[string]any{"content": box}})
|
||||||
|
case "Input.dispatchMouseEvent":
|
||||||
|
target.reply(msg.ID, map[string]any{})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
check: func(t *testing.T, msgs []cdpMessage) {
|
||||||
|
if len(msgs) != 15 {
|
||||||
|
t.Fatalf("drag and drop made %d CDP calls, want 15", len(msgs))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var msgs []cdpMessage
|
||||||
|
wv, _ := newActionHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
msgs = append(msgs, msg)
|
||||||
|
tc.handler(t, target, msg)
|
||||||
|
})
|
||||||
|
|
||||||
|
err := tc.action.Execute(context.Background(), wv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%T.Execute returned error: %v", tc.action, err)
|
||||||
|
}
|
||||||
|
tc.check(t, msgs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActions_DoubleClickAction_Ugly_FallsBackToJS(t *testing.T) {
|
||||||
|
var expressions []string
|
||||||
|
wv, _ := newActionHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
switch msg.Method {
|
||||||
|
case "DOM.getDocument":
|
||||||
|
target.reply(msg.ID, map[string]any{"root": map[string]any{"nodeId": float64(1)}})
|
||||||
|
case "DOM.querySelector":
|
||||||
|
target.reply(msg.ID, map[string]any{"nodeId": float64(10)})
|
||||||
|
case "DOM.describeNode":
|
||||||
|
target.reply(msg.ID, map[string]any{"node": map[string]any{"nodeName": "BUTTON"}})
|
||||||
|
case "DOM.resolveNode":
|
||||||
|
target.reply(msg.ID, map[string]any{"object": map[string]any{"objectId": "obj-1"}})
|
||||||
|
case "Runtime.callFunctionOn":
|
||||||
|
target.reply(msg.ID, map[string]any{"result": map[string]any{"value": map[string]any{"innerHTML": "", "innerText": ""}}})
|
||||||
|
case "DOM.getBoxModel":
|
||||||
|
target.reply(msg.ID, map[string]any{})
|
||||||
|
case "Runtime.evaluate":
|
||||||
|
expr, _ := msg.Params["expression"].(string)
|
||||||
|
expressions = append(expressions, expr)
|
||||||
|
target.replyValue(msg.ID, true)
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := (DoubleClickAction{Selector: "#button"}).Execute(context.Background(), wv); err != nil {
|
||||||
|
t.Fatalf("DoubleClickAction.Execute returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(expressions) != 1 || !strings.Contains(expressions[0], `new MouseEvent('dblclick'`) {
|
||||||
|
t.Fatalf("DoubleClickAction fallback expression = %v", expressions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActions_RightClickAction_Ugly_FallsBackToJS(t *testing.T) {
|
||||||
|
var expressions []string
|
||||||
|
wv, _ := newActionHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
switch msg.Method {
|
||||||
|
case "DOM.getDocument":
|
||||||
|
target.reply(msg.ID, map[string]any{"root": map[string]any{"nodeId": float64(1)}})
|
||||||
|
case "DOM.querySelector":
|
||||||
|
target.reply(msg.ID, map[string]any{"nodeId": float64(11)})
|
||||||
|
case "DOM.describeNode":
|
||||||
|
target.reply(msg.ID, map[string]any{"node": map[string]any{"nodeName": "BUTTON"}})
|
||||||
|
case "DOM.resolveNode":
|
||||||
|
target.reply(msg.ID, map[string]any{"object": map[string]any{"objectId": "obj-2"}})
|
||||||
|
case "Runtime.callFunctionOn":
|
||||||
|
target.reply(msg.ID, map[string]any{"result": map[string]any{"value": map[string]any{"innerHTML": "", "innerText": ""}}})
|
||||||
|
case "DOM.getBoxModel":
|
||||||
|
target.reply(msg.ID, map[string]any{})
|
||||||
|
case "Runtime.evaluate":
|
||||||
|
expr, _ := msg.Params["expression"].(string)
|
||||||
|
expressions = append(expressions, expr)
|
||||||
|
target.replyValue(msg.ID, true)
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := (RightClickAction{Selector: "#button"}).Execute(context.Background(), wv); err != nil {
|
||||||
|
t.Fatalf("RightClickAction.Execute returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(expressions) != 1 || !strings.Contains(expressions[0], `new MouseEvent('contextmenu'`) {
|
||||||
|
t.Fatalf("RightClickAction fallback expression = %v", expressions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActions_PressKeyAction_Good_SimpleCharacter(t *testing.T) {
|
||||||
|
var methods []string
|
||||||
|
wv, _ := newActionHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
methods = append(methods, msg.Method)
|
||||||
|
if msg.Method != "Input.dispatchKeyEvent" {
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
if msg.Params["type"] == "keyDown" {
|
||||||
|
if got := msg.Params["text"]; got != "a" {
|
||||||
|
t.Fatalf("keyDown text = %v, want a", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
target.reply(msg.ID, map[string]any{})
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := (PressKeyAction{Key: "a"}).Execute(context.Background(), wv); err != nil {
|
||||||
|
t.Fatalf("PressKeyAction.Execute returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(methods) != 2 {
|
||||||
|
t.Fatalf("PressKeyAction made %d CDP calls, want 2", len(methods))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActions_ActionSequence_Bad_StopsOnError(t *testing.T) {
|
||||||
|
seq := NewActionSequence().
|
||||||
|
Add(failingAction{}).
|
||||||
|
Add(recordingAction{})
|
||||||
|
|
||||||
|
err := seq.Execute(context.Background(), &Webview{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ActionSequence.Execute succeeded despite a failing action")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "action index 0 failed") {
|
||||||
|
t.Fatalf("ActionSequence.Execute error = %v, want wrapped index failure", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActions_WaitForSelectorAction_Good(t *testing.T) {
|
||||||
|
var calls int
|
||||||
|
wv, _ := newActionHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
if msg.Method != "Runtime.evaluate" {
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
calls++
|
||||||
|
if calls == 1 {
|
||||||
|
target.replyValue(msg.ID, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target.replyValue(msg.ID, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := (WaitForSelectorAction{Selector: "#ready"}).Execute(context.Background(), wv); err != nil {
|
||||||
|
t.Fatalf("WaitForSelectorAction.Execute returned error: %v", err)
|
||||||
|
}
|
||||||
|
if calls < 2 {
|
||||||
|
t.Fatalf("WaitForSelectorAction made %d evaluate calls, want at least 2", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActions_HoverAction_Bad_MissingBoundingBox(t *testing.T) {
|
||||||
|
wv, _ := newActionHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
switch msg.Method {
|
||||||
|
case "DOM.getDocument":
|
||||||
|
target.reply(msg.ID, map[string]any{"root": map[string]any{"nodeId": float64(1)}})
|
||||||
|
case "DOM.querySelector":
|
||||||
|
target.reply(msg.ID, map[string]any{"nodeId": float64(10)})
|
||||||
|
case "DOM.describeNode":
|
||||||
|
target.reply(msg.ID, map[string]any{"node": map[string]any{"nodeName": "DIV"}})
|
||||||
|
case "DOM.resolveNode":
|
||||||
|
target.reply(msg.ID, map[string]any{"object": map[string]any{"objectId": "obj-1"}})
|
||||||
|
case "Runtime.callFunctionOn":
|
||||||
|
target.reply(msg.ID, map[string]any{"result": map[string]any{"value": map[string]any{"innerHTML": "", "innerText": ""}}})
|
||||||
|
case "DOM.getBoxModel":
|
||||||
|
target.reply(msg.ID, map[string]any{})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := (HoverAction{Selector: "#menu"}).Execute(context.Background(), wv); err == nil {
|
||||||
|
t.Fatal("HoverAction succeeded without a bounding box")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActions_ClickAction_Ugly_FallsBackToJS(t *testing.T) {
|
||||||
|
var expressions []string
|
||||||
|
wv, _ := newActionHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
switch msg.Method {
|
||||||
|
case "DOM.getDocument":
|
||||||
|
target.reply(msg.ID, map[string]any{"root": map[string]any{"nodeId": float64(1)}})
|
||||||
|
case "DOM.querySelector":
|
||||||
|
target.reply(msg.ID, map[string]any{"nodeId": float64(10)})
|
||||||
|
case "DOM.describeNode":
|
||||||
|
target.reply(msg.ID, map[string]any{"node": map[string]any{"nodeName": "BUTTON"}})
|
||||||
|
case "DOM.resolveNode":
|
||||||
|
target.reply(msg.ID, map[string]any{"object": map[string]any{"objectId": "obj-1"}})
|
||||||
|
case "Runtime.callFunctionOn":
|
||||||
|
target.reply(msg.ID, map[string]any{"result": map[string]any{"value": map[string]any{"innerHTML": "", "innerText": ""}}})
|
||||||
|
case "DOM.getBoxModel":
|
||||||
|
target.reply(msg.ID, map[string]any{})
|
||||||
|
case "Runtime.evaluate":
|
||||||
|
expr, _ := msg.Params["expression"].(string)
|
||||||
|
expressions = append(expressions, expr)
|
||||||
|
target.replyValue(msg.ID, true)
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := (ClickAction{Selector: "#button"}).Execute(context.Background(), wv); err != nil {
|
||||||
|
t.Fatalf("ClickAction returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(expressions) != 1 || !strings.Contains(expressions[0], `document.querySelector("#button")?.click()`) {
|
||||||
|
t.Fatalf("ClickAction fallback expression = %v", expressions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type failingAction struct{}
|
||||||
|
|
||||||
|
func (failingAction) Execute(context.Context, *Webview) error {
|
||||||
|
return errors.New("boom")
|
||||||
|
}
|
||||||
|
|
||||||
|
type recordingAction struct{}
|
||||||
|
|
||||||
|
func (recordingAction) Execute(context.Context, *Webview) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type uploadFileAction struct {
|
||||||
|
selector string
|
||||||
|
files []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *uploadFileAction) Execute(ctx context.Context, wv *Webview) error {
|
||||||
|
return wv.UploadFile(a.selector, a.files)
|
||||||
|
}
|
||||||
|
|
||||||
|
type dragDropAction struct {
|
||||||
|
source string
|
||||||
|
target string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *dragDropAction) Execute(ctx context.Context, wv *Webview) error {
|
||||||
|
return wv.DragAndDrop(a.source, a.target)
|
||||||
|
}
|
||||||
273
angular.go
273
angular.go
|
|
@ -1,12 +1,12 @@
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
package webview
|
package webview
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
core "dappco.re/go/core"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AngularHelper provides Angular-specific testing utilities.
|
// AngularHelper provides Angular-specific testing utilities.
|
||||||
|
|
@ -15,7 +15,10 @@ type AngularHelper struct {
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAngularHelper creates a new Angular helper for the webview.
|
// Create Angular-specific helpers for a page already loaded in the Webview.
|
||||||
|
//
|
||||||
|
// ah := webview.NewAngularHelper(wv)
|
||||||
|
// ah.SetTimeout(15 * time.Second)
|
||||||
func NewAngularHelper(wv *Webview) *AngularHelper {
|
func NewAngularHelper(wv *Webview) *AngularHelper {
|
||||||
return &AngularHelper{
|
return &AngularHelper{
|
||||||
wv: wv,
|
wv: wv,
|
||||||
|
|
@ -42,10 +45,10 @@ func (ah *AngularHelper) waitForAngular(ctx context.Context) error {
|
||||||
// Check if Angular is present
|
// Check if Angular is present
|
||||||
isAngular, err := ah.isAngularApp(ctx)
|
isAngular, err := ah.isAngularApp(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return coreerr.E("AngularHelper.WaitForAngular", "failed to detect Angular app", err)
|
||||||
}
|
}
|
||||||
if !isAngular {
|
if !isAngular {
|
||||||
return coreerr.E("AngularHelper.waitForAngular", "not an Angular application", nil)
|
return coreerr.E("AngularHelper.WaitForAngular", "not an Angular application", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for Zone.js stability
|
// Wait for Zone.js stability
|
||||||
|
|
@ -78,7 +81,7 @@ func (ah *AngularHelper) isAngularApp(ctx context.Context) (bool, error) {
|
||||||
|
|
||||||
result, err := ah.wv.evaluate(ctx, script)
|
result, err := ah.wv.evaluate(ctx, script)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, coreerr.E("AngularHelper.WaitForAngular", "failed to detect Angular app", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
isAngular, ok := result.(bool)
|
isAngular, ok := result.(bool)
|
||||||
|
|
@ -93,6 +96,21 @@ func (ah *AngularHelper) isAngularApp(ctx context.Context) (bool, error) {
|
||||||
func (ah *AngularHelper) waitForZoneStability(ctx context.Context) error {
|
func (ah *AngularHelper) waitForZoneStability(ctx context.Context) error {
|
||||||
script := `
|
script := `
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
|
const pollZone = () => {
|
||||||
|
if (!window.Zone || !window.Zone.current) {
|
||||||
|
resolve(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inner = window.Zone.current._inner || window.Zone.current;
|
||||||
|
if (!inner._hasPendingMicrotasks && !inner._hasPendingMacrotasks) {
|
||||||
|
resolve(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(pollZone, 50);
|
||||||
|
};
|
||||||
|
|
||||||
// Get the root elements
|
// Get the root elements
|
||||||
const roots = window.getAllAngularRootElements ? window.getAllAngularRootElements() : [];
|
const roots = window.getAllAngularRootElements ? window.getAllAngularRootElements() : [];
|
||||||
if (roots.length === 0) {
|
if (roots.length === 0) {
|
||||||
|
|
@ -121,28 +139,7 @@ func (ah *AngularHelper) waitForZoneStability(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!zone) {
|
if (!zone) {
|
||||||
// Fallback: check window.Zone
|
pollZone();
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -153,29 +150,33 @@ func (ah *AngularHelper) waitForZoneStability(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for stability
|
// Wait for stability
|
||||||
|
try {
|
||||||
const sub = zone.onStable.subscribe(() => {
|
const sub = zone.onStable.subscribe(() => {
|
||||||
sub.unsubscribe();
|
sub.unsubscribe();
|
||||||
resolve(true);
|
resolve(true);
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
// Timeout fallback
|
pollZone();
|
||||||
setTimeout(() => {
|
}
|
||||||
sub.unsubscribe();
|
|
||||||
resolve(zone.isStable);
|
|
||||||
}, 5000);
|
|
||||||
})
|
})
|
||||||
`
|
`
|
||||||
|
|
||||||
ticker := time.NewTicker(100 * time.Millisecond)
|
result, err := ah.wv.evaluate(ctx, script)
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
// First evaluate the promise
|
|
||||||
_, err := ah.wv.evaluate(ctx, script)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If the script fails, fall back to simple polling
|
// If the script fails, fall back to simple polling
|
||||||
return ah.pollForStability(ctx)
|
if pollErr := ah.pollForStability(ctx); pollErr != nil {
|
||||||
|
return coreerr.E("AngularHelper.WaitForAngular", "failed to wait for Zone stability", pollErr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if stable, ok := result.(bool); ok && stable {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ah.pollForStability(ctx); err != nil {
|
||||||
|
return coreerr.E("AngularHelper.WaitForAngular", "failed to wait for Zone stability", err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,7 +198,7 @@ func (ah *AngularHelper) pollForStability(ctx context.Context) error {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return coreerr.E("AngularHelper.WaitForComponent", "timed out waiting for component", ctx.Err())
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
result, err := ah.wv.evaluate(ctx, script)
|
result, err := ah.wv.evaluate(ctx, script)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -215,7 +216,7 @@ func (ah *AngularHelper) NavigateByRouter(path string) error {
|
||||||
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
|
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := core.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
const roots = window.getAllAngularRootElements ? window.getAllAngularRootElements() : [];
|
const roots = window.getAllAngularRootElements ? window.getAllAngularRootElements() : [];
|
||||||
if (roots.length === 0) {
|
if (roots.length === 0) {
|
||||||
|
|
@ -244,7 +245,10 @@ func (ah *AngularHelper) NavigateByRouter(path string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for navigation to complete
|
// Wait for navigation to complete
|
||||||
return ah.waitForZoneStability(ctx)
|
if err := ah.waitForZoneStability(ctx); err != nil {
|
||||||
|
return coreerr.E("AngularHelper.NavigateByRouter", "failed to wait for router navigation", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRouterState returns the current Angular router state.
|
// GetRouterState returns the current Angular router state.
|
||||||
|
|
@ -277,7 +281,7 @@ func (ah *AngularHelper) GetRouterState() (*AngularRouterState, error) {
|
||||||
|
|
||||||
result, err := ah.wv.evaluate(ctx, script)
|
result, err := ah.wv.evaluate(ctx, script)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, coreerr.E("AngularHelper.GetRouterState", "failed to read router state", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result == nil {
|
if result == nil {
|
||||||
|
|
@ -294,27 +298,12 @@ func (ah *AngularHelper) GetRouterState() (*AngularRouterState, error) {
|
||||||
URL: getString(resultMap, "url"),
|
URL: getString(resultMap, "url"),
|
||||||
}
|
}
|
||||||
|
|
||||||
if fragment, ok := resultMap["fragment"].(string); ok {
|
if fragment, ok := resultMap["fragment"]; ok && fragment != nil {
|
||||||
state.Fragment = fragment
|
state.Fragment = core.Sprint(fragment)
|
||||||
}
|
}
|
||||||
|
|
||||||
if params, ok := resultMap["params"].(map[string]any); ok {
|
state.Params = copyStringOnlyMap(resultMap["params"])
|
||||||
state.Params = make(map[string]string)
|
state.QueryParams = copyStringOnlyMap(resultMap["queryParams"])
|
||||||
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
|
return state, nil
|
||||||
}
|
}
|
||||||
|
|
@ -332,21 +321,27 @@ func (ah *AngularHelper) GetComponentProperty(selector, propertyName string) (an
|
||||||
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
|
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := core.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
const element = document.querySelector(%q);
|
const selector = %s;
|
||||||
|
const propertyName = %s;
|
||||||
|
const element = document.querySelector(selector);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
throw new Error('Element not found: %s');
|
throw new Error('Element not found: ' + selector);
|
||||||
}
|
}
|
||||||
const component = window.ng.probe(element).componentInstance;
|
const component = window.ng.probe(element).componentInstance;
|
||||||
if (!component) {
|
if (!component) {
|
||||||
throw new Error('No Angular component found on element');
|
throw new Error('No Angular component found on element');
|
||||||
}
|
}
|
||||||
return component[%q];
|
return component[propertyName];
|
||||||
})()
|
})()
|
||||||
`, selector, selector, propertyName)
|
`, formatJSValue(selector), formatJSValue(propertyName))
|
||||||
|
|
||||||
return ah.wv.evaluate(ctx, script)
|
result, err := ah.wv.evaluate(ctx, script)
|
||||||
|
if err != nil {
|
||||||
|
return nil, coreerr.E("AngularHelper.GetComponentProperty", "failed to read component property", err)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetComponentProperty sets a property on an Angular component.
|
// SetComponentProperty sets a property on an Angular component.
|
||||||
|
|
@ -354,17 +349,19 @@ func (ah *AngularHelper) SetComponentProperty(selector, propertyName string, val
|
||||||
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
|
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := core.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
const element = document.querySelector(%q);
|
const selector = %s;
|
||||||
|
const propertyName = %s;
|
||||||
|
const element = document.querySelector(selector);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
throw new Error('Element not found: %s');
|
throw new Error('Element not found: ' + selector);
|
||||||
}
|
}
|
||||||
const component = window.ng.probe(element).componentInstance;
|
const component = window.ng.probe(element).componentInstance;
|
||||||
if (!component) {
|
if (!component) {
|
||||||
throw new Error('No Angular component found on element');
|
throw new Error('No Angular component found on element');
|
||||||
}
|
}
|
||||||
component[%q] = %v;
|
component[propertyName] = %s;
|
||||||
|
|
||||||
// Trigger change detection
|
// Trigger change detection
|
||||||
const injector = window.ng.probe(element).injector;
|
const injector = window.ng.probe(element).injector;
|
||||||
|
|
@ -374,10 +371,13 @@ func (ah *AngularHelper) SetComponentProperty(selector, propertyName string, val
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
})()
|
})()
|
||||||
`, selector, selector, propertyName, formatJSValue(value))
|
`, formatJSValue(selector), formatJSValue(propertyName), formatJSValue(value))
|
||||||
|
|
||||||
_, err := ah.wv.evaluate(ctx, script)
|
_, err := ah.wv.evaluate(ctx, script)
|
||||||
return err
|
if err != nil {
|
||||||
|
return coreerr.E("AngularHelper.SetComponentProperty", "failed to set component property", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CallComponentMethod calls a method on an Angular component.
|
// CallComponentMethod calls a method on an Angular component.
|
||||||
|
|
@ -385,7 +385,7 @@ func (ah *AngularHelper) CallComponentMethod(selector, methodName string, args .
|
||||||
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
|
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
var argsStr strings.Builder
|
argsStr := core.NewBuilder()
|
||||||
for i, arg := range args {
|
for i, arg := range args {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
argsStr.WriteString(", ")
|
argsStr.WriteString(", ")
|
||||||
|
|
@ -393,20 +393,22 @@ func (ah *AngularHelper) CallComponentMethod(selector, methodName string, args .
|
||||||
argsStr.WriteString(formatJSValue(arg))
|
argsStr.WriteString(formatJSValue(arg))
|
||||||
}
|
}
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := core.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
const element = document.querySelector(%q);
|
const selector = %s;
|
||||||
|
const methodName = %s;
|
||||||
|
const element = document.querySelector(selector);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
throw new Error('Element not found: %s');
|
throw new Error('Element not found: ' + selector);
|
||||||
}
|
}
|
||||||
const component = window.ng.probe(element).componentInstance;
|
const component = window.ng.probe(element).componentInstance;
|
||||||
if (!component) {
|
if (!component) {
|
||||||
throw new Error('No Angular component found on element');
|
throw new Error('No Angular component found on element');
|
||||||
}
|
}
|
||||||
if (typeof component[%q] !== 'function') {
|
if (typeof component[methodName] !== 'function') {
|
||||||
throw new Error('Method not found: %s');
|
throw new Error('Method not found: ' + methodName);
|
||||||
}
|
}
|
||||||
const result = component[%q](%s);
|
const result = component[methodName](%s);
|
||||||
|
|
||||||
// Trigger change detection
|
// Trigger change detection
|
||||||
const injector = window.ng.probe(element).injector;
|
const injector = window.ng.probe(element).injector;
|
||||||
|
|
@ -416,9 +418,13 @@ func (ah *AngularHelper) CallComponentMethod(selector, methodName string, args .
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
})()
|
})()
|
||||||
`, selector, selector, methodName, methodName, methodName, argsStr.String())
|
`, formatJSValue(selector), formatJSValue(methodName), argsStr.String())
|
||||||
|
|
||||||
return ah.wv.evaluate(ctx, script)
|
result, err := ah.wv.evaluate(ctx, script)
|
||||||
|
if err != nil {
|
||||||
|
return nil, coreerr.E("AngularHelper.CallComponentMethod", "failed to call component method", err)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TriggerChangeDetection manually triggers Angular change detection.
|
// TriggerChangeDetection manually triggers Angular change detection.
|
||||||
|
|
@ -446,7 +452,10 @@ func (ah *AngularHelper) TriggerChangeDetection() error {
|
||||||
`
|
`
|
||||||
|
|
||||||
_, err := ah.wv.evaluate(ctx, script)
|
_, err := ah.wv.evaluate(ctx, script)
|
||||||
return err
|
if err != nil {
|
||||||
|
return coreerr.E("AngularHelper.TriggerChangeDetection", "failed to trigger change detection", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetService gets an Angular service by token name.
|
// GetService gets an Angular service by token name.
|
||||||
|
|
@ -454,7 +463,7 @@ func (ah *AngularHelper) GetService(serviceName string) (any, error) {
|
||||||
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
|
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := core.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
const roots = window.getAllAngularRootElements ? window.getAllAngularRootElements() : [];
|
const roots = window.getAllAngularRootElements ? window.getAllAngularRootElements() : [];
|
||||||
for (const root of roots) {
|
for (const root of roots) {
|
||||||
|
|
@ -473,7 +482,11 @@ func (ah *AngularHelper) GetService(serviceName string) (any, error) {
|
||||||
})()
|
})()
|
||||||
`, serviceName)
|
`, serviceName)
|
||||||
|
|
||||||
return ah.wv.evaluate(ctx, script)
|
result, err := ah.wv.evaluate(ctx, script)
|
||||||
|
if err != nil {
|
||||||
|
return nil, coreerr.E("AngularHelper.GetService", "failed to get Angular service", err)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WaitForComponent waits for an Angular component to be present.
|
// WaitForComponent waits for an Angular component to be present.
|
||||||
|
|
@ -481,7 +494,7 @@ func (ah *AngularHelper) WaitForComponent(selector string) error {
|
||||||
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
|
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := core.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
const element = document.querySelector(%q);
|
const element = document.querySelector(%q);
|
||||||
if (!element) return false;
|
if (!element) return false;
|
||||||
|
|
@ -523,20 +536,25 @@ func (ah *AngularHelper) DispatchEvent(selector, eventName string, detail any) e
|
||||||
detailStr = formatJSValue(detail)
|
detailStr = formatJSValue(detail)
|
||||||
}
|
}
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := core.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
const element = document.querySelector(%q);
|
const selector = %s;
|
||||||
|
const eventName = %s;
|
||||||
|
const element = document.querySelector(selector);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
throw new Error('Element not found: %s');
|
throw new Error('Element not found: ' + selector);
|
||||||
}
|
}
|
||||||
const event = new CustomEvent(%q, { bubbles: true, detail: %s });
|
const event = new CustomEvent(eventName, { bubbles: true, detail: %s });
|
||||||
element.dispatchEvent(event);
|
element.dispatchEvent(event);
|
||||||
return true;
|
return true;
|
||||||
})()
|
})()
|
||||||
`, selector, selector, eventName, detailStr)
|
`, formatJSValue(selector), formatJSValue(eventName), detailStr)
|
||||||
|
|
||||||
_, err := ah.wv.evaluate(ctx, script)
|
_, err := ah.wv.evaluate(ctx, script)
|
||||||
return err
|
if err != nil {
|
||||||
|
return coreerr.E("AngularHelper.DispatchEvent", "failed to dispatch event", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetNgModel gets the value of an ngModel-bound input.
|
// GetNgModel gets the value of an ngModel-bound input.
|
||||||
|
|
@ -544,7 +562,7 @@ func (ah *AngularHelper) GetNgModel(selector string) (any, error) {
|
||||||
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
|
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := core.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
const element = document.querySelector(%q);
|
const element = document.querySelector(%q);
|
||||||
if (!element) return null;
|
if (!element) return null;
|
||||||
|
|
@ -563,7 +581,11 @@ func (ah *AngularHelper) GetNgModel(selector string) (any, error) {
|
||||||
})()
|
})()
|
||||||
`, selector)
|
`, selector)
|
||||||
|
|
||||||
return ah.wv.evaluate(ctx, script)
|
result, err := ah.wv.evaluate(ctx, script)
|
||||||
|
if err != nil {
|
||||||
|
return nil, coreerr.E("AngularHelper.GetNgModel", "failed to read ngModel value", err)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetNgModel sets the value of an ngModel-bound input.
|
// SetNgModel sets the value of an ngModel-bound input.
|
||||||
|
|
@ -571,14 +593,15 @@ func (ah *AngularHelper) SetNgModel(selector string, value any) error {
|
||||||
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
|
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := core.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
const element = document.querySelector(%q);
|
const selector = %s;
|
||||||
|
const element = document.querySelector(selector);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
throw new Error('Element not found: %s');
|
throw new Error('Element not found: ' + selector);
|
||||||
}
|
}
|
||||||
|
|
||||||
element.value = %v;
|
element.value = %s;
|
||||||
element.dispatchEvent(new Event('input', { bubbles: true }));
|
element.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
element.dispatchEvent(new Event('change', { bubbles: true }));
|
element.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
|
||||||
|
|
@ -597,10 +620,13 @@ func (ah *AngularHelper) SetNgModel(selector string, value any) error {
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
})()
|
})()
|
||||||
`, selector, selector, formatJSValue(value))
|
`, formatJSValue(selector), formatJSValue(value))
|
||||||
|
|
||||||
_, err := ah.wv.evaluate(ctx, script)
|
_, err := ah.wv.evaluate(ctx, script)
|
||||||
return err
|
if err != nil {
|
||||||
|
return coreerr.E("AngularHelper.SetNgModel", "failed to set ngModel value", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
|
|
@ -612,18 +638,37 @@ func getString(m map[string]any, key string) string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatJSValue(v any) string {
|
func copyStringOnlyMap(value any) map[string]string {
|
||||||
switch val := v.(type) {
|
switch typed := value.(type) {
|
||||||
case string:
|
case map[string]any:
|
||||||
return fmt.Sprintf("%q", val)
|
result := make(map[string]string, len(typed))
|
||||||
case bool:
|
for key, item := range typed {
|
||||||
if val {
|
if text, ok := item.(string); ok {
|
||||||
return "true"
|
result[key] = text
|
||||||
}
|
}
|
||||||
return "false"
|
}
|
||||||
case nil:
|
return result
|
||||||
return "null"
|
case map[string]string:
|
||||||
|
result := make(map[string]string, len(typed))
|
||||||
|
for key, item := range typed {
|
||||||
|
result[key] = item
|
||||||
|
}
|
||||||
|
return result
|
||||||
default:
|
default:
|
||||||
return fmt.Sprintf("%v", val)
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func formatJSValue(v any) string {
|
||||||
|
r := core.JSONMarshal(v)
|
||||||
|
if r.OK {
|
||||||
|
return string(r.Value.([]byte))
|
||||||
|
}
|
||||||
|
|
||||||
|
r = core.JSONMarshal(core.Sprint(v))
|
||||||
|
if r.OK {
|
||||||
|
return string(r.Value.([]byte))
|
||||||
|
}
|
||||||
|
|
||||||
|
return "null"
|
||||||
|
}
|
||||||
|
|
|
||||||
328
angular_test.go
Normal file
328
angular_test.go
Normal file
|
|
@ -0,0 +1,328 @@
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
|
package webview
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newAngularTestHarness(t *testing.T, onMessage func(*fakeCDPTarget, cdpMessage)) (*AngularHelper, *fakeCDPTarget, *CDPClient) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
server := newFakeCDPServer(t)
|
||||||
|
target := server.primaryTarget()
|
||||||
|
target.onMessage = onMessage
|
||||||
|
|
||||||
|
client := newConnectedCDPClient(t, target)
|
||||||
|
wv := &Webview{
|
||||||
|
client: client,
|
||||||
|
ctx: context.Background(),
|
||||||
|
timeout: time.Second,
|
||||||
|
consoleLogs: make([]ConsoleMessage, 0),
|
||||||
|
consoleLimit: 10,
|
||||||
|
}
|
||||||
|
return NewAngularHelper(wv), target, client
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAngular_SetTimeout_Good(t *testing.T) {
|
||||||
|
ah := NewAngularHelper(&Webview{})
|
||||||
|
ah.SetTimeout(5 * time.Second)
|
||||||
|
if ah.timeout != 5*time.Second {
|
||||||
|
t.Fatalf("SetTimeout = %v, want 5s", ah.timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAngular_WaitForAngular_Bad_NotAngular(t *testing.T) {
|
||||||
|
ah, _, _ := newAngularTestHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
if msg.Method != "Runtime.evaluate" {
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
target.replyValue(msg.ID, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := ah.WaitForAngular(); err == nil {
|
||||||
|
t.Fatal("WaitForAngular succeeded for a non-Angular page")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAngular_WaitForAngular_Good(t *testing.T) {
|
||||||
|
var evaluateCount int
|
||||||
|
ah, _, _ := newAngularTestHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
if msg.Method != "Runtime.evaluate" {
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
evaluateCount++
|
||||||
|
expr, _ := msg.Params["expression"].(string)
|
||||||
|
if strings.Contains(expr, "getAllAngularRootElements") || strings.Contains(expr, "[ng-version]") {
|
||||||
|
target.replyValue(msg.ID, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target.replyValue(msg.ID, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := ah.WaitForAngular(); err != nil {
|
||||||
|
t.Fatalf("WaitForAngular returned error: %v", err)
|
||||||
|
}
|
||||||
|
if evaluateCount < 2 {
|
||||||
|
t.Fatalf("WaitForAngular made %d evaluate calls, want at least 2", evaluateCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAngular_waitForZoneStability_Good_FallsBackToPolling(t *testing.T) {
|
||||||
|
var calls int
|
||||||
|
ah, _, _ := newAngularTestHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
if msg.Method != "Runtime.evaluate" {
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
calls++
|
||||||
|
expr, _ := msg.Params["expression"].(string)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(expr, "new Promise"):
|
||||||
|
target.replyError(msg.ID, "zone probe failed")
|
||||||
|
default:
|
||||||
|
target.replyValue(msg.ID, true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := ah.waitForZoneStability(context.Background()); err != nil {
|
||||||
|
t.Fatalf("waitForZoneStability returned error: %v", err)
|
||||||
|
}
|
||||||
|
if calls < 2 {
|
||||||
|
t.Fatalf("waitForZoneStability made %d evaluate calls, want at least 2", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAngular_NavigateByRouter_Good(t *testing.T) {
|
||||||
|
var expressions []string
|
||||||
|
ah, _, _ := newAngularTestHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
if msg.Method != "Runtime.evaluate" {
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
expr, _ := msg.Params["expression"].(string)
|
||||||
|
expressions = append(expressions, expr)
|
||||||
|
if strings.Contains(expr, "navigateByUrl") {
|
||||||
|
target.replyValue(msg.ID, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target.replyValue(msg.ID, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := ah.NavigateByRouter("/dashboard"); err != nil {
|
||||||
|
t.Fatalf("NavigateByRouter returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(expressions) < 2 {
|
||||||
|
t.Fatalf("NavigateByRouter made %d evaluate calls, want at least 2", len(expressions))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAngular_NavigateByRouter_Bad(t *testing.T) {
|
||||||
|
ah, _, _ := newAngularTestHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
if msg.Method != "Runtime.evaluate" {
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
target.replyError(msg.ID, "could not find router")
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := ah.NavigateByRouter("/dashboard"); err == nil {
|
||||||
|
t.Fatal("NavigateByRouter succeeded despite evaluation error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAngular_GetComponentProperty_Good(t *testing.T) {
|
||||||
|
ah, _, _ := newAngularTestHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
if msg.Method != "Runtime.evaluate" {
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
expr, _ := msg.Params["expression"].(string)
|
||||||
|
if !strings.Contains(expr, `const selector = "app-user";`) {
|
||||||
|
t.Fatalf("expression did not quote selector: %s", expr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(expr, `const propertyName = "displayName";`) {
|
||||||
|
t.Fatalf("expression did not quote property name: %s", expr)
|
||||||
|
}
|
||||||
|
target.replyValue(msg.ID, "Ada")
|
||||||
|
})
|
||||||
|
|
||||||
|
got, err := ah.GetComponentProperty("app-user", "displayName")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetComponentProperty returned error: %v", err)
|
||||||
|
}
|
||||||
|
if got != "Ada" {
|
||||||
|
t.Fatalf("GetComponentProperty = %v, want Ada", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAngular_SetComponentProperty_Good(t *testing.T) {
|
||||||
|
ah, _, _ := newAngularTestHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
if msg.Method != "Runtime.evaluate" {
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
expr, _ := msg.Params["expression"].(string)
|
||||||
|
if !strings.Contains(expr, `component[propertyName] = true;`) {
|
||||||
|
t.Fatalf("expression did not set the component property: %s", expr)
|
||||||
|
}
|
||||||
|
target.replyValue(msg.ID, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := ah.SetComponentProperty("app-user", "active", true); err != nil {
|
||||||
|
t.Fatalf("SetComponentProperty returned error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAngular_CallComponentMethod_Good(t *testing.T) {
|
||||||
|
ah, _, _ := newAngularTestHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
if msg.Method != "Runtime.evaluate" {
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
expr, _ := msg.Params["expression"].(string)
|
||||||
|
if !strings.Contains(expr, `component[methodName](1, "two")`) {
|
||||||
|
t.Fatalf("expression did not marshal method args: %s", expr)
|
||||||
|
}
|
||||||
|
target.replyValue(msg.ID, map[string]any{"ok": true})
|
||||||
|
})
|
||||||
|
|
||||||
|
got, err := ah.CallComponentMethod("app-user", "save", 1, "two")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CallComponentMethod returned error: %v", err)
|
||||||
|
}
|
||||||
|
if gotMap, ok := got.(map[string]any); !ok || gotMap["ok"] != true {
|
||||||
|
t.Fatalf("CallComponentMethod = %#v, want ok=true", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAngular_TriggerChangeDetection_Good(t *testing.T) {
|
||||||
|
ah, _, _ := newAngularTestHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
if msg.Method != "Runtime.evaluate" {
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
target.replyValue(msg.ID, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := ah.TriggerChangeDetection(); err != nil {
|
||||||
|
t.Fatalf("TriggerChangeDetection returned error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAngular_GetService_Good(t *testing.T) {
|
||||||
|
ah, _, _ := newAngularTestHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
if msg.Method != "Runtime.evaluate" {
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
target.replyValue(msg.ID, map[string]any{"name": "session"})
|
||||||
|
})
|
||||||
|
|
||||||
|
got, err := ah.GetService("SessionService")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetService returned error: %v", err)
|
||||||
|
}
|
||||||
|
if gotMap, ok := got.(map[string]any); !ok || gotMap["name"] != "session" {
|
||||||
|
t.Fatalf("GetService = %#v, want session map", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAngular_WaitForComponent_Good(t *testing.T) {
|
||||||
|
var calls int
|
||||||
|
ah, _, _ := newAngularTestHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
if msg.Method != "Runtime.evaluate" {
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
calls++
|
||||||
|
if calls == 1 {
|
||||||
|
target.replyValue(msg.ID, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target.replyValue(msg.ID, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := ah.WaitForComponent("app-user"); err != nil {
|
||||||
|
t.Fatalf("WaitForComponent returned error: %v", err)
|
||||||
|
}
|
||||||
|
if calls < 2 {
|
||||||
|
t.Fatalf("WaitForComponent calls = %d, want at least 2", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAngular_DispatchEvent_Good(t *testing.T) {
|
||||||
|
ah, _, _ := newAngularTestHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
if msg.Method != "Runtime.evaluate" {
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
expr, _ := msg.Params["expression"].(string)
|
||||||
|
if !strings.Contains(expr, `new CustomEvent(eventName, { bubbles: true, detail: {"count":1} })`) && !strings.Contains(expr, `new CustomEvent(eventName, { bubbles: true, detail: {\"count\":1} })`) {
|
||||||
|
t.Fatalf("expression did not dispatch custom event with detail: %s", expr)
|
||||||
|
}
|
||||||
|
target.replyValue(msg.ID, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := ah.DispatchEvent("app-user", "count-change", map[string]any{"count": 1}); err != nil {
|
||||||
|
t.Fatalf("DispatchEvent returned error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAngular_GetNgModel_Good(t *testing.T) {
|
||||||
|
ah, _, _ := newAngularTestHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
if msg.Method != "Runtime.evaluate" {
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
target.replyValue(msg.ID, "hello")
|
||||||
|
})
|
||||||
|
|
||||||
|
got, err := ah.GetNgModel("input[name=email]")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetNgModel returned error: %v", err)
|
||||||
|
}
|
||||||
|
if got != "hello" {
|
||||||
|
t.Fatalf("GetNgModel = %v, want hello", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAngular_SetNgModel_Good(t *testing.T) {
|
||||||
|
ah, _, _ := newAngularTestHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
if msg.Method != "Runtime.evaluate" {
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
target.replyValue(msg.ID, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := ah.SetNgModel(`input[name="x"]`, `";window.hacked=true;//`); err != nil {
|
||||||
|
t.Fatalf("SetNgModel returned error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAngular_copyStringOnlyMap_Good(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in any
|
||||||
|
want map[string]string
|
||||||
|
}{
|
||||||
|
{name: "map any", in: map[string]any{"a": "1", "b": 2}, want: map[string]string{"a": "1"}},
|
||||||
|
{name: "map string", in: map[string]string{"c": "3"}, want: map[string]string{"c": "3"}},
|
||||||
|
{name: "nil", in: nil, want: nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := copyStringOnlyMap(tc.in)
|
||||||
|
if len(got) != len(tc.want) {
|
||||||
|
t.Fatalf("copyStringOnlyMap len = %d, want %d", len(got), len(tc.want))
|
||||||
|
}
|
||||||
|
for k, want := range tc.want {
|
||||||
|
if got[k] != want {
|
||||||
|
t.Fatalf("copyStringOnlyMap[%q] = %q, want %q", k, got[k], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAngular_formatJSValue_Ugly_FallsBackToSprint(t *testing.T) {
|
||||||
|
got := formatJSValue(make(chan int))
|
||||||
|
if got == "null" {
|
||||||
|
t.Fatalf("formatJSValue fallback returned %q, want quoted sprint output", got)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(got, "\"") || !strings.HasSuffix(got, "\"") {
|
||||||
|
t.Fatalf("formatJSValue fallback = %q, want quoted string output", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
856
audit_issue2_test.go
Normal file
856
audit_issue2_test.go
Normal file
|
|
@ -0,0 +1,856 @@
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
|
package webview
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeCDPServer struct {
|
||||||
|
t *testing.T
|
||||||
|
server *httptest.Server
|
||||||
|
mu sync.Mutex
|
||||||
|
nextTarget int
|
||||||
|
targets map[string]*fakeCDPTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeCDPTarget struct {
|
||||||
|
server *fakeCDPServer
|
||||||
|
id string
|
||||||
|
onConnect func(*fakeCDPTarget)
|
||||||
|
onMessage func(*fakeCDPTarget, cdpMessage)
|
||||||
|
connMu sync.Mutex
|
||||||
|
conn *websocket.Conn
|
||||||
|
received chan cdpMessage
|
||||||
|
connected chan struct{}
|
||||||
|
closed chan struct{}
|
||||||
|
connectedOnce sync.Once
|
||||||
|
closedOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeCDPServer(t *testing.T) *fakeCDPServer {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
server := &fakeCDPServer{
|
||||||
|
t: t,
|
||||||
|
targets: make(map[string]*fakeCDPTarget),
|
||||||
|
}
|
||||||
|
server.server = httptest.NewServer(http.HandlerFunc(server.handle))
|
||||||
|
server.addTarget("target-1")
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeCDPServer) Close() {
|
||||||
|
s.server.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeCDPServer) DebugURL() string {
|
||||||
|
return s.server.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeCDPServer) addTarget(id string) *fakeCDPTarget {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
target := &fakeCDPTarget{
|
||||||
|
server: s,
|
||||||
|
id: id,
|
||||||
|
received: make(chan cdpMessage, 16),
|
||||||
|
connected: make(chan struct{}),
|
||||||
|
closed: make(chan struct{}),
|
||||||
|
}
|
||||||
|
s.targets[id] = target
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeCDPServer) newTarget() *fakeCDPTarget {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.nextTarget++
|
||||||
|
id := core.Sprintf("target-%d", s.nextTarget+1)
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
return s.addTarget(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeCDPServer) primaryTarget() *fakeCDPTarget {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.targets["target-1"]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeCDPServer) handle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/json":
|
||||||
|
s.handleListTargets(w)
|
||||||
|
case r.URL.Path == "/json/new":
|
||||||
|
s.handleNewTarget(w)
|
||||||
|
case r.URL.Path == "/json/version":
|
||||||
|
s.writeJSON(w, map[string]string{
|
||||||
|
"Browser": "Chrome/123.0",
|
||||||
|
})
|
||||||
|
case core.HasPrefix(r.URL.Path, "/devtools/page/"):
|
||||||
|
s.handleWebSocket(w, r, core.TrimPrefix(r.URL.Path, "/devtools/page/"))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeCDPServer) handleListTargets(w http.ResponseWriter) {
|
||||||
|
s.mu.Lock()
|
||||||
|
targets := make([]TargetInfo, 0, len(s.targets))
|
||||||
|
for id := range s.targets {
|
||||||
|
targets = append(targets, TargetInfo{
|
||||||
|
ID: id,
|
||||||
|
Type: "page",
|
||||||
|
Title: id,
|
||||||
|
URL: "about:blank",
|
||||||
|
WebSocketDebuggerURL: s.webSocketURL(id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
s.writeJSON(w, targets)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeCDPServer) handleNewTarget(w http.ResponseWriter) {
|
||||||
|
target := s.newTarget()
|
||||||
|
s.writeJSON(w, TargetInfo{
|
||||||
|
ID: target.id,
|
||||||
|
Type: "page",
|
||||||
|
Title: target.id,
|
||||||
|
URL: "about:blank",
|
||||||
|
WebSocketDebuggerURL: s.webSocketURL(target.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeCDPServer) handleWebSocket(w http.ResponseWriter, r *http.Request, id string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
target := s.targets[id]
|
||||||
|
s.mu.Unlock()
|
||||||
|
if target == nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
upgrader := websocket.Upgrader{
|
||||||
|
CheckOrigin: func(*http.Request) bool { return true },
|
||||||
|
}
|
||||||
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
s.t.Fatalf("failed to upgrade test WebSocket: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
target.attach(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeCDPServer) writeJSON(w http.ResponseWriter, value any) {
|
||||||
|
s.t.Helper()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(value); err != nil {
|
||||||
|
s.t.Fatalf("failed to encode JSON: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeCDPServer) webSocketURL(id string) string {
|
||||||
|
wsURL, err := url.Parse(s.server.URL)
|
||||||
|
if err != nil {
|
||||||
|
s.t.Fatalf("failed to parse test server URL: %v", err)
|
||||||
|
}
|
||||||
|
if wsURL.Scheme == "http" {
|
||||||
|
wsURL.Scheme = "ws"
|
||||||
|
} else {
|
||||||
|
wsURL.Scheme = "wss"
|
||||||
|
}
|
||||||
|
wsURL.Path = "/devtools/page/" + id
|
||||||
|
wsURL.RawQuery = ""
|
||||||
|
wsURL.Fragment = ""
|
||||||
|
|
||||||
|
return wsURL.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tgt *fakeCDPTarget) attach(conn *websocket.Conn) {
|
||||||
|
tgt.connMu.Lock()
|
||||||
|
tgt.conn = conn
|
||||||
|
tgt.connMu.Unlock()
|
||||||
|
|
||||||
|
tgt.connectedOnce.Do(func() {
|
||||||
|
close(tgt.connected)
|
||||||
|
})
|
||||||
|
|
||||||
|
go tgt.readLoop()
|
||||||
|
|
||||||
|
if tgt.onConnect != nil {
|
||||||
|
go tgt.onConnect(tgt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tgt *fakeCDPTarget) readLoop() {
|
||||||
|
defer tgt.closedOnce.Do(func() {
|
||||||
|
close(tgt.closed)
|
||||||
|
})
|
||||||
|
|
||||||
|
for {
|
||||||
|
_, data, err := tgt.conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var msg cdpMessage
|
||||||
|
if r := core.JSONUnmarshal(data, &msg); !r.OK {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case tgt.received <- msg:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
if tgt.onMessage != nil {
|
||||||
|
tgt.onMessage(tgt, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tgt *fakeCDPTarget) reply(id int64, result map[string]any) {
|
||||||
|
tgt.writeJSON(cdpResponse{
|
||||||
|
ID: id,
|
||||||
|
Result: result,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tgt *fakeCDPTarget) replyError(id int64, message string) {
|
||||||
|
tgt.writeJSON(cdpResponse{
|
||||||
|
ID: id,
|
||||||
|
Error: &cdpError{
|
||||||
|
Message: message,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tgt *fakeCDPTarget) replyValue(id int64, value any) {
|
||||||
|
tgt.reply(id, map[string]any{
|
||||||
|
"result": map[string]any{
|
||||||
|
"value": value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tgt *fakeCDPTarget) writeJSON(value any) {
|
||||||
|
tgt.server.t.Helper()
|
||||||
|
|
||||||
|
tgt.connMu.Lock()
|
||||||
|
defer tgt.connMu.Unlock()
|
||||||
|
if tgt.conn == nil {
|
||||||
|
tgt.server.t.Fatal("test WebSocket connection was not established")
|
||||||
|
}
|
||||||
|
if err := tgt.conn.WriteJSON(value); err != nil {
|
||||||
|
tgt.server.t.Fatalf("failed to write test WebSocket message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tgt *fakeCDPTarget) closeWebSocket() {
|
||||||
|
tgt.connMu.Lock()
|
||||||
|
defer tgt.connMu.Unlock()
|
||||||
|
if tgt.conn != nil {
|
||||||
|
_ = tgt.conn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tgt *fakeCDPTarget) waitForMessage(tb testing.TB) cdpMessage {
|
||||||
|
tb.Helper()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case msg := <-tgt.received:
|
||||||
|
return msg
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
tb.Fatal("timed out waiting for CDP message")
|
||||||
|
return cdpMessage{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tgt *fakeCDPTarget) waitConnected(tb testing.TB) {
|
||||||
|
tb.Helper()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-tgt.connected:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
tb.Fatal("timed out waiting for WebSocket connection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tgt *fakeCDPTarget) waitClosed(tb testing.TB) {
|
||||||
|
tb.Helper()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-tgt.closed:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
tb.Fatal("timed out waiting for WebSocket closure")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCDPClientClose_Good_UnblocksReadLoop(t *testing.T) {
|
||||||
|
server := newFakeCDPServer(t)
|
||||||
|
target := server.primaryTarget()
|
||||||
|
|
||||||
|
client, err := NewCDPClient(server.DebugURL())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCDPClient returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
target.waitConnected(t)
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- client.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Close returned error: %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Close blocked waiting for readLoop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCDPClientReadLoop_Ugly_StopsOnTerminalReadError(t *testing.T) {
|
||||||
|
server := newFakeCDPServer(t)
|
||||||
|
target := server.primaryTarget()
|
||||||
|
target.onConnect = func(target *fakeCDPTarget) {
|
||||||
|
target.closeWebSocket()
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := NewCDPClient(server.DebugURL())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCDPClient returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-client.done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("readLoop did not stop after terminal read error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCDPClientCloseTab_Good_ClosesTargetOnly(t *testing.T) {
|
||||||
|
server := newFakeCDPServer(t)
|
||||||
|
target := server.primaryTarget()
|
||||||
|
target.onMessage = func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
if msg.Method != "Target.closeTarget" {
|
||||||
|
t.Fatalf("CloseTab sent %q, want Target.closeTarget", msg.Method)
|
||||||
|
}
|
||||||
|
if got := msg.Params["targetId"]; got != target.id {
|
||||||
|
t.Fatalf("Target.closeTarget targetId = %v, want %q", got, target.id)
|
||||||
|
}
|
||||||
|
target.reply(msg.ID, map[string]any{"success": true})
|
||||||
|
go func() {
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
target.closeWebSocket()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := NewCDPClient(server.DebugURL())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCDPClient returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.CloseTab(); err != nil {
|
||||||
|
t.Fatalf("CloseTab returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := target.waitForMessage(t)
|
||||||
|
if msg.Method == "Browser.close" {
|
||||||
|
t.Fatal("CloseTab closed the whole browser")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCDPClientDispatchEvent_Good_HandlerParamsAreIsolated(t *testing.T) {
|
||||||
|
client := &CDPClient{
|
||||||
|
handlers: make(map[string][]func(map[string]any)),
|
||||||
|
}
|
||||||
|
|
||||||
|
firstDone := make(chan map[string]any, 1)
|
||||||
|
secondDone := make(chan map[string]any, 1)
|
||||||
|
|
||||||
|
client.OnEvent("Runtime.testEvent", func(params map[string]any) {
|
||||||
|
params["value"] = "mutated"
|
||||||
|
params["nested"].(map[string]any)["count"] = 1
|
||||||
|
params["items"].([]any)[0].(map[string]any)["id"] = "changed"
|
||||||
|
firstDone <- params
|
||||||
|
})
|
||||||
|
client.OnEvent("Runtime.testEvent", func(params map[string]any) {
|
||||||
|
secondDone <- params
|
||||||
|
})
|
||||||
|
|
||||||
|
original := map[string]any{
|
||||||
|
"nested": map[string]any{"count": 0},
|
||||||
|
"items": []any{map[string]any{"id": "original"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
client.dispatchEvent("Runtime.testEvent", original)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-firstDone:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("first handler did not run")
|
||||||
|
}
|
||||||
|
|
||||||
|
var secondParams map[string]any
|
||||||
|
select {
|
||||||
|
case secondParams = <-secondDone:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("second handler did not run")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := secondParams["value"]; ok {
|
||||||
|
t.Fatal("second handler observed first handler mutation")
|
||||||
|
}
|
||||||
|
if got := secondParams["nested"].(map[string]any)["count"]; got != 0 {
|
||||||
|
t.Fatalf("second handler nested count = %v, want 0", got)
|
||||||
|
}
|
||||||
|
if got := secondParams["items"].([]any)[0].(map[string]any)["id"]; got != "original" {
|
||||||
|
t.Fatalf("second handler slice payload = %v, want %q", got, "original")
|
||||||
|
}
|
||||||
|
if got := original["nested"].(map[string]any)["count"]; got != 0 {
|
||||||
|
t.Fatalf("original params were mutated: nested count = %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewCDPClient_Bad_RejectsCrossHostWebSocket(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/json" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode([]TargetInfo{{
|
||||||
|
ID: "target-1",
|
||||||
|
Type: "page",
|
||||||
|
WebSocketDebuggerURL: "ws://example.com/devtools/page/target-1",
|
||||||
|
}}); err != nil {
|
||||||
|
t.Fatalf("failed to encode targets: %v", err)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
_, err := NewCDPClient(server.URL)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("NewCDPClient succeeded with a cross-host WebSocket URL")
|
||||||
|
}
|
||||||
|
if !core.Contains(err.Error(), "invalid target WebSocket URL") {
|
||||||
|
t.Fatalf("NewCDPClient error = %v, want cross-host WebSocket validation failure", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebviewNew_Bad_ClosesClientWhenEnableConsoleFails(t *testing.T) {
|
||||||
|
server := newFakeCDPServer(t)
|
||||||
|
target := server.primaryTarget()
|
||||||
|
target.onMessage = func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
if msg.Method != "Runtime.enable" {
|
||||||
|
t.Fatalf("enableConsole sent %q before Runtime.enable failed", msg.Method)
|
||||||
|
}
|
||||||
|
target.replyError(msg.ID, "runtime disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := New(
|
||||||
|
WithTimeout(250*time.Millisecond),
|
||||||
|
WithDebugURL(server.DebugURL()),
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("New succeeded when Runtime.enable failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
target.waitClosed(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAngularHelperWaitForZoneStability_Good_AwaitsPromise(t *testing.T) {
|
||||||
|
server := newFakeCDPServer(t)
|
||||||
|
target := server.primaryTarget()
|
||||||
|
target.onMessage = func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
if msg.Method != "Runtime.evaluate" {
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
target.replyValue(msg.ID, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := NewCDPClient(server.DebugURL())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCDPClient returned error: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = client.Close() }()
|
||||||
|
|
||||||
|
wv := &Webview{
|
||||||
|
client: client,
|
||||||
|
ctx: context.Background(),
|
||||||
|
timeout: time.Second,
|
||||||
|
}
|
||||||
|
ah := NewAngularHelper(wv)
|
||||||
|
|
||||||
|
if err := ah.waitForZoneStability(context.Background()); err != nil {
|
||||||
|
t.Fatalf("waitForZoneStability returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := target.waitForMessage(t)
|
||||||
|
if got := msg.Params["awaitPromise"]; got != true {
|
||||||
|
t.Fatalf("Runtime.evaluate awaitPromise = %v, want true", got)
|
||||||
|
}
|
||||||
|
if got := msg.Params["returnByValue"]; got != true {
|
||||||
|
t.Fatalf("Runtime.evaluate returnByValue = %v, want true", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAngularHelperSetNgModel_Good_EscapesSelectorAndValue(t *testing.T) {
|
||||||
|
server := newFakeCDPServer(t)
|
||||||
|
target := server.primaryTarget()
|
||||||
|
target.onMessage = func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
if msg.Method != "Runtime.evaluate" {
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
target.replyValue(msg.ID, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := NewCDPClient(server.DebugURL())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCDPClient returned error: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = client.Close() }()
|
||||||
|
|
||||||
|
wv := &Webview{
|
||||||
|
client: client,
|
||||||
|
ctx: context.Background(),
|
||||||
|
timeout: time.Second,
|
||||||
|
}
|
||||||
|
ah := NewAngularHelper(wv)
|
||||||
|
|
||||||
|
selector := `input[name="x'];window.hacked=true;//"]`
|
||||||
|
value := `";window.hacked=true;//`
|
||||||
|
if err := ah.SetNgModel(selector, value); err != nil {
|
||||||
|
t.Fatalf("SetNgModel returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expression, _ := target.waitForMessage(t).Params["expression"].(string)
|
||||||
|
if !core.Contains(expression, "const selector = "+formatJSValue(selector)+";") {
|
||||||
|
t.Fatalf("expression did not contain safely quoted selector: %s", expression)
|
||||||
|
}
|
||||||
|
if !core.Contains(expression, "element.value = "+formatJSValue(value)+";") {
|
||||||
|
t.Fatalf("expression did not contain safely quoted value: %s", expression)
|
||||||
|
}
|
||||||
|
if core.Contains(expression, "throw new Error('Element not found: "+selector+"')") {
|
||||||
|
t.Fatalf("expression still embedded selector directly in error text: %s", expression)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleWatcherWaitForMessage_Good_IsolatesTemporaryHandlers(t *testing.T) {
|
||||||
|
cw := &ConsoleWatcher{
|
||||||
|
messages: make([]ConsoleMessage, 0),
|
||||||
|
filters: make([]ConsoleFilter, 0),
|
||||||
|
limit: 1000,
|
||||||
|
handlers: make([]consoleHandlerRegistration, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
results := make(chan string, 2)
|
||||||
|
errorsCh := make(chan error, 2)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
msg, err := cw.WaitForMessage(ctx, ConsoleFilter{Type: "error"})
|
||||||
|
if err != nil {
|
||||||
|
errorsCh <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results <- "error:" + msg.Text
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
msg, err := cw.WaitForMessage(ctx, ConsoleFilter{Type: "log"})
|
||||||
|
if err != nil {
|
||||||
|
errorsCh <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results <- "log:" + msg.Text
|
||||||
|
}()
|
||||||
|
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
cw.addMessage(ConsoleMessage{Type: "error", Text: "first"})
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
cw.addMessage(ConsoleMessage{Type: "log", Text: "second"})
|
||||||
|
|
||||||
|
got := make(map[string]bool, 2)
|
||||||
|
for range 2 {
|
||||||
|
select {
|
||||||
|
case err := <-errorsCh:
|
||||||
|
t.Fatalf("WaitForMessage returned error: %v", err)
|
||||||
|
case result := <-results:
|
||||||
|
got[result] = true
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("timed out waiting for console waiter results")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !got["error:first"] || !got["log:second"] {
|
||||||
|
t.Fatalf("unexpected console waiter results: %#v", got)
|
||||||
|
}
|
||||||
|
if len(cw.handlers) != 0 {
|
||||||
|
t.Fatalf("temporary handlers leaked: %d", len(cw.handlers))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExceptionWatcherWaitForException_Good_PreservesExistingHandlers(t *testing.T) {
|
||||||
|
ew := &ExceptionWatcher{
|
||||||
|
exceptions: make([]ExceptionInfo, 0),
|
||||||
|
handlers: make([]exceptionHandlerRegistration, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
waitDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := ew.WaitForException(ctx)
|
||||||
|
waitDone <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
count := 0
|
||||||
|
ew.AddHandler(func(ExceptionInfo) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
count++
|
||||||
|
})
|
||||||
|
|
||||||
|
ew.handleException(map[string]any{
|
||||||
|
"exceptionDetails": map[string]any{
|
||||||
|
"text": "first",
|
||||||
|
"lineNumber": float64(1),
|
||||||
|
"columnNumber": float64(1),
|
||||||
|
"url": "https://example.com/app.js",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-waitDone:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WaitForException returned error: %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("timed out waiting for exception waiter")
|
||||||
|
}
|
||||||
|
|
||||||
|
ew.handleException(map[string]any{
|
||||||
|
"exceptionDetails": map[string]any{
|
||||||
|
"text": "second",
|
||||||
|
"lineNumber": float64(2),
|
||||||
|
"columnNumber": float64(1),
|
||||||
|
"url": "https://example.com/app.js",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if count != 2 {
|
||||||
|
t.Fatalf("persistent handler count = %d, want 2", count)
|
||||||
|
}
|
||||||
|
if len(ew.handlers) != 1 {
|
||||||
|
t.Fatalf("unexpected handler count after waiter removal: %d", len(ew.handlers))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebviewGoBack_Good_UsesNavigationHistoryAndWaitsForLoad(t *testing.T) {
|
||||||
|
server := newFakeCDPServer(t)
|
||||||
|
target := server.primaryTarget()
|
||||||
|
|
||||||
|
var methods []string
|
||||||
|
target.onMessage = func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
methods = append(methods, msg.Method)
|
||||||
|
|
||||||
|
switch msg.Method {
|
||||||
|
case "Page.getNavigationHistory":
|
||||||
|
target.reply(msg.ID, map[string]any{
|
||||||
|
"currentIndex": float64(1),
|
||||||
|
"entries": []any{
|
||||||
|
map[string]any{"id": float64(11)},
|
||||||
|
map[string]any{"id": float64(12)},
|
||||||
|
map[string]any{"id": float64(13)},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
case "Page.navigateToHistoryEntry":
|
||||||
|
if got, ok := msg.Params["entryId"].(float64); !ok || got != 11 {
|
||||||
|
t.Fatalf("navigateToHistoryEntry entryId = %v, want 11", msg.Params["entryId"])
|
||||||
|
}
|
||||||
|
target.reply(msg.ID, map[string]any{})
|
||||||
|
case "Runtime.evaluate":
|
||||||
|
target.replyValue(msg.ID, "complete")
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := NewCDPClient(server.DebugURL())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCDPClient returned error: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = client.Close() }()
|
||||||
|
|
||||||
|
wv := &Webview{
|
||||||
|
client: client,
|
||||||
|
ctx: context.Background(),
|
||||||
|
timeout: time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := wv.GoBack(); err != nil {
|
||||||
|
t.Fatalf("GoBack returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(methods) != 3 {
|
||||||
|
t.Fatalf("expected 3 CDP calls, got %d (%v)", len(methods), methods)
|
||||||
|
}
|
||||||
|
if methods[0] != "Page.getNavigationHistory" || methods[1] != "Page.navigateToHistoryEntry" || methods[2] != "Runtime.evaluate" {
|
||||||
|
t.Fatalf("unexpected call sequence: %v", methods)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebviewGoForward_Good_UsesNavigationHistoryAndWaitsForLoad(t *testing.T) {
|
||||||
|
server := newFakeCDPServer(t)
|
||||||
|
target := server.primaryTarget()
|
||||||
|
target.onMessage = func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
switch msg.Method {
|
||||||
|
case "Page.getNavigationHistory":
|
||||||
|
target.reply(msg.ID, map[string]any{
|
||||||
|
"currentIndex": float64(1),
|
||||||
|
"entries": []any{
|
||||||
|
map[string]any{"id": float64(11)},
|
||||||
|
map[string]any{"id": float64(12)},
|
||||||
|
map[string]any{"id": float64(13)},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
case "Page.navigateToHistoryEntry":
|
||||||
|
if got, ok := msg.Params["entryId"].(float64); !ok || got != 13 {
|
||||||
|
t.Fatalf("navigateToHistoryEntry entryId = %v, want 13", msg.Params["entryId"])
|
||||||
|
}
|
||||||
|
target.reply(msg.ID, map[string]any{})
|
||||||
|
case "Runtime.evaluate":
|
||||||
|
target.replyValue(msg.ID, "complete")
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := NewCDPClient(server.DebugURL())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCDPClient returned error: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = client.Close() }()
|
||||||
|
|
||||||
|
wv := &Webview{
|
||||||
|
client: client,
|
||||||
|
ctx: context.Background(),
|
||||||
|
timeout: time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := wv.GoForward(); err != nil {
|
||||||
|
t.Fatalf("GoForward returned error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebviewEvaluate_Bad_UsesExceptionText(t *testing.T) {
|
||||||
|
server := newFakeCDPServer(t)
|
||||||
|
target := server.primaryTarget()
|
||||||
|
target.onMessage = func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
if msg.Method != "Runtime.evaluate" {
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
target.writeJSON(cdpResponse{
|
||||||
|
ID: msg.ID,
|
||||||
|
Result: map[string]any{
|
||||||
|
"exceptionDetails": map[string]any{
|
||||||
|
"text": "ReferenceError: missingValue is not defined",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := NewCDPClient(server.DebugURL())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCDPClient returned error: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = client.Close() }()
|
||||||
|
|
||||||
|
wv := &Webview{
|
||||||
|
client: client,
|
||||||
|
ctx: context.Background(),
|
||||||
|
timeout: time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := wv.Evaluate("missingValue"); err == nil || !core.Contains(err.Error(), "ReferenceError: missingValue is not defined") {
|
||||||
|
t.Fatalf("Evaluate error = %v, want exception text", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAngularHelperGetRouterState_Good_KeepsOnlyStringParams(t *testing.T) {
|
||||||
|
server := newFakeCDPServer(t)
|
||||||
|
target := server.primaryTarget()
|
||||||
|
target.onMessage = func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
if msg.Method != "Runtime.evaluate" {
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
target.replyValue(msg.ID, map[string]any{
|
||||||
|
"url": "/items/123",
|
||||||
|
"fragment": "details",
|
||||||
|
"params": map[string]any{
|
||||||
|
"id": "123",
|
||||||
|
"active": true,
|
||||||
|
},
|
||||||
|
"queryParams": map[string]any{
|
||||||
|
"page": "2",
|
||||||
|
"debug": float64(1),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := NewCDPClient(server.DebugURL())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCDPClient returned error: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = client.Close() }()
|
||||||
|
|
||||||
|
wv := &Webview{
|
||||||
|
client: client,
|
||||||
|
ctx: context.Background(),
|
||||||
|
timeout: time.Second,
|
||||||
|
}
|
||||||
|
ah := NewAngularHelper(wv)
|
||||||
|
|
||||||
|
state, err := ah.GetRouterState()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetRouterState returned error: %v", err)
|
||||||
|
}
|
||||||
|
if state.Params["id"] != "123" {
|
||||||
|
t.Fatalf("unexpected params: %#v", state.Params)
|
||||||
|
}
|
||||||
|
if _, ok := state.Params["active"]; ok {
|
||||||
|
t.Fatalf("expected non-string params to be omitted, got %#v", state.Params)
|
||||||
|
}
|
||||||
|
if state.QueryParams["page"] != "2" {
|
||||||
|
t.Fatalf("unexpected query params: %#v", state.QueryParams)
|
||||||
|
}
|
||||||
|
if _, ok := state.QueryParams["debug"]; ok {
|
||||||
|
t.Fatalf("expected non-string query params to be omitted, got %#v", state.QueryParams)
|
||||||
|
}
|
||||||
|
}
|
||||||
565
cdp.go
565
cdp.go
|
|
@ -1,18 +1,37 @@
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
package webview
|
package webview
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"io"
|
"io"
|
||||||
"iter"
|
"iter"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
"slices"
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
const debugEndpointTimeout = 10 * time.Second
|
||||||
|
const maxDebugResponseBytes = 1 << 20
|
||||||
|
const maxCDPMessageBytes = 16 << 20
|
||||||
|
|
||||||
|
var (
|
||||||
|
defaultDebugHTTPClient = &http.Client{
|
||||||
|
Timeout: debugEndpointTimeout,
|
||||||
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
},
|
||||||
|
}
|
||||||
|
errCDPClientClosed = core.NewError("cdp client closed")
|
||||||
)
|
)
|
||||||
|
|
||||||
// CDPClient handles communication with Chrome DevTools Protocol via WebSocket.
|
// CDPClient handles communication with Chrome DevTools Protocol via WebSocket.
|
||||||
|
|
@ -20,21 +39,24 @@ type CDPClient struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
conn *websocket.Conn
|
conn *websocket.Conn
|
||||||
debugURL string
|
debugURL string
|
||||||
|
debugHTTPURL *url.URL
|
||||||
wsURL string
|
wsURL string
|
||||||
|
|
||||||
// Message tracking
|
// Message tracking
|
||||||
msgID atomic.Int64
|
messageID atomic.Int64
|
||||||
pending map[int64]chan *cdpResponse
|
pending map[int64]chan *cdpResponse
|
||||||
pendMu sync.Mutex
|
pendingMu sync.Mutex
|
||||||
|
|
||||||
// Event handlers
|
// Event handlers
|
||||||
handlers map[string][]func(map[string]any)
|
handlers map[string][]func(map[string]any)
|
||||||
handMu sync.RWMutex
|
handlersMu sync.RWMutex
|
||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
|
closeOnce sync.Once
|
||||||
|
closeErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
// cdpMessage represents a CDP protocol message.
|
// cdpMessage represents a CDP protocol message.
|
||||||
|
|
@ -76,51 +98,41 @@ type TargetInfo struct {
|
||||||
// NewCDPClient creates a new CDP client connected to the given debug URL.
|
// 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).
|
// The debug URL should be the Chrome DevTools HTTP endpoint (e.g., http://localhost:9222).
|
||||||
func NewCDPClient(debugURL string) (*CDPClient, error) {
|
func NewCDPClient(debugURL string) (*CDPClient, error) {
|
||||||
// Get available targets
|
debugHTTPURL, err := parseDebugURL(debugURL)
|
||||||
resp, err := http.Get(debugURL + "/json")
|
if err != nil {
|
||||||
|
return nil, coreerr.E("CDPClient.New", "invalid debug URL", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), debugEndpointTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
targets, err := listTargetsAt(ctx, debugHTTPURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, coreerr.E("CDPClient.New", "failed to get targets", err)
|
return nil, coreerr.E("CDPClient.New", "failed to get targets", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, coreerr.E("CDPClient.New", "failed to read targets", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var targets []TargetInfo
|
|
||||||
if err := json.Unmarshal(body, &targets); err != nil {
|
|
||||||
return nil, coreerr.E("CDPClient.New", "failed to parse targets", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find a page target
|
// Find a page target
|
||||||
var wsURL string
|
var wsURL string
|
||||||
for _, t := range targets {
|
for _, t := range targets {
|
||||||
if t.Type == "page" && t.WebSocketDebuggerURL != "" {
|
if t.Type == "page" && t.WebSocketDebuggerURL != "" {
|
||||||
wsURL = t.WebSocketDebuggerURL
|
wsURL, err = validateTargetWebSocketURL(debugHTTPURL, t.WebSocketDebuggerURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, coreerr.E("CDPClient.New", "invalid target WebSocket URL", err)
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if wsURL == "" {
|
if wsURL == "" {
|
||||||
// Try to create a new target
|
newTarget, err := createTargetAt(ctx, debugHTTPURL, "")
|
||||||
resp, err := http.Get(debugURL + "/json/new")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, coreerr.E("CDPClient.New", "no page targets found and failed to create new", err)
|
return nil, coreerr.E("CDPClient.New", "no page targets found and failed to create new", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
wsURL, err = validateTargetWebSocketURL(debugHTTPURL, newTarget.WebSocketDebuggerURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, coreerr.E("CDPClient.New", "failed to read new target", err)
|
return nil, coreerr.E("CDPClient.New", "invalid new target WebSocket URL", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var newTarget TargetInfo
|
|
||||||
if err := json.Unmarshal(body, &newTarget); err != nil {
|
|
||||||
return nil, coreerr.E("CDPClient.New", "failed to parse new target", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
wsURL = newTarget.WebSocketDebuggerURL
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if wsURL == "" {
|
if wsURL == "" {
|
||||||
|
|
@ -132,53 +144,41 @@ func NewCDPClient(debugURL string) (*CDPClient, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, coreerr.E("CDPClient.New", "failed to connect to WebSocket", err)
|
return nil, coreerr.E("CDPClient.New", "failed to connect to WebSocket", err)
|
||||||
}
|
}
|
||||||
|
conn.SetReadLimit(maxCDPMessageBytes)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
return newCDPClient(debugHTTPURL, wsURL, conn), nil
|
||||||
|
|
||||||
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.
|
// Close closes the CDP connection.
|
||||||
func (c *CDPClient) Close() error {
|
func (c *CDPClient) Close() error {
|
||||||
c.cancel()
|
c.close(errCDPClientClosed)
|
||||||
<-c.done // Wait for read loop to finish
|
<-c.done
|
||||||
return c.conn.Close()
|
if c.closeErr != nil {
|
||||||
|
return coreerr.E("CDPClient.Close", "failed to close WebSocket", c.closeErr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call sends a CDP method call and waits for the response.
|
// 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) {
|
func (c *CDPClient) Call(ctx context.Context, method string, params map[string]any) (map[string]any, error) {
|
||||||
id := c.msgID.Add(1)
|
id := c.messageID.Add(1)
|
||||||
|
|
||||||
msg := cdpMessage{
|
msg := cdpMessage{
|
||||||
ID: id,
|
ID: id,
|
||||||
Method: method,
|
Method: method,
|
||||||
Params: params,
|
Params: cloneMapAny(params),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register response channel
|
// Register response channel
|
||||||
respCh := make(chan *cdpResponse, 1)
|
respCh := make(chan *cdpResponse, 1)
|
||||||
c.pendMu.Lock()
|
c.pendingMu.Lock()
|
||||||
c.pending[id] = respCh
|
c.pending[id] = respCh
|
||||||
c.pendMu.Unlock()
|
c.pendingMu.Unlock()
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
c.pendMu.Lock()
|
c.pendingMu.Lock()
|
||||||
delete(c.pending, id)
|
delete(c.pending, id)
|
||||||
c.pendMu.Unlock()
|
c.pendingMu.Unlock()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Send message
|
// Send message
|
||||||
|
|
@ -193,6 +193,8 @@ func (c *CDPClient) Call(ctx context.Context, method string, params map[string]a
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return nil, ctx.Err()
|
return nil, ctx.Err()
|
||||||
|
case <-c.ctx.Done():
|
||||||
|
return nil, coreerr.E("CDPClient.Call", "client closed", errCDPClientClosed)
|
||||||
case resp := <-respCh:
|
case resp := <-respCh:
|
||||||
if resp.Error != nil {
|
if resp.Error != nil {
|
||||||
return nil, coreerr.E("CDPClient.Call", resp.Error.Message, nil)
|
return nil, coreerr.E("CDPClient.Call", resp.Error.Message, nil)
|
||||||
|
|
@ -203,8 +205,8 @@ func (c *CDPClient) Call(ctx context.Context, method string, params map[string]a
|
||||||
|
|
||||||
// OnEvent registers a handler for CDP events.
|
// OnEvent registers a handler for CDP events.
|
||||||
func (c *CDPClient) OnEvent(method string, handler func(map[string]any)) {
|
func (c *CDPClient) OnEvent(method string, handler func(map[string]any)) {
|
||||||
c.handMu.Lock()
|
c.handlersMu.Lock()
|
||||||
defer c.handMu.Unlock()
|
defer c.handlersMu.Unlock()
|
||||||
c.handlers[method] = append(c.handlers[method], handler)
|
c.handlers[method] = append(c.handlers[method], handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -213,39 +215,43 @@ func (c *CDPClient) readLoop() {
|
||||||
defer close(c.done)
|
defer close(c.done)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
|
||||||
case <-c.ctx.Done():
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
_, data, err := c.conn.ReadMessage()
|
_, data, err := c.conn.ReadMessage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Check if context was cancelled
|
if c.ctx.Err() != nil {
|
||||||
select {
|
|
||||||
case <-c.ctx.Done():
|
|
||||||
return
|
return
|
||||||
default:
|
}
|
||||||
// Log error but continue (could be temporary)
|
if isTerminalReadError(err) {
|
||||||
|
c.close(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var netErr net.Error
|
||||||
|
if core.As(err, &netErr) && netErr.Timeout() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.close(err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to parse as response
|
// Try to parse as response
|
||||||
var resp cdpResponse
|
var resp cdpResponse
|
||||||
if err := json.Unmarshal(data, &resp); err == nil && resp.ID > 0 {
|
if r := core.JSONUnmarshal(data, &resp); r.OK && resp.ID > 0 {
|
||||||
c.pendMu.Lock()
|
c.pendingMu.Lock()
|
||||||
if ch, ok := c.pending[resp.ID]; ok {
|
if ch, ok := c.pending[resp.ID]; ok {
|
||||||
respCopy := resp
|
respCopy := resp
|
||||||
ch <- &respCopy
|
select {
|
||||||
|
case ch <- &respCopy:
|
||||||
|
default:
|
||||||
}
|
}
|
||||||
c.pendMu.Unlock()
|
}
|
||||||
|
c.pendingMu.Unlock()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to parse as event
|
// Try to parse as event
|
||||||
var event cdpEvent
|
var event cdpEvent
|
||||||
if err := json.Unmarshal(data, &event); err == nil && event.Method != "" {
|
if r := core.JSONUnmarshal(data, &event); r.OK && event.Method != "" {
|
||||||
c.dispatchEvent(event.Method, event.Params)
|
c.dispatchEvent(event.Method, event.Params)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -253,13 +259,14 @@ func (c *CDPClient) readLoop() {
|
||||||
|
|
||||||
// dispatchEvent dispatches an event to registered handlers.
|
// dispatchEvent dispatches an event to registered handlers.
|
||||||
func (c *CDPClient) dispatchEvent(method string, params map[string]any) {
|
func (c *CDPClient) dispatchEvent(method string, params map[string]any) {
|
||||||
c.handMu.RLock()
|
c.handlersMu.RLock()
|
||||||
handlers := slices.Clone(c.handlers[method])
|
handlers := slices.Clone(c.handlers[method])
|
||||||
c.handMu.RUnlock()
|
c.handlersMu.RUnlock()
|
||||||
|
|
||||||
for _, handler := range handlers {
|
for _, handler := range handlers {
|
||||||
// Call handler in goroutine to avoid blocking
|
// Call handler in goroutine to avoid blocking
|
||||||
go handler(params)
|
handlerParams := cloneMapAny(params)
|
||||||
|
go handler(handlerParams)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -267,7 +274,7 @@ func (c *CDPClient) dispatchEvent(method string, params map[string]any) {
|
||||||
func (c *CDPClient) Send(method string, params map[string]any) error {
|
func (c *CDPClient) Send(method string, params map[string]any) error {
|
||||||
msg := cdpMessage{
|
msg := cdpMessage{
|
||||||
Method: method,
|
Method: method,
|
||||||
Params: params,
|
Params: cloneMapAny(params),
|
||||||
}
|
}
|
||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
|
|
@ -287,83 +294,72 @@ func (c *CDPClient) WebSocketURL() string {
|
||||||
|
|
||||||
// NewTab creates a new browser tab and returns a new CDPClient connected to it.
|
// NewTab creates a new browser tab and returns a new CDPClient connected to it.
|
||||||
func (c *CDPClient) NewTab(url string) (*CDPClient, error) {
|
func (c *CDPClient) NewTab(url string) (*CDPClient, error) {
|
||||||
endpoint := c.debugURL + "/json/new"
|
ctx, cancel := context.WithTimeout(c.ctx, debugEndpointTimeout)
|
||||||
if url != "" {
|
defer cancel()
|
||||||
endpoint += "?" + url
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := http.Get(endpoint)
|
target, err := createTargetAt(ctx, c.debugHTTPURL, url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, coreerr.E("CDPClient.NewTab", "failed to create new tab", err)
|
return nil, coreerr.E("CDPClient.NewTab", "failed to create new tab", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, coreerr.E("CDPClient.NewTab", "failed to read response", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var target TargetInfo
|
|
||||||
if err := json.Unmarshal(body, &target); err != nil {
|
|
||||||
return nil, coreerr.E("CDPClient.NewTab", "failed to parse target", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if target.WebSocketDebuggerURL == "" {
|
if target.WebSocketDebuggerURL == "" {
|
||||||
return nil, coreerr.E("CDPClient.NewTab", "no WebSocket URL for new tab", nil)
|
return nil, coreerr.E("CDPClient.NewTab", "no WebSocket URL for new tab", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
wsURL, err := validateTargetWebSocketURL(c.debugHTTPURL, target.WebSocketDebuggerURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, coreerr.E("CDPClient.NewTab", "invalid WebSocket URL for new tab", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Connect to new tab
|
// Connect to new tab
|
||||||
conn, _, err := websocket.DefaultDialer.Dial(target.WebSocketDebuggerURL, nil)
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, coreerr.E("CDPClient.NewTab", "failed to connect to new tab", err)
|
return nil, coreerr.E("CDPClient.NewTab", "failed to connect to new tab", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
return newCDPClient(c.debugHTTPURL, wsURL, conn), nil
|
||||||
|
|
||||||
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).
|
// CloseTab closes the current tab (target).
|
||||||
func (c *CDPClient) CloseTab() error {
|
func (c *CDPClient) CloseTab() error {
|
||||||
// Extract target ID from WebSocket URL
|
targetID, err := targetIDFromWebSocketURL(c.wsURL)
|
||||||
// Format: ws://host:port/devtools/page/TARGET_ID
|
if err != nil {
|
||||||
// We'll use the Browser.close target API
|
return coreerr.E("CDPClient.CloseTab", "failed to determine target ID", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = c.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx, cancel := context.WithTimeout(c.ctx, debugEndpointTimeout)
|
||||||
_, err := c.Call(ctx, "Browser.close", nil)
|
defer cancel()
|
||||||
return err
|
|
||||||
|
result, err := c.Call(ctx, "Target.closeTarget", map[string]any{
|
||||||
|
"targetId": targetID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return coreerr.E("CDPClient.CloseTab", "failed to close target", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if success, ok := result["success"].(bool); ok && !success {
|
||||||
|
return coreerr.E("CDPClient.CloseTab", "target close was not acknowledged", nil)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListTargets returns all available targets.
|
// ListTargets returns all available targets.
|
||||||
func ListTargets(debugURL string) ([]TargetInfo, error) {
|
func ListTargets(debugURL string) ([]TargetInfo, error) {
|
||||||
resp, err := http.Get(debugURL + "/json")
|
debugHTTPURL, err := parseDebugURL(debugURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, coreerr.E("ListTargets", "invalid debug URL", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), debugEndpointTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
targets, err := listTargetsAt(ctx, debugHTTPURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, coreerr.E("ListTargets", "failed to get targets", err)
|
return nil, coreerr.E("ListTargets", "failed to get targets", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, coreerr.E("ListTargets", "failed to read targets", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var targets []TargetInfo
|
|
||||||
if err := json.Unmarshal(body, &targets); err != nil {
|
|
||||||
return nil, coreerr.E("ListTargets", "failed to parse targets", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return targets, nil
|
return targets, nil
|
||||||
}
|
}
|
||||||
|
|
@ -385,21 +381,312 @@ func ListTargetsAll(debugURL string) iter.Seq[TargetInfo] {
|
||||||
|
|
||||||
// GetVersion returns Chrome version information.
|
// GetVersion returns Chrome version information.
|
||||||
func GetVersion(debugURL string) (map[string]string, error) {
|
func GetVersion(debugURL string) (map[string]string, error) {
|
||||||
resp, err := http.Get(debugURL + "/json/version")
|
debugHTTPURL, err := parseDebugURL(debugURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, coreerr.E("GetVersion", "invalid debug URL", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), debugEndpointTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
body, err := doDebugRequest(ctx, debugHTTPURL, "/json/version", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, coreerr.E("GetVersion", "failed to get version", err)
|
return nil, coreerr.E("GetVersion", "failed to get version", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, coreerr.E("GetVersion", "failed to read version", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var version map[string]string
|
var version map[string]string
|
||||||
if err := json.Unmarshal(body, &version); err != nil {
|
if r := core.JSONUnmarshal(body, &version); !r.OK {
|
||||||
return nil, coreerr.E("GetVersion", "failed to parse version", err)
|
return nil, coreerr.E("GetVersion", "failed to parse version", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
return version, nil
|
return version, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newCDPClient(debugHTTPURL *url.URL, wsURL string, conn *websocket.Conn) *CDPClient {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
baseCopy := *debugHTTPURL
|
||||||
|
conn.SetReadLimit(maxCDPMessageBytes)
|
||||||
|
|
||||||
|
client := &CDPClient{
|
||||||
|
conn: conn,
|
||||||
|
debugURL: canonicalDebugURL(&baseCopy),
|
||||||
|
debugHTTPURL: &baseCopy,
|
||||||
|
wsURL: wsURL,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDebugURL(raw string) (*url.URL, error) {
|
||||||
|
debugURL, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if debugURL.Scheme != "http" && debugURL.Scheme != "https" {
|
||||||
|
return nil, coreerr.E("CDPClient.parseDebugURL", "debug URL must use http or https", nil)
|
||||||
|
}
|
||||||
|
if debugURL.Host == "" {
|
||||||
|
return nil, coreerr.E("CDPClient.parseDebugURL", "debug URL host is required", nil)
|
||||||
|
}
|
||||||
|
if debugURL.User != nil {
|
||||||
|
return nil, coreerr.E("CDPClient.parseDebugURL", "debug URL must not include credentials", nil)
|
||||||
|
}
|
||||||
|
if debugURL.RawQuery != "" || debugURL.Fragment != "" {
|
||||||
|
return nil, coreerr.E("CDPClient.parseDebugURL", "debug URL must not include query or fragment", nil)
|
||||||
|
}
|
||||||
|
if debugURL.Path == "" {
|
||||||
|
debugURL.Path = "/"
|
||||||
|
}
|
||||||
|
if debugURL.Path != "/" {
|
||||||
|
return nil, coreerr.E("CDPClient.parseDebugURL", "debug URL must point at the DevTools root", nil)
|
||||||
|
}
|
||||||
|
if !isLoopbackHost(debugURL.Hostname()) {
|
||||||
|
return nil, coreerr.E("CDPClient.parseDebugURL", "debug URL host must be localhost or loopback", nil)
|
||||||
|
}
|
||||||
|
return debugURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLoopbackHost(host string) bool {
|
||||||
|
if host == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if core.Lower(host) == "localhost" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
ip := net.ParseIP(host)
|
||||||
|
return ip != nil && ip.IsLoopback()
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalDebugURL(debugURL *url.URL) string {
|
||||||
|
return core.TrimSuffix(debugURL.String(), "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func doDebugRequest(ctx context.Context, debugHTTPURL *url.URL, endpoint, rawQuery string) ([]byte, error) {
|
||||||
|
reqURL := *debugHTTPURL
|
||||||
|
reqURL.Path = endpoint
|
||||||
|
reqURL.RawPath = ""
|
||||||
|
reqURL.RawQuery = rawQuery
|
||||||
|
reqURL.Fragment = ""
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := defaultDebugHTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||||
|
return nil, coreerr.E("CDPClient.doDebugRequest", "debug endpoint returned "+resp.Status, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxDebugResponseBytes+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(body) > maxDebugResponseBytes {
|
||||||
|
return nil, coreerr.E("CDPClient.doDebugRequest", "debug endpoint response too large", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listTargetsAt(ctx context.Context, debugHTTPURL *url.URL) ([]TargetInfo, error) {
|
||||||
|
body, err := doDebugRequest(ctx, debugHTTPURL, "/json", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var targets []TargetInfo
|
||||||
|
if r := core.JSONUnmarshal(body, &targets); !r.OK {
|
||||||
|
return nil, coreerr.E("CDPClient.listTargetsAt", "failed to parse targets", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return targets, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTargetAt(ctx context.Context, debugHTTPURL *url.URL, pageURL string) (*TargetInfo, error) {
|
||||||
|
if pageURL != "" {
|
||||||
|
if err := validateNavigationURL(pageURL); err != nil {
|
||||||
|
return nil, coreerr.E("CDPClient.createTargetAt", "invalid page URL", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rawQuery := ""
|
||||||
|
if pageURL != "" {
|
||||||
|
rawQuery = url.QueryEscape(pageURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := doDebugRequest(ctx, debugHTTPURL, "/json/new", rawQuery)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var target TargetInfo
|
||||||
|
if r := core.JSONUnmarshal(body, &target); !r.OK {
|
||||||
|
return nil, coreerr.E("CDPClient.createTargetAt", "failed to parse target", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &target, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateTargetWebSocketURL(debugHTTPURL *url.URL, raw string) (string, error) {
|
||||||
|
wsURL, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if wsURL.Scheme != "ws" && wsURL.Scheme != "wss" {
|
||||||
|
return "", coreerr.E("CDPClient.validateTargetWebSocketURL", "target WebSocket URL must use ws or wss", nil)
|
||||||
|
}
|
||||||
|
if !sameEndpointHost(debugHTTPURL, wsURL) {
|
||||||
|
return "", coreerr.E("CDPClient.validateTargetWebSocketURL", "target WebSocket URL must match debug URL host", nil)
|
||||||
|
}
|
||||||
|
return wsURL.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateNavigationURL(raw string) error {
|
||||||
|
navigationURL, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch core.Lower(navigationURL.Scheme) {
|
||||||
|
case "http", "https":
|
||||||
|
if navigationURL.Host == "" {
|
||||||
|
return coreerr.E("CDPClient.validateNavigationURL", "navigation URL host is required", nil)
|
||||||
|
}
|
||||||
|
if navigationURL.User != nil {
|
||||||
|
return coreerr.E("CDPClient.validateNavigationURL", "navigation URL must not include credentials", nil)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case "about":
|
||||||
|
if raw == "about:blank" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return coreerr.E("CDPClient.validateNavigationURL", "only about:blank is permitted for non-http navigation", nil)
|
||||||
|
default:
|
||||||
|
return coreerr.E("CDPClient.validateNavigationURL", "navigation URL must use http, https, or about:blank", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameEndpointHost(httpURL, wsURL *url.URL) bool {
|
||||||
|
return core.Lower(httpURL.Hostname()) == core.Lower(wsURL.Hostname()) && normalisedPort(httpURL) == normalisedPort(wsURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalisedPort(u *url.URL) string {
|
||||||
|
if port := u.Port(); port != "" {
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
|
||||||
|
switch u.Scheme {
|
||||||
|
case "http", "ws":
|
||||||
|
return "80"
|
||||||
|
case "https", "wss":
|
||||||
|
return "443"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func targetIDFromWebSocketURL(raw string) (string, error) {
|
||||||
|
wsURL, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
targetID := path.Base(core.TrimSuffix(wsURL.Path, "/"))
|
||||||
|
if targetID == "." || targetID == "/" || targetID == "" {
|
||||||
|
return "", coreerr.E("CDPClient.targetIDFromWebSocketURL", "missing target ID in WebSocket URL", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CDPClient) close(reason error) {
|
||||||
|
c.closeOnce.Do(func() {
|
||||||
|
c.cancel()
|
||||||
|
c.failPending(reason)
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
err := c.conn.Close()
|
||||||
|
c.mu.Unlock()
|
||||||
|
if err != nil && !isTerminalReadError(err) {
|
||||||
|
c.closeErr = err
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CDPClient) failPending(err error) {
|
||||||
|
c.pendingMu.Lock()
|
||||||
|
defer c.pendingMu.Unlock()
|
||||||
|
|
||||||
|
for id, ch := range c.pending {
|
||||||
|
resp := &cdpResponse{
|
||||||
|
ID: id,
|
||||||
|
Error: &cdpError{
|
||||||
|
Message: err.Error(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case ch <- resp:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTerminalReadError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if core.Is(err, net.ErrClosed) || core.Is(err, websocket.ErrCloseSent) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var closeErr *websocket.CloseError
|
||||||
|
return core.As(err, &closeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneMapAny(src map[string]any) map[string]any {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dst := make(map[string]any, len(src))
|
||||||
|
for key, value := range src {
|
||||||
|
dst[key] = cloneAny(value)
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneSliceAny(src []any) []any {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dst := make([]any, len(src))
|
||||||
|
for i, value := range src {
|
||||||
|
dst[i] = cloneAny(value)
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneAny(value any) any {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
return cloneMapAny(typed)
|
||||||
|
case []any:
|
||||||
|
return cloneSliceAny(typed)
|
||||||
|
default:
|
||||||
|
return typed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
425
cdp_test.go
Normal file
425
cdp_test.go
Normal file
|
|
@ -0,0 +1,425 @@
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
|
package webview
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newConnectedCDPClient(t *testing.T, target *fakeCDPTarget) *CDPClient {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
client, err := NewCDPClient(target.server.DebugURL())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCDPClient returned error: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = client.Close()
|
||||||
|
})
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCdp_parseDebugURL_Good(t *testing.T) {
|
||||||
|
tests := []string{
|
||||||
|
"http://localhost:9222",
|
||||||
|
"http://127.0.0.1:9222",
|
||||||
|
"http://[::1]:9222",
|
||||||
|
"https://localhost:9222/",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, raw := range tests {
|
||||||
|
t.Run(raw, func(t *testing.T) {
|
||||||
|
got, err := parseDebugURL(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseDebugURL returned error: %v", err)
|
||||||
|
}
|
||||||
|
if got.Scheme != strings.Split(raw, ":")[0] {
|
||||||
|
t.Fatalf("parseDebugURL scheme = %q, want %q", got.Scheme, strings.Split(raw, ":")[0])
|
||||||
|
}
|
||||||
|
if got.Path != "/" {
|
||||||
|
t.Fatalf("parseDebugURL path = %q, want %q", got.Path, "/")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCdp_parseDebugURL_Bad(t *testing.T) {
|
||||||
|
tests := []string{
|
||||||
|
"http://example.com:9222",
|
||||||
|
"http://localhost:9222/json",
|
||||||
|
"http://localhost:9222?x=1",
|
||||||
|
"http://localhost:9222#frag",
|
||||||
|
"http://user:pass@localhost:9222",
|
||||||
|
"ftp://localhost:9222",
|
||||||
|
"localhost:9222",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, raw := range tests {
|
||||||
|
t.Run(raw, func(t *testing.T) {
|
||||||
|
if _, err := parseDebugURL(raw); err == nil {
|
||||||
|
t.Fatalf("parseDebugURL(%q) returned nil error", raw)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCdp_validateNavigationURL_Good(t *testing.T) {
|
||||||
|
for _, raw := range []string{
|
||||||
|
"http://localhost:8080/path?q=1",
|
||||||
|
"https://example.com",
|
||||||
|
"about:blank",
|
||||||
|
} {
|
||||||
|
t.Run(raw, func(t *testing.T) {
|
||||||
|
if err := validateNavigationURL(raw); err != nil {
|
||||||
|
t.Fatalf("validateNavigationURL returned error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCdp_validateNavigationURL_Bad(t *testing.T) {
|
||||||
|
for _, raw := range []string{
|
||||||
|
"javascript:alert(1)",
|
||||||
|
"data:text/html,hello",
|
||||||
|
"file:///etc/passwd",
|
||||||
|
"about:srcdoc",
|
||||||
|
"http://",
|
||||||
|
"https://user:pass@example.com",
|
||||||
|
} {
|
||||||
|
t.Run(raw, func(t *testing.T) {
|
||||||
|
if err := validateNavigationURL(raw); err == nil {
|
||||||
|
t.Fatalf("validateNavigationURL(%q) returned nil error", raw)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCdp_normalisedPort_Good(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
raw string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"http://localhost", "80"},
|
||||||
|
{"ws://localhost", "80"},
|
||||||
|
{"https://localhost", "443"},
|
||||||
|
{"wss://localhost", "443"},
|
||||||
|
{"http://localhost:1234", "1234"},
|
||||||
|
{"ws://localhost:5678", "5678"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.raw, func(t *testing.T) {
|
||||||
|
u, err := url.Parse(tc.raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("url.Parse returned error: %v", err)
|
||||||
|
}
|
||||||
|
if got := normalisedPort(u); got != tc.want {
|
||||||
|
t.Fatalf("normalisedPort(%q) = %q, want %q", tc.raw, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCdp_normalisedPort_Ugly(t *testing.T) {
|
||||||
|
u, err := url.Parse("ftp://localhost")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("url.Parse returned error: %v", err)
|
||||||
|
}
|
||||||
|
if got := normalisedPort(u); got != "" {
|
||||||
|
t.Fatalf("normalisedPort(ftp://localhost) = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCdp_targetIDFromWebSocketURL_Good(t *testing.T) {
|
||||||
|
got, err := targetIDFromWebSocketURL("ws://localhost:9222/devtools/page/target-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("targetIDFromWebSocketURL returned error: %v", err)
|
||||||
|
}
|
||||||
|
if got != "target-1" {
|
||||||
|
t.Fatalf("targetIDFromWebSocketURL = %q, want %q", got, "target-1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCdp_targetIDFromWebSocketURL_Bad(t *testing.T) {
|
||||||
|
for _, raw := range []string{
|
||||||
|
"ws://localhost:9222/",
|
||||||
|
"ws://localhost:9222",
|
||||||
|
} {
|
||||||
|
t.Run(raw, func(t *testing.T) {
|
||||||
|
if _, err := targetIDFromWebSocketURL(raw); err == nil {
|
||||||
|
t.Fatalf("targetIDFromWebSocketURL(%q) returned nil error", raw)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCdp_validateTargetWebSocketURL_Bad(t *testing.T) {
|
||||||
|
debugURL := mustParseURL(t, "http://localhost:9222")
|
||||||
|
for _, raw := range []string{
|
||||||
|
"http://localhost:9222/devtools/page/target-1",
|
||||||
|
"ws://example.com/devtools/page/target-1",
|
||||||
|
} {
|
||||||
|
t.Run(raw, func(t *testing.T) {
|
||||||
|
if _, err := validateTargetWebSocketURL(debugURL, raw); err == nil {
|
||||||
|
t.Fatalf("validateTargetWebSocketURL(%q) returned nil error", raw)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCdp_isTerminalReadError_Good(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "nil", err: nil, want: false},
|
||||||
|
{name: "net closed", err: net.ErrClosed, want: true},
|
||||||
|
{name: "ws close sent", err: websocket.ErrCloseSent, want: true},
|
||||||
|
{name: "close error", err: &websocket.CloseError{Code: 1000, Text: "bye"}, want: true},
|
||||||
|
{name: "other", err: context.DeadlineExceeded, want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := isTerminalReadError(tc.err); got != tc.want {
|
||||||
|
t.Fatalf("isTerminalReadError(%v) = %v, want %v", tc.err, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCdp_cloneHelpers_Good(t *testing.T) {
|
||||||
|
original := map[string]any{
|
||||||
|
"nested": map[string]any{"count": float64(1)},
|
||||||
|
"items": []any{map[string]any{"id": "alpha"}},
|
||||||
|
"value": "original",
|
||||||
|
}
|
||||||
|
|
||||||
|
cloned := cloneMapAny(original)
|
||||||
|
cloned["value"] = "changed"
|
||||||
|
cloned["nested"].(map[string]any)["count"] = float64(2)
|
||||||
|
cloned["items"].([]any)[0].(map[string]any)["id"] = "beta"
|
||||||
|
|
||||||
|
if got := original["value"]; got != "original" {
|
||||||
|
t.Fatalf("original scalar mutated: %v", got)
|
||||||
|
}
|
||||||
|
if got := original["nested"].(map[string]any)["count"]; got != float64(1) {
|
||||||
|
t.Fatalf("original nested map mutated: %v", got)
|
||||||
|
}
|
||||||
|
if got := original["items"].([]any)[0].(map[string]any)["id"]; got != "alpha" {
|
||||||
|
t.Fatalf("original nested slice mutated: %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cloneMapAny(nil) != nil {
|
||||||
|
t.Fatal("cloneMapAny(nil) = non-nil")
|
||||||
|
}
|
||||||
|
if cloneSliceAny(nil) != nil {
|
||||||
|
t.Fatal("cloneSliceAny(nil) = non-nil")
|
||||||
|
}
|
||||||
|
if got := cloneAny(original).(map[string]any)["value"]; got != "original" {
|
||||||
|
t.Fatalf("cloneAny(map) = %v, want original value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCdp_ListTargets_Good(t *testing.T) {
|
||||||
|
server := newFakeCDPServer(t)
|
||||||
|
|
||||||
|
targets, err := ListTargets(server.DebugURL())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListTargets returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(targets) != 1 {
|
||||||
|
t.Fatalf("ListTargets returned %d targets, want 1", len(targets))
|
||||||
|
}
|
||||||
|
if targets[0].Type != "page" {
|
||||||
|
t.Fatalf("ListTargets type = %q, want page", targets[0].Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := make([]TargetInfo, 0)
|
||||||
|
for target := range ListTargetsAll(server.DebugURL()) {
|
||||||
|
got = append(got, target)
|
||||||
|
}
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("ListTargetsAll yielded %d targets, want 1", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCdp_GetVersion_Good(t *testing.T) {
|
||||||
|
server := newFakeCDPServer(t)
|
||||||
|
|
||||||
|
version, err := GetVersion(server.DebugURL())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetVersion returned error: %v", err)
|
||||||
|
}
|
||||||
|
if got := version["Browser"]; got != "Chrome/123.0" {
|
||||||
|
t.Fatalf("GetVersion Browser = %q, want Chrome/123.0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCdp_NewCDPClient_Good_AutoCreatesTarget(t *testing.T) {
|
||||||
|
server := newFakeCDPServer(t)
|
||||||
|
server.mu.Lock()
|
||||||
|
server.targets = make(map[string]*fakeCDPTarget)
|
||||||
|
server.nextTarget = 0
|
||||||
|
server.mu.Unlock()
|
||||||
|
|
||||||
|
client, err := NewCDPClient(server.DebugURL())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCDPClient returned error: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = client.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
if client.DebugURL() != server.DebugURL() {
|
||||||
|
t.Fatalf("DebugURL() = %q, want %q", client.DebugURL(), server.DebugURL())
|
||||||
|
}
|
||||||
|
if client.WebSocketURL() == "" {
|
||||||
|
t.Fatal("WebSocketURL() returned empty string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCdp_NewCDPClient_Bad_RejectsInvalidDebugURL(t *testing.T) {
|
||||||
|
_, err := NewCDPClient("http://example.com:9222")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("NewCDPClient succeeded for remote host")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCdp_Send_Good(t *testing.T) {
|
||||||
|
server := newFakeCDPServer(t)
|
||||||
|
target := server.primaryTarget()
|
||||||
|
|
||||||
|
client := newConnectedCDPClient(t, target)
|
||||||
|
|
||||||
|
done := make(chan cdpMessage, 1)
|
||||||
|
target.onMessage = func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
done <- msg
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.Send("Page.enable", map[string]any{"foo": "bar"}); err != nil {
|
||||||
|
t.Fatalf("Send returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case msg := <-done:
|
||||||
|
if msg.Method != "Page.enable" {
|
||||||
|
t.Fatalf("Send method = %q, want Page.enable", msg.Method)
|
||||||
|
}
|
||||||
|
if got := msg.Params["foo"]; got != "bar" {
|
||||||
|
t.Fatalf("Send param foo = %v, want bar", got)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("timed out waiting for sent message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCdp_NewTab_Good(t *testing.T) {
|
||||||
|
server := newFakeCDPServer(t)
|
||||||
|
target := server.primaryTarget()
|
||||||
|
client := newConnectedCDPClient(t, target)
|
||||||
|
|
||||||
|
tab, err := client.NewTab("about:blank")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewTab returned error: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = tab.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
if tab.WebSocketURL() == "" {
|
||||||
|
t.Fatal("NewTab returned empty WebSocket URL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCdp_CloseTab_Bad_TargetCloseNotAcknowledged(t *testing.T) {
|
||||||
|
server := newFakeCDPServer(t)
|
||||||
|
target := server.primaryTarget()
|
||||||
|
target.onMessage = func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
if msg.Method != "Target.closeTarget" {
|
||||||
|
t.Fatalf("CloseTab sent %q, want Target.closeTarget", msg.Method)
|
||||||
|
}
|
||||||
|
target.reply(msg.ID, map[string]any{"success": false})
|
||||||
|
}
|
||||||
|
|
||||||
|
client := newConnectedCDPClient(t, target)
|
||||||
|
if err := client.CloseTab(); err == nil {
|
||||||
|
t.Fatal("CloseTab succeeded without target close acknowledgement")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCdp_failPending_Good(t *testing.T) {
|
||||||
|
c1 := make(chan *cdpResponse, 1)
|
||||||
|
c2 := make(chan *cdpResponse, 1)
|
||||||
|
client := &CDPClient{
|
||||||
|
pending: map[int64]chan *cdpResponse{
|
||||||
|
1: c1,
|
||||||
|
2: c2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
client.failPending(errors.New("boom"))
|
||||||
|
|
||||||
|
for i, ch := range []chan *cdpResponse{c1, c2} {
|
||||||
|
select {
|
||||||
|
case resp := <-ch:
|
||||||
|
if resp.Error == nil || resp.Error.Message != "boom" {
|
||||||
|
t.Fatalf("pending response %d = %#v, want boom error", i+1, resp)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatalf("pending response %d was not delivered", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCdp_createTargetAt_Good(t *testing.T) {
|
||||||
|
server := newFakeCDPServer(t)
|
||||||
|
target, err := createTargetAt(context.Background(), mustParseURL(t, server.DebugURL()), "about:blank")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createTargetAt returned error: %v", err)
|
||||||
|
}
|
||||||
|
if target == nil || target.WebSocketDebuggerURL == "" {
|
||||||
|
t.Fatalf("createTargetAt returned %#v", target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCdp_createTargetAt_Bad_InvalidPageURL(t *testing.T) {
|
||||||
|
server := newFakeCDPServer(t)
|
||||||
|
if _, err := createTargetAt(context.Background(), mustParseURL(t, server.DebugURL()), "javascript:alert(1)"); err == nil {
|
||||||
|
t.Fatal("createTargetAt succeeded with a dangerous page URL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCdp_doDebugRequest_Bad_HTTPStatus(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusTeapot)
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
debugURL, err := parseDebugURL(server.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseDebugURL returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := doDebugRequest(context.Background(), debugURL, "/json", ""); err == nil {
|
||||||
|
t.Fatal("doDebugRequest returned nil error for non-2xx status")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustParseURL(t *testing.T, raw string) *url.URL {
|
||||||
|
t.Helper()
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("url.Parse returned error: %v", err)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
449
console.go
449
console.go
|
|
@ -1,13 +1,17 @@
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
package webview
|
package webview
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"iter"
|
"iter"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ConsoleWatcher provides advanced console message watching capabilities.
|
// ConsoleWatcher provides advanced console message watching capabilities.
|
||||||
|
|
@ -17,34 +21,174 @@ type ConsoleWatcher struct {
|
||||||
messages []ConsoleMessage
|
messages []ConsoleMessage
|
||||||
filters []ConsoleFilter
|
filters []ConsoleFilter
|
||||||
limit int
|
limit int
|
||||||
handlers []ConsoleHandler
|
handlers []consoleHandlerRegistration
|
||||||
|
waiters []consoleMessageWaiter
|
||||||
|
nextHandlerID atomic.Int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConsoleFilter filters console messages.
|
// ConsoleFilter filters console messages.
|
||||||
type ConsoleFilter struct {
|
type ConsoleFilter struct {
|
||||||
Type string // Filter by type (log, warn, error, info, debug), empty for all
|
Type string // Exact message type match, empty for all
|
||||||
Pattern string // Filter by text pattern (substring match)
|
Pattern string // Filter by text pattern (substring match)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConsoleHandler is called when a matching console message is received.
|
// ConsoleHandler is called when a matching console message is received.
|
||||||
type ConsoleHandler func(msg ConsoleMessage)
|
type ConsoleHandler func(msg ConsoleMessage)
|
||||||
|
|
||||||
// NewConsoleWatcher creates a new console watcher for the webview.
|
type consoleHandlerRegistration struct {
|
||||||
|
id int64
|
||||||
|
handler ConsoleHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
type consoleMessageWaiter struct {
|
||||||
|
filter ConsoleFilter
|
||||||
|
ch chan ConsoleMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch console messages from a Webview while a flow is running.
|
||||||
|
//
|
||||||
|
// watcher := webview.NewConsoleWatcher(wv)
|
||||||
|
// watcher.AddFilter(webview.ConsoleFilter{Type: "error"})
|
||||||
func NewConsoleWatcher(wv *Webview) *ConsoleWatcher {
|
func NewConsoleWatcher(wv *Webview) *ConsoleWatcher {
|
||||||
cw := &ConsoleWatcher{
|
watcher := &ConsoleWatcher{
|
||||||
wv: wv,
|
wv: wv,
|
||||||
messages: make([]ConsoleMessage, 0, 100),
|
messages: make([]ConsoleMessage, 0, 1000),
|
||||||
filters: make([]ConsoleFilter, 0),
|
filters: make([]ConsoleFilter, 0),
|
||||||
limit: 1000,
|
limit: 1000,
|
||||||
handlers: make([]ConsoleHandler, 0),
|
handlers: make([]consoleHandlerRegistration, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
if wv == nil || wv.client == nil {
|
||||||
|
return watcher
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to console events from the webview's client
|
// Subscribe to console events from the webview's client
|
||||||
wv.client.OnEvent("Runtime.consoleAPICalled", func(params map[string]any) {
|
wv.client.OnEvent("Runtime.consoleAPICalled", func(params map[string]any) {
|
||||||
cw.handleConsoleEvent(params)
|
watcher.handleConsoleEvent(params)
|
||||||
})
|
})
|
||||||
|
|
||||||
return cw
|
return watcher
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeConsoleType converts CDP event types to the package's stored value.
|
||||||
|
//
|
||||||
|
// It accepts legacy warning aliases and stores the compact warn form used by
|
||||||
|
// the existing console message contract.
|
||||||
|
func normalizeConsoleType(raw string) string {
|
||||||
|
normalized := strings.ToLower(core.Trim(core.Sprint(raw)))
|
||||||
|
if normalized == "warn" || normalized == "warning" {
|
||||||
|
return "warn"
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
// canonicalConsoleType returns the RFC-canonical console type name.
|
||||||
|
func canonicalConsoleType(raw string) string {
|
||||||
|
normalized := strings.ToLower(core.Trim(core.Sprint(raw)))
|
||||||
|
if normalized == "warn" || normalized == "warning" {
|
||||||
|
return "warning"
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
// consoleTextFromArgs extracts message text from Runtime.consoleAPICalled args.
|
||||||
|
func consoleTextFromArgs(args []any) string {
|
||||||
|
text := core.NewBuilder()
|
||||||
|
for i, arg := range args {
|
||||||
|
if i > 0 {
|
||||||
|
text.WriteString(" ")
|
||||||
|
}
|
||||||
|
text.WriteString(consoleArgText(arg))
|
||||||
|
}
|
||||||
|
|
||||||
|
return text.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func consoleArgText(arg any) string {
|
||||||
|
remoteObj, ok := arg.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return consoleValueToString(arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if value, ok := remoteObj["value"]; ok {
|
||||||
|
return consoleValueToString(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if desc, ok := remoteObj["description"].(string); ok && desc != "" {
|
||||||
|
return desc
|
||||||
|
}
|
||||||
|
|
||||||
|
if preview, ok := remoteObj["preview"].(map[string]any); ok {
|
||||||
|
if description, ok := preview["description"].(string); ok && description != "" {
|
||||||
|
return description
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if preview, ok := remoteObj["preview"].(map[string]any); ok {
|
||||||
|
if value, ok := preview["value"].(string); ok && value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if r := core.JSONMarshal(remoteObj); r.OK {
|
||||||
|
if encoded, ok := r.Value.([]byte); ok {
|
||||||
|
return string(encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func consoleValueToString(value any) string {
|
||||||
|
if value == nil {
|
||||||
|
return "null"
|
||||||
|
}
|
||||||
|
if valueStr, ok := value.(string); ok {
|
||||||
|
return valueStr
|
||||||
|
}
|
||||||
|
|
||||||
|
if r := core.JSONMarshal(value); r.OK {
|
||||||
|
if encoded, ok := r.Value.([]byte); ok {
|
||||||
|
return string(encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return core.Sprint(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func consoleCaptureTimestamp() time.Time {
|
||||||
|
return time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimConsoleMessages(messages []ConsoleMessage, limit int) []ConsoleMessage {
|
||||||
|
if limit < 0 {
|
||||||
|
limit = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if overflow := len(messages) - limit; overflow > 0 {
|
||||||
|
copy(messages, messages[overflow:])
|
||||||
|
messages = messages[:len(messages)-overflow]
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
func runtimeExceptionText(exceptionDetails map[string]any) string {
|
||||||
|
if exception, ok := exceptionDetails["exception"].(map[string]any); ok {
|
||||||
|
if description, ok := exception["description"].(string); ok && description != "" {
|
||||||
|
return description
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if text, ok := exceptionDetails["text"].(string); ok && text != "" {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
return "JavaScript error"
|
||||||
|
}
|
||||||
|
|
||||||
|
func runtimeExceptionError(scope string, exceptionDetails map[string]any) error {
|
||||||
|
return coreerr.E(scope, runtimeExceptionText(exceptionDetails), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddFilter adds a filter to the watcher.
|
// AddFilter adds a filter to the watcher.
|
||||||
|
|
@ -63,15 +207,51 @@ func (cw *ConsoleWatcher) ClearFilters() {
|
||||||
|
|
||||||
// AddHandler adds a handler for console messages.
|
// AddHandler adds a handler for console messages.
|
||||||
func (cw *ConsoleWatcher) AddHandler(handler ConsoleHandler) {
|
func (cw *ConsoleWatcher) AddHandler(handler ConsoleHandler) {
|
||||||
cw.mu.Lock()
|
cw.addHandler(handler)
|
||||||
defer cw.mu.Unlock()
|
|
||||||
cw.handlers = append(cw.handlers, handler)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetLimit sets the maximum number of messages to retain.
|
func (cw *ConsoleWatcher) addHandler(handler ConsoleHandler) int64 {
|
||||||
|
cw.mu.Lock()
|
||||||
|
defer cw.mu.Unlock()
|
||||||
|
id := cw.nextHandlerID.Add(1)
|
||||||
|
cw.handlers = append(cw.handlers, consoleHandlerRegistration{
|
||||||
|
id: id,
|
||||||
|
handler: handler,
|
||||||
|
})
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cw *ConsoleWatcher) removeHandler(id int64) {
|
||||||
|
cw.mu.Lock()
|
||||||
|
defer cw.mu.Unlock()
|
||||||
|
|
||||||
|
for i, registration := range cw.handlers {
|
||||||
|
if registration.id == id {
|
||||||
|
cw.handlers = slices.Delete(cw.handlers, i, i+1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cw *ConsoleWatcher) removeWaiter(ch chan ConsoleMessage) {
|
||||||
|
cw.mu.Lock()
|
||||||
|
defer cw.mu.Unlock()
|
||||||
|
|
||||||
|
for i, waiter := range cw.waiters {
|
||||||
|
if waiter.ch == ch {
|
||||||
|
cw.waiters = slices.Delete(cw.waiters, i, i+1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLimit replaces the retention limit for future appends.
|
||||||
func (cw *ConsoleWatcher) SetLimit(limit int) {
|
func (cw *ConsoleWatcher) SetLimit(limit int) {
|
||||||
cw.mu.Lock()
|
cw.mu.Lock()
|
||||||
defer cw.mu.Unlock()
|
defer cw.mu.Unlock()
|
||||||
|
if limit < 0 {
|
||||||
|
limit = 0
|
||||||
|
}
|
||||||
cw.limit = limit
|
cw.limit = limit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,7 +307,7 @@ func (cw *ConsoleWatcher) ErrorsAll() iter.Seq[ConsoleMessage] {
|
||||||
defer cw.mu.RUnlock()
|
defer cw.mu.RUnlock()
|
||||||
|
|
||||||
for _, msg := range cw.messages {
|
for _, msg := range cw.messages {
|
||||||
if msg.Type == "error" {
|
if canonicalConsoleType(msg.Type) == "error" {
|
||||||
if !yield(msg) {
|
if !yield(msg) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -148,7 +328,7 @@ func (cw *ConsoleWatcher) WarningsAll() iter.Seq[ConsoleMessage] {
|
||||||
defer cw.mu.RUnlock()
|
defer cw.mu.RUnlock()
|
||||||
|
|
||||||
for _, msg := range cw.messages {
|
for _, msg := range cw.messages {
|
||||||
if msg.Type == "warning" {
|
if canonicalConsoleType(msg.Type) == "warning" {
|
||||||
if !yield(msg) {
|
if !yield(msg) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -166,7 +346,6 @@ func (cw *ConsoleWatcher) Clear() {
|
||||||
|
|
||||||
// WaitForMessage waits for a message matching the filter.
|
// WaitForMessage waits for a message matching the filter.
|
||||||
func (cw *ConsoleWatcher) WaitForMessage(ctx context.Context, filter ConsoleFilter) (*ConsoleMessage, error) {
|
func (cw *ConsoleWatcher) WaitForMessage(ctx context.Context, filter ConsoleFilter) (*ConsoleMessage, error) {
|
||||||
// First check existing messages
|
|
||||||
cw.mu.RLock()
|
cw.mu.RLock()
|
||||||
for _, msg := range cw.messages {
|
for _, msg := range cw.messages {
|
||||||
if cw.matchesSingleFilter(msg, filter) {
|
if cw.matchesSingleFilter(msg, filter) {
|
||||||
|
|
@ -176,29 +355,25 @@ func (cw *ConsoleWatcher) WaitForMessage(ctx context.Context, filter ConsoleFilt
|
||||||
}
|
}
|
||||||
cw.mu.RUnlock()
|
cw.mu.RUnlock()
|
||||||
|
|
||||||
// Set up a channel for new messages
|
messageCh := make(chan ConsoleMessage, 1)
|
||||||
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()
|
cw.mu.Lock()
|
||||||
// Remove handler (simple implementation - in production you'd want a handle-based removal)
|
for _, msg := range cw.messages {
|
||||||
cw.handlers = cw.handlers[:len(cw.handlers)-1]
|
if cw.matchesSingleFilter(msg, filter) {
|
||||||
cw.mu.Unlock()
|
cw.mu.Unlock()
|
||||||
}()
|
return &msg, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cw.waiters = append(cw.waiters, consoleMessageWaiter{
|
||||||
|
filter: filter,
|
||||||
|
ch: messageCh,
|
||||||
|
})
|
||||||
|
cw.mu.Unlock()
|
||||||
|
defer cw.removeWaiter(messageCh)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return nil, ctx.Err()
|
return nil, ctx.Err()
|
||||||
case msg := <-msgCh:
|
case msg := <-messageCh:
|
||||||
return &msg, nil
|
return &msg, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -214,7 +389,7 @@ func (cw *ConsoleWatcher) HasErrors() bool {
|
||||||
defer cw.mu.RUnlock()
|
defer cw.mu.RUnlock()
|
||||||
|
|
||||||
for _, msg := range cw.messages {
|
for _, msg := range cw.messages {
|
||||||
if msg.Type == "error" {
|
if canonicalConsoleType(msg.Type) == "error" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -235,7 +410,7 @@ func (cw *ConsoleWatcher) ErrorCount() int {
|
||||||
|
|
||||||
count := 0
|
count := 0
|
||||||
for _, msg := range cw.messages {
|
for _, msg := range cw.messages {
|
||||||
if msg.Type == "error" {
|
if canonicalConsoleType(msg.Type) == "error" {
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -244,21 +419,11 @@ func (cw *ConsoleWatcher) ErrorCount() int {
|
||||||
|
|
||||||
// handleConsoleEvent processes incoming console events.
|
// handleConsoleEvent processes incoming console events.
|
||||||
func (cw *ConsoleWatcher) handleConsoleEvent(params map[string]any) {
|
func (cw *ConsoleWatcher) handleConsoleEvent(params map[string]any) {
|
||||||
msgType, _ := params["type"].(string)
|
msgType := canonicalConsoleType(core.Sprint(params["type"]))
|
||||||
|
|
||||||
// Extract args
|
// Extract args
|
||||||
args, _ := params["args"].([]any)
|
args, _ := params["args"].([]any)
|
||||||
var text strings.Builder
|
text := consoleTextFromArgs(args)
|
||||||
for i, arg := range args {
|
|
||||||
if argMap, ok := arg.(map[string]any); ok {
|
|
||||||
if val, ok := argMap["value"]; ok {
|
|
||||||
if i > 0 {
|
|
||||||
text.WriteString(" ")
|
|
||||||
}
|
|
||||||
text.WriteString(fmt.Sprint(val))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract stack trace info
|
// Extract stack trace info
|
||||||
stackTrace, _ := params["stackTrace"].(map[string]any)
|
stackTrace, _ := params["stackTrace"].(map[string]any)
|
||||||
|
|
@ -276,8 +441,8 @@ func (cw *ConsoleWatcher) handleConsoleEvent(params map[string]any) {
|
||||||
|
|
||||||
msg := ConsoleMessage{
|
msg := ConsoleMessage{
|
||||||
Type: msgType,
|
Type: msgType,
|
||||||
Text: text.String(),
|
Text: text,
|
||||||
Timestamp: time.Now(),
|
Timestamp: consoleCaptureTimestamp(),
|
||||||
URL: url,
|
URL: url,
|
||||||
Line: line,
|
Line: line,
|
||||||
Column: column,
|
Column: column,
|
||||||
|
|
@ -290,23 +455,34 @@ func (cw *ConsoleWatcher) handleConsoleEvent(params map[string]any) {
|
||||||
func (cw *ConsoleWatcher) addMessage(msg ConsoleMessage) {
|
func (cw *ConsoleWatcher) addMessage(msg ConsoleMessage) {
|
||||||
cw.mu.Lock()
|
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)
|
cw.messages = append(cw.messages, msg)
|
||||||
|
cw.messages = trimConsoleMessages(cw.messages, cw.limit)
|
||||||
|
|
||||||
// Copy handlers to call outside lock
|
// Copy handlers to call outside lock
|
||||||
handlers := slices.Clone(cw.handlers)
|
handlers := slices.Clone(cw.handlers)
|
||||||
|
waiters := slices.Clone(cw.waiters)
|
||||||
cw.mu.Unlock()
|
cw.mu.Unlock()
|
||||||
|
|
||||||
// Call handlers
|
for _, waiter := range waiters {
|
||||||
for _, handler := range handlers {
|
if cw.matchesSingleFilter(msg, waiter.filter) {
|
||||||
handler(msg)
|
select {
|
||||||
|
case waiter.ch <- msg:
|
||||||
|
default:
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// matchesFilter checks if a message matches any filter.
|
// Call handlers
|
||||||
|
for _, registration := range handlers {
|
||||||
|
registration.handler(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchesFilter checks whether a message matches the active filter set.
|
||||||
|
//
|
||||||
|
// When no filters are configured, every message matches. When filters exist,
|
||||||
|
// the watcher uses OR semantics: a message is included as soon as it matches
|
||||||
|
// one configured filter.
|
||||||
func (cw *ConsoleWatcher) matchesFilter(msg ConsoleMessage) bool {
|
func (cw *ConsoleWatcher) matchesFilter(msg ConsoleMessage) bool {
|
||||||
if len(cw.filters) == 0 {
|
if len(cw.filters) == 0 {
|
||||||
return true
|
return true
|
||||||
|
|
@ -321,9 +497,13 @@ func (cw *ConsoleWatcher) matchesFilter(msg ConsoleMessage) bool {
|
||||||
|
|
||||||
// matchesSingleFilter checks if a message matches a specific filter.
|
// matchesSingleFilter checks if a message matches a specific filter.
|
||||||
func (cw *ConsoleWatcher) matchesSingleFilter(msg ConsoleMessage, filter ConsoleFilter) bool {
|
func (cw *ConsoleWatcher) matchesSingleFilter(msg ConsoleMessage, filter ConsoleFilter) bool {
|
||||||
if filter.Type != "" && msg.Type != filter.Type {
|
if filter.Type != "" {
|
||||||
|
filterType := canonicalConsoleType(filter.Type)
|
||||||
|
messageType := canonicalConsoleType(msg.Type)
|
||||||
|
if messageType != filterType {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if filter.Pattern != "" {
|
if filter.Pattern != "" {
|
||||||
// Simple substring match
|
// Simple substring match
|
||||||
if !containsString(msg.Text, filter.Pattern) {
|
if !containsString(msg.Text, filter.Pattern) {
|
||||||
|
|
@ -333,6 +513,10 @@ func (cw *ConsoleWatcher) matchesSingleFilter(msg ConsoleMessage, filter Console
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isWarningType(messageType string) bool {
|
||||||
|
return canonicalConsoleType(messageType) == "warning"
|
||||||
|
}
|
||||||
|
|
||||||
// containsString checks if s contains substr (case-sensitive).
|
// containsString checks if s contains substr (case-sensitive).
|
||||||
func containsString(s, substr string) bool {
|
func containsString(s, substr string) bool {
|
||||||
return len(substr) == 0 || (len(s) >= len(substr) && findString(s, substr) >= 0)
|
return len(substr) == 0 || (len(s) >= len(substr) && findString(s, substr) >= 0)
|
||||||
|
|
@ -363,15 +547,35 @@ type ExceptionWatcher struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
wv *Webview
|
wv *Webview
|
||||||
exceptions []ExceptionInfo
|
exceptions []ExceptionInfo
|
||||||
handlers []func(ExceptionInfo)
|
limit int
|
||||||
|
handlers []exceptionHandlerRegistration
|
||||||
|
waiters []exceptionWaiter
|
||||||
|
nextHandlerID atomic.Int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewExceptionWatcher creates a new exception watcher.
|
type exceptionHandlerRegistration struct {
|
||||||
|
id int64
|
||||||
|
handler func(ExceptionInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
type exceptionWaiter struct {
|
||||||
|
ch chan ExceptionInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture Runtime.exceptionThrown events from the active page.
|
||||||
|
//
|
||||||
|
// watcher := webview.NewExceptionWatcher(wv)
|
||||||
|
// exc, err := watcher.WaitForException(ctx)
|
||||||
func NewExceptionWatcher(wv *Webview) *ExceptionWatcher {
|
func NewExceptionWatcher(wv *Webview) *ExceptionWatcher {
|
||||||
ew := &ExceptionWatcher{
|
ew := &ExceptionWatcher{
|
||||||
wv: wv,
|
wv: wv,
|
||||||
exceptions: make([]ExceptionInfo, 0),
|
exceptions: make([]ExceptionInfo, 0),
|
||||||
handlers: make([]func(ExceptionInfo), 0),
|
limit: 1000,
|
||||||
|
handlers: make([]exceptionHandlerRegistration, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
if wv == nil || wv.client == nil {
|
||||||
|
return ew
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to exception events
|
// Subscribe to exception events
|
||||||
|
|
@ -424,14 +628,46 @@ func (ew *ExceptionWatcher) Count() int {
|
||||||
|
|
||||||
// AddHandler adds a handler for exceptions.
|
// AddHandler adds a handler for exceptions.
|
||||||
func (ew *ExceptionWatcher) AddHandler(handler func(ExceptionInfo)) {
|
func (ew *ExceptionWatcher) AddHandler(handler func(ExceptionInfo)) {
|
||||||
|
ew.addHandler(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ew *ExceptionWatcher) addHandler(handler func(ExceptionInfo)) int64 {
|
||||||
ew.mu.Lock()
|
ew.mu.Lock()
|
||||||
defer ew.mu.Unlock()
|
defer ew.mu.Unlock()
|
||||||
ew.handlers = append(ew.handlers, handler)
|
id := ew.nextHandlerID.Add(1)
|
||||||
|
ew.handlers = append(ew.handlers, exceptionHandlerRegistration{
|
||||||
|
id: id,
|
||||||
|
handler: handler,
|
||||||
|
})
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ew *ExceptionWatcher) removeHandler(id int64) {
|
||||||
|
ew.mu.Lock()
|
||||||
|
defer ew.mu.Unlock()
|
||||||
|
|
||||||
|
for i, registration := range ew.handlers {
|
||||||
|
if registration.id == id {
|
||||||
|
ew.handlers = slices.Delete(ew.handlers, i, i+1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ew *ExceptionWatcher) removeWaiter(ch chan ExceptionInfo) {
|
||||||
|
ew.mu.Lock()
|
||||||
|
defer ew.mu.Unlock()
|
||||||
|
|
||||||
|
for i, waiter := range ew.waiters {
|
||||||
|
if waiter.ch == ch {
|
||||||
|
ew.waiters = slices.Delete(ew.waiters, i, i+1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WaitForException waits for an exception to be thrown.
|
// WaitForException waits for an exception to be thrown.
|
||||||
func (ew *ExceptionWatcher) WaitForException(ctx context.Context) (*ExceptionInfo, error) {
|
func (ew *ExceptionWatcher) WaitForException(ctx context.Context) (*ExceptionInfo, error) {
|
||||||
// Check existing exceptions first
|
|
||||||
ew.mu.RLock()
|
ew.mu.RLock()
|
||||||
if len(ew.exceptions) > 0 {
|
if len(ew.exceptions) > 0 {
|
||||||
exc := ew.exceptions[len(ew.exceptions)-1]
|
exc := ew.exceptions[len(ew.exceptions)-1]
|
||||||
|
|
@ -440,21 +676,16 @@ func (ew *ExceptionWatcher) WaitForException(ctx context.Context) (*ExceptionInf
|
||||||
}
|
}
|
||||||
ew.mu.RUnlock()
|
ew.mu.RUnlock()
|
||||||
|
|
||||||
// Set up a channel for new exceptions
|
|
||||||
excCh := make(chan ExceptionInfo, 1)
|
excCh := make(chan ExceptionInfo, 1)
|
||||||
handler := func(exc ExceptionInfo) {
|
|
||||||
select {
|
|
||||||
case excCh <- exc:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ew.AddHandler(handler)
|
|
||||||
defer func() {
|
|
||||||
ew.mu.Lock()
|
ew.mu.Lock()
|
||||||
ew.handlers = ew.handlers[:len(ew.handlers)-1]
|
if len(ew.exceptions) > 0 {
|
||||||
|
exc := ew.exceptions[len(ew.exceptions)-1]
|
||||||
ew.mu.Unlock()
|
ew.mu.Unlock()
|
||||||
}()
|
return &exc, nil
|
||||||
|
}
|
||||||
|
ew.waiters = append(ew.waiters, exceptionWaiter{ch: excCh})
|
||||||
|
ew.mu.Unlock()
|
||||||
|
defer ew.removeWaiter(excCh)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
|
|
@ -477,7 +708,7 @@ func (ew *ExceptionWatcher) handleException(params map[string]any) {
|
||||||
url, _ := exceptionDetails["url"].(string)
|
url, _ := exceptionDetails["url"].(string)
|
||||||
|
|
||||||
// Extract stack trace
|
// Extract stack trace
|
||||||
var stackTrace strings.Builder
|
stackTrace := core.NewBuilder()
|
||||||
if st, ok := exceptionDetails["stackTrace"].(map[string]any); ok {
|
if st, ok := exceptionDetails["stackTrace"].(map[string]any); ok {
|
||||||
if frames, ok := st["callFrames"].([]any); ok {
|
if frames, ok := st["callFrames"].([]any); ok {
|
||||||
for _, f := range frames {
|
for _, f := range frames {
|
||||||
|
|
@ -486,18 +717,14 @@ func (ew *ExceptionWatcher) handleException(params map[string]any) {
|
||||||
frameURL, _ := frame["url"].(string)
|
frameURL, _ := frame["url"].(string)
|
||||||
frameLine, _ := frame["lineNumber"].(float64)
|
frameLine, _ := frame["lineNumber"].(float64)
|
||||||
frameCol, _ := frame["columnNumber"].(float64)
|
frameCol, _ := frame["columnNumber"].(float64)
|
||||||
stackTrace.WriteString(fmt.Sprintf(" at %s (%s:%d:%d)\n", funcName, frameURL, int(frameLine), int(frameCol)))
|
stackTrace.WriteString(core.Sprintf(" at %s (%s:%d:%d)\n", funcName, frameURL, int(frameLine), int(frameCol)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to get exception value description
|
// Try to get exception value description
|
||||||
if exc, ok := exceptionDetails["exception"].(map[string]any); ok {
|
text = runtimeExceptionText(exceptionDetails)
|
||||||
if desc, ok := exc["description"].(string); ok && desc != "" {
|
|
||||||
text = desc
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
info := ExceptionInfo{
|
info := ExceptionInfo{
|
||||||
Text: text,
|
Text: text,
|
||||||
|
|
@ -510,21 +737,30 @@ func (ew *ExceptionWatcher) handleException(params map[string]any) {
|
||||||
|
|
||||||
ew.mu.Lock()
|
ew.mu.Lock()
|
||||||
ew.exceptions = append(ew.exceptions, info)
|
ew.exceptions = append(ew.exceptions, info)
|
||||||
|
ew.exceptions = trimExceptionInfos(ew.exceptions, ew.limit)
|
||||||
handlers := slices.Clone(ew.handlers)
|
handlers := slices.Clone(ew.handlers)
|
||||||
|
waiters := slices.Clone(ew.waiters)
|
||||||
ew.mu.Unlock()
|
ew.mu.Unlock()
|
||||||
|
|
||||||
|
for _, waiter := range waiters {
|
||||||
|
select {
|
||||||
|
case waiter.ch <- info:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Call handlers
|
// Call handlers
|
||||||
for _, handler := range handlers {
|
for _, registration := range handlers {
|
||||||
handler(info)
|
registration.handler(info)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatConsoleOutput formats console messages for display.
|
// FormatConsoleOutput formats console messages for display.
|
||||||
func FormatConsoleOutput(messages []ConsoleMessage) string {
|
func FormatConsoleOutput(messages []ConsoleMessage) string {
|
||||||
var output strings.Builder
|
output := core.NewBuilder()
|
||||||
for _, msg := range messages {
|
for _, msg := range messages {
|
||||||
prefix := ""
|
prefix := ""
|
||||||
switch msg.Type {
|
switch canonicalConsoleType(msg.Type) {
|
||||||
case "error":
|
case "error":
|
||||||
prefix = "[ERROR]"
|
prefix = "[ERROR]"
|
||||||
case "warning":
|
case "warning":
|
||||||
|
|
@ -537,7 +773,46 @@ func FormatConsoleOutput(messages []ConsoleMessage) string {
|
||||||
prefix = "[LOG]"
|
prefix = "[LOG]"
|
||||||
}
|
}
|
||||||
timestamp := msg.Timestamp.Format("15:04:05.000")
|
timestamp := msg.Timestamp.Format("15:04:05.000")
|
||||||
output.WriteString(fmt.Sprintf("%s %s %s\n", timestamp, prefix, msg.Text))
|
output.WriteString(core.Sprintf("%s %s %s\n", timestamp, prefix, sanitizeConsoleText(msg.Text)))
|
||||||
}
|
}
|
||||||
return output.String()
|
return output.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func trimExceptionInfos(exceptions []ExceptionInfo, limit int) []ExceptionInfo {
|
||||||
|
if limit < 0 {
|
||||||
|
limit = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if overflow := len(exceptions) - limit; overflow > 0 {
|
||||||
|
copy(exceptions, exceptions[overflow:])
|
||||||
|
exceptions = exceptions[:len(exceptions)-overflow]
|
||||||
|
}
|
||||||
|
|
||||||
|
return exceptions
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeConsoleText(text string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(text))
|
||||||
|
|
||||||
|
for _, r := range text {
|
||||||
|
switch r {
|
||||||
|
case '\n':
|
||||||
|
b.WriteString(`\n`)
|
||||||
|
case '\r':
|
||||||
|
b.WriteString(`\r`)
|
||||||
|
case '\t':
|
||||||
|
b.WriteString(`\t`)
|
||||||
|
case '\x1b':
|
||||||
|
b.WriteString(`\x1b`)
|
||||||
|
default:
|
||||||
|
if r < 0x20 || r == 0x7f {
|
||||||
|
b.WriteByte(' ')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
|
||||||
446
console_test.go
Normal file
446
console_test.go
Normal file
|
|
@ -0,0 +1,446 @@
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
|
package webview
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConsole_normalizeConsoleType_Good(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
raw string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{raw: "warn", want: "warn"},
|
||||||
|
{raw: "warning", want: "warn"},
|
||||||
|
{raw: " WARNING ", want: "warn"},
|
||||||
|
{raw: "error", want: "error"},
|
||||||
|
{raw: "info", want: "info"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.raw, func(t *testing.T) {
|
||||||
|
if got := normalizeConsoleType(tc.raw); got != tc.want {
|
||||||
|
t.Fatalf("normalizeConsoleType(%q) = %q, want %q", tc.raw, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsole_consoleValueToString_Good(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
val any
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "nil", val: nil, want: "null"},
|
||||||
|
{name: "string", val: "hello", want: "hello"},
|
||||||
|
{name: "number", val: float64(12), want: "12"},
|
||||||
|
{name: "bool", val: true, want: "true"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := consoleValueToString(tc.val); got != tc.want {
|
||||||
|
t.Fatalf("consoleValueToString(%v) = %q, want %q", tc.val, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsole_consoleArgText_Good(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
arg any
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "value", arg: map[string]any{"value": "alpha"}, want: "alpha"},
|
||||||
|
{name: "description", arg: map[string]any{"description": "bravo"}, want: "bravo"},
|
||||||
|
{name: "preview description", arg: map[string]any{"preview": map[string]any{"description": "charlie"}}, want: "charlie"},
|
||||||
|
{name: "preview value", arg: map[string]any{"preview": map[string]any{"value": "delta"}}, want: "delta"},
|
||||||
|
{name: "plain scalar", arg: 42, want: "42"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := consoleArgText(tc.arg); got != tc.want {
|
||||||
|
t.Fatalf("consoleArgText(%v) = %q, want %q", tc.arg, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsole_consoleArgText_Ugly(t *testing.T) {
|
||||||
|
got := consoleArgText(map[string]any{"value": map[string]any{"nested": true}})
|
||||||
|
if !strings.Contains(got, `"nested":true`) {
|
||||||
|
t.Fatalf("consoleArgText fallback JSON = %q, want JSON encoding", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsole_trimConsoleMessages_Good(t *testing.T) {
|
||||||
|
messages := []ConsoleMessage{
|
||||||
|
{Text: "one"},
|
||||||
|
{Text: "two"},
|
||||||
|
{Text: "three"},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
limit int
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{name: "no trim", limit: 3, want: []string{"one", "two", "three"}},
|
||||||
|
{name: "trim to one", limit: 1, want: []string{"three"}},
|
||||||
|
{name: "zero", limit: 0, want: nil},
|
||||||
|
{name: "negative", limit: -1, want: nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
cloned := append([]ConsoleMessage(nil), messages...)
|
||||||
|
got := trimConsoleMessages(cloned, tc.limit)
|
||||||
|
if len(got) != len(tc.want) {
|
||||||
|
t.Fatalf("trimConsoleMessages len = %d, want %d", len(got), len(tc.want))
|
||||||
|
}
|
||||||
|
for i, want := range tc.want {
|
||||||
|
if got[i].Text != want {
|
||||||
|
t.Fatalf("trimConsoleMessages[%d] = %q, want %q", i, got[i].Text, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsole_trimExceptionInfos_Good(t *testing.T) {
|
||||||
|
exceptions := []ExceptionInfo{
|
||||||
|
{Text: "one"},
|
||||||
|
{Text: "two"},
|
||||||
|
{Text: "three"},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
limit int
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{name: "no trim", limit: 3, want: []string{"one", "two", "three"}},
|
||||||
|
{name: "trim to one", limit: 1, want: []string{"three"}},
|
||||||
|
{name: "zero", limit: 0, want: nil},
|
||||||
|
{name: "negative", limit: -1, want: nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
cloned := append([]ExceptionInfo(nil), exceptions...)
|
||||||
|
got := trimExceptionInfos(cloned, tc.limit)
|
||||||
|
if len(got) != len(tc.want) {
|
||||||
|
t.Fatalf("trimExceptionInfos len = %d, want %d", len(got), len(tc.want))
|
||||||
|
}
|
||||||
|
for i, want := range tc.want {
|
||||||
|
if got[i].Text != want {
|
||||||
|
t.Fatalf("trimExceptionInfos[%d] = %q, want %q", i, got[i].Text, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsole_sanitizeConsoleText_Good(t *testing.T) {
|
||||||
|
got := sanitizeConsoleText("line1\nline2\r\t\x1b[31m\x7f")
|
||||||
|
if !strings.Contains(got, `line1\nline2\r\t\x1b[31m`) {
|
||||||
|
t.Fatalf("sanitizeConsoleText did not escape control characters: %q", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "\x7f") {
|
||||||
|
t.Fatalf("sanitizeConsoleText kept DEL byte: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsole_runtimeExceptionText_Good(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in map[string]any
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "description", in: map[string]any{"exception": map[string]any{"description": "stack"}}, want: "stack"},
|
||||||
|
{name: "text", in: map[string]any{"text": "boom"}, want: "boom"},
|
||||||
|
{name: "default", in: map[string]any{}, want: "JavaScript error"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := runtimeExceptionText(tc.in); got != tc.want {
|
||||||
|
t.Fatalf("runtimeExceptionText = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsole_NewConsoleWatcher_Good(t *testing.T) {
|
||||||
|
watcher := NewConsoleWatcher(nil)
|
||||||
|
if watcher == nil {
|
||||||
|
t.Fatal("NewConsoleWatcher returned nil")
|
||||||
|
}
|
||||||
|
if watcher.Count() != 0 {
|
||||||
|
t.Fatalf("NewConsoleWatcher count = %d, want 0", watcher.Count())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsole_NewConsoleWatcher_Good_SubscribesToClient(t *testing.T) {
|
||||||
|
server := newFakeCDPServer(t)
|
||||||
|
target := server.primaryTarget()
|
||||||
|
client := newConnectedCDPClient(t, target)
|
||||||
|
watcher := NewConsoleWatcher(&Webview{client: client})
|
||||||
|
|
||||||
|
target.writeJSON(cdpEvent{
|
||||||
|
Method: "Runtime.consoleAPICalled",
|
||||||
|
Params: map[string]any{
|
||||||
|
"type": "log",
|
||||||
|
"args": []any{map[string]any{"value": "hello"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
if watcher.Count() != 1 {
|
||||||
|
t.Fatalf("NewConsoleWatcher subscription count = %d, want 1", watcher.Count())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsole_WaitForError_Good(t *testing.T) {
|
||||||
|
watcher := &ConsoleWatcher{
|
||||||
|
messages: make([]ConsoleMessage, 0),
|
||||||
|
filters: make([]ConsoleFilter, 0),
|
||||||
|
limit: 10,
|
||||||
|
handlers: make([]consoleHandlerRegistration, 0),
|
||||||
|
}
|
||||||
|
watcher.addMessage(ConsoleMessage{Type: "warn", Text: "ignore"})
|
||||||
|
watcher.addMessage(ConsoleMessage{Type: "error", Text: "boom"})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
msg, err := watcher.WaitForError(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WaitForError returned error: %v", err)
|
||||||
|
}
|
||||||
|
if msg.Text != "boom" {
|
||||||
|
t.Fatalf("WaitForError text = %q, want boom", msg.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsole_WaitForError_Bad(t *testing.T) {
|
||||||
|
watcher := &ConsoleWatcher{
|
||||||
|
messages: make([]ConsoleMessage, 0),
|
||||||
|
filters: make([]ConsoleFilter, 0),
|
||||||
|
limit: 10,
|
||||||
|
handlers: make([]consoleHandlerRegistration, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if _, err := watcher.WaitForError(ctx); err == nil {
|
||||||
|
t.Fatal("WaitForError succeeded without an error message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsole_WaitForMessage_Bad_TimesOut(t *testing.T) {
|
||||||
|
watcher := &ConsoleWatcher{
|
||||||
|
messages: make([]ConsoleMessage, 0),
|
||||||
|
filters: make([]ConsoleFilter, 0),
|
||||||
|
limit: 10,
|
||||||
|
handlers: make([]consoleHandlerRegistration, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if _, err := watcher.WaitForMessage(ctx, ConsoleFilter{Type: "error"}); err == nil {
|
||||||
|
t.Fatal("WaitForMessage succeeded without a matching message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsole_handleConsoleEvent_Good(t *testing.T) {
|
||||||
|
watcher := &ConsoleWatcher{
|
||||||
|
messages: make([]ConsoleMessage, 0),
|
||||||
|
filters: make([]ConsoleFilter, 0),
|
||||||
|
limit: 10,
|
||||||
|
handlers: make([]consoleHandlerRegistration, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
watcher.handleConsoleEvent(map[string]any{
|
||||||
|
"type": "warning",
|
||||||
|
"args": []any{
|
||||||
|
map[string]any{"value": "alpha"},
|
||||||
|
map[string]any{"description": "beta"},
|
||||||
|
},
|
||||||
|
"stackTrace": map[string]any{
|
||||||
|
"callFrames": []any{
|
||||||
|
map[string]any{
|
||||||
|
"url": "https://example.com/app.js",
|
||||||
|
"lineNumber": float64(12),
|
||||||
|
"columnNumber": float64(34),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs := watcher.Messages()
|
||||||
|
if len(msgs) != 1 {
|
||||||
|
t.Fatalf("handleConsoleEvent stored %d messages, want 1", len(msgs))
|
||||||
|
}
|
||||||
|
if msgs[0].Type != "warning" {
|
||||||
|
t.Fatalf("handleConsoleEvent type = %q, want warning", msgs[0].Type)
|
||||||
|
}
|
||||||
|
if msgs[0].Text != "alpha beta" {
|
||||||
|
t.Fatalf("handleConsoleEvent text = %q, want %q", msgs[0].Text, "alpha beta")
|
||||||
|
}
|
||||||
|
if msgs[0].URL != "https://example.com/app.js" || msgs[0].Line != 12 || msgs[0].Column != 34 {
|
||||||
|
t.Fatalf("handleConsoleEvent stack info = %#v", msgs[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsole_removeHandler_Good(t *testing.T) {
|
||||||
|
cw := &ConsoleWatcher{
|
||||||
|
handlers: []consoleHandlerRegistration{
|
||||||
|
{id: 1},
|
||||||
|
{id: 2},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cw.removeHandler(1)
|
||||||
|
if len(cw.handlers) != 1 || cw.handlers[0].id != 2 {
|
||||||
|
t.Fatalf("removeHandler did not remove the requested handler: %#v", cw.handlers)
|
||||||
|
}
|
||||||
|
|
||||||
|
cw.removeHandler(99)
|
||||||
|
if len(cw.handlers) != 1 || cw.handlers[0].id != 2 {
|
||||||
|
t.Fatalf("removeHandler changed handlers unexpectedly: %#v", cw.handlers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsole_SetLimit_Bad_NegativeBecomesZero(t *testing.T) {
|
||||||
|
watcher := &ConsoleWatcher{
|
||||||
|
limit: 10,
|
||||||
|
handlers: make([]consoleHandlerRegistration, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
watcher.SetLimit(-1)
|
||||||
|
if watcher.limit != 0 {
|
||||||
|
t.Fatalf("SetLimit(-1) = %d, want 0", watcher.limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsole_NewExceptionWatcher_Good(t *testing.T) {
|
||||||
|
watcher := NewExceptionWatcher(nil)
|
||||||
|
if watcher == nil {
|
||||||
|
t.Fatal("NewExceptionWatcher returned nil")
|
||||||
|
}
|
||||||
|
if watcher.Count() != 0 {
|
||||||
|
t.Fatalf("NewExceptionWatcher count = %d, want 0", watcher.Count())
|
||||||
|
}
|
||||||
|
if watcher.limit != 1000 {
|
||||||
|
t.Fatalf("NewExceptionWatcher limit = %d, want 1000", watcher.limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsole_NewExceptionWatcher_Good_SubscribesToClient(t *testing.T) {
|
||||||
|
server := newFakeCDPServer(t)
|
||||||
|
target := server.primaryTarget()
|
||||||
|
client := newConnectedCDPClient(t, target)
|
||||||
|
watcher := NewExceptionWatcher(&Webview{client: client})
|
||||||
|
|
||||||
|
target.writeJSON(cdpEvent{
|
||||||
|
Method: "Runtime.exceptionThrown",
|
||||||
|
Params: map[string]any{
|
||||||
|
"exceptionDetails": map[string]any{
|
||||||
|
"text": "boom",
|
||||||
|
"lineNumber": float64(1),
|
||||||
|
"columnNumber": float64(2),
|
||||||
|
"url": "https://example.com/app.js",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
if watcher.Count() != 1 {
|
||||||
|
t.Fatalf("NewExceptionWatcher subscription count = %d, want 1", watcher.Count())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsole_ExceptionWatcherTrimsOldExceptions_Good(t *testing.T) {
|
||||||
|
watcher := &ExceptionWatcher{
|
||||||
|
exceptions: make([]ExceptionInfo, 0),
|
||||||
|
limit: 2,
|
||||||
|
handlers: make([]exceptionHandlerRegistration, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range 3 {
|
||||||
|
watcher.handleException(map[string]any{
|
||||||
|
"exceptionDetails": map[string]any{
|
||||||
|
"text": string(rune('a' + i)),
|
||||||
|
"lineNumber": float64(i + 1),
|
||||||
|
"columnNumber": float64(1),
|
||||||
|
"url": "https://example.com/app.js",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := watcher.Count(); got != 2 {
|
||||||
|
t.Fatalf("ExceptionWatcher count = %d, want 2", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
excs := watcher.Exceptions()
|
||||||
|
if len(excs) != 2 || excs[0].Text != "b" || excs[1].Text != "c" {
|
||||||
|
t.Fatalf("ExceptionWatcher retained %#v, want [b c]", excs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsole_ExceptionWatcher_removeHandler_Good(t *testing.T) {
|
||||||
|
ew := &ExceptionWatcher{
|
||||||
|
handlers: []exceptionHandlerRegistration{
|
||||||
|
{id: 1},
|
||||||
|
{id: 2},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ew.removeHandler(2)
|
||||||
|
if len(ew.handlers) != 1 || ew.handlers[0].id != 1 {
|
||||||
|
t.Fatalf("removeHandler did not remove the requested exception handler: %#v", ew.handlers)
|
||||||
|
}
|
||||||
|
|
||||||
|
ew.removeHandler(99)
|
||||||
|
if len(ew.handlers) != 1 || ew.handlers[0].id != 1 {
|
||||||
|
t.Fatalf("removeHandler changed exception handlers unexpectedly: %#v", ew.handlers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsole_WaitForException_Bad_TimesOut(t *testing.T) {
|
||||||
|
ew := &ExceptionWatcher{
|
||||||
|
exceptions: make([]ExceptionInfo, 0),
|
||||||
|
handlers: make([]exceptionHandlerRegistration, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if _, err := ew.WaitForException(ctx); err == nil {
|
||||||
|
t.Fatal("WaitForException succeeded without an exception")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsole_isWarningType_Good(t *testing.T) {
|
||||||
|
tests := map[string]bool{
|
||||||
|
"warn": true,
|
||||||
|
"warning": true,
|
||||||
|
"ERROR": false,
|
||||||
|
}
|
||||||
|
for raw, want := range tests {
|
||||||
|
if got := isWarningType(raw); got != want {
|
||||||
|
t.Fatalf("isWarningType(%q) = %v, want %v", raw, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
160
docs/api-contract.md
Normal file
160
docs/api-contract.md
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
---
|
||||||
|
title: API Contract
|
||||||
|
description: Extracted exported API contract for go-webview with signatures and test coverage notes.
|
||||||
|
---
|
||||||
|
|
||||||
|
# API Contract
|
||||||
|
|
||||||
|
This inventory covers the current exported surface of `dappco.re/go/core/webview`.
|
||||||
|
|
||||||
|
Coverage notes:
|
||||||
|
- Coverage is based on `webview_test.go`.
|
||||||
|
- `Indirect via ...` means the symbol is only exercised through another exported API or helper path.
|
||||||
|
- `None` means no evidence was found in the current test file.
|
||||||
|
|
||||||
|
| Kind | Name | Signature | Description | Test coverage |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| Function | `FormatConsoleOutput` | `func FormatConsoleOutput(messages []ConsoleMessage) string` | FormatConsoleOutput formats console messages for display. | `TestFormatConsoleOutput_Good`, `TestFormatConsoleOutput_Good_Empty`. |
|
||||||
|
| Function | `GetVersion` | `func GetVersion(debugURL string) (map[string]string, error)` | GetVersion returns Chrome version information. | None in `webview_test.go`. |
|
||||||
|
| Function | `ListTargetsAll` | `func ListTargetsAll(debugURL string) iter.Seq[TargetInfo]` | ListTargetsAll returns an iterator over all available targets. | None in `webview_test.go`. |
|
||||||
|
| Type | `Action` | `type Action interface { Execute(ctx context.Context, wv *Webview) error }` | Action represents a browser action that can be performed. | Indirect via `TestActionSequence_Good`, `TestWaitAction_Good_ContextCancelled`, and `TestWaitAction_Good_ShortWait`. |
|
||||||
|
| Method | `Action.Execute` | `Execute(ctx context.Context, wv *Webview) error` | Runs an action against a Webview within the caller's context. | Indirect via `TestWaitAction_Good_ContextCancelled` and `TestWaitAction_Good_ShortWait`. |
|
||||||
|
| Type | `ActionSequence` | `type ActionSequence struct { /* unexported fields */ }` | ActionSequence represents a sequence of actions to execute. | `TestActionSequence_Good`. |
|
||||||
|
| Function | `NewActionSequence` | `func NewActionSequence() *ActionSequence` | NewActionSequence creates a new action sequence. | `TestActionSequence_Good`. |
|
||||||
|
| Method | `ActionSequence.Add` | `func (s *ActionSequence) Add(action Action) *ActionSequence` | Add adds an action to the sequence. | Indirect via `TestActionSequence_Good` builder chaining. |
|
||||||
|
| Method | `ActionSequence.Click` | `func (s *ActionSequence) Click(selector string) *ActionSequence` | Click adds a click action. | `TestActionSequence_Good`. |
|
||||||
|
| Method | `ActionSequence.Execute` | `func (s *ActionSequence) Execute(ctx context.Context, wv *Webview) error` | Execute executes all actions in the sequence. | None in `webview_test.go`. |
|
||||||
|
| Method | `ActionSequence.Navigate` | `func (s *ActionSequence) Navigate(url string) *ActionSequence` | Navigate adds a navigate action. | `TestActionSequence_Good`. |
|
||||||
|
| Method | `ActionSequence.Type` | `func (s *ActionSequence) Type(selector, text string) *ActionSequence` | Type adds a type action. | `TestActionSequence_Good`. |
|
||||||
|
| Method | `ActionSequence.Wait` | `func (s *ActionSequence) Wait(d time.Duration) *ActionSequence` | Wait adds a wait action. | `TestActionSequence_Good`. |
|
||||||
|
| Method | `ActionSequence.WaitForSelector` | `func (s *ActionSequence) WaitForSelector(selector string) *ActionSequence` | WaitForSelector adds a wait for selector action. | `TestActionSequence_Good`. |
|
||||||
|
| Type | `AngularHelper` | `type AngularHelper struct { /* unexported fields */ }` | AngularHelper provides Angular-specific testing utilities. | None in `webview_test.go`. |
|
||||||
|
| Function | `NewAngularHelper` | `func NewAngularHelper(wv *Webview) *AngularHelper` | NewAngularHelper creates a new Angular helper for the webview. | None in `webview_test.go`. |
|
||||||
|
| Method | `AngularHelper.CallComponentMethod` | `func (ah *AngularHelper) CallComponentMethod(selector, methodName string, args ...any) (any, error)` | CallComponentMethod calls a method on an Angular component. | None in `webview_test.go`. |
|
||||||
|
| Method | `AngularHelper.DispatchEvent` | `func (ah *AngularHelper) DispatchEvent(selector, eventName string, detail any) error` | DispatchEvent dispatches a custom event on an element. | None in `webview_test.go`. |
|
||||||
|
| Method | `AngularHelper.GetComponentProperty` | `func (ah *AngularHelper) GetComponentProperty(selector, propertyName string) (any, error)` | GetComponentProperty gets a property from an Angular component. | None in `webview_test.go`. |
|
||||||
|
| Method | `AngularHelper.GetNgModel` | `func (ah *AngularHelper) GetNgModel(selector string) (any, error)` | GetNgModel gets the value of an ngModel-bound input. | None in `webview_test.go`. |
|
||||||
|
| Method | `AngularHelper.GetRouterState` | `func (ah *AngularHelper) GetRouterState() (*AngularRouterState, error)` | GetRouterState returns the current Angular router state. | None in `webview_test.go`. |
|
||||||
|
| Method | `AngularHelper.GetService` | `func (ah *AngularHelper) GetService(serviceName string) (any, error)` | GetService gets an Angular service by token name. | None in `webview_test.go`. |
|
||||||
|
| Method | `AngularHelper.NavigateByRouter` | `func (ah *AngularHelper) NavigateByRouter(path string) error` | NavigateByRouter navigates using Angular Router. | None in `webview_test.go`. |
|
||||||
|
| Method | `AngularHelper.SetComponentProperty` | `func (ah *AngularHelper) SetComponentProperty(selector, propertyName string, value any) error` | SetComponentProperty sets a property on an Angular component. | None in `webview_test.go`. |
|
||||||
|
| Method | `AngularHelper.SetNgModel` | `func (ah *AngularHelper) SetNgModel(selector string, value any) error` | SetNgModel sets the value of an ngModel-bound input. | None in `webview_test.go`. |
|
||||||
|
| Method | `AngularHelper.SetTimeout` | `func (ah *AngularHelper) SetTimeout(d time.Duration)` | SetTimeout sets the default timeout for Angular operations. | None in `webview_test.go`. |
|
||||||
|
| Method | `AngularHelper.TriggerChangeDetection` | `func (ah *AngularHelper) TriggerChangeDetection() error` | TriggerChangeDetection manually triggers Angular change detection. | None in `webview_test.go`. |
|
||||||
|
| Method | `AngularHelper.WaitForAngular` | `func (ah *AngularHelper) WaitForAngular() error` | WaitForAngular waits for Angular to finish all pending operations. | None in `webview_test.go`. |
|
||||||
|
| Method | `AngularHelper.WaitForComponent` | `func (ah *AngularHelper) WaitForComponent(selector string) error` | WaitForComponent waits for an Angular component to be present. | None in `webview_test.go`. |
|
||||||
|
| Type | `AngularRouterState` | `type AngularRouterState struct { URL string Fragment string Params map[string]string QueryParams map[string]string }` | AngularRouterState represents Angular router state. | `TestAngularRouterState_Good`. |
|
||||||
|
| Type | `BlurAction` | `type BlurAction struct { Selector string }` | BlurAction removes focus from an element. | `TestBlurAction_Good`. |
|
||||||
|
| Method | `BlurAction.Execute` | `func (a BlurAction) Execute(ctx context.Context, wv *Webview) error` | Execute removes focus from the element. | None in `webview_test.go`. |
|
||||||
|
| Type | `BoundingBox` | `type BoundingBox struct { X float64 Y float64 Width float64 Height float64 }` | BoundingBox represents the bounding rectangle of an element. | `TestBoundingBox_Good`; also nested in `TestElementInfo_Good`. |
|
||||||
|
| Type | `CDPClient` | `type CDPClient struct { /* unexported fields */ }` | CDPClient handles communication with Chrome DevTools Protocol via WebSocket. | None in `webview_test.go`. |
|
||||||
|
| Function | `NewCDPClient` | `func NewCDPClient(debugURL string) (*CDPClient, error)` | NewCDPClient creates a new CDP client connected to the given debug URL. | Indirect error-path coverage via `TestNew_Bad_InvalidDebugURL`. |
|
||||||
|
| Method | `CDPClient.Call` | `func (c *CDPClient) Call(ctx context.Context, method string, params map[string]any) (map[string]any, error)` | Call sends a CDP method call and waits for the response. | None in `webview_test.go`. |
|
||||||
|
| Method | `CDPClient.Close` | `func (c *CDPClient) Close() error` | Close closes the CDP connection. | None in `webview_test.go`. |
|
||||||
|
| Method | `CDPClient.CloseTab` | `func (c *CDPClient) CloseTab() error` | CloseTab closes the current tab (target). | None in `webview_test.go`. |
|
||||||
|
| Method | `CDPClient.DebugURL` | `func (c *CDPClient) DebugURL() string` | DebugURL returns the debug HTTP URL. | None in `webview_test.go`. |
|
||||||
|
| Method | `CDPClient.NewTab` | `func (c *CDPClient) NewTab(url string) (*CDPClient, error)` | NewTab creates a new browser tab and returns a new CDPClient connected to it. | None in `webview_test.go`. |
|
||||||
|
| Method | `CDPClient.OnEvent` | `func (c *CDPClient) OnEvent(method string, handler func(map[string]any))` | OnEvent registers a handler for CDP events. | None in `webview_test.go`. |
|
||||||
|
| Method | `CDPClient.Send` | `func (c *CDPClient) Send(method string, params map[string]any) error` | Send sends a fire-and-forget CDP message (no response expected). | None in `webview_test.go`. |
|
||||||
|
| Method | `CDPClient.WebSocketURL` | `func (c *CDPClient) WebSocketURL() string` | WebSocketURL returns the WebSocket URL being used. | None in `webview_test.go`. |
|
||||||
|
| Type | `CheckAction` | `type CheckAction struct { Selector string Checked bool }` | CheckAction checks or unchecks a checkbox. | `TestCheckAction_Good`. |
|
||||||
|
| Method | `CheckAction.Execute` | `func (a CheckAction) Execute(ctx context.Context, wv *Webview) error` | Execute checks/unchecks the checkbox. | None in `webview_test.go`. |
|
||||||
|
| Type | `ClearAction` | `type ClearAction struct { Selector string }` | ClearAction clears the value of an input element. | `TestClearAction_Good`. |
|
||||||
|
| Method | `ClearAction.Execute` | `func (a ClearAction) Execute(ctx context.Context, wv *Webview) error` | Execute clears the input value. | None in `webview_test.go`. |
|
||||||
|
| Type | `ClickAction` | `type ClickAction struct { Selector string }` | ClickAction represents a click action. | `TestClickAction_Good`. |
|
||||||
|
| Method | `ClickAction.Execute` | `func (a ClickAction) Execute(ctx context.Context, wv *Webview) error` | Execute performs the click action. | None in `webview_test.go`. |
|
||||||
|
| Type | `ConsoleFilter` | `type ConsoleFilter struct { Type string Pattern string }` | ConsoleFilter filters console messages. | `TestConsoleWatcherFilter_Good`, `TestConsoleWatcherFilteredMessages_Good`. |
|
||||||
|
| Type | `ConsoleHandler` | `type ConsoleHandler func(msg ConsoleMessage)` | ConsoleHandler is called when a matching console message is received. | Indirect via `TestConsoleWatcherHandler_Good`. |
|
||||||
|
| Type | `ConsoleMessage` | `type ConsoleMessage struct { Type string Text string Timestamp time.Time URL string Line int Column int }` | ConsoleMessage represents a captured console log message. | `TestConsoleMessage_Good`; also used by console watcher tests. |
|
||||||
|
| Type | `ConsoleWatcher` | `type ConsoleWatcher struct { /* unexported fields */ }` | ConsoleWatcher provides advanced console message watching capabilities. | `TestConsoleWatcherFilter_Good`, `TestConsoleWatcherCounts_Good`, `TestConsoleWatcherAddMessage_Good`, `TestConsoleWatcherHandler_Good`, `TestConsoleWatcherFilteredMessages_Good`. |
|
||||||
|
| Function | `NewConsoleWatcher` | `func NewConsoleWatcher(wv *Webview) *ConsoleWatcher` | NewConsoleWatcher creates a new console watcher for the webview. | None in `webview_test.go`. |
|
||||||
|
| Method | `ConsoleWatcher.AddFilter` | `func (cw *ConsoleWatcher) AddFilter(filter ConsoleFilter)` | AddFilter adds a filter to the watcher. | `TestConsoleWatcherFilter_Good`. |
|
||||||
|
| Method | `ConsoleWatcher.AddHandler` | `func (cw *ConsoleWatcher) AddHandler(handler ConsoleHandler)` | AddHandler adds a handler for console messages. | `TestConsoleWatcherHandler_Good`. |
|
||||||
|
| Method | `ConsoleWatcher.Clear` | `func (cw *ConsoleWatcher) Clear()` | Clear clears all captured messages. | `TestConsoleWatcherCounts_Good`. |
|
||||||
|
| Method | `ConsoleWatcher.ClearFilters` | `func (cw *ConsoleWatcher) ClearFilters()` | ClearFilters removes all filters. | `TestConsoleWatcherFilter_Good`. |
|
||||||
|
| Method | `ConsoleWatcher.Count` | `func (cw *ConsoleWatcher) Count() int` | Count returns the number of captured messages. | `TestConsoleWatcherCounts_Good`. |
|
||||||
|
| Method | `ConsoleWatcher.ErrorCount` | `func (cw *ConsoleWatcher) ErrorCount() int` | ErrorCount returns the number of error messages. | `TestConsoleWatcherCounts_Good`. |
|
||||||
|
| Method | `ConsoleWatcher.Errors` | `func (cw *ConsoleWatcher) Errors() []ConsoleMessage` | Errors returns all error messages. | `TestConsoleWatcherCounts_Good`. |
|
||||||
|
| Method | `ConsoleWatcher.ErrorsAll` | `func (cw *ConsoleWatcher) ErrorsAll() iter.Seq[ConsoleMessage]` | ErrorsAll returns an iterator over all error messages. | Indirect via `ConsoleWatcher.Errors()` in `TestConsoleWatcherCounts_Good`. |
|
||||||
|
| Method | `ConsoleWatcher.FilteredMessages` | `func (cw *ConsoleWatcher) FilteredMessages() []ConsoleMessage` | FilteredMessages returns messages matching the current filters. | `TestConsoleWatcherFilteredMessages_Good`. |
|
||||||
|
| Method | `ConsoleWatcher.FilteredMessagesAll` | `func (cw *ConsoleWatcher) FilteredMessagesAll() iter.Seq[ConsoleMessage]` | FilteredMessagesAll returns an iterator over messages matching the current filters. | Indirect via `ConsoleWatcher.FilteredMessages()` in `TestConsoleWatcherFilteredMessages_Good`. |
|
||||||
|
| Method | `ConsoleWatcher.HasErrors` | `func (cw *ConsoleWatcher) HasErrors() bool` | HasErrors returns true if there are any error messages. | `TestConsoleWatcherCounts_Good`. |
|
||||||
|
| Method | `ConsoleWatcher.Messages` | `func (cw *ConsoleWatcher) Messages() []ConsoleMessage` | Messages returns all captured messages. | None in `webview_test.go`. |
|
||||||
|
| Method | `ConsoleWatcher.MessagesAll` | `func (cw *ConsoleWatcher) MessagesAll() iter.Seq[ConsoleMessage]` | MessagesAll returns an iterator over all captured messages. | None in `webview_test.go`. |
|
||||||
|
| Method | `ConsoleWatcher.SetLimit` | `func (cw *ConsoleWatcher) SetLimit(limit int)` | SetLimit sets the maximum number of messages to retain. | None in `webview_test.go`. |
|
||||||
|
| Method | `ConsoleWatcher.WaitForError` | `func (cw *ConsoleWatcher) WaitForError(ctx context.Context) (*ConsoleMessage, error)` | WaitForError waits for an error message. | None in `webview_test.go`. |
|
||||||
|
| Method | `ConsoleWatcher.WaitForMessage` | `func (cw *ConsoleWatcher) WaitForMessage(ctx context.Context, filter ConsoleFilter) (*ConsoleMessage, error)` | WaitForMessage waits for a message matching the filter. | None in `webview_test.go`. |
|
||||||
|
| Method | `ConsoleWatcher.Warnings` | `func (cw *ConsoleWatcher) Warnings() []ConsoleMessage` | Warnings returns all warning messages. | `TestConsoleWatcherCounts_Good`. |
|
||||||
|
| Method | `ConsoleWatcher.WarningsAll` | `func (cw *ConsoleWatcher) WarningsAll() iter.Seq[ConsoleMessage]` | WarningsAll returns an iterator over all warning messages. | Indirect via `ConsoleWatcher.Warnings()` in `TestConsoleWatcherCounts_Good`. |
|
||||||
|
| Type | `DoubleClickAction` | `type DoubleClickAction struct { Selector string }` | DoubleClickAction double-clicks an element. | `TestDoubleClickAction_Good`. |
|
||||||
|
| Method | `DoubleClickAction.Execute` | `func (a DoubleClickAction) Execute(ctx context.Context, wv *Webview) error` | Execute double-clicks the element. | None in `webview_test.go`. |
|
||||||
|
| Type | `ElementInfo` | `type ElementInfo struct { NodeID int TagName string Attributes map[string]string InnerHTML string InnerText string BoundingBox *BoundingBox }` | ElementInfo represents information about a DOM element. | `TestElementInfo_Good`. |
|
||||||
|
| Type | `ExceptionInfo` | `type ExceptionInfo struct { Text string LineNumber int ColumnNumber int URL string StackTrace string Timestamp time.Time }` | ExceptionInfo represents information about a JavaScript exception. | `TestExceptionInfo_Good`; also used by `TestExceptionWatcher_Good`. |
|
||||||
|
| Type | `ExceptionWatcher` | `type ExceptionWatcher struct { /* unexported fields */ }` | ExceptionWatcher watches for JavaScript exceptions. | `TestExceptionWatcher_Good`. |
|
||||||
|
| Function | `NewExceptionWatcher` | `func NewExceptionWatcher(wv *Webview) *ExceptionWatcher` | NewExceptionWatcher creates a new exception watcher. | None in `webview_test.go`. |
|
||||||
|
| Method | `ExceptionWatcher.AddHandler` | `func (ew *ExceptionWatcher) AddHandler(handler func(ExceptionInfo))` | AddHandler adds a handler for exceptions. | None in `webview_test.go`. |
|
||||||
|
| Method | `ExceptionWatcher.Clear` | `func (ew *ExceptionWatcher) Clear()` | Clear clears all captured exceptions. | `TestExceptionWatcher_Good`. |
|
||||||
|
| Method | `ExceptionWatcher.Count` | `func (ew *ExceptionWatcher) Count() int` | Count returns the number of exceptions. | `TestExceptionWatcher_Good`. |
|
||||||
|
| Method | `ExceptionWatcher.Exceptions` | `func (ew *ExceptionWatcher) Exceptions() []ExceptionInfo` | Exceptions returns all captured exceptions. | `TestExceptionWatcher_Good`. |
|
||||||
|
| Method | `ExceptionWatcher.ExceptionsAll` | `func (ew *ExceptionWatcher) ExceptionsAll() iter.Seq[ExceptionInfo]` | ExceptionsAll returns an iterator over all captured exceptions. | Indirect via `ExceptionWatcher.Exceptions()` in `TestExceptionWatcher_Good`. |
|
||||||
|
| Method | `ExceptionWatcher.HasExceptions` | `func (ew *ExceptionWatcher) HasExceptions() bool` | HasExceptions returns true if there are any exceptions. | `TestExceptionWatcher_Good`. |
|
||||||
|
| Method | `ExceptionWatcher.WaitForException` | `func (ew *ExceptionWatcher) WaitForException(ctx context.Context) (*ExceptionInfo, error)` | WaitForException waits for an exception to be thrown. | None in `webview_test.go`. |
|
||||||
|
| Type | `FocusAction` | `type FocusAction struct { Selector string }` | FocusAction focuses an element. | `TestFocusAction_Good`. |
|
||||||
|
| Method | `FocusAction.Execute` | `func (a FocusAction) Execute(ctx context.Context, wv *Webview) error` | Execute focuses the element. | None in `webview_test.go`. |
|
||||||
|
| Type | `HoverAction` | `type HoverAction struct { Selector string }` | HoverAction hovers over an element. | `TestHoverAction_Good`. |
|
||||||
|
| Method | `HoverAction.Execute` | `func (a HoverAction) Execute(ctx context.Context, wv *Webview) error` | Execute hovers over the element. | None in `webview_test.go`. |
|
||||||
|
| Type | `NavigateAction` | `type NavigateAction struct { URL string }` | NavigateAction represents a navigation action. | `TestNavigateAction_Good`. |
|
||||||
|
| Method | `NavigateAction.Execute` | `func (a NavigateAction) Execute(ctx context.Context, wv *Webview) error` | Execute performs the navigate action. | None in `webview_test.go`. |
|
||||||
|
| Type | `Option` | `type Option func(*Webview) error` | Option configures a Webview instance. | Used in `TestWithTimeout_Good`, `TestWithConsoleLimit_Good`, and `TestNew_Bad_InvalidDebugURL`. |
|
||||||
|
| Function | `WithConsoleLimit` | `func WithConsoleLimit(limit int) Option` | WithConsoleLimit sets the maximum number of console messages to retain. | `TestWithConsoleLimit_Good`. |
|
||||||
|
| Function | `WithDebugURL` | `func WithDebugURL(url string) Option` | WithDebugURL sets the Chrome DevTools debugging URL. | Indirect error-path coverage via `TestNew_Bad_InvalidDebugURL`. |
|
||||||
|
| Function | `WithTimeout` | `func WithTimeout(d time.Duration) Option` | WithTimeout sets the default timeout for operations. | `TestWithTimeout_Good`. |
|
||||||
|
| Type | `PressKeyAction` | `type PressKeyAction struct { Key string }` | PressKeyAction presses a key. | `TestPressKeyAction_Good`. |
|
||||||
|
| Method | `PressKeyAction.Execute` | `func (a PressKeyAction) Execute(ctx context.Context, wv *Webview) error` | Execute presses the key. | None in `webview_test.go`. |
|
||||||
|
| Type | `RemoveAttributeAction` | `type RemoveAttributeAction struct { Selector string Attribute string }` | RemoveAttributeAction removes an attribute from an element. | `TestRemoveAttributeAction_Good`. |
|
||||||
|
| Method | `RemoveAttributeAction.Execute` | `func (a RemoveAttributeAction) Execute(ctx context.Context, wv *Webview) error` | Execute removes the attribute. | None in `webview_test.go`. |
|
||||||
|
| Type | `RightClickAction` | `type RightClickAction struct { Selector string }` | RightClickAction right-clicks an element. | `TestRightClickAction_Good`. |
|
||||||
|
| Method | `RightClickAction.Execute` | `func (a RightClickAction) Execute(ctx context.Context, wv *Webview) error` | Execute right-clicks the element. | None in `webview_test.go`. |
|
||||||
|
| Type | `ScrollAction` | `type ScrollAction struct { X int Y int }` | ScrollAction represents a scroll action. | `TestScrollAction_Good`. |
|
||||||
|
| Method | `ScrollAction.Execute` | `func (a ScrollAction) Execute(ctx context.Context, wv *Webview) error` | Execute performs the scroll action. | None in `webview_test.go`. |
|
||||||
|
| Type | `ScrollIntoViewAction` | `type ScrollIntoViewAction struct { Selector string }` | ScrollIntoViewAction scrolls an element into view. | `TestScrollIntoViewAction_Good`. |
|
||||||
|
| Method | `ScrollIntoViewAction.Execute` | `func (a ScrollIntoViewAction) Execute(ctx context.Context, wv *Webview) error` | Execute scrolls the element into view. | None in `webview_test.go`. |
|
||||||
|
| Type | `SelectAction` | `type SelectAction struct { Selector string Value string }` | SelectAction selects an option in a select element. | `TestSelectAction_Good`. |
|
||||||
|
| Method | `SelectAction.Execute` | `func (a SelectAction) Execute(ctx context.Context, wv *Webview) error` | Execute selects the option. | None in `webview_test.go`. |
|
||||||
|
| Type | `SetAttributeAction` | `type SetAttributeAction struct { Selector string Attribute string Value string }` | SetAttributeAction sets an attribute on an element. | `TestSetAttributeAction_Good`. |
|
||||||
|
| Method | `SetAttributeAction.Execute` | `func (a SetAttributeAction) Execute(ctx context.Context, wv *Webview) error` | Execute sets the attribute. | None in `webview_test.go`. |
|
||||||
|
| Type | `SetValueAction` | `type SetValueAction struct { Selector string Value string }` | SetValueAction sets the value of an input element. | `TestSetValueAction_Good`. |
|
||||||
|
| Method | `SetValueAction.Execute` | `func (a SetValueAction) Execute(ctx context.Context, wv *Webview) error` | Execute sets the value. | None in `webview_test.go`. |
|
||||||
|
| Type | `TargetInfo` | `type TargetInfo struct { ID string Type string Title string URL string WebSocketDebuggerURL string }` | TargetInfo represents Chrome DevTools target information. | `TestTargetInfo_Good`. |
|
||||||
|
| Function | `ListTargets` | `func ListTargets(debugURL string) ([]TargetInfo, error)` | ListTargets returns all available targets. | None in `webview_test.go`. |
|
||||||
|
| Type | `TypeAction` | `type TypeAction struct { Selector string Text string }` | TypeAction represents a typing action. | `TestTypeAction_Good`. |
|
||||||
|
| Method | `TypeAction.Execute` | `func (a TypeAction) Execute(ctx context.Context, wv *Webview) error` | Execute performs the type action. | None in `webview_test.go`. |
|
||||||
|
| Type | `WaitAction` | `type WaitAction struct { Duration time.Duration }` | WaitAction represents a wait action. | `TestWaitAction_Good`, `TestWaitAction_Good_ContextCancelled`, `TestWaitAction_Good_ShortWait`. |
|
||||||
|
| Method | `WaitAction.Execute` | `func (a WaitAction) Execute(ctx context.Context, wv *Webview) error` | Execute performs the wait action. | `TestWaitAction_Good_ContextCancelled`, `TestWaitAction_Good_ShortWait`. |
|
||||||
|
| Type | `WaitForSelectorAction` | `type WaitForSelectorAction struct { Selector string }` | WaitForSelectorAction represents waiting for a selector. | `TestWaitForSelectorAction_Good`. |
|
||||||
|
| Method | `WaitForSelectorAction.Execute` | `func (a WaitForSelectorAction) Execute(ctx context.Context, wv *Webview) error` | Execute waits for the selector to appear. | None in `webview_test.go`. |
|
||||||
|
| Type | `Webview` | `type Webview struct { /* unexported fields */ }` | Webview represents a connection to a Chrome DevTools Protocol endpoint. | Structural coverage in `TestWithTimeout_Good`, `TestWithConsoleLimit_Good`, and `TestAddConsoleMessage_Good`; no public-method test. |
|
||||||
|
| Function | `New` | `func New(opts ...Option) (*Webview, error)` | New creates a new Webview instance with the given options. | `TestNew_Bad_NoDebugURL`, `TestNew_Bad_InvalidDebugURL`. |
|
||||||
|
| Method | `Webview.ClearConsole` | `func (wv *Webview) ClearConsole()` | ClearConsole clears captured console messages. | None in `webview_test.go`. |
|
||||||
|
| Method | `Webview.Click` | `func (wv *Webview) Click(selector string) error` | Click clicks on an element matching the selector. | None in `webview_test.go`. |
|
||||||
|
| Method | `Webview.Close` | `func (wv *Webview) Close() error` | Close closes the Webview connection. | None in `webview_test.go`. |
|
||||||
|
| Method | `Webview.DragAndDrop` | `func (wv *Webview) DragAndDrop(sourceSelector, targetSelector string) error` | DragAndDrop performs a drag and drop operation. | None in `webview_test.go`. |
|
||||||
|
| Method | `Webview.Evaluate` | `func (wv *Webview) Evaluate(script string) (any, error)` | Evaluate executes JavaScript and returns the result. | None in `webview_test.go`. |
|
||||||
|
| Method | `Webview.GetConsole` | `func (wv *Webview) GetConsole() []ConsoleMessage` | GetConsole returns captured console messages. | None in `webview_test.go`. |
|
||||||
|
| Method | `Webview.GetConsoleAll` | `func (wv *Webview) GetConsoleAll() iter.Seq[ConsoleMessage]` | GetConsoleAll returns an iterator over captured console messages. | None in `webview_test.go`. |
|
||||||
|
| Method | `Webview.GetHTML` | `func (wv *Webview) GetHTML(selector string) (string, error)` | GetHTML returns the outer HTML of an element or the whole document. | None in `webview_test.go`. |
|
||||||
|
| Method | `Webview.GetTitle` | `func (wv *Webview) GetTitle() (string, error)` | GetTitle returns the current page title. | None in `webview_test.go`. |
|
||||||
|
| Method | `Webview.GetURL` | `func (wv *Webview) GetURL() (string, error)` | GetURL returns the current page URL. | None in `webview_test.go`. |
|
||||||
|
| Method | `Webview.GoBack` | `func (wv *Webview) GoBack() error` | GoBack navigates back in history. | None in `webview_test.go`. |
|
||||||
|
| Method | `Webview.GoForward` | `func (wv *Webview) GoForward() error` | GoForward navigates forward in history. | None in `webview_test.go`. |
|
||||||
|
| Method | `Webview.Navigate` | `func (wv *Webview) Navigate(url string) error` | Navigate navigates to the specified URL. | None in `webview_test.go`. |
|
||||||
|
| Method | `Webview.QuerySelector` | `func (wv *Webview) QuerySelector(selector string) (*ElementInfo, error)` | QuerySelector finds an element by CSS selector and returns its information. | None in `webview_test.go`. |
|
||||||
|
| Method | `Webview.QuerySelectorAll` | `func (wv *Webview) QuerySelectorAll(selector string) ([]*ElementInfo, error)` | QuerySelectorAll finds all elements matching the selector. | None in `webview_test.go`. |
|
||||||
|
| Method | `Webview.QuerySelectorAllAll` | `func (wv *Webview) QuerySelectorAllAll(selector string) iter.Seq[*ElementInfo]` | QuerySelectorAllAll returns an iterator over all elements matching the selector. | None in `webview_test.go`. |
|
||||||
|
| Method | `Webview.Reload` | `func (wv *Webview) Reload() error` | Reload reloads the current page. | None in `webview_test.go`. |
|
||||||
|
| Method | `Webview.Screenshot` | `func (wv *Webview) Screenshot() ([]byte, error)` | Screenshot captures a screenshot and returns it as PNG bytes. | None in `webview_test.go`. |
|
||||||
|
| Method | `Webview.SetUserAgent` | `func (wv *Webview) SetUserAgent(userAgent string) error` | SetUserAgent sets the user agent string. | None in `webview_test.go`. |
|
||||||
|
| Method | `Webview.SetViewport` | `func (wv *Webview) SetViewport(width, height int) error` | SetViewport sets the viewport size. | None in `webview_test.go`. |
|
||||||
|
| Method | `Webview.Type` | `func (wv *Webview) Type(selector, text string) error` | Type types text into an element matching the selector. | None in `webview_test.go`. |
|
||||||
|
| Method | `Webview.UploadFile` | `func (wv *Webview) UploadFile(selector string, filePaths []string) error` | UploadFile uploads a file to a file input element. | None in `webview_test.go`. |
|
||||||
|
| Method | `Webview.WaitForSelector` | `func (wv *Webview) WaitForSelector(selector string) error` | WaitForSelector waits for an element matching the selector to appear. | None in `webview_test.go`. |
|
||||||
42
docs/convention-drift-audit.md
Normal file
42
docs/convention-drift-audit.md
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
# Convention Drift Audit
|
||||||
|
|
||||||
|
Date: 2026-03-23
|
||||||
|
|
||||||
|
Scope notes:
|
||||||
|
- `CLAUDE.md` reviewed.
|
||||||
|
- `CODEX.md` was not present anywhere under `/workspace`, so this audit is based on `CLAUDE.md` and the checked-in repository docs.
|
||||||
|
- `go test ./...` passes.
|
||||||
|
- `go test -coverprofile=webview.cover ./...` reports `16.1%` statement coverage.
|
||||||
|
- No source fixes were applied as part of this audit.
|
||||||
|
|
||||||
|
## `stdlib` -> `core.*`
|
||||||
|
|
||||||
|
- `docs/development.md:120` still tells contributors to wrap errors with `fmt.Errorf("context: %w", err)` so callers can use `errors.Is` and `errors.As`; `CLAUDE.md` now requires `coreerr.E("Scope.Method", "description", err)`. This is documentation drift rather than code drift.
|
||||||
|
|
||||||
|
## UK English
|
||||||
|
|
||||||
|
- `README.md:2` uses `License` in the badge alt text and badge label.
|
||||||
|
- `CONTRIBUTING.md:34` uses the US heading `License` instead of `Licence`.
|
||||||
|
- `docs/development.md:138` uses `licenced`; that is inconsistent with the repo's other licence/licensed wording.
|
||||||
|
- `webview.go:705` says `center coordinates` in a comment.
|
||||||
|
- `webview.go:718` says `center point` in a comment.
|
||||||
|
- `actions.go:511` says `center points` in a comment.
|
||||||
|
|
||||||
|
## Missing tests
|
||||||
|
|
||||||
|
- `actions.go:22`, `actions.go:33`, `actions.go:43`, `actions.go:74`, `actions.go:85`, `actions.go:97`, `actions.go:109`, `actions.go:121`, `actions.go:133`, `actions.go:153`, `actions.go:172`, `actions.go:189`, `actions.go:216`, `actions.go:263`, `actions.go:307`, `actions.go:378`, `actions.go:391`, `actions.go:404`, `actions.go:461`, `actions.go:471`, `actions.go:490` have no behavioural coverage. Existing action tests in `webview_test.go` only check field assignment and builder length, not execution paths.
|
||||||
|
- `angular.go:19`, `angular.go:27`, `angular.go:33`, `angular.go:41`, `angular.go:56`, `angular.go:93`, `angular.go:183`, `angular.go:214`, `angular.go:251`, `angular.go:331`, `angular.go:353`, `angular.go:384`, `angular.go:425`, `angular.go:453`, `angular.go:480`, `angular.go:517`, `angular.go:543`, `angular.go:570` are entirely uncovered. The Angular helper layer has no `_Good`, `_Bad`, or `_Ugly` behavioural tests.
|
||||||
|
- `cdp.go:78` is only lightly exercised by the invalid-debug-URL path; there is no success-path coverage for target discovery, tab creation, or WebSocket connection setup.
|
||||||
|
- `cdp.go:156`, `cdp.go:163`, `cdp.go:205`, `cdp.go:212`, `cdp.go:255`, `cdp.go:267`, `cdp.go:279`, `cdp.go:284`, `cdp.go:289`, `cdp.go:340`, `cdp.go:351`, `cdp.go:372`, `cdp.go:387` have no direct behavioural coverage for transport lifecycle, event dispatch, tab management, target enumeration, or version probing.
|
||||||
|
- `console.go:33`, `console.go:72`, `console.go:79`, `console.go:84`, `console.go:168`, `console.go:207`, `console.go:246`, `console.go:371`, `console.go:427`, `console.go:434`, `console.go:469` have no direct tests. The concurrency-sensitive watcher subscription, wait APIs, and event parsing paths are currently unverified.
|
||||||
|
- `webview.go:81` and `webview.go:110` are only partially covered; there is no success-path test for `WithDebugURL` plus `New` initialisation, including `Runtime.enable`, `Page.enable`, and `DOM.enable`.
|
||||||
|
- `webview.go:143`, `webview.go:152`, `webview.go:168`, `webview.go:176`, `webview.go:184`, `webview.go:192`, `webview.go:200`, `webview.go:219`, `webview.go:224`, `webview.go:238`, `webview.go:245`, `webview.go:272`, `webview.go:280`, `webview.go:288`, `webview.go:306`, `webview.go:324`, `webview.go:349`, `webview.go:363`, `webview.go:374`, `webview.go:387`, `webview.go:398`, `webview.go:422`, `webview.go:453`, `webview.go:495`, `webview.go:517`, `webview.go:541`, `webview.go:569`, `webview.go:604`, `webview.go:648`, `webview.go:704`, `webview.go:740` have no direct behavioural coverage across the main browser API, DOM lookup helpers, CDP evaluation path, and console capture path.
|
||||||
|
|
||||||
|
## SPDX headers
|
||||||
|
|
||||||
|
- `actions.go:1` is missing the required `// SPDX-License-Identifier: EUPL-1.2` header.
|
||||||
|
- `angular.go:1` is missing the required `// SPDX-License-Identifier: EUPL-1.2` header.
|
||||||
|
- `cdp.go:1` is missing the required `// SPDX-License-Identifier: EUPL-1.2` header.
|
||||||
|
- `console.go:1` is missing the required `// SPDX-License-Identifier: EUPL-1.2` header.
|
||||||
|
- `webview.go:1` is missing the required `// SPDX-License-Identifier: EUPL-1.2` header.
|
||||||
|
- `webview_test.go:1` is missing the required `// SPDX-License-Identifier: EUPL-1.2` header.
|
||||||
|
|
@ -9,7 +9,7 @@ description: How to build, test, and contribute to go-webview -- prerequisites,
|
||||||
|
|
||||||
### Go
|
### Go
|
||||||
|
|
||||||
Go 1.26 or later is required. The module path is `forge.lthn.ai/core/go-webview`.
|
Go 1.26 or later is required. The module path is `dappco.re/go/core/webview`.
|
||||||
|
|
||||||
### Chrome or Chromium
|
### Chrome or Chromium
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
## Origin
|
## Origin
|
||||||
|
|
||||||
go-webview was extracted from the `pkg/webview/` directory of `forge.lthn.ai/core/go` on 19 February 2026 by Virgil. The extraction made the package independently importable and gave it its own module path, dependency management, and commit history.
|
go-webview was extracted from the `pkg/webview/` directory of `dappco.re/go/core` on 19 February 2026 by Virgil. The extraction made the package independently importable and gave it its own module path, dependency management, and commit history.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -12,7 +12,7 @@ go-webview was extracted from the `pkg/webview/` directory of `forge.lthn.ai/cor
|
||||||
|
|
||||||
Commit `45f119b9ac0e0ebe34f5c8387a070a5b8bd2de6b` — 2026-02-19
|
Commit `45f119b9ac0e0ebe34f5c8387a070a5b8bd2de6b` — 2026-02-19
|
||||||
|
|
||||||
Initial extraction. All source files were moved from `go` `pkg/webview/` into the root of this repository. The module was renamed from the internal path to `forge.lthn.ai/core/go-webview`.
|
Initial extraction. All source files were moved from `go` `pkg/webview/` into the root of this repository. The module was renamed from the internal path to `dappco.re/go/core/webview`.
|
||||||
|
|
||||||
Files established at extraction:
|
Files established at extraction:
|
||||||
|
|
||||||
|
|
@ -73,7 +73,7 @@ The JavaScript Promise used in `waitForZoneStability` has an internal 5-second `
|
||||||
|
|
||||||
### CloseTab Implementation
|
### CloseTab Implementation
|
||||||
|
|
||||||
`CDPClient.CloseTab` calls `Browser.close`, which closes the entire browser rather than just the tab. The correct CDP command for closing a single tab is `Target.closeTarget` with the target's ID extracted from the WebSocket URL. This is a bug.
|
`CDPClient.CloseTab` now uses `Target.closeTarget` with the current target ID extracted from the WebSocket URL, so closing one tab no longer tears down the entire browser.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ description: Chrome DevTools Protocol client for browser automation, testing, an
|
||||||
|
|
||||||
The package does not launch Chrome itself. The caller is responsible for starting the browser process before constructing a `Webview`.
|
The package does not launch Chrome itself. The caller is responsible for starting the browser process before constructing a `Webview`.
|
||||||
|
|
||||||
**Module path:** `forge.lthn.ai/core/go-webview`
|
**Module path:** `dappco.re/go/core/webview`
|
||||||
**Licence:** EUPL-1.2
|
**Licence:** EUPL-1.2
|
||||||
**Go version:** 1.26+
|
**Go version:** 1.26+
|
||||||
**Dependencies:** `github.com/gorilla/websocket v1.5.3`
|
**Dependencies:** `github.com/gorilla/websocket v1.5.3`
|
||||||
|
|
@ -33,7 +33,7 @@ google-chrome --headless=new --remote-debugging-port=9222 --no-sandbox --disable
|
||||||
Then use the package in Go:
|
Then use the package in Go:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
import "forge.lthn.ai/core/go-webview"
|
import "dappco.re/go/core/webview"
|
||||||
|
|
||||||
// Connect to Chrome
|
// Connect to Chrome
|
||||||
wv, err := webview.New(webview.WithDebugURL("http://localhost:9222"))
|
wv, err := webview.New(webview.WithDebugURL("http://localhost:9222"))
|
||||||
|
|
@ -136,6 +136,8 @@ value, err := ah.GetComponentProperty("app-widget", "title")
|
||||||
|
|
||||||
## Further Documentation
|
## Further Documentation
|
||||||
|
|
||||||
|
- [API Contract](api-contract.md) -- exported type, function, and method inventory with signatures and test coverage notes
|
||||||
- [Architecture](architecture.md) -- internals, data flow, CDP protocol, type reference
|
- [Architecture](architecture.md) -- internals, data flow, CDP protocol, type reference
|
||||||
- [Development Guide](development.md) -- build, test, contribute, coding standards
|
- [Development Guide](development.md) -- build, test, contribute, coding standards
|
||||||
- [Project History](history.md) -- extraction origin, completed phases, known limitations
|
- [Project History](history.md) -- extraction origin, completed phases, known limitations
|
||||||
|
- [Security Attack Vector Mapping](security-attack-vector-mapping.md) -- external input entry points, current validation, and attack-surface notes
|
||||||
|
|
|
||||||
65
docs/security-attack-vector-mapping.md
Normal file
65
docs/security-attack-vector-mapping.md
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
# Security Attack Vector Mapping
|
||||||
|
|
||||||
|
Date: 2026-03-23
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `CODEX.md` was not present in this repository when this mapping was prepared, so repo-specific conventions were taken from `CLAUDE.md`.
|
||||||
|
- Thin wrappers are grouped with the underlying sink when they share the same trust boundary and behaviour. Examples: `ActionSequence.Navigate` is grouped with `NavigateAction.Execute` and `Webview.Navigate`.
|
||||||
|
- This is a mapping document only. No mitigations or code changes are proposed here.
|
||||||
|
|
||||||
|
## Caller-Controlled Inputs
|
||||||
|
|
||||||
|
| Function | File:line | Input source | What it flows into | Current validation | Potential attack vector |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `WithDebugURL`, `NewCDPClient`, `ListTargets`, `ListTargetsAll`, `GetVersion` | `webview.go:81`<br>`cdp.go:78`<br>`cdp.go:351`<br>`cdp.go:372`<br>`cdp.go:387` | Caller-supplied Chrome debug URL | `http.Get(debugURL + "/json")`, `http.Get(debugURL + "/json/version")`, `json.Unmarshal`, and, in `NewCDPClient`, `websocket.DefaultDialer.Dial` to the returned `webSocketDebuggerUrl` | No scheme, host, auth, status-code, or body-size validation; JSON shape trusted after `json.Unmarshal` | SSRF against arbitrary internal hosts; unauthenticated trust in a hostile CDP endpoint; malicious `/json` can steer the code into a WS connection to an attacker host; large responses can cause memory pressure |
|
||||||
|
| `CDPClient.NewTab` | `cdp.go:289` | Caller-supplied URL for the new tab; remote `/json/new` response body | Raw string concatenation into `debugURL + "/json/new?" + url`, then `http.Get`, `json.Unmarshal`, and `websocket.DefaultDialer.Dial` to the returned WS URL | No URL escaping; no scheme or destination checks; no status-code or body-size validation | Query manipulation against the debug endpoint; opening attacker-chosen pages in the browser; SSRF through the debug service; hostile response can redirect the WS dial |
|
||||||
|
| `CDPClient.Call`, `CDPClient.Send` | `cdp.go:163`<br>`cdp.go:267` | Caller-supplied CDP method names and params | JSON serialisation to the live DevTools WebSocket | No allow-list or schema validation beyond JSON encoding | Arbitrary CDP command execution, including powerful browser control primitives; blind fire-and-forget misuse via `Send`; broader blast radius if an untrusted component can reach this API |
|
||||||
|
| `CDPClient.OnEvent` | `cdp.go:205` | Caller-supplied event names and callbacks | Stored in `handlers`, later invoked by `dispatchEvent` for browser-originated CDP events | No validation or deduplication | Unbounded handler registration; browser event floods can amplify into caller callback fan-out and goroutine pressure |
|
||||||
|
| `Webview.Navigate`, `NavigateAction.Execute`, `ActionSequence.Navigate` | `webview.go:152`<br>`actions.go:43`<br>`actions.go:446` | Caller-supplied navigation URL or action field | CDP `Page.navigate`, then `waitForLoad` polling via `Runtime.evaluate("document.readyState")` | No scheme, host, or destination validation | Browser-mediated SSRF to internal services; navigation to sensitive schemes such as `file:`, `data:`, `javascript:`, or others if Chrome permits; automation redirection into attacker-controlled flows |
|
||||||
|
| `Webview.Click`, `ClickAction.Execute`, `ActionSequence.Click` | `webview.go:168`<br>`webview.go:704`<br>`actions.go:22`<br>`actions.go:436` | Caller-supplied CSS selector or action field | `DOM.querySelector`; either CDP mouse events or JS fallback `document.querySelector(%q)?.click()` | Only existence and bounding-box checks; JS fallback uses `%q` for selector quoting | Expensive selector abuse against large DOMs; arbitrary interaction with attacker-chosen elements; destructive clicks inside a privileged browser session |
|
||||||
|
| `Webview.Type`, `TypeAction.Execute`, `ActionSequence.Type` | `webview.go:176`<br>`webview.go:740`<br>`actions.go:33`<br>`actions.go:441` | Caller-supplied selector and text | JS focus script, then `Input.dispatchKeyEvent` for each rune | Selector is JS-quoted with `%q`; text is unbounded | Arbitrary input injection into forms and widgets; credential stuffing into the current page; large payloads can generate high event volume |
|
||||||
|
| `Webview.QuerySelector` | `webview.go:184`<br>`webview.go:569` | Caller-supplied selector | `DOM.getDocument`, `DOM.querySelector`, `DOM.describeNode`, `DOM.getBoxModel`, then `ElementInfo` returned | No selector validation beyond CDP/browser parsing; result fields only type-asserted | DOM metadata exfiltration from an untrusted page; attacker-controlled attribute values returned to the caller; selector complexity abuse |
|
||||||
|
| `Webview.QuerySelectorAll`, `Webview.QuerySelectorAllAll` | `webview.go:192`<br>`webview.go:200`<br>`webview.go:604` | Caller-supplied selector | `DOM.querySelectorAll`, then `getElementInfo` per returned node | No selector validation beyond CDP/browser parsing; no cap on result count | Large node sets can amplify CPU and memory use; DOM data exfiltration; selector complexity abuse |
|
||||||
|
| `Webview.WaitForSelector`, `WaitForSelectorAction.Execute`, `ActionSequence.WaitForSelector` | `webview.go:280`<br>`webview.go:517`<br>`actions.go:74`<br>`actions.go:456` | Caller-supplied selector | Repeated `Runtime.evaluate("!!document.querySelector(%q)")` until timeout | Selector is JS-quoted with `%q`; no complexity or rate limits beyond the 100 ms ticker | Polling on hostile/large DOMs can create steady CPU load; attacker controls when the wait resolves |
|
||||||
|
| `Webview.Evaluate` | `webview.go:272`<br>`webview.go:541` | Caller-supplied JavaScript source | CDP `Runtime.evaluate` with `returnByValue: true`, result returned to caller | No validation; this surface is intentionally arbitrary | Direct arbitrary JS execution in the page; DOM/session data exfiltration; page mutation; leverage of any privileged browser APIs exposed to the page context |
|
||||||
|
| `Webview.GetHTML` | `webview.go:324` | Optional caller-supplied selector | Fixed or selector-based JS passed to `Runtime.evaluate`, HTML returned | Selector is JS-quoted with `%q`; no output size limit | Full-document or targeted DOM exfiltration; large HTML payloads can cause memory pressure; selector complexity abuse |
|
||||||
|
| `Webview.SetViewport` | `webview.go:349` | Caller-supplied width and height | CDP `Emulation.setDeviceMetricsOverride` | No range checks | Extreme dimensions can drive browser resource use or renderer instability |
|
||||||
|
| `Webview.SetUserAgent` | `webview.go:363` | Caller-supplied User-Agent string | CDP `Emulation.setUserAgentOverride` | No allow-list or content filtering in package code | Header spoofing, app feature-gating bypass, and downstream log pollution if Chrome accepts unusual characters |
|
||||||
|
| `Webview.UploadFile` | `actions.go:471` | Caller-supplied selector and local file paths | `DOM.setFileInputFiles` | Selector must resolve; file paths are not normalised, existence-checked, or restricted | Sensitive local file selection followed by browser-side upload or exfiltration if the page submits the form |
|
||||||
|
| `Webview.DragAndDrop` | `actions.go:490` | Caller-supplied source and target selectors | `querySelector` for both ends, then `Input.dispatchMouseEvent` sequence | Existence and bounding-box checks only | Arbitrary drag/drop interactions in a privileged session; selector complexity abuse |
|
||||||
|
| `ScrollAction.Execute` | `actions.go:85` | Caller-populated X/Y values | Raw JS `window.scrollTo(%d, %d)` via `Webview.evaluate` | Numeric formatting only | Large values can produce unexpected page behaviour; lower-risk than the arbitrary-script surface but still direct page control |
|
||||||
|
| `ScrollIntoViewAction.Execute`, `FocusAction.Execute`, `BlurAction.Execute`, `ClearAction.Execute`, `SelectAction.Execute`, `CheckAction.Execute`, `SetAttributeAction.Execute`, `RemoveAttributeAction.Execute`, `SetValueAction.Execute` | `actions.go:97`<br>`actions.go:109`<br>`actions.go:121`<br>`actions.go:133`<br>`actions.go:153`<br>`actions.go:172`<br>`actions.go:378`<br>`actions.go:391`<br>`actions.go:404` | Caller-populated selector, value, attribute, or checked-state fields | Constructed JS passed to `Webview.evaluate`; several rows also dispatch `input`/`change` events | String inputs are JS-quoted with `%q`; no semantic allow-list or size checks | Arbitrary DOM mutation and synthetic event dispatch; selector complexity abuse; low direct string-injection risk because `%q` quoting is used |
|
||||||
|
| `HoverAction.Execute`, `DoubleClickAction.Execute`, `RightClickAction.Execute` | `actions.go:189`<br>`actions.go:216`<br>`actions.go:263` | Caller-populated selectors | `querySelector` plus CDP mouse events, with JS fallbacks for double/right click | Existence and bounding-box checks; fallback selectors are JS-quoted with `%q` | Arbitrary pointer interaction, including double-click and context-menu behaviour inside a privileged session; selector complexity abuse |
|
||||||
|
| `PressKeyAction.Execute` | `actions.go:307` | Caller-populated key name or text | `Input.dispatchKeyEvent`; unknown keys are sent as raw `"text"` | Small allow-list for common keys; all other input is passed through | Synthetic keystroke injection, control-character delivery, and high-volume key event generation |
|
||||||
|
| `AngularHelper.NavigateByRouter` | `angular.go:214` | Caller-supplied Angular router path | JS `router.navigateByUrl(%q)` followed by Zone stability wait | Path is JS-quoted with `%q`; no route allow-list | Route manipulation inside a privileged SPA session; app-specific workflow or authorisation bypass if an untrusted caller controls the path |
|
||||||
|
| `AngularHelper.GetComponentProperty` | `angular.go:331` | Caller-supplied selector and property name | JS querySelector, `window.ng.probe(element).componentInstance`, then `component[%q]` returned | Selector in `querySelector` and property name are quoted, but selector is also interpolated raw into an error string with `%s` | Arbitrary component state read; JS injection if a crafted selector forces the error path and breaks out of the raw error string |
|
||||||
|
| `AngularHelper.SetComponentProperty` | `angular.go:353` | Caller-supplied selector, property name, and value | JS querySelector, `component[%q] = %v`, then `ApplicationRef.tick()` | Property name is quoted; selector also appears raw in an error string; `formatJSValue` only safely quotes strings, bools, and `nil`, and uses raw `%v` otherwise | Arbitrary component state mutation; JS injection via the raw selector error path or via crafted non-primitive values rendered with raw `%v` |
|
||||||
|
| `AngularHelper.CallComponentMethod` | `angular.go:384` | Caller-supplied selector, method name, and args | JS querySelector, `component[%q](%s)`, then `ApplicationRef.tick()` | Method name is quoted at call time but also appears raw in an error string; args use `formatJSValue` | Arbitrary component method invocation; JS injection via selector/method-name error paths or crafted args rendered with raw `%v` |
|
||||||
|
| `AngularHelper.GetService` | `angular.go:453` | Caller-supplied Angular DI token name | JS `injector.get(%q)` followed by `JSON.stringify/parse`, returned to caller | Service name is JS-quoted; no size or content limits on serialised output | Exfiltration of DI service state from debug-enabled Angular apps; large services can cause serialisation or memory pressure |
|
||||||
|
| `AngularHelper.WaitForComponent` | `angular.go:480` | Caller-supplied selector | Repeated JS querySelector plus `window.ng.probe` until timeout | Selector is JS-quoted with `%q` | Polling on hostile DOMs can create steady CPU load; attacker controls when the wait resolves |
|
||||||
|
| `AngularHelper.DispatchEvent` | `angular.go:517` | Caller-supplied selector, event name, and detail payload | JS `new CustomEvent(%q, { bubbles: true, detail: %s })`, then `dispatchEvent` | Event name is quoted; selector also appears raw in an error string; `detail` uses `formatJSValue` | Synthetic event injection into Angular app logic; JS injection via the raw selector error path or crafted detail rendered with raw `%v` |
|
||||||
|
| `AngularHelper.GetNgModel` | `angular.go:543` | Caller-supplied selector | JS querySelector, optional Angular debug probe, value/text returned to caller | Selector is JS-quoted with `%q` | Exfiltration of form or model values from the current page |
|
||||||
|
| `AngularHelper.SetNgModel` | `angular.go:570` | Caller-supplied selector and value | JS `element.value = %v`, `input`/`change` events, and `ApplicationRef.tick()` | Selector also appears raw in an error string; value uses `formatJSValue` | Arbitrary model mutation; business-logic and event injection; JS injection via raw selector error path or crafted value rendered with raw `%v` |
|
||||||
|
| `ConsoleWatcher.WaitForMessage` | `console.go:168` | Caller-supplied filter pattern plus browser-originated console text | Substring scans over stored and future console messages | No pattern-length cap or escaping | Large attacker-controlled log lines combined with long caller-supplied patterns can amplify CPU use; hostile pages can control when the wait resolves |
|
||||||
|
| `FormatConsoleOutput` | `console.go:524` | Caller- or browser-supplied `ConsoleMessage` fields | Raw `fmt.Sprintf` into output lines | No sanitisation of text, URL, or prefix content | Log forging and terminal escape propagation if the formatted output is printed or persisted verbatim |
|
||||||
|
|
||||||
|
## Browser- and CDP-Originated Inputs
|
||||||
|
|
||||||
|
| Function | File:line | Input source | What it flows into | Current validation | Potential attack vector |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `CDPClient.readLoop` | `cdp.go:212` | Raw WebSocket frames from the connected CDP peer | `json.Unmarshal` into `cdpResponse` or `cdpEvent`, then pending response channels or `dispatchEvent` | No explicit frame-size limit, schema validation, origin check, or auth check; malformed frames are mostly ignored | Memory pressure from large frames; silent desynchronisation; spoofed responses/events from a hostile endpoint; event-flood delivery into higher layers |
|
||||||
|
| `CDPClient.dispatchEvent` | `cdp.go:255` | CDP event method and params forwarded from `readLoop` | One goroutine per registered handler | Clones the handler slice but does not rate-limit or bound concurrency | Goroutine exhaustion and scheduler pressure under high-volume event streams |
|
||||||
|
| `Webview.Screenshot` | `webview.go:245` | Browser-supplied base64 screenshot payload | Base64 decode into a byte slice returned to caller | Type assertion and base64 decode only; no size cap | Large screenshot payloads can cause memory pressure or decode-time DoS |
|
||||||
|
| `Webview.handleConsoleEvent` | `webview.go:453` | `Runtime.consoleAPICalled` event params from the page via CDP | Builds `ConsoleMessage` and appends it to the Webview ring buffer | Best-effort type assertions only; no sanitisation of text, URL, or stack data | Log forging, terminal escape propagation, and bounded memory pressure up to `consoleLimit` |
|
||||||
|
| `NewConsoleWatcher`, `ConsoleWatcher.handleConsoleEvent` | `console.go:33`<br>`console.go:246` | `Runtime.consoleAPICalled` event params from the page via CDP | Builds `ConsoleMessage`, stores it in the watcher buffer, then notifies registered handlers | Best-effort type assertions only; bounded by `limit`; no sanitisation | Caller handler fan-out on attacker-controlled log data; bounded memory pressure; log forging |
|
||||||
|
| `NewExceptionWatcher`, `ExceptionWatcher.handleException` | `console.go:371`<br>`console.go:468` | `Runtime.exceptionThrown` event params from the page via CDP | Extracts exception text and stack trace, appends to `ew.exceptions`, then calls registered handlers | Best-effort type assertions only; no sanitisation; no retention limit | Unbounded memory growth under exception spam; attacker-controlled stack traces and text reaching caller sinks; handler fan-out DoS |
|
||||||
|
| `ExceptionWatcher.WaitForException` | `console.go:434` | Stored and future browser-originated exception data | Returns the latest `ExceptionInfo` to the caller | No validation beyond prior parsing | Attacker controls exception timing and payload content that may be logged or acted on by the caller |
|
||||||
|
| `Webview.GetURL`, `Webview.GetTitle` | `webview.go:288`<br>`webview.go:306` | Page-controlled `window.location.href` and `document.title` values | Fixed `Runtime.evaluate` calls returning strings to the caller | Only result type assertions | Low-volume data exfiltration from the current page; attacker controls returned strings |
|
||||||
|
| `AngularHelper.GetRouterState` | `angular.go:251` | Page-controlled Angular router state returned from `Runtime.evaluate` | Parsed into `AngularRouterState` and returned to caller | Type assertions on expected string and map fields only | Exfiltration of route params, query params, and fragments from the SPA; large values can increase memory use |
|
||||||
|
|
||||||
|
## Local Configuration Inputs That Amplify Exposure
|
||||||
|
|
||||||
|
| Function | File:line | Input source | What it flows into | Current validation | Potential attack vector |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `WithTimeout`, `AngularHelper.SetTimeout`, `WaitAction.Execute`, `ActionSequence.Wait` | `webview.go:93`<br>`angular.go:27`<br>`actions.go:59`<br>`actions.go:451` | Caller-supplied durations | Context deadlines and `time.After` waits | No range checks | Excessively long values can pin goroutines and prolong exposure windows; zero or negative values can short-circuit synchronisation logic |
|
||||||
|
| `WithConsoleLimit`, `ConsoleWatcher.SetLimit` | `webview.go:102`<br>`console.go:72` | Caller-supplied message limits | In-memory retention size for console buffers | No lower or upper bound checks | Very large limits increase memory retention under noisy pages; low or negative values do not disable capture cleanly |
|
||||||
6
go.mod
6
go.mod
|
|
@ -1,7 +1,9 @@
|
||||||
module forge.lthn.ai/core/go-webview
|
module dappco.re/go/core/webview
|
||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require github.com/gorilla/websocket v1.5.3
|
require github.com/gorilla/websocket v1.5.3
|
||||||
|
|
||||||
require forge.lthn.ai/core/go-log v0.0.4
|
require dappco.re/go/core/log v0.1.0
|
||||||
|
|
||||||
|
require dappco.re/go/core v0.8.0-alpha.1
|
||||||
|
|
|
||||||
6
go.sum
6
go.sum
|
|
@ -1,5 +1,7 @@
|
||||||
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
|
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
|
||||||
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
||||||
|
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
|
||||||
|
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
|
|
||||||
506
specs/RFC.md
Normal file
506
specs/RFC.md
Normal file
|
|
@ -0,0 +1,506 @@
|
||||||
|
# webview, **Import:** `dappco.re/go/core/webview`, **Files:** 5
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
### Action
|
||||||
|
Declaration: `type Action interface`
|
||||||
|
|
||||||
|
Browser action contract used by `ActionSequence`.
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
- `Execute(ctx context.Context, wv *Webview) error`: Runs the action against the supplied `Webview` and caller-owned context.
|
||||||
|
|
||||||
|
### ActionSequence
|
||||||
|
Declaration: `type ActionSequence struct`
|
||||||
|
|
||||||
|
Represents an ordered list of `Action` values. All fields are unexported.
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
- `Add(action Action) *ActionSequence`: Appends `action` to the sequence and returns the same sequence for chaining.
|
||||||
|
- `Click(selector string) *ActionSequence`: Appends `ClickAction{Selector: selector}`.
|
||||||
|
- `Execute(ctx context.Context, wv *Webview) error`: Executes actions in insertion order. The first failing action stops execution and is wrapped as `ActionSequence.Execute` with the zero-based action index.
|
||||||
|
- `Navigate(url string) *ActionSequence`: Appends `NavigateAction{URL: url}`.
|
||||||
|
- `Type(selector, text string) *ActionSequence`: Appends `TypeAction{Selector: selector, Text: text}`.
|
||||||
|
- `Wait(d time.Duration) *ActionSequence`: Appends `WaitAction{Duration: d}`.
|
||||||
|
- `WaitForSelector(selector string) *ActionSequence`: Appends `WaitForSelectorAction{Selector: selector}`.
|
||||||
|
|
||||||
|
### AngularHelper
|
||||||
|
Declaration: `type AngularHelper struct`
|
||||||
|
|
||||||
|
Angular-specific helper bound to a `Webview`. All fields are unexported. The helper stores the target `Webview` and a per-operation timeout, which defaults to 30 seconds in `NewAngularHelper`.
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
- `CallComponentMethod(selector, methodName string, args ...any) (any, error)`: Looks up the Angular component instance for `selector`, verifies that `methodName` is callable, invokes it with JSON-marshalled arguments, ticks `ApplicationRef` when available, and returns the evaluated result.
|
||||||
|
- `DispatchEvent(selector, eventName string, detail any) error`: Dispatches a bubbling `CustomEvent` on the matched element. `detail` is marshalled into the page script, or `null` when omitted.
|
||||||
|
- `GetComponentProperty(selector, propertyName string) (any, error)`: Returns `componentInstance[propertyName]` for the Angular component attached to the matched element.
|
||||||
|
- `GetNgModel(selector string) (any, error)`: Returns `element.value` for `input`, `select`, and `textarea` elements, otherwise `element.value || element.textContent`. Missing elements produce `nil`.
|
||||||
|
- `GetRouterState() (*AngularRouterState, error)`: Walks Angular root elements, resolves the first available Router, and returns its URL, fragment, root params, and root query params. Only string values are copied into the returned maps.
|
||||||
|
- `GetService(serviceName string) (any, error)`: Resolves a service from the first Angular root injector and returns `JSON.parse(JSON.stringify(service))`, so only JSON-serialisable data survives.
|
||||||
|
- `NavigateByRouter(path string) error`: Resolves Angular Router from a root injector, calls `navigateByUrl(path)`, and then waits for Zone.js stability.
|
||||||
|
- `SetComponentProperty(selector, propertyName string, value any) error`: Sets `componentInstance[propertyName]` and ticks `ApplicationRef` when available.
|
||||||
|
- `SetNgModel(selector string, value any) error`: Assigns `element.value`, dispatches bubbling `input` and `change` events, and then ticks `ApplicationRef` on the first Angular root that exposes it.
|
||||||
|
- `SetTimeout(d time.Duration)`: Replaces the helper's default timeout for later Angular operations.
|
||||||
|
- `TriggerChangeDetection() error`: Tries to tick `ApplicationRef` on the first Angular root. The method only reports script-evaluation failures; a `false` result from the script is ignored.
|
||||||
|
- `WaitForAngular() error`: Verifies that the page looks like an Angular application, then waits for Zone.js stability. It first tries an async Zone-based script and falls back to polling every 50 ms until the helper timeout expires.
|
||||||
|
- `WaitForComponent(selector string) error`: Polls every 100 ms until `selector` resolves to an element with an Angular `componentInstance`, or until the helper timeout expires.
|
||||||
|
|
||||||
|
### AngularRouterState
|
||||||
|
Declaration: `type AngularRouterState struct`
|
||||||
|
|
||||||
|
Represents the Angular Router state returned by `AngularHelper.GetRouterState`.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `URL string`: Current router URL.
|
||||||
|
- `Fragment string`: Current URL fragment when Angular reports one.
|
||||||
|
- `Params map[string]string`: Root route parameters copied from the router state.
|
||||||
|
- `QueryParams map[string]string`: Root query parameters copied from the router state.
|
||||||
|
|
||||||
|
### BlurAction
|
||||||
|
Declaration: `type BlurAction struct`
|
||||||
|
|
||||||
|
Removes focus from an element selected by CSS.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `Selector string`: CSS selector passed to `document.querySelector`.
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
- `Execute(ctx context.Context, wv *Webview) error`: Runs `document.querySelector(Selector)?.blur()` through `wv.evaluate`.
|
||||||
|
|
||||||
|
### BoundingBox
|
||||||
|
Declaration: `type BoundingBox struct`
|
||||||
|
|
||||||
|
Represents element coordinates derived from `DOM.getBoxModel`.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `X float64`: Left edge of the content box.
|
||||||
|
- `Y float64`: Top edge of the content box.
|
||||||
|
- `Width float64`: Width computed from the first and second X coordinates in the CDP content quad.
|
||||||
|
- `Height float64`: Height computed from the first and third Y coordinates in the CDP content quad.
|
||||||
|
|
||||||
|
### CDPClient
|
||||||
|
Declaration: `type CDPClient struct`
|
||||||
|
|
||||||
|
Low-level Chrome DevTools Protocol client backed by a single WebSocket connection. All fields are unexported.
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
- `Call(ctx context.Context, method string, params map[string]any) (map[string]any, error)`: Clones `params`, assigns a monotonically increasing message ID, writes the request, and waits for the matching CDP response, `ctx.Done()`, or client shutdown.
|
||||||
|
- `Close() error`: Cancels the client context, fails pending calls, closes the WebSocket, waits for the read loop to exit, and returns a wrapped close error only when the socket close itself fails with a non-terminal error.
|
||||||
|
- `CloseTab() error`: Extracts the current target ID from the WebSocket URL, calls `Target.closeTarget`, checks `success` when the field is present, and then closes the client.
|
||||||
|
- `DebugURL() string`: Returns the canonical debug HTTP URL stored on the client.
|
||||||
|
- `NewTab(url string) (*CDPClient, error)`: Creates a target via `/json/new`, validates the returned WebSocket debugger URL, dials it, and returns a new `CDPClient`.
|
||||||
|
- `OnEvent(method string, handler func(map[string]any))`: Registers a handler for a CDP event method name. Event dispatch clones the handler list and event params and invokes each handler in its own goroutine.
|
||||||
|
- `Send(method string, params map[string]any) error`: Clones `params` and writes a fire-and-forget CDP message without waiting for a response.
|
||||||
|
- `WebSocketURL() string`: Returns the target WebSocket URL currently in use.
|
||||||
|
|
||||||
|
### CheckAction
|
||||||
|
Declaration: `type CheckAction struct`
|
||||||
|
|
||||||
|
Synchronises a checkbox state by clicking when the current `checked` state differs from the requested value.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `Selector string`: CSS selector for the checkbox element.
|
||||||
|
- `Checked bool`: Desired checked state.
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
- `Execute(ctx context.Context, wv *Webview) error`: Evaluates a script that clicks the element only when `el.checked != Checked`.
|
||||||
|
|
||||||
|
### ClearAction
|
||||||
|
Declaration: `type ClearAction struct`
|
||||||
|
|
||||||
|
Clears the value of an input-like element.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `Selector string`: CSS selector for the target element.
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
- `Execute(ctx context.Context, wv *Webview) error`: Sets `el.value = ""` and dispatches bubbling `input` and `change` events when the element exists.
|
||||||
|
|
||||||
|
### ClickAction
|
||||||
|
Declaration: `type ClickAction struct`
|
||||||
|
|
||||||
|
Action wrapper for the `Webview` click implementation.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `Selector string`: CSS selector for the target element.
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
- `Execute(ctx context.Context, wv *Webview) error`: Delegates to `wv.click(ctx, Selector)`.
|
||||||
|
|
||||||
|
### ConsoleFilter
|
||||||
|
Declaration: `type ConsoleFilter struct`
|
||||||
|
|
||||||
|
Filter used by `ConsoleWatcher`.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `Type string`: Exact message-type match. The watcher compares it directly against `ConsoleMessage.Type`.
|
||||||
|
- `Pattern string`: Case-sensitive substring match against `ConsoleMessage.Text`.
|
||||||
|
|
||||||
|
### ConsoleHandler
|
||||||
|
Declaration: `type ConsoleHandler func(msg ConsoleMessage)`
|
||||||
|
|
||||||
|
Callback signature used by `ConsoleWatcher.AddHandler`.
|
||||||
|
|
||||||
|
### ConsoleMessage
|
||||||
|
Declaration: `type ConsoleMessage struct`
|
||||||
|
|
||||||
|
Represents one console message captured from `Runtime.consoleAPICalled`.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `Type string`: Console message type reported by CDP.
|
||||||
|
- `Text string`: Message text built by joining `args[*].value` with spaces.
|
||||||
|
- `Timestamp time.Time`: Local capture time assigned in Go with `time.Now()`.
|
||||||
|
- `URL string`: URL taken from the first stack-frame entry when present.
|
||||||
|
- `Line int`: Line number taken from the first stack-frame entry when present.
|
||||||
|
- `Column int`: Column number taken from the first stack-frame entry when present.
|
||||||
|
|
||||||
|
### ConsoleWatcher
|
||||||
|
Declaration: `type ConsoleWatcher struct`
|
||||||
|
|
||||||
|
Higher-level console log collector layered on top of `Webview`. All fields are unexported. New watchers start with an empty message buffer and a default limit of 1000 messages.
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
- `AddFilter(filter ConsoleFilter)`: Appends a filter. When at least one filter exists, `FilteredMessages` and `FilteredMessagesAll` use OR semantics across filters.
|
||||||
|
- `AddHandler(handler ConsoleHandler)`: Registers a callback for future messages captured by this watcher.
|
||||||
|
- `Clear()`: Removes all stored messages.
|
||||||
|
- `ClearFilters()`: Removes every registered filter.
|
||||||
|
- `Count() int`: Returns the current number of stored messages.
|
||||||
|
- `ErrorCount() int`: Counts stored messages where `Type == "error"`.
|
||||||
|
- `Errors() []ConsoleMessage`: Returns a collected slice of error messages.
|
||||||
|
- `ErrorsAll() iter.Seq[ConsoleMessage]`: Returns an iterator that yields stored messages whose type is exactly `"error"`.
|
||||||
|
- `FilteredMessages() []ConsoleMessage`: Returns a collected slice of messages that match the current filter set, or all messages when no filters exist.
|
||||||
|
- `FilteredMessagesAll() iter.Seq[ConsoleMessage]`: Returns an iterator over the current filtered view.
|
||||||
|
- `HasErrors() bool`: Reports whether any stored message has `Type == "error"`.
|
||||||
|
- `Messages() []ConsoleMessage`: Returns a collected slice of all stored messages.
|
||||||
|
- `MessagesAll() iter.Seq[ConsoleMessage]`: Returns an iterator over the stored message buffer.
|
||||||
|
- `SetLimit(limit int)`: Replaces the retention limit used for future appends. Existing messages are only trimmed on later writes.
|
||||||
|
- `WaitForError(ctx context.Context) (*ConsoleMessage, error)`: Equivalent to `WaitForMessage(ctx, ConsoleFilter{Type: "error"})`.
|
||||||
|
- `WaitForMessage(ctx context.Context, filter ConsoleFilter) (*ConsoleMessage, error)`: Returns the first already-stored message that matches `filter`, otherwise registers a temporary handler and waits for the next matching message or `ctx.Done()`.
|
||||||
|
- `Warnings() []ConsoleMessage`: Returns a collected slice of messages whose type is exactly `"warning"`.
|
||||||
|
- `WarningsAll() iter.Seq[ConsoleMessage]`: Returns an iterator over stored messages whose type is exactly `"warning"`.
|
||||||
|
|
||||||
|
### DoubleClickAction
|
||||||
|
Declaration: `type DoubleClickAction struct`
|
||||||
|
|
||||||
|
Double-click action for an element selected by CSS.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `Selector string`: CSS selector for the target element.
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
- `Execute(ctx context.Context, wv *Webview) error`: Uses a JavaScript `dblclick` fallback when the element has no bounding box; otherwise sends two `mousePressed` and `mouseReleased` pairs with increasing `clickCount` values at the element centre.
|
||||||
|
|
||||||
|
### ElementInfo
|
||||||
|
Declaration: `type ElementInfo struct`
|
||||||
|
|
||||||
|
Represents DOM metadata returned by `Webview.querySelector` and `Webview.querySelectorAll`.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `NodeID int`: CDP node ID used for later DOM operations.
|
||||||
|
- `TagName string`: `nodeName` returned by `DOM.describeNode`.
|
||||||
|
- `Attributes map[string]string`: Attributes parsed from the alternating key/value array returned by CDP.
|
||||||
|
- `InnerHTML string`: Declared field for inner HTML content. The current implementation does not populate it.
|
||||||
|
- `InnerText string`: Declared field for inner text content. The current implementation does not populate it.
|
||||||
|
- `BoundingBox *BoundingBox`: Element box derived from `DOM.getBoxModel`. It is `nil` when box lookup fails.
|
||||||
|
|
||||||
|
### ExceptionInfo
|
||||||
|
Declaration: `type ExceptionInfo struct`
|
||||||
|
|
||||||
|
Represents one `Runtime.exceptionThrown` event captured by `ExceptionWatcher`.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `Text string`: Exception text, overridden by `exception.description` when CDP provides one.
|
||||||
|
- `LineNumber int`: Line number reported by CDP.
|
||||||
|
- `ColumnNumber int`: Column number reported by CDP.
|
||||||
|
- `URL string`: Source URL reported by CDP.
|
||||||
|
- `StackTrace string`: Stack trace formatted in Go as one `at function (url:line:column)` line per call frame.
|
||||||
|
- `Timestamp time.Time`: Local capture time assigned in Go with `time.Now()`.
|
||||||
|
|
||||||
|
### ExceptionWatcher
|
||||||
|
Declaration: `type ExceptionWatcher struct`
|
||||||
|
|
||||||
|
Collector for JavaScript exceptions emitted by the bound `Webview`. All fields are unexported.
|
||||||
|
New watchers start with an empty exception buffer and a default limit of 1000 stored exceptions. When the limit is exceeded, the oldest entries are trimmed on later writes.
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
- `AddHandler(handler func(ExceptionInfo))`: Registers a callback for future exception events.
|
||||||
|
- `Clear()`: Removes all stored exceptions.
|
||||||
|
- `Count() int`: Returns the current number of stored exceptions.
|
||||||
|
- `Exceptions() []ExceptionInfo`: Returns a collected slice of all stored exceptions.
|
||||||
|
- `ExceptionsAll() iter.Seq[ExceptionInfo]`: Returns an iterator over the stored exception buffer.
|
||||||
|
- `HasExceptions() bool`: Reports whether any exceptions have been captured.
|
||||||
|
- `WaitForException(ctx context.Context) (*ExceptionInfo, error)`: Returns the most recently stored exception immediately when one already exists; otherwise registers a temporary handler and waits for the next exception or `ctx.Done()`.
|
||||||
|
|
||||||
|
### FocusAction
|
||||||
|
Declaration: `type FocusAction struct`
|
||||||
|
|
||||||
|
Moves focus to an element selected by CSS.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `Selector string`: CSS selector passed to `document.querySelector`.
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
- `Execute(ctx context.Context, wv *Webview) error`: Runs `document.querySelector(Selector)?.focus()` through `wv.evaluate`.
|
||||||
|
|
||||||
|
### HoverAction
|
||||||
|
Declaration: `type HoverAction struct`
|
||||||
|
|
||||||
|
Moves the mouse pointer over an element.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `Selector string`: CSS selector for the target element.
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
- `Execute(ctx context.Context, wv *Webview) error`: Looks up the element, requires a non-nil bounding box, computes the box centre, and sends one `Input.dispatchMouseEvent` call with `type: "mouseMoved"`.
|
||||||
|
|
||||||
|
### NavigateAction
|
||||||
|
Declaration: `type NavigateAction struct`
|
||||||
|
|
||||||
|
Action wrapper for page navigation.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `URL string`: URL passed to `Page.navigate`.
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
- `Execute(ctx context.Context, wv *Webview) error`: Calls `Page.navigate` and then waits until `document.readyState == "complete"`.
|
||||||
|
|
||||||
|
### Option
|
||||||
|
Declaration: `type Option func(*Webview) error`
|
||||||
|
|
||||||
|
Functional configuration hook applied by `New`. The exported option constructors are `WithDebugURL`, `WithTimeout`, and `WithConsoleLimit`.
|
||||||
|
|
||||||
|
### PressKeyAction
|
||||||
|
Declaration: `type PressKeyAction struct`
|
||||||
|
|
||||||
|
Key-press action implemented through `Input.dispatchKeyEvent`.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `Key string`: Key name or character to send.
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
- `Execute(ctx context.Context, wv *Webview) error`: Uses explicit CDP key metadata for common keys such as `Enter`, `Tab`, arrow keys, `Delete`, and paging keys. For any other string it sends a `keyDown` with `text: Key` followed by a bare `keyUp`.
|
||||||
|
|
||||||
|
### RemoveAttributeAction
|
||||||
|
Declaration: `type RemoveAttributeAction struct`
|
||||||
|
|
||||||
|
Removes a DOM attribute.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `Selector string`: CSS selector for the target element.
|
||||||
|
- `Attribute string`: Attribute name to remove.
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
- `Execute(ctx context.Context, wv *Webview) error`: Runs `document.querySelector(Selector)?.removeAttribute(Attribute)` through `wv.evaluate`.
|
||||||
|
|
||||||
|
### RightClickAction
|
||||||
|
Declaration: `type RightClickAction struct`
|
||||||
|
|
||||||
|
Context-click action for an element selected by CSS.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `Selector string`: CSS selector for the target element.
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
- `Execute(ctx context.Context, wv *Webview) error`: Uses a JavaScript `contextmenu` fallback when the element has no bounding box; otherwise sends `mousePressed` and `mouseReleased` with `button: "right"` at the element centre.
|
||||||
|
|
||||||
|
### ScrollAction
|
||||||
|
Declaration: `type ScrollAction struct`
|
||||||
|
|
||||||
|
Window scroll action.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `X int`: Horizontal scroll target passed to `window.scrollTo`.
|
||||||
|
- `Y int`: Vertical scroll target passed to `window.scrollTo`.
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
- `Execute(ctx context.Context, wv *Webview) error`: Evaluates `window.scrollTo(X, Y)`.
|
||||||
|
|
||||||
|
### ScrollIntoViewAction
|
||||||
|
Declaration: `type ScrollIntoViewAction struct`
|
||||||
|
|
||||||
|
Scrolls a selected element into view.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `Selector string`: CSS selector for the target element.
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
- `Execute(ctx context.Context, wv *Webview) error`: Evaluates `document.querySelector(Selector)?.scrollIntoView({behavior: "smooth", block: "center"})`.
|
||||||
|
|
||||||
|
### SelectAction
|
||||||
|
Declaration: `type SelectAction struct`
|
||||||
|
|
||||||
|
Select-element value setter.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `Selector string`: CSS selector for the target element.
|
||||||
|
- `Value string`: Option value assigned to `el.value`.
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
- `Execute(ctx context.Context, wv *Webview) error`: Sets `el.value` and dispatches a bubbling `change` event when the element exists.
|
||||||
|
|
||||||
|
### SetAttributeAction
|
||||||
|
Declaration: `type SetAttributeAction struct`
|
||||||
|
|
||||||
|
Sets a DOM attribute on the selected element.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `Selector string`: CSS selector for the target element.
|
||||||
|
- `Attribute string`: Attribute name to set.
|
||||||
|
- `Value string`: Attribute value passed to `setAttribute`.
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
- `Execute(ctx context.Context, wv *Webview) error`: Runs `document.querySelector(Selector)?.setAttribute(Attribute, Value)` through `wv.evaluate`.
|
||||||
|
|
||||||
|
### SetValueAction
|
||||||
|
Declaration: `type SetValueAction struct`
|
||||||
|
|
||||||
|
Directly sets an input-like value.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `Selector string`: CSS selector for the target element.
|
||||||
|
- `Value string`: Value assigned to `el.value`.
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
- `Execute(ctx context.Context, wv *Webview) error`: Sets `el.value` and dispatches bubbling `input` and `change` events when the element exists.
|
||||||
|
|
||||||
|
### TargetInfo
|
||||||
|
Declaration: `type TargetInfo struct`
|
||||||
|
|
||||||
|
Represents one target entry returned by Chrome's `/json` and `/json/new` endpoints.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `ID string`: Chrome target ID.
|
||||||
|
- `Type string`: Target type such as `page`.
|
||||||
|
- `Title string`: Target title reported by Chrome.
|
||||||
|
- `URL string`: Target URL reported by Chrome.
|
||||||
|
- `WebSocketDebuggerURL string`: WebSocket debugger URL for the target.
|
||||||
|
|
||||||
|
### TypeAction
|
||||||
|
Declaration: `type TypeAction struct`
|
||||||
|
|
||||||
|
Action wrapper for `Webview` text entry.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `Selector string`: CSS selector for the target element.
|
||||||
|
- `Text string`: Text sent one rune at a time.
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
- `Execute(ctx context.Context, wv *Webview) error`: Delegates to `wv.typeText(ctx, Selector, Text)`.
|
||||||
|
|
||||||
|
### WaitAction
|
||||||
|
Declaration: `type WaitAction struct`
|
||||||
|
|
||||||
|
Time-based delay action.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `Duration time.Duration`: Delay length.
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
- `Execute(ctx context.Context, wv *Webview) error`: Waits for `Duration` with `time.After`, but returns `ctx.Err()` immediately if the context is cancelled first.
|
||||||
|
|
||||||
|
### WaitForSelectorAction
|
||||||
|
Declaration: `type WaitForSelectorAction struct`
|
||||||
|
|
||||||
|
Action wrapper for selector polling.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `Selector string`: CSS selector to wait for.
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
- `Execute(ctx context.Context, wv *Webview) error`: Delegates to `wv.waitForSelector(ctx, Selector)`.
|
||||||
|
|
||||||
|
### Webview
|
||||||
|
Declaration: `type Webview struct`
|
||||||
|
|
||||||
|
High-level browser automation client built on `CDPClient`. All fields are unexported. Instances created by `New` carry a default timeout of 30 seconds and a default console retention limit of 1000 messages.
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
- `ClearConsole()`: Removes all console messages stored on the `Webview`.
|
||||||
|
- `Click(selector string) error`: Creates a timeout-scoped context and delegates to the internal click path. The internal click logic queries the element, uses a JavaScript `.click()` fallback when there is no bounding box, and otherwise sends left-button press and release events at the element centre.
|
||||||
|
- `Close() error`: Cancels the `Webview` context and closes the underlying `CDPClient` when one exists.
|
||||||
|
- `DragAndDrop(sourceSelector, targetSelector string) error`: Looks up both elements, requires bounding boxes for both, then sends `mousePressed` at the source centre, `mouseMoved` to the target centre, and `mouseReleased` at the target centre.
|
||||||
|
- `Evaluate(script string) (any, error)`: Evaluates JavaScript through `Runtime.evaluate` with `returnByValue: true` and `awaitPromise: true`. When CDP reports `exceptionDetails`, the method returns a wrapped JavaScript error.
|
||||||
|
- `GetConsole() []ConsoleMessage`: Returns a collected slice of stored console messages.
|
||||||
|
- `GetConsoleAll() iter.Seq[ConsoleMessage]`: Returns an iterator over the stored console message buffer.
|
||||||
|
- `GetHTML(selector string) (string, error)`: Returns `document.documentElement.outerHTML` when `selector` is empty; otherwise returns `document.querySelector(selector)?.outerHTML || ""`.
|
||||||
|
- `GetTitle() (string, error)`: Evaluates `document.title` and requires the result to be a string.
|
||||||
|
- `GetURL() (string, error)`: Evaluates `window.location.href` and requires the result to be a string.
|
||||||
|
- `GoBack() error`: Calls `Page.getNavigationHistory`, selects the previous entry, and then calls `Page.navigateToHistoryEntry`.
|
||||||
|
- `GoForward() error`: Calls `Page.getNavigationHistory`, selects the next entry, and then calls `Page.navigateToHistoryEntry`.
|
||||||
|
- `Navigate(url string) error`: Calls `Page.navigate` and then polls `document.readyState` every 100 ms until it becomes `"complete"` or the timeout expires.
|
||||||
|
- `QuerySelector(selector string) (*ElementInfo, error)`: Fetches the document root, runs `DOM.querySelector`, errors when the selector does not resolve, and returns `ElementInfo` for the matched node.
|
||||||
|
- `QuerySelectorAll(selector string) ([]*ElementInfo, error)`: Runs `DOM.querySelectorAll` and returns one `ElementInfo` per node ID whose metadata lookup succeeds. Nodes whose metadata fetch fails are skipped.
|
||||||
|
- `QuerySelectorAllAll(selector string) iter.Seq[*ElementInfo]`: Returns an iterator that runs `QuerySelectorAll` under the `Webview` timeout and yields each element. Errors produce an empty iterator.
|
||||||
|
- `Reload() error`: Calls `Page.reload` and then waits for `document.readyState == "complete"`.
|
||||||
|
- `Screenshot() ([]byte, error)`: Calls `Page.captureScreenshot` with `format: "png"`, decodes the returned base64 payload, and returns the PNG bytes.
|
||||||
|
- `SetUserAgent(userAgent string) error`: Calls `Emulation.setUserAgentOverride`.
|
||||||
|
- `SetViewport(width, height int) error`: Calls `Emulation.setDeviceMetricsOverride` with the supplied size, `deviceScaleFactor: 1`, and `mobile: false`.
|
||||||
|
- `Type(selector, text string) error`: Creates a timeout-scoped context and delegates to the internal typing path. The internal logic focuses the element with JavaScript, then sends one `keyDown` with `text` and one `keyUp` per rune in `text`.
|
||||||
|
- `UploadFile(selector string, filePaths []string) error`: Resolves the target node with `QuerySelector` and passes its node ID to `DOM.setFileInputFiles`.
|
||||||
|
- `WaitForSelector(selector string) error`: Polls `!!document.querySelector(selector)` every 100 ms until it becomes true or the timeout expires.
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### FormatConsoleOutput
|
||||||
|
`func FormatConsoleOutput(messages []ConsoleMessage) string`
|
||||||
|
|
||||||
|
Formats each message as `HH:MM:SS.mmm [PREFIX] text\n`. Prefixes are `[ERROR]` for `error`, `[WARN]` for `warning`, `[INFO]` for `info`, `[DEBUG]` for `debug`, and `[LOG]` for every other type.
|
||||||
|
|
||||||
|
### GetVersion
|
||||||
|
`func GetVersion(debugURL string) (map[string]string, error)`
|
||||||
|
|
||||||
|
Validates `debugURL`, requests `/json/version`, and decodes the response body into `map[string]string`.
|
||||||
|
|
||||||
|
### ListTargets
|
||||||
|
`func ListTargets(debugURL string) ([]TargetInfo, error)`
|
||||||
|
|
||||||
|
Validates `debugURL`, requests `/json`, and decodes the response body into a slice of `TargetInfo`.
|
||||||
|
|
||||||
|
### ListTargetsAll
|
||||||
|
`func ListTargetsAll(debugURL string) iter.Seq[TargetInfo]`
|
||||||
|
|
||||||
|
Iterator wrapper around `ListTargets`. When `ListTargets` fails, the iterator yields no values.
|
||||||
|
|
||||||
|
### New
|
||||||
|
`func New(opts ...Option) (*Webview, error)`
|
||||||
|
|
||||||
|
Creates a `Webview`, applies `opts` in order, requires an option that installs a `CDPClient`, enables the `Runtime`, `Page`, and `DOM` domains, and subscribes console capture. On any option or initialisation failure it cancels the context and closes the client when one was created.
|
||||||
|
|
||||||
|
### NewActionSequence
|
||||||
|
`func NewActionSequence() *ActionSequence`
|
||||||
|
|
||||||
|
Creates an empty `ActionSequence`.
|
||||||
|
|
||||||
|
### NewAngularHelper
|
||||||
|
`func NewAngularHelper(wv *Webview) *AngularHelper`
|
||||||
|
|
||||||
|
Creates an `AngularHelper` bound to `wv` with a 30-second default timeout.
|
||||||
|
|
||||||
|
### NewCDPClient
|
||||||
|
`func NewCDPClient(debugURL string) (*CDPClient, error)`
|
||||||
|
|
||||||
|
Validates that `debugURL` is an `http` or `https` DevTools root URL with no credentials, query, fragment, or non-root path. The function requests `/json`, picks the first `page` target with a debugger WebSocket URL, creates a new target via `/json/new` when none exists, validates that the WebSocket host matches the debug host, dials the socket, and starts the client's read loop.
|
||||||
|
|
||||||
|
### NewConsoleWatcher
|
||||||
|
`func NewConsoleWatcher(wv *Webview) *ConsoleWatcher`
|
||||||
|
|
||||||
|
Creates a `ConsoleWatcher`, initialises an empty message buffer with a 1000-message limit, and subscribes it to `Runtime.consoleAPICalled` events on `wv.client`.
|
||||||
|
|
||||||
|
### NewExceptionWatcher
|
||||||
|
`func NewExceptionWatcher(wv *Webview) *ExceptionWatcher`
|
||||||
|
|
||||||
|
Creates an `ExceptionWatcher`, initialises an empty exception buffer with a 1000-exception limit, and subscribes it to `Runtime.exceptionThrown` events on `wv.client`.
|
||||||
|
|
||||||
|
### WithConsoleLimit
|
||||||
|
`func WithConsoleLimit(limit int) Option`
|
||||||
|
|
||||||
|
Returns an `Option` that replaces `Webview.consoleLimit`. The default used by `New` is 1000.
|
||||||
|
|
||||||
|
### WithDebugURL
|
||||||
|
`func WithDebugURL(url string) Option`
|
||||||
|
|
||||||
|
Returns an `Option` that dials a `CDPClient` immediately and stores it on the `Webview`.
|
||||||
|
|
||||||
|
### WithTimeout
|
||||||
|
`func WithTimeout(d time.Duration) Option`
|
||||||
|
|
||||||
|
Returns an `Option` that replaces the default per-operation timeout used by `Webview` methods.
|
||||||
247
webview.go
247
webview.go
|
|
@ -1,3 +1,4 @@
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
// Package webview provides browser automation via Chrome DevTools Protocol (CDP).
|
// Package webview provides browser automation via Chrome DevTools Protocol (CDP).
|
||||||
//
|
//
|
||||||
// The package allows controlling Chrome/Chromium browsers for automated testing,
|
// The package allows controlling Chrome/Chromium browsers for automated testing,
|
||||||
|
|
@ -24,14 +25,13 @@ package webview
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
|
||||||
"iter"
|
"iter"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
core "dappco.re/go/core"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Webview represents a connection to a Chrome DevTools Protocol endpoint.
|
// Webview represents a connection to a Chrome DevTools Protocol endpoint.
|
||||||
|
|
@ -76,8 +76,9 @@ type BoundingBox struct {
|
||||||
// Option configures a Webview instance.
|
// Option configures a Webview instance.
|
||||||
type Option func(*Webview) error
|
type Option func(*Webview) error
|
||||||
|
|
||||||
// WithDebugURL sets the Chrome DevTools debugging URL.
|
// Connect to Chrome running with --remote-debugging-port=9222.
|
||||||
// Example: http://localhost:9222
|
//
|
||||||
|
// webview.New(webview.WithDebugURL("http://localhost:9222"))
|
||||||
func WithDebugURL(url string) Option {
|
func WithDebugURL(url string) Option {
|
||||||
return func(wv *Webview) error {
|
return func(wv *Webview) error {
|
||||||
client, err := NewCDPClient(url)
|
client, err := NewCDPClient(url)
|
||||||
|
|
@ -89,7 +90,9 @@ func WithDebugURL(url string) Option {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithTimeout sets the default timeout for operations.
|
// Give every Webview operation a 10 second default deadline.
|
||||||
|
//
|
||||||
|
// webview.New(webview.WithDebugURL("http://localhost:9222"), webview.WithTimeout(10*time.Second))
|
||||||
func WithTimeout(d time.Duration) Option {
|
func WithTimeout(d time.Duration) Option {
|
||||||
return func(wv *Webview) error {
|
return func(wv *Webview) error {
|
||||||
wv.timeout = d
|
wv.timeout = d
|
||||||
|
|
@ -97,16 +100,22 @@ func WithTimeout(d time.Duration) Option {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithConsoleLimit sets the maximum number of console messages to retain.
|
// Retain only the most recent 200 console messages on the Webview.
|
||||||
// Default is 1000.
|
//
|
||||||
|
// webview.New(webview.WithDebugURL("http://localhost:9222"), webview.WithConsoleLimit(200))
|
||||||
func WithConsoleLimit(limit int) Option {
|
func WithConsoleLimit(limit int) Option {
|
||||||
return func(wv *Webview) error {
|
return func(wv *Webview) error {
|
||||||
|
if limit < 0 {
|
||||||
|
limit = 0
|
||||||
|
}
|
||||||
wv.consoleLimit = limit
|
wv.consoleLimit = limit
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Webview instance with the given options.
|
// Create a Webview bound to an existing Chrome DevTools endpoint.
|
||||||
|
//
|
||||||
|
// wv, err := webview.New(webview.WithDebugURL("http://localhost:9222"))
|
||||||
func New(opts ...Option) (*Webview, error) {
|
func New(opts ...Option) (*Webview, error) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
|
@ -114,13 +123,20 @@ func New(opts ...Option) (*Webview, error) {
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
timeout: 30 * time.Second,
|
timeout: 30 * time.Second,
|
||||||
consoleLogs: make([]ConsoleMessage, 0, 100),
|
consoleLogs: make([]ConsoleMessage, 0, 1000),
|
||||||
consoleLimit: 1000,
|
consoleLimit: 1000,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cleanupOnError := func() {
|
||||||
|
cancel()
|
||||||
|
if wv.client != nil {
|
||||||
|
_ = wv.client.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
if err := opt(wv); err != nil {
|
if err := opt(wv); err != nil {
|
||||||
cancel()
|
cleanupOnError()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -132,7 +148,7 @@ func New(opts ...Option) (*Webview, error) {
|
||||||
|
|
||||||
// Enable console capture
|
// Enable console capture
|
||||||
if err := wv.enableConsole(); err != nil {
|
if err := wv.enableConsole(); err != nil {
|
||||||
cancel()
|
cleanupOnError()
|
||||||
return nil, coreerr.E("Webview.New", "failed to enable console capture", err)
|
return nil, coreerr.E("Webview.New", "failed to enable console capture", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,23 +164,35 @@ func (wv *Webview) Close() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigate navigates to the specified URL.
|
// Load a page and wait for document.readyState === "complete".
|
||||||
|
//
|
||||||
|
// wv.Navigate("https://example.com")
|
||||||
func (wv *Webview) Navigate(url string) error {
|
func (wv *Webview) Navigate(url string) error {
|
||||||
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
|
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
return wv.navigate(ctx, url, "Webview.Navigate")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wv *Webview) navigate(ctx context.Context, rawURL, scope string) error {
|
||||||
|
if err := validateNavigationURL(rawURL); err != nil {
|
||||||
|
return coreerr.E(scope, "invalid navigation URL", err)
|
||||||
|
}
|
||||||
|
|
||||||
_, err := wv.client.Call(ctx, "Page.navigate", map[string]any{
|
_, err := wv.client.Call(ctx, "Page.navigate", map[string]any{
|
||||||
"url": url,
|
"url": rawURL,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return coreerr.E("Webview.Navigate", "failed to navigate", err)
|
return coreerr.E(scope, "failed to navigate", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for page load
|
// Wait for page load
|
||||||
return wv.waitForLoad(ctx)
|
return wv.waitForLoad(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Click clicks on an element matching the selector.
|
// Click a button or link resolved by CSS selector.
|
||||||
|
//
|
||||||
|
// wv.Click("button[type=submit]")
|
||||||
func (wv *Webview) Click(selector string) error {
|
func (wv *Webview) Click(selector string) error {
|
||||||
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
|
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
@ -172,7 +200,9 @@ func (wv *Webview) Click(selector string) error {
|
||||||
return wv.click(ctx, selector)
|
return wv.click(ctx, selector)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Type types text into an element matching the selector.
|
// Focus an input and type text through CDP key events.
|
||||||
|
//
|
||||||
|
// wv.Type("input[name=email]", "agent@example.com")
|
||||||
func (wv *Webview) Type(selector, text string) error {
|
func (wv *Webview) Type(selector, text string) error {
|
||||||
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
|
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
@ -180,7 +210,9 @@ func (wv *Webview) Type(selector, text string) error {
|
||||||
return wv.typeText(ctx, selector, text)
|
return wv.typeText(ctx, selector, text)
|
||||||
}
|
}
|
||||||
|
|
||||||
// QuerySelector finds an element by CSS selector and returns its information.
|
// Inspect the first matching element, including attributes and box metrics.
|
||||||
|
//
|
||||||
|
// elem, err := wv.QuerySelector("#main")
|
||||||
func (wv *Webview) QuerySelector(selector string) (*ElementInfo, error) {
|
func (wv *Webview) QuerySelector(selector string) (*ElementInfo, error) {
|
||||||
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
|
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
@ -188,7 +220,9 @@ func (wv *Webview) QuerySelector(selector string) (*ElementInfo, error) {
|
||||||
return wv.querySelector(ctx, selector)
|
return wv.querySelector(ctx, selector)
|
||||||
}
|
}
|
||||||
|
|
||||||
// QuerySelectorAll finds all elements matching the selector.
|
// Inspect every element that matches the CSS selector.
|
||||||
|
//
|
||||||
|
// items, err := wv.QuerySelectorAll("table tbody tr")
|
||||||
func (wv *Webview) QuerySelectorAll(selector string) ([]*ElementInfo, error) {
|
func (wv *Webview) QuerySelectorAll(selector string) ([]*ElementInfo, error) {
|
||||||
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
|
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
@ -241,7 +275,9 @@ func (wv *Webview) ClearConsole() {
|
||||||
wv.consoleLogs = wv.consoleLogs[:0]
|
wv.consoleLogs = wv.consoleLogs[:0]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Screenshot captures a screenshot and returns it as PNG bytes.
|
// Capture the current page as PNG bytes.
|
||||||
|
//
|
||||||
|
// png, err := wv.Screenshot()
|
||||||
func (wv *Webview) Screenshot() ([]byte, error) {
|
func (wv *Webview) Screenshot() ([]byte, error) {
|
||||||
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
|
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
@ -266,7 +302,10 @@ func (wv *Webview) Screenshot() ([]byte, error) {
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Evaluate executes JavaScript and returns the result.
|
// Run JavaScript in the page and return the serialised value.
|
||||||
|
//
|
||||||
|
// title, err := wv.Evaluate("document.title")
|
||||||
|
//
|
||||||
// Note: This intentionally executes arbitrary JavaScript in the browser context
|
// Note: This intentionally executes arbitrary JavaScript in the browser context
|
||||||
// for browser automation purposes. The script runs in the sandboxed browser environment.
|
// for browser automation purposes. The script runs in the sandboxed browser environment.
|
||||||
func (wv *Webview) Evaluate(script string) (any, error) {
|
func (wv *Webview) Evaluate(script string) (any, error) {
|
||||||
|
|
@ -276,7 +315,9 @@ func (wv *Webview) Evaluate(script string) (any, error) {
|
||||||
return wv.evaluate(ctx, script)
|
return wv.evaluate(ctx, script)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WaitForSelector waits for an element matching the selector to appear.
|
// Block until an element matching the selector exists in the DOM.
|
||||||
|
//
|
||||||
|
// wv.WaitForSelector("[data-ready=true]")
|
||||||
func (wv *Webview) WaitForSelector(selector string) error {
|
func (wv *Webview) WaitForSelector(selector string) error {
|
||||||
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
|
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
@ -329,7 +370,7 @@ func (wv *Webview) GetHTML(selector string) (string, error) {
|
||||||
if selector == "" {
|
if selector == "" {
|
||||||
script = "document.documentElement.outerHTML"
|
script = "document.documentElement.outerHTML"
|
||||||
} else {
|
} else {
|
||||||
script = fmt.Sprintf("document.querySelector(%q)?.outerHTML || ''", selector)
|
script = core.Sprintf("document.querySelector(%q)?.outerHTML || ''", selector)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := wv.evaluate(ctx, script)
|
result, err := wv.evaluate(ctx, script)
|
||||||
|
|
@ -345,7 +386,9 @@ func (wv *Webview) GetHTML(selector string) (string, error) {
|
||||||
return html, nil
|
return html, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetViewport sets the viewport size.
|
// Emulate a 1440x900 desktop viewport for later interactions.
|
||||||
|
//
|
||||||
|
// wv.SetViewport(1440, 900)
|
||||||
func (wv *Webview) SetViewport(width, height int) error {
|
func (wv *Webview) SetViewport(width, height int) error {
|
||||||
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
|
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
@ -356,10 +399,16 @@ func (wv *Webview) SetViewport(width, height int) error {
|
||||||
"deviceScaleFactor": 1,
|
"deviceScaleFactor": 1,
|
||||||
"mobile": false,
|
"mobile": false,
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
return coreerr.E("Webview.SetViewport", "failed to set viewport", err)
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetUserAgent sets the user agent string.
|
// Override the browser user agent for later requests.
|
||||||
|
//
|
||||||
|
// wv.SetUserAgent("Mozilla/5.0 AgentHarness/1.0")
|
||||||
func (wv *Webview) SetUserAgent(userAgent string) error {
|
func (wv *Webview) SetUserAgent(userAgent string) error {
|
||||||
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
|
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
@ -367,6 +416,10 @@ func (wv *Webview) SetUserAgent(userAgent string) error {
|
||||||
_, err := wv.client.Call(ctx, "Emulation.setUserAgentOverride", map[string]any{
|
_, err := wv.client.Call(ctx, "Emulation.setUserAgentOverride", map[string]any{
|
||||||
"userAgent": userAgent,
|
"userAgent": userAgent,
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
return coreerr.E("Webview.SetUserAgent", "failed to set user agent", err)
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -385,24 +438,56 @@ func (wv *Webview) Reload() error {
|
||||||
|
|
||||||
// GoBack navigates back in history.
|
// GoBack navigates back in history.
|
||||||
func (wv *Webview) GoBack() error {
|
func (wv *Webview) GoBack() error {
|
||||||
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
|
return wv.navigateHistory(-1, "Webview.GoBack")
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
_, err := wv.client.Call(ctx, "Page.goBackOrForward", map[string]any{
|
|
||||||
"delta": -1,
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GoForward navigates forward in history.
|
// GoForward navigates forward in history.
|
||||||
func (wv *Webview) GoForward() error {
|
func (wv *Webview) GoForward() error {
|
||||||
|
return wv.navigateHistory(1, "Webview.GoForward")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wv *Webview) navigateHistory(delta int, scope string) error {
|
||||||
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
|
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
_, err := wv.client.Call(ctx, "Page.goBackOrForward", map[string]any{
|
history, err := wv.client.Call(ctx, "Page.getNavigationHistory", nil)
|
||||||
"delta": 1,
|
if err != nil {
|
||||||
|
return coreerr.E(scope, "failed to get navigation history", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentIndexFloat, ok := history["currentIndex"].(float64)
|
||||||
|
if !ok {
|
||||||
|
return coreerr.E(scope, "invalid navigation history index", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, ok := history["entries"].([]any)
|
||||||
|
if !ok {
|
||||||
|
return coreerr.E(scope, "invalid navigation history entries", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
targetIndex := int(currentIndexFloat) + delta
|
||||||
|
if targetIndex < 0 || targetIndex >= len(entries) {
|
||||||
|
return coreerr.E(scope, "no navigation history entry available", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, ok := entries[targetIndex].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return coreerr.E(scope, "invalid navigation history entry", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
entryIDFloat, ok := entry["id"].(float64)
|
||||||
|
if !ok {
|
||||||
|
return coreerr.E(scope, "invalid navigation history entry id", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = wv.client.Call(ctx, "Page.navigateToHistoryEntry", map[string]any{
|
||||||
|
"entryId": int(entryIDFloat),
|
||||||
})
|
})
|
||||||
return err
|
if err != nil {
|
||||||
|
return coreerr.E(scope, "failed to navigate history", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return wv.waitForLoad(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// addConsoleMessage adds a console message to the log.
|
// addConsoleMessage adds a console message to the log.
|
||||||
|
|
@ -410,11 +495,8 @@ func (wv *Webview) addConsoleMessage(msg ConsoleMessage) {
|
||||||
wv.mu.Lock()
|
wv.mu.Lock()
|
||||||
defer wv.mu.Unlock()
|
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)
|
wv.consoleLogs = append(wv.consoleLogs, msg)
|
||||||
|
wv.consoleLogs = trimConsoleMessages(wv.consoleLogs, wv.consoleLimit)
|
||||||
}
|
}
|
||||||
|
|
||||||
// enableConsole enables console message capture.
|
// enableConsole enables console message capture.
|
||||||
|
|
@ -450,21 +532,11 @@ func (wv *Webview) enableConsole() error {
|
||||||
|
|
||||||
// handleConsoleEvent processes console API events.
|
// handleConsoleEvent processes console API events.
|
||||||
func (wv *Webview) handleConsoleEvent(params map[string]any) {
|
func (wv *Webview) handleConsoleEvent(params map[string]any) {
|
||||||
msgType, _ := params["type"].(string)
|
msgType := normalizeConsoleType(core.Sprint(params["type"]))
|
||||||
|
|
||||||
// Extract args
|
// Extract args
|
||||||
args, _ := params["args"].([]any)
|
args, _ := params["args"].([]any)
|
||||||
var text strings.Builder
|
text := consoleTextFromArgs(args)
|
||||||
for i, arg := range args {
|
|
||||||
if argMap, ok := arg.(map[string]any); ok {
|
|
||||||
if val, ok := argMap["value"]; ok {
|
|
||||||
if i > 0 {
|
|
||||||
text.WriteString(" ")
|
|
||||||
}
|
|
||||||
text.WriteString(fmt.Sprint(val))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract stack trace info
|
// Extract stack trace info
|
||||||
stackTrace, _ := params["stackTrace"].(map[string]any)
|
stackTrace, _ := params["stackTrace"].(map[string]any)
|
||||||
|
|
@ -482,8 +554,8 @@ func (wv *Webview) handleConsoleEvent(params map[string]any) {
|
||||||
|
|
||||||
wv.addConsoleMessage(ConsoleMessage{
|
wv.addConsoleMessage(ConsoleMessage{
|
||||||
Type: msgType,
|
Type: msgType,
|
||||||
Text: text.String(),
|
Text: text,
|
||||||
Timestamp: time.Now(),
|
Timestamp: consoleCaptureTimestamp(),
|
||||||
URL: url,
|
URL: url,
|
||||||
Line: line,
|
Line: line,
|
||||||
Column: column,
|
Column: column,
|
||||||
|
|
@ -517,7 +589,7 @@ func (wv *Webview) waitForSelector(ctx context.Context, selector string) error {
|
||||||
ticker := time.NewTicker(100 * time.Millisecond)
|
ticker := time.NewTicker(100 * time.Millisecond)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
script := fmt.Sprintf("!!document.querySelector(%q)", selector)
|
script := core.Sprintf("!!document.querySelector(%q)", selector)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
|
|
@ -541,6 +613,7 @@ func (wv *Webview) evaluate(ctx context.Context, script string) (any, error) {
|
||||||
result, err := wv.client.Call(ctx, "Runtime.evaluate", map[string]any{
|
result, err := wv.client.Call(ctx, "Runtime.evaluate", map[string]any{
|
||||||
"expression": script,
|
"expression": script,
|
||||||
"returnByValue": true,
|
"returnByValue": true,
|
||||||
|
"awaitPromise": true,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, coreerr.E("Webview.evaluate", "failed to evaluate script", err)
|
return nil, coreerr.E("Webview.evaluate", "failed to evaluate script", err)
|
||||||
|
|
@ -548,12 +621,7 @@ func (wv *Webview) evaluate(ctx context.Context, script string) (any, error) {
|
||||||
|
|
||||||
// Check for exception
|
// Check for exception
|
||||||
if exceptionDetails, ok := result["exceptionDetails"].(map[string]any); ok {
|
if exceptionDetails, ok := result["exceptionDetails"].(map[string]any); ok {
|
||||||
if exception, ok := exceptionDetails["exception"].(map[string]any); ok {
|
return nil, runtimeExceptionError("Webview.evaluate", exceptionDetails)
|
||||||
if description, ok := exception["description"].(string); ok {
|
|
||||||
return nil, coreerr.E("Webview.evaluate", description, nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, coreerr.E("Webview.evaluate", "JavaScript error", nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract result value
|
// Extract result value
|
||||||
|
|
@ -561,7 +629,7 @@ func (wv *Webview) evaluate(ctx context.Context, script string) (any, error) {
|
||||||
return resultObj["value"], nil
|
return resultObj["value"], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, nil
|
return nil, coreerr.E("Webview.evaluate", "missing evaluation result", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// querySelector finds an element by selector.
|
// querySelector finds an element by selector.
|
||||||
|
|
@ -670,6 +738,8 @@ func (wv *Webview) getElementInfo(ctx context.Context, nodeID int) (*ElementInfo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
innerHTML, innerText := wv.getElementContent(ctx, nodeID)
|
||||||
|
|
||||||
// Get bounding box
|
// Get bounding box
|
||||||
var box *BoundingBox
|
var box *BoundingBox
|
||||||
if boxResult, err := wv.client.Call(ctx, "DOM.getBoxModel", map[string]any{
|
if boxResult, err := wv.client.Call(ctx, "DOM.getBoxModel", map[string]any{
|
||||||
|
|
@ -695,10 +765,61 @@ func (wv *Webview) getElementInfo(ctx context.Context, nodeID int) (*ElementInfo
|
||||||
NodeID: nodeID,
|
NodeID: nodeID,
|
||||||
TagName: tagName,
|
TagName: tagName,
|
||||||
Attributes: attrs,
|
Attributes: attrs,
|
||||||
|
InnerHTML: innerHTML,
|
||||||
|
InnerText: innerText,
|
||||||
BoundingBox: box,
|
BoundingBox: box,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getElementContent retrieves the element's inner HTML and inner text.
|
||||||
|
func (wv *Webview) getElementContent(ctx context.Context, nodeID int) (string, string) {
|
||||||
|
resolveResult, err := wv.client.Call(ctx, "DOM.resolveNode", map[string]any{
|
||||||
|
"nodeId": nodeID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
object, ok := resolveResult["object"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
objectID, ok := object["objectId"].(string)
|
||||||
|
if !ok || objectID == "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
callResult, err := wv.client.Call(ctx, "Runtime.callFunctionOn", map[string]any{
|
||||||
|
"objectId": objectID,
|
||||||
|
"functionDeclaration": "function() { return { innerHTML: this.innerHTML || '', innerText: this.innerText || '' }; }",
|
||||||
|
"returnByValue": true,
|
||||||
|
"awaitPromise": true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseElementContent(callResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseElementContent extracts inner HTML and inner text from a CDP response.
|
||||||
|
func parseElementContent(result map[string]any) (string, string) {
|
||||||
|
resultObj, ok := result["result"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
value, ok := resultObj["value"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
innerHTML, _ := value["innerHTML"].(string)
|
||||||
|
innerText, _ := value["innerText"].(string)
|
||||||
|
return innerHTML, innerText
|
||||||
|
}
|
||||||
|
|
||||||
// click performs a click on an element.
|
// click performs a click on an element.
|
||||||
func (wv *Webview) click(ctx context.Context, selector string) error {
|
func (wv *Webview) click(ctx context.Context, selector string) error {
|
||||||
// Find element and get its center coordinates
|
// Find element and get its center coordinates
|
||||||
|
|
@ -709,7 +830,7 @@ func (wv *Webview) click(ctx context.Context, selector string) error {
|
||||||
|
|
||||||
if elem.BoundingBox == nil {
|
if elem.BoundingBox == nil {
|
||||||
// Fallback to JavaScript click
|
// Fallback to JavaScript click
|
||||||
script := fmt.Sprintf("document.querySelector(%q)?.click()", selector)
|
script := core.Sprintf("document.querySelector(%q)?.click()", selector)
|
||||||
_, err := wv.evaluate(ctx, script)
|
_, err := wv.evaluate(ctx, script)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -738,7 +859,7 @@ func (wv *Webview) click(ctx context.Context, selector string) error {
|
||||||
// typeText types text into an element.
|
// typeText types text into an element.
|
||||||
func (wv *Webview) typeText(ctx context.Context, selector, text string) error {
|
func (wv *Webview) typeText(ctx context.Context, selector, text string) error {
|
||||||
// Focus the element first
|
// Focus the element first
|
||||||
script := fmt.Sprintf("document.querySelector(%q)?.focus()", selector)
|
script := core.Sprintf("document.querySelector(%q)?.focus()", selector)
|
||||||
_, err := wv.evaluate(ctx, script)
|
_, err := wv.evaluate(ctx, script)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return coreerr.E("Webview.typeText", "failed to focus element", err)
|
return coreerr.E("Webview.typeText", "failed to focus element", err)
|
||||||
|
|
|
||||||
484
webview_methods_test.go
Normal file
484
webview_methods_test.go
Normal file
|
|
@ -0,0 +1,484 @@
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
|
package webview
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newWebviewHarness(t *testing.T, onMessage func(*fakeCDPTarget, cdpMessage)) (*Webview, *fakeCDPTarget) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
server := newFakeCDPServer(t)
|
||||||
|
target := server.primaryTarget()
|
||||||
|
target.onMessage = onMessage
|
||||||
|
|
||||||
|
client := newConnectedCDPClient(t, target)
|
||||||
|
wv := &Webview{
|
||||||
|
client: client,
|
||||||
|
ctx: context.Background(),
|
||||||
|
timeout: time.Second,
|
||||||
|
consoleLogs: make([]ConsoleMessage, 0),
|
||||||
|
consoleLimit: 10,
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = client.Close()
|
||||||
|
})
|
||||||
|
return wv, target
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebview_Close_Good(t *testing.T) {
|
||||||
|
server := newFakeCDPServer(t)
|
||||||
|
client := newConnectedCDPClient(t, server.primaryTarget())
|
||||||
|
wv := &Webview{
|
||||||
|
client: client,
|
||||||
|
ctx: context.Background(),
|
||||||
|
cancel: func() {},
|
||||||
|
consoleLogs: make([]ConsoleMessage, 0),
|
||||||
|
consoleLimit: 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := wv.Close(); err != nil {
|
||||||
|
t.Fatalf("Close returned error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebview_New_Good_EnablesConsoleCapture(t *testing.T) {
|
||||||
|
server := newFakeCDPServer(t)
|
||||||
|
target := server.primaryTarget()
|
||||||
|
target.onMessage = func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
switch msg.Method {
|
||||||
|
case "Runtime.enable", "Page.enable", "DOM.enable":
|
||||||
|
target.reply(msg.ID, map[string]any{})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected method %q during New", msg.Method)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wv, err := New(WithDebugURL(server.DebugURL()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New returned error: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = wv.Close() }()
|
||||||
|
|
||||||
|
target.writeJSON(cdpEvent{
|
||||||
|
Method: "Runtime.consoleAPICalled",
|
||||||
|
Params: map[string]any{
|
||||||
|
"type": "log",
|
||||||
|
"args": []any{map[string]any{"value": "hello"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
if got := wv.GetConsole(); len(got) != 1 || got[0].Text != "hello" {
|
||||||
|
t.Fatalf("New console capture = %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebview_Navigate_Bad(t *testing.T) {
|
||||||
|
wv, _ := newWebviewHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
t.Fatalf("unexpected CDP call %q for invalid navigation URL", msg.Method)
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := wv.Navigate("javascript:alert(1)"); err == nil {
|
||||||
|
t.Fatal("Navigate succeeded with dangerous URL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebview_Navigate_Good(t *testing.T) {
|
||||||
|
var methods []string
|
||||||
|
wv, _ := newWebviewHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
methods = append(methods, msg.Method)
|
||||||
|
switch msg.Method {
|
||||||
|
case "Page.navigate":
|
||||||
|
target.reply(msg.ID, map[string]any{})
|
||||||
|
case "Runtime.evaluate":
|
||||||
|
target.replyValue(msg.ID, "complete")
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := wv.Navigate("https://example.com"); err != nil {
|
||||||
|
t.Fatalf("Navigate returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(methods) != 2 || methods[0] != "Page.navigate" || methods[1] != "Runtime.evaluate" {
|
||||||
|
t.Fatalf("Navigate call order = %v", methods)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebview_QuerySelectorAndAll_Good(t *testing.T) {
|
||||||
|
wv, _ := newWebviewHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
switch msg.Method {
|
||||||
|
case "DOM.getDocument":
|
||||||
|
target.reply(msg.ID, map[string]any{"root": map[string]any{"nodeId": float64(1)}})
|
||||||
|
case "DOM.querySelector":
|
||||||
|
target.reply(msg.ID, map[string]any{"nodeId": float64(21)})
|
||||||
|
case "DOM.querySelectorAll":
|
||||||
|
target.reply(msg.ID, map[string]any{"nodeIds": []any{float64(21), float64(22)}})
|
||||||
|
case "DOM.describeNode":
|
||||||
|
target.reply(msg.ID, map[string]any{"node": map[string]any{"nodeName": "DIV", "attributes": []any{"id", "main"}}})
|
||||||
|
case "DOM.resolveNode":
|
||||||
|
target.reply(msg.ID, map[string]any{"object": map[string]any{"objectId": "obj-1"}})
|
||||||
|
case "Runtime.callFunctionOn":
|
||||||
|
target.reply(msg.ID, map[string]any{"result": map[string]any{"value": map[string]any{"innerHTML": "<span>hello</span>", "innerText": "hello"}}})
|
||||||
|
case "DOM.getBoxModel":
|
||||||
|
target.reply(msg.ID, map[string]any{"model": map[string]any{"content": []any{float64(1), float64(2), float64(11), float64(2), float64(11), float64(12), float64(1), float64(12)}}})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
elem, err := wv.QuerySelector("#main")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("QuerySelector returned error: %v", err)
|
||||||
|
}
|
||||||
|
if elem.NodeID != 21 || elem.TagName != "DIV" || elem.InnerText != "hello" {
|
||||||
|
t.Fatalf("QuerySelector returned %#v", elem)
|
||||||
|
}
|
||||||
|
if elem.BoundingBox == nil || elem.BoundingBox.Width != 10 || elem.BoundingBox.Height != 10 {
|
||||||
|
t.Fatalf("QuerySelector bounding box = %#v", elem.BoundingBox)
|
||||||
|
}
|
||||||
|
|
||||||
|
all, err := wv.QuerySelectorAll("div.item")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("QuerySelectorAll returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(all) != 2 {
|
||||||
|
t.Fatalf("QuerySelectorAll len = %d, want 2", len(all))
|
||||||
|
}
|
||||||
|
|
||||||
|
iterated := make([]int, 0)
|
||||||
|
for elem := range wv.QuerySelectorAllAll("div.item") {
|
||||||
|
iterated = append(iterated, elem.NodeID)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if len(iterated) != 1 || iterated[0] != 21 {
|
||||||
|
t.Fatalf("QuerySelectorAllAll yielded %v, want first node 21", iterated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebview_ClickAndType_Good(t *testing.T) {
|
||||||
|
var methods []string
|
||||||
|
wv, _ := newWebviewHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
methods = append(methods, msg.Method)
|
||||||
|
switch msg.Method {
|
||||||
|
case "DOM.getDocument":
|
||||||
|
target.reply(msg.ID, map[string]any{"root": map[string]any{"nodeId": float64(1)}})
|
||||||
|
case "DOM.querySelector":
|
||||||
|
target.reply(msg.ID, map[string]any{"nodeId": float64(10)})
|
||||||
|
case "DOM.describeNode":
|
||||||
|
target.reply(msg.ID, map[string]any{"node": map[string]any{"nodeName": "BUTTON"}})
|
||||||
|
case "DOM.resolveNode":
|
||||||
|
target.reply(msg.ID, map[string]any{"object": map[string]any{"objectId": "obj-1"}})
|
||||||
|
case "Runtime.callFunctionOn":
|
||||||
|
target.reply(msg.ID, map[string]any{"result": map[string]any{"value": map[string]any{"innerHTML": "", "innerText": ""}}})
|
||||||
|
case "DOM.getBoxModel":
|
||||||
|
target.reply(msg.ID, map[string]any{"model": map[string]any{"content": []any{float64(10), float64(20), float64(30), float64(20), float64(30), float64(40), float64(10), float64(40)}}})
|
||||||
|
case "Input.dispatchMouseEvent", "Input.dispatchKeyEvent":
|
||||||
|
target.reply(msg.ID, map[string]any{})
|
||||||
|
case "Runtime.evaluate":
|
||||||
|
target.replyValue(msg.ID, true)
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := wv.Click("#button"); err != nil {
|
||||||
|
t.Fatalf("Click returned error: %v", err)
|
||||||
|
}
|
||||||
|
if err := wv.Type("#input", "ab"); err != nil {
|
||||||
|
t.Fatalf("Type returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(methods) < 8 {
|
||||||
|
t.Fatalf("Click+Type methods = %v", methods)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebview_WaitForSelector_Good(t *testing.T) {
|
||||||
|
var calls int
|
||||||
|
wv, _ := newWebviewHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
if msg.Method != "Runtime.evaluate" {
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
calls++
|
||||||
|
if calls == 1 {
|
||||||
|
target.replyValue(msg.ID, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target.replyValue(msg.ID, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := wv.WaitForSelector("#ready"); err != nil {
|
||||||
|
t.Fatalf("WaitForSelector returned error: %v", err)
|
||||||
|
}
|
||||||
|
if calls < 2 {
|
||||||
|
t.Fatalf("WaitForSelector calls = %d, want at least 2", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebview_ScreenshotAndInfo_Good(t *testing.T) {
|
||||||
|
wv, _ := newWebviewHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
switch msg.Method {
|
||||||
|
case "Page.captureScreenshot":
|
||||||
|
if got := msg.Params["format"]; got != "png" {
|
||||||
|
t.Fatalf("captureScreenshot format = %v, want png", got)
|
||||||
|
}
|
||||||
|
target.reply(msg.ID, map[string]any{"data": base64.StdEncoding.EncodeToString([]byte{0x89, 0x50, 0x4e, 0x47})})
|
||||||
|
case "Runtime.evaluate":
|
||||||
|
expr, _ := msg.Params["expression"].(string)
|
||||||
|
switch expr {
|
||||||
|
case "window.location.href":
|
||||||
|
target.replyValue(msg.ID, "https://example.com")
|
||||||
|
case "document.title":
|
||||||
|
target.replyValue(msg.ID, "Example")
|
||||||
|
case "document.documentElement.outerHTML":
|
||||||
|
target.replyValue(msg.ID, "<html></html>")
|
||||||
|
case "document.readyState":
|
||||||
|
target.replyValue(msg.ID, "complete")
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected evaluate expression %q", expr)
|
||||||
|
}
|
||||||
|
case "Emulation.setDeviceMetricsOverride", "Emulation.setUserAgentOverride", "Page.reload":
|
||||||
|
target.reply(msg.ID, map[string]any{})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
png, err := wv.Screenshot()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Screenshot returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(png) != 4 || png[0] != 0x89 {
|
||||||
|
t.Fatalf("Screenshot bytes = %v", png)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got, err := wv.GetURL(); err != nil || got != "https://example.com" {
|
||||||
|
t.Fatalf("GetURL = %q, %v", got, err)
|
||||||
|
}
|
||||||
|
if got, err := wv.GetTitle(); err != nil || got != "Example" {
|
||||||
|
t.Fatalf("GetTitle = %q, %v", got, err)
|
||||||
|
}
|
||||||
|
if got, err := wv.GetHTML(""); err != nil || got != "<html></html>" {
|
||||||
|
t.Fatalf("GetHTML = %q, %v", got, err)
|
||||||
|
}
|
||||||
|
if err := wv.SetViewport(1440, 900); err != nil {
|
||||||
|
t.Fatalf("SetViewport returned error: %v", err)
|
||||||
|
}
|
||||||
|
if err := wv.SetUserAgent("AgentHarness/1.0"); err != nil {
|
||||||
|
t.Fatalf("SetUserAgent returned error: %v", err)
|
||||||
|
}
|
||||||
|
if err := wv.Reload(); err != nil {
|
||||||
|
t.Fatalf("Reload returned error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebview_Screenshot_Bad_InvalidData(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
result map[string]any
|
||||||
|
}{
|
||||||
|
{name: "missing data", result: map[string]any{}},
|
||||||
|
{name: "invalid base64", result: map[string]any{"data": "%%%"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
wv, _ := newWebviewHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
if msg.Method != "Page.captureScreenshot" {
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
target.reply(msg.ID, tc.result)
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := wv.Screenshot(); err == nil {
|
||||||
|
t.Fatalf("Screenshot succeeded with %#v", tc.result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebview_GetURL_Bad_InvalidResult(t *testing.T) {
|
||||||
|
wv, _ := newWebviewHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
if msg.Method != "Runtime.evaluate" {
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
target.reply(msg.ID, map[string]any{"result": map[string]any{"value": float64(1)}})
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := wv.GetURL(); err == nil {
|
||||||
|
t.Fatal("GetURL succeeded with a non-string result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebview_GetTitle_Bad_InvalidResult(t *testing.T) {
|
||||||
|
wv, _ := newWebviewHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
if msg.Method != "Runtime.evaluate" {
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
target.reply(msg.ID, map[string]any{"result": map[string]any{"value": float64(1)}})
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := wv.GetTitle(); err == nil {
|
||||||
|
t.Fatal("GetTitle succeeded with a non-string result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebview_GetHTML_Bad_InvalidResult(t *testing.T) {
|
||||||
|
wv, _ := newWebviewHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
if msg.Method != "Runtime.evaluate" {
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
target.reply(msg.ID, map[string]any{"result": map[string]any{"value": float64(1)}})
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := wv.GetHTML("#main"); err == nil {
|
||||||
|
t.Fatal("GetHTML succeeded with a non-string result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebview_NavigateHistory_Bad_MalformedHistory(t *testing.T) {
|
||||||
|
wv, _ := newWebviewHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
if msg.Method != "Page.getNavigationHistory" {
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
target.reply(msg.ID, map[string]any{
|
||||||
|
"currentIndex": "bad",
|
||||||
|
"entries": "bad",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := wv.GoBack(); err == nil {
|
||||||
|
t.Fatal("GoBack succeeded with malformed navigation history")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebview_Console_Good(t *testing.T) {
|
||||||
|
wv := &Webview{
|
||||||
|
consoleLogs: make([]ConsoleMessage, 0),
|
||||||
|
consoleLimit: 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
wv.addConsoleMessage(ConsoleMessage{Text: "one"})
|
||||||
|
wv.addConsoleMessage(ConsoleMessage{Text: "two"})
|
||||||
|
wv.addConsoleMessage(ConsoleMessage{Text: "three"})
|
||||||
|
|
||||||
|
got := wv.GetConsole()
|
||||||
|
if len(got) != 2 || got[0].Text != "two" || got[1].Text != "three" {
|
||||||
|
t.Fatalf("GetConsole = %#v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
iterated := make([]string, 0)
|
||||||
|
for msg := range wv.GetConsoleAll() {
|
||||||
|
iterated = append(iterated, msg.Text)
|
||||||
|
}
|
||||||
|
if len(iterated) != 2 {
|
||||||
|
t.Fatalf("GetConsoleAll = %#v", iterated)
|
||||||
|
}
|
||||||
|
|
||||||
|
wv.ClearConsole()
|
||||||
|
if got := wv.GetConsole(); len(got) != 0 {
|
||||||
|
t.Fatalf("ClearConsole did not empty logs: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebview_UploadFileAndDragAndDrop_Good(t *testing.T) {
|
||||||
|
var methods []string
|
||||||
|
wv, _ := newWebviewHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
methods = append(methods, msg.Method)
|
||||||
|
switch msg.Method {
|
||||||
|
case "DOM.getDocument":
|
||||||
|
target.reply(msg.ID, map[string]any{"root": map[string]any{"nodeId": float64(1)}})
|
||||||
|
case "DOM.querySelector":
|
||||||
|
sel, _ := msg.Params["selector"].(string)
|
||||||
|
switch sel {
|
||||||
|
case "#file":
|
||||||
|
target.reply(msg.ID, map[string]any{"nodeId": float64(41)})
|
||||||
|
case "#source":
|
||||||
|
target.reply(msg.ID, map[string]any{"nodeId": float64(42)})
|
||||||
|
case "#target":
|
||||||
|
target.reply(msg.ID, map[string]any{"nodeId": float64(43)})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected selector %q", sel)
|
||||||
|
}
|
||||||
|
case "DOM.describeNode":
|
||||||
|
target.reply(msg.ID, map[string]any{"node": map[string]any{"nodeName": "INPUT"}})
|
||||||
|
case "DOM.resolveNode":
|
||||||
|
target.reply(msg.ID, map[string]any{"object": map[string]any{"objectId": "obj-1"}})
|
||||||
|
case "Runtime.callFunctionOn":
|
||||||
|
target.reply(msg.ID, map[string]any{"result": map[string]any{"value": map[string]any{"innerHTML": "", "innerText": ""}}})
|
||||||
|
case "DOM.getBoxModel":
|
||||||
|
nodeID := int(msg.Params["nodeId"].(float64))
|
||||||
|
box := []any{float64(nodeID), float64(nodeID), float64(nodeID + 1), float64(nodeID), float64(nodeID + 1), float64(nodeID + 1), float64(nodeID), float64(nodeID + 1)}
|
||||||
|
target.reply(msg.ID, map[string]any{"model": map[string]any{"content": box}})
|
||||||
|
case "DOM.setFileInputFiles", "Input.dispatchMouseEvent":
|
||||||
|
target.reply(msg.ID, map[string]any{})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := wv.UploadFile("#file", []string{"/tmp/a.txt"}); err != nil {
|
||||||
|
t.Fatalf("UploadFile returned error: %v", err)
|
||||||
|
}
|
||||||
|
if err := wv.DragAndDrop("#source", "#target"); err != nil {
|
||||||
|
t.Fatalf("DragAndDrop returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(methods) < 10 {
|
||||||
|
t.Fatalf("UploadFile+DragAndDrop methods = %v", methods)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebview_WaitForSelector_Bad(t *testing.T) {
|
||||||
|
wv, _ := newWebviewHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
if msg.Method != "Runtime.evaluate" {
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
target.replyValue(msg.ID, false)
|
||||||
|
})
|
||||||
|
wv.timeout = 50 * time.Millisecond
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(wv.ctx, 50*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
if err := wv.waitForSelector(ctx, "#never"); err == nil {
|
||||||
|
t.Fatal("waitForSelector succeeded without matching element")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebview_Click_Ugly_FallsBackToJS(t *testing.T) {
|
||||||
|
var expressions []string
|
||||||
|
wv, _ := newWebviewHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
|
||||||
|
switch msg.Method {
|
||||||
|
case "DOM.getDocument":
|
||||||
|
target.reply(msg.ID, map[string]any{"root": map[string]any{"nodeId": float64(1)}})
|
||||||
|
case "DOM.querySelector":
|
||||||
|
target.reply(msg.ID, map[string]any{"nodeId": float64(10)})
|
||||||
|
case "DOM.describeNode":
|
||||||
|
target.reply(msg.ID, map[string]any{"node": map[string]any{"nodeName": "BUTTON"}})
|
||||||
|
case "DOM.resolveNode":
|
||||||
|
target.reply(msg.ID, map[string]any{"object": map[string]any{"objectId": "obj-1"}})
|
||||||
|
case "Runtime.callFunctionOn":
|
||||||
|
target.reply(msg.ID, map[string]any{"result": map[string]any{"value": map[string]any{"innerHTML": "", "innerText": ""}}})
|
||||||
|
case "DOM.getBoxModel":
|
||||||
|
target.reply(msg.ID, map[string]any{})
|
||||||
|
case "Runtime.evaluate":
|
||||||
|
expr, _ := msg.Params["expression"].(string)
|
||||||
|
expressions = append(expressions, expr)
|
||||||
|
target.replyValue(msg.ID, true)
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected method %q", msg.Method)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := wv.Click("#button"); err != nil {
|
||||||
|
t.Fatalf("Click returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(expressions) != 1 || !strings.Contains(expressions[0], `document.querySelector("#button")?.click()`) {
|
||||||
|
t.Fatalf("Click fallback expression = %v", expressions)
|
||||||
|
}
|
||||||
|
}
|
||||||
739
webview_test.go
739
webview_test.go
|
|
@ -1,6 +1,12 @@
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
package webview
|
package webview
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
@ -118,6 +124,20 @@ func TestWithConsoleLimit_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestWithConsoleLimit_Bad_NegativeBecomesZero verifies negative limits are clamped to zero.
|
||||||
|
func TestWithConsoleLimit_Bad_NegativeBecomesZero(t *testing.T) {
|
||||||
|
wv := &Webview{consoleLimit: 10}
|
||||||
|
opt := WithConsoleLimit(-1)
|
||||||
|
|
||||||
|
if err := opt(wv); err != nil {
|
||||||
|
t.Fatalf("WithConsoleLimit returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if wv.consoleLimit != 0 {
|
||||||
|
t.Fatalf("Expected consoleLimit 0, got %d", wv.consoleLimit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestNew_Bad_NoDebugURL verifies New fails without a debug URL.
|
// TestNew_Bad_NoDebugURL verifies New fails without a debug URL.
|
||||||
func TestNew_Bad_NoDebugURL(t *testing.T) {
|
func TestNew_Bad_NoDebugURL(t *testing.T) {
|
||||||
_, err := New()
|
_, err := New()
|
||||||
|
|
@ -134,6 +154,13 @@ func TestNew_Bad_InvalidDebugURL(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWebview_Close_Good_NoClient(t *testing.T) {
|
||||||
|
wv := &Webview{cancel: func() {}}
|
||||||
|
if err := wv.Close(); err != nil {
|
||||||
|
t.Fatalf("Close returned error for nil client: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestActionSequence_Good verifies action sequence building works.
|
// TestActionSequence_Good verifies action sequence building works.
|
||||||
func TestActionSequence_Good(t *testing.T) {
|
func TestActionSequence_Good(t *testing.T) {
|
||||||
seq := NewActionSequence().
|
seq := NewActionSequence().
|
||||||
|
|
@ -148,6 +175,52 @@ func TestActionSequence_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestActionSequence_Good_AllBuilders verifies every fluent builder appends the expected action.
|
||||||
|
func TestActionSequence_Good_AllBuilders(t *testing.T) {
|
||||||
|
seq := NewActionSequence().
|
||||||
|
Scroll(0, 500).
|
||||||
|
ScrollIntoView("#target").
|
||||||
|
Focus("#input").
|
||||||
|
Blur("#input").
|
||||||
|
Clear("#input").
|
||||||
|
Select("#dropdown", "option1").
|
||||||
|
Check("#checkbox", true).
|
||||||
|
Hover("#menu-item").
|
||||||
|
DoubleClick("#editable").
|
||||||
|
RightClick("#context-menu-trigger").
|
||||||
|
PressKey("Enter").
|
||||||
|
SetAttribute("#element", "data-value", "test").
|
||||||
|
RemoveAttribute("#element", "disabled").
|
||||||
|
SetValue("#input", "new value")
|
||||||
|
|
||||||
|
if len(seq.actions) != 14 {
|
||||||
|
t.Fatalf("Expected 14 actions, got %d", len(seq.actions))
|
||||||
|
}
|
||||||
|
|
||||||
|
wantTypes := []any{
|
||||||
|
ScrollAction{X: 0, Y: 500},
|
||||||
|
ScrollIntoViewAction{Selector: "#target"},
|
||||||
|
FocusAction{Selector: "#input"},
|
||||||
|
BlurAction{Selector: "#input"},
|
||||||
|
ClearAction{Selector: "#input"},
|
||||||
|
SelectAction{Selector: "#dropdown", Value: "option1"},
|
||||||
|
CheckAction{Selector: "#checkbox", Checked: true},
|
||||||
|
HoverAction{Selector: "#menu-item"},
|
||||||
|
DoubleClickAction{Selector: "#editable"},
|
||||||
|
RightClickAction{Selector: "#context-menu-trigger"},
|
||||||
|
PressKeyAction{Key: "Enter"},
|
||||||
|
SetAttributeAction{Selector: "#element", Attribute: "data-value", Value: "test"},
|
||||||
|
RemoveAttributeAction{Selector: "#element", Attribute: "disabled"},
|
||||||
|
SetValueAction{Selector: "#input", Value: "new value"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, want := range wantTypes {
|
||||||
|
if got := seq.actions[i]; got != want {
|
||||||
|
t.Fatalf("action %d = %#v, want %#v", i, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestClickAction_Good verifies ClickAction struct.
|
// TestClickAction_Good verifies ClickAction struct.
|
||||||
func TestClickAction_Good(t *testing.T) {
|
func TestClickAction_Good(t *testing.T) {
|
||||||
action := ClickAction{Selector: "#submit"}
|
action := ClickAction{Selector: "#submit"}
|
||||||
|
|
@ -333,3 +406,669 @@ func TestScrollIntoViewAction_Good(t *testing.T) {
|
||||||
t.Errorf("Expected selector '#target', got %q", action.Selector)
|
t.Errorf("Expected selector '#target', got %q", action.Selector)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFormatConsoleOutput_Good verifies console output formatting.
|
||||||
|
func TestFormatConsoleOutput_Good(t *testing.T) {
|
||||||
|
ts := time.Date(2026, 1, 15, 14, 30, 45, 123000000, time.UTC)
|
||||||
|
messages := []ConsoleMessage{
|
||||||
|
{Type: "error", Text: "something broke", Timestamp: ts},
|
||||||
|
{Type: "warning", Text: "deprecated call", Timestamp: ts},
|
||||||
|
{Type: "info", Text: "loaded", Timestamp: ts},
|
||||||
|
{Type: "debug", Text: "trace data", Timestamp: ts},
|
||||||
|
{Type: "log", Text: "hello world", Timestamp: ts},
|
||||||
|
}
|
||||||
|
|
||||||
|
output := FormatConsoleOutput(messages)
|
||||||
|
|
||||||
|
expected := []string{
|
||||||
|
"14:30:45.123 [ERROR] something broke",
|
||||||
|
"14:30:45.123 [WARN] deprecated call",
|
||||||
|
"14:30:45.123 [INFO] loaded",
|
||||||
|
"14:30:45.123 [DEBUG] trace data",
|
||||||
|
"14:30:45.123 [LOG] hello world",
|
||||||
|
}
|
||||||
|
for _, exp := range expected {
|
||||||
|
if !containsString(output, exp) {
|
||||||
|
t.Errorf("Expected output to contain %q", exp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFormatConsoleOutput_Good_Empty verifies empty message list.
|
||||||
|
func TestFormatConsoleOutput_Good_Empty(t *testing.T) {
|
||||||
|
output := FormatConsoleOutput(nil)
|
||||||
|
if output != "" {
|
||||||
|
t.Errorf("Expected empty string, got %q", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFormatConsoleOutput_Good_SanitisesControlCharacters verifies console output is safe for log sinks.
|
||||||
|
func TestFormatConsoleOutput_Good_SanitisesControlCharacters(t *testing.T) {
|
||||||
|
output := FormatConsoleOutput([]ConsoleMessage{
|
||||||
|
{
|
||||||
|
Type: "error",
|
||||||
|
Text: "first line\nsecond line\x1b[31m",
|
||||||
|
Timestamp: time.Date(2026, 1, 15, 14, 30, 45, 0, time.UTC),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if !containsString(output, `first line\nsecond line\x1b[31m`) {
|
||||||
|
t.Fatalf("expected control characters to be escaped, got %q", output)
|
||||||
|
}
|
||||||
|
if containsString(output, "\nsecond line") {
|
||||||
|
t.Fatalf("expected embedded newlines to be escaped, got %q", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNormalizeConsoleType_Good verifies CDP warning aliases are normalised.
|
||||||
|
func TestNormalizeConsoleType_Good(t *testing.T) {
|
||||||
|
if got := normalizeConsoleType("warn"); got != "warn" {
|
||||||
|
t.Fatalf("normalizeConsoleType(\"warn\") = %q, want %q", got, "warn")
|
||||||
|
}
|
||||||
|
if got := normalizeConsoleType("WARNING"); got != "warn" {
|
||||||
|
t.Fatalf("normalizeConsoleType(\"WARNING\") = %q, want %q", got, "warn")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebviewHandleConsoleEvent_Good_NormalizesWarningType verifies CDP warning aliases are stored as warn.
|
||||||
|
func TestWebviewHandleConsoleEvent_Good_NormalizesWarningType(t *testing.T) {
|
||||||
|
wv := &Webview{
|
||||||
|
consoleLogs: make([]ConsoleMessage, 0),
|
||||||
|
consoleLimit: 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
wv.handleConsoleEvent(map[string]any{
|
||||||
|
"type": "warn",
|
||||||
|
"args": []any{
|
||||||
|
map[string]any{"value": "deprecated"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(wv.consoleLogs) != 1 {
|
||||||
|
t.Fatalf("Expected one console message, got %d", len(wv.consoleLogs))
|
||||||
|
}
|
||||||
|
if wv.consoleLogs[0].Type != "warn" {
|
||||||
|
t.Fatalf("Expected warn type, got %q", wv.consoleLogs[0].Type)
|
||||||
|
}
|
||||||
|
if wv.consoleLogs[0].Text != "deprecated" {
|
||||||
|
t.Fatalf("Expected text %q, got %q", "deprecated", wv.consoleLogs[0].Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestContainsString_Good verifies substring matching.
|
||||||
|
func TestContainsString_Good(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
s, substr string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"hello world", "world", true},
|
||||||
|
{"hello world", "hello", true},
|
||||||
|
{"hello world", "xyz", false},
|
||||||
|
{"hello", "", true},
|
||||||
|
{"", "", true},
|
||||||
|
{"", "a", false},
|
||||||
|
{"abc", "abc", true},
|
||||||
|
{"abc", "abcd", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
got := containsString(tc.s, tc.substr)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("containsString(%q, %q) = %v, want %v", tc.s, tc.substr, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindString_Good verifies string search.
|
||||||
|
func TestFindString_Good(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
s, substr string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"hello world", "world", 6},
|
||||||
|
{"hello world", "hello", 0},
|
||||||
|
{"hello world", "xyz", -1},
|
||||||
|
{"abcabc", "abc", 0},
|
||||||
|
{"abc", "abc", 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
got := findString(tc.s, tc.substr)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("findString(%q, %q) = %d, want %d", tc.s, tc.substr, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFormatJSValue_Good verifies JavaScript value formatting.
|
||||||
|
func TestFormatJSValue_Good(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input any
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"hello", `"hello"`},
|
||||||
|
{true, "true"},
|
||||||
|
{false, "false"},
|
||||||
|
{nil, "null"},
|
||||||
|
{42, "42"},
|
||||||
|
{3.14, "3.14"},
|
||||||
|
{map[string]any{"enabled": true}, `{"enabled":true}`},
|
||||||
|
{[]any{1, "two"}, `[1,"two"]`},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
got := formatJSValue(tc.input)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("formatJSValue(%v) = %q, want %q", tc.input, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseDebugURL_Bad_RejectsRemoteHosts verifies debug endpoints are loopback-only.
|
||||||
|
func TestParseDebugURL_Bad_RejectsRemoteHosts(t *testing.T) {
|
||||||
|
for _, raw := range []string{
|
||||||
|
"http://example.com:9222",
|
||||||
|
"http://10.0.0.1:9222",
|
||||||
|
"http://[2001:db8::1]:9222",
|
||||||
|
} {
|
||||||
|
if _, err := parseDebugURL(raw); err == nil {
|
||||||
|
t.Fatalf("parseDebugURL(%q) returned nil error", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseDebugURL_Good_AllowsLoopbackHosts verifies local debugging endpoints remain usable.
|
||||||
|
func TestParseDebugURL_Good_AllowsLoopbackHosts(t *testing.T) {
|
||||||
|
for _, raw := range []string{
|
||||||
|
"http://localhost:9222",
|
||||||
|
"http://127.0.0.1:9222",
|
||||||
|
"http://[::1]:9222",
|
||||||
|
} {
|
||||||
|
if _, err := parseDebugURL(raw); err != nil {
|
||||||
|
t.Fatalf("parseDebugURL(%q) returned error: %v", raw, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateNavigationURL_Good_AllowsWebURLs verifies navigation accepts HTTP(S) pages.
|
||||||
|
func TestValidateNavigationURL_Good_AllowsWebURLs(t *testing.T) {
|
||||||
|
for _, raw := range []string{
|
||||||
|
"https://example.com",
|
||||||
|
"http://localhost:8080/path?q=1",
|
||||||
|
"about:blank",
|
||||||
|
} {
|
||||||
|
if err := validateNavigationURL(raw); err != nil {
|
||||||
|
t.Fatalf("validateNavigationURL(%q) returned error: %v", raw, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateNavigationURL_Bad_RejectsDangerousSchemes verifies non-web schemes are blocked.
|
||||||
|
func TestValidateNavigationURL_Bad_RejectsDangerousSchemes(t *testing.T) {
|
||||||
|
for _, raw := range []string{
|
||||||
|
"javascript:alert(1)",
|
||||||
|
"data:text/html,hello",
|
||||||
|
"file:///etc/passwd",
|
||||||
|
"about:srcdoc",
|
||||||
|
"ftp://example.com",
|
||||||
|
} {
|
||||||
|
if err := validateNavigationURL(raw); err == nil {
|
||||||
|
t.Fatalf("validateNavigationURL(%q) returned nil error", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDoDebugRequest_Bad_RejectsOversizedBody verifies debug responses are bounded.
|
||||||
|
func TestDoDebugRequest_Bad_RejectsOversizedBody(t *testing.T) {
|
||||||
|
var payload strings.Builder
|
||||||
|
payload.Grow(maxDebugResponseBytes + 1)
|
||||||
|
payload.WriteString(strings.Repeat("a", maxDebugResponseBytes+1))
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = io.WriteString(w, payload.String())
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
debugURL, err := parseDebugURL(server.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseDebugURL returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := doDebugRequest(context.Background(), debugURL, "/json", ""); err == nil {
|
||||||
|
t.Fatal("doDebugRequest returned nil error for oversized body")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetString_Good verifies map string extraction.
|
||||||
|
func TestGetString_Good(t *testing.T) {
|
||||||
|
m := map[string]any{
|
||||||
|
"name": "test",
|
||||||
|
"count": 42,
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := getString(m, "name"); got != "test" {
|
||||||
|
t.Errorf("getString(m, 'name') = %q, want 'test'", got)
|
||||||
|
}
|
||||||
|
if got := getString(m, "count"); got != "" {
|
||||||
|
t.Errorf("getString(m, 'count') = %q, want empty (not a string)", got)
|
||||||
|
}
|
||||||
|
if got := getString(m, "missing"); got != "" {
|
||||||
|
t.Errorf("getString(m, 'missing') = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseElementContent_Good verifies inner content extraction from CDP output.
|
||||||
|
func TestParseElementContent_Good(t *testing.T) {
|
||||||
|
result := map[string]any{
|
||||||
|
"result": map[string]any{
|
||||||
|
"value": map[string]any{
|
||||||
|
"innerHTML": "<span>Hello</span>",
|
||||||
|
"innerText": "Hello",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
innerHTML, innerText := parseElementContent(result)
|
||||||
|
if innerHTML != "<span>Hello</span>" {
|
||||||
|
t.Fatalf("parseElementContent innerHTML = %q, want %q", innerHTML, "<span>Hello</span>")
|
||||||
|
}
|
||||||
|
if innerText != "Hello" {
|
||||||
|
t.Fatalf("parseElementContent innerText = %q, want %q", innerText, "Hello")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWaitAction_Good_ContextCancelled verifies WaitAction respects context cancellation.
|
||||||
|
func TestWaitAction_Good_ContextCancelled(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel() // Cancel immediately
|
||||||
|
|
||||||
|
action := WaitAction{Duration: 10 * time.Second}
|
||||||
|
err := action.Execute(ctx, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected context cancelled error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWaitAction_Good_ShortWait verifies WaitAction completes after duration.
|
||||||
|
func TestWaitAction_Good_ShortWait(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
action := WaitAction{Duration: 10 * time.Millisecond}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
err := action.Execute(ctx, nil)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if elapsed < 10*time.Millisecond {
|
||||||
|
t.Errorf("Expected at least 10ms elapsed, got %v", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAddConsoleMessage_Good verifies console message buffer management.
|
||||||
|
func TestAddConsoleMessage_Good(t *testing.T) {
|
||||||
|
wv := &Webview{
|
||||||
|
consoleLogs: make([]ConsoleMessage, 0, 10),
|
||||||
|
consoleLimit: 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add messages up to the limit
|
||||||
|
for i := range 6 {
|
||||||
|
wv.addConsoleMessage(ConsoleMessage{
|
||||||
|
Type: "log",
|
||||||
|
Text: time.Duration(i).String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buffer should have been trimmed
|
||||||
|
if len(wv.consoleLogs) > wv.consoleLimit {
|
||||||
|
t.Errorf("Expected at most %d messages, got %d", wv.consoleLimit, len(wv.consoleLogs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAddConsoleMessage_Good_ZeroLimitDropsMessages verifies zero retention disables storage.
|
||||||
|
func TestAddConsoleMessage_Good_ZeroLimitDropsMessages(t *testing.T) {
|
||||||
|
wv := &Webview{
|
||||||
|
consoleLogs: make([]ConsoleMessage, 0, 1),
|
||||||
|
consoleLimit: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
wv.addConsoleMessage(ConsoleMessage{Type: "log", Text: "ignored"})
|
||||||
|
|
||||||
|
if len(wv.consoleLogs) != 0 {
|
||||||
|
t.Fatalf("Expected zero retained messages, got %d", len(wv.consoleLogs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConsoleWatcherFilter_Good verifies console watcher filter matching.
|
||||||
|
func TestConsoleWatcherFilter_Good(t *testing.T) {
|
||||||
|
// Create a minimal ConsoleWatcher without a real Webview
|
||||||
|
cw := &ConsoleWatcher{
|
||||||
|
messages: make([]ConsoleMessage, 0),
|
||||||
|
filters: make([]ConsoleFilter, 0),
|
||||||
|
limit: 1000,
|
||||||
|
handlers: make([]consoleHandlerRegistration, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
// No filters — everything matches
|
||||||
|
msg := ConsoleMessage{Type: "error", Text: "test error"}
|
||||||
|
if !cw.matchesFilter(msg) {
|
||||||
|
t.Error("Expected message to match with no filters")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add type filter
|
||||||
|
cw.AddFilter(ConsoleFilter{Type: "error"})
|
||||||
|
if !cw.matchesFilter(msg) {
|
||||||
|
t.Error("Expected error message to match error filter")
|
||||||
|
}
|
||||||
|
|
||||||
|
logMsg := ConsoleMessage{Type: "log", Text: "test log"}
|
||||||
|
if cw.matchesFilter(logMsg) {
|
||||||
|
t.Error("Expected log message NOT to match error filter")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add pattern filter
|
||||||
|
cw.ClearFilters()
|
||||||
|
cw.AddFilter(ConsoleFilter{Pattern: "hello"})
|
||||||
|
helloMsg := ConsoleMessage{Type: "log", Text: "hello world"}
|
||||||
|
if !cw.matchesFilter(helloMsg) {
|
||||||
|
t.Error("Expected 'hello world' to match pattern 'hello'")
|
||||||
|
}
|
||||||
|
if cw.matchesFilter(msg) {
|
||||||
|
t.Error("Expected 'test error' NOT to match pattern 'hello'")
|
||||||
|
}
|
||||||
|
|
||||||
|
cw.ClearFilters()
|
||||||
|
cw.AddFilter(ConsoleFilter{Type: "warning"})
|
||||||
|
if !cw.matchesFilter(ConsoleMessage{Type: "warning", Text: "deprecated"}) {
|
||||||
|
t.Error("Expected warning message to match warning filter")
|
||||||
|
}
|
||||||
|
if !cw.matchesFilter(ConsoleMessage{Type: "warn", Text: "deprecated"}) {
|
||||||
|
t.Error("Expected warn message to match warning filter")
|
||||||
|
}
|
||||||
|
cw.ClearFilters()
|
||||||
|
cw.AddFilter(ConsoleFilter{Type: "warn"})
|
||||||
|
if !cw.matchesFilter(ConsoleMessage{Type: "warning", Text: "deprecated"}) {
|
||||||
|
t.Error("Expected warning message to match warn filter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConsoleWatcherCounts_Good verifies console watcher counting methods.
|
||||||
|
func TestConsoleWatcherCounts_Good(t *testing.T) {
|
||||||
|
cw := &ConsoleWatcher{
|
||||||
|
messages: []ConsoleMessage{
|
||||||
|
{Type: "log", Text: "info 1"},
|
||||||
|
{Type: "error", Text: "err 1"},
|
||||||
|
{Type: "log", Text: "info 2"},
|
||||||
|
{Type: "error", Text: "err 2"},
|
||||||
|
{Type: "warn", Text: "warn 1"},
|
||||||
|
},
|
||||||
|
filters: make([]ConsoleFilter, 0),
|
||||||
|
limit: 1000,
|
||||||
|
handlers: make([]consoleHandlerRegistration, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
if cw.Count() != 5 {
|
||||||
|
t.Errorf("Expected count 5, got %d", cw.Count())
|
||||||
|
}
|
||||||
|
if cw.ErrorCount() != 2 {
|
||||||
|
t.Errorf("Expected error count 2, got %d", cw.ErrorCount())
|
||||||
|
}
|
||||||
|
if !cw.HasErrors() {
|
||||||
|
t.Error("Expected HasErrors() to be true")
|
||||||
|
}
|
||||||
|
|
||||||
|
errors := cw.Errors()
|
||||||
|
if len(errors) != 2 {
|
||||||
|
t.Errorf("Expected 2 errors, got %d", len(errors))
|
||||||
|
}
|
||||||
|
|
||||||
|
warnings := cw.Warnings()
|
||||||
|
if len(warnings) != 1 {
|
||||||
|
t.Errorf("Expected 1 warning, got %d", len(warnings))
|
||||||
|
}
|
||||||
|
|
||||||
|
cw.Clear()
|
||||||
|
if cw.Count() != 0 {
|
||||||
|
t.Errorf("Expected count 0 after clear, got %d", cw.Count())
|
||||||
|
}
|
||||||
|
if cw.HasErrors() {
|
||||||
|
t.Error("Expected HasErrors() to be false after clear")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExceptionWatcher_Good verifies exception watcher basic operations.
|
||||||
|
func TestExceptionWatcher_Good(t *testing.T) {
|
||||||
|
ew := &ExceptionWatcher{
|
||||||
|
exceptions: make([]ExceptionInfo, 0),
|
||||||
|
handlers: make([]exceptionHandlerRegistration, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
if ew.HasExceptions() {
|
||||||
|
t.Error("Expected no exceptions initially")
|
||||||
|
}
|
||||||
|
if ew.Count() != 0 {
|
||||||
|
t.Errorf("Expected count 0, got %d", ew.Count())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate adding an exception
|
||||||
|
ew.exceptions = append(ew.exceptions, ExceptionInfo{
|
||||||
|
Text: "TypeError: undefined is not a function",
|
||||||
|
LineNumber: 10,
|
||||||
|
URL: "https://example.com/app.js",
|
||||||
|
})
|
||||||
|
|
||||||
|
if !ew.HasExceptions() {
|
||||||
|
t.Error("Expected HasExceptions() to be true")
|
||||||
|
}
|
||||||
|
if ew.Count() != 1 {
|
||||||
|
t.Errorf("Expected count 1, got %d", ew.Count())
|
||||||
|
}
|
||||||
|
|
||||||
|
exceptions := ew.Exceptions()
|
||||||
|
if len(exceptions) != 1 {
|
||||||
|
t.Errorf("Expected 1 exception, got %d", len(exceptions))
|
||||||
|
}
|
||||||
|
if exceptions[0].Text != "TypeError: undefined is not a function" {
|
||||||
|
t.Errorf("Unexpected exception text: %q", exceptions[0].Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
ew.Clear()
|
||||||
|
if ew.Count() != 0 {
|
||||||
|
t.Errorf("Expected count 0 after clear, got %d", ew.Count())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAngularRouterState_Good verifies AngularRouterState struct.
|
||||||
|
func TestAngularRouterState_Good(t *testing.T) {
|
||||||
|
state := AngularRouterState{
|
||||||
|
URL: "/dashboard",
|
||||||
|
Fragment: "section1",
|
||||||
|
Params: map[string]string{"id": "123"},
|
||||||
|
QueryParams: map[string]string{
|
||||||
|
"tab": "settings",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.URL != "/dashboard" {
|
||||||
|
t.Errorf("Expected URL '/dashboard', got %q", state.URL)
|
||||||
|
}
|
||||||
|
if state.Fragment != "section1" {
|
||||||
|
t.Errorf("Expected fragment 'section1', got %q", state.Fragment)
|
||||||
|
}
|
||||||
|
if state.Params["id"] != "123" {
|
||||||
|
t.Errorf("Expected param id '123', got %q", state.Params["id"])
|
||||||
|
}
|
||||||
|
if state.QueryParams["tab"] != "settings" {
|
||||||
|
t.Errorf("Expected query param tab 'settings', got %q", state.QueryParams["tab"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetInfo_Good verifies TargetInfo struct.
|
||||||
|
func TestTargetInfo_Good(t *testing.T) {
|
||||||
|
target := TargetInfo{
|
||||||
|
ID: "ABC123",
|
||||||
|
Type: "page",
|
||||||
|
Title: "Example",
|
||||||
|
URL: "https://example.com",
|
||||||
|
WebSocketDebuggerURL: "ws://localhost:9222/devtools/page/ABC123",
|
||||||
|
}
|
||||||
|
|
||||||
|
if target.ID != "ABC123" {
|
||||||
|
t.Errorf("Expected ID 'ABC123', got %q", target.ID)
|
||||||
|
}
|
||||||
|
if target.Type != "page" {
|
||||||
|
t.Errorf("Expected type 'page', got %q", target.Type)
|
||||||
|
}
|
||||||
|
if target.WebSocketDebuggerURL == "" {
|
||||||
|
t.Error("Expected WebSocketDebuggerURL to be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConsoleWatcherAddMessage_Good verifies message buffer limit enforcement.
|
||||||
|
func TestConsoleWatcherAddMessage_Good(t *testing.T) {
|
||||||
|
cw := &ConsoleWatcher{
|
||||||
|
messages: make([]ConsoleMessage, 0),
|
||||||
|
filters: make([]ConsoleFilter, 0),
|
||||||
|
limit: 5,
|
||||||
|
handlers: make([]consoleHandlerRegistration, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add messages past the limit
|
||||||
|
for i := range 7 {
|
||||||
|
cw.addMessage(ConsoleMessage{
|
||||||
|
Type: "log",
|
||||||
|
Text: time.Duration(i).String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cw.messages) > cw.limit {
|
||||||
|
t.Errorf("Expected at most %d messages, got %d", cw.limit, len(cw.messages))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConsoleWatcherHandler_Good verifies handlers are called for new messages.
|
||||||
|
func TestConsoleWatcherHandler_Good(t *testing.T) {
|
||||||
|
cw := &ConsoleWatcher{
|
||||||
|
messages: make([]ConsoleMessage, 0),
|
||||||
|
filters: make([]ConsoleFilter, 0),
|
||||||
|
limit: 1000,
|
||||||
|
handlers: make([]consoleHandlerRegistration, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
var received ConsoleMessage
|
||||||
|
cw.AddHandler(func(msg ConsoleMessage) {
|
||||||
|
received = msg
|
||||||
|
})
|
||||||
|
|
||||||
|
cw.addMessage(ConsoleMessage{Type: "error", Text: "handler test"})
|
||||||
|
|
||||||
|
if received.Text != "handler test" {
|
||||||
|
t.Errorf("Handler not called or wrong message: got %q", received.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConsoleWatcherFilteredMessages_Good verifies filtered message retrieval.
|
||||||
|
func TestConsoleWatcherFilteredMessages_Good(t *testing.T) {
|
||||||
|
cw := &ConsoleWatcher{
|
||||||
|
messages: []ConsoleMessage{
|
||||||
|
{Type: "log", Text: "info msg"},
|
||||||
|
{Type: "error", Text: "error msg"},
|
||||||
|
{Type: "log", Text: "another info"},
|
||||||
|
},
|
||||||
|
filters: []ConsoleFilter{{Type: "error"}},
|
||||||
|
limit: 1000,
|
||||||
|
handlers: make([]consoleHandlerRegistration, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := cw.FilteredMessages()
|
||||||
|
if len(filtered) != 1 {
|
||||||
|
t.Fatalf("Expected 1 filtered message, got %d", len(filtered))
|
||||||
|
}
|
||||||
|
if filtered[0].Type != "error" {
|
||||||
|
t.Errorf("Expected error type, got %q", filtered[0].Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConsoleWatcherFilteredMessages_Good_UsesAnyActiveFilter verifies filters compose as a union.
|
||||||
|
func TestConsoleWatcherFilteredMessages_Good_UsesAnyActiveFilter(t *testing.T) {
|
||||||
|
cw := &ConsoleWatcher{
|
||||||
|
messages: []ConsoleMessage{
|
||||||
|
{Type: "error", Text: "boom happened"},
|
||||||
|
{Type: "error", Text: "different message"},
|
||||||
|
{Type: "log", Text: "boom happened"},
|
||||||
|
},
|
||||||
|
filters: []ConsoleFilter{
|
||||||
|
{Type: "error"},
|
||||||
|
{Pattern: "boom"},
|
||||||
|
},
|
||||||
|
limit: 1000,
|
||||||
|
handlers: make([]consoleHandlerRegistration, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := cw.FilteredMessages()
|
||||||
|
if len(filtered) != 3 {
|
||||||
|
t.Fatalf("Expected 3 filtered messages, got %d", len(filtered))
|
||||||
|
}
|
||||||
|
if filtered[0].Text != "boom happened" {
|
||||||
|
t.Fatalf("Expected the first matching message, got %q", filtered[0].Text)
|
||||||
|
}
|
||||||
|
if filtered[1].Text != "different message" {
|
||||||
|
t.Fatalf("Expected the second stored message to remain visible, got %q", filtered[1].Text)
|
||||||
|
}
|
||||||
|
if filtered[2].Text != "boom happened" {
|
||||||
|
t.Fatalf("Expected the log message matching the pattern filter, got %q", filtered[2].Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConsoleWatcherSetLimit_Good_AppliesToFutureWrites verifies shrinking the limit trims buffered messages on the next append.
|
||||||
|
func TestConsoleWatcherSetLimit_Good_AppliesToFutureWrites(t *testing.T) {
|
||||||
|
cw := &ConsoleWatcher{
|
||||||
|
messages: []ConsoleMessage{
|
||||||
|
{Type: "log", Text: "first"},
|
||||||
|
{Type: "log", Text: "second"},
|
||||||
|
{Type: "log", Text: "third"},
|
||||||
|
},
|
||||||
|
limit: 1000,
|
||||||
|
handlers: make([]consoleHandlerRegistration, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
cw.SetLimit(2)
|
||||||
|
|
||||||
|
if cw.Count() != 3 {
|
||||||
|
t.Fatalf("Expected 3 messages to remain until the next append, got %d", cw.Count())
|
||||||
|
}
|
||||||
|
|
||||||
|
cw.addMessage(ConsoleMessage{Type: "log", Text: "fourth"})
|
||||||
|
|
||||||
|
if cw.Count() != 2 {
|
||||||
|
t.Fatalf("Expected 2 messages after the next append, got %d", cw.Count())
|
||||||
|
}
|
||||||
|
if messages := cw.Messages(); messages[0].Text != "third" || messages[1].Text != "fourth" {
|
||||||
|
t.Fatalf("Unexpected retained messages after trimming: %#v", messages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExceptionInfo_Good verifies ExceptionInfo struct.
|
||||||
|
func TestExceptionInfo_Good(t *testing.T) {
|
||||||
|
info := ExceptionInfo{
|
||||||
|
Text: "ReferenceError: foo is not defined",
|
||||||
|
LineNumber: 42,
|
||||||
|
ColumnNumber: 10,
|
||||||
|
URL: "https://example.com/app.js",
|
||||||
|
StackTrace: " at bar (app.js:42:10)\n",
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Text != "ReferenceError: foo is not defined" {
|
||||||
|
t.Errorf("Unexpected text: %q", info.Text)
|
||||||
|
}
|
||||||
|
if info.LineNumber != 42 {
|
||||||
|
t.Errorf("Expected line 42, got %d", info.LineNumber)
|
||||||
|
}
|
||||||
|
if info.StackTrace == "" {
|
||||||
|
t.Error("Expected stack trace to be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue