feat(webview): CDP history nav + deterministic console limits + exception text
- 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:
parent
7a4450cf9f
commit
3002b4801d
5 changed files with 336 additions and 61 deletions
42
angular.go
42
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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
58
console.go
58
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,
|
||||
|
|
|
|||
72
webview.go
72
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
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue