Add missing webview tests
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run

This commit is contained in:
Snider 2026-04-16 00:15:00 +01:00
parent 40d2e584aa
commit f38ceb3bd6
5 changed files with 1853 additions and 0 deletions

426
actions_test.go Normal file
View file

@ -0,0 +1,426 @@
// SPDX-License-Identifier: EUPL-1.2
package webview
import (
"context"
"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_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_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 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)
}

318
angular_test.go Normal file
View file

@ -0,0 +1,318 @@
// 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)
}
}
})
}
}

404
cdp_test.go Normal file
View file

@ -0,0 +1,404 @@
// 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_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_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
}

301
console_test.go Normal file
View file

@ -0,0 +1,301 @@
// 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_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_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_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())
}
}
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_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)
}
}
}

404
webview_methods_test.go Normal file
View file

@ -0,0 +1,404 @@
// 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_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)
}
}