From 8a59058c342f15d133e95f118f4522176aa20414 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Wed, 1 Apr 2026 13:12:59 +0000 Subject: [PATCH 01/13] feat: add cdpmonitor stub with start/stop lifecycle From e94fd9fc136563b9221f2debce8bd49fcf54ae02 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Wed, 1 Apr 2026 13:25:19 +0000 Subject: [PATCH 02/13] feat: add CDP protocol message types and internal state structs --- server/lib/cdpmonitor/types.go | 113 +++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 server/lib/cdpmonitor/types.go diff --git a/server/lib/cdpmonitor/types.go b/server/lib/cdpmonitor/types.go new file mode 100644 index 00000000..f53e733b --- /dev/null +++ b/server/lib/cdpmonitor/types.go @@ -0,0 +1,113 @@ +package cdpmonitor + +import ( + "encoding/json" + "fmt" +) + +// targetInfo holds metadata about an attached CDP target/session. +type targetInfo struct { + targetID string + url string + targetType string +} + +// cdpError is the JSON-RPC error object returned by Chrome. +type cdpError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (e *cdpError) Error() string { + return fmt.Sprintf("CDP error %d: %s", e.Code, e.Message) +} + +// cdpMessage is the JSON-RPC message envelope used by Chrome's DevTools Protocol. +type cdpMessage struct { + ID int64 `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Params json.RawMessage `json:"params,omitempty"` + SessionID string `json:"sessionId,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *cdpError `json:"error,omitempty"` +} + +// networkReqState holds request + response metadata until loadingFinished. +type networkReqState struct { + method string + url string + headers json.RawMessage + postData string + resourceType string + initiator json.RawMessage + status int + statusText string + resHeaders json.RawMessage + mimeType string +} + +// cdpConsoleArg is a single Runtime.consoleAPICalled argument. +type cdpConsoleArg struct { + Type string `json:"type"` + Value string `json:"value"` +} + +// cdpConsoleParams is the shape of Runtime.consoleAPICalled params. +type cdpConsoleParams struct { + Type string `json:"type"` + Args []cdpConsoleArg `json:"args"` + StackTrace json.RawMessage `json:"stackTrace"` +} + +// cdpExceptionDetails is the shape of Runtime.exceptionThrown params. +type cdpExceptionDetails struct { + ExceptionDetails struct { + Text string `json:"text"` + LineNumber int `json:"lineNumber"` + ColumnNumber int `json:"columnNumber"` + URL string `json:"url"` + StackTrace json.RawMessage `json:"stackTrace"` + } `json:"exceptionDetails"` +} + +// cdpTargetInfo is the shared TargetInfo shape used by Target events. +type cdpTargetInfo struct { + TargetID string `json:"targetId"` + Type string `json:"type"` + URL string `json:"url"` +} + +// cdpNetworkRequestParams is the shape of Network.requestWillBeSent params. +type cdpNetworkRequestParams struct { + RequestID string `json:"requestId"` + ResourceType string `json:"resourceType"` + Request struct { + Method string `json:"method"` + URL string `json:"url"` + Headers json.RawMessage `json:"headers"` + PostData string `json:"postData"` + } `json:"request"` + Initiator json.RawMessage `json:"initiator"` +} + +// cdpResponseReceivedParams is the shape of Network.responseReceived params. +type cdpResponseReceivedParams struct { + RequestID string `json:"requestId"` + Response struct { + Status int `json:"status"` + StatusText string `json:"statusText"` + Headers json.RawMessage `json:"headers"` + MimeType string `json:"mimeType"` + } `json:"response"` +} + +// cdpAttachedToTargetParams is the shape of Target.attachedToTarget params. +type cdpAttachedToTargetParams struct { + SessionID string `json:"sessionId"` + TargetInfo cdpTargetInfo `json:"targetInfo"` +} + +// cdpTargetCreatedParams is the shape of Target.targetCreated params. +type cdpTargetCreatedParams struct { + TargetInfo cdpTargetInfo `json:"targetInfo"` +} From 96dede633cd5d917b148be09bf6b036989e2adff Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Wed, 1 Apr 2026 13:25:29 +0000 Subject: [PATCH 03/13] feat: implement CDP monitor with websocket capture, event handlers, and reconnect --- server/lib/cdpmonitor/computed.go | 180 ++++++++++++++ server/lib/cdpmonitor/domains.go | 87 +++++++ server/lib/cdpmonitor/handlers.go | 362 ++++++++++++++++++++++++++++ server/lib/cdpmonitor/monitor.go | 307 ++++++++++++++++++++++- server/lib/cdpmonitor/screenshot.go | 87 +++++++ 5 files changed, 1017 insertions(+), 6 deletions(-) create mode 100644 server/lib/cdpmonitor/computed.go create mode 100644 server/lib/cdpmonitor/domains.go create mode 100644 server/lib/cdpmonitor/handlers.go create mode 100644 server/lib/cdpmonitor/screenshot.go diff --git a/server/lib/cdpmonitor/computed.go b/server/lib/cdpmonitor/computed.go new file mode 100644 index 00000000..c753730f --- /dev/null +++ b/server/lib/cdpmonitor/computed.go @@ -0,0 +1,180 @@ +package cdpmonitor + +import ( + "encoding/json" + "sync" + "time" + + "github.com/onkernel/kernel-images/server/lib/events" +) +// computedState holds the mutable state for all computed meta-events. +type computedState struct { + mu sync.Mutex + publish PublishFunc + + // network_idle: 500 ms debounce after all pending requests finish. + netPending int + netTimer *time.Timer + netFired bool + + // layout_settled: 1s after page_load with no intervening layout shifts. + layoutTimer *time.Timer + layoutFired bool + pageLoadSeen bool + + // navigation_settled: fires once dom_content_loaded, network_idle, and + // layout_settled have all fired after the same Page.frameNavigated. + navDOMLoaded bool + navNetIdle bool + navLayoutSettled bool + navFired bool +} + +// newComputedState creates a fresh computedState backed by the given publish func. +func newComputedState(publish PublishFunc) *computedState { + return &computedState{publish: publish} +} + +func stopTimer(t *time.Timer) { + if t == nil { + return + } + if !t.Stop() { + select { + case <-t.C: + default: + } + } +} + +// resetOnNavigation resets all state machines. Called on Page.frameNavigated +func (s *computedState) resetOnNavigation() { + s.mu.Lock() + defer s.mu.Unlock() + + stopTimer(s.netTimer) + s.netTimer = nil + s.netPending = 0 + s.netFired = false + + stopTimer(s.layoutTimer) + s.layoutTimer = nil + s.layoutFired = false + s.pageLoadSeen = false + + s.navDOMLoaded = false + s.navNetIdle = false + s.navLayoutSettled = false + s.navFired = false +} + +func (s *computedState) onRequest() { + s.mu.Lock() + defer s.mu.Unlock() + s.netPending++ + // A new request invalidates any pending network_idle timer + stopTimer(s.netTimer) + s.netTimer = nil +} + +// onLoadingFinished is called on Network.loadingFinished or Network.loadingFailed. +func (s *computedState) onLoadingFinished() { + s.mu.Lock() + defer s.mu.Unlock() + + s.netPending-- + if s.netPending < 0 { + s.netPending = 0 + } + if s.netPending > 0 || s.netFired { + return + } + // All requests done and not yet fired — start 500 ms debounce timer. + stopTimer(s.netTimer) + s.netTimer = time.AfterFunc(500*time.Millisecond, func() { + s.mu.Lock() + defer s.mu.Unlock() + if s.netFired || s.netPending > 0 { + return + } + s.netFired = true + s.navNetIdle = true + s.publish(events.Event{ + Ts: time.Now().UnixMilli(), + Type: "network_idle", + Category: events.CategoryNetwork, + Source: events.Source{Kind: events.KindCDP}, + DetailLevel: events.DetailStandard, + Data: json.RawMessage(`{}`), + }) + s.checkNavigationSettled() + }) +} + +// onPageLoad is called on Page.loadEventFired. +func (s *computedState) onPageLoad() { + s.mu.Lock() + defer s.mu.Unlock() + s.pageLoadSeen = true + if s.layoutFired { + return + } + // Start the 1 s layout_settled timer. + stopTimer(s.layoutTimer) + s.layoutTimer = time.AfterFunc(1*time.Second, s.emitLayoutSettled) +} + +// onLayoutShift is called when a layout_shift sentinel arrives from injected JS. +func (s *computedState) onLayoutShift() { + s.mu.Lock() + defer s.mu.Unlock() + if s.layoutFired || !s.pageLoadSeen { + return + } + // Reset the timer to 1 s from now. + stopTimer(s.layoutTimer) + s.layoutTimer = time.AfterFunc(1*time.Second, s.emitLayoutSettled) +} + +// emitLayoutSettled is called from the layout timer's AfterFunc goroutine +func (s *computedState) emitLayoutSettled() { + s.mu.Lock() + defer s.mu.Unlock() + if s.layoutFired || !s.pageLoadSeen { + return + } + s.layoutFired = true + s.navLayoutSettled = true + s.publish(events.Event{ + Ts: time.Now().UnixMilli(), + Type: "layout_settled", + Category: events.CategoryPage, + Source: events.Source{Kind: events.KindCDP}, + DetailLevel: events.DetailStandard, + Data: json.RawMessage(`{}`), + }) + s.checkNavigationSettled() +} + +// onDOMContentLoaded is called on Page.domContentEventFired. +func (s *computedState) onDOMContentLoaded() { + s.mu.Lock() + defer s.mu.Unlock() + s.navDOMLoaded = true + s.checkNavigationSettled() +} + +// checkNavigationSettled emits navigation_settled if all three flags are set +func (s *computedState) checkNavigationSettled() { + if s.navDOMLoaded && s.navNetIdle && s.navLayoutSettled && !s.navFired { + s.navFired = true + s.publish(events.Event{ + Ts: time.Now().UnixMilli(), + Type: "navigation_settled", + Category: events.CategoryPage, + Source: events.Source{Kind: events.KindCDP}, + DetailLevel: events.DetailStandard, + Data: json.RawMessage(`{}`), + }) + } +} diff --git a/server/lib/cdpmonitor/domains.go b/server/lib/cdpmonitor/domains.go new file mode 100644 index 00000000..f32932c6 --- /dev/null +++ b/server/lib/cdpmonitor/domains.go @@ -0,0 +1,87 @@ +package cdpmonitor + +import "context" + +// bindingName is the JS function exposed via Runtime.addBinding. +// Page JS calls this to fire Runtime.bindingCalled CDP events. +const bindingName = "__kernelEvent" + +// enableDomains enables CDP domains, registers the event binding, and starts +// layout-shift observation. Failures are non-fatal. +func (m *Monitor) enableDomains(ctx context.Context, sessionID string) { + for _, method := range []string{ + "Runtime.enable", + "Network.enable", + "Page.enable", + "DOM.enable", + } { + _, _ = m.send(ctx, method, nil, sessionID) + } + + _, _ = m.send(ctx, "Runtime.addBinding", map[string]any{ + "name": bindingName, + }, sessionID) + + _, _ = m.send(ctx, "PerformanceTimeline.enable", map[string]any{ + "eventTypes": []string{"layout-shift"}, + }, sessionID) +} + +// injectedJS tracks clicks, keys, and scrolls via the __kernelEvent binding. +// Layout shifts are handled natively by PerformanceTimeline.enable. +const injectedJS = `(function() { + var send = window.__kernelEvent; + if (!send) return; + + function sel(el) { + return el.id ? '#' + el.id : (el.className ? '.' + String(el.className).split(' ')[0] : ''); + } + + document.addEventListener('click', function(e) { + var t = e.target || {}; + send(JSON.stringify({ + type: 'interaction_click', + x: e.clientX, y: e.clientY, + selector: sel(t), tag: t.tagName || '', + text: (t.innerText || '').slice(0, 100) + })); + }, true); + + document.addEventListener('keydown', function(e) { + var t = e.target || {}; + send(JSON.stringify({ + type: 'interaction_key', + key: e.key, + selector: sel(t), tag: t.tagName || '' + })); + }, true); + + var scrollTimer = null; + var scrollStart = {x: window.scrollX, y: window.scrollY}; + document.addEventListener('scroll', function(e) { + var fromX = scrollStart.x, fromY = scrollStart.y; + var target = e.target; + var s = target === document ? 'document' : sel(target); + if (scrollTimer) clearTimeout(scrollTimer); + scrollTimer = setTimeout(function() { + var toX = window.scrollX, toY = window.scrollY; + if (Math.abs(toX - fromX) > 5 || Math.abs(toY - fromY) > 5) { + send(JSON.stringify({ + type: 'scroll_settled', + from_x: fromX, from_y: fromY, + to_x: toX, to_y: toY, + target_selector: s + })); + } + scrollStart = {x: toX, y: toY}; + }, 300); + }, true); +})();` + +// injectScript registers the interaction tracking JS for the given session. +func (m *Monitor) injectScript(ctx context.Context, sessionID string) error { + _, err := m.send(ctx, "Page.addScriptToEvaluateOnNewDocument", map[string]any{ + "source": injectedJS, + }, sessionID) + return err +} diff --git a/server/lib/cdpmonitor/handlers.go b/server/lib/cdpmonitor/handlers.go new file mode 100644 index 00000000..3501f50a --- /dev/null +++ b/server/lib/cdpmonitor/handlers.go @@ -0,0 +1,362 @@ +package cdpmonitor + +import ( + "encoding/json" + "time" + "unicode/utf8" + + "github.com/onkernel/kernel-images/server/lib/events" +) + +// publishEvent stamps common fields and publishes an Event. +func (m *Monitor) publishEvent(eventType string, source events.Source, sourceEvent string, data json.RawMessage, sessionID string) { + src := source + src.Event = sourceEvent + if sessionID != "" { + if src.Metadata == nil { + src.Metadata = make(map[string]string) + } + src.Metadata["cdp_session_id"] = sessionID + } + m.publish(events.Event{ + Ts: time.Now().UnixMilli(), + Type: eventType, + Category: events.CategoryFor(eventType), + Source: src, + DetailLevel: events.DetailStandard, + Data: data, + }) +} + +// dispatchEvent routes a CDP event to its handler. +func (m *Monitor) dispatchEvent(msg cdpMessage) { + switch msg.Method { + case "Runtime.consoleAPICalled": + m.handleConsole(msg.Params, msg.SessionID) + case "Runtime.exceptionThrown": + m.handleExceptionThrown(msg.Params, msg.SessionID) + case "Runtime.bindingCalled": + m.handleBindingCalled(msg.Params, msg.SessionID) + case "Network.requestWillBeSent": + m.handleNetworkRequest(msg.Params, msg.SessionID) + case "Network.responseReceived": + m.handleResponseReceived(msg.Params, msg.SessionID) + case "Network.loadingFinished": + m.handleLoadingFinished(msg.Params, msg.SessionID) + case "Network.loadingFailed": + m.handleLoadingFailed(msg.Params, msg.SessionID) + case "Page.frameNavigated": + m.handleFrameNavigated(msg.Params, msg.SessionID) + case "Page.domContentEventFired": + m.handleDOMContentLoaded(msg.Params, msg.SessionID) + case "Page.loadEventFired": + m.handleLoadEventFired(msg.Params, msg.SessionID) + case "DOM.documentUpdated": + m.handleDOMUpdated(msg.Params, msg.SessionID) + case "PerformanceTimeline.timelineEventAdded": + m.handleTimelineEvent(msg.Params, msg.SessionID) + case "Target.attachedToTarget": + m.handleAttachedToTarget(msg) + case "Target.targetCreated": + m.handleTargetCreated(msg.Params, msg.SessionID) + case "Target.targetDestroyed": + m.handleTargetDestroyed(msg.Params, msg.SessionID) + } +} + +func (m *Monitor) handleConsole(params json.RawMessage, sessionID string) { + var p cdpConsoleParams + if err := json.Unmarshal(params, &p); err != nil { + return + } + + text := "" + if len(p.Args) > 0 { + text = p.Args[0].Value + } + argValues := make([]string, 0, len(p.Args)) + for _, a := range p.Args { + argValues = append(argValues, a.Value) + } + data, _ := json.Marshal(map[string]any{ + "level": p.Type, + "text": text, + "args": argValues, + "stack_trace": p.StackTrace, + }) + m.publishEvent("console_log", events.Source{Kind: events.KindCDP}, "Runtime.consoleAPICalled", data, sessionID) +} + +func (m *Monitor) handleExceptionThrown(params json.RawMessage, sessionID string) { + var p cdpExceptionDetails + if err := json.Unmarshal(params, &p); err != nil { + return + } + data, _ := json.Marshal(map[string]any{ + "text": p.ExceptionDetails.Text, + "line": p.ExceptionDetails.LineNumber, + "column": p.ExceptionDetails.ColumnNumber, + "url": p.ExceptionDetails.URL, + "stack_trace": p.ExceptionDetails.StackTrace, + }) + m.publishEvent("console_error", events.Source{Kind: events.KindCDP}, "Runtime.exceptionThrown", data, sessionID) + go m.maybeScreenshot(m.lifecycleCtx) +} + +// handleBindingCalled processes __kernelEvent binding calls. +func (m *Monitor) handleBindingCalled(params json.RawMessage, sessionID string) { + var p struct { + Name string `json:"name"` + Payload string `json:"payload"` + } + if err := json.Unmarshal(params, &p); err != nil || p.Name != bindingName { + return + } + payload := json.RawMessage(p.Payload) + if !json.Valid(payload) { + return + } + var header struct { + Type string `json:"type"` + } + if err := json.Unmarshal(payload, &header); err != nil { + return + } + switch header.Type { + case "interaction_click", "interaction_key", "scroll_settled": + m.publishEvent(header.Type, events.Source{Kind: events.KindCDP}, "Runtime.bindingCalled", payload, sessionID) + } +} + +// handleTimelineEvent processes layout-shift events from PerformanceTimeline. +func (m *Monitor) handleTimelineEvent(params json.RawMessage, sessionID string) { + var p struct { + Event struct { + Type string `json:"type"` + LayoutShift json.RawMessage `json:"layoutShiftDetails,omitempty"` + } `json:"event"` + } + if err := json.Unmarshal(params, &p); err != nil || p.Event.Type != "layout-shift" { + return + } + m.publishEvent("layout_shift", events.Source{Kind: events.KindCDP}, "PerformanceTimeline.timelineEventAdded", params, sessionID) + m.computed.onLayoutShift() +} + +func (m *Monitor) handleNetworkRequest(params json.RawMessage, sessionID string) { + var p cdpNetworkRequestParams + if err := json.Unmarshal(params, &p); err != nil { + return + } + m.pendReqMu.Lock() + m.pendingRequests[p.RequestID] = networkReqState{ + method: p.Request.Method, + url: p.Request.URL, + headers: p.Request.Headers, + postData: p.Request.PostData, + resourceType: p.ResourceType, + initiator: p.Initiator, + } + m.pendReqMu.Unlock() + data, _ := json.Marshal(map[string]any{ + "method": p.Request.Method, + "url": p.Request.URL, + "headers": p.Request.Headers, + "post_data": p.Request.PostData, + "resource_type": p.ResourceType, + "initiator": p.Initiator, + }) + m.publishEvent("network_request", events.Source{Kind: events.KindCDP}, "Network.requestWillBeSent", data, sessionID) + m.computed.onRequest() +} + +func (m *Monitor) handleResponseReceived(params json.RawMessage, sessionID string) { + var p cdpResponseReceivedParams + if err := json.Unmarshal(params, &p); err != nil { + return + } + m.pendReqMu.Lock() + if state, ok := m.pendingRequests[p.RequestID]; ok { + state.status = p.Response.Status + state.statusText = p.Response.StatusText + state.resHeaders = p.Response.Headers + state.mimeType = p.Response.MimeType + m.pendingRequests[p.RequestID] = state + } + m.pendReqMu.Unlock() +} + +func (m *Monitor) handleLoadingFinished(params json.RawMessage, sessionID string) { + var p struct { + RequestID string `json:"requestId"` + } + if err := json.Unmarshal(params, &p); err != nil { + return + } + m.pendReqMu.Lock() + state, ok := m.pendingRequests[p.RequestID] + if ok { + delete(m.pendingRequests, p.RequestID) + } + m.pendReqMu.Unlock() + if !ok { + return + } + // Fetch response body async to avoid blocking readLoop. + go func() { + ctx := m.lifecycleCtx + body := "" + result, err := m.send(ctx, "Network.getResponseBody", map[string]any{ + "requestId": p.RequestID, + }, sessionID) + if err == nil { + var resp struct { + Body string `json:"body"` + Base64Encoded bool `json:"base64Encoded"` + } + if json.Unmarshal(result, &resp) == nil { + body = truncateBody(resp.Body) + } + } + data, _ := json.Marshal(map[string]any{ + "method": state.method, + "url": state.url, + "status": state.status, + "status_text": state.statusText, + "headers": state.resHeaders, + "mime_type": state.mimeType, + "body": body, + }) + m.publishEvent("network_response", events.Source{Kind: events.KindCDP}, "Network.loadingFinished", data, sessionID) + m.computed.onLoadingFinished() + }() +} + +func (m *Monitor) handleLoadingFailed(params json.RawMessage, sessionID string) { + var p struct { + RequestID string `json:"requestId"` + ErrorText string `json:"errorText"` + Canceled bool `json:"canceled"` + } + if err := json.Unmarshal(params, &p); err != nil { + return + } + m.pendReqMu.Lock() + state, ok := m.pendingRequests[p.RequestID] + if ok { + delete(m.pendingRequests, p.RequestID) + } + m.pendReqMu.Unlock() + + ev := map[string]any{ + "error_text": p.ErrorText, + "canceled": p.Canceled, + } + if ok { + ev["url"] = state.url + } + data, _ := json.Marshal(ev) + m.publishEvent("network_loading_failed", events.Source{Kind: events.KindCDP}, "Network.loadingFailed", data, sessionID) + m.computed.onLoadingFinished() +} + +// truncateBody caps body at ~900KB on a valid UTF-8 boundary. +func truncateBody(body string) string { + const maxBody = 900 * 1024 + if len(body) <= maxBody { + return body + } + // Back up to a valid rune boundary. + truncated := body[:maxBody] + for !utf8.ValidString(truncated) { + truncated = truncated[:len(truncated)-1] + } + return truncated +} + +func (m *Monitor) handleFrameNavigated(params json.RawMessage, sessionID string) { + var p struct { + Frame struct { + ID string `json:"id"` + ParentID string `json:"parentId"` + URL string `json:"url"` + } `json:"frame"` + } + if err := json.Unmarshal(params, &p); err != nil { + return + } + data, _ := json.Marshal(map[string]any{ + "url": p.Frame.URL, + "frame_id": p.Frame.ID, + "parent_frame_id": p.Frame.ParentID, + }) + m.publishEvent("navigation", events.Source{Kind: events.KindCDP}, "Page.frameNavigated", data, sessionID) + + m.pendReqMu.Lock() + clear(m.pendingRequests) + m.pendReqMu.Unlock() + + m.computed.resetOnNavigation() +} + +func (m *Monitor) handleDOMContentLoaded(params json.RawMessage, sessionID string) { + m.publishEvent("dom_content_loaded", events.Source{Kind: events.KindCDP}, "Page.domContentEventFired", params, sessionID) + m.computed.onDOMContentLoaded() +} + +func (m *Monitor) handleLoadEventFired(params json.RawMessage, sessionID string) { + m.publishEvent("page_load", events.Source{Kind: events.KindCDP}, "Page.loadEventFired", params, sessionID) + m.computed.onPageLoad() + go m.maybeScreenshot(m.lifecycleCtx) +} + +func (m *Monitor) handleDOMUpdated(params json.RawMessage, sessionID string) { + m.publishEvent("dom_updated", events.Source{Kind: events.KindCDP}, "DOM.documentUpdated", params, sessionID) +} + +// handleAttachedToTarget stores the session and enables domains + injects script. +func (m *Monitor) handleAttachedToTarget(msg cdpMessage) { + var params cdpAttachedToTargetParams + if err := json.Unmarshal(msg.Params, ¶ms); err != nil { + return + } + m.sessionsMu.Lock() + m.sessions[params.SessionID] = targetInfo{ + targetID: params.TargetInfo.TargetID, + url: params.TargetInfo.URL, + targetType: params.TargetInfo.Type, + } + m.sessionsMu.Unlock() + + // Async to avoid blocking readLoop. + go func() { + m.enableDomains(m.lifecycleCtx, params.SessionID) + _ = m.injectScript(m.lifecycleCtx, params.SessionID) + }() +} + +func (m *Monitor) handleTargetCreated(params json.RawMessage, sessionID string) { + var p cdpTargetCreatedParams + if err := json.Unmarshal(params, &p); err != nil { + return + } + data, _ := json.Marshal(map[string]any{ + "target_id": p.TargetInfo.TargetID, + "target_type": p.TargetInfo.Type, + "url": p.TargetInfo.URL, + }) + m.publishEvent("target_created", events.Source{Kind: events.KindCDP}, "Target.targetCreated", data, sessionID) +} + +func (m *Monitor) handleTargetDestroyed(params json.RawMessage, sessionID string) { + var p struct { + TargetID string `json:"targetId"` + } + if err := json.Unmarshal(params, &p); err != nil { + return + } + data, _ := json.Marshal(map[string]any{ + "target_id": p.TargetID, + }) + m.publishEvent("target_destroyed", events.Source{Kind: events.KindCDP}, "Target.targetDestroyed", data, sessionID) +} diff --git a/server/lib/cdpmonitor/monitor.go b/server/lib/cdpmonitor/monitor.go index 737f9650..886e5946 100644 --- a/server/lib/cdpmonitor/monitor.go +++ b/server/lib/cdpmonitor/monitor.go @@ -2,8 +2,13 @@ package cdpmonitor import ( "context" + "encoding/json" + "fmt" + "sync" "sync/atomic" + "time" + "github.com/coder/websocket" "github.com/onkernel/kernel-images/server/lib/events" ) @@ -17,14 +22,49 @@ type UpstreamProvider interface { type PublishFunc func(ev events.Event) // Monitor manages a CDP WebSocket connection with auto-attach session fan-out. -// Single-use per capture session: call Start to begin, Stop to tear down. type Monitor struct { + upstreamMgr UpstreamProvider + publish PublishFunc + displayNum int + + conn *websocket.Conn + connMu sync.Mutex + + nextID atomic.Int64 + pendMu sync.Mutex + pending map[int64]chan cdpMessage + + sessionsMu sync.RWMutex + sessions map[string]targetInfo // sessionID → targetInfo + + pendReqMu sync.Mutex + pendingRequests map[string]networkReqState // requestId → networkReqState + + computed *computedState + + lastScreenshotAt atomic.Int64 // unix millis of last capture + screenshotFn func(ctx context.Context, displayNum int) ([]byte, error) // nil → real ffmpeg + + lifecycleCtx context.Context // cancelled on Stop() + cancel context.CancelFunc + done chan struct{} + running atomic.Bool } // New creates a Monitor. displayNum is the X display for ffmpeg screenshots. -func New(_ UpstreamProvider, _ PublishFunc, _ int) *Monitor { - return &Monitor{} +func New(upstreamMgr UpstreamProvider, publish PublishFunc, displayNum int) *Monitor { + m := &Monitor{ + upstreamMgr: upstreamMgr, + publish: publish, + displayNum: displayNum, + sessions: make(map[string]targetInfo), + pending: make(map[int64]chan cdpMessage), + pendingRequests: make(map[string]networkReqState), + } + m.computed = newComputedState(publish) + m.lifecycleCtx = context.Background() + return m } // IsRunning reports whether the monitor is actively capturing. @@ -33,9 +73,264 @@ func (m *Monitor) IsRunning() bool { } // Start begins CDP capture. Restarts if already running. -func (m *Monitor) Start(_ context.Context) error { +func (m *Monitor) Start(parentCtx context.Context) error { + if m.running.Load() { + m.Stop() + } + + devtoolsURL := m.upstreamMgr.Current() + if devtoolsURL == "" { + return fmt.Errorf("cdpmonitor: no DevTools URL available") + } + + conn, _, err := websocket.Dial(parentCtx, devtoolsURL, nil) + if err != nil { + return fmt.Errorf("cdpmonitor: dial %s: %w", devtoolsURL, err) + } + conn.SetReadLimit(8 * 1024 * 1024) + + m.connMu.Lock() + m.conn = conn + m.connMu.Unlock() + + ctx, cancel := context.WithCancel(parentCtx) + m.lifecycleCtx = ctx + m.cancel = cancel + m.done = make(chan struct{}) + + m.running.Store(true) + + go m.readLoop(ctx) + go m.subscribeToUpstream(ctx) + go m.initSession(ctx) // must run after readLoop starts + return nil } -// Stop tears down the monitor. Safe to call multiple times. -func (m *Monitor) Stop() {} +// Stop cancels the context and waits for goroutines to exit. +func (m *Monitor) Stop() { + if !m.running.Swap(false) { + return + } + if m.cancel != nil { + m.cancel() + } + if m.done != nil { + <-m.done + } + m.connMu.Lock() + if m.conn != nil { + _ = m.conn.Close(websocket.StatusNormalClosure, "stopped") + m.conn = nil + } + m.connMu.Unlock() + + m.sessionsMu.Lock() + m.sessions = make(map[string]targetInfo) + m.sessionsMu.Unlock() + + m.pendReqMu.Lock() + m.pendingRequests = make(map[string]networkReqState) + m.pendReqMu.Unlock() + + m.computed.resetOnNavigation() +} + +// readLoop reads CDP messages, routing responses to pending callers and +// dispatching events. Exits on connection close; respawned on reconnect. +func (m *Monitor) readLoop(ctx context.Context) { + defer close(m.done) + + for { + m.connMu.Lock() + conn := m.conn + m.connMu.Unlock() + if conn == nil { + return + } + + _, b, err := conn.Read(ctx) + if err != nil { + return + } + + var msg cdpMessage + if err := json.Unmarshal(b, &msg); err != nil { + continue + } + + if msg.ID != 0 { + m.pendMu.Lock() + ch, ok := m.pending[msg.ID] + m.pendMu.Unlock() + if ok { + select { + case ch <- msg: + default: + } + } + continue + } + + m.dispatchEvent(msg) + } +} + +// send issues a CDP command and blocks until the response arrives. +func (m *Monitor) send(ctx context.Context, method string, params any, sessionID string) (json.RawMessage, error) { + id := m.nextID.Add(1) + + var rawParams json.RawMessage + if params != nil { + b, err := json.Marshal(params) + if err != nil { + return nil, fmt.Errorf("marshal params: %w", err) + } + rawParams = b + } + + req := cdpMessage{ID: id, Method: method, Params: rawParams, SessionID: sessionID} + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + + ch := make(chan cdpMessage, 1) + m.pendMu.Lock() + m.pending[id] = ch + m.pendMu.Unlock() + defer func() { + m.pendMu.Lock() + delete(m.pending, id) + m.pendMu.Unlock() + }() + + m.connMu.Lock() + conn := m.conn + m.connMu.Unlock() + if conn == nil { + return nil, fmt.Errorf("cdpmonitor: connection not open") + } + + if err := conn.Write(ctx, websocket.MessageText, reqBytes); err != nil { + return nil, fmt.Errorf("write: %w", err) + } + + select { + case resp := <-ch: + if resp.Error != nil { + return nil, resp.Error + } + return resp.Result, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +// initSession enables CDP domains and injects the interaction-tracking script +// on a fresh connection (called async). +func (m *Monitor) initSession(ctx context.Context) { + _, _ = m.send(ctx, "Target.setAutoAttach", map[string]any{ + "autoAttach": true, + "waitForDebuggerOnStart": false, + "flatten": true, + }, "") + m.enableDomains(ctx, "") + _ = m.injectScript(ctx, "") +} + +// restartReadLoop waits for the old readLoop to exit, then spawns a new one. +func (m *Monitor) restartReadLoop(ctx context.Context) { + <-m.done + m.done = make(chan struct{}) + go m.readLoop(ctx) +} + +// subscribeToUpstream reconnects with backoff on Chrome restarts, emitting +// monitor_disconnected / monitor_reconnected events. +func (m *Monitor) subscribeToUpstream(ctx context.Context) { + ch, cancel := m.upstreamMgr.Subscribe() + defer cancel() + + backoffs := []time.Duration{ + 250 * time.Millisecond, + 500 * time.Millisecond, + 1 * time.Second, + 2 * time.Second, + } + + for { + select { + case <-ctx.Done(): + return + case newURL, ok := <-ch: + if !ok { + return + } + m.publish(events.Event{ + Ts: time.Now().UnixMilli(), + Type: "monitor_disconnected", + Category: events.CategorySystem, + Source: events.Source{Kind: events.KindLocalProcess}, + DetailLevel: events.DetailMinimal, + Data: json.RawMessage(`{"reason":"chrome_restarted"}`), + }) + + startReconnect := time.Now() + + m.connMu.Lock() + if m.conn != nil { + _ = m.conn.Close(websocket.StatusNormalClosure, "reconnecting") + m.conn = nil + } + m.connMu.Unlock() + + var reconnErr error + for attempt := range 10 { + if ctx.Err() != nil { + return + } + + idx := min(attempt, len(backoffs)-1) + select { + case <-ctx.Done(): + return + case <-time.After(backoffs[idx]): + } + + conn, _, err := websocket.Dial(ctx, newURL, nil) + if err != nil { + reconnErr = err + continue + } + conn.SetReadLimit(8 * 1024 * 1024) + + m.connMu.Lock() + m.conn = conn + m.connMu.Unlock() + + reconnErr = nil + break + } + + if reconnErr != nil { + return + } + + m.restartReadLoop(ctx) + go m.initSession(ctx) + + m.publish(events.Event{ + Ts: time.Now().UnixMilli(), + Type: "monitor_reconnected", + Category: events.CategorySystem, + Source: events.Source{Kind: events.KindLocalProcess}, + DetailLevel: events.DetailMinimal, + Data: json.RawMessage(fmt.Sprintf( + `{"reconnect_duration_ms":%d}`, + time.Since(startReconnect).Milliseconds(), + )), + }) + } + } +} diff --git a/server/lib/cdpmonitor/screenshot.go b/server/lib/cdpmonitor/screenshot.go new file mode 100644 index 00000000..54b7b985 --- /dev/null +++ b/server/lib/cdpmonitor/screenshot.go @@ -0,0 +1,87 @@ +package cdpmonitor + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "os/exec" + "time" + + "github.com/onkernel/kernel-images/server/lib/events" +) + +// maybeScreenshot triggers a screenshot if the rate-limit window has elapsed. +// It uses an atomic CAS on lastScreenshotAt to ensure only one screenshot runs +// at a time. +func (m *Monitor) maybeScreenshot(ctx context.Context) { + now := time.Now().UnixMilli() + last := m.lastScreenshotAt.Load() + if now-last < 2000 { + return + } + if !m.lastScreenshotAt.CompareAndSwap(last, now) { + return + } + go m.captureScreenshot(ctx) +} + +// captureScreenshot takes a screenshot via ffmpeg x11grab (or the screenshotFn +// seam in tests), optionally downscales it, and publishes a screenshot event. +func (m *Monitor) captureScreenshot(ctx context.Context) { + var pngBytes []byte + var err error + + if m.screenshotFn != nil { + pngBytes, err = m.screenshotFn(ctx, m.displayNum) + } else { + pngBytes, err = captureViaFFmpeg(ctx, m.displayNum, 1) + } + if err != nil { + return + } + + // Downscale if base64 output would exceed 950KB (~729KB raw). + const rawThreshold = 729 * 1024 + for scale := 2; len(pngBytes) > rawThreshold && scale <= 16 && m.screenshotFn == nil; scale *= 2 { + pngBytes, err = captureViaFFmpeg(ctx, m.displayNum, scale) + if err != nil { + return + } + } + + encoded := base64.StdEncoding.EncodeToString(pngBytes) + data := json.RawMessage(fmt.Sprintf(`{"png":%q}`, encoded)) + + m.publish(events.Event{ + Ts: time.Now().UnixMilli(), + Type: "screenshot", + Category: events.CategorySystem, + Source: events.Source{Kind: events.KindLocalProcess}, + DetailLevel: events.DetailStandard, + Data: data, + }) +} + +// captureViaFFmpeg runs ffmpeg x11grab to capture a PNG screenshot. +// If divisor > 1, a scale filter is applied to reduce the output size. +func captureViaFFmpeg(ctx context.Context, displayNum, divisor int) ([]byte, error) { + args := []string{ + "-f", "x11grab", + "-i", fmt.Sprintf(":%d", displayNum), + "-vframes", "1", + } + if divisor > 1 { + args = append(args, "-vf", fmt.Sprintf("scale=iw/%d:ih/%d", divisor, divisor)) + } + args = append(args, "-f", "image2", "pipe:1") + + var out bytes.Buffer + cmd := exec.CommandContext(ctx, "ffmpeg", args...) + cmd.Stdout = &out + if err := cmd.Run(); err != nil { + return nil, err + } + return out.Bytes(), nil +} From 005d7784a7f8619c8e4bbf2f2de64d5515a8baaf Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Wed, 1 Apr 2026 13:25:34 +0000 Subject: [PATCH 04/13] test: add CDP monitor test suite with in-process websocket mock --- server/lib/cdpmonitor/monitor_test.go | 1142 +++++++++++++++++++++++++ 1 file changed, 1142 insertions(+) create mode 100644 server/lib/cdpmonitor/monitor_test.go diff --git a/server/lib/cdpmonitor/monitor_test.go b/server/lib/cdpmonitor/monitor_test.go new file mode 100644 index 00000000..d16104f1 --- /dev/null +++ b/server/lib/cdpmonitor/monitor_test.go @@ -0,0 +1,1142 @@ +package cdpmonitor + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" + "github.com/onkernel/kernel-images/server/lib/events" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeCDPServer is a minimal WebSocket server that accepts connections and +// lets the test drive scripted message sequences. +type fakeCDPServer struct { + srv *httptest.Server + conn *websocket.Conn + connMu sync.Mutex + msgCh chan []byte // inbound messages from Monitor +} + +func newFakeCDPServer(t *testing.T) *fakeCDPServer { + t.Helper() + f := &fakeCDPServer{ + msgCh: make(chan []byte, 128), + } + f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true}) + if err != nil { + return + } + f.connMu.Lock() + f.conn = c + f.connMu.Unlock() + // drain messages from Monitor into msgCh until connection closes + go func() { + for { + _, b, err := c.Read(context.Background()) + if err != nil { + return + } + f.msgCh <- b + } + }() + })) + return f +} + +// wsURL returns a ws:// URL pointing at the fake server. +func (f *fakeCDPServer) wsURL() string { + return "ws" + strings.TrimPrefix(f.srv.URL, "http") +} + +// sendToMonitor pushes a raw JSON message to the Monitor's readLoop. +func (f *fakeCDPServer) sendToMonitor(t *testing.T, msg any) { + t.Helper() + f.connMu.Lock() + c := f.conn + f.connMu.Unlock() + require.NotNil(t, c, "no active connection") + err := wsjson.Write(context.Background(), c, msg) + require.NoError(t, err) +} + +// readFromMonitor blocks until the Monitor sends a message (with timeout). +func (f *fakeCDPServer) readFromMonitor(t *testing.T, timeout time.Duration) cdpMessage { + t.Helper() + select { + case b := <-f.msgCh: + var msg cdpMessage + require.NoError(t, json.Unmarshal(b, &msg)) + return msg + case <-time.After(timeout): + t.Fatal("timeout waiting for message from Monitor") + return cdpMessage{} + } +} + +func (f *fakeCDPServer) close() { + f.connMu.Lock() + if f.conn != nil { + _ = f.conn.Close(websocket.StatusNormalClosure, "done") + } + f.connMu.Unlock() + f.srv.Close() +} + +// fakeUpstream implements UpstreamProvider for tests. +type fakeUpstream struct { + mu sync.Mutex + current string + subs []chan string +} + +func newFakeUpstream(url string) *fakeUpstream { + return &fakeUpstream{current: url} +} + +func (f *fakeUpstream) Current() string { + f.mu.Lock() + defer f.mu.Unlock() + return f.current +} + +func (f *fakeUpstream) Subscribe() (<-chan string, func()) { + ch := make(chan string, 1) + f.mu.Lock() + f.subs = append(f.subs, ch) + f.mu.Unlock() + cancel := func() { + f.mu.Lock() + for i, s := range f.subs { + if s == ch { + f.subs = append(f.subs[:i], f.subs[i+1:]...) + break + } + } + f.mu.Unlock() + close(ch) + } + return ch, cancel +} + +// notifyRestart simulates Chrome restarting with a new DevTools URL. +func (f *fakeUpstream) notifyRestart(newURL string) { + f.mu.Lock() + f.current = newURL + subs := make([]chan string, len(f.subs)) + copy(subs, f.subs) + f.mu.Unlock() + for _, ch := range subs { + select { + case ch <- newURL: + default: + } + } +} + +// --- Tests --- + +// TestMonitorStart verifies that Monitor.Start() dials the URL from +// UpstreamProvider.Current() and establishes an isolated WebSocket connection. +func TestMonitorStart(t *testing.T) { + srv := newFakeCDPServer(t) + defer srv.close() + + upstream := newFakeUpstream(srv.wsURL()) + var published []events.Event + var publishMu sync.Mutex + publishFn := func(ev events.Event) { + publishMu.Lock() + published = append(published, ev) + publishMu.Unlock() + } + + m := New(upstream, publishFn, 99) + + ctx := context.Background() + err := m.Start(ctx) + require.NoError(t, err) + defer m.Stop() + + // Give readLoop time to start and send the setAutoAttach command. + // We just verify the connection was made and the Monitor is running. + assert.True(t, m.IsRunning()) + + // Read the first message sent by the Monitor — it should be Target.setAutoAttach. + msg := srv.readFromMonitor(t, 3*time.Second) + assert.Equal(t, "Target.setAutoAttach", msg.Method) +} + +// TestAutoAttach verifies that after Start(), the Monitor sends +// Target.setAutoAttach{autoAttach:true, waitForDebuggerOnStart:false, flatten:true} +// and that on receiving Target.attachedToTarget the session is stored. +func TestAutoAttach(t *testing.T) { + srv := newFakeCDPServer(t) + defer srv.close() + + upstream := newFakeUpstream(srv.wsURL()) + publishFn := func(ev events.Event) {} + + m := New(upstream, publishFn, 99) + + ctx := context.Background() + err := m.Start(ctx) + require.NoError(t, err) + defer m.Stop() + + // Read the setAutoAttach request from the Monitor. + msg := srv.readFromMonitor(t, 3*time.Second) + assert.Equal(t, "Target.setAutoAttach", msg.Method) + + var params struct { + AutoAttach bool `json:"autoAttach"` + WaitForDebuggerOnStart bool `json:"waitForDebuggerOnStart"` + Flatten bool `json:"flatten"` + } + require.NoError(t, json.Unmarshal(msg.Params, ¶ms)) + assert.True(t, params.AutoAttach) + assert.False(t, params.WaitForDebuggerOnStart) + assert.True(t, params.Flatten) + + // Acknowledge the command with a response. + srv.sendToMonitor(t, map[string]any{ + "id": msg.ID, + "result": map[string]any{}, + }) + + // Drain any domain-enable commands sent after setAutoAttach. + // The Monitor calls enableDomains (Runtime.enable, Network.enable, Page.enable, DOM.enable). + drainTimeout := time.NewTimer(500 * time.Millisecond) + for { + select { + case b := <-srv.msgCh: + var m2 cdpMessage + _ = json.Unmarshal(b, &m2) + // respond to enable commands + srv.connMu.Lock() + c := srv.conn + srv.connMu.Unlock() + if c != nil && m2.ID != 0 { + _ = wsjson.Write(context.Background(), c, map[string]any{ + "id": m2.ID, + "result": map[string]any{}, + }) + } + case <-drainTimeout.C: + goto afterDrain + } + } +afterDrain: + + // Now simulate Target.attachedToTarget event. + const testSessionID = "session-abc-123" + const testTargetID = "target-xyz-456" + srv.sendToMonitor(t, map[string]any{ + "method": "Target.attachedToTarget", + "params": map[string]any{ + "sessionId": testSessionID, + "targetInfo": map[string]any{ + "targetId": testTargetID, + "type": "page", + "url": "https://example.com", + }, + }, + }) + + // Give the Monitor time to process the event and store the session. + require.Eventually(t, func() bool { + m.sessionsMu.RLock() + defer m.sessionsMu.RUnlock() + _, ok := m.sessions[testSessionID] + return ok + }, 2*time.Second, 50*time.Millisecond, "session not stored after attachedToTarget") + + m.sessionsMu.RLock() + info := m.sessions[testSessionID] + m.sessionsMu.RUnlock() + assert.Equal(t, testTargetID, info.targetID) + assert.Equal(t, "page", info.targetType) +} + +// TestLifecycle verifies the idle→running→stopped→restart state machine. +func TestLifecycle(t *testing.T) { + srv := newFakeCDPServer(t) + defer srv.close() + + upstream := newFakeUpstream(srv.wsURL()) + publishFn := func(ev events.Event) {} + + m := New(upstream, publishFn, 99) + + // Idle at boot. + assert.False(t, m.IsRunning(), "should be idle at boot") + + ctx := context.Background() + + // First Start. + err := m.Start(ctx) + require.NoError(t, err) + assert.True(t, m.IsRunning(), "should be running after Start") + + // Drain the setAutoAttach message. + select { + case <-srv.msgCh: + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for setAutoAttach") + } + + // Stop. + m.Stop() + assert.False(t, m.IsRunning(), "should be stopped after Stop") + + // Second Start while stopped — should start fresh. + err = m.Start(ctx) + require.NoError(t, err) + assert.True(t, m.IsRunning(), "should be running after second Start") + + // Drain the setAutoAttach message for the second start. + select { + case <-srv.msgCh: + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for setAutoAttach on second start") + } + + // Second Start while already running — stop+restart. + err = m.Start(ctx) + require.NoError(t, err) + assert.True(t, m.IsRunning(), "should be running after stop+restart") + + m.Stop() + assert.False(t, m.IsRunning(), "should be stopped at end") +} + +// TestReconnect verifies that when UpstreamManager emits a new URL (Chrome restart), +// the monitor emits monitor_disconnected, reconnects, and emits monitor_reconnected. +func TestReconnect(t *testing.T) { + srv1 := newFakeCDPServer(t) + + upstream := newFakeUpstream(srv1.wsURL()) + + var published []events.Event + var publishMu sync.Mutex + var publishCount atomic.Int32 + publishFn := func(ev events.Event) { + publishMu.Lock() + published = append(published, ev) + publishMu.Unlock() + publishCount.Add(1) + } + + m := New(upstream, publishFn, 99) + + ctx := context.Background() + err := m.Start(ctx) + require.NoError(t, err) + defer m.Stop() + + // Drain setAutoAttach from srv1. + select { + case <-srv1.msgCh: + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for initial setAutoAttach") + } + + // Set up srv2 as the new Chrome URL. + srv2 := newFakeCDPServer(t) + defer srv2.close() + defer srv1.close() + + // Trigger Chrome restart notification. + upstream.notifyRestart(srv2.wsURL()) + + // Wait for monitor_disconnected event. + require.Eventually(t, func() bool { + publishMu.Lock() + defer publishMu.Unlock() + for _, ev := range published { + if ev.Type == "monitor_disconnected" { + return true + } + } + return false + }, 3*time.Second, 50*time.Millisecond, "monitor_disconnected not published") + + // Wait for the Monitor to connect to srv2 and send setAutoAttach. + select { + case <-srv2.msgCh: + // setAutoAttach received on srv2 + case <-time.After(5*time.Second): + t.Fatal("timeout waiting for setAutoAttach on srv2 after reconnect") + } + + // Wait for monitor_reconnected event. + require.Eventually(t, func() bool { + publishMu.Lock() + defer publishMu.Unlock() + for _, ev := range published { + if ev.Type == "monitor_reconnected" { + return true + } + } + return false + }, 3*time.Second, 50*time.Millisecond, "monitor_reconnected not published") + + // Verify monitor_reconnected contains reconnect_duration_ms. + publishMu.Lock() + var reconnEv events.Event + for _, ev := range published { + if ev.Type == "monitor_reconnected" { + reconnEv = ev + break + } + } + publishMu.Unlock() + + require.NotEmpty(t, reconnEv.Type) + var data map[string]any + require.NoError(t, json.Unmarshal(reconnEv.Data, &data)) + _, hasField := data["reconnect_duration_ms"] + assert.True(t, hasField, "monitor_reconnected missing reconnect_duration_ms field") +} + +// listenAndRespondAll drains srv.msgCh and responds with empty results until stopCh is closed. +func listenAndRespondAll(srv *fakeCDPServer, stopCh <-chan struct{}) { + for { + select { + case b := <-srv.msgCh: + var msg cdpMessage + if err := json.Unmarshal(b, &msg); err != nil { + continue + } + if msg.ID == 0 { + continue + } + srv.connMu.Lock() + c := srv.conn + srv.connMu.Unlock() + if c != nil { + _ = wsjson.Write(context.Background(), c, map[string]any{ + "id": msg.ID, + "result": map[string]any{}, + }) + } + case <-stopCh: + return + } + } +} + + +// startMonitorWithFakeServer is a helper that starts a monitor against a fake CDP server, +// drains the initial setAutoAttach + domain-enable commands, and returns a cleanup func. +func startMonitorWithFakeServer(t *testing.T, srv *fakeCDPServer) (*Monitor, *[]events.Event, *sync.Mutex, func()) { + t.Helper() + published := make([]events.Event, 0, 32) + var mu sync.Mutex + publishFn := func(ev events.Event) { + mu.Lock() + published = append(published, ev) + mu.Unlock() + } + upstream := newFakeUpstream(srv.wsURL()) + m := New(upstream, publishFn, 99) + ctx := context.Background() + require.NoError(t, m.Start(ctx)) + + stopResponder := make(chan struct{}) + go listenAndRespondAll(srv, stopResponder) + + cleanup := func() { + close(stopResponder) + m.Stop() + } + // Wait until the fake server has an active connection. + require.Eventually(t, func() bool { + srv.connMu.Lock() + defer srv.connMu.Unlock() + return srv.conn != nil + }, 3*time.Second, 20*time.Millisecond, "fake server never received a connection") + // Allow the readLoop and init commands to settle before sending test events. + time.Sleep(150 * time.Millisecond) + return m, &published, &mu, cleanup +} + +// waitForEvent blocks until an event of the given type is published, or times out. +func waitForEvent(t *testing.T, published *[]events.Event, mu *sync.Mutex, eventType string, timeout time.Duration) events.Event { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + mu.Lock() + for _, ev := range *published { + if ev.Type == eventType { + mu.Unlock() + return ev + } + } + mu.Unlock() + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("timeout waiting for event type=%q", eventType) + return events.Event{} +} + + +// TestConsoleEvents verifies console_log, console_error, and [KERNEL_EVENT] sentinel routing. +func TestConsoleEvents(t *testing.T) { + srv := newFakeCDPServer(t) + defer srv.close() + + _, published, mu, cleanup := startMonitorWithFakeServer(t, srv) + defer cleanup() + + // 1. consoleAPICalled → console_log + srv.sendToMonitor(t, map[string]any{ + "method": "Runtime.consoleAPICalled", + "params": map[string]any{ + "type": "log", + "args": []any{map[string]any{"type": "string", "value": "hello world"}}, + "executionContextId": 1, + }, + }) + ev := waitForEvent(t, published, mu, "console_log", 2*time.Second) + assert.Equal(t, events.CategoryConsole, ev.Category) + assert.Equal(t, events.KindCDP, ev.Source.Kind) + assert.Equal(t, "Runtime.consoleAPICalled", ev.Source.Event) + assert.Equal(t, events.DetailStandard, ev.DetailLevel) + var data map[string]any + require.NoError(t, json.Unmarshal(ev.Data, &data)) + assert.Equal(t, "log", data["level"]) + assert.Equal(t, "hello world", data["text"]) + + // 2. exceptionThrown → console_error + srv.sendToMonitor(t, map[string]any{ + "method": "Runtime.exceptionThrown", + "params": map[string]any{ + "timestamp": 1234.5, + "exceptionDetails": map[string]any{ + "text": "Uncaught TypeError", + "lineNumber": 42, + "columnNumber": 7, + "url": "https://example.com/app.js", + }, + }, + }) + ev2 := waitForEvent(t, published, mu, "console_error", 2*time.Second) + assert.Equal(t, events.CategoryConsole, ev2.Category) + assert.Equal(t, events.KindCDP, ev2.Source.Kind) + assert.Equal(t, "Runtime.exceptionThrown", ev2.Source.Event) + assert.Equal(t, events.DetailStandard, ev2.DetailLevel) + var data2 map[string]any + require.NoError(t, json.Unmarshal(ev2.Data, &data2)) + assert.Equal(t, "Uncaught TypeError", data2["text"]) + assert.Equal(t, float64(42), data2["line"]) + assert.Equal(t, float64(7), data2["column"]) + + // 3. Runtime.bindingCalled → interaction_click (via __kernelEvent binding) + srv.sendToMonitor(t, map[string]any{ + "method": "Runtime.bindingCalled", + "params": map[string]any{ + "name": "__kernelEvent", + "payload": `{"type":"interaction_click","x":10,"y":20,"selector":"button","tag":"BUTTON","text":"OK"}`, + }, + }) + ev3 := waitForEvent(t, published, mu, "interaction_click", 2*time.Second) + assert.Equal(t, events.CategoryInteraction, ev3.Category) + assert.Equal(t, "Runtime.bindingCalled", ev3.Source.Event) +} + +// TestNetworkEvents verifies network_request, network_response, and network_loading_failed. +func TestNetworkEvents(t *testing.T) { + srv := newFakeCDPServer(t) + defer srv.close() + + published := make([]events.Event, 0, 32) + var mu sync.Mutex + upstream := newFakeUpstream(srv.wsURL()) + m := New(upstream, func(ev events.Event) { + mu.Lock() + published = append(published, ev) + mu.Unlock() + }, 99) + ctx := context.Background() + require.NoError(t, m.Start(ctx)) + defer m.Stop() + + // Responder goroutine: answer all commands from the monitor. + // For Network.getResponseBody, return a real body; for everything else return {}. + stopResponder := make(chan struct{}) + defer close(stopResponder) + go func() { + for { + select { + case b := <-srv.msgCh: + var msg cdpMessage + if err := json.Unmarshal(b, &msg); err != nil { + continue + } + if msg.ID == 0 { + continue + } + srv.connMu.Lock() + c := srv.conn + srv.connMu.Unlock() + if c == nil { + continue + } + var resp any + if msg.Method == "Network.getResponseBody" { + resp = map[string]any{ + "id": msg.ID, + "result": map[string]any{"body": `{"ok":true}`, "base64Encoded": false}, + } + } else { + resp = map[string]any{"id": msg.ID, "result": map[string]any{}} + } + _ = wsjson.Write(context.Background(), c, resp) + case <-stopResponder: + return + } + } + }() + + // Wait for connection. + require.Eventually(t, func() bool { + srv.connMu.Lock() + defer srv.connMu.Unlock() + return srv.conn != nil + }, 3*time.Second, 20*time.Millisecond) + time.Sleep(150 * time.Millisecond) + + const reqID = "req-001" + + // 1. requestWillBeSent → network_request + srv.sendToMonitor(t, map[string]any{ + "method": "Network.requestWillBeSent", + "params": map[string]any{ + "requestId": reqID, + "resourceType": "XHR", + "request": map[string]any{ + "method": "POST", + "url": "https://api.example.com/data", + "headers": map[string]any{"Content-Type": "application/json"}, + }, + "initiator": map[string]any{"type": "script"}, + }, + }) + ev := waitForEvent(t, &published, &mu, "network_request", 2*time.Second) + assert.Equal(t, events.CategoryNetwork, ev.Category) + assert.Equal(t, events.KindCDP, ev.Source.Kind) + assert.Equal(t, "Network.requestWillBeSent", ev.Source.Event) + var data map[string]any + require.NoError(t, json.Unmarshal(ev.Data, &data)) + assert.Equal(t, "POST", data["method"]) + assert.Equal(t, "https://api.example.com/data", data["url"]) + + // 2. responseReceived + loadingFinished → network_response (with body via getResponseBody) + srv.sendToMonitor(t, map[string]any{ + "method": "Network.responseReceived", + "params": map[string]any{ + "requestId": reqID, + "response": map[string]any{ + "status": 200, + "statusText": "OK", + "url": "https://api.example.com/data", + "headers": map[string]any{"Content-Type": "application/json"}, + "mimeType": "application/json", + }, + }, + }) + srv.sendToMonitor(t, map[string]any{ + "method": "Network.loadingFinished", + "params": map[string]any{ + "requestId": reqID, + }, + }) + + ev2 := waitForEvent(t, &published, &mu, "network_response", 3*time.Second) + assert.Equal(t, events.CategoryNetwork, ev2.Category) + assert.Equal(t, "Network.loadingFinished", ev2.Source.Event) + var data2 map[string]any + require.NoError(t, json.Unmarshal(ev2.Data, &data2)) + assert.Equal(t, float64(200), data2["status"]) + assert.NotEmpty(t, data2["body"]) + + // 3. loadingFailed → network_loading_failed + const reqID2 = "req-002" + srv.sendToMonitor(t, map[string]any{ + "method": "Network.requestWillBeSent", + "params": map[string]any{ + "requestId": reqID2, + "request": map[string]any{ + "method": "GET", + "url": "https://fail.example.com/", + }, + }, + }) + waitForEvent(t, &published, &mu, "network_request", 2*time.Second) + + mu.Lock() + published = published[:0] + mu.Unlock() + + srv.sendToMonitor(t, map[string]any{ + "method": "Network.loadingFailed", + "params": map[string]any{ + "requestId": reqID2, + "errorText": "net::ERR_CONNECTION_REFUSED", + "canceled": false, + }, + }) + ev3 := waitForEvent(t, &published, &mu, "network_loading_failed", 2*time.Second) + assert.Equal(t, events.CategoryNetwork, ev3.Category) + var data3 map[string]any + require.NoError(t, json.Unmarshal(ev3.Data, &data3)) + assert.Equal(t, "net::ERR_CONNECTION_REFUSED", data3["error_text"]) +} + +// TestPageEvents verifies navigation, dom_content_loaded, page_load, and dom_updated. +func TestPageEvents(t *testing.T) { + srv := newFakeCDPServer(t) + defer srv.close() + + _, published, mu, cleanup := startMonitorWithFakeServer(t, srv) + defer cleanup() + + // frameNavigated → navigation + srv.sendToMonitor(t, map[string]any{ + "method": "Page.frameNavigated", + "params": map[string]any{ + "frame": map[string]any{ + "id": "frame-1", + "url": "https://example.com/page", + }, + }, + }) + ev := waitForEvent(t, published, mu, "navigation", 2*time.Second) + assert.Equal(t, events.CategoryPage, ev.Category) + assert.Equal(t, events.KindCDP, ev.Source.Kind) + assert.Equal(t, "Page.frameNavigated", ev.Source.Event) + var data map[string]any + require.NoError(t, json.Unmarshal(ev.Data, &data)) + assert.Equal(t, "https://example.com/page", data["url"]) + + // domContentEventFired → dom_content_loaded + srv.sendToMonitor(t, map[string]any{ + "method": "Page.domContentEventFired", + "params": map[string]any{"timestamp": 1000.0}, + }) + ev2 := waitForEvent(t, published, mu, "dom_content_loaded", 2*time.Second) + assert.Equal(t, events.CategoryPage, ev2.Category) + + // loadEventFired → page_load + srv.sendToMonitor(t, map[string]any{ + "method": "Page.loadEventFired", + "params": map[string]any{"timestamp": 1001.0}, + }) + ev3 := waitForEvent(t, published, mu, "page_load", 2*time.Second) + assert.Equal(t, events.CategoryPage, ev3.Category) + + // documentUpdated → dom_updated + srv.sendToMonitor(t, map[string]any{ + "method": "DOM.documentUpdated", + "params": map[string]any{}, + }) + ev4 := waitForEvent(t, published, mu, "dom_updated", 2*time.Second) + assert.Equal(t, events.CategoryPage, ev4.Category) +} + +// TestTargetEvents verifies target_created and target_destroyed. +func TestTargetEvents(t *testing.T) { + srv := newFakeCDPServer(t) + defer srv.close() + + _, published, mu, cleanup := startMonitorWithFakeServer(t, srv) + defer cleanup() + + // targetCreated → target_created + srv.sendToMonitor(t, map[string]any{ + "method": "Target.targetCreated", + "params": map[string]any{ + "targetInfo": map[string]any{ + "targetId": "target-1", + "type": "page", + "url": "https://new.example.com", + }, + }, + }) + ev := waitForEvent(t, published, mu, "target_created", 2*time.Second) + assert.Equal(t, events.CategoryPage, ev.Category) + assert.Equal(t, events.KindCDP, ev.Source.Kind) + assert.Equal(t, "Target.targetCreated", ev.Source.Event) + var data map[string]any + require.NoError(t, json.Unmarshal(ev.Data, &data)) + assert.Equal(t, "target-1", data["target_id"]) + + // targetDestroyed → target_destroyed + srv.sendToMonitor(t, map[string]any{ + "method": "Target.targetDestroyed", + "params": map[string]any{ + "targetId": "target-1", + }, + }) + ev2 := waitForEvent(t, published, mu, "target_destroyed", 2*time.Second) + assert.Equal(t, events.CategoryPage, ev2.Category) + var data2 map[string]any + require.NoError(t, json.Unmarshal(ev2.Data, &data2)) + assert.Equal(t, "target-1", data2["target_id"]) +} + +// TestBindingAndTimeline verifies that scroll_settled arrives via +// Runtime.bindingCalled and layout_shift arrives via PerformanceTimeline. +func TestBindingAndTimeline(t *testing.T) { + srv := newFakeCDPServer(t) + defer srv.close() + + _, published, mu, cleanup := startMonitorWithFakeServer(t, srv) + defer cleanup() + + // scroll_settled via Runtime.bindingCalled + srv.sendToMonitor(t, map[string]any{ + "method": "Runtime.bindingCalled", + "params": map[string]any{ + "name": "__kernelEvent", + "payload": `{"type":"scroll_settled","from_x":0,"from_y":0,"to_x":0,"to_y":500,"target_selector":"body"}`, + }, + }) + ev := waitForEvent(t, published, mu, "scroll_settled", 2*time.Second) + assert.Equal(t, events.CategoryInteraction, ev.Category) + assert.Equal(t, "Runtime.bindingCalled", ev.Source.Event) + var data map[string]any + require.NoError(t, json.Unmarshal(ev.Data, &data)) + assert.Equal(t, float64(500), data["to_y"]) + + // layout_shift via PerformanceTimeline.timelineEventAdded + srv.sendToMonitor(t, map[string]any{ + "method": "PerformanceTimeline.timelineEventAdded", + "params": map[string]any{ + "event": map[string]any{ + "type": "layout-shift", + }, + }, + }) + ev2 := waitForEvent(t, published, mu, "layout_shift", 2*time.Second) + assert.Equal(t, events.KindCDP, ev2.Source.Kind) + assert.Equal(t, "PerformanceTimeline.timelineEventAdded", ev2.Source.Event) + + noEventWithin(t, published, mu, "console_log", 100*time.Millisecond) +} + +// TestScreenshot verifies rate limiting and the screenshotFn testable seam. +func TestScreenshot(t *testing.T) { + srv := newFakeCDPServer(t) + defer srv.close() + + m, published, mu, cleanup := startMonitorWithFakeServer(t, srv) + defer cleanup() + + // Inject a mock screenshotFn that returns a tiny valid PNG. + var captureCount atomic.Int32 + // 1x1 white PNG (minimal valid PNG bytes) + minimalPNG := []byte{ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR chunk length + type + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // width=1, height=1 + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, // bit depth=8, color type=2, ... + 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, // IDAT chunk + 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, + 0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc, + 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, // IEND chunk + 0x44, 0xae, 0x42, 0x60, 0x82, + } + m.screenshotFn = func(ctx context.Context, displayNum int) ([]byte, error) { + captureCount.Add(1) + return minimalPNG, nil + } + + // First maybeScreenshot call — should capture. + ctx := context.Background() + m.maybeScreenshot(ctx) + // Give the goroutine time to run. + require.Eventually(t, func() bool { + return captureCount.Load() == 1 + }, 2*time.Second, 20*time.Millisecond) + + // Second call immediately after — should be rate-limited (no capture). + m.maybeScreenshot(ctx) + time.Sleep(100 * time.Millisecond) + assert.Equal(t, int32(1), captureCount.Load(), "second call within 2s should be rate-limited") + + // Verify screenshot event was published with png field. + ev := waitForEvent(t, published, mu, "screenshot", 2*time.Second) + assert.Equal(t, events.CategorySystem, ev.Category) + assert.Equal(t, events.KindLocalProcess, ev.Source.Kind) + var data map[string]any + require.NoError(t, json.Unmarshal(ev.Data, &data)) + assert.NotEmpty(t, data["png"]) + + // Fast-forward lastScreenshotAt to simulate 2s+ elapsed. + m.lastScreenshotAt.Store(time.Now().Add(-3 * time.Second).UnixMilli()) + m.maybeScreenshot(ctx) + require.Eventually(t, func() bool { + return captureCount.Load() == 2 + }, 2*time.Second, 20*time.Millisecond) +} + +// --- Computed meta-event tests --- + +// newComputedMonitor creates a Monitor with a capture function and returns +// the published events slice and its mutex for inspection. +func newComputedMonitor(t *testing.T) (*Monitor, *[]events.Event, *sync.Mutex) { + t.Helper() + var mu sync.Mutex + published := make([]events.Event, 0) + publishFn := func(ev events.Event) { + mu.Lock() + published = append(published, ev) + mu.Unlock() + } + upstream := newFakeUpstream("ws://127.0.0.1:0") // not used; no real dial + m := New(upstream, publishFn, 0) + return m, &published, &mu +} + + +// noEventWithin asserts that no event of the given type is published within d. +func noEventWithin(t *testing.T, published *[]events.Event, mu *sync.Mutex, eventType string, d time.Duration) { + t.Helper() + deadline := time.Now().Add(d) + for time.Now().Before(deadline) { + mu.Lock() + for _, ev := range *published { + if ev.Type == eventType { + mu.Unlock() + t.Fatalf("unexpected event %q published", eventType) + } + } + mu.Unlock() + time.Sleep(10 * time.Millisecond) + } +} + +// TestNetworkIdle verifies the 500ms debounce for network_idle. +func TestNetworkIdle(t *testing.T) { + m, published, mu := newComputedMonitor(t) + + // Simulate navigation (resets computed state). + navParams, _ := json.Marshal(map[string]any{ + "frame": map[string]any{"id": "f1", "url": "https://example.com"}, + }) + m.handleFrameNavigated(navParams, "s1") + // Drain the navigation event from published. + + // Helper to send requestWillBeSent. + sendReq := func(id string) { + p, _ := json.Marshal(map[string]any{ + "requestId": id, + "resourceType": "Document", + "request": map[string]any{"method": "GET", "url": "https://example.com/" + id}, + }) + m.handleNetworkRequest(p, "s1") + } + // Helper to send loadingFinished. + sendFinished := func(id string) { + // store minimal state so LoadAndDelete finds it + m.pendReqMu.Lock() + m.pendingRequests[id] = networkReqState{method: "GET", url: "https://example.com/" + id} + m.pendReqMu.Unlock() + p, _ := json.Marshal(map[string]any{"requestId": id}) + m.handleLoadingFinished(p, "s1") + } + + // Send 3 requests, then finish them all. + sendReq("r1") + sendReq("r2") + sendReq("r3") + + t0 := time.Now() + sendFinished("r1") + sendFinished("r2") + sendFinished("r3") + + // network_idle should fire ~500ms after the last loadingFinished. + ev := waitForEvent(t,published, mu, "network_idle", 2*time.Second) + elapsed := time.Since(t0) + assert.GreaterOrEqual(t, elapsed.Milliseconds(), int64(400), "network_idle fired too early") + assert.Equal(t, events.CategoryNetwork, ev.Category) + assert.Equal(t, events.KindCDP, ev.Source.Kind) + assert.Equal(t, "", ev.Source.Event) + + // --- Timer reset test: new request within 500ms resets the clock --- + m2, published2, mu2 := newComputedMonitor(t) + navParams2, _ := json.Marshal(map[string]any{ + "frame": map[string]any{"id": "f1", "url": "https://example.com"}, + }) + m2.handleFrameNavigated(navParams2, "s1") + + sendReq2 := func(id string) { + p, _ := json.Marshal(map[string]any{ + "requestId": id, + "resourceType": "Document", + "request": map[string]any{"method": "GET", "url": "https://example.com/" + id}, + }) + m2.handleNetworkRequest(p, "s1") + } + sendFinished2 := func(id string) { + m2.pendReqMu.Lock() + m2.pendingRequests[id] = networkReqState{method: "GET", url: "https://example.com/" + id} + m2.pendReqMu.Unlock() + p, _ := json.Marshal(map[string]any{"requestId": id}) + m2.handleLoadingFinished(p, "s1") + } + + sendReq2("a1") + sendFinished2("a1") + // 200ms later, a new request starts (timer should reset) + time.Sleep(200 * time.Millisecond) + sendReq2("a2") + t1 := time.Now() + sendFinished2("a2") + + ev2 := waitForEvent(t,published2, mu2, "network_idle", 2*time.Second) + elapsed2 := time.Since(t1) + // Should fire ~500ms after a2 finished, not 500ms after a1 + assert.GreaterOrEqual(t, elapsed2.Milliseconds(), int64(400), "network_idle should reset timer on new request") + assert.Equal(t, events.CategoryNetwork, ev2.Category) +} + +// TestLayoutSettled verifies the 1s debounce for layout_settled. +func TestLayoutSettled(t *testing.T) { + m, published, mu := newComputedMonitor(t) + + // Navigate to reset state. + navParams, _ := json.Marshal(map[string]any{ + "frame": map[string]any{"id": "f1", "url": "https://example.com"}, + }) + m.handleFrameNavigated(navParams, "s1") + + // Simulate page_load (Page.loadEventFired). + // We bypass the ffmpeg screenshot side-effect by keeping screenshotFn nil-safe. + t0 := time.Now() + m.handleLoadEventFired(json.RawMessage(`{}`), "s1") + + // layout_settled should fire ~1s after page_load (no layout shifts). + ev := waitForEvent(t,published, mu, "layout_settled", 3*time.Second) + elapsed := time.Since(t0) + assert.GreaterOrEqual(t, elapsed.Milliseconds(), int64(900), "layout_settled fired too early") + assert.Equal(t, events.CategoryPage, ev.Category) + assert.Equal(t, events.KindCDP, ev.Source.Kind) + assert.Equal(t, "", ev.Source.Event) + + // --- Layout shift resets the timer --- + m2, published2, mu2 := newComputedMonitor(t) + navParams2, _ := json.Marshal(map[string]any{ + "frame": map[string]any{"id": "f1", "url": "https://example.com"}, + }) + m2.handleFrameNavigated(navParams2, "s1") + m2.handleLoadEventFired(json.RawMessage(`{}`), "s1") + + // Simulate a native CDP layout shift at 600ms. + time.Sleep(600 * time.Millisecond) + shiftParams, _ := json.Marshal(map[string]any{ + "event": map[string]any{"type": "layout-shift"}, + }) + m2.handleTimelineEvent(shiftParams, "s1") + t1 := time.Now() + + // layout_settled fires ~1s after the shift, not 1s after page_load. + ev2 := waitForEvent(t,published2, mu2, "layout_settled", 3*time.Second) + elapsed2 := time.Since(t1) + assert.GreaterOrEqual(t, elapsed2.Milliseconds(), int64(900), "layout_settled should reset after layout_shift") + assert.Equal(t, events.CategoryPage, ev2.Category) +} + +// TestScrollSettled verifies that a scroll_settled sentinel from JS is passed through. +func TestScrollSettled(t *testing.T) { + m, published, mu := newComputedMonitor(t) + + // Simulate scroll_settled via Runtime.bindingCalled. + bindingParams, _ := json.Marshal(map[string]any{ + "name": "__kernelEvent", + "payload": `{"type":"scroll_settled"}`, + }) + m.handleBindingCalled(bindingParams, "s1") + + ev := waitForEvent(t,published, mu, "scroll_settled", 1*time.Second) + assert.Equal(t, events.CategoryInteraction, ev.Category) +} + +// TestNavigationSettled verifies the three-flag gate for navigation_settled. +func TestNavigationSettled(t *testing.T) { + m, published, mu := newComputedMonitor(t) + + // Navigate to initialise flags. + navParams, _ := json.Marshal(map[string]any{ + "frame": map[string]any{"id": "f1", "url": "https://example.com"}, + }) + m.handleFrameNavigated(navParams, "s1") + + // Trigger dom_content_loaded. + m.handleDOMContentLoaded(json.RawMessage(`{}`), "s1") + + // Trigger network_idle via load cycle. + reqP, _ := json.Marshal(map[string]any{ + "requestId": "r1", "resourceType": "Document", + "request": map[string]any{"method": "GET", "url": "https://example.com/r1"}, + }) + m.handleNetworkRequest(reqP, "s1") + m.pendReqMu.Lock() + m.pendingRequests["r1"] = networkReqState{method: "GET", url: "https://example.com/r1"} + m.pendReqMu.Unlock() + finP, _ := json.Marshal(map[string]any{"requestId": "r1"}) + m.handleLoadingFinished(finP, "s1") + + // Trigger layout_settled via page_load (1s timer). + m.handleLoadEventFired(json.RawMessage(`{}`), "s1") + + // Wait for navigation_settled (all three flags set). + ev := waitForEvent(t,published, mu, "navigation_settled", 3*time.Second) + assert.Equal(t, events.CategoryPage, ev.Category) + assert.Equal(t, events.KindCDP, ev.Source.Kind) + assert.Equal(t, "", ev.Source.Event) + + // --- Navigation interrupt test --- + m2, published2, mu2 := newComputedMonitor(t) + + navP1, _ := json.Marshal(map[string]any{ + "frame": map[string]any{"id": "f1", "url": "https://example.com"}, + }) + m2.handleFrameNavigated(navP1, "s1") + + // Start sequence: dom_content_loaded + network_idle. + m2.handleDOMContentLoaded(json.RawMessage(`{}`), "s1") + reqP2, _ := json.Marshal(map[string]any{ + "requestId": "r2", "resourceType": "Document", + "request": map[string]any{"method": "GET", "url": "https://example.com/r2"}, + }) + m2.handleNetworkRequest(reqP2, "s1") + m2.pendReqMu.Lock() + m2.pendingRequests["r2"] = networkReqState{method: "GET", url: "https://example.com/r2"} + m2.pendReqMu.Unlock() + finP2, _ := json.Marshal(map[string]any{"requestId": "r2"}) + m2.handleLoadingFinished(finP2, "s1") + + // Interrupt with a new navigation before layout_settled fires. + navP2, _ := json.Marshal(map[string]any{ + "frame": map[string]any{"id": "f1", "url": "https://example.com/page2"}, + }) + m2.handleFrameNavigated(navP2, "s1") + + // navigation_settled should NOT fire for the interrupted sequence. + noEventWithin(t, published2, mu2, "navigation_settled", 1500*time.Millisecond) + _ = mu2 // suppress unused warning +} From 12fe9a0fea47273c517398df133d1a6ccd8e2b40 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Thu, 2 Apr 2026 12:05:28 +0000 Subject: [PATCH 05/13] review: create util.go for helper funcs --- server/lib/cdpmonitor/computed.go | 11 +++- server/lib/cdpmonitor/handlers.go | 85 ++++++++++++++-------------- server/lib/cdpmonitor/monitor.go | 90 ++++++++++++++++++++++++------ server/lib/cdpmonitor/types.go | 1 - server/lib/cdpmonitor/util.go | 92 +++++++++++++++++++++++++++++++ 5 files changed, 213 insertions(+), 66 deletions(-) create mode 100644 server/lib/cdpmonitor/util.go diff --git a/server/lib/cdpmonitor/computed.go b/server/lib/cdpmonitor/computed.go index c753730f..1bbe4573 100644 --- a/server/lib/cdpmonitor/computed.go +++ b/server/lib/cdpmonitor/computed.go @@ -7,6 +7,11 @@ import ( "github.com/onkernel/kernel-images/server/lib/events" ) +const ( + networkIdleDebounce = 500 * time.Millisecond + layoutSettledDebounce = 1 * time.Second +) + // computedState holds the mutable state for all computed meta-events. type computedState struct { mu sync.Mutex @@ -91,7 +96,7 @@ func (s *computedState) onLoadingFinished() { } // All requests done and not yet fired — start 500 ms debounce timer. stopTimer(s.netTimer) - s.netTimer = time.AfterFunc(500*time.Millisecond, func() { + s.netTimer = time.AfterFunc(networkIdleDebounce, func() { s.mu.Lock() defer s.mu.Unlock() if s.netFired || s.netPending > 0 { @@ -121,7 +126,7 @@ func (s *computedState) onPageLoad() { } // Start the 1 s layout_settled timer. stopTimer(s.layoutTimer) - s.layoutTimer = time.AfterFunc(1*time.Second, s.emitLayoutSettled) + s.layoutTimer = time.AfterFunc(layoutSettledDebounce, s.emitLayoutSettled) } // onLayoutShift is called when a layout_shift sentinel arrives from injected JS. @@ -133,7 +138,7 @@ func (s *computedState) onLayoutShift() { } // Reset the timer to 1 s from now. stopTimer(s.layoutTimer) - s.layoutTimer = time.AfterFunc(1*time.Second, s.emitLayoutSettled) + s.layoutTimer = time.AfterFunc(layoutSettledDebounce, s.emitLayoutSettled) } // emitLayoutSettled is called from the layout timer's AfterFunc goroutine diff --git a/server/lib/cdpmonitor/handlers.go b/server/lib/cdpmonitor/handlers.go index 3501f50a..7450dc1c 100644 --- a/server/lib/cdpmonitor/handlers.go +++ b/server/lib/cdpmonitor/handlers.go @@ -3,12 +3,11 @@ package cdpmonitor import ( "encoding/json" "time" - "unicode/utf8" "github.com/onkernel/kernel-images/server/lib/events" ) -// publishEvent stamps common fields and publishes an Event. +// publishEvent stamps common fields and publishes an event. func (m *Monitor) publishEvent(eventType string, source events.Source, sourceEvent string, data json.RawMessage, sessionID string) { src := source src.Event = sourceEvent @@ -103,7 +102,7 @@ func (m *Monitor) handleExceptionThrown(params json.RawMessage, sessionID string go m.maybeScreenshot(m.lifecycleCtx) } -// handleBindingCalled processes __kernelEvent binding calls. +// handleBindingCalled processes __kernelEvent binding calls from the page. func (m *Monitor) handleBindingCalled(params json.RawMessage, sessionID string) { var p struct { Name string `json:"name"` @@ -128,7 +127,7 @@ func (m *Monitor) handleBindingCalled(params json.RawMessage, sessionID string) } } -// handleTimelineEvent processes layout-shift events from PerformanceTimeline. +// handleTimelineEvent processes PerformanceTimeline layout-shift events. func (m *Monitor) handleTimelineEvent(params json.RawMessage, sessionID string) { var p struct { Event struct { @@ -148,6 +147,15 @@ func (m *Monitor) handleNetworkRequest(params json.RawMessage, sessionID string) if err := json.Unmarshal(params, &p); err != nil { return } + // Extract only the initiator type; the stack trace is too verbose and dominates event size. + var initiatorType string + var raw struct { + Type string `json:"type"` + } + if json.Unmarshal(p.Initiator, &raw) == nil { + initiatorType = raw.Type + } + m.pendReqMu.Lock() m.pendingRequests[p.RequestID] = networkReqState{ method: p.Request.Method, @@ -155,16 +163,15 @@ func (m *Monitor) handleNetworkRequest(params json.RawMessage, sessionID string) headers: p.Request.Headers, postData: p.Request.PostData, resourceType: p.ResourceType, - initiator: p.Initiator, } m.pendReqMu.Unlock() data, _ := json.Marshal(map[string]any{ - "method": p.Request.Method, - "url": p.Request.URL, - "headers": p.Request.Headers, - "post_data": p.Request.PostData, - "resource_type": p.ResourceType, - "initiator": p.Initiator, + "method": p.Request.Method, + "url": p.Request.URL, + "headers": p.Request.Headers, + "post_data": p.Request.PostData, + "resource_type": p.ResourceType, + "initiator_type": initiatorType, }) m.publishEvent("network_request", events.Source{Kind: events.KindCDP}, "Network.requestWillBeSent", data, sessionID) m.computed.onRequest() @@ -202,30 +209,33 @@ func (m *Monitor) handleLoadingFinished(params json.RawMessage, sessionID string if !ok { return } - // Fetch response body async to avoid blocking readLoop. + // Fetch response body async to avoid blocking readLoop; binary types are skipped. go func() { ctx := m.lifecycleCtx body := "" - result, err := m.send(ctx, "Network.getResponseBody", map[string]any{ - "requestId": p.RequestID, - }, sessionID) - if err == nil { - var resp struct { - Body string `json:"body"` - Base64Encoded bool `json:"base64Encoded"` - } - if json.Unmarshal(result, &resp) == nil { - body = truncateBody(resp.Body) + if isTextualResource(state.resourceType, state.mimeType) { + result, err := m.send(ctx, "Network.getResponseBody", map[string]any{ + "requestId": p.RequestID, + }, sessionID) + if err == nil { + var resp struct { + Body string `json:"body"` + Base64Encoded bool `json:"base64Encoded"` + } + if json.Unmarshal(result, &resp) == nil { + body = truncateBody(resp.Body, bodyCapFor(state.mimeType)) + } } } data, _ := json.Marshal(map[string]any{ - "method": state.method, - "url": state.url, - "status": state.status, - "status_text": state.statusText, - "headers": state.resHeaders, - "mime_type": state.mimeType, - "body": body, + "method": state.method, + "url": state.url, + "status": state.status, + "status_text": state.statusText, + "headers": state.resHeaders, + "mime_type": state.mimeType, + "resource_type": state.resourceType, + "body": body, }) m.publishEvent("network_response", events.Source{Kind: events.KindCDP}, "Network.loadingFinished", data, sessionID) m.computed.onLoadingFinished() @@ -260,19 +270,6 @@ func (m *Monitor) handleLoadingFailed(params json.RawMessage, sessionID string) m.computed.onLoadingFinished() } -// truncateBody caps body at ~900KB on a valid UTF-8 boundary. -func truncateBody(body string) string { - const maxBody = 900 * 1024 - if len(body) <= maxBody { - return body - } - // Back up to a valid rune boundary. - truncated := body[:maxBody] - for !utf8.ValidString(truncated) { - truncated = truncated[:len(truncated)-1] - } - return truncated -} func (m *Monitor) handleFrameNavigated(params json.RawMessage, sessionID string) { var p struct { @@ -314,7 +311,7 @@ func (m *Monitor) handleDOMUpdated(params json.RawMessage, sessionID string) { m.publishEvent("dom_updated", events.Source{Kind: events.KindCDP}, "DOM.documentUpdated", params, sessionID) } -// handleAttachedToTarget stores the session and enables domains + injects script. +// handleAttachedToTarget stores the new session then enables domains and injects script. func (m *Monitor) handleAttachedToTarget(msg cdpMessage) { var params cdpAttachedToTargetParams if err := json.Unmarshal(msg.Params, ¶ms); err != nil { @@ -328,7 +325,7 @@ func (m *Monitor) handleAttachedToTarget(msg cdpMessage) { } m.sessionsMu.Unlock() - // Async to avoid blocking readLoop. + // Async to avoid blocking the readLoop. go func() { m.enableDomains(m.lifecycleCtx, params.SessionID) _ = m.injectScript(m.lifecycleCtx, params.SessionID) diff --git a/server/lib/cdpmonitor/monitor.go b/server/lib/cdpmonitor/monitor.go index 886e5946..3151375d 100644 --- a/server/lib/cdpmonitor/monitor.go +++ b/server/lib/cdpmonitor/monitor.go @@ -21,7 +21,11 @@ type UpstreamProvider interface { // PublishFunc publishes an Event to the pipeline. type PublishFunc func(ev events.Event) +const wsReadLimit = 8 * 1024 * 1024 + // Monitor manages a CDP WebSocket connection with auto-attach session fan-out. +// Reusable: Stop followed by Start reconnects cleanly. All exported methods are +// safe to call concurrently. Stop blocks until the read goroutine exits. type Monitor struct { upstreamMgr UpstreamProvider publish PublishFunc @@ -83,17 +87,20 @@ func (m *Monitor) Start(parentCtx context.Context) error { return fmt.Errorf("cdpmonitor: no DevTools URL available") } - conn, _, err := websocket.Dial(parentCtx, devtoolsURL, nil) + // Use background context so the monitor outlives the caller's request context. + ctx, cancel := context.WithCancel(context.Background()) + + conn, _, err := websocket.Dial(ctx, devtoolsURL, nil) if err != nil { + cancel() return fmt.Errorf("cdpmonitor: dial %s: %w", devtoolsURL, err) } - conn.SetReadLimit(8 * 1024 * 1024) + conn.SetReadLimit(wsReadLimit) m.connMu.Lock() m.conn = conn m.connMu.Unlock() - ctx, cancel := context.WithCancel(parentCtx) m.lifecycleCtx = ctx m.cancel = cancel m.done = make(chan struct{}) @@ -136,19 +143,18 @@ func (m *Monitor) Stop() { m.computed.resetOnNavigation() } -// readLoop reads CDP messages, routing responses to pending callers and -// dispatching events. Exits on connection close; respawned on reconnect. +// readLoop reads CDP messages, routing responses to pending callers and dispatching events. func (m *Monitor) readLoop(ctx context.Context) { defer close(m.done) - for { - m.connMu.Lock() - conn := m.conn - m.connMu.Unlock() - if conn == nil { - return - } + m.connMu.Lock() + conn := m.conn + m.connMu.Unlock() + if conn == nil { + return + } + for { _, b, err := conn.Read(ctx) if err != nil { return @@ -227,8 +233,8 @@ func (m *Monitor) send(ctx context.Context, method string, params any, sessionID } } -// initSession enables CDP domains and injects the interaction-tracking script -// on a fresh connection (called async). +// initSession enables CDP domains, injects the interaction-tracking script, +// and manually attaches to any targets already open when the monitor started. func (m *Monitor) initSession(ctx context.Context) { _, _ = m.send(ctx, "Target.setAutoAttach", map[string]any{ "autoAttach": true, @@ -237,17 +243,65 @@ func (m *Monitor) initSession(ctx context.Context) { }, "") m.enableDomains(ctx, "") _ = m.injectScript(ctx, "") + m.attachExistingTargets(ctx) +} + +// attachExistingTargets fetches all open targets and attaches to any that are +// not already tracked. This catches pages that were open before Start() was called. +func (m *Monitor) attachExistingTargets(ctx context.Context) { + result, err := m.send(ctx, "Target.getTargets", nil, "") + if err != nil { + return + } + var resp struct { + TargetInfos []cdpTargetInfo `json:"targetInfos"` + } + if err := json.Unmarshal(result, &resp); err != nil { + return + } + for _, ti := range resp.TargetInfos { + if ti.Type != "page" { + continue + } + m.sessionsMu.RLock() + alreadyAttached := false + for _, info := range m.sessions { + if info.targetID == ti.TargetID { + alreadyAttached = true + break + } + } + m.sessionsMu.RUnlock() + if alreadyAttached { + continue + } + go func(targetID string) { + res, err := m.send(ctx, "Target.attachToTarget", map[string]any{ + "targetId": targetID, + "flatten": true, + }, "") + if err != nil { + return + } + var attached struct { + SessionID string `json:"sessionId"` + } + if json.Unmarshal(res, &attached) == nil && attached.SessionID != "" { + m.enableDomains(ctx, attached.SessionID) + _ = m.injectScript(ctx, attached.SessionID) + } + }(ti.TargetID) + } } -// restartReadLoop waits for the old readLoop to exit, then spawns a new one. +// restartReadLoop waits for the current readLoop to exit, then starts a new one. func (m *Monitor) restartReadLoop(ctx context.Context) { <-m.done m.done = make(chan struct{}) go m.readLoop(ctx) } -// subscribeToUpstream reconnects with backoff on Chrome restarts, emitting -// monitor_disconnected / monitor_reconnected events. +// subscribeToUpstream reconnects with backoff on Chrome restarts, publishing disconnect/reconnect events. func (m *Monitor) subscribeToUpstream(ctx context.Context) { ch, cancel := m.upstreamMgr.Subscribe() defer cancel() @@ -303,7 +357,7 @@ func (m *Monitor) subscribeToUpstream(ctx context.Context) { reconnErr = err continue } - conn.SetReadLimit(8 * 1024 * 1024) + conn.SetReadLimit(wsReadLimit) m.connMu.Lock() m.conn = conn diff --git a/server/lib/cdpmonitor/types.go b/server/lib/cdpmonitor/types.go index f53e733b..c61c3335 100644 --- a/server/lib/cdpmonitor/types.go +++ b/server/lib/cdpmonitor/types.go @@ -39,7 +39,6 @@ type networkReqState struct { headers json.RawMessage postData string resourceType string - initiator json.RawMessage status int statusText string resHeaders json.RawMessage diff --git a/server/lib/cdpmonitor/util.go b/server/lib/cdpmonitor/util.go new file mode 100644 index 00000000..5c29fad9 --- /dev/null +++ b/server/lib/cdpmonitor/util.go @@ -0,0 +1,92 @@ +package cdpmonitor + +import ( + "slices" + "strings" + "unicode/utf8" +) + +// isTextualResource reports whether the resource warrants body capture. +// resourceType is checked first; mimeType is a fallback for resources with no type (e.g. in-flight at attach time). +func isTextualResource(resourceType, mimeType string) bool { + switch resourceType { + case "Font", "Image", "Media": + return false + } + return isCapturedMIME(mimeType) +} + +// isCapturedMIME returns true for MIME types whose bodies are worth capturing. +// Binary formats (vendor types, binary encodings, raw streams) are excluded. +func isCapturedMIME(mime string) bool { + if mime == "" { + return true // unknown, capture conservatively + } + for _, prefix := range []string{"image/", "font/", "audio/", "video/"} { + if strings.HasPrefix(mime, prefix) { + return false + } + } + if slices.Contains([]string{ + "application/octet-stream", + "application/wasm", + "application/pdf", + "application/zip", + "application/gzip", + "application/x-protobuf", + "application/x-msgpack", + "application/x-thrift", + }, mime) { + return false + } + // Skip vendor binary formats; allow vnd types with text-based suffixes (+json, +xml, +csv). + if sub, ok := strings.CutPrefix(mime, "application/vnd."); ok { + for _, textSuffix := range []string{"+json", "+xml", "+csv"} { + if strings.HasSuffix(sub, textSuffix) { + return true + } + } + return false + } + return true +} + +// bodyCapFor returns the max body capture size for a MIME type. +// Structured data (JSON, XML, form data) gets 900 KB; everything else gets 10 KB. +func bodyCapFor(mime string) int { + const fullCap = 900 * 1024 + const contextCap = 10 * 1024 + structuredPrefixes := []string{ + "application/json", + "application/xml", + "application/x-www-form-urlencoded", + "application/graphql", + "text/xml", + "text/csv", + } + for _, p := range structuredPrefixes { + if strings.HasPrefix(mime, p) { + return fullCap + } + } + // vnd types with +json/+xml suffix are treated as structured. + for _, suffix := range []string{"+json", "+xml"} { + if strings.HasSuffix(mime, suffix) { + return fullCap + } + } + return contextCap +} + +// truncateBody caps body at the given limit on a valid UTF-8 boundary. +func truncateBody(body string, maxBody int) string { + if len(body) <= maxBody { + return body + } + // Walk back at most UTFMax bytes to find a clean rune boundary. + i := maxBody + for i > maxBody-utf8.UTFMax && !utf8.RuneStart(body[i]) { + i-- + } + return body[:i] +} From 3cdc4b7a24fe9aa38ad70fe218e16ad6a47a2bae Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Thu, 2 Apr 2026 13:31:17 +0000 Subject: [PATCH 06/13] review --- server/lib/cdpmonitor/domains.go | 2 + server/lib/cdpmonitor/handlers.go | 61 +++++++++++++-------- server/lib/cdpmonitor/monitor.go | 83 ++++++++++++++++++++--------- server/lib/cdpmonitor/screenshot.go | 2 +- server/lib/cdpmonitor/types.go | 6 ++- server/lib/cdpmonitor/util.go | 21 +++++++- 6 files changed, 124 insertions(+), 51 deletions(-) diff --git a/server/lib/cdpmonitor/domains.go b/server/lib/cdpmonitor/domains.go index f32932c6..1e95e0b3 100644 --- a/server/lib/cdpmonitor/domains.go +++ b/server/lib/cdpmonitor/domains.go @@ -30,8 +30,10 @@ func (m *Monitor) enableDomains(ctx context.Context, sessionID string) { // injectedJS tracks clicks, keys, and scrolls via the __kernelEvent binding. // Layout shifts are handled natively by PerformanceTimeline.enable. const injectedJS = `(function() { + if (window.__kernelEventInjected) return; var send = window.__kernelEvent; if (!send) return; + window.__kernelEventInjected = true; function sel(el) { return el.id ? '#' + el.id : (el.className ? '.' + String(el.className).split(' ')[0] : ''); diff --git a/server/lib/cdpmonitor/handlers.go b/server/lib/cdpmonitor/handlers.go index 7450dc1c..35664993 100644 --- a/server/lib/cdpmonitor/handlers.go +++ b/server/lib/cdpmonitor/handlers.go @@ -8,7 +8,7 @@ import ( ) // publishEvent stamps common fields and publishes an event. -func (m *Monitor) publishEvent(eventType string, source events.Source, sourceEvent string, data json.RawMessage, sessionID string) { +func (m *Monitor) publishEvent(eventType string, detail events.DetailLevel, source events.Source, sourceEvent string, data json.RawMessage, sessionID string) { src := source src.Event = sourceEvent if sessionID != "" { @@ -17,12 +17,14 @@ func (m *Monitor) publishEvent(eventType string, source events.Source, sourceEve } src.Metadata["cdp_session_id"] = sessionID } + url, _ := m.currentURL.Load().(string) m.publish(events.Event{ Ts: time.Now().UnixMilli(), Type: eventType, Category: events.CategoryFor(eventType), Source: src, - DetailLevel: events.DetailStandard, + DetailLevel: detail, + URL: url, Data: data, }) } @@ -71,11 +73,11 @@ func (m *Monitor) handleConsole(params json.RawMessage, sessionID string) { text := "" if len(p.Args) > 0 { - text = p.Args[0].Value + text = consoleArgString(p.Args[0]) } argValues := make([]string, 0, len(p.Args)) for _, a := range p.Args { - argValues = append(argValues, a.Value) + argValues = append(argValues, consoleArgString(a)) } data, _ := json.Marshal(map[string]any{ "level": p.Type, @@ -83,7 +85,7 @@ func (m *Monitor) handleConsole(params json.RawMessage, sessionID string) { "args": argValues, "stack_trace": p.StackTrace, }) - m.publishEvent("console_log", events.Source{Kind: events.KindCDP}, "Runtime.consoleAPICalled", data, sessionID) + m.publishEvent("console_log", events.DetailStandard, events.Source{Kind: events.KindCDP}, "Runtime.consoleAPICalled", data, sessionID) } func (m *Monitor) handleExceptionThrown(params json.RawMessage, sessionID string) { @@ -98,8 +100,8 @@ func (m *Monitor) handleExceptionThrown(params json.RawMessage, sessionID string "url": p.ExceptionDetails.URL, "stack_trace": p.ExceptionDetails.StackTrace, }) - m.publishEvent("console_error", events.Source{Kind: events.KindCDP}, "Runtime.exceptionThrown", data, sessionID) - go m.maybeScreenshot(m.lifecycleCtx) + m.publishEvent("console_error", events.DetailStandard, events.Source{Kind: events.KindCDP}, "Runtime.exceptionThrown", data, sessionID) + go m.maybeScreenshot(m.getLifecycleCtx()) } // handleBindingCalled processes __kernelEvent binding calls from the page. @@ -123,7 +125,7 @@ func (m *Monitor) handleBindingCalled(params json.RawMessage, sessionID string) } switch header.Type { case "interaction_click", "interaction_key", "scroll_settled": - m.publishEvent(header.Type, events.Source{Kind: events.KindCDP}, "Runtime.bindingCalled", payload, sessionID) + m.publishEvent(header.Type, events.DetailStandard, events.Source{Kind: events.KindCDP}, "Runtime.bindingCalled", payload, sessionID) } } @@ -138,7 +140,7 @@ func (m *Monitor) handleTimelineEvent(params json.RawMessage, sessionID string) if err := json.Unmarshal(params, &p); err != nil || p.Event.Type != "layout-shift" { return } - m.publishEvent("layout_shift", events.Source{Kind: events.KindCDP}, "PerformanceTimeline.timelineEventAdded", params, sessionID) + m.publishEvent("layout_shift", events.DetailStandard, events.Source{Kind: events.KindCDP}, "PerformanceTimeline.timelineEventAdded", params, sessionID) m.computed.onLayoutShift() } @@ -158,6 +160,7 @@ func (m *Monitor) handleNetworkRequest(params json.RawMessage, sessionID string) m.pendReqMu.Lock() m.pendingRequests[p.RequestID] = networkReqState{ + sessionID: sessionID, method: p.Request.Method, url: p.Request.URL, headers: p.Request.Headers, @@ -173,7 +176,7 @@ func (m *Monitor) handleNetworkRequest(params json.RawMessage, sessionID string) "resource_type": p.ResourceType, "initiator_type": initiatorType, }) - m.publishEvent("network_request", events.Source{Kind: events.KindCDP}, "Network.requestWillBeSent", data, sessionID) + m.publishEvent("network_request", events.DetailStandard, events.Source{Kind: events.KindCDP}, "Network.requestWillBeSent", data, sessionID) m.computed.onRequest() } @@ -211,7 +214,7 @@ func (m *Monitor) handleLoadingFinished(params json.RawMessage, sessionID string } // Fetch response body async to avoid blocking readLoop; binary types are skipped. go func() { - ctx := m.lifecycleCtx + ctx := m.getLifecycleCtx() body := "" if isTextualResource(state.resourceType, state.mimeType) { result, err := m.send(ctx, "Network.getResponseBody", map[string]any{ @@ -237,7 +240,11 @@ func (m *Monitor) handleLoadingFinished(params json.RawMessage, sessionID string "resource_type": state.resourceType, "body": body, }) - m.publishEvent("network_response", events.Source{Kind: events.KindCDP}, "Network.loadingFinished", data, sessionID) + detail := events.DetailStandard + if body != "" { + detail = events.DetailVerbose + } + m.publishEvent("network_response", detail, events.Source{Kind: events.KindCDP}, "Network.loadingFinished", data, sessionID) m.computed.onLoadingFinished() }() } @@ -266,7 +273,7 @@ func (m *Monitor) handleLoadingFailed(params json.RawMessage, sessionID string) ev["url"] = state.url } data, _ := json.Marshal(ev) - m.publishEvent("network_loading_failed", events.Source{Kind: events.KindCDP}, "Network.loadingFailed", data, sessionID) + m.publishEvent("network_loading_failed", events.DetailStandard, events.Source{Kind: events.KindCDP}, "Network.loadingFailed", data, sessionID) m.computed.onLoadingFinished() } @@ -287,28 +294,36 @@ func (m *Monitor) handleFrameNavigated(params json.RawMessage, sessionID string) "frame_id": p.Frame.ID, "parent_frame_id": p.Frame.ParentID, }) - m.publishEvent("navigation", events.Source{Kind: events.KindCDP}, "Page.frameNavigated", data, sessionID) + // Only track top-level frame navigations (no parent). + if p.Frame.ParentID == "" { + m.currentURL.Store(p.Frame.URL) + } + m.publishEvent("navigation", events.DetailStandard, events.Source{Kind: events.KindCDP}, "Page.frameNavigated", data, sessionID) m.pendReqMu.Lock() - clear(m.pendingRequests) + for id, req := range m.pendingRequests { + if req.sessionID == sessionID { + delete(m.pendingRequests, id) + } + } m.pendReqMu.Unlock() m.computed.resetOnNavigation() } func (m *Monitor) handleDOMContentLoaded(params json.RawMessage, sessionID string) { - m.publishEvent("dom_content_loaded", events.Source{Kind: events.KindCDP}, "Page.domContentEventFired", params, sessionID) + m.publishEvent("dom_content_loaded", events.DetailMinimal, events.Source{Kind: events.KindCDP}, "Page.domContentEventFired", params, sessionID) m.computed.onDOMContentLoaded() } func (m *Monitor) handleLoadEventFired(params json.RawMessage, sessionID string) { - m.publishEvent("page_load", events.Source{Kind: events.KindCDP}, "Page.loadEventFired", params, sessionID) + m.publishEvent("page_load", events.DetailMinimal, events.Source{Kind: events.KindCDP}, "Page.loadEventFired", params, sessionID) m.computed.onPageLoad() - go m.maybeScreenshot(m.lifecycleCtx) + go m.maybeScreenshot(m.getLifecycleCtx()) } func (m *Monitor) handleDOMUpdated(params json.RawMessage, sessionID string) { - m.publishEvent("dom_updated", events.Source{Kind: events.KindCDP}, "DOM.documentUpdated", params, sessionID) + m.publishEvent("dom_updated", events.DetailMinimal, events.Source{Kind: events.KindCDP}, "DOM.documentUpdated", params, sessionID) } // handleAttachedToTarget stores the new session then enables domains and injects script. @@ -327,8 +342,8 @@ func (m *Monitor) handleAttachedToTarget(msg cdpMessage) { // Async to avoid blocking the readLoop. go func() { - m.enableDomains(m.lifecycleCtx, params.SessionID) - _ = m.injectScript(m.lifecycleCtx, params.SessionID) + m.enableDomains(m.getLifecycleCtx(), params.SessionID) + _ = m.injectScript(m.getLifecycleCtx(), params.SessionID) }() } @@ -342,7 +357,7 @@ func (m *Monitor) handleTargetCreated(params json.RawMessage, sessionID string) "target_type": p.TargetInfo.Type, "url": p.TargetInfo.URL, }) - m.publishEvent("target_created", events.Source{Kind: events.KindCDP}, "Target.targetCreated", data, sessionID) + m.publishEvent("target_created", events.DetailMinimal, events.Source{Kind: events.KindCDP}, "Target.targetCreated", data, sessionID) } func (m *Monitor) handleTargetDestroyed(params json.RawMessage, sessionID string) { @@ -355,5 +370,5 @@ func (m *Monitor) handleTargetDestroyed(params json.RawMessage, sessionID string data, _ := json.Marshal(map[string]any{ "target_id": p.TargetID, }) - m.publishEvent("target_destroyed", events.Source{Kind: events.KindCDP}, "Target.targetDestroyed", data, sessionID) + m.publishEvent("target_destroyed", events.DetailMinimal, events.Source{Kind: events.KindCDP}, "Target.targetDestroyed", data, sessionID) } diff --git a/server/lib/cdpmonitor/monitor.go b/server/lib/cdpmonitor/monitor.go index 3151375d..4422c8a4 100644 --- a/server/lib/cdpmonitor/monitor.go +++ b/server/lib/cdpmonitor/monitor.go @@ -24,19 +24,19 @@ type PublishFunc func(ev events.Event) const wsReadLimit = 8 * 1024 * 1024 // Monitor manages a CDP WebSocket connection with auto-attach session fan-out. -// Reusable: Stop followed by Start reconnects cleanly. All exported methods are -// safe to call concurrently. Stop blocks until the read goroutine exits. type Monitor struct { upstreamMgr UpstreamProvider publish PublishFunc displayNum int + // lifeMu serializes Start, Stop, and restartReadLoop to prevent races on + // conn, lifecycleCtx, cancel, and done. + lifeMu sync.Mutex conn *websocket.Conn - connMu sync.Mutex - nextID atomic.Int64 - pendMu sync.Mutex - pending map[int64]chan cdpMessage + nextID atomic.Int64 + pendMu sync.Mutex + pending map[int64]chan cdpMessage sessionsMu sync.RWMutex sessions map[string]targetInfo // sessionID → targetInfo @@ -44,6 +44,8 @@ type Monitor struct { pendReqMu sync.Mutex pendingRequests map[string]networkReqState // requestId → networkReqState + currentURL atomic.Value // last URL from Page.frameNavigated + computed *computedState lastScreenshotAt atomic.Int64 // unix millis of last capture @@ -76,6 +78,14 @@ func (m *Monitor) IsRunning() bool { return m.running.Load() } +// getLifecycleCtx returns the current lifecycle context under lifeMu. +func (m *Monitor) getLifecycleCtx() context.Context { + m.lifeMu.Lock() + ctx := m.lifecycleCtx + m.lifeMu.Unlock() + return ctx +} + // Start begins CDP capture. Restarts if already running. func (m *Monitor) Start(parentCtx context.Context) error { if m.running.Load() { @@ -97,13 +107,12 @@ func (m *Monitor) Start(parentCtx context.Context) error { } conn.SetReadLimit(wsReadLimit) - m.connMu.Lock() + m.lifeMu.Lock() m.conn = conn - m.connMu.Unlock() - m.lifecycleCtx = ctx m.cancel = cancel m.done = make(chan struct{}) + m.lifeMu.Unlock() m.running.Store(true) @@ -119,18 +128,31 @@ func (m *Monitor) Stop() { if !m.running.Swap(false) { return } + + m.lifeMu.Lock() if m.cancel != nil { m.cancel() } - if m.done != nil { - <-m.done + done := m.done + m.lifeMu.Unlock() + + if done != nil { + <-done } - m.connMu.Lock() + + m.lifeMu.Lock() if m.conn != nil { _ = m.conn.Close(websocket.StatusNormalClosure, "stopped") m.conn = nil } - m.connMu.Unlock() + m.lifeMu.Unlock() + + m.clearState() +} + +// clearState resets sessions, pending requests, and computed state. +func (m *Monitor) clearState() { + m.currentURL.Store("") m.sessionsMu.Lock() m.sessions = make(map[string]targetInfo) @@ -145,11 +167,12 @@ func (m *Monitor) Stop() { // readLoop reads CDP messages, routing responses to pending callers and dispatching events. func (m *Monitor) readLoop(ctx context.Context) { - defer close(m.done) - - m.connMu.Lock() + m.lifeMu.Lock() + done := m.done conn := m.conn - m.connMu.Unlock() + m.lifeMu.Unlock() + defer close(done) + if conn == nil { return } @@ -211,13 +234,14 @@ func (m *Monitor) send(ctx context.Context, method string, params any, sessionID m.pendMu.Unlock() }() - m.connMu.Lock() + m.lifeMu.Lock() conn := m.conn - m.connMu.Unlock() + m.lifeMu.Unlock() if conn == nil { return nil, fmt.Errorf("cdpmonitor: connection not open") } + // coder/websocket allows concurrent Read + Write on the same Conn. if err := conn.Write(ctx, websocket.MessageText, reqBytes); err != nil { return nil, fmt.Errorf("write: %w", err) } @@ -296,8 +320,16 @@ func (m *Monitor) attachExistingTargets(ctx context.Context) { // restartReadLoop waits for the current readLoop to exit, then starts a new one. func (m *Monitor) restartReadLoop(ctx context.Context) { - <-m.done + m.lifeMu.Lock() + done := m.done + m.lifeMu.Unlock() + + <-done + + m.lifeMu.Lock() m.done = make(chan struct{}) + m.lifeMu.Unlock() + go m.readLoop(ctx) } @@ -332,12 +364,15 @@ func (m *Monitor) subscribeToUpstream(ctx context.Context) { startReconnect := time.Now() - m.connMu.Lock() + m.lifeMu.Lock() if m.conn != nil { _ = m.conn.Close(websocket.StatusNormalClosure, "reconnecting") m.conn = nil } - m.connMu.Unlock() + m.lifeMu.Unlock() + + // Clear stale state from the previous Chrome instance. + m.clearState() var reconnErr error for attempt := range 10 { @@ -359,9 +394,9 @@ func (m *Monitor) subscribeToUpstream(ctx context.Context) { } conn.SetReadLimit(wsReadLimit) - m.connMu.Lock() + m.lifeMu.Lock() m.conn = conn - m.connMu.Unlock() + m.lifeMu.Unlock() reconnErr = nil break diff --git a/server/lib/cdpmonitor/screenshot.go b/server/lib/cdpmonitor/screenshot.go index 54b7b985..abb559d2 100644 --- a/server/lib/cdpmonitor/screenshot.go +++ b/server/lib/cdpmonitor/screenshot.go @@ -52,7 +52,7 @@ func (m *Monitor) captureScreenshot(ctx context.Context) { } encoded := base64.StdEncoding.EncodeToString(pngBytes) - data := json.RawMessage(fmt.Sprintf(`{"png":%q}`, encoded)) + data, _ := json.Marshal(map[string]string{"png": encoded}) m.publish(events.Event{ Ts: time.Now().UnixMilli(), diff --git a/server/lib/cdpmonitor/types.go b/server/lib/cdpmonitor/types.go index c61c3335..9beab2bf 100644 --- a/server/lib/cdpmonitor/types.go +++ b/server/lib/cdpmonitor/types.go @@ -34,6 +34,7 @@ type cdpMessage struct { // networkReqState holds request + response metadata until loadingFinished. type networkReqState struct { + sessionID string method string url string headers json.RawMessage @@ -46,9 +47,10 @@ type networkReqState struct { } // cdpConsoleArg is a single Runtime.consoleAPICalled argument. +// Value is json.RawMessage because CDP sends strings, numbers, objects, etc. type cdpConsoleArg struct { - Type string `json:"type"` - Value string `json:"value"` + Type string `json:"type"` + Value json.RawMessage `json:"value,omitempty"` } // cdpConsoleParams is the shape of Runtime.consoleAPICalled params. diff --git a/server/lib/cdpmonitor/util.go b/server/lib/cdpmonitor/util.go index 5c29fad9..5dae2fce 100644 --- a/server/lib/cdpmonitor/util.go +++ b/server/lib/cdpmonitor/util.go @@ -1,11 +1,27 @@ package cdpmonitor import ( + "encoding/json" "slices" "strings" "unicode/utf8" ) +// consoleArgString extracts a display string from a CDP console argument. +// For strings it unquotes the JSON value; for other types it returns the raw JSON. +func consoleArgString(a cdpConsoleArg) string { + if len(a.Value) == 0 { + return a.Type // e.g. "undefined", "null" + } + if a.Type == "string" { + var s string + if json.Unmarshal(a.Value, &s) == nil { + return s + } + } + return string(a.Value) +} + // isTextualResource reports whether the resource warrants body capture. // resourceType is checked first; mimeType is a fallback for resources with no type (e.g. in-flight at attach time). func isTextualResource(resourceType, mimeType string) bool { @@ -20,7 +36,7 @@ func isTextualResource(resourceType, mimeType string) bool { // Binary formats (vendor types, binary encodings, raw streams) are excluded. func isCapturedMIME(mime string) bool { if mime == "" { - return true // unknown, capture conservatively + return false // unknown } for _, prefix := range []string{"image/", "font/", "audio/", "video/"} { if strings.HasPrefix(mime, prefix) { @@ -83,6 +99,9 @@ func truncateBody(body string, maxBody int) string { if len(body) <= maxBody { return body } + if maxBody <= utf8.UTFMax { + return body[:maxBody] + } // Walk back at most UTFMax bytes to find a clean rune boundary. i := maxBody for i > maxBody-utf8.UTFMax && !utf8.RuneStart(body[i]) { From a015c77d708bf32883017576539482dc67370745 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Thu, 2 Apr 2026 14:02:47 +0000 Subject: [PATCH 07/13] review: update test --- server/lib/cdpmonitor/monitor_test.go | 1414 ++++++++++++------------- 1 file changed, 651 insertions(+), 763 deletions(-) diff --git a/server/lib/cdpmonitor/monitor_test.go b/server/lib/cdpmonitor/monitor_test.go index d16104f1..8f793340 100644 --- a/server/lib/cdpmonitor/monitor_test.go +++ b/server/lib/cdpmonitor/monitor_test.go @@ -18,20 +18,27 @@ import ( "github.com/stretchr/testify/require" ) +// --------------------------------------------------------------------------- +// Test infrastructure +// --------------------------------------------------------------------------- + // fakeCDPServer is a minimal WebSocket server that accepts connections and // lets the test drive scripted message sequences. type fakeCDPServer struct { srv *httptest.Server conn *websocket.Conn connMu sync.Mutex - msgCh chan []byte // inbound messages from Monitor + connCh chan struct{} // closed when the first connection is accepted + msgCh chan []byte // inbound messages from Monitor } func newFakeCDPServer(t *testing.T) *fakeCDPServer { t.Helper() f := &fakeCDPServer{ - msgCh: make(chan []byte, 128), + msgCh: make(chan []byte, 128), + connCh: make(chan struct{}), } + var connOnce sync.Once f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { c, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true}) if err != nil { @@ -40,7 +47,7 @@ func newFakeCDPServer(t *testing.T) *fakeCDPServer { f.connMu.Lock() f.conn = c f.connMu.Unlock() - // drain messages from Monitor into msgCh until connection closes + connOnce.Do(func() { close(f.connCh) }) go func() { for { _, b, err := c.Read(context.Background()) @@ -54,23 +61,19 @@ func newFakeCDPServer(t *testing.T) *fakeCDPServer { return f } -// wsURL returns a ws:// URL pointing at the fake server. func (f *fakeCDPServer) wsURL() string { return "ws" + strings.TrimPrefix(f.srv.URL, "http") } -// sendToMonitor pushes a raw JSON message to the Monitor's readLoop. func (f *fakeCDPServer) sendToMonitor(t *testing.T, msg any) { t.Helper() f.connMu.Lock() c := f.conn f.connMu.Unlock() require.NotNil(t, c, "no active connection") - err := wsjson.Write(context.Background(), c, msg) - require.NoError(t, err) + require.NoError(t, wsjson.Write(context.Background(), c, msg)) } -// readFromMonitor blocks until the Monitor sends a message (with timeout). func (f *fakeCDPServer) readFromMonitor(t *testing.T, timeout time.Duration) cdpMessage { t.Helper() select { @@ -129,7 +132,6 @@ func (f *fakeUpstream) Subscribe() (<-chan string, func()) { return ch, cancel } -// notifyRestart simulates Chrome restarting with a new DevTools URL. func (f *fakeUpstream) notifyRestart(newURL string) { f.mu.Lock() f.current = newURL @@ -144,57 +146,217 @@ func (f *fakeUpstream) notifyRestart(newURL string) { } } -// --- Tests --- +// eventCollector captures published events with channel-based notification. +type eventCollector struct { + mu sync.Mutex + events []events.Event + notify chan struct{} // signaled on every publish +} -// TestMonitorStart verifies that Monitor.Start() dials the URL from -// UpstreamProvider.Current() and establishes an isolated WebSocket connection. -func TestMonitorStart(t *testing.T) { - srv := newFakeCDPServer(t) - defer srv.close() +func newEventCollector() *eventCollector { + return &eventCollector{notify: make(chan struct{}, 256)} +} + +func (c *eventCollector) publishFn() PublishFunc { + return func(ev events.Event) { + c.mu.Lock() + c.events = append(c.events, ev) + c.mu.Unlock() + select { + case c.notify <- struct{}{}: + default: + } + } +} + +// waitFor blocks until an event of the given type is published, or fails. +func (c *eventCollector) waitFor(t *testing.T, eventType string, timeout time.Duration) events.Event { + t.Helper() + deadline := time.After(timeout) + for { + c.mu.Lock() + for _, ev := range c.events { + if ev.Type == eventType { + c.mu.Unlock() + return ev + } + } + c.mu.Unlock() + select { + case <-c.notify: + case <-deadline: + t.Fatalf("timeout waiting for event type=%q", eventType) + return events.Event{} + } + } +} + +// waitForNew blocks until a NEW event of the given type is published after this +// call, ignoring any events already in the collector. +func (c *eventCollector) waitForNew(t *testing.T, eventType string, timeout time.Duration) events.Event { + t.Helper() + c.mu.Lock() + skip := len(c.events) + c.mu.Unlock() + + deadline := time.After(timeout) + for { + c.mu.Lock() + for i := skip; i < len(c.events); i++ { + if c.events[i].Type == eventType { + ev := c.events[i] + c.mu.Unlock() + return ev + } + } + c.mu.Unlock() + select { + case <-c.notify: + case <-deadline: + t.Fatalf("timeout waiting for new event type=%q", eventType) + return events.Event{} + } + } +} + +// assertNone verifies that no event of the given type arrives within d. +func (c *eventCollector) assertNone(t *testing.T, eventType string, d time.Duration) { + t.Helper() + deadline := time.After(d) + for { + select { + case <-c.notify: + c.mu.Lock() + for _, ev := range c.events { + if ev.Type == eventType { + c.mu.Unlock() + t.Fatalf("unexpected event %q published", eventType) + return + } + } + c.mu.Unlock() + case <-deadline: + return + } + } +} + + +// ResponderFunc is called for each CDP command the Monitor sends. +// Return nil to use the default empty result. +type ResponderFunc func(msg cdpMessage) any + +// listenAndRespond drains srv.msgCh, calls fn for each command, and sends the +// response. If fn is nil or returns nil, sends {"id": msg.ID, "result": {}}. +func listenAndRespond(srv *fakeCDPServer, stopCh <-chan struct{}, fn ResponderFunc) { + for { + select { + case b := <-srv.msgCh: + var msg cdpMessage + if json.Unmarshal(b, &msg) != nil || msg.ID == 0 { + continue + } + srv.connMu.Lock() + c := srv.conn + srv.connMu.Unlock() + if c == nil { + continue + } + var resp any + if fn != nil { + resp = fn(msg) + } + if resp == nil { + resp = map[string]any{"id": msg.ID, "result": map[string]any{}} + } + _ = wsjson.Write(context.Background(), c, resp) + case <-stopCh: + return + } + } +} +// startMonitor creates a Monitor against srv, starts it, waits for the +// connection, and launches a responder goroutine. Returns cleanup func. +func startMonitor(t *testing.T, srv *fakeCDPServer, fn ResponderFunc) (*Monitor, *eventCollector, func()) { + t.Helper() + ec := newEventCollector() upstream := newFakeUpstream(srv.wsURL()) - var published []events.Event - var publishMu sync.Mutex - publishFn := func(ev events.Event) { - publishMu.Lock() - published = append(published, ev) - publishMu.Unlock() + m := New(upstream, ec.publishFn(), 99) + require.NoError(t, m.Start(context.Background())) + + stopResponder := make(chan struct{}) + go listenAndRespond(srv, stopResponder, fn) + + // Wait for the websocket connection to be established. + select { + case <-srv.connCh: + case <-time.After(3 * time.Second): + t.Fatal("fake server never received a connection") } + // Wait for the init sequence (setAutoAttach + domain enables + script injection + // + getTargets) to complete. The responder goroutine handles all responses; + // we just need to wait for the burst to finish. + waitForInitDone(t, srv) - m := New(upstream, publishFn, 99) + cleanup := func() { + close(stopResponder) + m.Stop() + } + return m, ec, cleanup +} - ctx := context.Background() - err := m.Start(ctx) - require.NoError(t, err) - defer m.Stop() +// waitForInitDone waits for the Monitor's init sequence to complete by +// detecting a 100ms gap in activity on the message channel. The responder +// goroutine handles responses; this just waits for the burst to end. +func waitForInitDone(t *testing.T, _ *fakeCDPServer) { + t.Helper() + // The init sequence sends ~8 commands. Wait until the responder has + // processed them all by checking for a quiet period. + deadline := time.After(5 * time.Second) + for { + select { + case <-time.After(100 * time.Millisecond): + return + case <-deadline: + t.Fatal("init sequence did not complete") + } + } +} - // Give readLoop time to start and send the setAutoAttach command. - // We just verify the connection was made and the Monitor is running. - assert.True(t, m.IsRunning()) +// newComputedMonitor creates an unconnected Monitor for testing computed state +// (network_idle, layout_settled, navigation_settled) without a real websocket. +func newComputedMonitor(t *testing.T) (*Monitor, *eventCollector) { + t.Helper() + ec := newEventCollector() + upstream := newFakeUpstream("ws://127.0.0.1:0") + m := New(upstream, ec.publishFn(), 0) + return m, ec +} - // Read the first message sent by the Monitor — it should be Target.setAutoAttach. - msg := srv.readFromMonitor(t, 3*time.Second) - assert.Equal(t, "Target.setAutoAttach", msg.Method) +// navigateMonitor sends a Page.frameNavigated to reset computed state. +func navigateMonitor(m *Monitor, url string) { + p, _ := json.Marshal(map[string]any{ + "frame": map[string]any{"id": "f1", "url": url}, + }) + m.handleFrameNavigated(p, "s1") } -// TestAutoAttach verifies that after Start(), the Monitor sends -// Target.setAutoAttach{autoAttach:true, waitForDebuggerOnStart:false, flatten:true} -// and that on receiving Target.attachedToTarget the session is stored. +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + func TestAutoAttach(t *testing.T) { srv := newFakeCDPServer(t) defer srv.close() + ec := newEventCollector() upstream := newFakeUpstream(srv.wsURL()) - publishFn := func(ev events.Event) {} - - m := New(upstream, publishFn, 99) - - ctx := context.Background() - err := m.Start(ctx) - require.NoError(t, err) + m := New(upstream, ec.publishFn(), 99) + require.NoError(t, m.Start(context.Background())) defer m.Stop() - // Read the setAutoAttach request from the Monitor. + // The first command should be Target.setAutoAttach with correct params. msg := srv.readFromMonitor(t, 3*time.Second) assert.Equal(t, "Target.setAutoAttach", msg.Method) @@ -208,654 +370,434 @@ func TestAutoAttach(t *testing.T) { assert.False(t, params.WaitForDebuggerOnStart) assert.True(t, params.Flatten) - // Acknowledge the command with a response. - srv.sendToMonitor(t, map[string]any{ - "id": msg.ID, - "result": map[string]any{}, - }) - - // Drain any domain-enable commands sent after setAutoAttach. - // The Monitor calls enableDomains (Runtime.enable, Network.enable, Page.enable, DOM.enable). - drainTimeout := time.NewTimer(500 * time.Millisecond) - for { - select { - case b := <-srv.msgCh: - var m2 cdpMessage - _ = json.Unmarshal(b, &m2) - // respond to enable commands - srv.connMu.Lock() - c := srv.conn - srv.connMu.Unlock() - if c != nil && m2.ID != 0 { - _ = wsjson.Write(context.Background(), c, map[string]any{ - "id": m2.ID, - "result": map[string]any{}, - }) - } - case <-drainTimeout.C: - goto afterDrain - } - } -afterDrain: + // Respond and drain domain-enable commands. + stopResponder := make(chan struct{}) + go listenAndRespond(srv, stopResponder, nil) + defer close(stopResponder) + srv.sendToMonitor(t, map[string]any{"id": msg.ID, "result": map[string]any{}}) - // Now simulate Target.attachedToTarget event. - const testSessionID = "session-abc-123" - const testTargetID = "target-xyz-456" + // Simulate Target.attachedToTarget — session should be stored. srv.sendToMonitor(t, map[string]any{ "method": "Target.attachedToTarget", "params": map[string]any{ - "sessionId": testSessionID, - "targetInfo": map[string]any{ - "targetId": testTargetID, - "type": "page", - "url": "https://example.com", - }, + "sessionId": "session-abc", + "targetInfo": map[string]any{"targetId": "target-xyz", "type": "page", "url": "https://example.com"}, }, }) - - // Give the Monitor time to process the event and store the session. require.Eventually(t, func() bool { m.sessionsMu.RLock() defer m.sessionsMu.RUnlock() - _, ok := m.sessions[testSessionID] + _, ok := m.sessions["session-abc"] return ok - }, 2*time.Second, 50*time.Millisecond, "session not stored after attachedToTarget") + }, 2*time.Second, 50*time.Millisecond, "session not stored") m.sessionsMu.RLock() - info := m.sessions[testSessionID] + info := m.sessions["session-abc"] m.sessionsMu.RUnlock() - assert.Equal(t, testTargetID, info.targetID) + assert.Equal(t, "target-xyz", info.targetID) assert.Equal(t, "page", info.targetType) } -// TestLifecycle verifies the idle→running→stopped→restart state machine. func TestLifecycle(t *testing.T) { srv := newFakeCDPServer(t) defer srv.close() + ec := newEventCollector() upstream := newFakeUpstream(srv.wsURL()) - publishFn := func(ev events.Event) {} - - m := New(upstream, publishFn, 99) - - // Idle at boot. - assert.False(t, m.IsRunning(), "should be idle at boot") + m := New(upstream, ec.publishFn(), 99) - ctx := context.Background() + assert.False(t, m.IsRunning(), "idle at boot") - // First Start. - err := m.Start(ctx) - require.NoError(t, err) - assert.True(t, m.IsRunning(), "should be running after Start") - - // Drain the setAutoAttach message. - select { - case <-srv.msgCh: - case <-time.After(2 * time.Second): - t.Fatal("timeout waiting for setAutoAttach") - } + require.NoError(t, m.Start(context.Background())) + assert.True(t, m.IsRunning(), "running after Start") + srv.readFromMonitor(t, 2*time.Second) // drain setAutoAttach - // Stop. m.Stop() - assert.False(t, m.IsRunning(), "should be stopped after Stop") + assert.False(t, m.IsRunning(), "stopped after Stop") - // Second Start while stopped — should start fresh. - err = m.Start(ctx) - require.NoError(t, err) - assert.True(t, m.IsRunning(), "should be running after second Start") + // Restart while stopped. + require.NoError(t, m.Start(context.Background())) + assert.True(t, m.IsRunning(), "running after second Start") + srv.readFromMonitor(t, 2*time.Second) - // Drain the setAutoAttach message for the second start. - select { - case <-srv.msgCh: - case <-time.After(2 * time.Second): - t.Fatal("timeout waiting for setAutoAttach on second start") - } - - // Second Start while already running — stop+restart. - err = m.Start(ctx) - require.NoError(t, err) - assert.True(t, m.IsRunning(), "should be running after stop+restart") + // Restart while running — implicit Stop+Start. + require.NoError(t, m.Start(context.Background())) + assert.True(t, m.IsRunning(), "running after implicit restart") m.Stop() - assert.False(t, m.IsRunning(), "should be stopped at end") + assert.False(t, m.IsRunning(), "stopped at end") } -// TestReconnect verifies that when UpstreamManager emits a new URL (Chrome restart), -// the monitor emits monitor_disconnected, reconnects, and emits monitor_reconnected. func TestReconnect(t *testing.T) { srv1 := newFakeCDPServer(t) upstream := newFakeUpstream(srv1.wsURL()) - - var published []events.Event - var publishMu sync.Mutex - var publishCount atomic.Int32 - publishFn := func(ev events.Event) { - publishMu.Lock() - published = append(published, ev) - publishMu.Unlock() - publishCount.Add(1) - } - - m := New(upstream, publishFn, 99) - - ctx := context.Background() - err := m.Start(ctx) - require.NoError(t, err) + ec := newEventCollector() + m := New(upstream, ec.publishFn(), 99) + require.NoError(t, m.Start(context.Background())) defer m.Stop() - // Drain setAutoAttach from srv1. - select { - case <-srv1.msgCh: - case <-time.After(2 * time.Second): - t.Fatal("timeout waiting for initial setAutoAttach") - } + srv1.readFromMonitor(t, 2*time.Second) // drain setAutoAttach - // Set up srv2 as the new Chrome URL. srv2 := newFakeCDPServer(t) defer srv2.close() defer srv1.close() - // Trigger Chrome restart notification. upstream.notifyRestart(srv2.wsURL()) - // Wait for monitor_disconnected event. - require.Eventually(t, func() bool { - publishMu.Lock() - defer publishMu.Unlock() - for _, ev := range published { - if ev.Type == "monitor_disconnected" { - return true - } - } - return false - }, 3*time.Second, 50*time.Millisecond, "monitor_disconnected not published") + ec.waitFor(t, "monitor_disconnected", 3*time.Second) - // Wait for the Monitor to connect to srv2 and send setAutoAttach. - select { - case <-srv2.msgCh: - // setAutoAttach received on srv2 - case <-time.After(5*time.Second): - t.Fatal("timeout waiting for setAutoAttach on srv2 after reconnect") - } + // Wait for the Monitor to reconnect to srv2. + srv2.readFromMonitor(t, 5*time.Second) - // Wait for monitor_reconnected event. - require.Eventually(t, func() bool { - publishMu.Lock() - defer publishMu.Unlock() - for _, ev := range published { - if ev.Type == "monitor_reconnected" { - return true - } - } - return false - }, 3*time.Second, 50*time.Millisecond, "monitor_reconnected not published") - - // Verify monitor_reconnected contains reconnect_duration_ms. - publishMu.Lock() - var reconnEv events.Event - for _, ev := range published { - if ev.Type == "monitor_reconnected" { - reconnEv = ev - break - } - } - publishMu.Unlock() - - require.NotEmpty(t, reconnEv.Type) + ev := ec.waitFor(t, "monitor_reconnected", 3*time.Second) var data map[string]any - require.NoError(t, json.Unmarshal(reconnEv.Data, &data)) - _, hasField := data["reconnect_duration_ms"] - assert.True(t, hasField, "monitor_reconnected missing reconnect_duration_ms field") -} - -// listenAndRespondAll drains srv.msgCh and responds with empty results until stopCh is closed. -func listenAndRespondAll(srv *fakeCDPServer, stopCh <-chan struct{}) { - for { - select { - case b := <-srv.msgCh: - var msg cdpMessage - if err := json.Unmarshal(b, &msg); err != nil { - continue - } - if msg.ID == 0 { - continue - } - srv.connMu.Lock() - c := srv.conn - srv.connMu.Unlock() - if c != nil { - _ = wsjson.Write(context.Background(), c, map[string]any{ - "id": msg.ID, - "result": map[string]any{}, - }) - } - case <-stopCh: - return - } - } -} - - -// startMonitorWithFakeServer is a helper that starts a monitor against a fake CDP server, -// drains the initial setAutoAttach + domain-enable commands, and returns a cleanup func. -func startMonitorWithFakeServer(t *testing.T, srv *fakeCDPServer) (*Monitor, *[]events.Event, *sync.Mutex, func()) { - t.Helper() - published := make([]events.Event, 0, 32) - var mu sync.Mutex - publishFn := func(ev events.Event) { - mu.Lock() - published = append(published, ev) - mu.Unlock() - } - upstream := newFakeUpstream(srv.wsURL()) - m := New(upstream, publishFn, 99) - ctx := context.Background() - require.NoError(t, m.Start(ctx)) - - stopResponder := make(chan struct{}) - go listenAndRespondAll(srv, stopResponder) - - cleanup := func() { - close(stopResponder) - m.Stop() - } - // Wait until the fake server has an active connection. - require.Eventually(t, func() bool { - srv.connMu.Lock() - defer srv.connMu.Unlock() - return srv.conn != nil - }, 3*time.Second, 20*time.Millisecond, "fake server never received a connection") - // Allow the readLoop and init commands to settle before sending test events. - time.Sleep(150 * time.Millisecond) - return m, &published, &mu, cleanup -} - -// waitForEvent blocks until an event of the given type is published, or times out. -func waitForEvent(t *testing.T, published *[]events.Event, mu *sync.Mutex, eventType string, timeout time.Duration) events.Event { - t.Helper() - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - mu.Lock() - for _, ev := range *published { - if ev.Type == eventType { - mu.Unlock() - return ev - } - } - mu.Unlock() - time.Sleep(20 * time.Millisecond) - } - t.Fatalf("timeout waiting for event type=%q", eventType) - return events.Event{} + require.NoError(t, json.Unmarshal(ev.Data, &data)) + _, ok := data["reconnect_duration_ms"] + assert.True(t, ok, "missing reconnect_duration_ms") } - -// TestConsoleEvents verifies console_log, console_error, and [KERNEL_EVENT] sentinel routing. func TestConsoleEvents(t *testing.T) { srv := newFakeCDPServer(t) defer srv.close() - _, published, mu, cleanup := startMonitorWithFakeServer(t, srv) + _, ec, cleanup := startMonitor(t, srv, nil) defer cleanup() - // 1. consoleAPICalled → console_log - srv.sendToMonitor(t, map[string]any{ - "method": "Runtime.consoleAPICalled", - "params": map[string]any{ - "type": "log", - "args": []any{map[string]any{"type": "string", "value": "hello world"}}, - "executionContextId": 1, - }, + t.Run("console_log", func(t *testing.T) { + srv.sendToMonitor(t, map[string]any{ + "method": "Runtime.consoleAPICalled", + "params": map[string]any{ + "type": "log", + "args": []any{map[string]any{"type": "string", "value": "hello world"}}, + }, + }) + ev := ec.waitFor(t, "console_log", 2*time.Second) + assert.Equal(t, events.CategoryConsole, ev.Category) + assert.Equal(t, events.KindCDP, ev.Source.Kind) + assert.Equal(t, "Runtime.consoleAPICalled", ev.Source.Event) + assert.Equal(t, events.DetailStandard, ev.DetailLevel) + + var data map[string]any + require.NoError(t, json.Unmarshal(ev.Data, &data)) + assert.Equal(t, "log", data["level"]) + assert.Equal(t, "hello world", data["text"]) }) - ev := waitForEvent(t, published, mu, "console_log", 2*time.Second) - assert.Equal(t, events.CategoryConsole, ev.Category) - assert.Equal(t, events.KindCDP, ev.Source.Kind) - assert.Equal(t, "Runtime.consoleAPICalled", ev.Source.Event) - assert.Equal(t, events.DetailStandard, ev.DetailLevel) - var data map[string]any - require.NoError(t, json.Unmarshal(ev.Data, &data)) - assert.Equal(t, "log", data["level"]) - assert.Equal(t, "hello world", data["text"]) - // 2. exceptionThrown → console_error - srv.sendToMonitor(t, map[string]any{ - "method": "Runtime.exceptionThrown", - "params": map[string]any{ - "timestamp": 1234.5, - "exceptionDetails": map[string]any{ - "text": "Uncaught TypeError", - "lineNumber": 42, - "columnNumber": 7, - "url": "https://example.com/app.js", + t.Run("exception_thrown", func(t *testing.T) { + srv.sendToMonitor(t, map[string]any{ + "method": "Runtime.exceptionThrown", + "params": map[string]any{ + "timestamp": 1234.5, + "exceptionDetails": map[string]any{ + "text": "Uncaught TypeError", + "lineNumber": 42, + "columnNumber": 7, + "url": "https://example.com/app.js", + }, }, - }, + }) + ev := ec.waitFor(t, "console_error", 2*time.Second) + assert.Equal(t, events.CategoryConsole, ev.Category) + assert.Equal(t, events.DetailStandard, ev.DetailLevel) + + var data map[string]any + require.NoError(t, json.Unmarshal(ev.Data, &data)) + assert.Equal(t, "Uncaught TypeError", data["text"]) + assert.Equal(t, float64(42), data["line"]) }) - ev2 := waitForEvent(t, published, mu, "console_error", 2*time.Second) - assert.Equal(t, events.CategoryConsole, ev2.Category) - assert.Equal(t, events.KindCDP, ev2.Source.Kind) - assert.Equal(t, "Runtime.exceptionThrown", ev2.Source.Event) - assert.Equal(t, events.DetailStandard, ev2.DetailLevel) - var data2 map[string]any - require.NoError(t, json.Unmarshal(ev2.Data, &data2)) - assert.Equal(t, "Uncaught TypeError", data2["text"]) - assert.Equal(t, float64(42), data2["line"]) - assert.Equal(t, float64(7), data2["column"]) - - // 3. Runtime.bindingCalled → interaction_click (via __kernelEvent binding) - srv.sendToMonitor(t, map[string]any{ - "method": "Runtime.bindingCalled", - "params": map[string]any{ - "name": "__kernelEvent", - "payload": `{"type":"interaction_click","x":10,"y":20,"selector":"button","tag":"BUTTON","text":"OK"}`, - }, + + t.Run("non_string_args", func(t *testing.T) { + srv.sendToMonitor(t, map[string]any{ + "method": "Runtime.consoleAPICalled", + "params": map[string]any{ + "type": "log", + "args": []any{ + map[string]any{"type": "number", "value": 42}, + map[string]any{"type": "object", "value": map[string]any{"key": "val"}}, + map[string]any{"type": "undefined"}, + }, + }, + }) + ev := ec.waitForNew(t, "console_log", 2*time.Second) + var data map[string]any + require.NoError(t, json.Unmarshal(ev.Data, &data)) + args := data["args"].([]any) + assert.Equal(t, "42", args[0]) + assert.Contains(t, args[1], "key") + assert.Equal(t, "undefined", args[2]) }) - ev3 := waitForEvent(t, published, mu, "interaction_click", 2*time.Second) - assert.Equal(t, events.CategoryInteraction, ev3.Category) - assert.Equal(t, "Runtime.bindingCalled", ev3.Source.Event) } -// TestNetworkEvents verifies network_request, network_response, and network_loading_failed. func TestNetworkEvents(t *testing.T) { srv := newFakeCDPServer(t) defer srv.close() - published := make([]events.Event, 0, 32) - var mu sync.Mutex - upstream := newFakeUpstream(srv.wsURL()) - m := New(upstream, func(ev events.Event) { - mu.Lock() - published = append(published, ev) - mu.Unlock() - }, 99) - ctx := context.Background() - require.NoError(t, m.Start(ctx)) - defer m.Stop() - - // Responder goroutine: answer all commands from the monitor. - // For Network.getResponseBody, return a real body; for everything else return {}. - stopResponder := make(chan struct{}) - defer close(stopResponder) - go func() { - for { - select { - case b := <-srv.msgCh: - var msg cdpMessage - if err := json.Unmarshal(b, &msg); err != nil { - continue - } - if msg.ID == 0 { - continue - } - srv.connMu.Lock() - c := srv.conn - srv.connMu.Unlock() - if c == nil { - continue - } - var resp any - if msg.Method == "Network.getResponseBody" { - resp = map[string]any{ - "id": msg.ID, - "result": map[string]any{"body": `{"ok":true}`, "base64Encoded": false}, - } - } else { - resp = map[string]any{"id": msg.ID, "result": map[string]any{}} - } - _ = wsjson.Write(context.Background(), c, resp) - case <-stopResponder: - return + // Custom responder: return a body for Network.getResponseBody. + responder := func(msg cdpMessage) any { + if msg.Method == "Network.getResponseBody" { + return map[string]any{ + "id": msg.ID, + "result": map[string]any{"body": `{"ok":true}`, "base64Encoded": false}, } } - }() - - // Wait for connection. - require.Eventually(t, func() bool { - srv.connMu.Lock() - defer srv.connMu.Unlock() - return srv.conn != nil - }, 3*time.Second, 20*time.Millisecond) - time.Sleep(150 * time.Millisecond) - - const reqID = "req-001" + return nil + } + _, ec, cleanup := startMonitor(t, srv, responder) + defer cleanup() - // 1. requestWillBeSent → network_request - srv.sendToMonitor(t, map[string]any{ - "method": "Network.requestWillBeSent", - "params": map[string]any{ - "requestId": reqID, - "resourceType": "XHR", - "request": map[string]any{ - "method": "POST", - "url": "https://api.example.com/data", - "headers": map[string]any{"Content-Type": "application/json"}, + t.Run("request_and_response", func(t *testing.T) { + srv.sendToMonitor(t, map[string]any{ + "method": "Network.requestWillBeSent", + "params": map[string]any{ + "requestId": "req-001", + "resourceType": "XHR", + "request": map[string]any{ + "method": "POST", + "url": "https://api.example.com/data", + "headers": map[string]any{"Content-Type": "application/json"}, + }, + "initiator": map[string]any{"type": "script"}, }, - "initiator": map[string]any{"type": "script"}, - }, - }) - ev := waitForEvent(t, &published, &mu, "network_request", 2*time.Second) - assert.Equal(t, events.CategoryNetwork, ev.Category) - assert.Equal(t, events.KindCDP, ev.Source.Kind) - assert.Equal(t, "Network.requestWillBeSent", ev.Source.Event) - var data map[string]any - require.NoError(t, json.Unmarshal(ev.Data, &data)) - assert.Equal(t, "POST", data["method"]) - assert.Equal(t, "https://api.example.com/data", data["url"]) - - // 2. responseReceived + loadingFinished → network_response (with body via getResponseBody) - srv.sendToMonitor(t, map[string]any{ - "method": "Network.responseReceived", - "params": map[string]any{ - "requestId": reqID, - "response": map[string]any{ - "status": 200, - "statusText": "OK", - "url": "https://api.example.com/data", - "headers": map[string]any{"Content-Type": "application/json"}, - "mimeType": "application/json", + }) + ev := ec.waitFor(t, "network_request", 2*time.Second) + assert.Equal(t, events.CategoryNetwork, ev.Category) + assert.Equal(t, "Network.requestWillBeSent", ev.Source.Event) + + var data map[string]any + require.NoError(t, json.Unmarshal(ev.Data, &data)) + assert.Equal(t, "POST", data["method"]) + assert.Equal(t, "https://api.example.com/data", data["url"]) + + // Complete the request lifecycle. + srv.sendToMonitor(t, map[string]any{ + "method": "Network.responseReceived", + "params": map[string]any{ + "requestId": "req-001", + "response": map[string]any{ + "status": 200, "statusText": "OK", + "headers": map[string]any{"Content-Type": "application/json"}, "mimeType": "application/json", + }, }, - }, - }) - srv.sendToMonitor(t, map[string]any{ - "method": "Network.loadingFinished", - "params": map[string]any{ - "requestId": reqID, - }, - }) + }) + srv.sendToMonitor(t, map[string]any{ + "method": "Network.loadingFinished", + "params": map[string]any{"requestId": "req-001"}, + }) - ev2 := waitForEvent(t, &published, &mu, "network_response", 3*time.Second) - assert.Equal(t, events.CategoryNetwork, ev2.Category) - assert.Equal(t, "Network.loadingFinished", ev2.Source.Event) - var data2 map[string]any - require.NoError(t, json.Unmarshal(ev2.Data, &data2)) - assert.Equal(t, float64(200), data2["status"]) - assert.NotEmpty(t, data2["body"]) + ev2 := ec.waitFor(t, "network_response", 3*time.Second) + assert.Equal(t, "Network.loadingFinished", ev2.Source.Event) + var data2 map[string]any + require.NoError(t, json.Unmarshal(ev2.Data, &data2)) + assert.Equal(t, float64(200), data2["status"]) + assert.NotEmpty(t, data2["body"]) + }) - // 3. loadingFailed → network_loading_failed - const reqID2 = "req-002" - srv.sendToMonitor(t, map[string]any{ - "method": "Network.requestWillBeSent", - "params": map[string]any{ - "requestId": reqID2, - "request": map[string]any{ - "method": "GET", - "url": "https://fail.example.com/", + t.Run("loading_failed", func(t *testing.T) { + srv.sendToMonitor(t, map[string]any{ + "method": "Network.requestWillBeSent", + "params": map[string]any{ + "requestId": "req-002", + "request": map[string]any{"method": "GET", "url": "https://fail.example.com/"}, }, - }, + }) + ec.waitForNew(t, "network_request", 2*time.Second) + + srv.sendToMonitor(t, map[string]any{ + "method": "Network.loadingFailed", + "params": map[string]any{ + "requestId": "req-002", + "errorText": "net::ERR_CONNECTION_REFUSED", + "canceled": false, + }, + }) + ev := ec.waitFor(t, "network_loading_failed", 2*time.Second) + assert.Equal(t, events.CategoryNetwork, ev.Category) + var data map[string]any + require.NoError(t, json.Unmarshal(ev.Data, &data)) + assert.Equal(t, "net::ERR_CONNECTION_REFUSED", data["error_text"]) }) - waitForEvent(t, &published, &mu, "network_request", 2*time.Second) - mu.Lock() - published = published[:0] - mu.Unlock() + t.Run("binary_resource_skips_body", func(t *testing.T) { + var getBodyCalled atomic.Bool + srv.sendToMonitor(t, map[string]any{ + "method": "Network.requestWillBeSent", + "params": map[string]any{ + "requestId": "img-001", + "resourceType": "Image", + "request": map[string]any{"method": "GET", "url": "https://example.com/photo.png"}, + }, + }) + srv.sendToMonitor(t, map[string]any{ + "method": "Network.responseReceived", + "params": map[string]any{ + "requestId": "img-001", + "response": map[string]any{"status": 200, "statusText": "OK", "headers": map[string]any{}, "mimeType": "image/png"}, + }, + }) + srv.sendToMonitor(t, map[string]any{ + "method": "Network.loadingFinished", + "params": map[string]any{"requestId": "img-001"}, + }) - srv.sendToMonitor(t, map[string]any{ - "method": "Network.loadingFailed", - "params": map[string]any{ - "requestId": reqID2, - "errorText": "net::ERR_CONNECTION_REFUSED", - "canceled": false, - }, + ev := ec.waitForNew(t, "network_response", 3*time.Second) + var data map[string]any + require.NoError(t, json.Unmarshal(ev.Data, &data)) + assert.Equal(t, "", data["body"], "binary resource should have empty body") + assert.False(t, getBodyCalled.Load(), "should not call getResponseBody for images") }) - ev3 := waitForEvent(t, &published, &mu, "network_loading_failed", 2*time.Second) - assert.Equal(t, events.CategoryNetwork, ev3.Category) - var data3 map[string]any - require.NoError(t, json.Unmarshal(ev3.Data, &data3)) - assert.Equal(t, "net::ERR_CONNECTION_REFUSED", data3["error_text"]) } -// TestPageEvents verifies navigation, dom_content_loaded, page_load, and dom_updated. func TestPageEvents(t *testing.T) { srv := newFakeCDPServer(t) defer srv.close() - _, published, mu, cleanup := startMonitorWithFakeServer(t, srv) + _, ec, cleanup := startMonitor(t, srv, nil) defer cleanup() - // frameNavigated → navigation srv.sendToMonitor(t, map[string]any{ "method": "Page.frameNavigated", "params": map[string]any{ - "frame": map[string]any{ - "id": "frame-1", - "url": "https://example.com/page", - }, + "frame": map[string]any{"id": "frame-1", "url": "https://example.com/page"}, }, }) - ev := waitForEvent(t, published, mu, "navigation", 2*time.Second) + ev := ec.waitFor(t, "navigation", 2*time.Second) assert.Equal(t, events.CategoryPage, ev.Category) - assert.Equal(t, events.KindCDP, ev.Source.Kind) assert.Equal(t, "Page.frameNavigated", ev.Source.Event) var data map[string]any require.NoError(t, json.Unmarshal(ev.Data, &data)) assert.Equal(t, "https://example.com/page", data["url"]) - // domContentEventFired → dom_content_loaded srv.sendToMonitor(t, map[string]any{ "method": "Page.domContentEventFired", "params": map[string]any{"timestamp": 1000.0}, }) - ev2 := waitForEvent(t, published, mu, "dom_content_loaded", 2*time.Second) + ev2 := ec.waitFor(t, "dom_content_loaded", 2*time.Second) assert.Equal(t, events.CategoryPage, ev2.Category) + assert.Equal(t, events.DetailMinimal, ev2.DetailLevel) - // loadEventFired → page_load srv.sendToMonitor(t, map[string]any{ "method": "Page.loadEventFired", "params": map[string]any{"timestamp": 1001.0}, }) - ev3 := waitForEvent(t, published, mu, "page_load", 2*time.Second) + ev3 := ec.waitFor(t, "page_load", 2*time.Second) assert.Equal(t, events.CategoryPage, ev3.Category) + assert.Equal(t, events.DetailMinimal, ev3.DetailLevel) - // documentUpdated → dom_updated srv.sendToMonitor(t, map[string]any{ "method": "DOM.documentUpdated", "params": map[string]any{}, }) - ev4 := waitForEvent(t, published, mu, "dom_updated", 2*time.Second) + ev4 := ec.waitFor(t, "dom_updated", 2*time.Second) assert.Equal(t, events.CategoryPage, ev4.Category) + assert.Equal(t, events.DetailMinimal, ev4.DetailLevel) } -// TestTargetEvents verifies target_created and target_destroyed. func TestTargetEvents(t *testing.T) { srv := newFakeCDPServer(t) defer srv.close() - _, published, mu, cleanup := startMonitorWithFakeServer(t, srv) + _, ec, cleanup := startMonitor(t, srv, nil) defer cleanup() - // targetCreated → target_created srv.sendToMonitor(t, map[string]any{ "method": "Target.targetCreated", "params": map[string]any{ - "targetInfo": map[string]any{ - "targetId": "target-1", - "type": "page", - "url": "https://new.example.com", - }, + "targetInfo": map[string]any{"targetId": "t-1", "type": "page", "url": "https://new.example.com"}, }, }) - ev := waitForEvent(t, published, mu, "target_created", 2*time.Second) + ev := ec.waitFor(t, "target_created", 2*time.Second) assert.Equal(t, events.CategoryPage, ev.Category) - assert.Equal(t, events.KindCDP, ev.Source.Kind) - assert.Equal(t, "Target.targetCreated", ev.Source.Event) + assert.Equal(t, events.DetailMinimal, ev.DetailLevel) var data map[string]any require.NoError(t, json.Unmarshal(ev.Data, &data)) - assert.Equal(t, "target-1", data["target_id"]) + assert.Equal(t, "t-1", data["target_id"]) - // targetDestroyed → target_destroyed srv.sendToMonitor(t, map[string]any{ "method": "Target.targetDestroyed", - "params": map[string]any{ - "targetId": "target-1", - }, + "params": map[string]any{"targetId": "t-1"}, }) - ev2 := waitForEvent(t, published, mu, "target_destroyed", 2*time.Second) + ev2 := ec.waitFor(t, "target_destroyed", 2*time.Second) assert.Equal(t, events.CategoryPage, ev2.Category) - var data2 map[string]any - require.NoError(t, json.Unmarshal(ev2.Data, &data2)) - assert.Equal(t, "target-1", data2["target_id"]) + assert.Equal(t, events.DetailMinimal, ev2.DetailLevel) } -// TestBindingAndTimeline verifies that scroll_settled arrives via -// Runtime.bindingCalled and layout_shift arrives via PerformanceTimeline. func TestBindingAndTimeline(t *testing.T) { srv := newFakeCDPServer(t) defer srv.close() - _, published, mu, cleanup := startMonitorWithFakeServer(t, srv) + _, ec, cleanup := startMonitor(t, srv, nil) defer cleanup() - // scroll_settled via Runtime.bindingCalled - srv.sendToMonitor(t, map[string]any{ - "method": "Runtime.bindingCalled", - "params": map[string]any{ - "name": "__kernelEvent", - "payload": `{"type":"scroll_settled","from_x":0,"from_y":0,"to_x":0,"to_y":500,"target_selector":"body"}`, - }, + t.Run("interaction_click", func(t *testing.T) { + srv.sendToMonitor(t, map[string]any{ + "method": "Runtime.bindingCalled", + "params": map[string]any{ + "name": "__kernelEvent", + "payload": `{"type":"interaction_click","x":10,"y":20,"selector":"button","tag":"BUTTON","text":"OK"}`, + }, + }) + ev := ec.waitFor(t, "interaction_click", 2*time.Second) + assert.Equal(t, events.CategoryInteraction, ev.Category) + assert.Equal(t, "Runtime.bindingCalled", ev.Source.Event) }) - ev := waitForEvent(t, published, mu, "scroll_settled", 2*time.Second) - assert.Equal(t, events.CategoryInteraction, ev.Category) - assert.Equal(t, "Runtime.bindingCalled", ev.Source.Event) - var data map[string]any - require.NoError(t, json.Unmarshal(ev.Data, &data)) - assert.Equal(t, float64(500), data["to_y"]) - // layout_shift via PerformanceTimeline.timelineEventAdded - srv.sendToMonitor(t, map[string]any{ - "method": "PerformanceTimeline.timelineEventAdded", - "params": map[string]any{ - "event": map[string]any{ - "type": "layout-shift", + t.Run("scroll_settled", func(t *testing.T) { + srv.sendToMonitor(t, map[string]any{ + "method": "Runtime.bindingCalled", + "params": map[string]any{ + "name": "__kernelEvent", + "payload": `{"type":"scroll_settled","from_x":0,"from_y":0,"to_x":0,"to_y":500,"target_selector":"body"}`, }, - }, + }) + ev := ec.waitFor(t, "scroll_settled", 2*time.Second) + assert.Equal(t, events.CategoryInteraction, ev.Category) + var data map[string]any + require.NoError(t, json.Unmarshal(ev.Data, &data)) + assert.Equal(t, float64(500), data["to_y"]) }) - ev2 := waitForEvent(t, published, mu, "layout_shift", 2*time.Second) - assert.Equal(t, events.KindCDP, ev2.Source.Kind) - assert.Equal(t, "PerformanceTimeline.timelineEventAdded", ev2.Source.Event) - noEventWithin(t, published, mu, "console_log", 100*time.Millisecond) + t.Run("layout_shift", func(t *testing.T) { + srv.sendToMonitor(t, map[string]any{ + "method": "PerformanceTimeline.timelineEventAdded", + "params": map[string]any{ + "event": map[string]any{"type": "layout-shift"}, + }, + }) + ev := ec.waitFor(t, "layout_shift", 2*time.Second) + assert.Equal(t, events.KindCDP, ev.Source.Kind) + assert.Equal(t, "PerformanceTimeline.timelineEventAdded", ev.Source.Event) + }) + + t.Run("unknown_binding_ignored", func(t *testing.T) { + srv.sendToMonitor(t, map[string]any{ + "method": "Runtime.bindingCalled", + "params": map[string]any{ + "name": "someOtherBinding", + "payload": `{"type":"interaction_click"}`, + }, + }) + ec.assertNone(t, "interaction_click", 100*time.Millisecond) + }) } -// TestScreenshot verifies rate limiting and the screenshotFn testable seam. func TestScreenshot(t *testing.T) { srv := newFakeCDPServer(t) defer srv.close() - m, published, mu, cleanup := startMonitorWithFakeServer(t, srv) + m, ec, cleanup := startMonitor(t, srv, nil) defer cleanup() - // Inject a mock screenshotFn that returns a tiny valid PNG. var captureCount atomic.Int32 - // 1x1 white PNG (minimal valid PNG bytes) minimalPNG := []byte{ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature - 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR chunk length + type - 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // width=1, height=1 - 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, // bit depth=8, color type=2, ... - 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, // IDAT chunk + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, + 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc, - 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, // IEND chunk + 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, } m.screenshotFn = func(ctx context.Context, displayNum int) ([]byte, error) { @@ -863,280 +805,226 @@ func TestScreenshot(t *testing.T) { return minimalPNG, nil } - // First maybeScreenshot call — should capture. - ctx := context.Background() - m.maybeScreenshot(ctx) - // Give the goroutine time to run. - require.Eventually(t, func() bool { - return captureCount.Load() == 1 - }, 2*time.Second, 20*time.Millisecond) - - // Second call immediately after — should be rate-limited (no capture). - m.maybeScreenshot(ctx) - time.Sleep(100 * time.Millisecond) - assert.Equal(t, int32(1), captureCount.Load(), "second call within 2s should be rate-limited") - - // Verify screenshot event was published with png field. - ev := waitForEvent(t, published, mu, "screenshot", 2*time.Second) - assert.Equal(t, events.CategorySystem, ev.Category) - assert.Equal(t, events.KindLocalProcess, ev.Source.Kind) - var data map[string]any - require.NoError(t, json.Unmarshal(ev.Data, &data)) - assert.NotEmpty(t, data["png"]) + t.Run("capture_and_publish", func(t *testing.T) { + m.maybeScreenshot(context.Background()) + require.Eventually(t, func() bool { return captureCount.Load() == 1 }, 2*time.Second, 20*time.Millisecond) - // Fast-forward lastScreenshotAt to simulate 2s+ elapsed. - m.lastScreenshotAt.Store(time.Now().Add(-3 * time.Second).UnixMilli()) - m.maybeScreenshot(ctx) - require.Eventually(t, func() bool { - return captureCount.Load() == 2 - }, 2*time.Second, 20*time.Millisecond) -} + ev := ec.waitFor(t, "screenshot", 2*time.Second) + assert.Equal(t, events.CategorySystem, ev.Category) + assert.Equal(t, events.KindLocalProcess, ev.Source.Kind) + var data map[string]any + require.NoError(t, json.Unmarshal(ev.Data, &data)) + assert.NotEmpty(t, data["png"]) + }) -// --- Computed meta-event tests --- + t.Run("rate_limited", func(t *testing.T) { + before := captureCount.Load() + m.maybeScreenshot(context.Background()) + time.Sleep(100 * time.Millisecond) + assert.Equal(t, before, captureCount.Load(), "should be rate-limited within 2s") + }) -// newComputedMonitor creates a Monitor with a capture function and returns -// the published events slice and its mutex for inspection. -func newComputedMonitor(t *testing.T) (*Monitor, *[]events.Event, *sync.Mutex) { - t.Helper() - var mu sync.Mutex - published := make([]events.Event, 0) - publishFn := func(ev events.Event) { - mu.Lock() - published = append(published, ev) - mu.Unlock() - } - upstream := newFakeUpstream("ws://127.0.0.1:0") // not used; no real dial - m := New(upstream, publishFn, 0) - return m, &published, &mu + t.Run("captures_after_cooldown", func(t *testing.T) { + m.lastScreenshotAt.Store(time.Now().Add(-3 * time.Second).UnixMilli()) + before := captureCount.Load() + m.maybeScreenshot(context.Background()) + require.Eventually(t, func() bool { return captureCount.Load() > before }, 2*time.Second, 20*time.Millisecond) + }) } +func TestAttachExistingTargets(t *testing.T) { + srv := newFakeCDPServer(t) + defer srv.close() -// noEventWithin asserts that no event of the given type is published within d. -func noEventWithin(t *testing.T, published *[]events.Event, mu *sync.Mutex, eventType string, d time.Duration) { - t.Helper() - deadline := time.Now().Add(d) - for time.Now().Before(deadline) { - mu.Lock() - for _, ev := range *published { - if ev.Type == eventType { - mu.Unlock() - t.Fatalf("unexpected event %q published", eventType) + responder := func(msg cdpMessage) any { + srv.connMu.Lock() + c := srv.conn + srv.connMu.Unlock() + switch msg.Method { + case "Target.getTargets": + return map[string]any{ + "id": msg.ID, + "result": map[string]any{ + "targetInfos": []any{ + map[string]any{"targetId": "existing-1", "type": "page", "url": "https://preexisting.example.com"}, + }, + }, } + case "Target.attachToTarget": + if c != nil { + _ = wsjson.Write(context.Background(), c, map[string]any{ + "method": "Target.attachedToTarget", + "params": map[string]any{ + "sessionId": "session-existing-1", + "targetInfo": map[string]any{"targetId": "existing-1", "type": "page", "url": "https://preexisting.example.com"}, + }, + }) + } + return map[string]any{"id": msg.ID, "result": map[string]any{"sessionId": "session-existing-1"}} } - mu.Unlock() - time.Sleep(10 * time.Millisecond) + return nil } + + m, _, cleanup := startMonitor(t, srv, responder) + defer cleanup() + + require.Eventually(t, func() bool { + m.sessionsMu.RLock() + defer m.sessionsMu.RUnlock() + _, ok := m.sessions["session-existing-1"] + return ok + }, 3*time.Second, 50*time.Millisecond, "existing target not auto-attached") + + m.sessionsMu.RLock() + info := m.sessions["session-existing-1"] + m.sessionsMu.RUnlock() + assert.Equal(t, "existing-1", info.targetID) } -// TestNetworkIdle verifies the 500ms debounce for network_idle. -func TestNetworkIdle(t *testing.T) { - m, published, mu := newComputedMonitor(t) +func TestURLPopulated(t *testing.T) { + srv := newFakeCDPServer(t) + defer srv.close() + + _, ec, cleanup := startMonitor(t, srv, nil) + defer cleanup() - // Simulate navigation (resets computed state). - navParams, _ := json.Marshal(map[string]any{ - "frame": map[string]any{"id": "f1", "url": "https://example.com"}, + srv.sendToMonitor(t, map[string]any{ + "method": "Page.frameNavigated", + "params": map[string]any{ + "frame": map[string]any{"id": "f1", "url": "https://example.com/page"}, + }, }) - m.handleFrameNavigated(navParams, "s1") - // Drain the navigation event from published. - - // Helper to send requestWillBeSent. - sendReq := func(id string) { - p, _ := json.Marshal(map[string]any{ - "requestId": id, - "resourceType": "Document", - "request": map[string]any{"method": "GET", "url": "https://example.com/" + id}, - }) - m.handleNetworkRequest(p, "s1") - } - // Helper to send loadingFinished. - sendFinished := func(id string) { - // store minimal state so LoadAndDelete finds it - m.pendReqMu.Lock() - m.pendingRequests[id] = networkReqState{method: "GET", url: "https://example.com/" + id} - m.pendReqMu.Unlock() - p, _ := json.Marshal(map[string]any{"requestId": id}) - m.handleLoadingFinished(p, "s1") - } + ec.waitFor(t, "navigation", 2*time.Second) - // Send 3 requests, then finish them all. - sendReq("r1") - sendReq("r2") - sendReq("r3") - - t0 := time.Now() - sendFinished("r1") - sendFinished("r2") - sendFinished("r3") - - // network_idle should fire ~500ms after the last loadingFinished. - ev := waitForEvent(t,published, mu, "network_idle", 2*time.Second) - elapsed := time.Since(t0) - assert.GreaterOrEqual(t, elapsed.Milliseconds(), int64(400), "network_idle fired too early") - assert.Equal(t, events.CategoryNetwork, ev.Category) - assert.Equal(t, events.KindCDP, ev.Source.Kind) - assert.Equal(t, "", ev.Source.Event) - - // --- Timer reset test: new request within 500ms resets the clock --- - m2, published2, mu2 := newComputedMonitor(t) - navParams2, _ := json.Marshal(map[string]any{ - "frame": map[string]any{"id": "f1", "url": "https://example.com"}, + srv.sendToMonitor(t, map[string]any{ + "method": "Runtime.consoleAPICalled", + "params": map[string]any{ + "type": "log", + "args": []any{map[string]any{"type": "string", "value": "test"}}, + }, }) - m2.handleFrameNavigated(navParams2, "s1") + ev := ec.waitFor(t, "console_log", 2*time.Second) + assert.Equal(t, "https://example.com/page", ev.URL) +} - sendReq2 := func(id string) { - p, _ := json.Marshal(map[string]any{ - "requestId": id, - "resourceType": "Document", - "request": map[string]any{"method": "GET", "url": "https://example.com/" + id}, - }) - m2.handleNetworkRequest(p, "s1") - } - sendFinished2 := func(id string) { - m2.pendReqMu.Lock() - m2.pendingRequests[id] = networkReqState{method: "GET", url: "https://example.com/" + id} - m2.pendReqMu.Unlock() - p, _ := json.Marshal(map[string]any{"requestId": id}) - m2.handleLoadingFinished(p, "s1") - } +// --------------------------------------------------------------------------- +// Computed meta-event tests — use direct handler calls, no websocket needed. +// --------------------------------------------------------------------------- - sendReq2("a1") - sendFinished2("a1") - // 200ms later, a new request starts (timer should reset) - time.Sleep(200 * time.Millisecond) - sendReq2("a2") - t1 := time.Now() - sendFinished2("a2") - - ev2 := waitForEvent(t,published2, mu2, "network_idle", 2*time.Second) - elapsed2 := time.Since(t1) - // Should fire ~500ms after a2 finished, not 500ms after a1 - assert.GreaterOrEqual(t, elapsed2.Milliseconds(), int64(400), "network_idle should reset timer on new request") - assert.Equal(t, events.CategoryNetwork, ev2.Category) +// simulateRequest sends a Network.requestWillBeSent through the handler. +func simulateRequest(m *Monitor, id string) { + p, _ := json.Marshal(map[string]any{ + "requestId": id, "resourceType": "Document", + "request": map[string]any{"method": "GET", "url": "https://example.com/" + id}, + }) + m.handleNetworkRequest(p, "s1") } -// TestLayoutSettled verifies the 1s debounce for layout_settled. -func TestLayoutSettled(t *testing.T) { - m, published, mu := newComputedMonitor(t) +// simulateFinished stores minimal state and sends Network.loadingFinished. +func simulateFinished(m *Monitor, id string) { + m.pendReqMu.Lock() + m.pendingRequests[id] = networkReqState{method: "GET", url: "https://example.com/" + id} + m.pendReqMu.Unlock() + p, _ := json.Marshal(map[string]any{"requestId": id}) + m.handleLoadingFinished(p, "s1") +} - // Navigate to reset state. - navParams, _ := json.Marshal(map[string]any{ - "frame": map[string]any{"id": "f1", "url": "https://example.com"}, +func TestNetworkIdle(t *testing.T) { + t.Run("debounce_500ms", func(t *testing.T) { + m, ec := newComputedMonitor(t) + navigateMonitor(m, "https://example.com") + + simulateRequest(m, "r1") + simulateRequest(m, "r2") + simulateRequest(m, "r3") + + t0 := time.Now() + simulateFinished(m, "r1") + simulateFinished(m, "r2") + simulateFinished(m, "r3") + + ev := ec.waitFor(t, "network_idle", 2*time.Second) + assert.GreaterOrEqual(t, time.Since(t0).Milliseconds(), int64(400), "fired too early") + assert.Equal(t, events.CategoryNetwork, ev.Category) }) - m.handleFrameNavigated(navParams, "s1") - // Simulate page_load (Page.loadEventFired). - // We bypass the ffmpeg screenshot side-effect by keeping screenshotFn nil-safe. - t0 := time.Now() - m.handleLoadEventFired(json.RawMessage(`{}`), "s1") + t.Run("timer_reset_on_new_request", func(t *testing.T) { + m, ec := newComputedMonitor(t) + navigateMonitor(m, "https://example.com") - // layout_settled should fire ~1s after page_load (no layout shifts). - ev := waitForEvent(t,published, mu, "layout_settled", 3*time.Second) - elapsed := time.Since(t0) - assert.GreaterOrEqual(t, elapsed.Milliseconds(), int64(900), "layout_settled fired too early") - assert.Equal(t, events.CategoryPage, ev.Category) - assert.Equal(t, events.KindCDP, ev.Source.Kind) - assert.Equal(t, "", ev.Source.Event) + simulateRequest(m, "a1") + simulateFinished(m, "a1") + time.Sleep(200 * time.Millisecond) - // --- Layout shift resets the timer --- - m2, published2, mu2 := newComputedMonitor(t) - navParams2, _ := json.Marshal(map[string]any{ - "frame": map[string]any{"id": "f1", "url": "https://example.com"}, - }) - m2.handleFrameNavigated(navParams2, "s1") - m2.handleLoadEventFired(json.RawMessage(`{}`), "s1") + simulateRequest(m, "a2") + t1 := time.Now() + simulateFinished(m, "a2") - // Simulate a native CDP layout shift at 600ms. - time.Sleep(600 * time.Millisecond) - shiftParams, _ := json.Marshal(map[string]any{ - "event": map[string]any{"type": "layout-shift"}, + ec.waitFor(t, "network_idle", 2*time.Second) + assert.GreaterOrEqual(t, time.Since(t1).Milliseconds(), int64(400), "should reset timer on new request") }) - m2.handleTimelineEvent(shiftParams, "s1") - t1 := time.Now() - - // layout_settled fires ~1s after the shift, not 1s after page_load. - ev2 := waitForEvent(t,published2, mu2, "layout_settled", 3*time.Second) - elapsed2 := time.Since(t1) - assert.GreaterOrEqual(t, elapsed2.Milliseconds(), int64(900), "layout_settled should reset after layout_shift") - assert.Equal(t, events.CategoryPage, ev2.Category) } -// TestScrollSettled verifies that a scroll_settled sentinel from JS is passed through. -func TestScrollSettled(t *testing.T) { - m, published, mu := newComputedMonitor(t) +func TestLayoutSettled(t *testing.T) { + t.Run("debounce_1s_after_page_load", func(t *testing.T) { + m, ec := newComputedMonitor(t) + navigateMonitor(m, "https://example.com") + + t0 := time.Now() + m.handleLoadEventFired(json.RawMessage(`{}`), "s1") - // Simulate scroll_settled via Runtime.bindingCalled. - bindingParams, _ := json.Marshal(map[string]any{ - "name": "__kernelEvent", - "payload": `{"type":"scroll_settled"}`, + ev := ec.waitFor(t, "layout_settled", 3*time.Second) + assert.GreaterOrEqual(t, time.Since(t0).Milliseconds(), int64(900), "fired too early") + assert.Equal(t, events.CategoryPage, ev.Category) }) - m.handleBindingCalled(bindingParams, "s1") - ev := waitForEvent(t,published, mu, "scroll_settled", 1*time.Second) - assert.Equal(t, events.CategoryInteraction, ev.Category) + t.Run("layout_shift_resets_timer", func(t *testing.T) { + m, ec := newComputedMonitor(t) + navigateMonitor(m, "https://example.com") + m.handleLoadEventFired(json.RawMessage(`{}`), "s1") + + time.Sleep(600 * time.Millisecond) + shiftParams, _ := json.Marshal(map[string]any{ + "event": map[string]any{"type": "layout-shift"}, + }) + m.handleTimelineEvent(shiftParams, "s1") + t1 := time.Now() + + ec.waitFor(t, "layout_settled", 3*time.Second) + assert.GreaterOrEqual(t, time.Since(t1).Milliseconds(), int64(900), "should reset after layout_shift") + }) } -// TestNavigationSettled verifies the three-flag gate for navigation_settled. func TestNavigationSettled(t *testing.T) { - m, published, mu := newComputedMonitor(t) + t.Run("fires_when_all_three_flags_set", func(t *testing.T) { + m, ec := newComputedMonitor(t) + navigateMonitor(m, "https://example.com") - // Navigate to initialise flags. - navParams, _ := json.Marshal(map[string]any{ - "frame": map[string]any{"id": "f1", "url": "https://example.com"}, - }) - m.handleFrameNavigated(navParams, "s1") + m.handleDOMContentLoaded(json.RawMessage(`{}`), "s1") - // Trigger dom_content_loaded. - m.handleDOMContentLoaded(json.RawMessage(`{}`), "s1") + // Trigger network_idle. + simulateRequest(m, "r1") + simulateFinished(m, "r1") - // Trigger network_idle via load cycle. - reqP, _ := json.Marshal(map[string]any{ - "requestId": "r1", "resourceType": "Document", - "request": map[string]any{"method": "GET", "url": "https://example.com/r1"}, + // Trigger layout_settled via page_load. + m.handleLoadEventFired(json.RawMessage(`{}`), "s1") + + ev := ec.waitFor(t, "navigation_settled", 3*time.Second) + assert.Equal(t, events.CategoryPage, ev.Category) }) - m.handleNetworkRequest(reqP, "s1") - m.pendReqMu.Lock() - m.pendingRequests["r1"] = networkReqState{method: "GET", url: "https://example.com/r1"} - m.pendReqMu.Unlock() - finP, _ := json.Marshal(map[string]any{"requestId": "r1"}) - m.handleLoadingFinished(finP, "s1") - // Trigger layout_settled via page_load (1s timer). - m.handleLoadEventFired(json.RawMessage(`{}`), "s1") + t.Run("interrupted_by_new_navigation", func(t *testing.T) { + m, ec := newComputedMonitor(t) + navigateMonitor(m, "https://example.com") - // Wait for navigation_settled (all three flags set). - ev := waitForEvent(t,published, mu, "navigation_settled", 3*time.Second) - assert.Equal(t, events.CategoryPage, ev.Category) - assert.Equal(t, events.KindCDP, ev.Source.Kind) - assert.Equal(t, "", ev.Source.Event) + m.handleDOMContentLoaded(json.RawMessage(`{}`), "s1") - // --- Navigation interrupt test --- - m2, published2, mu2 := newComputedMonitor(t) + simulateRequest(m, "r2") + simulateFinished(m, "r2") - navP1, _ := json.Marshal(map[string]any{ - "frame": map[string]any{"id": "f1", "url": "https://example.com"}, - }) - m2.handleFrameNavigated(navP1, "s1") + // Interrupt before layout_settled fires. + navigateMonitor(m, "https://example.com/page2") - // Start sequence: dom_content_loaded + network_idle. - m2.handleDOMContentLoaded(json.RawMessage(`{}`), "s1") - reqP2, _ := json.Marshal(map[string]any{ - "requestId": "r2", "resourceType": "Document", - "request": map[string]any{"method": "GET", "url": "https://example.com/r2"}, + ec.assertNone(t, "navigation_settled", 1500*time.Millisecond) }) - m2.handleNetworkRequest(reqP2, "s1") - m2.pendReqMu.Lock() - m2.pendingRequests["r2"] = networkReqState{method: "GET", url: "https://example.com/r2"} - m2.pendReqMu.Unlock() - finP2, _ := json.Marshal(map[string]any{"requestId": "r2"}) - m2.handleLoadingFinished(finP2, "s1") - - // Interrupt with a new navigation before layout_settled fires. - navP2, _ := json.Marshal(map[string]any{ - "frame": map[string]any{"id": "f1", "url": "https://example.com/page2"}, - }) - m2.handleFrameNavigated(navP2, "s1") - - // navigation_settled should NOT fire for the interrupted sequence. - noEventWithin(t, published2, mu2, "navigation_settled", 1500*time.Millisecond) - _ = mu2 // suppress unused warning } From 64673a5e0258017e525b62d71e97c91c2d8d2ae9 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Mon, 6 Apr 2026 12:22:42 +0000 Subject: [PATCH 08/13] review: clean up functions and tests --- server/lib/cdpmonitor/computed.go | 6 +- server/lib/cdpmonitor/domains.go | 59 ++--------- server/lib/cdpmonitor/handlers.go | 48 ++++----- server/lib/cdpmonitor/interaction.js | 50 ++++++++++ server/lib/cdpmonitor/monitor.go | 137 +++++++++++++------------- server/lib/cdpmonitor/monitor_test.go | 78 ++++++--------- server/lib/cdpmonitor/screenshot.go | 6 +- 7 files changed, 187 insertions(+), 197 deletions(-) create mode 100644 server/lib/cdpmonitor/interaction.js diff --git a/server/lib/cdpmonitor/computed.go b/server/lib/cdpmonitor/computed.go index 1bbe4573..0a8edfad 100644 --- a/server/lib/cdpmonitor/computed.go +++ b/server/lib/cdpmonitor/computed.go @@ -94,7 +94,7 @@ func (s *computedState) onLoadingFinished() { if s.netPending > 0 || s.netFired { return } - // All requests done and not yet fired — start 500 ms debounce timer. + // All requests done and not yet fired: start 500ms debounce timer. stopTimer(s.netTimer) s.netTimer = time.AfterFunc(networkIdleDebounce, func() { s.mu.Lock() @@ -124,7 +124,7 @@ func (s *computedState) onPageLoad() { if s.layoutFired { return } - // Start the 1 s layout_settled timer. + // Start the 1s layout_settled timer. stopTimer(s.layoutTimer) s.layoutTimer = time.AfterFunc(layoutSettledDebounce, s.emitLayoutSettled) } @@ -136,7 +136,7 @@ func (s *computedState) onLayoutShift() { if s.layoutFired || !s.pageLoadSeen { return } - // Reset the timer to 1 s from now. + // Reset the timer to 1s from now. stopTimer(s.layoutTimer) s.layoutTimer = time.AfterFunc(layoutSettledDebounce, s.emitLayoutSettled) } diff --git a/server/lib/cdpmonitor/domains.go b/server/lib/cdpmonitor/domains.go index 1e95e0b3..31315f07 100644 --- a/server/lib/cdpmonitor/domains.go +++ b/server/lib/cdpmonitor/domains.go @@ -1,6 +1,9 @@ package cdpmonitor -import "context" +import ( + "context" + _ "embed" +) // bindingName is the JS function exposed via Runtime.addBinding. // Page JS calls this to fire Runtime.bindingCalled CDP events. @@ -13,7 +16,6 @@ func (m *Monitor) enableDomains(ctx context.Context, sessionID string) { "Runtime.enable", "Network.enable", "Page.enable", - "DOM.enable", } { _, _ = m.send(ctx, method, nil, sessionID) } @@ -29,56 +31,9 @@ func (m *Monitor) enableDomains(ctx context.Context, sessionID string) { // injectedJS tracks clicks, keys, and scrolls via the __kernelEvent binding. // Layout shifts are handled natively by PerformanceTimeline.enable. -const injectedJS = `(function() { - if (window.__kernelEventInjected) return; - var send = window.__kernelEvent; - if (!send) return; - window.__kernelEventInjected = true; - - function sel(el) { - return el.id ? '#' + el.id : (el.className ? '.' + String(el.className).split(' ')[0] : ''); - } - - document.addEventListener('click', function(e) { - var t = e.target || {}; - send(JSON.stringify({ - type: 'interaction_click', - x: e.clientX, y: e.clientY, - selector: sel(t), tag: t.tagName || '', - text: (t.innerText || '').slice(0, 100) - })); - }, true); - - document.addEventListener('keydown', function(e) { - var t = e.target || {}; - send(JSON.stringify({ - type: 'interaction_key', - key: e.key, - selector: sel(t), tag: t.tagName || '' - })); - }, true); - - var scrollTimer = null; - var scrollStart = {x: window.scrollX, y: window.scrollY}; - document.addEventListener('scroll', function(e) { - var fromX = scrollStart.x, fromY = scrollStart.y; - var target = e.target; - var s = target === document ? 'document' : sel(target); - if (scrollTimer) clearTimeout(scrollTimer); - scrollTimer = setTimeout(function() { - var toX = window.scrollX, toY = window.scrollY; - if (Math.abs(toX - fromX) > 5 || Math.abs(toY - fromY) > 5) { - send(JSON.stringify({ - type: 'scroll_settled', - from_x: fromX, from_y: fromY, - to_x: toX, to_y: toY, - target_selector: s - })); - } - scrollStart = {x: toX, y: toY}; - }, 300); - }, true); -})();` +// +//go:embed interaction.js +var injectedJS string // injectScript registers the interaction tracking JS for the given session. func (m *Monitor) injectScript(ctx context.Context, sessionID string) error { diff --git a/server/lib/cdpmonitor/handlers.go b/server/lib/cdpmonitor/handlers.go index 35664993..c00d6a64 100644 --- a/server/lib/cdpmonitor/handlers.go +++ b/server/lib/cdpmonitor/handlers.go @@ -52,8 +52,6 @@ func (m *Monitor) dispatchEvent(msg cdpMessage) { m.handleDOMContentLoaded(msg.Params, msg.SessionID) case "Page.loadEventFired": m.handleLoadEventFired(msg.Params, msg.SessionID) - case "DOM.documentUpdated": - m.handleDOMUpdated(msg.Params, msg.SessionID) case "PerformanceTimeline.timelineEventAdded": m.handleTimelineEvent(msg.Params, msg.SessionID) case "Target.attachedToTarget": @@ -101,7 +99,7 @@ func (m *Monitor) handleExceptionThrown(params json.RawMessage, sessionID string "stack_trace": p.ExceptionDetails.StackTrace, }) m.publishEvent("console_error", events.DetailStandard, events.Source{Kind: events.KindCDP}, "Runtime.exceptionThrown", data, sessionID) - go m.maybeScreenshot(m.getLifecycleCtx()) + go m.tryScreenshot(m.getLifecycleCtx()) } // handleBindingCalled processes __kernelEvent binding calls from the page. @@ -214,22 +212,7 @@ func (m *Monitor) handleLoadingFinished(params json.RawMessage, sessionID string } // Fetch response body async to avoid blocking readLoop; binary types are skipped. go func() { - ctx := m.getLifecycleCtx() - body := "" - if isTextualResource(state.resourceType, state.mimeType) { - result, err := m.send(ctx, "Network.getResponseBody", map[string]any{ - "requestId": p.RequestID, - }, sessionID) - if err == nil { - var resp struct { - Body string `json:"body"` - Base64Encoded bool `json:"base64Encoded"` - } - if json.Unmarshal(result, &resp) == nil { - body = truncateBody(resp.Body, bodyCapFor(state.mimeType)) - } - } - } + body := m.fetchResponseBody(p.RequestID, sessionID, state) data, _ := json.Marshal(map[string]any{ "method": state.method, "url": state.url, @@ -249,6 +232,27 @@ func (m *Monitor) handleLoadingFinished(params json.RawMessage, sessionID string }() } +// fetchResponseBody retrieves and truncates the response body for textual resources. +func (m *Monitor) fetchResponseBody(requestID, sessionID string, state networkReqState) string { + if !isTextualResource(state.resourceType, state.mimeType) { + return "" + } + result, err := m.send(m.getLifecycleCtx(), "Network.getResponseBody", map[string]any{ + "requestId": requestID, + }, sessionID) + if err != nil { + return "" + } + var resp struct { + Body string `json:"body"` + Base64Encoded bool `json:"base64Encoded"` + } + if json.Unmarshal(result, &resp) != nil { + return "" + } + return truncateBody(resp.Body, bodyCapFor(state.mimeType)) +} + func (m *Monitor) handleLoadingFailed(params json.RawMessage, sessionID string) { var p struct { RequestID string `json:"requestId"` @@ -319,11 +323,7 @@ func (m *Monitor) handleDOMContentLoaded(params json.RawMessage, sessionID strin func (m *Monitor) handleLoadEventFired(params json.RawMessage, sessionID string) { m.publishEvent("page_load", events.DetailMinimal, events.Source{Kind: events.KindCDP}, "Page.loadEventFired", params, sessionID) m.computed.onPageLoad() - go m.maybeScreenshot(m.getLifecycleCtx()) -} - -func (m *Monitor) handleDOMUpdated(params json.RawMessage, sessionID string) { - m.publishEvent("dom_updated", events.DetailMinimal, events.Source{Kind: events.KindCDP}, "DOM.documentUpdated", params, sessionID) + go m.tryScreenshot(m.getLifecycleCtx()) } // handleAttachedToTarget stores the new session then enables domains and injects script. diff --git a/server/lib/cdpmonitor/interaction.js b/server/lib/cdpmonitor/interaction.js new file mode 100644 index 00000000..8b107fd9 --- /dev/null +++ b/server/lib/cdpmonitor/interaction.js @@ -0,0 +1,50 @@ +(function() { + if (window.__kernelEventInjected) return; + var send = window.__kernelEvent; + if (!send) return; + window.__kernelEventInjected = true; + + function sel(el) { + return el.id ? '#' + el.id : (el.className ? '.' + String(el.className).split(' ')[0] : ''); + } + + document.addEventListener('click', function(e) { + var t = e.target || {}; + send(JSON.stringify({ + type: 'interaction_click', + x: e.clientX, y: e.clientY, + selector: sel(t), tag: t.tagName || '', + text: (t.innerText || '').slice(0, 100) + })); + }, true); + + document.addEventListener('keydown', function(e) { + var t = e.target || {}; + send(JSON.stringify({ + type: 'interaction_key', + key: e.key, + selector: sel(t), tag: t.tagName || '' + })); + }, true); + + var scrollTimer = null; + var scrollStart = {x: window.scrollX, y: window.scrollY}; + document.addEventListener('scroll', function(e) { + var fromX = scrollStart.x, fromY = scrollStart.y; + var target = e.target; + var s = target === document ? 'document' : sel(target); + if (scrollTimer) clearTimeout(scrollTimer); + scrollTimer = setTimeout(function() { + var toX = window.scrollX, toY = window.scrollY; + if (Math.abs(toX - fromX) > 5 || Math.abs(toY - fromY) > 5) { + send(JSON.stringify({ + type: 'scroll_settled', + from_x: fromX, from_y: fromY, + to_x: toX, to_y: toY, + target_selector: s + })); + } + scrollStart = {x: toX, y: toY}; + }, 300); + }, true); +})(); \ No newline at end of file diff --git a/server/lib/cdpmonitor/monitor.go b/server/lib/cdpmonitor/monitor.go index 4422c8a4..2230ce37 100644 --- a/server/lib/cdpmonitor/monitor.go +++ b/server/lib/cdpmonitor/monitor.go @@ -338,13 +338,6 @@ func (m *Monitor) subscribeToUpstream(ctx context.Context) { ch, cancel := m.upstreamMgr.Subscribe() defer cancel() - backoffs := []time.Duration{ - 250 * time.Millisecond, - 500 * time.Millisecond, - 1 * time.Second, - 2 * time.Second, - } - for { select { case <-ctx.Done(): @@ -353,73 +346,85 @@ func (m *Monitor) subscribeToUpstream(ctx context.Context) { if !ok { return } - m.publish(events.Event{ - Ts: time.Now().UnixMilli(), - Type: "monitor_disconnected", - Category: events.CategorySystem, - Source: events.Source{Kind: events.KindLocalProcess}, - DetailLevel: events.DetailMinimal, - Data: json.RawMessage(`{"reason":"chrome_restarted"}`), - }) - - startReconnect := time.Now() - - m.lifeMu.Lock() - if m.conn != nil { - _ = m.conn.Close(websocket.StatusNormalClosure, "reconnecting") - m.conn = nil - } - m.lifeMu.Unlock() + m.handleUpstreamRestart(ctx, newURL) + } + } +} - // Clear stale state from the previous Chrome instance. - m.clearState() +// handleUpstreamRestart tears down the old connection, reconnects with backoff, +// and re-initializes the CDP session. +func (m *Monitor) handleUpstreamRestart(ctx context.Context, newURL string) { + m.publish(events.Event{ + Ts: time.Now().UnixMilli(), + Type: "monitor_disconnected", + Category: events.CategorySystem, + Source: events.Source{Kind: events.KindLocalProcess}, + DetailLevel: events.DetailMinimal, + Data: json.RawMessage(`{"reason":"chrome_restarted"}`), + }) - var reconnErr error - for attempt := range 10 { - if ctx.Err() != nil { - return - } + startReconnect := time.Now() - idx := min(attempt, len(backoffs)-1) - select { - case <-ctx.Done(): - return - case <-time.After(backoffs[idx]): - } + m.lifeMu.Lock() + if m.conn != nil { + _ = m.conn.Close(websocket.StatusNormalClosure, "reconnecting") + m.conn = nil + } + m.lifeMu.Unlock() - conn, _, err := websocket.Dial(ctx, newURL, nil) - if err != nil { - reconnErr = err - continue - } - conn.SetReadLimit(wsReadLimit) + m.clearState() - m.lifeMu.Lock() - m.conn = conn - m.lifeMu.Unlock() + if !m.reconnectWithBackoff(ctx, newURL) { + return + } - reconnErr = nil - break - } + m.restartReadLoop(ctx) + go m.initSession(ctx) + + m.publish(events.Event{ + Ts: time.Now().UnixMilli(), + Type: "monitor_reconnected", + Category: events.CategorySystem, + Source: events.Source{Kind: events.KindLocalProcess}, + DetailLevel: events.DetailMinimal, + Data: json.RawMessage(fmt.Sprintf( + `{"reconnect_duration_ms":%d}`, + time.Since(startReconnect).Milliseconds(), + )), + }) +} - if reconnErr != nil { - return - } +var reconnectBackoffs = []time.Duration{ + 250 * time.Millisecond, + 500 * time.Millisecond, + 1 * time.Second, + 2 * time.Second, +} + +// reconnectWithBackoff attempts to dial newURL up to 10 times with exponential backoff. +func (m *Monitor) reconnectWithBackoff(ctx context.Context, newURL string) bool { + for attempt := range 10 { + if ctx.Err() != nil { + return false + } + + idx := min(attempt, len(reconnectBackoffs)-1) + select { + case <-ctx.Done(): + return false + case <-time.After(reconnectBackoffs[idx]): + } - m.restartReadLoop(ctx) - go m.initSession(ctx) - - m.publish(events.Event{ - Ts: time.Now().UnixMilli(), - Type: "monitor_reconnected", - Category: events.CategorySystem, - Source: events.Source{Kind: events.KindLocalProcess}, - DetailLevel: events.DetailMinimal, - Data: json.RawMessage(fmt.Sprintf( - `{"reconnect_duration_ms":%d}`, - time.Since(startReconnect).Milliseconds(), - )), - }) + conn, _, err := websocket.Dial(ctx, newURL, nil) + if err != nil { + continue } + conn.SetReadLimit(wsReadLimit) + + m.lifeMu.Lock() + m.conn = conn + m.lifeMu.Unlock() + return true } + return false } diff --git a/server/lib/cdpmonitor/monitor_test.go b/server/lib/cdpmonitor/monitor_test.go index 8f793340..2ee16c3c 100644 --- a/server/lib/cdpmonitor/monitor_test.go +++ b/server/lib/cdpmonitor/monitor_test.go @@ -1,8 +1,12 @@ package cdpmonitor import ( + "bytes" "context" "encoding/json" + "image" + "image/color" + "image/png" "net/http" "net/http/httptest" "strings" @@ -18,9 +22,14 @@ import ( "github.com/stretchr/testify/require" ) -// --------------------------------------------------------------------------- -// Test infrastructure -// --------------------------------------------------------------------------- +// minimalPNG is a valid 1x1 PNG used as a test fixture for screenshot tests. +var minimalPNG = func() []byte { + img := image.NewRGBA(image.Rect(0, 0, 1, 1)) + img.Set(0, 0, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + var buf bytes.Buffer + _ = png.Encode(&buf, img) + return buf.Bytes() +}() // fakeCDPServer is a minimal WebSocket server that accepts connections and // lets the test drive scripted message sequences. @@ -241,7 +250,6 @@ func (c *eventCollector) assertNone(t *testing.T, eventType string, d time.Durat } } - // ResponderFunc is called for each CDP command the Monitor sends. // Return nil to use the default empty result. type ResponderFunc func(msg cdpMessage) any @@ -297,7 +305,7 @@ func startMonitor(t *testing.T, srv *fakeCDPServer, fn ResponderFunc) (*Monitor, // Wait for the init sequence (setAutoAttach + domain enables + script injection // + getTargets) to complete. The responder goroutine handles all responses; // we just need to wait for the burst to finish. - waitForInitDone(t, srv) + waitForInitDone(t) cleanup := func() { close(stopResponder) @@ -309,7 +317,7 @@ func startMonitor(t *testing.T, srv *fakeCDPServer, fn ResponderFunc) (*Monitor, // waitForInitDone waits for the Monitor's init sequence to complete by // detecting a 100ms gap in activity on the message channel. The responder // goroutine handles responses; this just waits for the burst to end. -func waitForInitDone(t *testing.T, _ *fakeCDPServer) { +func waitForInitDone(t *testing.T) { t.Helper() // The init sequence sends ~8 commands. Wait until the responder has // processed them all by checking for a quiet period. @@ -342,10 +350,6 @@ func navigateMonitor(m *Monitor, url string) { m.handleFrameNavigated(p, "s1") } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - func TestAutoAttach(t *testing.T) { srv := newFakeCDPServer(t) defer srv.close() @@ -533,9 +537,11 @@ func TestNetworkEvents(t *testing.T) { srv := newFakeCDPServer(t) defer srv.close() - // Custom responder: return a body for Network.getResponseBody. + // Custom responder: return a body for Network.getResponseBody and track calls. + var getBodyCalled atomic.Bool responder := func(msg cdpMessage) any { if msg.Method == "Network.getResponseBody" { + getBodyCalled.Store(true) return map[string]any{ "id": msg.ID, "result": map[string]any{"body": `{"ok":true}`, "base64Encoded": false}, @@ -619,7 +625,7 @@ func TestNetworkEvents(t *testing.T) { }) t.Run("binary_resource_skips_body", func(t *testing.T) { - var getBodyCalled atomic.Bool + getBodyCalled.Store(false) srv.sendToMonitor(t, map[string]any{ "method": "Network.requestWillBeSent", "params": map[string]any{ @@ -683,14 +689,6 @@ func TestPageEvents(t *testing.T) { ev3 := ec.waitFor(t, "page_load", 2*time.Second) assert.Equal(t, events.CategoryPage, ev3.Category) assert.Equal(t, events.DetailMinimal, ev3.DetailLevel) - - srv.sendToMonitor(t, map[string]any{ - "method": "DOM.documentUpdated", - "params": map[string]any{}, - }) - ev4 := ec.waitFor(t, "dom_updated", 2*time.Second) - assert.Equal(t, events.CategoryPage, ev4.Category) - assert.Equal(t, events.DetailMinimal, ev4.DetailLevel) } func TestTargetEvents(t *testing.T) { @@ -789,24 +787,13 @@ func TestScreenshot(t *testing.T) { defer cleanup() var captureCount atomic.Int32 - minimalPNG := []byte{ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, - 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, - 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, - 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, - 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, - 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, - 0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc, - 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, - 0x44, 0xae, 0x42, 0x60, 0x82, - } m.screenshotFn = func(ctx context.Context, displayNum int) ([]byte, error) { captureCount.Add(1) return minimalPNG, nil } t.Run("capture_and_publish", func(t *testing.T) { - m.maybeScreenshot(context.Background()) + m.tryScreenshot(context.Background()) require.Eventually(t, func() bool { return captureCount.Load() == 1 }, 2*time.Second, 20*time.Millisecond) ev := ec.waitFor(t, "screenshot", 2*time.Second) @@ -819,7 +806,7 @@ func TestScreenshot(t *testing.T) { t.Run("rate_limited", func(t *testing.T) { before := captureCount.Load() - m.maybeScreenshot(context.Background()) + m.tryScreenshot(context.Background()) time.Sleep(100 * time.Millisecond) assert.Equal(t, before, captureCount.Load(), "should be rate-limited within 2s") }) @@ -827,7 +814,7 @@ func TestScreenshot(t *testing.T) { t.Run("captures_after_cooldown", func(t *testing.T) { m.lastScreenshotAt.Store(time.Now().Add(-3 * time.Second).UnixMilli()) before := captureCount.Load() - m.maybeScreenshot(context.Background()) + m.tryScreenshot(context.Background()) require.Eventually(t, func() bool { return captureCount.Load() > before }, 2*time.Second, 20*time.Millisecond) }) } @@ -837,9 +824,6 @@ func TestAttachExistingTargets(t *testing.T) { defer srv.close() responder := func(msg cdpMessage) any { - srv.connMu.Lock() - c := srv.conn - srv.connMu.Unlock() switch msg.Method { case "Target.getTargets": return map[string]any{ @@ -851,15 +835,13 @@ func TestAttachExistingTargets(t *testing.T) { }, } case "Target.attachToTarget": - if c != nil { - _ = wsjson.Write(context.Background(), c, map[string]any{ - "method": "Target.attachedToTarget", - "params": map[string]any{ - "sessionId": "session-existing-1", - "targetInfo": map[string]any{"targetId": "existing-1", "type": "page", "url": "https://preexisting.example.com"}, - }, - }) - } + srv.sendToMonitor(t, map[string]any{ + "method": "Target.attachedToTarget", + "params": map[string]any{ + "sessionId": "session-existing-1", + "targetInfo": map[string]any{"targetId": "existing-1", "type": "page", "url": "https://preexisting.example.com"}, + }, + }) return map[string]any{"id": msg.ID, "result": map[string]any{"sessionId": "session-existing-1"}} } return nil @@ -907,10 +889,6 @@ func TestURLPopulated(t *testing.T) { assert.Equal(t, "https://example.com/page", ev.URL) } -// --------------------------------------------------------------------------- -// Computed meta-event tests — use direct handler calls, no websocket needed. -// --------------------------------------------------------------------------- - // simulateRequest sends a Network.requestWillBeSent through the handler. func simulateRequest(m *Monitor, id string) { p, _ := json.Marshal(map[string]any{ diff --git a/server/lib/cdpmonitor/screenshot.go b/server/lib/cdpmonitor/screenshot.go index abb559d2..e3ca3c39 100644 --- a/server/lib/cdpmonitor/screenshot.go +++ b/server/lib/cdpmonitor/screenshot.go @@ -6,16 +6,17 @@ import ( "encoding/base64" "encoding/json" "fmt" + "io" "os/exec" "time" "github.com/onkernel/kernel-images/server/lib/events" ) -// maybeScreenshot triggers a screenshot if the rate-limit window has elapsed. +// tryScreenshot triggers a screenshot if the rate-limit window has elapsed. // It uses an atomic CAS on lastScreenshotAt to ensure only one screenshot runs // at a time. -func (m *Monitor) maybeScreenshot(ctx context.Context) { +func (m *Monitor) tryScreenshot(ctx context.Context) { now := time.Now().UnixMilli() last := m.lastScreenshotAt.Load() if now-last < 2000 { @@ -80,6 +81,7 @@ func captureViaFFmpeg(ctx context.Context, displayNum, divisor int) ([]byte, err var out bytes.Buffer cmd := exec.CommandContext(ctx, "ffmpeg", args...) cmd.Stdout = &out + cmd.Stderr = io.Discard if err := cmd.Run(); err != nil { return nil, err } From 3cebd20ebe88ed31ad27119ea404f97f15b5ed73 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Mon, 6 Apr 2026 13:27:21 +0000 Subject: [PATCH 09/13] review: fix naming --- server/lib/cdpmonitor/computed.go | 31 ++++++++++++++++++--------- server/lib/cdpmonitor/handlers.go | 28 +++++++++++++----------- server/lib/cdpmonitor/monitor.go | 11 +++++----- server/lib/cdpmonitor/monitor_test.go | 2 +- server/lib/cdpmonitor/types.go | 27 ++++++++++++++++++++++- server/lib/events/event.go | 8 +++---- 6 files changed, 73 insertions(+), 34 deletions(-) diff --git a/server/lib/cdpmonitor/computed.go b/server/lib/cdpmonitor/computed.go index 0a8edfad..576a28c6 100644 --- a/server/lib/cdpmonitor/computed.go +++ b/server/lib/cdpmonitor/computed.go @@ -17,6 +17,11 @@ type computedState struct { mu sync.Mutex publish PublishFunc + // navSeq is incremented on every resetOnNavigation. AfterFunc callbacks + // capture their navSeq at creation and bail if it has changed, preventing + // stale timers from publishing events for a previous navigation. + navSeq int + // network_idle: 500 ms debounce after all pending requests finish. netPending int netTimer *time.Timer @@ -52,11 +57,14 @@ func stopTimer(t *time.Timer) { } } -// resetOnNavigation resets all state machines. Called on Page.frameNavigated +// resetOnNavigation resets all state machines. Called on Page.frameNavigated. +// Increments navSeq so any AfterFunc callbacks already running will discard their results. func (s *computedState) resetOnNavigation() { s.mu.Lock() defer s.mu.Unlock() + s.navSeq++ + stopTimer(s.netTimer) s.netTimer = nil s.netPending = 0 @@ -96,17 +104,18 @@ func (s *computedState) onLoadingFinished() { } // All requests done and not yet fired: start 500ms debounce timer. stopTimer(s.netTimer) + navSeq := s.navSeq s.netTimer = time.AfterFunc(networkIdleDebounce, func() { s.mu.Lock() defer s.mu.Unlock() - if s.netFired || s.netPending > 0 { + if s.navSeq != navSeq || s.netFired || s.netPending > 0 { return } s.netFired = true s.navNetIdle = true s.publish(events.Event{ Ts: time.Now().UnixMilli(), - Type: "network_idle", + Type: EventNetworkIdle, Category: events.CategoryNetwork, Source: events.Source{Kind: events.KindCDP}, DetailLevel: events.DetailStandard, @@ -126,7 +135,8 @@ func (s *computedState) onPageLoad() { } // Start the 1s layout_settled timer. stopTimer(s.layoutTimer) - s.layoutTimer = time.AfterFunc(layoutSettledDebounce, s.emitLayoutSettled) + navSeq := s.navSeq + s.layoutTimer = time.AfterFunc(layoutSettledDebounce, func() { s.emitLayoutSettled(navSeq) }) } // onLayoutShift is called when a layout_shift sentinel arrives from injected JS. @@ -138,21 +148,22 @@ func (s *computedState) onLayoutShift() { } // Reset the timer to 1s from now. stopTimer(s.layoutTimer) - s.layoutTimer = time.AfterFunc(layoutSettledDebounce, s.emitLayoutSettled) + navSeq := s.navSeq + s.layoutTimer = time.AfterFunc(layoutSettledDebounce, func() { s.emitLayoutSettled(navSeq) }) } -// emitLayoutSettled is called from the layout timer's AfterFunc goroutine -func (s *computedState) emitLayoutSettled() { +// emitLayoutSettled is called from the layout timer's AfterFunc goroutine. +func (s *computedState) emitLayoutSettled(navSeq int) { s.mu.Lock() defer s.mu.Unlock() - if s.layoutFired || !s.pageLoadSeen { + if s.navSeq != navSeq || s.layoutFired || !s.pageLoadSeen { return } s.layoutFired = true s.navLayoutSettled = true s.publish(events.Event{ Ts: time.Now().UnixMilli(), - Type: "layout_settled", + Type: EventLayoutSettled, Category: events.CategoryPage, Source: events.Source{Kind: events.KindCDP}, DetailLevel: events.DetailStandard, @@ -175,7 +186,7 @@ func (s *computedState) checkNavigationSettled() { s.navFired = true s.publish(events.Event{ Ts: time.Now().UnixMilli(), - Type: "navigation_settled", + Type: EventNavigationSettled, Category: events.CategoryPage, Source: events.Source{Kind: events.KindCDP}, DetailLevel: events.DetailStandard, diff --git a/server/lib/cdpmonitor/handlers.go b/server/lib/cdpmonitor/handlers.go index c00d6a64..a9841738 100644 --- a/server/lib/cdpmonitor/handlers.go +++ b/server/lib/cdpmonitor/handlers.go @@ -83,7 +83,7 @@ func (m *Monitor) handleConsole(params json.RawMessage, sessionID string) { "args": argValues, "stack_trace": p.StackTrace, }) - m.publishEvent("console_log", events.DetailStandard, events.Source{Kind: events.KindCDP}, "Runtime.consoleAPICalled", data, sessionID) + m.publishEvent(EventConsoleLog, events.DetailStandard, events.Source{Kind: events.KindCDP}, "Runtime.consoleAPICalled", data, sessionID) } func (m *Monitor) handleExceptionThrown(params json.RawMessage, sessionID string) { @@ -98,7 +98,7 @@ func (m *Monitor) handleExceptionThrown(params json.RawMessage, sessionID string "url": p.ExceptionDetails.URL, "stack_trace": p.ExceptionDetails.StackTrace, }) - m.publishEvent("console_error", events.DetailStandard, events.Source{Kind: events.KindCDP}, "Runtime.exceptionThrown", data, sessionID) + m.publishEvent(EventConsoleError, events.DetailStandard, events.Source{Kind: events.KindCDP}, "Runtime.exceptionThrown", data, sessionID) go m.tryScreenshot(m.getLifecycleCtx()) } @@ -122,7 +122,7 @@ func (m *Monitor) handleBindingCalled(params json.RawMessage, sessionID string) return } switch header.Type { - case "interaction_click", "interaction_key", "scroll_settled": + case EventInteractionClick, EventInteractionKey, EventScrollSettled: m.publishEvent(header.Type, events.DetailStandard, events.Source{Kind: events.KindCDP}, "Runtime.bindingCalled", payload, sessionID) } } @@ -138,7 +138,7 @@ func (m *Monitor) handleTimelineEvent(params json.RawMessage, sessionID string) if err := json.Unmarshal(params, &p); err != nil || p.Event.Type != "layout-shift" { return } - m.publishEvent("layout_shift", events.DetailStandard, events.Source{Kind: events.KindCDP}, "PerformanceTimeline.timelineEventAdded", params, sessionID) + m.publishEvent(EventLayoutShift, events.DetailStandard, events.Source{Kind: events.KindCDP}, "PerformanceTimeline.timelineEventAdded", params, sessionID) m.computed.onLayoutShift() } @@ -174,7 +174,7 @@ func (m *Monitor) handleNetworkRequest(params json.RawMessage, sessionID string) "resource_type": p.ResourceType, "initiator_type": initiatorType, }) - m.publishEvent("network_request", events.DetailStandard, events.Source{Kind: events.KindCDP}, "Network.requestWillBeSent", data, sessionID) + m.publishEvent(EventNetworkRequest, events.DetailStandard, events.Source{Kind: events.KindCDP}, "Network.requestWillBeSent", data, sessionID) m.computed.onRequest() } @@ -210,6 +210,9 @@ func (m *Monitor) handleLoadingFinished(params json.RawMessage, sessionID string if !ok { return } + // Decrement netPending immediately so network_idle tracking reflects true + // network completion, not body fetch completion + m.computed.onLoadingFinished() // Fetch response body async to avoid blocking readLoop; binary types are skipped. go func() { body := m.fetchResponseBody(p.RequestID, sessionID, state) @@ -227,8 +230,7 @@ func (m *Monitor) handleLoadingFinished(params json.RawMessage, sessionID string if body != "" { detail = events.DetailVerbose } - m.publishEvent("network_response", detail, events.Source{Kind: events.KindCDP}, "Network.loadingFinished", data, sessionID) - m.computed.onLoadingFinished() + m.publishEvent(EventNetworkResponse, detail, events.Source{Kind: events.KindCDP}, "Network.loadingFinished", data, sessionID) }() } @@ -277,7 +279,7 @@ func (m *Monitor) handleLoadingFailed(params json.RawMessage, sessionID string) ev["url"] = state.url } data, _ := json.Marshal(ev) - m.publishEvent("network_loading_failed", events.DetailStandard, events.Source{Kind: events.KindCDP}, "Network.loadingFailed", data, sessionID) + m.publishEvent(EventNetworkLoadingFailed, events.DetailStandard, events.Source{Kind: events.KindCDP}, "Network.loadingFailed", data, sessionID) m.computed.onLoadingFinished() } @@ -302,7 +304,7 @@ func (m *Monitor) handleFrameNavigated(params json.RawMessage, sessionID string) if p.Frame.ParentID == "" { m.currentURL.Store(p.Frame.URL) } - m.publishEvent("navigation", events.DetailStandard, events.Source{Kind: events.KindCDP}, "Page.frameNavigated", data, sessionID) + m.publishEvent(EventNavigation, events.DetailStandard, events.Source{Kind: events.KindCDP}, "Page.frameNavigated", data, sessionID) m.pendReqMu.Lock() for id, req := range m.pendingRequests { @@ -316,12 +318,12 @@ func (m *Monitor) handleFrameNavigated(params json.RawMessage, sessionID string) } func (m *Monitor) handleDOMContentLoaded(params json.RawMessage, sessionID string) { - m.publishEvent("dom_content_loaded", events.DetailMinimal, events.Source{Kind: events.KindCDP}, "Page.domContentEventFired", params, sessionID) + m.publishEvent(EventDOMContentLoaded, events.DetailMinimal, events.Source{Kind: events.KindCDP}, "Page.domContentEventFired", params, sessionID) m.computed.onDOMContentLoaded() } func (m *Monitor) handleLoadEventFired(params json.RawMessage, sessionID string) { - m.publishEvent("page_load", events.DetailMinimal, events.Source{Kind: events.KindCDP}, "Page.loadEventFired", params, sessionID) + m.publishEvent(EventPageLoad, events.DetailMinimal, events.Source{Kind: events.KindCDP}, "Page.loadEventFired", params, sessionID) m.computed.onPageLoad() go m.tryScreenshot(m.getLifecycleCtx()) } @@ -357,7 +359,7 @@ func (m *Monitor) handleTargetCreated(params json.RawMessage, sessionID string) "target_type": p.TargetInfo.Type, "url": p.TargetInfo.URL, }) - m.publishEvent("target_created", events.DetailMinimal, events.Source{Kind: events.KindCDP}, "Target.targetCreated", data, sessionID) + m.publishEvent(EventTargetCreated, events.DetailMinimal, events.Source{Kind: events.KindCDP}, "Target.targetCreated", data, sessionID) } func (m *Monitor) handleTargetDestroyed(params json.RawMessage, sessionID string) { @@ -370,5 +372,5 @@ func (m *Monitor) handleTargetDestroyed(params json.RawMessage, sessionID string data, _ := json.Marshal(map[string]any{ "target_id": p.TargetID, }) - m.publishEvent("target_destroyed", events.DetailMinimal, events.Source{Kind: events.KindCDP}, "Target.targetDestroyed", data, sessionID) + m.publishEvent(EventTargetDestroyed, events.DetailMinimal, events.Source{Kind: events.KindCDP}, "Target.targetDestroyed", data, sessionID) } diff --git a/server/lib/cdpmonitor/monitor.go b/server/lib/cdpmonitor/monitor.go index 2230ce37..1fa44e80 100644 --- a/server/lib/cdpmonitor/monitor.go +++ b/server/lib/cdpmonitor/monitor.go @@ -188,14 +188,15 @@ func (m *Monitor) readLoop(ctx context.Context) { continue } - if msg.ID != 0 { + if msg.ID != nil { m.pendMu.Lock() - ch, ok := m.pending[msg.ID] + ch, ok := m.pending[*msg.ID] m.pendMu.Unlock() if ok { select { case ch <- msg: default: + // send() already timed out and deregistered; discard. } } continue @@ -218,7 +219,7 @@ func (m *Monitor) send(ctx context.Context, method string, params any, sessionID rawParams = b } - req := cdpMessage{ID: id, Method: method, Params: rawParams, SessionID: sessionID} + req := cdpMessage{ID: &id, Method: method, Params: rawParams, SessionID: sessionID} reqBytes, err := json.Marshal(req) if err != nil { return nil, fmt.Errorf("marshal request: %w", err) @@ -356,7 +357,7 @@ func (m *Monitor) subscribeToUpstream(ctx context.Context) { func (m *Monitor) handleUpstreamRestart(ctx context.Context, newURL string) { m.publish(events.Event{ Ts: time.Now().UnixMilli(), - Type: "monitor_disconnected", + Type: EventMonitorDisconnected, Category: events.CategorySystem, Source: events.Source{Kind: events.KindLocalProcess}, DetailLevel: events.DetailMinimal, @@ -383,7 +384,7 @@ func (m *Monitor) handleUpstreamRestart(ctx context.Context, newURL string) { m.publish(events.Event{ Ts: time.Now().UnixMilli(), - Type: "monitor_reconnected", + Type: EventMonitorReconnected, Category: events.CategorySystem, Source: events.Source{Kind: events.KindLocalProcess}, DetailLevel: events.DetailMinimal, diff --git a/server/lib/cdpmonitor/monitor_test.go b/server/lib/cdpmonitor/monitor_test.go index 2ee16c3c..e6851f05 100644 --- a/server/lib/cdpmonitor/monitor_test.go +++ b/server/lib/cdpmonitor/monitor_test.go @@ -261,7 +261,7 @@ func listenAndRespond(srv *fakeCDPServer, stopCh <-chan struct{}, fn ResponderFu select { case b := <-srv.msgCh: var msg cdpMessage - if json.Unmarshal(b, &msg) != nil || msg.ID == 0 { + if json.Unmarshal(b, &msg) != nil || msg.ID == nil { continue } srv.connMu.Lock() diff --git a/server/lib/cdpmonitor/types.go b/server/lib/cdpmonitor/types.go index 9beab2bf..7e6a2ebe 100644 --- a/server/lib/cdpmonitor/types.go +++ b/server/lib/cdpmonitor/types.go @@ -5,6 +5,29 @@ import ( "fmt" ) +// Event type constants for all events published by the cdpmonitor. +const ( + EventConsoleLog = "console_log" + EventConsoleError = "console_error" + EventNetworkRequest = "network_request" + EventNetworkResponse = "network_response" + EventNetworkLoadingFailed = "network_loading_failed" + EventNetworkIdle = "network_idle" + EventNavigation = "navigation" + EventDOMContentLoaded = "dom_content_loaded" + EventPageLoad = "page_load" + EventLayoutShift = "layout_shift" + EventLayoutSettled = "layout_settled" + EventNavigationSettled = "navigation_settled" + EventTargetCreated = "target_created" + EventTargetDestroyed = "target_destroyed" + EventInteractionClick = "interaction_click" + EventInteractionKey = "interaction_key" + EventScrollSettled = "scroll_settled" + EventMonitorDisconnected = "monitor_disconnected" + EventMonitorReconnected = "monitor_reconnected" +) + // targetInfo holds metadata about an attached CDP target/session. type targetInfo struct { targetID string @@ -23,8 +46,10 @@ func (e *cdpError) Error() string { } // cdpMessage is the JSON-RPC message envelope used by Chrome's DevTools Protocol. +// ID is a pointer so we can distinguish an absent id (event) from id=0 (which +// Chrome never sends, but using a pointer is more correct than relying on that). type cdpMessage struct { - ID int64 `json:"id,omitempty"` + ID *int64 `json:"id,omitempty"` Method string `json:"method,omitempty"` Params json.RawMessage `json:"params,omitempty"` SessionID string `json:"sessionId,omitempty"` diff --git a/server/lib/events/event.go b/server/lib/events/event.go index cb5565d8..5ef7060e 100644 --- a/server/lib/events/event.go +++ b/server/lib/events/event.go @@ -69,17 +69,17 @@ type Envelope struct { } // CategoryFor derives an EventCategory from an event type string. -// It splits on the first dot and maps the prefix to a category. +// It splits on the first underscore and maps the prefix to a category. func CategoryFor(eventType string) EventCategory { - prefix, _, _ := strings.Cut(eventType, ".") + prefix, _, _ := strings.Cut(eventType, "_") switch prefix { case "console": return CategoryConsole case "network": return CategoryNetwork - case "page", "navigation", "dom", "target": + case "page", "navigation", "dom", "target", "layout": return CategoryPage - case "interaction", "layout", "scroll": + case "interaction", "scroll": return CategoryInteraction case "liveview": return CategoryLiveview From aacf7c2ed2d5804efff515258f87a98499c4b478 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Mon, 6 Apr 2026 13:40:52 +0000 Subject: [PATCH 10/13] review: split up tests --- server/lib/cdpmonitor/cdp_test.go | 367 +++++++++++ server/lib/cdpmonitor/computed_test.go | 108 ++++ server/lib/cdpmonitor/handlers_test.go | 328 ++++++++++ server/lib/cdpmonitor/monitor_test.go | 861 ++----------------------- 4 files changed, 843 insertions(+), 821 deletions(-) create mode 100644 server/lib/cdpmonitor/cdp_test.go create mode 100644 server/lib/cdpmonitor/computed_test.go create mode 100644 server/lib/cdpmonitor/handlers_test.go diff --git a/server/lib/cdpmonitor/cdp_test.go b/server/lib/cdpmonitor/cdp_test.go new file mode 100644 index 00000000..905b652a --- /dev/null +++ b/server/lib/cdpmonitor/cdp_test.go @@ -0,0 +1,367 @@ +package cdpmonitor + +import ( + "bytes" + "context" + "encoding/json" + "image" + "image/color" + "image/png" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" + "github.com/onkernel/kernel-images/server/lib/events" + "github.com/stretchr/testify/require" +) + +// minimalPNG is a valid 1x1 PNG used as a test fixture for screenshot tests. +var minimalPNG = func() []byte { + img := image.NewRGBA(image.Rect(0, 0, 1, 1)) + img.Set(0, 0, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + var buf bytes.Buffer + _ = png.Encode(&buf, img) + return buf.Bytes() +}() + +// testServer is a minimal WebSocket server that accepts connections and +// lets the test drive scripted message sequences. +type testServer struct { + srv *httptest.Server + conn *websocket.Conn + connMu sync.Mutex + connCh chan struct{} // closed when the first connection is accepted + msgCh chan []byte // inbound messages from Monitor +} + +func newTestServer(t *testing.T) *testServer { + t.Helper() + s := &testServer{ + msgCh: make(chan []byte, 128), + connCh: make(chan struct{}), + } + var connOnce sync.Once + s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true}) + if err != nil { + return + } + s.connMu.Lock() + s.conn = c + s.connMu.Unlock() + connOnce.Do(func() { close(s.connCh) }) + go func() { + for { + _, b, err := c.Read(context.Background()) + if err != nil { + return + } + s.msgCh <- b + } + }() + })) + return s +} + +func (s *testServer) wsURL() string { + return "ws" + strings.TrimPrefix(s.srv.URL, "http") +} + +func (s *testServer) sendToMonitor(t *testing.T, msg any) { + t.Helper() + s.connMu.Lock() + c := s.conn + s.connMu.Unlock() + require.NotNil(t, c, "no active connection") + require.NoError(t, wsjson.Write(context.Background(), c, msg)) +} + +func (s *testServer) readFromMonitor(t *testing.T, timeout time.Duration) cdpMessage { + t.Helper() + select { + case b := <-s.msgCh: + var msg cdpMessage + require.NoError(t, json.Unmarshal(b, &msg)) + return msg + case <-time.After(timeout): + t.Fatal("timeout waiting for message from Monitor") + return cdpMessage{} + } +} + +func (s *testServer) close() { + s.connMu.Lock() + if s.conn != nil { + _ = s.conn.Close(websocket.StatusNormalClosure, "done") + } + s.connMu.Unlock() + s.srv.Close() +} + +// testUpstream implements UpstreamProvider for tests. +type testUpstream struct { + mu sync.Mutex + current string + subs []chan string +} + +func newTestUpstream(url string) *testUpstream { + return &testUpstream{current: url} +} + +func (u *testUpstream) Current() string { + u.mu.Lock() + defer u.mu.Unlock() + return u.current +} + +func (u *testUpstream) Subscribe() (<-chan string, func()) { + ch := make(chan string, 1) + u.mu.Lock() + u.subs = append(u.subs, ch) + u.mu.Unlock() + cancel := func() { + u.mu.Lock() + for i, s := range u.subs { + if s == ch { + u.subs = append(u.subs[:i], u.subs[i+1:]...) + break + } + } + u.mu.Unlock() + close(ch) + } + return ch, cancel +} + +func (u *testUpstream) notifyRestart(newURL string) { + u.mu.Lock() + u.current = newURL + subs := make([]chan string, len(u.subs)) + copy(subs, u.subs) + u.mu.Unlock() + for _, ch := range subs { + select { + case ch <- newURL: + default: + } + } +} + +// eventCollector captures published events with channel-based notification. +type eventCollector struct { + mu sync.Mutex + events []events.Event + notify chan struct{} // signaled on every publish +} + +func newEventCollector() *eventCollector { + return &eventCollector{notify: make(chan struct{}, 256)} +} + +func (c *eventCollector) publishFn() PublishFunc { + return func(ev events.Event) { + c.mu.Lock() + c.events = append(c.events, ev) + c.mu.Unlock() + select { + case c.notify <- struct{}{}: + default: + } + } +} + +// waitFor blocks until an event of the given type is published, or fails. +func (c *eventCollector) waitFor(t *testing.T, eventType string, timeout time.Duration) events.Event { + t.Helper() + deadline := time.After(timeout) + for { + c.mu.Lock() + for _, ev := range c.events { + if ev.Type == eventType { + c.mu.Unlock() + return ev + } + } + c.mu.Unlock() + select { + case <-c.notify: + case <-deadline: + t.Fatalf("timeout waiting for event type=%q", eventType) + return events.Event{} + } + } +} + +// waitForNew blocks until a NEW event of the given type is published after this +// call, ignoring any events already in the collector. +func (c *eventCollector) waitForNew(t *testing.T, eventType string, timeout time.Duration) events.Event { + t.Helper() + c.mu.Lock() + skip := len(c.events) + c.mu.Unlock() + + deadline := time.After(timeout) + for { + c.mu.Lock() + for i := skip; i < len(c.events); i++ { + if c.events[i].Type == eventType { + ev := c.events[i] + c.mu.Unlock() + return ev + } + } + c.mu.Unlock() + select { + case <-c.notify: + case <-deadline: + t.Fatalf("timeout waiting for new event type=%q", eventType) + return events.Event{} + } + } +} + +// assertNone verifies that no event of the given type arrives within d. +func (c *eventCollector) assertNone(t *testing.T, eventType string, d time.Duration) { + t.Helper() + deadline := time.After(d) + for { + select { + case <-c.notify: + c.mu.Lock() + for _, ev := range c.events { + if ev.Type == eventType { + c.mu.Unlock() + t.Fatalf("unexpected event %q published", eventType) + return + } + } + c.mu.Unlock() + case <-deadline: + return + } + } +} + +// ResponderFunc is called for each CDP command the Monitor sends. +// Return nil to use the default empty result. +type ResponderFunc func(msg cdpMessage) any + +// listenAndRespond drains srv.msgCh, calls fn for each command, and sends the +// response. If fn is nil or returns nil, sends {"id": msg.ID, "result": {}}. +func listenAndRespond(srv *testServer, stopCh <-chan struct{}, fn ResponderFunc) { + for { + select { + case b := <-srv.msgCh: + var msg cdpMessage + if json.Unmarshal(b, &msg) != nil || msg.ID == nil { + continue + } + srv.connMu.Lock() + c := srv.conn + srv.connMu.Unlock() + if c == nil { + continue + } + var resp any + if fn != nil { + resp = fn(msg) + } + if resp == nil { + resp = map[string]any{"id": msg.ID, "result": map[string]any{}} + } + _ = wsjson.Write(context.Background(), c, resp) + case <-stopCh: + return + } + } +} + +// startMonitor creates a Monitor against srv, starts it, waits for the +// connection, and launches a responder goroutine. Returns cleanup func. +func startMonitor(t *testing.T, srv *testServer, fn ResponderFunc) (*Monitor, *eventCollector, func()) { + t.Helper() + ec := newEventCollector() + upstream := newTestUpstream(srv.wsURL()) + m := New(upstream, ec.publishFn(), 99) + require.NoError(t, m.Start(context.Background())) + + stopResponder := make(chan struct{}) + go listenAndRespond(srv, stopResponder, fn) + + // Wait for the websocket connection to be established. + select { + case <-srv.connCh: + case <-time.After(3 * time.Second): + t.Fatal("fake server never received a connection") + } + // Wait for the init sequence (setAutoAttach + domain enables + script injection + // + getTargets) to complete. The responder goroutine handles all responses; + // we just need to wait for the burst to finish. + waitForInitDone(t) + + cleanup := func() { + close(stopResponder) + m.Stop() + } + return m, ec, cleanup +} + +// waitForInitDone waits for the Monitor's init sequence to complete by +// detecting a 100ms gap in activity on the message channel. The responder +// goroutine handles responses; this just waits for the burst to end. +func waitForInitDone(t *testing.T) { + t.Helper() + // The init sequence sends ~8 commands. Wait until the responder has + // processed them all by checking for a quiet period. + deadline := time.After(5 * time.Second) + for { + select { + case <-time.After(100 * time.Millisecond): + return + case <-deadline: + t.Fatal("init sequence did not complete") + } + } +} + +// newComputedMonitor creates an unconnected Monitor for testing computed state +// (network_idle, layout_settled, navigation_settled) without a real websocket. +func newComputedMonitor(t *testing.T) (*Monitor, *eventCollector) { + t.Helper() + ec := newEventCollector() + upstream := newTestUpstream("ws://127.0.0.1:0") + m := New(upstream, ec.publishFn(), 0) + return m, ec +} + +// navigateMonitor sends a Page.frameNavigated to reset computed state. +func navigateMonitor(m *Monitor, url string) { + p, _ := json.Marshal(map[string]any{ + "frame": map[string]any{"id": "f1", "url": url}, + }) + m.handleFrameNavigated(p, "s1") +} + +// simulateRequest sends a Network.requestWillBeSent through the handler. +func simulateRequest(m *Monitor, id string) { + p, _ := json.Marshal(map[string]any{ + "requestId": id, "resourceType": "Document", + "request": map[string]any{"method": "GET", "url": "https://example.com/" + id}, + }) + m.handleNetworkRequest(p, "s1") +} + +// simulateFinished stores minimal state and sends Network.loadingFinished. +func simulateFinished(m *Monitor, id string) { + m.pendReqMu.Lock() + m.pendingRequests[id] = networkReqState{method: "GET", url: "https://example.com/" + id} + m.pendReqMu.Unlock() + p, _ := json.Marshal(map[string]any{"requestId": id}) + m.handleLoadingFinished(p, "s1") +} diff --git a/server/lib/cdpmonitor/computed_test.go b/server/lib/cdpmonitor/computed_test.go new file mode 100644 index 00000000..888b4c80 --- /dev/null +++ b/server/lib/cdpmonitor/computed_test.go @@ -0,0 +1,108 @@ +package cdpmonitor + +import ( + "encoding/json" + "testing" + "time" + + "github.com/onkernel/kernel-images/server/lib/events" + "github.com/stretchr/testify/assert" +) + +func TestNetworkIdle(t *testing.T) { + t.Run("debounce_500ms", func(t *testing.T) { + m, ec := newComputedMonitor(t) + navigateMonitor(m, "https://example.com") + + simulateRequest(m, "r1") + simulateRequest(m, "r2") + simulateRequest(m, "r3") + + t0 := time.Now() + simulateFinished(m, "r1") + simulateFinished(m, "r2") + simulateFinished(m, "r3") + + ev := ec.waitFor(t, "network_idle", 2*time.Second) + assert.GreaterOrEqual(t, time.Since(t0).Milliseconds(), int64(400), "fired too early") + assert.Equal(t, events.CategoryNetwork, ev.Category) + }) + + t.Run("timer_reset_on_new_request", func(t *testing.T) { + m, ec := newComputedMonitor(t) + navigateMonitor(m, "https://example.com") + + simulateRequest(m, "a1") + simulateFinished(m, "a1") + time.Sleep(200 * time.Millisecond) + + simulateRequest(m, "a2") + t1 := time.Now() + simulateFinished(m, "a2") + + ec.waitFor(t, "network_idle", 2*time.Second) + assert.GreaterOrEqual(t, time.Since(t1).Milliseconds(), int64(400), "should reset timer on new request") + }) +} + +func TestLayoutSettled(t *testing.T) { + t.Run("debounce_1s_after_page_load", func(t *testing.T) { + m, ec := newComputedMonitor(t) + navigateMonitor(m, "https://example.com") + + t0 := time.Now() + m.handleLoadEventFired(json.RawMessage(`{}`), "s1") + + ev := ec.waitFor(t, "layout_settled", 3*time.Second) + assert.GreaterOrEqual(t, time.Since(t0).Milliseconds(), int64(900), "fired too early") + assert.Equal(t, events.CategoryPage, ev.Category) + }) + + t.Run("layout_shift_resets_timer", func(t *testing.T) { + m, ec := newComputedMonitor(t) + navigateMonitor(m, "https://example.com") + m.handleLoadEventFired(json.RawMessage(`{}`), "s1") + + time.Sleep(600 * time.Millisecond) + shiftParams, _ := json.Marshal(map[string]any{ + "event": map[string]any{"type": "layout-shift"}, + }) + m.handleTimelineEvent(shiftParams, "s1") + t1 := time.Now() + + ec.waitFor(t, "layout_settled", 3*time.Second) + assert.GreaterOrEqual(t, time.Since(t1).Milliseconds(), int64(900), "should reset after layout_shift") + }) +} + +func TestNavigationSettled(t *testing.T) { + t.Run("fires_when_all_three_flags_set", func(t *testing.T) { + m, ec := newComputedMonitor(t) + navigateMonitor(m, "https://example.com") + + m.handleDOMContentLoaded(json.RawMessage(`{}`), "s1") + + simulateRequest(m, "r1") + simulateFinished(m, "r1") + + m.handleLoadEventFired(json.RawMessage(`{}`), "s1") + + ev := ec.waitFor(t, "navigation_settled", 3*time.Second) + assert.Equal(t, events.CategoryPage, ev.Category) + }) + + t.Run("interrupted_by_new_navigation", func(t *testing.T) { + m, ec := newComputedMonitor(t) + navigateMonitor(m, "https://example.com") + + m.handleDOMContentLoaded(json.RawMessage(`{}`), "s1") + + simulateRequest(m, "r2") + simulateFinished(m, "r2") + + // Interrupt before layout_settled fires. + navigateMonitor(m, "https://example.com/page2") + + ec.assertNone(t, "navigation_settled", 1500*time.Millisecond) + }) +} diff --git a/server/lib/cdpmonitor/handlers_test.go b/server/lib/cdpmonitor/handlers_test.go new file mode 100644 index 00000000..6128c4b0 --- /dev/null +++ b/server/lib/cdpmonitor/handlers_test.go @@ -0,0 +1,328 @@ +package cdpmonitor + +import ( + "encoding/json" + "sync/atomic" + "testing" + "time" + + "github.com/onkernel/kernel-images/server/lib/events" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConsoleEvents(t *testing.T) { + srv := newTestServer(t) + defer srv.close() + + _, ec, cleanup := startMonitor(t, srv, nil) + defer cleanup() + + t.Run("console_log", func(t *testing.T) { + srv.sendToMonitor(t, map[string]any{ + "method": "Runtime.consoleAPICalled", + "params": map[string]any{ + "type": "log", + "args": []any{map[string]any{"type": "string", "value": "hello world"}}, + }, + }) + ev := ec.waitFor(t, "console_log", 2*time.Second) + assert.Equal(t, events.CategoryConsole, ev.Category) + assert.Equal(t, events.KindCDP, ev.Source.Kind) + assert.Equal(t, "Runtime.consoleAPICalled", ev.Source.Event) + assert.Equal(t, events.DetailStandard, ev.DetailLevel) + + var data map[string]any + require.NoError(t, json.Unmarshal(ev.Data, &data)) + assert.Equal(t, "log", data["level"]) + assert.Equal(t, "hello world", data["text"]) + }) + + t.Run("exception_thrown", func(t *testing.T) { + srv.sendToMonitor(t, map[string]any{ + "method": "Runtime.exceptionThrown", + "params": map[string]any{ + "timestamp": 1234.5, + "exceptionDetails": map[string]any{ + "text": "Uncaught TypeError", + "lineNumber": 42, + "columnNumber": 7, + "url": "https://example.com/app.js", + }, + }, + }) + ev := ec.waitFor(t, "console_error", 2*time.Second) + assert.Equal(t, events.CategoryConsole, ev.Category) + assert.Equal(t, events.DetailStandard, ev.DetailLevel) + + var data map[string]any + require.NoError(t, json.Unmarshal(ev.Data, &data)) + assert.Equal(t, "Uncaught TypeError", data["text"]) + assert.Equal(t, float64(42), data["line"]) + }) + + t.Run("non_string_args", func(t *testing.T) { + srv.sendToMonitor(t, map[string]any{ + "method": "Runtime.consoleAPICalled", + "params": map[string]any{ + "type": "log", + "args": []any{ + map[string]any{"type": "number", "value": 42}, + map[string]any{"type": "object", "value": map[string]any{"key": "val"}}, + map[string]any{"type": "undefined"}, + }, + }, + }) + ev := ec.waitForNew(t, "console_log", 2*time.Second) + var data map[string]any + require.NoError(t, json.Unmarshal(ev.Data, &data)) + args := data["args"].([]any) + assert.Equal(t, "42", args[0]) + assert.Contains(t, args[1], "key") + assert.Equal(t, "undefined", args[2]) + }) +} + +func TestNetworkEvents(t *testing.T) { + srv := newTestServer(t) + defer srv.close() + + var getBodyCalled atomic.Bool + responder := func(msg cdpMessage) any { + if msg.Method == "Network.getResponseBody" { + getBodyCalled.Store(true) + return map[string]any{ + "id": msg.ID, + "result": map[string]any{"body": `{"ok":true}`, "base64Encoded": false}, + } + } + return nil + } + _, ec, cleanup := startMonitor(t, srv, responder) + defer cleanup() + + t.Run("request_and_response", func(t *testing.T) { + srv.sendToMonitor(t, map[string]any{ + "method": "Network.requestWillBeSent", + "params": map[string]any{ + "requestId": "req-001", + "resourceType": "XHR", + "request": map[string]any{ + "method": "POST", + "url": "https://api.example.com/data", + "headers": map[string]any{"Content-Type": "application/json"}, + }, + "initiator": map[string]any{"type": "script"}, + }, + }) + ev := ec.waitFor(t, "network_request", 2*time.Second) + assert.Equal(t, events.CategoryNetwork, ev.Category) + assert.Equal(t, "Network.requestWillBeSent", ev.Source.Event) + + var data map[string]any + require.NoError(t, json.Unmarshal(ev.Data, &data)) + assert.Equal(t, "POST", data["method"]) + assert.Equal(t, "https://api.example.com/data", data["url"]) + + srv.sendToMonitor(t, map[string]any{ + "method": "Network.responseReceived", + "params": map[string]any{ + "requestId": "req-001", + "response": map[string]any{ + "status": 200, "statusText": "OK", + "headers": map[string]any{"Content-Type": "application/json"}, "mimeType": "application/json", + }, + }, + }) + srv.sendToMonitor(t, map[string]any{ + "method": "Network.loadingFinished", + "params": map[string]any{"requestId": "req-001"}, + }) + + ev2 := ec.waitFor(t, "network_response", 3*time.Second) + assert.Equal(t, "Network.loadingFinished", ev2.Source.Event) + var data2 map[string]any + require.NoError(t, json.Unmarshal(ev2.Data, &data2)) + assert.Equal(t, float64(200), data2["status"]) + assert.NotEmpty(t, data2["body"]) + }) + + t.Run("loading_failed", func(t *testing.T) { + srv.sendToMonitor(t, map[string]any{ + "method": "Network.requestWillBeSent", + "params": map[string]any{ + "requestId": "req-002", + "request": map[string]any{"method": "GET", "url": "https://fail.example.com/"}, + }, + }) + ec.waitForNew(t, "network_request", 2*time.Second) + + srv.sendToMonitor(t, map[string]any{ + "method": "Network.loadingFailed", + "params": map[string]any{ + "requestId": "req-002", + "errorText": "net::ERR_CONNECTION_REFUSED", + "canceled": false, + }, + }) + ev := ec.waitFor(t, "network_loading_failed", 2*time.Second) + assert.Equal(t, events.CategoryNetwork, ev.Category) + var data map[string]any + require.NoError(t, json.Unmarshal(ev.Data, &data)) + assert.Equal(t, "net::ERR_CONNECTION_REFUSED", data["error_text"]) + }) + + t.Run("binary_resource_skips_body", func(t *testing.T) { + getBodyCalled.Store(false) + srv.sendToMonitor(t, map[string]any{ + "method": "Network.requestWillBeSent", + "params": map[string]any{ + "requestId": "img-001", + "resourceType": "Image", + "request": map[string]any{"method": "GET", "url": "https://example.com/photo.png"}, + }, + }) + srv.sendToMonitor(t, map[string]any{ + "method": "Network.responseReceived", + "params": map[string]any{ + "requestId": "img-001", + "response": map[string]any{"status": 200, "statusText": "OK", "headers": map[string]any{}, "mimeType": "image/png"}, + }, + }) + srv.sendToMonitor(t, map[string]any{ + "method": "Network.loadingFinished", + "params": map[string]any{"requestId": "img-001"}, + }) + + ev := ec.waitForNew(t, "network_response", 3*time.Second) + var data map[string]any + require.NoError(t, json.Unmarshal(ev.Data, &data)) + assert.Equal(t, "", data["body"], "binary resource should have empty body") + assert.False(t, getBodyCalled.Load(), "should not call getResponseBody for images") + }) +} + +func TestPageEvents(t *testing.T) { + srv := newTestServer(t) + defer srv.close() + + _, ec, cleanup := startMonitor(t, srv, nil) + defer cleanup() + + srv.sendToMonitor(t, map[string]any{ + "method": "Page.frameNavigated", + "params": map[string]any{ + "frame": map[string]any{"id": "frame-1", "url": "https://example.com/page"}, + }, + }) + ev := ec.waitFor(t, "navigation", 2*time.Second) + assert.Equal(t, events.CategoryPage, ev.Category) + assert.Equal(t, "Page.frameNavigated", ev.Source.Event) + var data map[string]any + require.NoError(t, json.Unmarshal(ev.Data, &data)) + assert.Equal(t, "https://example.com/page", data["url"]) + + srv.sendToMonitor(t, map[string]any{ + "method": "Page.domContentEventFired", + "params": map[string]any{"timestamp": 1000.0}, + }) + ev2 := ec.waitFor(t, "dom_content_loaded", 2*time.Second) + assert.Equal(t, events.CategoryPage, ev2.Category) + assert.Equal(t, events.DetailMinimal, ev2.DetailLevel) + + srv.sendToMonitor(t, map[string]any{ + "method": "Page.loadEventFired", + "params": map[string]any{"timestamp": 1001.0}, + }) + ev3 := ec.waitFor(t, "page_load", 2*time.Second) + assert.Equal(t, events.CategoryPage, ev3.Category) + assert.Equal(t, events.DetailMinimal, ev3.DetailLevel) +} + +func TestTargetEvents(t *testing.T) { + srv := newTestServer(t) + defer srv.close() + + _, ec, cleanup := startMonitor(t, srv, nil) + defer cleanup() + + srv.sendToMonitor(t, map[string]any{ + "method": "Target.targetCreated", + "params": map[string]any{ + "targetInfo": map[string]any{"targetId": "t-1", "type": "page", "url": "https://new.example.com"}, + }, + }) + ev := ec.waitFor(t, "target_created", 2*time.Second) + assert.Equal(t, events.CategoryPage, ev.Category) + assert.Equal(t, events.DetailMinimal, ev.DetailLevel) + var data map[string]any + require.NoError(t, json.Unmarshal(ev.Data, &data)) + assert.Equal(t, "t-1", data["target_id"]) + + srv.sendToMonitor(t, map[string]any{ + "method": "Target.targetDestroyed", + "params": map[string]any{"targetId": "t-1"}, + }) + ev2 := ec.waitFor(t, "target_destroyed", 2*time.Second) + assert.Equal(t, events.CategoryPage, ev2.Category) + assert.Equal(t, events.DetailMinimal, ev2.DetailLevel) +} + +func TestBindingAndTimeline(t *testing.T) { + srv := newTestServer(t) + defer srv.close() + + _, ec, cleanup := startMonitor(t, srv, nil) + defer cleanup() + + t.Run("interaction_click", func(t *testing.T) { + srv.sendToMonitor(t, map[string]any{ + "method": "Runtime.bindingCalled", + "params": map[string]any{ + "name": "__kernelEvent", + "payload": `{"type":"interaction_click","x":10,"y":20,"selector":"button","tag":"BUTTON","text":"OK"}`, + }, + }) + ev := ec.waitFor(t, "interaction_click", 2*time.Second) + assert.Equal(t, events.CategoryInteraction, ev.Category) + assert.Equal(t, "Runtime.bindingCalled", ev.Source.Event) + }) + + t.Run("scroll_settled", func(t *testing.T) { + srv.sendToMonitor(t, map[string]any{ + "method": "Runtime.bindingCalled", + "params": map[string]any{ + "name": "__kernelEvent", + "payload": `{"type":"scroll_settled","from_x":0,"from_y":0,"to_x":0,"to_y":500,"target_selector":"body"}`, + }, + }) + ev := ec.waitFor(t, "scroll_settled", 2*time.Second) + assert.Equal(t, events.CategoryInteraction, ev.Category) + var data map[string]any + require.NoError(t, json.Unmarshal(ev.Data, &data)) + assert.Equal(t, float64(500), data["to_y"]) + }) + + t.Run("layout_shift", func(t *testing.T) { + srv.sendToMonitor(t, map[string]any{ + "method": "PerformanceTimeline.timelineEventAdded", + "params": map[string]any{ + "event": map[string]any{"type": "layout-shift"}, + }, + }) + ev := ec.waitFor(t, "layout_shift", 2*time.Second) + assert.Equal(t, events.KindCDP, ev.Source.Kind) + assert.Equal(t, "PerformanceTimeline.timelineEventAdded", ev.Source.Event) + }) + + t.Run("unknown_binding_ignored", func(t *testing.T) { + srv.sendToMonitor(t, map[string]any{ + "method": "Runtime.bindingCalled", + "params": map[string]any{ + "name": "someOtherBinding", + "payload": `{"type":"interaction_click"}`, + }, + }) + ec.assertNone(t, "interaction_click", 100*time.Millisecond) + }) +} diff --git a/server/lib/cdpmonitor/monitor_test.go b/server/lib/cdpmonitor/monitor_test.go index e6851f05..6a6f22e4 100644 --- a/server/lib/cdpmonitor/monitor_test.go +++ b/server/lib/cdpmonitor/monitor_test.go @@ -1,366 +1,27 @@ package cdpmonitor import ( - "bytes" "context" "encoding/json" - "image" - "image/color" - "image/png" - "net/http" - "net/http/httptest" - "strings" - "sync" "sync/atomic" "testing" "time" - "github.com/coder/websocket" - "github.com/coder/websocket/wsjson" "github.com/onkernel/kernel-images/server/lib/events" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// minimalPNG is a valid 1x1 PNG used as a test fixture for screenshot tests. -var minimalPNG = func() []byte { - img := image.NewRGBA(image.Rect(0, 0, 1, 1)) - img.Set(0, 0, color.RGBA{R: 255, G: 0, B: 0, A: 255}) - var buf bytes.Buffer - _ = png.Encode(&buf, img) - return buf.Bytes() -}() - -// fakeCDPServer is a minimal WebSocket server that accepts connections and -// lets the test drive scripted message sequences. -type fakeCDPServer struct { - srv *httptest.Server - conn *websocket.Conn - connMu sync.Mutex - connCh chan struct{} // closed when the first connection is accepted - msgCh chan []byte // inbound messages from Monitor -} - -func newFakeCDPServer(t *testing.T) *fakeCDPServer { - t.Helper() - f := &fakeCDPServer{ - msgCh: make(chan []byte, 128), - connCh: make(chan struct{}), - } - var connOnce sync.Once - f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - c, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true}) - if err != nil { - return - } - f.connMu.Lock() - f.conn = c - f.connMu.Unlock() - connOnce.Do(func() { close(f.connCh) }) - go func() { - for { - _, b, err := c.Read(context.Background()) - if err != nil { - return - } - f.msgCh <- b - } - }() - })) - return f -} - -func (f *fakeCDPServer) wsURL() string { - return "ws" + strings.TrimPrefix(f.srv.URL, "http") -} - -func (f *fakeCDPServer) sendToMonitor(t *testing.T, msg any) { - t.Helper() - f.connMu.Lock() - c := f.conn - f.connMu.Unlock() - require.NotNil(t, c, "no active connection") - require.NoError(t, wsjson.Write(context.Background(), c, msg)) -} - -func (f *fakeCDPServer) readFromMonitor(t *testing.T, timeout time.Duration) cdpMessage { - t.Helper() - select { - case b := <-f.msgCh: - var msg cdpMessage - require.NoError(t, json.Unmarshal(b, &msg)) - return msg - case <-time.After(timeout): - t.Fatal("timeout waiting for message from Monitor") - return cdpMessage{} - } -} - -func (f *fakeCDPServer) close() { - f.connMu.Lock() - if f.conn != nil { - _ = f.conn.Close(websocket.StatusNormalClosure, "done") - } - f.connMu.Unlock() - f.srv.Close() -} - -// fakeUpstream implements UpstreamProvider for tests. -type fakeUpstream struct { - mu sync.Mutex - current string - subs []chan string -} - -func newFakeUpstream(url string) *fakeUpstream { - return &fakeUpstream{current: url} -} - -func (f *fakeUpstream) Current() string { - f.mu.Lock() - defer f.mu.Unlock() - return f.current -} - -func (f *fakeUpstream) Subscribe() (<-chan string, func()) { - ch := make(chan string, 1) - f.mu.Lock() - f.subs = append(f.subs, ch) - f.mu.Unlock() - cancel := func() { - f.mu.Lock() - for i, s := range f.subs { - if s == ch { - f.subs = append(f.subs[:i], f.subs[i+1:]...) - break - } - } - f.mu.Unlock() - close(ch) - } - return ch, cancel -} - -func (f *fakeUpstream) notifyRestart(newURL string) { - f.mu.Lock() - f.current = newURL - subs := make([]chan string, len(f.subs)) - copy(subs, f.subs) - f.mu.Unlock() - for _, ch := range subs { - select { - case ch <- newURL: - default: - } - } -} - -// eventCollector captures published events with channel-based notification. -type eventCollector struct { - mu sync.Mutex - events []events.Event - notify chan struct{} // signaled on every publish -} - -func newEventCollector() *eventCollector { - return &eventCollector{notify: make(chan struct{}, 256)} -} - -func (c *eventCollector) publishFn() PublishFunc { - return func(ev events.Event) { - c.mu.Lock() - c.events = append(c.events, ev) - c.mu.Unlock() - select { - case c.notify <- struct{}{}: - default: - } - } -} - -// waitFor blocks until an event of the given type is published, or fails. -func (c *eventCollector) waitFor(t *testing.T, eventType string, timeout time.Duration) events.Event { - t.Helper() - deadline := time.After(timeout) - for { - c.mu.Lock() - for _, ev := range c.events { - if ev.Type == eventType { - c.mu.Unlock() - return ev - } - } - c.mu.Unlock() - select { - case <-c.notify: - case <-deadline: - t.Fatalf("timeout waiting for event type=%q", eventType) - return events.Event{} - } - } -} - -// waitForNew blocks until a NEW event of the given type is published after this -// call, ignoring any events already in the collector. -func (c *eventCollector) waitForNew(t *testing.T, eventType string, timeout time.Duration) events.Event { - t.Helper() - c.mu.Lock() - skip := len(c.events) - c.mu.Unlock() - - deadline := time.After(timeout) - for { - c.mu.Lock() - for i := skip; i < len(c.events); i++ { - if c.events[i].Type == eventType { - ev := c.events[i] - c.mu.Unlock() - return ev - } - } - c.mu.Unlock() - select { - case <-c.notify: - case <-deadline: - t.Fatalf("timeout waiting for new event type=%q", eventType) - return events.Event{} - } - } -} - -// assertNone verifies that no event of the given type arrives within d. -func (c *eventCollector) assertNone(t *testing.T, eventType string, d time.Duration) { - t.Helper() - deadline := time.After(d) - for { - select { - case <-c.notify: - c.mu.Lock() - for _, ev := range c.events { - if ev.Type == eventType { - c.mu.Unlock() - t.Fatalf("unexpected event %q published", eventType) - return - } - } - c.mu.Unlock() - case <-deadline: - return - } - } -} - -// ResponderFunc is called for each CDP command the Monitor sends. -// Return nil to use the default empty result. -type ResponderFunc func(msg cdpMessage) any - -// listenAndRespond drains srv.msgCh, calls fn for each command, and sends the -// response. If fn is nil or returns nil, sends {"id": msg.ID, "result": {}}. -func listenAndRespond(srv *fakeCDPServer, stopCh <-chan struct{}, fn ResponderFunc) { - for { - select { - case b := <-srv.msgCh: - var msg cdpMessage - if json.Unmarshal(b, &msg) != nil || msg.ID == nil { - continue - } - srv.connMu.Lock() - c := srv.conn - srv.connMu.Unlock() - if c == nil { - continue - } - var resp any - if fn != nil { - resp = fn(msg) - } - if resp == nil { - resp = map[string]any{"id": msg.ID, "result": map[string]any{}} - } - _ = wsjson.Write(context.Background(), c, resp) - case <-stopCh: - return - } - } -} - -// startMonitor creates a Monitor against srv, starts it, waits for the -// connection, and launches a responder goroutine. Returns cleanup func. -func startMonitor(t *testing.T, srv *fakeCDPServer, fn ResponderFunc) (*Monitor, *eventCollector, func()) { - t.Helper() - ec := newEventCollector() - upstream := newFakeUpstream(srv.wsURL()) - m := New(upstream, ec.publishFn(), 99) - require.NoError(t, m.Start(context.Background())) - - stopResponder := make(chan struct{}) - go listenAndRespond(srv, stopResponder, fn) - - // Wait for the websocket connection to be established. - select { - case <-srv.connCh: - case <-time.After(3 * time.Second): - t.Fatal("fake server never received a connection") - } - // Wait for the init sequence (setAutoAttach + domain enables + script injection - // + getTargets) to complete. The responder goroutine handles all responses; - // we just need to wait for the burst to finish. - waitForInitDone(t) - - cleanup := func() { - close(stopResponder) - m.Stop() - } - return m, ec, cleanup -} - -// waitForInitDone waits for the Monitor's init sequence to complete by -// detecting a 100ms gap in activity on the message channel. The responder -// goroutine handles responses; this just waits for the burst to end. -func waitForInitDone(t *testing.T) { - t.Helper() - // The init sequence sends ~8 commands. Wait until the responder has - // processed them all by checking for a quiet period. - deadline := time.After(5 * time.Second) - for { - select { - case <-time.After(100 * time.Millisecond): - return - case <-deadline: - t.Fatal("init sequence did not complete") - } - } -} - -// newComputedMonitor creates an unconnected Monitor for testing computed state -// (network_idle, layout_settled, navigation_settled) without a real websocket. -func newComputedMonitor(t *testing.T) (*Monitor, *eventCollector) { - t.Helper() - ec := newEventCollector() - upstream := newFakeUpstream("ws://127.0.0.1:0") - m := New(upstream, ec.publishFn(), 0) - return m, ec -} - -// navigateMonitor sends a Page.frameNavigated to reset computed state. -func navigateMonitor(m *Monitor, url string) { - p, _ := json.Marshal(map[string]any{ - "frame": map[string]any{"id": "f1", "url": url}, - }) - m.handleFrameNavigated(p, "s1") -} - func TestAutoAttach(t *testing.T) { - srv := newFakeCDPServer(t) + srv := newTestServer(t) defer srv.close() ec := newEventCollector() - upstream := newFakeUpstream(srv.wsURL()) + upstream := newTestUpstream(srv.wsURL()) m := New(upstream, ec.publishFn(), 99) require.NoError(t, m.Start(context.Background())) defer m.Stop() - // The first command should be Target.setAutoAttach with correct params. msg := srv.readFromMonitor(t, 3*time.Second) assert.Equal(t, "Target.setAutoAttach", msg.Method) @@ -374,13 +35,11 @@ func TestAutoAttach(t *testing.T) { assert.False(t, params.WaitForDebuggerOnStart) assert.True(t, params.Flatten) - // Respond and drain domain-enable commands. stopResponder := make(chan struct{}) go listenAndRespond(srv, stopResponder, nil) defer close(stopResponder) srv.sendToMonitor(t, map[string]any{"id": msg.ID, "result": map[string]any{}}) - // Simulate Target.attachedToTarget — session should be stored. srv.sendToMonitor(t, map[string]any{ "method": "Target.attachedToTarget", "params": map[string]any{ @@ -403,28 +62,26 @@ func TestAutoAttach(t *testing.T) { } func TestLifecycle(t *testing.T) { - srv := newFakeCDPServer(t) + srv := newTestServer(t) defer srv.close() ec := newEventCollector() - upstream := newFakeUpstream(srv.wsURL()) + upstream := newTestUpstream(srv.wsURL()) m := New(upstream, ec.publishFn(), 99) assert.False(t, m.IsRunning(), "idle at boot") require.NoError(t, m.Start(context.Background())) assert.True(t, m.IsRunning(), "running after Start") - srv.readFromMonitor(t, 2*time.Second) // drain setAutoAttach + srv.readFromMonitor(t, 2*time.Second) m.Stop() assert.False(t, m.IsRunning(), "stopped after Stop") - // Restart while stopped. require.NoError(t, m.Start(context.Background())) assert.True(t, m.IsRunning(), "running after second Start") srv.readFromMonitor(t, 2*time.Second) - // Restart while running — implicit Stop+Start. require.NoError(t, m.Start(context.Background())) assert.True(t, m.IsRunning(), "running after implicit restart") @@ -433,25 +90,23 @@ func TestLifecycle(t *testing.T) { } func TestReconnect(t *testing.T) { - srv1 := newFakeCDPServer(t) + srv1 := newTestServer(t) - upstream := newFakeUpstream(srv1.wsURL()) + upstream := newTestUpstream(srv1.wsURL()) ec := newEventCollector() m := New(upstream, ec.publishFn(), 99) require.NoError(t, m.Start(context.Background())) defer m.Stop() - srv1.readFromMonitor(t, 2*time.Second) // drain setAutoAttach + srv1.readFromMonitor(t, 2*time.Second) - srv2 := newFakeCDPServer(t) + srv2 := newTestServer(t) defer srv2.close() defer srv1.close() upstream.notifyRestart(srv2.wsURL()) ec.waitFor(t, "monitor_disconnected", 3*time.Second) - - // Wait for the Monitor to reconnect to srv2. srv2.readFromMonitor(t, 5*time.Second) ev := ec.waitFor(t, "monitor_reconnected", 3*time.Second) @@ -461,366 +116,8 @@ func TestReconnect(t *testing.T) { assert.True(t, ok, "missing reconnect_duration_ms") } -func TestConsoleEvents(t *testing.T) { - srv := newFakeCDPServer(t) - defer srv.close() - - _, ec, cleanup := startMonitor(t, srv, nil) - defer cleanup() - - t.Run("console_log", func(t *testing.T) { - srv.sendToMonitor(t, map[string]any{ - "method": "Runtime.consoleAPICalled", - "params": map[string]any{ - "type": "log", - "args": []any{map[string]any{"type": "string", "value": "hello world"}}, - }, - }) - ev := ec.waitFor(t, "console_log", 2*time.Second) - assert.Equal(t, events.CategoryConsole, ev.Category) - assert.Equal(t, events.KindCDP, ev.Source.Kind) - assert.Equal(t, "Runtime.consoleAPICalled", ev.Source.Event) - assert.Equal(t, events.DetailStandard, ev.DetailLevel) - - var data map[string]any - require.NoError(t, json.Unmarshal(ev.Data, &data)) - assert.Equal(t, "log", data["level"]) - assert.Equal(t, "hello world", data["text"]) - }) - - t.Run("exception_thrown", func(t *testing.T) { - srv.sendToMonitor(t, map[string]any{ - "method": "Runtime.exceptionThrown", - "params": map[string]any{ - "timestamp": 1234.5, - "exceptionDetails": map[string]any{ - "text": "Uncaught TypeError", - "lineNumber": 42, - "columnNumber": 7, - "url": "https://example.com/app.js", - }, - }, - }) - ev := ec.waitFor(t, "console_error", 2*time.Second) - assert.Equal(t, events.CategoryConsole, ev.Category) - assert.Equal(t, events.DetailStandard, ev.DetailLevel) - - var data map[string]any - require.NoError(t, json.Unmarshal(ev.Data, &data)) - assert.Equal(t, "Uncaught TypeError", data["text"]) - assert.Equal(t, float64(42), data["line"]) - }) - - t.Run("non_string_args", func(t *testing.T) { - srv.sendToMonitor(t, map[string]any{ - "method": "Runtime.consoleAPICalled", - "params": map[string]any{ - "type": "log", - "args": []any{ - map[string]any{"type": "number", "value": 42}, - map[string]any{"type": "object", "value": map[string]any{"key": "val"}}, - map[string]any{"type": "undefined"}, - }, - }, - }) - ev := ec.waitForNew(t, "console_log", 2*time.Second) - var data map[string]any - require.NoError(t, json.Unmarshal(ev.Data, &data)) - args := data["args"].([]any) - assert.Equal(t, "42", args[0]) - assert.Contains(t, args[1], "key") - assert.Equal(t, "undefined", args[2]) - }) -} - -func TestNetworkEvents(t *testing.T) { - srv := newFakeCDPServer(t) - defer srv.close() - - // Custom responder: return a body for Network.getResponseBody and track calls. - var getBodyCalled atomic.Bool - responder := func(msg cdpMessage) any { - if msg.Method == "Network.getResponseBody" { - getBodyCalled.Store(true) - return map[string]any{ - "id": msg.ID, - "result": map[string]any{"body": `{"ok":true}`, "base64Encoded": false}, - } - } - return nil - } - _, ec, cleanup := startMonitor(t, srv, responder) - defer cleanup() - - t.Run("request_and_response", func(t *testing.T) { - srv.sendToMonitor(t, map[string]any{ - "method": "Network.requestWillBeSent", - "params": map[string]any{ - "requestId": "req-001", - "resourceType": "XHR", - "request": map[string]any{ - "method": "POST", - "url": "https://api.example.com/data", - "headers": map[string]any{"Content-Type": "application/json"}, - }, - "initiator": map[string]any{"type": "script"}, - }, - }) - ev := ec.waitFor(t, "network_request", 2*time.Second) - assert.Equal(t, events.CategoryNetwork, ev.Category) - assert.Equal(t, "Network.requestWillBeSent", ev.Source.Event) - - var data map[string]any - require.NoError(t, json.Unmarshal(ev.Data, &data)) - assert.Equal(t, "POST", data["method"]) - assert.Equal(t, "https://api.example.com/data", data["url"]) - - // Complete the request lifecycle. - srv.sendToMonitor(t, map[string]any{ - "method": "Network.responseReceived", - "params": map[string]any{ - "requestId": "req-001", - "response": map[string]any{ - "status": 200, "statusText": "OK", - "headers": map[string]any{"Content-Type": "application/json"}, "mimeType": "application/json", - }, - }, - }) - srv.sendToMonitor(t, map[string]any{ - "method": "Network.loadingFinished", - "params": map[string]any{"requestId": "req-001"}, - }) - - ev2 := ec.waitFor(t, "network_response", 3*time.Second) - assert.Equal(t, "Network.loadingFinished", ev2.Source.Event) - var data2 map[string]any - require.NoError(t, json.Unmarshal(ev2.Data, &data2)) - assert.Equal(t, float64(200), data2["status"]) - assert.NotEmpty(t, data2["body"]) - }) - - t.Run("loading_failed", func(t *testing.T) { - srv.sendToMonitor(t, map[string]any{ - "method": "Network.requestWillBeSent", - "params": map[string]any{ - "requestId": "req-002", - "request": map[string]any{"method": "GET", "url": "https://fail.example.com/"}, - }, - }) - ec.waitForNew(t, "network_request", 2*time.Second) - - srv.sendToMonitor(t, map[string]any{ - "method": "Network.loadingFailed", - "params": map[string]any{ - "requestId": "req-002", - "errorText": "net::ERR_CONNECTION_REFUSED", - "canceled": false, - }, - }) - ev := ec.waitFor(t, "network_loading_failed", 2*time.Second) - assert.Equal(t, events.CategoryNetwork, ev.Category) - var data map[string]any - require.NoError(t, json.Unmarshal(ev.Data, &data)) - assert.Equal(t, "net::ERR_CONNECTION_REFUSED", data["error_text"]) - }) - - t.Run("binary_resource_skips_body", func(t *testing.T) { - getBodyCalled.Store(false) - srv.sendToMonitor(t, map[string]any{ - "method": "Network.requestWillBeSent", - "params": map[string]any{ - "requestId": "img-001", - "resourceType": "Image", - "request": map[string]any{"method": "GET", "url": "https://example.com/photo.png"}, - }, - }) - srv.sendToMonitor(t, map[string]any{ - "method": "Network.responseReceived", - "params": map[string]any{ - "requestId": "img-001", - "response": map[string]any{"status": 200, "statusText": "OK", "headers": map[string]any{}, "mimeType": "image/png"}, - }, - }) - srv.sendToMonitor(t, map[string]any{ - "method": "Network.loadingFinished", - "params": map[string]any{"requestId": "img-001"}, - }) - - ev := ec.waitForNew(t, "network_response", 3*time.Second) - var data map[string]any - require.NoError(t, json.Unmarshal(ev.Data, &data)) - assert.Equal(t, "", data["body"], "binary resource should have empty body") - assert.False(t, getBodyCalled.Load(), "should not call getResponseBody for images") - }) -} - -func TestPageEvents(t *testing.T) { - srv := newFakeCDPServer(t) - defer srv.close() - - _, ec, cleanup := startMonitor(t, srv, nil) - defer cleanup() - - srv.sendToMonitor(t, map[string]any{ - "method": "Page.frameNavigated", - "params": map[string]any{ - "frame": map[string]any{"id": "frame-1", "url": "https://example.com/page"}, - }, - }) - ev := ec.waitFor(t, "navigation", 2*time.Second) - assert.Equal(t, events.CategoryPage, ev.Category) - assert.Equal(t, "Page.frameNavigated", ev.Source.Event) - var data map[string]any - require.NoError(t, json.Unmarshal(ev.Data, &data)) - assert.Equal(t, "https://example.com/page", data["url"]) - - srv.sendToMonitor(t, map[string]any{ - "method": "Page.domContentEventFired", - "params": map[string]any{"timestamp": 1000.0}, - }) - ev2 := ec.waitFor(t, "dom_content_loaded", 2*time.Second) - assert.Equal(t, events.CategoryPage, ev2.Category) - assert.Equal(t, events.DetailMinimal, ev2.DetailLevel) - - srv.sendToMonitor(t, map[string]any{ - "method": "Page.loadEventFired", - "params": map[string]any{"timestamp": 1001.0}, - }) - ev3 := ec.waitFor(t, "page_load", 2*time.Second) - assert.Equal(t, events.CategoryPage, ev3.Category) - assert.Equal(t, events.DetailMinimal, ev3.DetailLevel) -} - -func TestTargetEvents(t *testing.T) { - srv := newFakeCDPServer(t) - defer srv.close() - - _, ec, cleanup := startMonitor(t, srv, nil) - defer cleanup() - - srv.sendToMonitor(t, map[string]any{ - "method": "Target.targetCreated", - "params": map[string]any{ - "targetInfo": map[string]any{"targetId": "t-1", "type": "page", "url": "https://new.example.com"}, - }, - }) - ev := ec.waitFor(t, "target_created", 2*time.Second) - assert.Equal(t, events.CategoryPage, ev.Category) - assert.Equal(t, events.DetailMinimal, ev.DetailLevel) - var data map[string]any - require.NoError(t, json.Unmarshal(ev.Data, &data)) - assert.Equal(t, "t-1", data["target_id"]) - - srv.sendToMonitor(t, map[string]any{ - "method": "Target.targetDestroyed", - "params": map[string]any{"targetId": "t-1"}, - }) - ev2 := ec.waitFor(t, "target_destroyed", 2*time.Second) - assert.Equal(t, events.CategoryPage, ev2.Category) - assert.Equal(t, events.DetailMinimal, ev2.DetailLevel) -} - -func TestBindingAndTimeline(t *testing.T) { - srv := newFakeCDPServer(t) - defer srv.close() - - _, ec, cleanup := startMonitor(t, srv, nil) - defer cleanup() - - t.Run("interaction_click", func(t *testing.T) { - srv.sendToMonitor(t, map[string]any{ - "method": "Runtime.bindingCalled", - "params": map[string]any{ - "name": "__kernelEvent", - "payload": `{"type":"interaction_click","x":10,"y":20,"selector":"button","tag":"BUTTON","text":"OK"}`, - }, - }) - ev := ec.waitFor(t, "interaction_click", 2*time.Second) - assert.Equal(t, events.CategoryInteraction, ev.Category) - assert.Equal(t, "Runtime.bindingCalled", ev.Source.Event) - }) - - t.Run("scroll_settled", func(t *testing.T) { - srv.sendToMonitor(t, map[string]any{ - "method": "Runtime.bindingCalled", - "params": map[string]any{ - "name": "__kernelEvent", - "payload": `{"type":"scroll_settled","from_x":0,"from_y":0,"to_x":0,"to_y":500,"target_selector":"body"}`, - }, - }) - ev := ec.waitFor(t, "scroll_settled", 2*time.Second) - assert.Equal(t, events.CategoryInteraction, ev.Category) - var data map[string]any - require.NoError(t, json.Unmarshal(ev.Data, &data)) - assert.Equal(t, float64(500), data["to_y"]) - }) - - t.Run("layout_shift", func(t *testing.T) { - srv.sendToMonitor(t, map[string]any{ - "method": "PerformanceTimeline.timelineEventAdded", - "params": map[string]any{ - "event": map[string]any{"type": "layout-shift"}, - }, - }) - ev := ec.waitFor(t, "layout_shift", 2*time.Second) - assert.Equal(t, events.KindCDP, ev.Source.Kind) - assert.Equal(t, "PerformanceTimeline.timelineEventAdded", ev.Source.Event) - }) - - t.Run("unknown_binding_ignored", func(t *testing.T) { - srv.sendToMonitor(t, map[string]any{ - "method": "Runtime.bindingCalled", - "params": map[string]any{ - "name": "someOtherBinding", - "payload": `{"type":"interaction_click"}`, - }, - }) - ec.assertNone(t, "interaction_click", 100*time.Millisecond) - }) -} - -func TestScreenshot(t *testing.T) { - srv := newFakeCDPServer(t) - defer srv.close() - - m, ec, cleanup := startMonitor(t, srv, nil) - defer cleanup() - - var captureCount atomic.Int32 - m.screenshotFn = func(ctx context.Context, displayNum int) ([]byte, error) { - captureCount.Add(1) - return minimalPNG, nil - } - - t.Run("capture_and_publish", func(t *testing.T) { - m.tryScreenshot(context.Background()) - require.Eventually(t, func() bool { return captureCount.Load() == 1 }, 2*time.Second, 20*time.Millisecond) - - ev := ec.waitFor(t, "screenshot", 2*time.Second) - assert.Equal(t, events.CategorySystem, ev.Category) - assert.Equal(t, events.KindLocalProcess, ev.Source.Kind) - var data map[string]any - require.NoError(t, json.Unmarshal(ev.Data, &data)) - assert.NotEmpty(t, data["png"]) - }) - - t.Run("rate_limited", func(t *testing.T) { - before := captureCount.Load() - m.tryScreenshot(context.Background()) - time.Sleep(100 * time.Millisecond) - assert.Equal(t, before, captureCount.Load(), "should be rate-limited within 2s") - }) - - t.Run("captures_after_cooldown", func(t *testing.T) { - m.lastScreenshotAt.Store(time.Now().Add(-3 * time.Second).UnixMilli()) - before := captureCount.Load() - m.tryScreenshot(context.Background()) - require.Eventually(t, func() bool { return captureCount.Load() > before }, 2*time.Second, 20*time.Millisecond) - }) -} - func TestAttachExistingTargets(t *testing.T) { - srv := newFakeCDPServer(t) + srv := newTestServer(t) defer srv.close() responder := func(msg cdpMessage) any { @@ -864,7 +161,7 @@ func TestAttachExistingTargets(t *testing.T) { } func TestURLPopulated(t *testing.T) { - srv := newFakeCDPServer(t) + srv := newTestServer(t) defer srv.close() _, ec, cleanup := startMonitor(t, srv, nil) @@ -889,120 +186,42 @@ func TestURLPopulated(t *testing.T) { assert.Equal(t, "https://example.com/page", ev.URL) } -// simulateRequest sends a Network.requestWillBeSent through the handler. -func simulateRequest(m *Monitor, id string) { - p, _ := json.Marshal(map[string]any{ - "requestId": id, "resourceType": "Document", - "request": map[string]any{"method": "GET", "url": "https://example.com/" + id}, - }) - m.handleNetworkRequest(p, "s1") -} - -// simulateFinished stores minimal state and sends Network.loadingFinished. -func simulateFinished(m *Monitor, id string) { - m.pendReqMu.Lock() - m.pendingRequests[id] = networkReqState{method: "GET", url: "https://example.com/" + id} - m.pendReqMu.Unlock() - p, _ := json.Marshal(map[string]any{"requestId": id}) - m.handleLoadingFinished(p, "s1") -} - -func TestNetworkIdle(t *testing.T) { - t.Run("debounce_500ms", func(t *testing.T) { - m, ec := newComputedMonitor(t) - navigateMonitor(m, "https://example.com") - - simulateRequest(m, "r1") - simulateRequest(m, "r2") - simulateRequest(m, "r3") - - t0 := time.Now() - simulateFinished(m, "r1") - simulateFinished(m, "r2") - simulateFinished(m, "r3") - - ev := ec.waitFor(t, "network_idle", 2*time.Second) - assert.GreaterOrEqual(t, time.Since(t0).Milliseconds(), int64(400), "fired too early") - assert.Equal(t, events.CategoryNetwork, ev.Category) - }) - - t.Run("timer_reset_on_new_request", func(t *testing.T) { - m, ec := newComputedMonitor(t) - navigateMonitor(m, "https://example.com") - - simulateRequest(m, "a1") - simulateFinished(m, "a1") - time.Sleep(200 * time.Millisecond) - - simulateRequest(m, "a2") - t1 := time.Now() - simulateFinished(m, "a2") - - ec.waitFor(t, "network_idle", 2*time.Second) - assert.GreaterOrEqual(t, time.Since(t1).Milliseconds(), int64(400), "should reset timer on new request") - }) -} - -func TestLayoutSettled(t *testing.T) { - t.Run("debounce_1s_after_page_load", func(t *testing.T) { - m, ec := newComputedMonitor(t) - navigateMonitor(m, "https://example.com") - - t0 := time.Now() - m.handleLoadEventFired(json.RawMessage(`{}`), "s1") +func TestScreenshot(t *testing.T) { + srv := newTestServer(t) + defer srv.close() - ev := ec.waitFor(t, "layout_settled", 3*time.Second) - assert.GreaterOrEqual(t, time.Since(t0).Milliseconds(), int64(900), "fired too early") - assert.Equal(t, events.CategoryPage, ev.Category) - }) + m, ec, cleanup := startMonitor(t, srv, nil) + defer cleanup() - t.Run("layout_shift_resets_timer", func(t *testing.T) { - m, ec := newComputedMonitor(t) - navigateMonitor(m, "https://example.com") - m.handleLoadEventFired(json.RawMessage(`{}`), "s1") + var captureCount atomic.Int32 + m.screenshotFn = func(ctx context.Context, displayNum int) ([]byte, error) { + captureCount.Add(1) + return minimalPNG, nil + } - time.Sleep(600 * time.Millisecond) - shiftParams, _ := json.Marshal(map[string]any{ - "event": map[string]any{"type": "layout-shift"}, - }) - m.handleTimelineEvent(shiftParams, "s1") - t1 := time.Now() + t.Run("capture_and_publish", func(t *testing.T) { + m.tryScreenshot(context.Background()) + require.Eventually(t, func() bool { return captureCount.Load() == 1 }, 2*time.Second, 20*time.Millisecond) - ec.waitFor(t, "layout_settled", 3*time.Second) - assert.GreaterOrEqual(t, time.Since(t1).Milliseconds(), int64(900), "should reset after layout_shift") + ev := ec.waitFor(t, "screenshot", 2*time.Second) + assert.Equal(t, events.CategorySystem, ev.Category) + assert.Equal(t, events.KindLocalProcess, ev.Source.Kind) + var data map[string]any + require.NoError(t, json.Unmarshal(ev.Data, &data)) + assert.NotEmpty(t, data["png"]) }) -} - -func TestNavigationSettled(t *testing.T) { - t.Run("fires_when_all_three_flags_set", func(t *testing.T) { - m, ec := newComputedMonitor(t) - navigateMonitor(m, "https://example.com") - m.handleDOMContentLoaded(json.RawMessage(`{}`), "s1") - - // Trigger network_idle. - simulateRequest(m, "r1") - simulateFinished(m, "r1") - - // Trigger layout_settled via page_load. - m.handleLoadEventFired(json.RawMessage(`{}`), "s1") - - ev := ec.waitFor(t, "navigation_settled", 3*time.Second) - assert.Equal(t, events.CategoryPage, ev.Category) + t.Run("rate_limited", func(t *testing.T) { + before := captureCount.Load() + m.tryScreenshot(context.Background()) + time.Sleep(100 * time.Millisecond) + assert.Equal(t, before, captureCount.Load(), "should be rate-limited within 2s") }) - t.Run("interrupted_by_new_navigation", func(t *testing.T) { - m, ec := newComputedMonitor(t) - navigateMonitor(m, "https://example.com") - - m.handleDOMContentLoaded(json.RawMessage(`{}`), "s1") - - simulateRequest(m, "r2") - simulateFinished(m, "r2") - - // Interrupt before layout_settled fires. - navigateMonitor(m, "https://example.com/page2") - - ec.assertNone(t, "navigation_settled", 1500*time.Millisecond) + t.Run("captures_after_cooldown", func(t *testing.T) { + m.lastScreenshotAt.Store(time.Now().Add(-3 * time.Second).UnixMilli()) + before := captureCount.Load() + m.tryScreenshot(context.Background()) + require.Eventually(t, func() bool { return captureCount.Load() > before }, 2*time.Second, 20*time.Millisecond) }) } From c5012b54a087333b604cedef33804a70309e508e Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Mon, 6 Apr 2026 14:02:48 +0000 Subject: [PATCH 11/13] review: cursor feedback --- server/lib/cdpmonitor/handlers.go | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/server/lib/cdpmonitor/handlers.go b/server/lib/cdpmonitor/handlers.go index a9841738..f487d8b6 100644 --- a/server/lib/cdpmonitor/handlers.go +++ b/server/lib/cdpmonitor/handlers.go @@ -210,8 +210,6 @@ func (m *Monitor) handleLoadingFinished(params json.RawMessage, sessionID string if !ok { return } - // Decrement netPending immediately so network_idle tracking reflects true - // network completion, not body fetch completion m.computed.onLoadingFinished() // Fetch response body async to avoid blocking readLoop; binary types are skipped. go func() { @@ -280,7 +278,9 @@ func (m *Monitor) handleLoadingFailed(params json.RawMessage, sessionID string) } data, _ := json.Marshal(ev) m.publishEvent(EventNetworkLoadingFailed, events.DetailStandard, events.Source{Kind: events.KindCDP}, "Network.loadingFailed", data, sessionID) - m.computed.onLoadingFinished() + if ok { + m.computed.onLoadingFinished() + } } @@ -306,15 +306,19 @@ func (m *Monitor) handleFrameNavigated(params json.RawMessage, sessionID string) } m.publishEvent(EventNavigation, events.DetailStandard, events.Source{Kind: events.KindCDP}, "Page.frameNavigated", data, sessionID) - m.pendReqMu.Lock() - for id, req := range m.pendingRequests { - if req.sessionID == sessionID { - delete(m.pendingRequests, id) + // Only reset state for top-level navigations; subframe (iframe) navigations + // should not disrupt main-page tracking. + if p.Frame.ParentID == "" { + m.pendReqMu.Lock() + for id, req := range m.pendingRequests { + if req.sessionID == sessionID { + delete(m.pendingRequests, id) + } } - } - m.pendReqMu.Unlock() + m.pendReqMu.Unlock() - m.computed.resetOnNavigation() + m.computed.resetOnNavigation() + } } func (m *Monitor) handleDOMContentLoaded(params json.RawMessage, sessionID string) { From 6058b4284fadc05d884db86ceb5bc98f1e7a93a0 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Mon, 6 Apr 2026 14:10:23 +0000 Subject: [PATCH 12/13] review: reduce network logs --- server/lib/cdpmonitor/handlers.go | 50 ++++++++++++++++---------- server/lib/cdpmonitor/handlers_test.go | 2 +- server/lib/cdpmonitor/util.go | 12 ++++--- 3 files changed, 41 insertions(+), 23 deletions(-) diff --git a/server/lib/cdpmonitor/handlers.go b/server/lib/cdpmonitor/handlers.go index f487d8b6..d9a177e4 100644 --- a/server/lib/cdpmonitor/handlers.go +++ b/server/lib/cdpmonitor/handlers.go @@ -166,14 +166,19 @@ func (m *Monitor) handleNetworkRequest(params json.RawMessage, sessionID string) resourceType: p.ResourceType, } m.pendReqMu.Unlock() - data, _ := json.Marshal(map[string]any{ - "method": p.Request.Method, - "url": p.Request.URL, - "headers": p.Request.Headers, - "post_data": p.Request.PostData, - "resource_type": p.ResourceType, - "initiator_type": initiatorType, - }) + ev := map[string]any{ + "method": p.Request.Method, + "url": p.Request.URL, + "headers": p.Request.Headers, + "initiator_type": initiatorType, + } + if p.Request.PostData != "" { + ev["post_data"] = p.Request.PostData + } + if p.ResourceType != "" { + ev["resource_type"] = p.ResourceType + } + data, _ := json.Marshal(ev) m.publishEvent(EventNetworkRequest, events.DetailStandard, events.Source{Kind: events.KindCDP}, "Network.requestWillBeSent", data, sessionID) m.computed.onRequest() } @@ -214,16 +219,25 @@ func (m *Monitor) handleLoadingFinished(params json.RawMessage, sessionID string // Fetch response body async to avoid blocking readLoop; binary types are skipped. go func() { body := m.fetchResponseBody(p.RequestID, sessionID, state) - data, _ := json.Marshal(map[string]any{ - "method": state.method, - "url": state.url, - "status": state.status, - "status_text": state.statusText, - "headers": state.resHeaders, - "mime_type": state.mimeType, - "resource_type": state.resourceType, - "body": body, - }) + ev := map[string]any{ + "method": state.method, + "url": state.url, + "status": state.status, + "headers": state.resHeaders, + } + if state.statusText != "" { + ev["status_text"] = state.statusText + } + if state.mimeType != "" { + ev["mime_type"] = state.mimeType + } + if state.resourceType != "" { + ev["resource_type"] = state.resourceType + } + if body != "" { + ev["body"] = body + } + data, _ := json.Marshal(ev) detail := events.DetailStandard if body != "" { detail = events.DetailVerbose diff --git a/server/lib/cdpmonitor/handlers_test.go b/server/lib/cdpmonitor/handlers_test.go index 6128c4b0..0626a4e2 100644 --- a/server/lib/cdpmonitor/handlers_test.go +++ b/server/lib/cdpmonitor/handlers_test.go @@ -197,7 +197,7 @@ func TestNetworkEvents(t *testing.T) { ev := ec.waitForNew(t, "network_response", 3*time.Second) var data map[string]any require.NoError(t, json.Unmarshal(ev.Data, &data)) - assert.Equal(t, "", data["body"], "binary resource should have empty body") + assert.Nil(t, data["body"], "binary resource should not have body field") assert.False(t, getBodyCalled.Load(), "should not call getResponseBody for images") }) } diff --git a/server/lib/cdpmonitor/util.go b/server/lib/cdpmonitor/util.go index 5dae2fce..26b250c0 100644 --- a/server/lib/cdpmonitor/util.go +++ b/server/lib/cdpmonitor/util.go @@ -26,7 +26,7 @@ func consoleArgString(a cdpConsoleArg) string { // resourceType is checked first; mimeType is a fallback for resources with no type (e.g. in-flight at attach time). func isTextualResource(resourceType, mimeType string) bool { switch resourceType { - case "Font", "Image", "Media": + case "Font", "Image", "Media", "Stylesheet", "Script": return false } return isCapturedMIME(mimeType) @@ -52,6 +52,10 @@ func isCapturedMIME(mime string) bool { "application/x-protobuf", "application/x-msgpack", "application/x-thrift", + "application/javascript", + "application/x-javascript", + "text/javascript", + "text/css", }, mime) { return false } @@ -68,10 +72,10 @@ func isCapturedMIME(mime string) bool { } // bodyCapFor returns the max body capture size for a MIME type. -// Structured data (JSON, XML, form data) gets 900 KB; everything else gets 10 KB. +// Structured data (JSON, XML, form data) gets 8 KB; everything else gets 4 KB. func bodyCapFor(mime string) int { - const fullCap = 900 * 1024 - const contextCap = 10 * 1024 + const fullCap = 8 * 1024 + const contextCap = 4 * 1024 structuredPrefixes := []string{ "application/json", "application/xml", From 0e65e1912aca7d70e863f3e4e3e346b7dd04424b Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Mon, 6 Apr 2026 14:13:02 +0000 Subject: [PATCH 13/13] review: clearState() now calls failPendingCommands() --- server/lib/cdpmonitor/monitor.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/server/lib/cdpmonitor/monitor.go b/server/lib/cdpmonitor/monitor.go index 1fa44e80..fbedcffb 100644 --- a/server/lib/cdpmonitor/monitor.go +++ b/server/lib/cdpmonitor/monitor.go @@ -151,6 +151,7 @@ func (m *Monitor) Stop() { } // clearState resets sessions, pending requests, and computed state. +// It also fails all in-flight send() calls so their goroutines are unblocked. func (m *Monitor) clearState() { m.currentURL.Store("") @@ -162,9 +163,29 @@ func (m *Monitor) clearState() { m.pendingRequests = make(map[string]networkReqState) m.pendReqMu.Unlock() + m.failPendingCommands() + m.computed.resetOnNavigation() } +// failPendingCommands unblocks all in-flight send() calls by delivering an +// error response. This prevents goroutine leaks when the connection is torn +// down during reconnect. +func (m *Monitor) failPendingCommands() { + m.pendMu.Lock() + old := m.pending + m.pending = make(map[int64]chan cdpMessage) + m.pendMu.Unlock() + + disconnectErr := &cdpError{Code: -1, Message: "connection closed"} + for _, ch := range old { + select { + case ch <- cdpMessage{Error: disconnectErr}: + default: + } + } +} + // readLoop reads CDP messages, routing responses to pending callers and dispatching events. func (m *Monitor) readLoop(ctx context.Context) { m.lifeMu.Lock()