feat(webview): CDP history nav + deterministic console limits + exception text
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run

- webview.go: back/forward navigation uses Page.getNavigationHistory +
  navigateToHistoryEntry (bounds-checked, load-wait) — replaces deprecated
  Page.goBackOrForward
- console.go + webview.go: console limits deterministic, clamp negative limits
  to 0, zero-retention supported, error counting normalised, multiple
  ConsoleWatcher filters compose as intersection
- webview.go + console.go + angular.go: JS exception text propagation, Angular
  router/query param map[string]string per RFC
- audit_issue2_test.go + webview_test.go: history nav, exception text, filter
  composition, retention trimming coverage

Verified: GOWORK=off go test ./... passes

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-04-14 18:35:10 +01:00
parent 7a4450cf9f
commit 3002b4801d
5 changed files with 336 additions and 61 deletions

View file

@ -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 {

View file

@ -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)
}
}

View file

@ -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,

View file

@ -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

View file

@ -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{