Skip to content

Commit 2c3567c

Browse files
Ripwordsclaude
andcommitted
feat(mcp): build DOM map from FullSnapshot for selector resolution
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 617cc92 commit 2c3567c

3 files changed

Lines changed: 113 additions & 5 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"events": [
3+
{
4+
"type": 4,
5+
"timestamp": 1700000000000,
6+
"data": { "href": "https://example.com/", "width": 1440, "height": 900 }
7+
},
8+
{
9+
"type": 3,
10+
"timestamp": 1700000001200,
11+
"data": { "source": 2, "type": 2, "id": 42, "x": 100, "y": 100 }
12+
}
13+
]
14+
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,11 @@ describe("buildReplayTranscript", () => {
99
expect(out.truncated).toBe(false)
1010
expect(out.transcript).toBe("Replay (0 events)")
1111
})
12+
13+
it("emits an unknown-element placeholder when no FullSnapshot is present", async () => {
14+
const fixture = await Bun.file(`${import.meta.dir}/__fixtures__/no-fullsnapshot.json`).json()
15+
const out = buildReplayTranscript(fixture.events, { verbosity: "summary" })
16+
expect(out.transcript).toContain("<unknown element")
17+
expect(out.eventCount).toBe(fixture.events.length)
18+
})
1219
})

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

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,75 @@ export interface ReplayTranscript {
4444
const DEFAULT_MAX_BYTES_SUMMARY = 4 * 1024
4545
const DEFAULT_MAX_BYTES_DETAILED = 16 * 1024
4646

47+
interface NodeMeta {
48+
tag: string
49+
attrs: Record<string, string>
50+
text: string | null
51+
parent: number | null
52+
}
53+
54+
function buildDomMapFromFullSnapshot(events: RrwebEvent[]): Map<number, NodeMeta> {
55+
const map = new Map<number, NodeMeta>()
56+
const fullSnapshot = events.find((e) => e.type === 2)
57+
if (!fullSnapshot) return map
58+
const root = (fullSnapshot.data as { node?: unknown }).node
59+
if (!root || typeof root !== "object") return map
60+
walkNode(root as Record<string, unknown>, null, map)
61+
return map
62+
}
63+
64+
function walkNode(
65+
node: Record<string, unknown>,
66+
parentId: number | null,
67+
out: Map<number, NodeMeta>,
68+
): void {
69+
const id = typeof node.id === "number" ? node.id : null
70+
const type = typeof node.type === "number" ? node.type : null
71+
if (id != null && type === 2) {
72+
// Element
73+
out.set(id, {
74+
tag: typeof node.tagName === "string" ? node.tagName.toLowerCase() : "unknown",
75+
attrs: (node.attributes as Record<string, string>) ?? {},
76+
text: null,
77+
parent: parentId,
78+
})
79+
} else if (id != null && type === 3 && typeof node.textContent === "string") {
80+
// Text
81+
out.set(id, { tag: "#text", attrs: {}, text: node.textContent, parent: parentId })
82+
}
83+
const children = Array.isArray(node.childNodes) ? (node.childNodes as unknown[]) : []
84+
for (const child of children) {
85+
if (child && typeof child === "object") {
86+
walkNode(child as Record<string, unknown>, id, out)
87+
}
88+
}
89+
}
90+
91+
function resolveSelector(id: number, dom: Map<number, NodeMeta>): string {
92+
const node = dom.get(id)
93+
if (!node) return `<unknown element ${id}>`
94+
if (node.tag === "#text") return `text "${(node.text ?? "").trim().slice(0, 40)}"`
95+
const name = node.attrs.name
96+
const ariaLabel = node.attrs["aria-label"]
97+
const type = node.attrs.type
98+
const className = node.attrs.class?.split(/\s+/).find(Boolean)
99+
const innerText = collectInnerText(id, dom).trim().slice(0, 40)
100+
if (name) return `${node.tag}[name="${name}"]`
101+
if (ariaLabel) return `${node.tag}[aria-label="${ariaLabel}"]`
102+
if (type && (node.tag === "button" || node.tag === "input")) return `${node.tag}[type="${type}"]`
103+
if (innerText && (node.tag === "button" || node.tag === "a")) return `${node.tag} "${innerText}"`
104+
if (className) return `${node.tag}.${className}`
105+
return `<${node.tag}>`
106+
}
107+
108+
function collectInnerText(id: number, dom: Map<number, NodeMeta>): string {
109+
let out = ""
110+
for (const [, meta] of dom) {
111+
if (meta.parent === id && meta.tag === "#text") out += meta.text ?? ""
112+
}
113+
return out
114+
}
115+
47116
export function buildReplayTranscript(
48117
events: RrwebEvent[],
49118
opts: BuildReplayTranscriptOptions,
@@ -56,17 +125,35 @@ export function buildReplayTranscript(
56125
truncated: false,
57126
}
58127
}
59-
// Real reduction lands in subsequent tasks. For now the empty-stream branch
60-
// is the only branch any test exercises. maxBytes is wired here so later
61-
// tasks can reference it without altering the function signature.
128+
// maxBytes is wired here so later tasks (Task 6) can reference it without
129+
// altering the function signature.
62130
const maxBytes =
63131
opts.maxBytes ??
64132
(opts.verbosity === "summary" ? DEFAULT_MAX_BYTES_SUMMARY : DEFAULT_MAX_BYTES_DETAILED)
65133
void maxBytes
134+
135+
const dom = buildDomMapFromFullSnapshot(events)
136+
const startTs = events[0]!.timestamp
137+
const endTs = events[events.length - 1]!.timestamp
138+
const lines: string[] = []
139+
140+
for (const e of events) {
141+
if (e.type === 3 && (e.data as { source?: number }).source === 2) {
142+
const id = (e.data as { id?: number }).id
143+
if (typeof id !== "number") continue
144+
const t = ((e.timestamp - startTs) / 1000).toFixed(1)
145+
lines.push(`[+${t}s] click ${resolveSelector(id, dom)}`)
146+
}
147+
}
148+
149+
const transcript =
150+
`Replay (${((endTs - startTs) / 1000).toFixed(1)}s, ${events.length} events)\n\n` +
151+
lines.join("\n")
152+
66153
return {
67-
transcript: `Replay (${events.length} events)`,
154+
transcript,
68155
eventCount: events.length,
69-
durationMs: events[events.length - 1]!.timestamp - events[0]!.timestamp,
156+
durationMs: endTs - startTs,
70157
truncated: false,
71158
}
72159
}

0 commit comments

Comments
 (0)