Skip to content

Commit 26f859f

Browse files
Ripwordsclaude
andcommitted
feat(mcp): emit navigation, coalesced inputs, console.error
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 2c3567c commit 26f859f

3 files changed

Lines changed: 146 additions & 2 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
{
2+
"events": [
3+
{
4+
"type": 4,
5+
"timestamp": 1700000000000,
6+
"data": { "href": "https://example.com/checkout?cart=abc-123", "width": 1440, "height": 900 }
7+
},
8+
{
9+
"type": 2,
10+
"timestamp": 1700000000010,
11+
"data": {
12+
"node": {
13+
"id": 1,
14+
"type": 2,
15+
"tagName": "html",
16+
"attributes": {},
17+
"childNodes": [
18+
{
19+
"id": 2,
20+
"type": 2,
21+
"tagName": "body",
22+
"attributes": {},
23+
"childNodes": [
24+
{
25+
"id": 10,
26+
"type": 2,
27+
"tagName": "input",
28+
"attributes": { "name": "email" },
29+
"childNodes": []
30+
},
31+
{
32+
"id": 11,
33+
"type": 2,
34+
"tagName": "input",
35+
"attributes": { "name": "cardNumber" },
36+
"childNodes": []
37+
},
38+
{
39+
"id": 12,
40+
"type": 2,
41+
"tagName": "button",
42+
"attributes": { "type": "submit" },
43+
"childNodes": [{ "id": 13, "type": 3, "textContent": "Pay $49.99" }]
44+
}
45+
]
46+
}
47+
]
48+
}
49+
}
50+
},
51+
{
52+
"type": 3,
53+
"timestamp": 1700000002100,
54+
"data": { "source": 5, "id": 10, "text": "alice@example.com" }
55+
},
56+
{ "type": 3, "timestamp": 1700000003400, "data": { "source": 5, "id": 11, "text": "•••" } },
57+
{ "type": 3, "timestamp": 1700000004900, "data": { "source": 2, "type": 2, "id": 12 } },
58+
{
59+
"type": 6,
60+
"timestamp": 1700000005800,
61+
"data": {
62+
"plugin": "rrweb/console@1",
63+
"payload": {
64+
"level": "error",
65+
"trace": [],
66+
"payload": ["TypeError: Cannot read properties of undefined (reading 'token')"]
67+
}
68+
}
69+
},
70+
{
71+
"type": 4,
72+
"timestamp": 1700000006000,
73+
"data": { "href": "https://example.com/checkout/error", "width": 1440, "height": 900 }
74+
}
75+
]
76+
}

apps/dashboard/server/mcp/replay-transcript.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,15 @@ describe("buildReplayTranscript", () => {
1616
expect(out.transcript).toContain("<unknown element")
1717
expect(out.eventCount).toBe(fixture.events.length)
1818
})
19+
20+
it("emits navigation, masked-stays-masked typing, click, and console.error", async () => {
21+
const fixture = await Bun.file(`${import.meta.dir}/__fixtures__/checkout-error.json`).json()
22+
const out = buildReplayTranscript(fixture.events, { verbosity: "summary" })
23+
expect(out.transcript).toContain("loaded /checkout?cart=abc-123")
24+
expect(out.transcript).toContain('typed "alice@example.com" into input[name="email"]')
25+
expect(out.transcript).toContain('typed "•••" into input[name="cardNumber"]')
26+
expect(out.transcript).toContain('click button[type="submit"]')
27+
expect(out.transcript).toContain("console.error")
28+
expect(out.transcript).toContain("loaded /checkout/error")
29+
})
1930
})

apps/dashboard/server/mcp/replay-transcript.ts

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,15 @@ function collectInnerText(id: number, dom: Map<number, NodeMeta>): string {
113113
return out
114114
}
115115

116+
function pathFromHref(href: string): string {
117+
try {
118+
const u = new URL(href)
119+
return `${u.pathname}${u.search}`
120+
} catch {
121+
return href
122+
}
123+
}
124+
116125
export function buildReplayTranscript(
117126
events: RrwebEvent[],
118127
opts: BuildReplayTranscriptOptions,
@@ -137,14 +146,62 @@ export function buildReplayTranscript(
137146
const endTs = events[events.length - 1]!.timestamp
138147
const lines: string[] = []
139148

149+
type InputBuffer = { id: number; value: string; firstTs: number }
150+
let pendingInput: InputBuffer | null = null
151+
152+
function flushInput(): void {
153+
if (!pendingInput) return
154+
const t = ((pendingInput.firstTs - startTs) / 1000).toFixed(1)
155+
lines.push(
156+
`[+${t}s] typed "${pendingInput.value}" into ${resolveSelector(pendingInput.id, dom)}`,
157+
)
158+
pendingInput = null
159+
}
160+
140161
for (const e of events) {
141-
if (e.type === 3 && (e.data as { source?: number }).source === 2) {
162+
const t = ((e.timestamp - startTs) / 1000).toFixed(1)
163+
// 4 = Meta (navigation / page load)
164+
if (e.type === 4) {
165+
flushInput()
166+
const href = (e.data as { href?: string }).href ?? ""
167+
lines.push(`[+${t}s] loaded ${pathFromHref(href)}`)
168+
continue
169+
}
170+
// 6 = Plugin (console capture lives here in rrweb)
171+
if (e.type === 6) {
172+
const payload = e.data as {
173+
plugin?: string
174+
payload?: { level?: string; payload?: unknown[] }
175+
}
176+
if (payload.plugin?.startsWith("rrweb/console") && payload.payload?.level === "error") {
177+
flushInput()
178+
const msg = (payload.payload.payload ?? []).map((p) => String(p)).join(" ")
179+
lines.push(`[+${t}s] console.error ${msg}`)
180+
}
181+
continue
182+
}
183+
if (e.type !== 3) continue
184+
const source = (e.data as { source?: number }).source
185+
// 2 = MouseInteraction
186+
if (source === 2) {
142187
const id = (e.data as { id?: number }).id
143188
if (typeof id !== "number") continue
144-
const t = ((e.timestamp - startTs) / 1000).toFixed(1)
189+
flushInput()
145190
lines.push(`[+${t}s] click ${resolveSelector(id, dom)}`)
191+
continue
192+
}
193+
// 5 = Input
194+
if (source === 5) {
195+
const id = (e.data as { id?: number }).id
196+
const text = (e.data as { text?: string }).text ?? ""
197+
if (typeof id !== "number") continue
198+
if (pendingInput && pendingInput.id !== id) flushInput()
199+
if (!pendingInput) pendingInput = { id, value: text, firstTs: e.timestamp }
200+
else pendingInput.value = text
201+
continue
146202
}
147203
}
204+
flushInput()
148205

149206
const transcript =
150207
`Replay (${((endTs - startTs) / 1000).toFixed(1)}s, ${events.length} events)\n\n` +

0 commit comments

Comments
 (0)