diff --git a/angular.go b/angular.go index ec9ce26..1d8c45a 100644 --- a/angular.go +++ b/angular.go @@ -286,27 +286,12 @@ func (ah *AngularHelper) GetRouterState() (*AngularRouterState, error) { URL: getString(resultMap, "url"), } - if fragment, ok := resultMap["fragment"].(string); ok { - state.Fragment = fragment + if fragment, ok := resultMap["fragment"]; ok && fragment != nil { + state.Fragment = core.Sprint(fragment) } - if params, ok := resultMap["params"].(map[string]any); ok { - state.Params = make(map[string]string) - for k, v := range params { - if s, ok := v.(string); ok { - state.Params[k] = s - } - } - } - - if queryParams, ok := resultMap["queryParams"].(map[string]any); ok { - state.QueryParams = make(map[string]string) - for k, v := range queryParams { - if s, ok := v.(string); ok { - state.QueryParams[k] = s - } - } - } + state.Params = stringifyMap(resultMap["params"]) + state.QueryParams = stringifyMap(resultMap["queryParams"]) return state, nil } @@ -613,6 +598,25 @@ func getString(m map[string]any, key string) string { return "" } +func stringifyMap(value any) map[string]string { + switch typed := value.(type) { + case map[string]any: + result := make(map[string]string, len(typed)) + for key, item := range typed { + result[key] = core.Sprint(item) + } + return result + case map[string]string: + result := make(map[string]string, len(typed)) + for key, item := range typed { + result[key] = item + } + return result + default: + return nil + } +} + func formatJSValue(v any) string { r := core.JSONMarshal(v) if r.OK { diff --git a/audit_issue2_test.go b/audit_issue2_test.go index 8d016a8..521ef6e 100644 --- a/audit_issue2_test.go +++ b/audit_issue2_test.go @@ -671,3 +671,167 @@ func TestExceptionWatcherWaitForException_Good_PreservesExistingHandlers(t *test t.Fatalf("unexpected handler count after waiter removal: %d", len(ew.handlers)) } } + +func TestWebviewGoBack_Good_UsesNavigationHistoryAndWaitsForLoad(t *testing.T) { + server := newFakeCDPServer(t) + target := server.primaryTarget() + + var methods []string + target.onMessage = func(target *fakeCDPTarget, msg cdpMessage) { + methods = append(methods, msg.Method) + + switch msg.Method { + case "Page.getNavigationHistory": + target.reply(msg.ID, map[string]any{ + "currentIndex": float64(1), + "entries": []map[string]any{ + {"id": float64(101), "url": "https://example.com/one"}, + {"id": float64(202), "url": "https://example.com/two"}, + }, + }) + case "Page.navigateToHistoryEntry": + if got, ok := msg.Params["entryId"].(float64); !ok || got != 101 { + t.Fatalf("navigateToHistoryEntry entryId = %v, want 101", msg.Params["entryId"]) + } + target.reply(msg.ID, map[string]any{}) + case "Runtime.evaluate": + target.replyValue(msg.ID, "complete") + default: + t.Fatalf("unexpected method %q", msg.Method) + } + } + + client, err := NewCDPClient(server.DebugURL()) + if err != nil { + t.Fatalf("NewCDPClient returned error: %v", err) + } + defer func() { _ = client.Close() }() + + wv := &Webview{ + client: client, + ctx: context.Background(), + timeout: time.Second, + } + + if err := wv.GoBack(); err != nil { + t.Fatalf("GoBack returned error: %v", err) + } + + if len(methods) != 3 { + t.Fatalf("expected 3 CDP calls, got %d (%v)", len(methods), methods) + } + if methods[0] != "Page.getNavigationHistory" || methods[1] != "Page.navigateToHistoryEntry" || methods[2] != "Runtime.evaluate" { + t.Fatalf("unexpected call sequence: %v", methods) + } +} + +func TestWebviewGoForward_Bad_NoHistoryEntry(t *testing.T) { + server := newFakeCDPServer(t) + target := server.primaryTarget() + target.onMessage = func(target *fakeCDPTarget, msg cdpMessage) { + if msg.Method != "Page.getNavigationHistory" { + t.Fatalf("unexpected method %q", msg.Method) + } + target.reply(msg.ID, map[string]any{ + "currentIndex": float64(0), + "entries": []map[string]any{ + {"id": float64(101), "url": "https://example.com/one"}, + }, + }) + } + + client, err := NewCDPClient(server.DebugURL()) + if err != nil { + t.Fatalf("NewCDPClient returned error: %v", err) + } + defer func() { _ = client.Close() }() + + wv := &Webview{ + client: client, + ctx: context.Background(), + timeout: time.Second, + } + + if err := wv.GoForward(); err == nil { + t.Fatal("GoForward succeeded without a forward history entry") + } +} + +func TestWebviewEvaluate_Bad_UsesExceptionText(t *testing.T) { + server := newFakeCDPServer(t) + target := server.primaryTarget() + target.onMessage = func(target *fakeCDPTarget, msg cdpMessage) { + if msg.Method != "Runtime.evaluate" { + t.Fatalf("unexpected method %q", msg.Method) + } + target.writeJSON(cdpResponse{ + ID: msg.ID, + Result: map[string]any{ + "exceptionDetails": map[string]any{ + "text": "ReferenceError: missingValue is not defined", + }, + }, + }) + } + + client, err := NewCDPClient(server.DebugURL()) + if err != nil { + t.Fatalf("NewCDPClient returned error: %v", err) + } + defer func() { _ = client.Close() }() + + wv := &Webview{ + client: client, + ctx: context.Background(), + timeout: time.Second, + } + + if _, err := wv.Evaluate("missingValue"); err == nil || !core.Contains(err.Error(), "ReferenceError: missingValue is not defined") { + t.Fatalf("Evaluate error = %v, want exception text", err) + } +} + +func TestAngularHelperGetRouterState_Good_StringifiesParams(t *testing.T) { + server := newFakeCDPServer(t) + target := server.primaryTarget() + target.onMessage = func(target *fakeCDPTarget, msg cdpMessage) { + if msg.Method != "Runtime.evaluate" { + t.Fatalf("unexpected method %q", msg.Method) + } + target.replyValue(msg.ID, map[string]any{ + "url": "/items/123", + "fragment": "details", + "params": map[string]any{ + "id": float64(123), + "active": true, + }, + "queryParams": map[string]any{ + "page": float64(2), + }, + }) + } + + client, err := NewCDPClient(server.DebugURL()) + if err != nil { + t.Fatalf("NewCDPClient returned error: %v", err) + } + defer func() { _ = client.Close() }() + + wv := &Webview{ + client: client, + ctx: context.Background(), + timeout: time.Second, + } + ah := NewAngularHelper(wv) + + state, err := ah.GetRouterState() + if err != nil { + t.Fatalf("GetRouterState returned error: %v", err) + } + if state.Params["id"] != "123" || state.Params["active"] != "true" { + t.Fatalf("unexpected params: %#v", state.Params) + } + if state.QueryParams["page"] != "2" { + t.Fatalf("unexpected query params: %#v", state.QueryParams) + } +} diff --git a/console.go b/console.go index fc1c2e6..8a65bee 100644 --- a/console.go +++ b/console.go @@ -11,6 +11,7 @@ import ( "time" core "dappco.re/go/core" + coreerr "dappco.re/go/core/log" ) // ConsoleWatcher provides advanced console message watching capabilities. @@ -143,6 +144,37 @@ func consoleMessageTimestamp(params map[string]any) time.Time { return time.Unix(seconds, nanoseconds).UTC() } +func trimConsoleMessages(messages []ConsoleMessage, limit int) []ConsoleMessage { + if limit < 0 { + limit = 0 + } + + if overflow := len(messages) - limit; overflow > 0 { + copy(messages, messages[overflow:]) + messages = messages[:len(messages)-overflow] + } + + return messages +} + +func runtimeExceptionText(exceptionDetails map[string]any) string { + if exception, ok := exceptionDetails["exception"].(map[string]any); ok { + if description, ok := exception["description"].(string); ok && description != "" { + return description + } + } + + if text, ok := exceptionDetails["text"].(string); ok && text != "" { + return text + } + + return "JavaScript error" +} + +func runtimeExceptionError(scope string, exceptionDetails map[string]any) error { + return coreerr.E(scope, runtimeExceptionText(exceptionDetails), nil) +} + // AddFilter adds a filter to the watcher. func (cw *ConsoleWatcher) AddFilter(filter ConsoleFilter) { cw.mu.Lock() @@ -189,7 +221,11 @@ func (cw *ConsoleWatcher) removeHandler(id int64) { func (cw *ConsoleWatcher) SetLimit(limit int) { cw.mu.Lock() defer cw.mu.Unlock() + if limit < 0 { + limit = 0 + } cw.limit = limit + cw.messages = trimConsoleMessages(cw.messages, cw.limit) } // Messages returns all captured messages. @@ -326,7 +362,7 @@ func (cw *ConsoleWatcher) HasErrors() bool { defer cw.mu.RUnlock() for _, msg := range cw.messages { - if msg.Type == "error" { + if normalizeConsoleType(msg.Type) == "error" { return true } } @@ -347,7 +383,7 @@ func (cw *ConsoleWatcher) ErrorCount() int { count := 0 for _, msg := range cw.messages { - if msg.Type == "error" { + if normalizeConsoleType(msg.Type) == "error" { count++ } } @@ -392,12 +428,8 @@ func (cw *ConsoleWatcher) handleConsoleEvent(params map[string]any) { func (cw *ConsoleWatcher) addMessage(msg ConsoleMessage) { cw.mu.Lock() - // Enforce limit - if len(cw.messages) >= cw.limit { - drop := min(100, len(cw.messages)) - cw.messages = cw.messages[drop:] - } cw.messages = append(cw.messages, msg) + cw.messages = trimConsoleMessages(cw.messages, cw.limit) // Copy handlers to call outside lock handlers := slices.Clone(cw.handlers) @@ -415,11 +447,11 @@ func (cw *ConsoleWatcher) matchesFilter(msg ConsoleMessage) bool { return true } for _, filter := range cw.filters { - if cw.matchesSingleFilter(msg, filter) { - return true + if !cw.matchesSingleFilter(msg, filter) { + return false } } - return false + return true } // matchesSingleFilter checks if a message matches a specific filter. @@ -619,11 +651,7 @@ func (ew *ExceptionWatcher) handleException(params map[string]any) { } // Try to get exception value description - if exc, ok := exceptionDetails["exception"].(map[string]any); ok { - if desc, ok := exc["description"].(string); ok && desc != "" { - text = desc - } - } + text = runtimeExceptionText(exceptionDetails) info := ExceptionInfo{ Text: text, diff --git a/webview.go b/webview.go index cc06cf9..64270e8 100644 --- a/webview.go +++ b/webview.go @@ -101,6 +101,9 @@ func WithTimeout(d time.Duration) Option { // Default is 1000. func WithConsoleLimit(limit int) Option { return func(wv *Webview) error { + if limit < 0 { + limit = 0 + } wv.consoleLimit = limit return nil } @@ -400,32 +403,56 @@ func (wv *Webview) Reload() error { // GoBack navigates back in history. func (wv *Webview) GoBack() error { - ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout) - defer cancel() - - _, err := wv.client.Call(ctx, "Page.goBackOrForward", map[string]any{ - "delta": -1, - }) - if err != nil { - return coreerr.E("Webview.GoBack", "failed to go back", err) - } - - return err + return wv.navigateHistory(-1, "Webview.GoBack") } // GoForward navigates forward in history. func (wv *Webview) GoForward() error { + return wv.navigateHistory(1, "Webview.GoForward") +} + +func (wv *Webview) navigateHistory(delta int, scope string) error { ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout) defer cancel() - _, err := wv.client.Call(ctx, "Page.goBackOrForward", map[string]any{ - "delta": 1, - }) + result, err := wv.client.Call(ctx, "Page.getNavigationHistory", nil) if err != nil { - return coreerr.E("Webview.GoForward", "failed to go forward", err) + return coreerr.E(scope, "failed to get navigation history", err) } - return err + currentIndex, ok := result["currentIndex"].(float64) + if !ok { + return coreerr.E(scope, "invalid navigation history index", nil) + } + + entries, ok := result["entries"].([]any) + if !ok { + return coreerr.E(scope, "invalid navigation history entries", nil) + } + + targetIndex := int(currentIndex) + delta + if targetIndex < 0 || targetIndex >= len(entries) { + return coreerr.E(scope, "no history entry available", nil) + } + + entry, ok := entries[targetIndex].(map[string]any) + if !ok { + return coreerr.E(scope, "invalid navigation history entry", nil) + } + + entryID, ok := entry["id"].(float64) + if !ok { + return coreerr.E(scope, "invalid navigation history entry ID", nil) + } + + _, err = wv.client.Call(ctx, "Page.navigateToHistoryEntry", map[string]any{ + "entryId": int(entryID), + }) + if err != nil { + return coreerr.E(scope, "failed to navigate to history entry", err) + } + + return wv.waitForLoad(ctx) } // addConsoleMessage adds a console message to the log. @@ -433,12 +460,8 @@ func (wv *Webview) addConsoleMessage(msg ConsoleMessage) { wv.mu.Lock() defer wv.mu.Unlock() - if len(wv.consoleLogs) >= wv.consoleLimit { - // Remove oldest messages - drop := min(100, len(wv.consoleLogs)) - wv.consoleLogs = wv.consoleLogs[drop:] - } wv.consoleLogs = append(wv.consoleLogs, msg) + wv.consoleLogs = trimConsoleMessages(wv.consoleLogs, wv.consoleLimit) } // enableConsole enables console message capture. @@ -563,12 +586,7 @@ func (wv *Webview) evaluate(ctx context.Context, script string) (any, error) { // Check for exception if exceptionDetails, ok := result["exceptionDetails"].(map[string]any); ok { - if exception, ok := exceptionDetails["exception"].(map[string]any); ok { - if description, ok := exception["description"].(string); ok { - return nil, coreerr.E("Webview.evaluate", description, nil) - } - } - return nil, coreerr.E("Webview.evaluate", "JavaScript error", nil) + return nil, runtimeExceptionError("Webview.evaluate", exceptionDetails) } // Extract result value diff --git a/webview_test.go b/webview_test.go index 349373c..2c40948 100644 --- a/webview_test.go +++ b/webview_test.go @@ -528,6 +528,20 @@ func TestAddConsoleMessage_Good(t *testing.T) { } } +// TestAddConsoleMessage_Good_ZeroLimitDropsMessages verifies zero retention disables storage. +func TestAddConsoleMessage_Good_ZeroLimitDropsMessages(t *testing.T) { + wv := &Webview{ + consoleLogs: make([]ConsoleMessage, 0, 1), + consoleLimit: 0, + } + + wv.addConsoleMessage(ConsoleMessage{Type: "log", Text: "ignored"}) + + if len(wv.consoleLogs) != 0 { + t.Fatalf("Expected zero retained messages, got %d", len(wv.consoleLogs)) + } +} + // TestConsoleWatcherFilter_Good verifies console watcher filter matching. func TestConsoleWatcherFilter_Good(t *testing.T) { // Create a minimal ConsoleWatcher without a real Webview @@ -764,6 +778,53 @@ func TestConsoleWatcherFilteredMessages_Good(t *testing.T) { } } +// TestConsoleWatcherFilteredMessages_Good_RequiresAllActiveFilters verifies filters compose as an intersection. +func TestConsoleWatcherFilteredMessages_Good_RequiresAllActiveFilters(t *testing.T) { + cw := &ConsoleWatcher{ + messages: []ConsoleMessage{ + {Type: "error", Text: "boom happened"}, + {Type: "error", Text: "different message"}, + {Type: "log", Text: "boom happened"}, + }, + filters: []ConsoleFilter{ + {Type: "error"}, + {Pattern: "boom"}, + }, + limit: 1000, + handlers: make([]consoleHandlerRegistration, 0), + } + + filtered := cw.FilteredMessages() + if len(filtered) != 1 { + t.Fatalf("Expected 1 filtered message, got %d", len(filtered)) + } + if filtered[0].Text != "boom happened" { + t.Fatalf("Expected the intersection match, got %q", filtered[0].Text) + } +} + +// TestConsoleWatcherSetLimit_Good_TrimsExistingMessages verifies shrinking the limit trims buffered messages immediately. +func TestConsoleWatcherSetLimit_Good_TrimsExistingMessages(t *testing.T) { + cw := &ConsoleWatcher{ + messages: []ConsoleMessage{ + {Type: "log", Text: "first"}, + {Type: "log", Text: "second"}, + {Type: "log", Text: "third"}, + }, + limit: 1000, + handlers: make([]consoleHandlerRegistration, 0), + } + + cw.SetLimit(2) + + if cw.Count() != 2 { + t.Fatalf("Expected 2 messages after trimming, got %d", cw.Count()) + } + if messages := cw.Messages(); messages[0].Text != "second" || messages[1].Text != "third" { + t.Fatalf("Unexpected retained messages: %#v", messages) + } +} + // TestExceptionInfo_Good verifies ExceptionInfo struct. func TestExceptionInfo_Good(t *testing.T) { info := ExceptionInfo{