From f38ceb3bd631106367aacf2b90d7a66d4630883d Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 16 Apr 2026 00:15:00 +0100 Subject: [PATCH] Add missing webview tests --- actions_test.go | 426 ++++++++++++++++++++++++++++++++++++++++ angular_test.go | 318 ++++++++++++++++++++++++++++++ cdp_test.go | 404 +++++++++++++++++++++++++++++++++++++ console_test.go | 301 ++++++++++++++++++++++++++++ webview_methods_test.go | 404 +++++++++++++++++++++++++++++++++++++ 5 files changed, 1853 insertions(+) create mode 100644 actions_test.go create mode 100644 angular_test.go create mode 100644 cdp_test.go create mode 100644 console_test.go create mode 100644 webview_methods_test.go 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) + } +}