From 8a59058c342f15d133e95f118f4522176aa20414 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Wed, 1 Apr 2026 13:12:59 +0000 Subject: [PATCH 01/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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() From a9eb638d193ed42d33e2620c4890a45f95da668f Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Fri, 20 Mar 2026 17:25:20 +0000 Subject: [PATCH 14/28] feat: add Pipeline glue type sequencing truncation, file write, and ring publish --- server/lib/events/pipeline.go | 67 +++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 server/lib/events/pipeline.go diff --git a/server/lib/events/pipeline.go b/server/lib/events/pipeline.go new file mode 100644 index 00000000..11661150 --- /dev/null +++ b/server/lib/events/pipeline.go @@ -0,0 +1,67 @@ +package events + +import ( + "sync/atomic" + "time" +) + +// Pipeline glues a RingBuffer and a FileWriter into a single write path. +// A single call to Publish stamps the event with a monotonic sequence number, +// applies truncation, durably appends it to the per-category log file, and +// then makes it available to ring buffer readers. +type Pipeline struct { + ring *RingBuffer + files *FileWriter + seq atomic.Uint64 + captureSessionID atomic.Value // stores string +} + +// NewPipeline returns a Pipeline backed by the supplied ring and file writer. +func NewPipeline(ring *RingBuffer, files *FileWriter) *Pipeline { + p := &Pipeline{ring: ring, files: files} + p.captureSessionID.Store("") + return p +} + +// Start sets the capture session ID that will be stamped on every subsequent +// published event. It may be called at any time; the change is immediately +// visible to concurrent Publish calls. +func (p *Pipeline) Start(captureSessionID string) { + p.captureSessionID.Store(captureSessionID) +} + +// Publish stamps, truncates, files, and broadcasts a single event. +// +// Ordering: +// 1. Stamp CaptureSessionID, Seq, Ts (Ts only if caller left it zero) +// 2. Apply truncateIfNeeded (SCHEMA-04) — must happen before both sinks +// 3. Write to FileWriter (durable before in-memory) +// 4. Publish to RingBuffer (in-memory fan-out) +// +// Errors from FileWriter.Write are silently dropped; the ring buffer always +// receives the event even if the file write fails. +func (p *Pipeline) Publish(ev BrowserEvent) { + ev.CaptureSessionID = p.captureSessionID.Load().(string) + ev.Seq = p.seq.Add(1) // starts at 1 + if ev.Ts == 0 { + ev.Ts = time.Now().UnixMilli() + } + ev = truncateIfNeeded(ev) + + // File write first — durable before in-memory. + _ = p.files.Write(ev) + + // Ring buffer last — readers see the event after the file is written. + p.ring.Publish(ev) +} + +// NewReader returns a Reader positioned at the start of the ring buffer. +func (p *Pipeline) NewReader() *Reader { + return p.ring.NewReader() +} + +// Close closes the underlying FileWriter, flushing and releasing all open +// file descriptors. +func (p *Pipeline) Close() error { + return p.files.Close() +} From dd31b840252c7a9ffaca2ec1d7884ad6fa932070 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Fri, 27 Mar 2026 11:37:14 +0000 Subject: [PATCH 15/28] review: fix truncateIfNeeded branch split, atomic.Pointer[string], Reader godoc, and test correctness --- server/lib/events/pipeline.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/server/lib/events/pipeline.go b/server/lib/events/pipeline.go index 11661150..ed2f3a58 100644 --- a/server/lib/events/pipeline.go +++ b/server/lib/events/pipeline.go @@ -13,13 +13,14 @@ type Pipeline struct { ring *RingBuffer files *FileWriter seq atomic.Uint64 - captureSessionID atomic.Value // stores string + captureSessionID atomic.Pointer[string] } // NewPipeline returns a Pipeline backed by the supplied ring and file writer. func NewPipeline(ring *RingBuffer, files *FileWriter) *Pipeline { p := &Pipeline{ring: ring, files: files} - p.captureSessionID.Store("") + empty := "" + p.captureSessionID.Store(&empty) return p } @@ -27,7 +28,7 @@ func NewPipeline(ring *RingBuffer, files *FileWriter) *Pipeline { // published event. It may be called at any time; the change is immediately // visible to concurrent Publish calls. func (p *Pipeline) Start(captureSessionID string) { - p.captureSessionID.Store(captureSessionID) + p.captureSessionID.Store(&captureSessionID) } // Publish stamps, truncates, files, and broadcasts a single event. @@ -41,17 +42,17 @@ func (p *Pipeline) Start(captureSessionID string) { // Errors from FileWriter.Write are silently dropped; the ring buffer always // receives the event even if the file write fails. func (p *Pipeline) Publish(ev BrowserEvent) { - ev.CaptureSessionID = p.captureSessionID.Load().(string) + ev.CaptureSessionID = *p.captureSessionID.Load() ev.Seq = p.seq.Add(1) // starts at 1 if ev.Ts == 0 { ev.Ts = time.Now().UnixMilli() } + if ev.DetailLevel == "" { + ev.DetailLevel = DetailDefault + } ev = truncateIfNeeded(ev) - // File write first — durable before in-memory. _ = p.files.Write(ev) - - // Ring buffer last — readers see the event after the file is written. p.ring.Publish(ev) } From b615377bb51e1843f04bc879e7bdad42b6881b6b Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Fri, 27 Mar 2026 12:30:03 +0000 Subject: [PATCH 16/28] fix: serialise Pipeline.Publish to guarantee monotonic seq delivery order --- server/lib/events/pipeline.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/server/lib/events/pipeline.go b/server/lib/events/pipeline.go index ed2f3a58..b7184abc 100644 --- a/server/lib/events/pipeline.go +++ b/server/lib/events/pipeline.go @@ -1,6 +1,7 @@ package events import ( + "sync" "sync/atomic" "time" ) @@ -10,6 +11,7 @@ import ( // applies truncation, durably appends it to the per-category log file, and // then makes it available to ring buffer readers. type Pipeline struct { + mu sync.Mutex ring *RingBuffer files *FileWriter seq atomic.Uint64 @@ -35,13 +37,18 @@ func (p *Pipeline) Start(captureSessionID string) { // // Ordering: // 1. Stamp CaptureSessionID, Seq, Ts (Ts only if caller left it zero) -// 2. Apply truncateIfNeeded (SCHEMA-04) — must happen before both sinks +// 2. Apply truncateIfNeeded — must happen before both sinks // 3. Write to FileWriter (durable before in-memory) // 4. Publish to RingBuffer (in-memory fan-out) // +// The mutex serialises concurrent callers so that seq assignment and sink +// delivery are atomic — readers always see events in seq order. // Errors from FileWriter.Write are silently dropped; the ring buffer always // receives the event even if the file write fails. func (p *Pipeline) Publish(ev BrowserEvent) { + p.mu.Lock() + defer p.mu.Unlock() + ev.CaptureSessionID = *p.captureSessionID.Load() ev.Seq = p.seq.Add(1) // starts at 1 if ev.Ts == 0 { From fbb97a5100169a81016a5901fa81bee1adef51e8 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Mon, 30 Mar 2026 15:01:13 +0000 Subject: [PATCH 17/28] review --- server/lib/events/pipeline.go | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/server/lib/events/pipeline.go b/server/lib/events/pipeline.go index b7184abc..c6f93dcb 100644 --- a/server/lib/events/pipeline.go +++ b/server/lib/events/pipeline.go @@ -1,15 +1,13 @@ package events import ( + "log/slog" "sync" "sync/atomic" "time" ) -// Pipeline glues a RingBuffer and a FileWriter into a single write path. -// A single call to Publish stamps the event with a monotonic sequence number, -// applies truncation, durably appends it to the per-category log file, and -// then makes it available to ring buffer readers. +// Pipeline glues a RingBuffer and a FileWriter into a single write path type Pipeline struct { mu sync.Mutex ring *RingBuffer @@ -18,7 +16,6 @@ type Pipeline struct { captureSessionID atomic.Pointer[string] } -// NewPipeline returns a Pipeline backed by the supplied ring and file writer. func NewPipeline(ring *RingBuffer, files *FileWriter) *Pipeline { p := &Pipeline{ring: ring, files: files} empty := "" @@ -27,8 +24,7 @@ func NewPipeline(ring *RingBuffer, files *FileWriter) *Pipeline { } // Start sets the capture session ID that will be stamped on every subsequent -// published event. It may be called at any time; the change is immediately -// visible to concurrent Publish calls. +// published event func (p *Pipeline) Start(captureSessionID string) { p.captureSessionID.Store(&captureSessionID) } @@ -40,36 +36,33 @@ func (p *Pipeline) Start(captureSessionID string) { // 2. Apply truncateIfNeeded — must happen before both sinks // 3. Write to FileWriter (durable before in-memory) // 4. Publish to RingBuffer (in-memory fan-out) -// -// The mutex serialises concurrent callers so that seq assignment and sink -// delivery are atomic — readers always see events in seq order. -// Errors from FileWriter.Write are silently dropped; the ring buffer always -// receives the event even if the file write fails. func (p *Pipeline) Publish(ev BrowserEvent) { p.mu.Lock() defer p.mu.Unlock() ev.CaptureSessionID = *p.captureSessionID.Load() - ev.Seq = p.seq.Add(1) // starts at 1 + ev.Seq = p.seq.Add(1) if ev.Ts == 0 { ev.Ts = time.Now().UnixMilli() } if ev.DetailLevel == "" { ev.DetailLevel = DetailDefault } - ev = truncateIfNeeded(ev) + ev, data := truncateIfNeeded(ev) - _ = p.files.Write(ev) + if err := p.files.Write(ev, data); err != nil { + slog.Error("pipeline: file write failed", "seq", ev.Seq, "category", ev.Category, "err", err) + } p.ring.Publish(ev) } -// NewReader returns a Reader positioned at the start of the ring buffer. +// NewReader returns a Reader positioned at the start of the ring buffer func (p *Pipeline) NewReader() *Reader { return p.ring.NewReader() } // Close closes the underlying FileWriter, flushing and releasing all open -// file descriptors. +// file descriptors func (p *Pipeline) Close() error { return p.files.Close() } From ab2900840e88d521dd9a81b6940b92e88a0fcfbb Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Tue, 31 Mar 2026 20:04:59 +0000 Subject: [PATCH 18/28] refactor: rename BrowserEvent to Event, DetailDefault to DetailStandard Event is the agreed portable name. DetailStandard avoids Go keyword ambiguity with "default". --- server/lib/events/pipeline.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/lib/events/pipeline.go b/server/lib/events/pipeline.go index c6f93dcb..1c3d31e8 100644 --- a/server/lib/events/pipeline.go +++ b/server/lib/events/pipeline.go @@ -36,7 +36,7 @@ func (p *Pipeline) Start(captureSessionID string) { // 2. Apply truncateIfNeeded — must happen before both sinks // 3. Write to FileWriter (durable before in-memory) // 4. Publish to RingBuffer (in-memory fan-out) -func (p *Pipeline) Publish(ev BrowserEvent) { +func (p *Pipeline) Publish(ev Event) { p.mu.Lock() defer p.mu.Unlock() @@ -46,7 +46,7 @@ func (p *Pipeline) Publish(ev BrowserEvent) { ev.Ts = time.Now().UnixMilli() } if ev.DetailLevel == "" { - ev.DetailLevel = DetailDefault + ev.DetailLevel = DetailStandard } ev, data := truncateIfNeeded(ev) From f0aed531c0b57169536ccffd0d31a754d287447f Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Tue, 31 Mar 2026 20:09:01 +0000 Subject: [PATCH 19/28] refactor: extract Envelope wrapper, move seq and capture_session_id out of Event Event is now purely producer-emitted content. Pipeline-assigned metadata (seq, capture_session_id) lives on the Envelope. truncateIfNeeded operates on the full Envelope. Pipeline type comment now documents lifecycle semantics. --- server/lib/events/pipeline.go | 38 +++++++++++++++++------------------ 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/server/lib/events/pipeline.go b/server/lib/events/pipeline.go index 1c3d31e8..403a1df0 100644 --- a/server/lib/events/pipeline.go +++ b/server/lib/events/pipeline.go @@ -7,7 +7,10 @@ import ( "time" ) -// Pipeline glues a RingBuffer and a FileWriter into a single write path +// Pipeline is a single-use write path that wraps events in envelopes and fans +// them out to a FileWriter (durable) and RingBuffer (in-memory). Call Start +// once with a capture session ID, then Publish concurrently. Close flushes the +// FileWriter; there is no restart or terminal event. type Pipeline struct { mu sync.Mutex ring *RingBuffer @@ -23,46 +26,43 @@ func NewPipeline(ring *RingBuffer, files *FileWriter) *Pipeline { return p } -// Start sets the capture session ID that will be stamped on every subsequent -// published event +// Start sets the capture session ID stamped on every subsequent envelope. func (p *Pipeline) Start(captureSessionID string) { p.captureSessionID.Store(&captureSessionID) } -// Publish stamps, truncates, files, and broadcasts a single event. -// -// Ordering: -// 1. Stamp CaptureSessionID, Seq, Ts (Ts only if caller left it zero) -// 2. Apply truncateIfNeeded — must happen before both sinks -// 3. Write to FileWriter (durable before in-memory) -// 4. Publish to RingBuffer (in-memory fan-out) +// Publish wraps ev in an Envelope, truncates if needed, then writes to +// FileWriter (durable) before RingBuffer (in-memory fan-out). func (p *Pipeline) Publish(ev Event) { p.mu.Lock() defer p.mu.Unlock() - ev.CaptureSessionID = *p.captureSessionID.Load() - ev.Seq = p.seq.Add(1) if ev.Ts == 0 { ev.Ts = time.Now().UnixMilli() } if ev.DetailLevel == "" { ev.DetailLevel = DetailStandard } - ev, data := truncateIfNeeded(ev) - if err := p.files.Write(ev, data); err != nil { - slog.Error("pipeline: file write failed", "seq", ev.Seq, "category", ev.Category, "err", err) + env := Envelope{ + CaptureSessionID: *p.captureSessionID.Load(), + Seq: p.seq.Add(1), + Event: ev, } - p.ring.Publish(ev) + env, data := truncateIfNeeded(env) + + if err := p.files.Write(env, data); err != nil { + slog.Error("pipeline: file write failed", "seq", env.Seq, "category", env.Event.Category, "err", err) + } + p.ring.Publish(env) } -// NewReader returns a Reader positioned at the start of the ring buffer +// NewReader returns a Reader positioned at the start of the ring buffer. func (p *Pipeline) NewReader() *Reader { return p.ring.NewReader() } -// Close closes the underlying FileWriter, flushing and releasing all open -// file descriptors +// Close flushes and releases all open file descriptors. func (p *Pipeline) Close() error { return p.files.Close() } From e07c02441e9ed2cec9d50458a88dc7a4349763fc Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Tue, 31 Mar 2026 20:14:41 +0000 Subject: [PATCH 20/28] refactor: unify seq as universal cursor, add NewReader(afterSeq) Ring buffer now indexes by envelope.Seq directly, removing the separate head/written counters. NewReader takes an explicit afterSeq for resume support. Renamed notify to readerWake for clarity. --- server/lib/events/pipeline.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/lib/events/pipeline.go b/server/lib/events/pipeline.go index 403a1df0..ba7a7660 100644 --- a/server/lib/events/pipeline.go +++ b/server/lib/events/pipeline.go @@ -58,8 +58,8 @@ func (p *Pipeline) Publish(ev Event) { } // NewReader returns a Reader positioned at the start of the ring buffer. -func (p *Pipeline) NewReader() *Reader { - return p.ring.NewReader() +func (p *Pipeline) NewReader(afterSeq uint64) *Reader { + return p.ring.NewReader(afterSeq) } // Close flushes and releases all open file descriptors. From 3945ce4639f0f84c409815342177e4960c7c49f1 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Tue, 31 Mar 2026 20:16:20 +0000 Subject: [PATCH 21/28] refactor: return ReadResult instead of synthetic drop events Drops are now stream metadata (ReadResult.Dropped) rather than fake events smuggled into the Event schema. Transport layer decides how to surface gaps on the wire. --- server/lib/events/ringbuffer.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/server/lib/events/ringbuffer.go b/server/lib/events/ringbuffer.go index d30a680c..846322de 100644 --- a/server/lib/events/ringbuffer.go +++ b/server/lib/events/ringbuffer.go @@ -55,6 +55,14 @@ type ReadResult struct { Dropped uint64 } +// ReadResult is returned by Reader.Read. Exactly one of Envelope or Dropped is +// set: Envelope is non-nil for a normal read, Dropped is non-zero when the +// reader fell behind and events were lost. +type ReadResult struct { + Envelope *Envelope + Dropped uint64 +} + // Reader tracks an independent read position in a RingBuffer. type Reader struct { rb *RingBuffer From 894e9d0b7dd1189096968b3bd6cbbb94fc65faa3 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Wed, 1 Apr 2026 11:59:16 +0000 Subject: [PATCH 22/28] fix: guard against nil marshal data and oversized non-data envelopes truncateIfNeeded now warns if the envelope still exceeds the 1MB limit after nulling data (e.g. huge url or source.metadata). Pipeline.Publish skips the file write when marshal returns nil to avoid writing corrupt bare-newline JSONL lines. --- server/lib/events/pipeline.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/lib/events/pipeline.go b/server/lib/events/pipeline.go index ba7a7660..e69c254f 100644 --- a/server/lib/events/pipeline.go +++ b/server/lib/events/pipeline.go @@ -51,7 +51,9 @@ func (p *Pipeline) Publish(ev Event) { } env, data := truncateIfNeeded(env) - if err := p.files.Write(env, data); err != nil { + if data == nil { + slog.Error("pipeline: marshal failed, skipping file write", "seq", env.Seq, "category", env.Event.Category) + } else if err := p.files.Write(env, data); err != nil { slog.Error("pipeline: file write failed", "seq", env.Seq, "category", env.Event.Category, "err", err) } p.ring.Publish(env) From 7d38b48bc7e5968266f17266031748ddbd7b5dea Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Mon, 6 Apr 2026 14:52:34 +0000 Subject: [PATCH 23/28] fix: remove duplicate ReadResult type in ringbuffer.go --- server/lib/events/ringbuffer.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/server/lib/events/ringbuffer.go b/server/lib/events/ringbuffer.go index 846322de..d30a680c 100644 --- a/server/lib/events/ringbuffer.go +++ b/server/lib/events/ringbuffer.go @@ -55,14 +55,6 @@ type ReadResult struct { Dropped uint64 } -// ReadResult is returned by Reader.Read. Exactly one of Envelope or Dropped is -// set: Envelope is non-nil for a normal read, Dropped is non-zero when the -// reader fell behind and events were lost. -type ReadResult struct { - Envelope *Envelope - Dropped uint64 -} - // Reader tracks an independent read position in a RingBuffer. type Reader struct { rb *RingBuffer From 6a67415045af368f3b20fba3c96788ddcd101490 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Mon, 6 Apr 2026 14:52:39 +0000 Subject: [PATCH 24/28] feat: add POST /events/publish and GET /events/stream endpoints --- server/cmd/api/api/api.go | 24 ++-- server/cmd/api/api/events.go | 98 ++++++++++++- server/cmd/api/api/events_publish_test.go | 166 ++++++++++++++++++++++ server/cmd/api/api/events_stream_test.go | 122 ++++++++++++++++ server/cmd/api/main.go | 12 +- 5 files changed, 400 insertions(+), 22 deletions(-) create mode 100644 server/cmd/api/api/events_publish_test.go create mode 100644 server/cmd/api/api/events_stream_test.go diff --git a/server/cmd/api/api/api.go b/server/cmd/api/api/api.go index 3523904d..f8f46bed 100644 --- a/server/cmd/api/api/api.go +++ b/server/cmd/api/api/api.go @@ -41,6 +41,11 @@ type ApiService struct { upstreamMgr *devtoolsproxy.UpstreamManager stz scaletozero.Controller + // CDP event pipeline and cdpMonitor. + captureSession *events.CaptureSession + cdpMonitor *cdpmonitor.Monitor + monitorMu sync.Mutex + // inputMu serializes input-related operations (mouse, keyboard, screenshot) inputMu sync.Mutex @@ -70,11 +75,6 @@ type ApiService struct { // xvfbResizeMu serializes background Xvfb restarts to prevent races // when multiple CDP fast-path resizes fire in quick succession. xvfbResizeMu sync.Mutex - - // CDP event pipeline and cdpMonitor. - captureSession *events.CaptureSession - cdpMonitor *cdpmonitor.Monitor - monitorMu sync.Mutex } var _ oapi.StrictServerInterface = (*ApiService)(nil) @@ -101,8 +101,6 @@ func New( return nil, fmt.Errorf("captureSession cannot be nil") } - mon := cdpmonitor.New(upstreamMgr, captureSession.Publish, displayNum) - return &ApiService{ recordManager: recordManager, factory: factory, @@ -114,7 +112,7 @@ func New( nekoAuthClient: nekoAuthClient, policy: &policy.Policy{}, captureSession: captureSession, - cdpMonitor: mon, + cdpMonitor: cdpmonitor.New(upstreamMgr, captureSession.Publish, displayNum), }, nil } @@ -334,9 +332,11 @@ func (s *ApiService) ListRecorders(ctx context.Context, _ oapi.ListRecordersRequ } func (s *ApiService) Shutdown(ctx context.Context) error { - s.monitorMu.Lock() - s.cdpMonitor.Stop() - _ = s.captureSession.Close() - s.monitorMu.Unlock() + if s.cdpMonitor != nil { + s.cdpMonitor.Stop() + } + if s.captureSession != nil { + _ = s.captureSession.Close() + } return s.recordManager.StopAll(ctx) } diff --git a/server/cmd/api/api/events.go b/server/cmd/api/api/events.go index f9021a17..c4bf43ad 100644 --- a/server/cmd/api/api/events.go +++ b/server/cmd/api/api/events.go @@ -2,21 +2,27 @@ package api import ( "context" + "encoding/json" + "fmt" + "io" "net/http" + "strconv" "github.com/google/uuid" + "github.com/onkernel/kernel-images/server/lib/events" "github.com/onkernel/kernel-images/server/lib/logger" ) // StartCapture handles POST /events/start. -// Generates a new capture session ID, seeds the pipeline, then starts the -// CDP monitor. If already running, the monitor is stopped and -// restarted with a fresh session ID +// Registered as a direct chi route (not via OpenAPI spec) because these are +// simple internal control endpoints with no request body. +// A second call while already running restarts capture (stop+start). func (s *ApiService) StartCapture(w http.ResponseWriter, r *http.Request) { s.monitorMu.Lock() defer s.monitorMu.Unlock() - s.captureSession.Start(uuid.New().String()) + captureSessionID := uuid.New().String() + s.captureSession.Start(captureSessionID) if err := s.cdpMonitor.Start(context.Background()); err != nil { logger.FromContext(r.Context()).Error("failed to start CDP monitor", "err", err) @@ -26,10 +32,92 @@ func (s *ApiService) StartCapture(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } -// StopCapture handles POST /events/stop +// StopCapture handles POST /events/stop. Idempotent if not running. func (s *ApiService) StopCapture(w http.ResponseWriter, r *http.Request) { s.monitorMu.Lock() defer s.monitorMu.Unlock() s.cdpMonitor.Stop() w.WriteHeader(http.StatusOK) } + +// PublishEvent handles POST /events/publish. +// Accepts an Event JSON body and ingests it into the pipeline (ring buffer + log file). +// Derives Category from Type if omitted; stamps KindKernelAPI if Source.Kind is omitted. +func (s *ApiService) PublishEvent(w http.ResponseWriter, r *http.Request) { + var ev events.Event + if err := json.NewDecoder(r.Body).Decode(&ev); err != nil { + http.Error(w, "invalid JSON body", http.StatusBadRequest) + return + } + + // Derive category if caller omitted it — FileWriter returns error for empty category. + if ev.Category == "" { + ev.Category = events.CategoryFor(ev.Type) + } + + // Stamp provenance if caller omitted source kind. + if ev.Source.Kind == "" { + ev.Source.Kind = events.KindKernelAPI + } + + s.captureSession.Publish(ev) + w.WriteHeader(http.StatusOK) +} + +// StreamEvents handles GET /events/stream. +// Delivers a live stream of Envelopes over Server-Sent Events. +// Each frame is formatted as "id: {seq}\ndata: {json}\n\n". +// Clients may reconnect with Last-Event-ID to resume from the next unseen event. +func (s *ApiService) StreamEvents(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + + // Parse Last-Event-ID for reconnection (ignore parse errors; default to 0). + var lastSeq uint64 + if v := r.Header.Get("Last-Event-ID"); v != "" { + if n, err := strconv.ParseUint(v, 10, 64); err == nil { + lastSeq = n + } + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + flusher.Flush() + + // NewReader(lastSeq) positions the reader to deliver events after lastSeq. + reader := s.captureSession.NewReader(lastSeq) + ctx := r.Context() + + // Main event loop. + for { + res, err := reader.Read(ctx) + if err != nil { + // Context cancelled (client disconnected). + return + } + if res.Envelope == nil { + // Drop notification — skip, client will see the gap via seq discontinuity. + continue + } + if err := writeSSEEnvelope(w, *res.Envelope); err != nil { + return + } + flusher.Flush() + } +} + +// writeSSEEnvelope marshals env to JSON and writes a single SSE frame to w. +// Frame format: "id: {seq}\ndata: {json}\n\n" +func writeSSEEnvelope(w io.Writer, env events.Envelope) error { + data, err := json.Marshal(env) + if err != nil { + return err + } + _, err = fmt.Fprintf(w, "id: %d\ndata: %s\n\n", env.Seq, data) + return err +} diff --git a/server/cmd/api/api/events_publish_test.go b/server/cmd/api/api/events_publish_test.go new file mode 100644 index 00000000..be3dace0 --- /dev/null +++ b/server/cmd/api/api/events_publish_test.go @@ -0,0 +1,166 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/onkernel/kernel-images/server/lib/events" + "github.com/onkernel/kernel-images/server/lib/recorder" + "github.com/onkernel/kernel-images/server/lib/scaletozero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newPublishTestService(t *testing.T, logDir string) (*ApiService, *events.CaptureSession) { + t.Helper() + ring := events.NewRingBuffer(16) + fw := events.NewFileWriter(logDir) + cs := events.NewCaptureSession(ring, fw) + cs.Start("test-session-123") + svc, err := New( + recorder.NewFFmpegManager(), + newMockFactory(), + newTestUpstreamManager(), + scaletozero.NewNoopController(), + newMockNekoClient(t), + cs, + 0, + ) + require.NoError(t, err) + return svc, cs +} + +func TestPublishEvent(t *testing.T) { + t.Run("happy_path", func(t *testing.T) { + logDir := t.TempDir() + svc, cs := newPublishTestService(t, logDir) + + b, _ := json.Marshal(events.Event{ + Type: "liveview.click", + Category: events.CategoryLiveview, + Source: events.Source{Kind: events.KindKernelAPI}, + Data: json.RawMessage(`{"x":100}`), + }) + req := httptest.NewRequest(http.MethodPost, "/events/publish", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + svc.PublishEvent(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + reader := cs.NewReader(0) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + res, err := reader.Read(ctx) + require.NoError(t, err) + require.NotNil(t, res.Envelope) + assert.Equal(t, "liveview.click", res.Envelope.Event.Type) + assert.Equal(t, events.CategoryLiveview, res.Envelope.Event.Category) + }) + + t.Run("invalid_json", func(t *testing.T) { + logDir := t.TempDir() + svc, _ := newPublishTestService(t, logDir) + + req := httptest.NewRequest(http.MethodPost, "/events/publish", bytes.NewReader([]byte(`not-json`))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + svc.PublishEvent(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("liveview_routes_correctly", func(t *testing.T) { + logDir := t.TempDir() + svc, _ := newPublishTestService(t, logDir) + + b, _ := json.Marshal(events.Event{ + Type: "liveview.click", + Category: events.CategoryLiveview, + Source: events.Source{Kind: events.KindKernelAPI}, + Data: json.RawMessage(`{"x":100}`), + }) + req := httptest.NewRequest(http.MethodPost, "/events/publish", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + svc.PublishEvent(w, req) + + require.Equal(t, http.StatusOK, w.Code) + entries, err := os.ReadDir(logDir) + require.NoError(t, err) + found := false + for _, e := range entries { + if e.Name() == "liveview.log" { + info, _ := e.Info() + assert.Greater(t, info.Size(), int64(0)) + found = true + } + } + assert.True(t, found, "liveview.log should exist in logDir") + }) + + t.Run("captcha_routes_correctly", func(t *testing.T) { + logDir := t.TempDir() + svc, _ := newPublishTestService(t, logDir) + + b, _ := json.Marshal(events.Event{ + Type: "captcha.solve", + Category: events.CategoryCaptcha, + Source: events.Source{Kind: events.KindKernelAPI}, + Data: json.RawMessage(`{"token":"abc"}`), + }) + req := httptest.NewRequest(http.MethodPost, "/events/publish", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + svc.PublishEvent(w, req) + + require.Equal(t, http.StatusOK, w.Code) + entries, err := os.ReadDir(logDir) + require.NoError(t, err) + found := false + for _, e := range entries { + if e.Name() == "captcha.log" { + info, _ := e.Info() + assert.Greater(t, info.Size(), int64(0)) + found = true + } + } + assert.True(t, found, "captcha.log should exist in logDir") + }) + + t.Run("category_derived_from_type", func(t *testing.T) { + logDir := t.TempDir() + svc, cs := newPublishTestService(t, logDir) + + // No Category field set — should be derived from Type prefix (underscore separator) + b, _ := json.Marshal(events.Event{ + Type: "liveview_click", + Data: json.RawMessage(`{"x":50}`), + }) + req := httptest.NewRequest(http.MethodPost, "/events/publish", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + svc.PublishEvent(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + reader := cs.NewReader(0) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + res, err := reader.Read(ctx) + require.NoError(t, err) + require.NotNil(t, res.Envelope) + assert.Equal(t, events.CategoryLiveview, res.Envelope.Event.Category) + + // liveview.log should also exist + _, statErr := os.Stat(filepath.Join(logDir, "liveview.log")) + assert.NoError(t, statErr, "liveview.log should exist after category derivation") + }) +} diff --git a/server/cmd/api/api/events_stream_test.go b/server/cmd/api/api/events_stream_test.go new file mode 100644 index 00000000..94070614 --- /dev/null +++ b/server/cmd/api/api/events_stream_test.go @@ -0,0 +1,122 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/onkernel/kernel-images/server/lib/events" + "github.com/stretchr/testify/assert" +) + +func TestStreamEvents(t *testing.T) { + t.Run("delivers_events", func(t *testing.T) { + logDir := t.TempDir() + svc, cs := newPublishTestService(t, logDir) + + // Publish 2 events before streaming + cs.Publish(events.Event{ + Type: "console.log", + Category: events.CategoryConsole, + Source: events.Source{Kind: events.KindCDP}, + }) + cs.Publish(events.Event{ + Type: "console.log", + Category: events.CategoryConsole, + Source: events.Source{Kind: events.KindCDP}, + }) + + // Create a request context that cancels after a short window + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + + req := httptest.NewRequest(http.MethodGet, "/events/stream", nil).WithContext(ctx) + w := httptest.NewRecorder() + + svc.StreamEvents(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "text/event-stream", w.Header().Get("Content-Type")) + body := w.Body.String() + assert.Contains(t, body, "id: 1", "should contain event with seq 1") + assert.Contains(t, body, "id: 2", "should contain event with seq 2") + }) + + t.Run("last_event_id_reconnect", func(t *testing.T) { + logDir := t.TempDir() + svc, cs := newPublishTestService(t, logDir) + + // Publish 3 events + cs.Publish(events.Event{Type: "console.log", Category: events.CategoryConsole, Source: events.Source{Kind: events.KindCDP}}) + cs.Publish(events.Event{Type: "console.log", Category: events.CategoryConsole, Source: events.Source{Kind: events.KindCDP}}) + cs.Publish(events.Event{Type: "console.log", Category: events.CategoryConsole, Source: events.Source{Kind: events.KindCDP}}) + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + + req := httptest.NewRequest(http.MethodGet, "/events/stream", nil).WithContext(ctx) + req.Header.Set("Last-Event-ID", "2") + w := httptest.NewRecorder() + + svc.StreamEvents(w, req) + + body := w.Body.String() + assert.Contains(t, body, "id: 3", "should contain event with seq 3") + assert.NotContains(t, body, "id: 1", "should not re-send seq 1") + assert.NotContains(t, body, "id: 2", "should not re-send seq 2") + }) + + t.Run("clean_disconnect", func(t *testing.T) { + logDir := t.TempDir() + svc, _ := newPublishTestService(t, logDir) + + // Context already cancelled before calling handler + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + req := httptest.NewRequest(http.MethodGet, "/events/stream", nil).WithContext(ctx) + w := httptest.NewRecorder() + + done := make(chan struct{}) + go func() { + svc.StreamEvents(w, req) + close(done) + }() + + select { + case <-done: + // Good: handler returned promptly + case <-time.After(100 * time.Millisecond): + t.Error("StreamEvents did not return promptly after context cancellation") + } + }) + + t.Run("no_flusher_returns_500", func(t *testing.T) { + logDir := t.TempDir() + svc, _ := newPublishTestService(t, logDir) + + req := httptest.NewRequest(http.MethodGet, "/events/stream", nil) + // Use a non-flusher ResponseWriter + w := &nonFlusherWriter{header: make(http.Header)} + + svc.StreamEvents(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.code) + }) +} + +// nonFlusherWriter is a ResponseWriter that does NOT implement http.Flusher. +type nonFlusherWriter struct { + header http.Header + code int + body strings.Builder +} + +func (w *nonFlusherWriter) Header() http.Header { return w.header } +func (w *nonFlusherWriter) WriteHeader(code int) { w.code = code } +func (w *nonFlusherWriter) Write(b []byte) (int, error) { + return w.body.Write(b) +} diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go index 767c4881..75a67039 100644 --- a/server/cmd/api/main.go +++ b/server/cmd/api/main.go @@ -24,8 +24,8 @@ import ( "github.com/onkernel/kernel-images/server/cmd/config" "github.com/onkernel/kernel-images/server/lib/chromedriverproxy" "github.com/onkernel/kernel-images/server/lib/devtoolsproxy" - "github.com/onkernel/kernel-images/server/lib/events" "github.com/onkernel/kernel-images/server/lib/logger" + "github.com/onkernel/kernel-images/server/lib/events" "github.com/onkernel/kernel-images/server/lib/nekoclient" oapi "github.com/onkernel/kernel-images/server/lib/oapi" "github.com/onkernel/kernel-images/server/lib/recorder" @@ -128,10 +128,6 @@ func main() { w.Header().Set("Content-Type", "application/json") w.Write(jsonData) }) - // capture events - r.Post("/events/start", apiService.StartCapture) - r.Post("/events/stop", apiService.StopCapture) - // PTY attach endpoint (WebSocket) - not part of OpenAPI spec // Uses WebSocket for bidirectional streaming, which works well through proxies. r.Get("/process/{process_id}/attach", func(w http.ResponseWriter, r *http.Request) { @@ -139,6 +135,12 @@ func main() { apiService.HandleProcessAttachWS(w, r, id) }) + // Events capture lifecycle (not part of OpenAPI spec — simple internal control endpoints) + r.Post("/events/start", apiService.StartCapture) + r.Post("/events/stop", apiService.StopCapture) + r.Post("/events/publish", apiService.PublishEvent) + r.Get("/events/stream", apiService.StreamEvents) + // Serve extension files for Chrome policy-installed extensions // This allows Chrome to download .crx and update.xml files via HTTP extensionsDir := "/home/kernel/extensions" From 9425b059884ec3d2a7c62839eec68d42445136e9 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Mon, 6 Apr 2026 15:10:38 +0000 Subject: [PATCH 25/28] review: update naming --- server/cmd/api/api/api.go | 4 +- server/cmd/api/api/events.go | 5 ++ server/cmd/api/api/events_publish_test.go | 24 ++++++-- server/cmd/api/api/events_stream_test.go | 10 ++-- server/cmd/api/main.go | 2 +- server/lib/events/pipeline.go | 70 ----------------------- 6 files changed, 34 insertions(+), 81 deletions(-) delete mode 100644 server/lib/events/pipeline.go diff --git a/server/cmd/api/api/api.go b/server/cmd/api/api/api.go index f8f46bed..68cdd201 100644 --- a/server/cmd/api/api/api.go +++ b/server/cmd/api/api/api.go @@ -336,7 +336,9 @@ func (s *ApiService) Shutdown(ctx context.Context) error { s.cdpMonitor.Stop() } if s.captureSession != nil { - _ = s.captureSession.Close() + if err := s.captureSession.Close(); err != nil { + logger.FromContext(ctx).Error("failed to close capture session", "err", err) + } } return s.recordManager.StopAll(ctx) } diff --git a/server/cmd/api/api/events.go b/server/cmd/api/api/events.go index c4bf43ad..6a17a3ac 100644 --- a/server/cmd/api/api/events.go +++ b/server/cmd/api/api/events.go @@ -50,6 +50,11 @@ func (s *ApiService) PublishEvent(w http.ResponseWriter, r *http.Request) { return } + if ev.Type == "" { + http.Error(w, "type is required", http.StatusBadRequest) + return + } + // Derive category if caller omitted it — FileWriter returns error for empty category. if ev.Category == "" { ev.Category = events.CategoryFor(ev.Type) diff --git a/server/cmd/api/api/events_publish_test.go b/server/cmd/api/api/events_publish_test.go index be3dace0..29704a7c 100644 --- a/server/cmd/api/api/events_publish_test.go +++ b/server/cmd/api/api/events_publish_test.go @@ -43,7 +43,7 @@ func TestPublishEvent(t *testing.T) { svc, cs := newPublishTestService(t, logDir) b, _ := json.Marshal(events.Event{ - Type: "liveview.click", + Type: "liveview_click", Category: events.CategoryLiveview, Source: events.Source{Kind: events.KindKernelAPI}, Data: json.RawMessage(`{"x":100}`), @@ -61,7 +61,7 @@ func TestPublishEvent(t *testing.T) { res, err := reader.Read(ctx) require.NoError(t, err) require.NotNil(t, res.Envelope) - assert.Equal(t, "liveview.click", res.Envelope.Event.Type) + assert.Equal(t, "liveview_click", res.Envelope.Event.Type) assert.Equal(t, events.CategoryLiveview, res.Envelope.Event.Category) }) @@ -77,12 +77,28 @@ func TestPublishEvent(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) }) + t.Run("empty_type_rejected", func(t *testing.T) { + logDir := t.TempDir() + svc, _ := newPublishTestService(t, logDir) + + b, _ := json.Marshal(events.Event{ + Category: events.CategoryConsole, + Data: json.RawMessage(`{"x":1}`), + }) + req := httptest.NewRequest(http.MethodPost, "/events/publish", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + svc.PublishEvent(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + t.Run("liveview_routes_correctly", func(t *testing.T) { logDir := t.TempDir() svc, _ := newPublishTestService(t, logDir) b, _ := json.Marshal(events.Event{ - Type: "liveview.click", + Type: "liveview_click", Category: events.CategoryLiveview, Source: events.Source{Kind: events.KindKernelAPI}, Data: json.RawMessage(`{"x":100}`), @@ -111,7 +127,7 @@ func TestPublishEvent(t *testing.T) { svc, _ := newPublishTestService(t, logDir) b, _ := json.Marshal(events.Event{ - Type: "captcha.solve", + Type: "captcha_solve", Category: events.CategoryCaptcha, Source: events.Source{Kind: events.KindKernelAPI}, Data: json.RawMessage(`{"token":"abc"}`), diff --git a/server/cmd/api/api/events_stream_test.go b/server/cmd/api/api/events_stream_test.go index 94070614..6ed4a8ae 100644 --- a/server/cmd/api/api/events_stream_test.go +++ b/server/cmd/api/api/events_stream_test.go @@ -19,12 +19,12 @@ func TestStreamEvents(t *testing.T) { // Publish 2 events before streaming cs.Publish(events.Event{ - Type: "console.log", + Type: "console_log", Category: events.CategoryConsole, Source: events.Source{Kind: events.KindCDP}, }) cs.Publish(events.Event{ - Type: "console.log", + Type: "console_log", Category: events.CategoryConsole, Source: events.Source{Kind: events.KindCDP}, }) @@ -50,9 +50,9 @@ func TestStreamEvents(t *testing.T) { svc, cs := newPublishTestService(t, logDir) // Publish 3 events - cs.Publish(events.Event{Type: "console.log", Category: events.CategoryConsole, Source: events.Source{Kind: events.KindCDP}}) - cs.Publish(events.Event{Type: "console.log", Category: events.CategoryConsole, Source: events.Source{Kind: events.KindCDP}}) - cs.Publish(events.Event{Type: "console.log", Category: events.CategoryConsole, Source: events.Source{Kind: events.KindCDP}}) + cs.Publish(events.Event{Type: "console_log", Category: events.CategoryConsole, Source: events.Source{Kind: events.KindCDP}}) + cs.Publish(events.Event{Type: "console_log", Category: events.CategoryConsole, Source: events.Source{Kind: events.KindCDP}}) + cs.Publish(events.Event{Type: "console_log", Category: events.CategoryConsole, Source: events.Source{Kind: events.KindCDP}}) ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) defer cancel() diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go index 75a67039..50b5636b 100644 --- a/server/cmd/api/main.go +++ b/server/cmd/api/main.go @@ -24,8 +24,8 @@ import ( "github.com/onkernel/kernel-images/server/cmd/config" "github.com/onkernel/kernel-images/server/lib/chromedriverproxy" "github.com/onkernel/kernel-images/server/lib/devtoolsproxy" - "github.com/onkernel/kernel-images/server/lib/logger" "github.com/onkernel/kernel-images/server/lib/events" + "github.com/onkernel/kernel-images/server/lib/logger" "github.com/onkernel/kernel-images/server/lib/nekoclient" oapi "github.com/onkernel/kernel-images/server/lib/oapi" "github.com/onkernel/kernel-images/server/lib/recorder" diff --git a/server/lib/events/pipeline.go b/server/lib/events/pipeline.go deleted file mode 100644 index e69c254f..00000000 --- a/server/lib/events/pipeline.go +++ /dev/null @@ -1,70 +0,0 @@ -package events - -import ( - "log/slog" - "sync" - "sync/atomic" - "time" -) - -// Pipeline is a single-use write path that wraps events in envelopes and fans -// them out to a FileWriter (durable) and RingBuffer (in-memory). Call Start -// once with a capture session ID, then Publish concurrently. Close flushes the -// FileWriter; there is no restart or terminal event. -type Pipeline struct { - mu sync.Mutex - ring *RingBuffer - files *FileWriter - seq atomic.Uint64 - captureSessionID atomic.Pointer[string] -} - -func NewPipeline(ring *RingBuffer, files *FileWriter) *Pipeline { - p := &Pipeline{ring: ring, files: files} - empty := "" - p.captureSessionID.Store(&empty) - return p -} - -// Start sets the capture session ID stamped on every subsequent envelope. -func (p *Pipeline) Start(captureSessionID string) { - p.captureSessionID.Store(&captureSessionID) -} - -// Publish wraps ev in an Envelope, truncates if needed, then writes to -// FileWriter (durable) before RingBuffer (in-memory fan-out). -func (p *Pipeline) Publish(ev Event) { - p.mu.Lock() - defer p.mu.Unlock() - - if ev.Ts == 0 { - ev.Ts = time.Now().UnixMilli() - } - if ev.DetailLevel == "" { - ev.DetailLevel = DetailStandard - } - - env := Envelope{ - CaptureSessionID: *p.captureSessionID.Load(), - Seq: p.seq.Add(1), - Event: ev, - } - env, data := truncateIfNeeded(env) - - if data == nil { - slog.Error("pipeline: marshal failed, skipping file write", "seq", env.Seq, "category", env.Event.Category) - } else if err := p.files.Write(env, data); err != nil { - slog.Error("pipeline: file write failed", "seq", env.Seq, "category", env.Event.Category, "err", err) - } - p.ring.Publish(env) -} - -// NewReader returns a Reader positioned at the start of the ring buffer. -func (p *Pipeline) NewReader(afterSeq uint64) *Reader { - return p.ring.NewReader(afterSeq) -} - -// Close flushes and releases all open file descriptors. -func (p *Pipeline) Close() error { - return p.files.Close() -} From c2435ed26b76de956a9092cc055d589b441d7192 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Mon, 6 Apr 2026 15:30:36 +0000 Subject: [PATCH 26/28] review: update tests --- server/cmd/api/api/events.go | 26 +---- server/cmd/api/api/events_publish_test.go | 128 ++++++++-------------- server/cmd/api/api/events_stream_test.go | 93 +++++++--------- 3 files changed, 92 insertions(+), 155 deletions(-) diff --git a/server/cmd/api/api/events.go b/server/cmd/api/api/events.go index 6a17a3ac..9c2435f8 100644 --- a/server/cmd/api/api/events.go +++ b/server/cmd/api/api/events.go @@ -13,10 +13,7 @@ import ( "github.com/onkernel/kernel-images/server/lib/logger" ) -// StartCapture handles POST /events/start. -// Registered as a direct chi route (not via OpenAPI spec) because these are -// simple internal control endpoints with no request body. -// A second call while already running restarts capture (stop+start). +// StartCapture handles POST /events/start. Restarts if already running. func (s *ApiService) StartCapture(w http.ResponseWriter, r *http.Request) { s.monitorMu.Lock() defer s.monitorMu.Unlock() @@ -32,7 +29,7 @@ func (s *ApiService) StartCapture(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } -// StopCapture handles POST /events/stop. Idempotent if not running. +// StopCapture handles POST /events/stop. No-op if not running. func (s *ApiService) StopCapture(w http.ResponseWriter, r *http.Request) { s.monitorMu.Lock() defer s.monitorMu.Unlock() @@ -41,8 +38,7 @@ func (s *ApiService) StopCapture(w http.ResponseWriter, r *http.Request) { } // PublishEvent handles POST /events/publish. -// Accepts an Event JSON body and ingests it into the pipeline (ring buffer + log file). -// Derives Category from Type if omitted; stamps KindKernelAPI if Source.Kind is omitted. +// Defaults Category (via CategoryFor) and Source.Kind (to KindKernelAPI) when omitted. func (s *ApiService) PublishEvent(w http.ResponseWriter, r *http.Request) { var ev events.Event if err := json.NewDecoder(r.Body).Decode(&ev); err != nil { @@ -55,12 +51,10 @@ func (s *ApiService) PublishEvent(w http.ResponseWriter, r *http.Request) { return } - // Derive category if caller omitted it — FileWriter returns error for empty category. if ev.Category == "" { ev.Category = events.CategoryFor(ev.Type) } - // Stamp provenance if caller omitted source kind. if ev.Source.Kind == "" { ev.Source.Kind = events.KindKernelAPI } @@ -69,10 +63,8 @@ func (s *ApiService) PublishEvent(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } -// StreamEvents handles GET /events/stream. -// Delivers a live stream of Envelopes over Server-Sent Events. -// Each frame is formatted as "id: {seq}\ndata: {json}\n\n". -// Clients may reconnect with Last-Event-ID to resume from the next unseen event. +// StreamEvents handles GET /events/stream (SSE). +// Supports Last-Event-ID for reconnection. func (s *ApiService) StreamEvents(w http.ResponseWriter, r *http.Request) { flusher, ok := w.(http.Flusher) if !ok { @@ -80,7 +72,6 @@ func (s *ApiService) StreamEvents(w http.ResponseWriter, r *http.Request) { return } - // Parse Last-Event-ID for reconnection (ignore parse errors; default to 0). var lastSeq uint64 if v := r.Header.Get("Last-Event-ID"); v != "" { if n, err := strconv.ParseUint(v, 10, 64); err == nil { @@ -94,19 +85,15 @@ func (s *ApiService) StreamEvents(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) flusher.Flush() - // NewReader(lastSeq) positions the reader to deliver events after lastSeq. reader := s.captureSession.NewReader(lastSeq) ctx := r.Context() - // Main event loop. for { res, err := reader.Read(ctx) if err != nil { - // Context cancelled (client disconnected). return } if res.Envelope == nil { - // Drop notification — skip, client will see the gap via seq discontinuity. continue } if err := writeSSEEnvelope(w, *res.Envelope); err != nil { @@ -116,8 +103,7 @@ func (s *ApiService) StreamEvents(w http.ResponseWriter, r *http.Request) { } } -// writeSSEEnvelope marshals env to JSON and writes a single SSE frame to w. -// Frame format: "id: {seq}\ndata: {json}\n\n" +// writeSSEEnvelope writes a single SSE frame: "id: {seq}\ndata: {json}\n\n". func writeSSEEnvelope(w io.Writer, env events.Envelope) error { data, err := json.Marshal(env) if err != nil { diff --git a/server/cmd/api/api/events_publish_test.go b/server/cmd/api/api/events_publish_test.go index 29704a7c..4e1e4099 100644 --- a/server/cmd/api/api/events_publish_test.go +++ b/server/cmd/api/api/events_publish_test.go @@ -23,7 +23,7 @@ func newPublishTestService(t *testing.T, logDir string) (*ApiService, *events.Ca ring := events.NewRingBuffer(16) fw := events.NewFileWriter(logDir) cs := events.NewCaptureSession(ring, fw) - cs.Start("test-session-123") + cs.Start("test-capture") svc, err := New( recorder.NewFFmpegManager(), newMockFactory(), @@ -37,37 +37,55 @@ func newPublishTestService(t *testing.T, logDir string) (*ApiService, *events.Ca return svc, cs } +func publishEvent(t *testing.T, svc *ApiService, ev events.Event) *httptest.ResponseRecorder { + t.Helper() + b, err := json.Marshal(ev) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, "/events/publish", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + svc.PublishEvent(w, req) + return w +} + +func readEnvelope(t *testing.T, cs *events.CaptureSession) events.Envelope { + t.Helper() + reader := cs.NewReader(0) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + res, err := reader.Read(ctx) + require.NoError(t, err) + require.NotNil(t, res.Envelope) + return *res.Envelope +} + +func assertLogFileExists(t *testing.T, logDir, filename string) { + t.Helper() + info, err := os.Stat(filepath.Join(logDir, filename)) + require.NoError(t, err, "%s should exist", filename) + assert.Greater(t, info.Size(), int64(0), "%s should be non-empty", filename) +} + func TestPublishEvent(t *testing.T) { - t.Run("happy_path", func(t *testing.T) { + t.Run("valid_event_published_to_ring", func(t *testing.T) { logDir := t.TempDir() svc, cs := newPublishTestService(t, logDir) - b, _ := json.Marshal(events.Event{ + w := publishEvent(t, svc, events.Event{ Type: "liveview_click", Category: events.CategoryLiveview, Source: events.Source{Kind: events.KindKernelAPI}, Data: json.RawMessage(`{"x":100}`), }) - req := httptest.NewRequest(http.MethodPost, "/events/publish", bytes.NewReader(b)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - svc.PublishEvent(w, req) - assert.Equal(t, http.StatusOK, w.Code) - reader := cs.NewReader(0) - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - res, err := reader.Read(ctx) - require.NoError(t, err) - require.NotNil(t, res.Envelope) - assert.Equal(t, "liveview_click", res.Envelope.Event.Type) - assert.Equal(t, events.CategoryLiveview, res.Envelope.Event.Category) + env := readEnvelope(t, cs) + assert.Equal(t, "liveview_click", env.Event.Type) + assert.Equal(t, events.CategoryLiveview, env.Event.Category) }) t.Run("invalid_json", func(t *testing.T) { - logDir := t.TempDir() - svc, _ := newPublishTestService(t, logDir) + svc, _ := newPublishTestService(t, t.TempDir()) req := httptest.NewRequest(http.MethodPost, "/events/publish", bytes.NewReader([]byte(`not-json`))) req.Header.Set("Content-Type", "application/json") @@ -78,105 +96,55 @@ func TestPublishEvent(t *testing.T) { }) t.Run("empty_type_rejected", func(t *testing.T) { - logDir := t.TempDir() - svc, _ := newPublishTestService(t, logDir) + svc, _ := newPublishTestService(t, t.TempDir()) - b, _ := json.Marshal(events.Event{ + w := publishEvent(t, svc, events.Event{ Category: events.CategoryConsole, Data: json.RawMessage(`{"x":1}`), }) - req := httptest.NewRequest(http.MethodPost, "/events/publish", bytes.NewReader(b)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - svc.PublishEvent(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code) }) - t.Run("liveview_routes_correctly", func(t *testing.T) { + t.Run("liveview_routes_to_log", func(t *testing.T) { logDir := t.TempDir() svc, _ := newPublishTestService(t, logDir) - b, _ := json.Marshal(events.Event{ + w := publishEvent(t, svc, events.Event{ Type: "liveview_click", Category: events.CategoryLiveview, Source: events.Source{Kind: events.KindKernelAPI}, Data: json.RawMessage(`{"x":100}`), }) - req := httptest.NewRequest(http.MethodPost, "/events/publish", bytes.NewReader(b)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - svc.PublishEvent(w, req) - require.Equal(t, http.StatusOK, w.Code) - entries, err := os.ReadDir(logDir) - require.NoError(t, err) - found := false - for _, e := range entries { - if e.Name() == "liveview.log" { - info, _ := e.Info() - assert.Greater(t, info.Size(), int64(0)) - found = true - } - } - assert.True(t, found, "liveview.log should exist in logDir") + assertLogFileExists(t, logDir, "liveview.log") }) - t.Run("captcha_routes_correctly", func(t *testing.T) { + t.Run("captcha_routes_to_log", func(t *testing.T) { logDir := t.TempDir() svc, _ := newPublishTestService(t, logDir) - b, _ := json.Marshal(events.Event{ + w := publishEvent(t, svc, events.Event{ Type: "captcha_solve", Category: events.CategoryCaptcha, Source: events.Source{Kind: events.KindKernelAPI}, Data: json.RawMessage(`{"token":"abc"}`), }) - req := httptest.NewRequest(http.MethodPost, "/events/publish", bytes.NewReader(b)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - svc.PublishEvent(w, req) - require.Equal(t, http.StatusOK, w.Code) - entries, err := os.ReadDir(logDir) - require.NoError(t, err) - found := false - for _, e := range entries { - if e.Name() == "captcha.log" { - info, _ := e.Info() - assert.Greater(t, info.Size(), int64(0)) - found = true - } - } - assert.True(t, found, "captcha.log should exist in logDir") + assertLogFileExists(t, logDir, "captcha.log") }) t.Run("category_derived_from_type", func(t *testing.T) { logDir := t.TempDir() svc, cs := newPublishTestService(t, logDir) - // No Category field set — should be derived from Type prefix (underscore separator) - b, _ := json.Marshal(events.Event{ + w := publishEvent(t, svc, events.Event{ Type: "liveview_click", Data: json.RawMessage(`{"x":50}`), }) - req := httptest.NewRequest(http.MethodPost, "/events/publish", bytes.NewReader(b)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - svc.PublishEvent(w, req) - require.Equal(t, http.StatusOK, w.Code) - reader := cs.NewReader(0) - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - res, err := reader.Read(ctx) - require.NoError(t, err) - require.NotNil(t, res.Envelope) - assert.Equal(t, events.CategoryLiveview, res.Envelope.Event.Category) - - // liveview.log should also exist - _, statErr := os.Stat(filepath.Join(logDir, "liveview.log")) - assert.NoError(t, statErr, "liveview.log should exist after category derivation") + env := readEnvelope(t, cs) + assert.Equal(t, events.CategoryLiveview, env.Event.Category) + assertLogFileExists(t, logDir, "liveview.log") }) } diff --git a/server/cmd/api/api/events_stream_test.go b/server/cmd/api/api/events_stream_test.go index 6ed4a8ae..fe4e893e 100644 --- a/server/cmd/api/api/events_stream_test.go +++ b/server/cmd/api/api/events_stream_test.go @@ -12,73 +12,62 @@ import ( "github.com/stretchr/testify/assert" ) +var testEvent = events.Event{ + Type: "console_log", + Category: events.CategoryConsole, + Source: events.Source{Kind: events.KindCDP}, +} + +func streamRequest(ctx context.Context) (*httptest.ResponseRecorder, *http.Request) { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/events/stream", nil).WithContext(ctx) + return w, req +} + func TestStreamEvents(t *testing.T) { - t.Run("delivers_events", func(t *testing.T) { - logDir := t.TempDir() - svc, cs := newPublishTestService(t, logDir) - - // Publish 2 events before streaming - cs.Publish(events.Event{ - Type: "console_log", - Category: events.CategoryConsole, - Source: events.Source{Kind: events.KindCDP}, - }) - cs.Publish(events.Event{ - Type: "console_log", - Category: events.CategoryConsole, - Source: events.Source{Kind: events.KindCDP}, - }) - - // Create a request context that cancels after a short window + t.Run("delivers_buffered_events", func(t *testing.T) { + svc, cs := newPublishTestService(t, t.TempDir()) + cs.Publish(testEvent) + cs.Publish(testEvent) + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) defer cancel() - - req := httptest.NewRequest(http.MethodGet, "/events/stream", nil).WithContext(ctx) - w := httptest.NewRecorder() + w, req := streamRequest(ctx) svc.StreamEvents(w, req) assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, "text/event-stream", w.Header().Get("Content-Type")) body := w.Body.String() - assert.Contains(t, body, "id: 1", "should contain event with seq 1") - assert.Contains(t, body, "id: 2", "should contain event with seq 2") + assert.Contains(t, body, "id: 1") + assert.Contains(t, body, "id: 2") }) - t.Run("last_event_id_reconnect", func(t *testing.T) { - logDir := t.TempDir() - svc, cs := newPublishTestService(t, logDir) - - // Publish 3 events - cs.Publish(events.Event{Type: "console_log", Category: events.CategoryConsole, Source: events.Source{Kind: events.KindCDP}}) - cs.Publish(events.Event{Type: "console_log", Category: events.CategoryConsole, Source: events.Source{Kind: events.KindCDP}}) - cs.Publish(events.Event{Type: "console_log", Category: events.CategoryConsole, Source: events.Source{Kind: events.KindCDP}}) + t.Run("resumes_after_last_event_id", func(t *testing.T) { + svc, cs := newPublishTestService(t, t.TempDir()) + cs.Publish(testEvent) + cs.Publish(testEvent) + cs.Publish(testEvent) ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) defer cancel() - - req := httptest.NewRequest(http.MethodGet, "/events/stream", nil).WithContext(ctx) + w, req := streamRequest(ctx) req.Header.Set("Last-Event-ID", "2") - w := httptest.NewRecorder() svc.StreamEvents(w, req) body := w.Body.String() - assert.Contains(t, body, "id: 3", "should contain event with seq 3") - assert.NotContains(t, body, "id: 1", "should not re-send seq 1") - assert.NotContains(t, body, "id: 2", "should not re-send seq 2") + assert.Contains(t, body, "id: 3") + assert.NotContains(t, body, "id: 1") + assert.NotContains(t, body, "id: 2") }) - t.Run("clean_disconnect", func(t *testing.T) { - logDir := t.TempDir() - svc, _ := newPublishTestService(t, logDir) + t.Run("exits_on_cancelled_context", func(t *testing.T) { + svc, _ := newPublishTestService(t, t.TempDir()) - // Context already cancelled before calling handler ctx, cancel := context.WithCancel(context.Background()) cancel() - - req := httptest.NewRequest(http.MethodGet, "/events/stream", nil).WithContext(ctx) - w := httptest.NewRecorder() + w, req := streamRequest(ctx) done := make(chan struct{}) go func() { @@ -88,18 +77,15 @@ func TestStreamEvents(t *testing.T) { select { case <-done: - // Good: handler returned promptly case <-time.After(100 * time.Millisecond): - t.Error("StreamEvents did not return promptly after context cancellation") + t.Error("StreamEvents did not return after context cancellation") } }) - t.Run("no_flusher_returns_500", func(t *testing.T) { - logDir := t.TempDir() - svc, _ := newPublishTestService(t, logDir) + t.Run("rejects_non_flusher", func(t *testing.T) { + svc, _ := newPublishTestService(t, t.TempDir()) req := httptest.NewRequest(http.MethodGet, "/events/stream", nil) - // Use a non-flusher ResponseWriter w := &nonFlusherWriter{header: make(http.Header)} svc.StreamEvents(w, req) @@ -108,15 +94,12 @@ func TestStreamEvents(t *testing.T) { }) } -// nonFlusherWriter is a ResponseWriter that does NOT implement http.Flusher. type nonFlusherWriter struct { header http.Header code int body strings.Builder } -func (w *nonFlusherWriter) Header() http.Header { return w.header } -func (w *nonFlusherWriter) WriteHeader(code int) { w.code = code } -func (w *nonFlusherWriter) Write(b []byte) (int, error) { - return w.body.Write(b) -} +func (w *nonFlusherWriter) Header() http.Header { return w.header } +func (w *nonFlusherWriter) WriteHeader(code int) { w.code = code } +func (w *nonFlusherWriter) Write(b []byte) (int, error) { return w.body.Write(b) } From 27856a3cfcc6fe52c429bd407854a6c34349ce3c Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Mon, 6 Apr 2026 16:13:54 +0000 Subject: [PATCH 27/28] review: add mutex lock/unlock --- server/cmd/api/api/api.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/cmd/api/api/api.go b/server/cmd/api/api/api.go index 68cdd201..57689c80 100644 --- a/server/cmd/api/api/api.go +++ b/server/cmd/api/api/api.go @@ -332,6 +332,7 @@ func (s *ApiService) ListRecorders(ctx context.Context, _ oapi.ListRecordersRequ } func (s *ApiService) Shutdown(ctx context.Context) error { + s.monitorMu.Lock() if s.cdpMonitor != nil { s.cdpMonitor.Stop() } @@ -340,5 +341,6 @@ func (s *ApiService) Shutdown(ctx context.Context) error { logger.FromContext(ctx).Error("failed to close capture session", "err", err) } } + s.monitorMu.Unlock() return s.recordManager.StopAll(ctx) } From 839e1b57f42d3773b13b85c471deb20a6365def0 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Tue, 7 Apr 2026 11:39:11 +0000 Subject: [PATCH 28/28] feat: add tests --- server/cmd/api/api/events_publish_test.go | 144 ++++++++++++++++++---- server/cmd/api/api/events_stream_test.go | 131 +++++++++++++++----- server/cmd/api/main.go | 2 +- 3 files changed, 218 insertions(+), 59 deletions(-) diff --git a/server/cmd/api/api/events_publish_test.go b/server/cmd/api/api/events_publish_test.go index 4e1e4099..d666d7fc 100644 --- a/server/cmd/api/api/events_publish_test.go +++ b/server/cmd/api/api/events_publish_test.go @@ -24,6 +24,7 @@ func newPublishTestService(t *testing.T, logDir string) (*ApiService, *events.Ca fw := events.NewFileWriter(logDir) cs := events.NewCaptureSession(ring, fw) cs.Start("test-capture") + t.Cleanup(func() { cs.Close() }) svc, err := New( recorder.NewFFmpegManager(), newMockFactory(), @@ -59,11 +60,11 @@ func readEnvelope(t *testing.T, cs *events.CaptureSession) events.Envelope { return *res.Envelope } -func assertLogFileExists(t *testing.T, logDir, filename string) { +func requireLogFileExists(t *testing.T, logDir, filename string) { t.Helper() info, err := os.Stat(filepath.Join(logDir, filename)) require.NoError(t, err, "%s should exist", filename) - assert.Greater(t, info.Size(), int64(0), "%s should be non-empty", filename) + require.Greater(t, info.Size(), int64(0), "%s should be non-empty", filename) } func TestPublishEvent(t *testing.T) { @@ -77,7 +78,7 @@ func TestPublishEvent(t *testing.T) { Source: events.Source{Kind: events.KindKernelAPI}, Data: json.RawMessage(`{"x":100}`), }) - assert.Equal(t, http.StatusOK, w.Code) + require.Equal(t, http.StatusOK, w.Code) env := readEnvelope(t, cs) assert.Equal(t, "liveview_click", env.Event.Type) @@ -92,7 +93,20 @@ func TestPublishEvent(t *testing.T) { w := httptest.NewRecorder() svc.PublishEvent(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code) + require.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "invalid JSON body") + }) + + t.Run("empty_body_rejected", func(t *testing.T) { + svc, _ := newPublishTestService(t, t.TempDir()) + + req := httptest.NewRequest(http.MethodPost, "/events/publish", http.NoBody) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + svc.PublishEvent(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "invalid JSON body") }) t.Run("empty_type_rejected", func(t *testing.T) { @@ -102,49 +116,125 @@ func TestPublishEvent(t *testing.T) { Category: events.CategoryConsole, Data: json.RawMessage(`{"x":1}`), }) - assert.Equal(t, http.StatusBadRequest, w.Code) + require.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "type is required") }) - t.Run("liveview_routes_to_log", func(t *testing.T) { + t.Run("category_derived_from_type", func(t *testing.T) { logDir := t.TempDir() - svc, _ := newPublishTestService(t, logDir) + svc, cs := newPublishTestService(t, logDir) w := publishEvent(t, svc, events.Event{ - Type: "liveview_click", - Category: events.CategoryLiveview, - Source: events.Source{Kind: events.KindKernelAPI}, - Data: json.RawMessage(`{"x":100}`), + Type: "liveview_click", + Data: json.RawMessage(`{"x":50}`), }) require.Equal(t, http.StatusOK, w.Code) - assertLogFileExists(t, logDir, "liveview.log") + + env := readEnvelope(t, cs) + assert.Equal(t, events.CategoryLiveview, env.Event.Category) + requireLogFileExists(t, logDir, "liveview.log") }) - t.Run("captcha_routes_to_log", func(t *testing.T) { - logDir := t.TempDir() - svc, _ := newPublishTestService(t, logDir) + t.Run("source_kind_defaults_to_kernel_api", func(t *testing.T) { + svc, cs := newPublishTestService(t, t.TempDir()) w := publishEvent(t, svc, events.Event{ - Type: "captcha_solve", - Category: events.CategoryCaptcha, - Source: events.Source{Kind: events.KindKernelAPI}, - Data: json.RawMessage(`{"token":"abc"}`), + Type: "console_log", + Data: json.RawMessage(`{"msg":"hi"}`), + }) + require.Equal(t, http.StatusOK, w.Code) + + env := readEnvelope(t, cs) + assert.Equal(t, events.KindKernelAPI, env.Event.Source.Kind) + }) + + t.Run("source_kind_preserved_when_set", func(t *testing.T) { + svc, cs := newPublishTestService(t, t.TempDir()) + + w := publishEvent(t, svc, events.Event{ + Type: "captcha_solve", + Source: events.Source{Kind: events.KindExtension}, + Data: json.RawMessage(`{"token":"abc"}`), }) require.Equal(t, http.StatusOK, w.Code) - assertLogFileExists(t, logDir, "captcha.log") + + env := readEnvelope(t, cs) + assert.Equal(t, events.KindExtension, env.Event.Source.Kind) }) - t.Run("category_derived_from_type", func(t *testing.T) { - logDir := t.TempDir() - svc, cs := newPublishTestService(t, logDir) + t.Run("unknown_type_prefix_defaults_to_system", func(t *testing.T) { + svc, cs := newPublishTestService(t, t.TempDir()) w := publishEvent(t, svc, events.Event{ - Type: "liveview_click", - Data: json.RawMessage(`{"x":50}`), + Type: "custom_something", + Data: json.RawMessage(`{"k":"v"}`), }) require.Equal(t, http.StatusOK, w.Code) env := readEnvelope(t, cs) - assert.Equal(t, events.CategoryLiveview, env.Event.Category) - assertLogFileExists(t, logDir, "liveview.log") + assert.Equal(t, events.CategorySystem, env.Event.Category) + }) + + t.Run("nil_data_accepted", func(t *testing.T) { + svc, cs := newPublishTestService(t, t.TempDir()) + + w := publishEvent(t, svc, events.Event{ + Type: "page_load", + Category: events.CategoryPage, + }) + require.Equal(t, http.StatusOK, w.Code) + + env := readEnvelope(t, cs) + assert.Equal(t, "page_load", env.Event.Type) + }) + + t.Run("routes_to_category_log_files", func(t *testing.T) { + tests := []struct { + name string + event events.Event + logFile string + }{ + { + name: "liveview", + event: events.Event{ + Type: "liveview_click", + Category: events.CategoryLiveview, + Source: events.Source{Kind: events.KindKernelAPI}, + Data: json.RawMessage(`{"x":100}`), + }, + logFile: "liveview.log", + }, + { + name: "captcha", + event: events.Event{ + Type: "captcha_solve", + Category: events.CategoryCaptcha, + Source: events.Source{Kind: events.KindKernelAPI}, + Data: json.RawMessage(`{"token":"abc"}`), + }, + logFile: "captcha.log", + }, + { + name: "network", + event: events.Event{ + Type: "network_request", + Category: events.CategoryNetwork, + Source: events.Source{Kind: events.KindKernelAPI}, + Data: json.RawMessage(`{"url":"https://example.com"}`), + }, + logFile: "network.log", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + logDir := t.TempDir() + svc, _ := newPublishTestService(t, logDir) + + w := publishEvent(t, svc, tt.event) + require.Equal(t, http.StatusOK, w.Code) + requireLogFileExists(t, logDir, tt.logFile) + }) + } }) } diff --git a/server/cmd/api/api/events_stream_test.go b/server/cmd/api/api/events_stream_test.go index fe4e893e..6e538480 100644 --- a/server/cmd/api/api/events_stream_test.go +++ b/server/cmd/api/api/events_stream_test.go @@ -2,64 +2,128 @@ package api import ( "context" + "encoding/json" "net/http" "net/http/httptest" + "regexp" "strings" "testing" "time" "github.com/onkernel/kernel-images/server/lib/events" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -var testEvent = events.Event{ - Type: "console_log", - Category: events.CategoryConsole, - Source: events.Source{Kind: events.KindCDP}, +func makeTestEvent() events.Event { + return events.Event{ + Type: "console_log", + Category: events.CategoryConsole, + Source: events.Source{Kind: events.KindCDP}, + } } -func streamRequest(ctx context.Context) (*httptest.ResponseRecorder, *http.Request) { +func timedStreamRequest(t *testing.T, timeout time.Duration) (*httptest.ResponseRecorder, *http.Request, context.CancelFunc) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), timeout) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/events/stream", nil).WithContext(ctx) - return w, req + return w, req, cancel } +// sseFrameRe matches a valid SSE frame: "id: \ndata: \n\n" +var sseFrameRe = regexp.MustCompile(`id: (\d+)\ndata: (\{.*\})\n\n`) + func TestStreamEvents(t *testing.T) { t.Run("delivers_buffered_events", func(t *testing.T) { svc, cs := newPublishTestService(t, t.TempDir()) - cs.Publish(testEvent) - cs.Publish(testEvent) + cs.Publish(makeTestEvent()) + cs.Publish(makeTestEvent()) - ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + w, req, cancel := timedStreamRequest(t, 2*time.Second) defer cancel() - w, req := streamRequest(ctx) svc.StreamEvents(w, req) - assert.Equal(t, http.StatusOK, w.Code) - assert.Equal(t, "text/event-stream", w.Header().Get("Content-Type")) - body := w.Body.String() - assert.Contains(t, body, "id: 1") - assert.Contains(t, body, "id: 2") + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, "text/event-stream", w.Header().Get("Content-Type")) + assert.Equal(t, "no-cache", w.Header().Get("Cache-Control")) + assert.Equal(t, "no", w.Header().Get("X-Accel-Buffering")) + + frames := sseFrameRe.FindAllStringSubmatch(w.Body.String(), -1) + require.Len(t, frames, 2) + assert.Equal(t, "1", frames[0][1]) + assert.Equal(t, "2", frames[1][1]) }) t.Run("resumes_after_last_event_id", func(t *testing.T) { svc, cs := newPublishTestService(t, t.TempDir()) - cs.Publish(testEvent) - cs.Publish(testEvent) - cs.Publish(testEvent) + cs.Publish(makeTestEvent()) + cs.Publish(makeTestEvent()) + cs.Publish(makeTestEvent()) - ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + w, req, cancel := timedStreamRequest(t, 2*time.Second) defer cancel() - w, req := streamRequest(ctx) req.Header.Set("Last-Event-ID", "2") svc.StreamEvents(w, req) - body := w.Body.String() - assert.Contains(t, body, "id: 3") - assert.NotContains(t, body, "id: 1") - assert.NotContains(t, body, "id: 2") + frames := sseFrameRe.FindAllStringSubmatch(w.Body.String(), -1) + require.Len(t, frames, 1) + assert.Equal(t, "3", frames[0][1]) + }) + + t.Run("invalid_last_event_id_starts_from_zero", func(t *testing.T) { + svc, cs := newPublishTestService(t, t.TempDir()) + cs.Publish(makeTestEvent()) + + w, req, cancel := timedStreamRequest(t, 2*time.Second) + defer cancel() + req.Header.Set("Last-Event-ID", "garbage") + + svc.StreamEvents(w, req) + + frames := sseFrameRe.FindAllStringSubmatch(w.Body.String(), -1) + require.Len(t, frames, 1, "invalid Last-Event-ID should fall back to seq 0") + assert.Equal(t, "1", frames[0][1]) + }) + + t.Run("skips_dropped_events_on_ring_overflow", func(t *testing.T) { + // Ring buffer capacity is 16 (from newPublishTestService). + // Publishing 20 events overflows the ring; the reader should + // skip nil-envelope results (dropped events) without hanging. + svc, cs := newPublishTestService(t, t.TempDir()) + for range 20 { + cs.Publish(makeTestEvent()) + } + + w, req, cancel := timedStreamRequest(t, 2*time.Second) + defer cancel() + + svc.StreamEvents(w, req) + + frames := sseFrameRe.FindAllStringSubmatch(w.Body.String(), -1) + // Ring capacity is 16; 20 publishes means 4 evicted, 16 surviving. + require.Len(t, frames, 16) + }) + + t.Run("sse_frame_contains_valid_json", func(t *testing.T) { + svc, cs := newPublishTestService(t, t.TempDir()) + cs.Publish(makeTestEvent()) + + w, req, cancel := timedStreamRequest(t, 2*time.Second) + defer cancel() + + svc.StreamEvents(w, req) + + frames := sseFrameRe.FindAllStringSubmatch(w.Body.String(), -1) + require.Len(t, frames, 1) + + var env events.Envelope + require.NoError(t, json.Unmarshal([]byte(frames[0][2]), &env)) + assert.Equal(t, uint64(1), env.Seq) + assert.Equal(t, "console_log", env.Event.Type) + assert.Equal(t, "test-capture", env.CaptureSessionID) }) t.Run("exits_on_cancelled_context", func(t *testing.T) { @@ -67,7 +131,11 @@ func TestStreamEvents(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - w, req := streamRequest(ctx) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/events/stream", nil).WithContext(ctx) + + timer := time.NewTimer(2 * time.Second) + defer timer.Stop() done := make(chan struct{}) go func() { @@ -77,8 +145,8 @@ func TestStreamEvents(t *testing.T) { select { case <-done: - case <-time.After(100 * time.Millisecond): - t.Error("StreamEvents did not return after context cancellation") + case <-timer.C: + t.Fatal("StreamEvents did not return after context cancellation") } }) @@ -90,7 +158,8 @@ func TestStreamEvents(t *testing.T) { svc.StreamEvents(w, req) - assert.Equal(t, http.StatusInternalServerError, w.code) + require.Equal(t, http.StatusInternalServerError, w.code) + assert.Contains(t, w.body.String(), "streaming not supported") }) } @@ -100,6 +169,6 @@ type nonFlusherWriter struct { body strings.Builder } -func (w *nonFlusherWriter) Header() http.Header { return w.header } -func (w *nonFlusherWriter) WriteHeader(code int) { w.code = code } -func (w *nonFlusherWriter) Write(b []byte) (int, error) { return w.body.Write(b) } +func (w *nonFlusherWriter) Header() http.Header { return w.header } +func (w *nonFlusherWriter) WriteHeader(code int) { w.code = code } +func (w *nonFlusherWriter) Write(b []byte) (int, error) { return w.body.Write(b) } diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go index 50b5636b..6fd380bb 100644 --- a/server/cmd/api/main.go +++ b/server/cmd/api/main.go @@ -135,7 +135,7 @@ func main() { apiService.HandleProcessAttachWS(w, r, id) }) - // Events capture lifecycle (not part of OpenAPI spec — simple internal control endpoints) + // Events capture lifecycle r.Post("/events/start", apiService.StartCapture) r.Post("/events/stop", apiService.StopCapture) r.Post("/events/publish", apiService.PublishEvent)