diff --git a/actions_test.go b/actions_test.go
new file mode 100644
index 0000000..2873def
--- /dev/null
+++ b/actions_test.go
@@ -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": "ok", "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)
+}
diff --git a/angular_test.go b/angular_test.go
new file mode 100644
index 0000000..8a8bc3f
--- /dev/null
+++ b/angular_test.go
@@ -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)
+ }
+ }
+ })
+ }
+}
diff --git a/cdp_test.go b/cdp_test.go
new file mode 100644
index 0000000..40d5b41
--- /dev/null
+++ b/cdp_test.go
@@ -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
+}
diff --git a/console_test.go b/console_test.go
new file mode 100644
index 0000000..3746d20
--- /dev/null
+++ b/console_test.go
@@ -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)
+ }
+ }
+}
diff --git a/webview_methods_test.go b/webview_methods_test.go
new file mode 100644
index 0000000..d5b348a
--- /dev/null
+++ b/webview_methods_test.go
@@ -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": "hello", "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, "")
+ 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 != "" {
+ 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)
+ }
+}