Skip to content

Commit a64605e

Browse files
committed
feat: add cdpmonitor handler tests and session/redirect integration tests
1 parent a0e9fb4 commit a64605e

File tree

3 files changed

+581
-0
lines changed

3 files changed

+581
-0
lines changed

server/lib/cdpmonitor/cdp_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,3 +333,39 @@ func startMonitor(t *testing.T, srv *testServer, fn ResponderFunc) (*Monitor, *e
333333
}
334334
return m, ec, cleanup
335335
}
336+
337+
// newComputedMonitor creates an unconnected Monitor for testing computed state
338+
// (network_idle, layout_settled, navigation_settled) without a real websocket.
339+
func newComputedMonitor(t *testing.T) (*Monitor, *eventCollector) {
340+
t.Helper()
341+
ec := newEventCollector()
342+
upstream := newTestUpstream("ws://127.0.0.1:0")
343+
m := New(upstream, ec.publishFn(), 0, discardLogger)
344+
return m, ec
345+
}
346+
347+
// navigateMonitor sends a Page.frameNavigated to reset computed state.
348+
func navigateMonitor(m *Monitor, url string) {
349+
p, _ := json.Marshal(map[string]any{
350+
"frame": map[string]any{"id": "f1", "url": url},
351+
})
352+
m.handleFrameNavigated(p, "s1")
353+
}
354+
355+
// simulateRequest sends a Network.requestWillBeSent through the handler.
356+
func simulateRequest(m *Monitor, id string) {
357+
p, _ := json.Marshal(map[string]any{
358+
"requestId": id, "resourceType": "Document",
359+
"request": map[string]any{"method": "GET", "url": "https://example.com/" + id},
360+
})
361+
m.handleNetworkRequest(p, "s1")
362+
}
363+
364+
// simulateFinished stores minimal state and sends Network.loadingFinished.
365+
func simulateFinished(m *Monitor, id string) {
366+
m.pendReqMu.Lock()
367+
m.pendingRequests[id] = networkReqState{method: "GET", url: "https://example.com/" + id}
368+
m.pendReqMu.Unlock()
369+
p, _ := json.Marshal(map[string]any{"requestId": id})
370+
m.handleLoadingFinished(p, "s1")
371+
}
Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
1+
package cdpmonitor
2+
3+
import (
4+
"encoding/json"
5+
"sync/atomic"
6+
"testing"
7+
"time"
8+
9+
"github.com/kernel/kernel-images/server/lib/events"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestConsoleEvents(t *testing.T) {
15+
srv := newTestServer(t)
16+
defer srv.close()
17+
18+
_, ec, cleanup := startMonitor(t, srv, nil)
19+
defer cleanup()
20+
21+
t.Run("console_log", func(t *testing.T) {
22+
srv.sendToMonitor(t, map[string]any{
23+
"method": "Runtime.consoleAPICalled",
24+
"params": map[string]any{
25+
"type": "log",
26+
"args": []any{map[string]any{"type": "string", "value": "hello world"}},
27+
},
28+
})
29+
ev := ec.waitFor(t, "console_log", 2*time.Second)
30+
assert.Equal(t, events.CategoryConsole, ev.Category)
31+
assert.Equal(t, events.KindCDP, ev.Source.Kind)
32+
assert.Equal(t, "Runtime.consoleAPICalled", ev.Source.Event)
33+
var data map[string]any
34+
require.NoError(t, json.Unmarshal(ev.Data, &data))
35+
assert.Equal(t, "log", data["level"])
36+
assert.Equal(t, "hello world", data["text"])
37+
})
38+
39+
t.Run("exception_thrown", func(t *testing.T) {
40+
srv.sendToMonitor(t, map[string]any{
41+
"method": "Runtime.exceptionThrown",
42+
"params": map[string]any{
43+
"timestamp": 1234.5,
44+
"exceptionDetails": map[string]any{
45+
"text": "Uncaught TypeError",
46+
"lineNumber": 42,
47+
"columnNumber": 7,
48+
"url": "https://example.com/app.js",
49+
},
50+
},
51+
})
52+
ev := ec.waitFor(t, "console_error", 2*time.Second)
53+
assert.Equal(t, events.CategoryConsole, ev.Category)
54+
var data map[string]any
55+
require.NoError(t, json.Unmarshal(ev.Data, &data))
56+
assert.Equal(t, "Uncaught TypeError", data["text"])
57+
assert.Equal(t, float64(42), data["line"])
58+
})
59+
60+
t.Run("non_string_args", func(t *testing.T) {
61+
srv.sendToMonitor(t, map[string]any{
62+
"method": "Runtime.consoleAPICalled",
63+
"params": map[string]any{
64+
"type": "log",
65+
"args": []any{
66+
map[string]any{"type": "number", "value": 42},
67+
map[string]any{"type": "object", "value": map[string]any{"key": "val"}},
68+
map[string]any{"type": "undefined"},
69+
},
70+
},
71+
})
72+
ev := ec.waitForNew(t, "console_log", 2*time.Second)
73+
var data map[string]any
74+
require.NoError(t, json.Unmarshal(ev.Data, &data))
75+
args := data["args"].([]any)
76+
assert.Equal(t, "42", args[0])
77+
assert.Contains(t, args[1], "key")
78+
assert.Equal(t, "undefined", args[2])
79+
})
80+
}
81+
82+
func TestNetworkEvents(t *testing.T) {
83+
srv := newTestServer(t)
84+
defer srv.close()
85+
86+
var getBodyCalled atomic.Bool
87+
responder := func(msg cdpMessage) any {
88+
if msg.Method == "Network.getResponseBody" {
89+
getBodyCalled.Store(true)
90+
return map[string]any{
91+
"id": msg.ID,
92+
"result": map[string]any{"body": `{"ok":true}`, "base64Encoded": false},
93+
}
94+
}
95+
return nil
96+
}
97+
_, ec, cleanup := startMonitor(t, srv, responder)
98+
defer cleanup()
99+
100+
t.Run("request_and_response", func(t *testing.T) {
101+
srv.sendToMonitor(t, map[string]any{
102+
"method": "Network.requestWillBeSent",
103+
"params": map[string]any{
104+
"requestId": "req-001",
105+
"resourceType": "XHR",
106+
"request": map[string]any{
107+
"method": "POST",
108+
"url": "https://api.example.com/data",
109+
"headers": map[string]any{"Content-Type": "application/json"},
110+
},
111+
"initiator": map[string]any{"type": "script"},
112+
},
113+
})
114+
ev := ec.waitFor(t, "network_request", 2*time.Second)
115+
assert.Equal(t, events.CategoryNetwork, ev.Category)
116+
assert.Equal(t, "Network.requestWillBeSent", ev.Source.Event)
117+
118+
var data map[string]any
119+
require.NoError(t, json.Unmarshal(ev.Data, &data))
120+
assert.Equal(t, "POST", data["method"])
121+
assert.Equal(t, "https://api.example.com/data", data["url"])
122+
123+
srv.sendToMonitor(t, map[string]any{
124+
"method": "Network.responseReceived",
125+
"params": map[string]any{
126+
"requestId": "req-001",
127+
"response": map[string]any{
128+
"status": 200, "statusText": "OK",
129+
"headers": map[string]any{"Content-Type": "application/json"}, "mimeType": "application/json",
130+
},
131+
},
132+
})
133+
srv.sendToMonitor(t, map[string]any{
134+
"method": "Network.loadingFinished",
135+
"params": map[string]any{"requestId": "req-001"},
136+
})
137+
138+
ev2 := ec.waitFor(t, "network_response", 3*time.Second)
139+
assert.Equal(t, "Network.loadingFinished", ev2.Source.Event)
140+
var data2 map[string]any
141+
require.NoError(t, json.Unmarshal(ev2.Data, &data2))
142+
assert.Equal(t, float64(200), data2["status"])
143+
assert.NotEmpty(t, data2["body"])
144+
})
145+
146+
t.Run("loading_failed", func(t *testing.T) {
147+
srv.sendToMonitor(t, map[string]any{
148+
"method": "Network.requestWillBeSent",
149+
"params": map[string]any{
150+
"requestId": "req-002",
151+
"request": map[string]any{"method": "GET", "url": "https://fail.example.com/"},
152+
},
153+
})
154+
ec.waitForNew(t, "network_request", 2*time.Second)
155+
156+
srv.sendToMonitor(t, map[string]any{
157+
"method": "Network.loadingFailed",
158+
"params": map[string]any{
159+
"requestId": "req-002",
160+
"errorText": "net::ERR_CONNECTION_REFUSED",
161+
"canceled": false,
162+
},
163+
})
164+
ev := ec.waitFor(t, "network_loading_failed", 2*time.Second)
165+
assert.Equal(t, events.CategoryNetwork, ev.Category)
166+
var data map[string]any
167+
require.NoError(t, json.Unmarshal(ev.Data, &data))
168+
assert.Equal(t, "net::ERR_CONNECTION_REFUSED", data["error_text"])
169+
})
170+
171+
t.Run("binary_resource_skips_body", func(t *testing.T) {
172+
getBodyCalled.Store(false)
173+
srv.sendToMonitor(t, map[string]any{
174+
"method": "Network.requestWillBeSent",
175+
"params": map[string]any{
176+
"requestId": "img-001",
177+
"resourceType": "Image",
178+
"request": map[string]any{"method": "GET", "url": "https://example.com/photo.png"},
179+
},
180+
})
181+
srv.sendToMonitor(t, map[string]any{
182+
"method": "Network.responseReceived",
183+
"params": map[string]any{
184+
"requestId": "img-001",
185+
"response": map[string]any{"status": 200, "statusText": "OK", "headers": map[string]any{}, "mimeType": "image/png"},
186+
},
187+
})
188+
srv.sendToMonitor(t, map[string]any{
189+
"method": "Network.loadingFinished",
190+
"params": map[string]any{"requestId": "img-001"},
191+
})
192+
193+
ev := ec.waitForNew(t, "network_response", 3*time.Second)
194+
var data map[string]any
195+
require.NoError(t, json.Unmarshal(ev.Data, &data))
196+
assert.Nil(t, data["body"], "binary resource should not have body field")
197+
assert.False(t, getBodyCalled.Load(), "should not call getResponseBody for images")
198+
})
199+
}
200+
201+
func TestPageEvents(t *testing.T) {
202+
srv := newTestServer(t)
203+
defer srv.close()
204+
205+
_, ec, cleanup := startMonitor(t, srv, nil)
206+
defer cleanup()
207+
208+
srv.sendToMonitor(t, map[string]any{
209+
"method": "Page.frameNavigated",
210+
"params": map[string]any{
211+
"frame": map[string]any{"id": "frame-1", "url": "https://example.com/page"},
212+
},
213+
})
214+
ev := ec.waitFor(t, "navigation", 2*time.Second)
215+
assert.Equal(t, events.CategoryPage, ev.Category)
216+
assert.Equal(t, "Page.frameNavigated", ev.Source.Event)
217+
var data map[string]any
218+
require.NoError(t, json.Unmarshal(ev.Data, &data))
219+
assert.Equal(t, "https://example.com/page", data["url"])
220+
221+
srv.sendToMonitor(t, map[string]any{
222+
"method": "Page.domContentEventFired",
223+
"params": map[string]any{"timestamp": 1000.0},
224+
})
225+
ev2 := ec.waitFor(t, "dom_content_loaded", 2*time.Second)
226+
assert.Equal(t, events.CategoryPage, ev2.Category)
227+
srv.sendToMonitor(t, map[string]any{
228+
"method": "Page.loadEventFired",
229+
"params": map[string]any{"timestamp": 1001.0},
230+
})
231+
ev3 := ec.waitFor(t, "page_load", 2*time.Second)
232+
assert.Equal(t, events.CategoryPage, ev3.Category)
233+
}
234+
235+
func TestTargetEvents(t *testing.T) {
236+
srv := newTestServer(t)
237+
defer srv.close()
238+
239+
_, ec, cleanup := startMonitor(t, srv, nil)
240+
defer cleanup()
241+
242+
srv.sendToMonitor(t, map[string]any{
243+
"method": "Target.targetCreated",
244+
"params": map[string]any{
245+
"targetInfo": map[string]any{"targetId": "t-1", "type": "page", "url": "https://new.example.com"},
246+
},
247+
})
248+
ev := ec.waitFor(t, "target_created", 2*time.Second)
249+
assert.Equal(t, events.CategoryPage, ev.Category)
250+
var data map[string]any
251+
require.NoError(t, json.Unmarshal(ev.Data, &data))
252+
assert.Equal(t, "t-1", data["target_id"])
253+
254+
srv.sendToMonitor(t, map[string]any{
255+
"method": "Target.targetDestroyed",
256+
"params": map[string]any{"targetId": "t-1"},
257+
})
258+
ev2 := ec.waitFor(t, "target_destroyed", 2*time.Second)
259+
assert.Equal(t, events.CategoryPage, ev2.Category)
260+
}
261+
262+
func TestBindingAndTimeline(t *testing.T) {
263+
srv := newTestServer(t)
264+
defer srv.close()
265+
266+
_, ec, cleanup := startMonitor(t, srv, nil)
267+
defer cleanup()
268+
269+
t.Run("interaction_click", func(t *testing.T) {
270+
srv.sendToMonitor(t, map[string]any{
271+
"method": "Runtime.bindingCalled",
272+
"params": map[string]any{
273+
"name": "__kernelEvent",
274+
"payload": `{"type":"interaction_click","x":10,"y":20,"selector":"button","tag":"BUTTON","text":"OK"}`,
275+
},
276+
})
277+
ev := ec.waitFor(t, "interaction_click", 2*time.Second)
278+
assert.Equal(t, events.CategoryInteraction, ev.Category)
279+
assert.Equal(t, "Runtime.bindingCalled", ev.Source.Event)
280+
})
281+
282+
t.Run("scroll_settled", func(t *testing.T) {
283+
srv.sendToMonitor(t, map[string]any{
284+
"method": "Runtime.bindingCalled",
285+
"params": map[string]any{
286+
"name": "__kernelEvent",
287+
"payload": `{"type":"scroll_settled","from_x":0,"from_y":0,"to_x":0,"to_y":500,"target_selector":"body"}`,
288+
},
289+
})
290+
ev := ec.waitFor(t, "scroll_settled", 2*time.Second)
291+
assert.Equal(t, events.CategoryInteraction, ev.Category)
292+
var data map[string]any
293+
require.NoError(t, json.Unmarshal(ev.Data, &data))
294+
assert.Equal(t, float64(500), data["to_y"])
295+
})
296+
297+
t.Run("layout_shift", func(t *testing.T) {
298+
srv.sendToMonitor(t, map[string]any{
299+
"method": "PerformanceTimeline.timelineEventAdded",
300+
"params": map[string]any{
301+
"event": map[string]any{"type": "layout-shift"},
302+
},
303+
})
304+
ev := ec.waitFor(t, "layout_shift", 2*time.Second)
305+
assert.Equal(t, events.KindCDP, ev.Source.Kind)
306+
assert.Equal(t, "PerformanceTimeline.timelineEventAdded", ev.Source.Event)
307+
})
308+
309+
t.Run("unknown_binding_ignored", func(t *testing.T) {
310+
srv.sendToMonitor(t, map[string]any{
311+
"method": "Runtime.bindingCalled",
312+
"params": map[string]any{
313+
"name": "someOtherBinding",
314+
"payload": `{"type":"interaction_click"}`,
315+
},
316+
})
317+
ec.assertNone(t, "interaction_click", 100*time.Millisecond)
318+
})
319+
320+
t.Run("rate_limited_per_session", func(t *testing.T) {
321+
// Send two binding events back-to-back within the 50ms window.
322+
// Only the first should produce a published event.
323+
before := func() int {
324+
ec.mu.Lock()
325+
defer ec.mu.Unlock()
326+
count := 0
327+
for _, ev := range ec.events {
328+
if ev.Type == EventInteractionClick {
329+
count++
330+
}
331+
}
332+
return count
333+
}
334+
countBefore := before()
335+
336+
for range 3 {
337+
srv.sendToMonitor(t, map[string]any{
338+
"method": "Runtime.bindingCalled",
339+
"params": map[string]any{
340+
"name": "__kernelEvent",
341+
"payload": `{"type":"interaction_click","x":1,"y":1,"selector":"a","tag":"A","text":"x"}`,
342+
},
343+
})
344+
}
345+
346+
// Wait a bit for async delivery, then check only 1 new event was published.
347+
time.Sleep(200 * time.Millisecond)
348+
ec.mu.Lock()
349+
countAfter := 0
350+
for _, ev := range ec.events {
351+
if ev.Type == EventInteractionClick {
352+
countAfter++
353+
}
354+
}
355+
ec.mu.Unlock()
356+
assert.Equal(t, countBefore+1, countAfter, "rate limiter should have dropped the 2nd and 3rd events")
357+
})
358+
}

0 commit comments

Comments
 (0)