@brett_lamy/docstream 1.21.0 → 1.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/replay/ReplayDevtools.d.ts +18 -0
- package/dist/replay/ReplayDevtools.d.ts.map +1 -0
- package/dist/replay/ReplayDevtools.js +137 -0
- package/dist/replay/ReplayDevtools.js.map +1 -0
- package/dist/replay/ReplayPlayer.d.ts +10 -2
- package/dist/replay/ReplayPlayer.d.ts.map +1 -1
- package/dist/replay/ReplayPlayer.js +16 -6
- package/dist/replay/ReplayPlayer.js.map +1 -1
- package/dist/replay/demo/index.d.ts.map +1 -1
- package/dist/replay/demo/index.js +15 -4
- package/dist/replay/demo/index.js.map +1 -1
- package/dist/replay/devtools.d.ts +70 -0
- package/dist/replay/devtools.d.ts.map +1 -0
- package/dist/replay/devtools.js +201 -0
- package/dist/replay/devtools.js.map +1 -0
- package/dist/replay/index.d.ts +3 -0
- package/dist/replay/index.d.ts.map +1 -1
- package/dist/replay/index.js +1 -0
- package/dist/replay/index.js.map +1 -1
- package/dist/styles.css +585 -0
- package/package.json +1 -1
- package/src/replay/ReplayDevtools.tsx +352 -0
- package/src/replay/ReplayPlayer.tsx +36 -6
- package/src/replay/demo/index.ts +15 -4
- package/src/replay/devtools.ts +251 -0
- package/src/replay/index.ts +3 -0
- package/src/styles.css +585 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/* The player's DevTools: a Console and a Network button in the top bar, with what had happened by the playhead —
|
|
2
|
+
requests sent and, in red, how many had failed; console errors in red — and, behind them, a panel over the
|
|
3
|
+
page in the manner of Chrome's DevTools. The Console lists what had been logged, warnings and errors on their
|
|
4
|
+
tinted rows with their stack traces; the Network tab lists the requests that had been sent (a request still
|
|
5
|
+
waiting at the playhead shows as pending), and a request's row opens its headers, payload and response.
|
|
6
|
+
|
|
7
|
+
Everything is a function of the playhead: scrub back and the lists shrink to what DevTools showed then. A row's
|
|
8
|
+
time jumps the player there. Esc closes the panel. Plain React + plain CSS, like the player. */
|
|
9
|
+
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent } from 'react'
|
|
10
|
+
import { ChevronRight, CircleX, Info, Network, SquareTerminal, TriangleAlert, X } from 'lucide-react'
|
|
11
|
+
import type { ReplayConsoleEntry, ReplayDevtoolsState, ReplayRequest } from './devtools'
|
|
12
|
+
|
|
13
|
+
export type ReplayDevtoolsTab = 'console' | 'network'
|
|
14
|
+
|
|
15
|
+
const time = (ms: number) => {
|
|
16
|
+
const s = Math.max(0, ms) / 1000
|
|
17
|
+
return `${Math.floor(s / 60)}:${(s % 60).toFixed(1).padStart(4, '0')}`
|
|
18
|
+
}
|
|
19
|
+
const duration = (ms?: number) => (ms === undefined ? '—' : ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(2)} s`)
|
|
20
|
+
|
|
21
|
+
/** The two buttons for the top bar (or the controls row): icon, request count and, in red, what failed. */
|
|
22
|
+
export function DevtoolsButtons({ state, open, onOpen }: {
|
|
23
|
+
state: ReplayDevtoolsState; open: ReplayDevtoolsTab | null; onOpen: (tab: ReplayDevtoolsTab) => void
|
|
24
|
+
}) {
|
|
25
|
+
const { requests, failed, errors, warnings } = state.counts
|
|
26
|
+
return (
|
|
27
|
+
<div data-slot="replay-devtools-buttons">
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
data-slot="replay-devtools-button"
|
|
31
|
+
data-tab="console"
|
|
32
|
+
aria-pressed={open === 'console'}
|
|
33
|
+
aria-label={`Console: ${errors} ${errors === 1 ? 'error' : 'errors'}, ${warnings} ${warnings === 1 ? 'warning' : 'warnings'}`}
|
|
34
|
+
title="Console"
|
|
35
|
+
onClick={() => onOpen('console')}
|
|
36
|
+
>
|
|
37
|
+
<SquareTerminal size={14} strokeWidth={2} />
|
|
38
|
+
{errors ? <span data-part="failed">{errors}</span> : null}
|
|
39
|
+
{warnings ? <span data-part="warn">{warnings}</span> : null}
|
|
40
|
+
</button>
|
|
41
|
+
<button
|
|
42
|
+
type="button"
|
|
43
|
+
data-slot="replay-devtools-button"
|
|
44
|
+
data-tab="network"
|
|
45
|
+
aria-pressed={open === 'network'}
|
|
46
|
+
aria-label={`Network: ${requests} ${requests === 1 ? 'request' : 'requests'}, ${failed} failed`}
|
|
47
|
+
title="Network"
|
|
48
|
+
onClick={() => onOpen('network')}
|
|
49
|
+
>
|
|
50
|
+
<Network size={14} strokeWidth={2} />
|
|
51
|
+
<span data-part="count">{requests}</span>
|
|
52
|
+
{failed ? <span data-part="failed">{failed}</span> : null}
|
|
53
|
+
</button>
|
|
54
|
+
</div>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The panel over the page: Console and Network, cut at the playhead. */
|
|
59
|
+
export function DevtoolsPanel({ state, at, tab, onTab, onClose, onSeek }: {
|
|
60
|
+
state: ReplayDevtoolsState; at: number; tab: ReplayDevtoolsTab; onTab: (tab: ReplayDevtoolsTab) => void
|
|
61
|
+
onClose: () => void; onSeek: (ms: number) => void
|
|
62
|
+
}) {
|
|
63
|
+
const panel = useRef<HTMLDivElement>(null)
|
|
64
|
+
const [height, setHeight] = useState(58)
|
|
65
|
+
useEffect(() => { panel.current?.focus({ preventScroll: true }) }, [])
|
|
66
|
+
|
|
67
|
+
// Drag the top edge to resize, as a fraction of the stage.
|
|
68
|
+
const resize = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
69
|
+
const stage = panel.current?.parentElement
|
|
70
|
+
if (!stage) return
|
|
71
|
+
e.preventDefault()
|
|
72
|
+
const el = e.currentTarget
|
|
73
|
+
el.setPointerCapture(e.pointerId)
|
|
74
|
+
const box = stage.getBoundingClientRect()
|
|
75
|
+
const move = (ev: PointerEvent) => setHeight(Math.min(92, Math.max(22, ((box.bottom - ev.clientY) / box.height) * 100)))
|
|
76
|
+
const up = () => { el.removeEventListener('pointermove', move); el.removeEventListener('pointerup', up) }
|
|
77
|
+
el.addEventListener('pointermove', move)
|
|
78
|
+
el.addEventListener('pointerup', up)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const onKeyDown = (e: ReactKeyboardEvent) => {
|
|
82
|
+
if (e.key === 'Escape') { e.stopPropagation(); onClose() }
|
|
83
|
+
// The player's own keys (space, arrows, J/K/L) stay out of the panel.
|
|
84
|
+
else if (!(e.target as HTMLElement).closest('input')) e.stopPropagation()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const tabs: { id: ReplayDevtoolsTab; label: string }[] = [{ id: 'console', label: 'Console' }, { id: 'network', label: 'Network' }]
|
|
88
|
+
return (
|
|
89
|
+
<div
|
|
90
|
+
ref={panel}
|
|
91
|
+
data-slot="replay-devtools"
|
|
92
|
+
role="dialog"
|
|
93
|
+
aria-label="DevTools"
|
|
94
|
+
tabIndex={-1}
|
|
95
|
+
onKeyDown={onKeyDown}
|
|
96
|
+
style={{ height: `${height}%` }}
|
|
97
|
+
>
|
|
98
|
+
<div data-slot="replay-devtools-resize" aria-hidden="true" onPointerDown={resize} />
|
|
99
|
+
<div data-slot="replay-devtools-tabs">
|
|
100
|
+
<div data-slot="replay-devtools-tablist" role="tablist" aria-label="DevTools panels">
|
|
101
|
+
{tabs.map((t) => (
|
|
102
|
+
<button
|
|
103
|
+
key={t.id}
|
|
104
|
+
type="button"
|
|
105
|
+
role="tab"
|
|
106
|
+
aria-selected={tab === t.id}
|
|
107
|
+
onClick={() => onTab(t.id)}
|
|
108
|
+
onKeyDown={(e) => {
|
|
109
|
+
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { e.preventDefault(); onTab(t.id === 'console' ? 'network' : 'console') }
|
|
110
|
+
}}
|
|
111
|
+
tabIndex={tab === t.id ? 0 : -1}
|
|
112
|
+
>
|
|
113
|
+
{t.label}
|
|
114
|
+
{t.id === 'console' && state.counts.errors ? <span data-part="failed">{state.counts.errors}</span> : null}
|
|
115
|
+
{t.id === 'network' && state.counts.failed ? <span data-part="failed">{state.counts.failed}</span> : null}
|
|
116
|
+
</button>
|
|
117
|
+
))}
|
|
118
|
+
</div>
|
|
119
|
+
<span data-part="at">As of {time(at)}</span>
|
|
120
|
+
<button type="button" data-slot="replay-devtools-close" aria-label="Close DevTools" onClick={onClose}><X size={14} /></button>
|
|
121
|
+
</div>
|
|
122
|
+
{tab === 'console' ? <ConsolePane state={state} onSeek={onSeek} /> : <NetworkPane state={state} onSeek={onSeek} />}
|
|
123
|
+
</div>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* ── Console ─────────────────────────────────────────────────────────────────────── */
|
|
128
|
+
|
|
129
|
+
type LevelFilter = 'all' | 'error' | 'warn' | 'info'
|
|
130
|
+
const LEVEL_ICON = { error: CircleX, warn: TriangleAlert, info: Info } as const
|
|
131
|
+
|
|
132
|
+
function ConsolePane({ state, onSeek }: { state: ReplayDevtoolsState; onSeek: (ms: number) => void }) {
|
|
133
|
+
const [query, setQuery] = useState('')
|
|
134
|
+
const [level, setLevel] = useState<LevelFilter>('all')
|
|
135
|
+
const list = useRef<HTMLDivElement>(null)
|
|
136
|
+
const rows = useMemo(() => {
|
|
137
|
+
const q = query.trim().toLowerCase()
|
|
138
|
+
return state.console.filter((e) =>
|
|
139
|
+
(level === 'all' || (level === 'info' ? e.level === 'info' || e.level === 'log' || e.level === 'debug' : e.level === level)) &&
|
|
140
|
+
(!q || e.message.toLowerCase().includes(q)))
|
|
141
|
+
}, [state.console, query, level])
|
|
142
|
+
// Follow new messages as they're logged, unless you've scrolled up to read.
|
|
143
|
+
const pinned = useRef(true)
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
const el = list.current
|
|
146
|
+
if (el && pinned.current) el.scrollTop = el.scrollHeight
|
|
147
|
+
}, [rows.length])
|
|
148
|
+
return (
|
|
149
|
+
<div data-slot="replay-devtools-pane" data-pane="console">
|
|
150
|
+
<div data-slot="replay-devtools-toolbar">
|
|
151
|
+
<input type="search" aria-label="Filter console" placeholder="Filter" value={query} onChange={(e) => setQuery(e.currentTarget.value)} />
|
|
152
|
+
<select aria-label="Log levels" value={level} onChange={(e) => setLevel(e.currentTarget.value as LevelFilter)}>
|
|
153
|
+
<option value="all">All levels</option>
|
|
154
|
+
<option value="error">Errors</option>
|
|
155
|
+
<option value="warn">Warnings</option>
|
|
156
|
+
<option value="info">Info</option>
|
|
157
|
+
</select>
|
|
158
|
+
<span data-part="summary">
|
|
159
|
+
{state.counts.errors ? <span data-part="failed">{state.counts.errors} {state.counts.errors === 1 ? 'error' : 'errors'}</span> : null}
|
|
160
|
+
{state.counts.warnings ? <span data-part="warn">{state.counts.warnings} {state.counts.warnings === 1 ? 'warning' : 'warnings'}</span> : null}
|
|
161
|
+
</span>
|
|
162
|
+
</div>
|
|
163
|
+
<div
|
|
164
|
+
ref={list}
|
|
165
|
+
data-slot="replay-devtools-list"
|
|
166
|
+
role="log"
|
|
167
|
+
aria-label="Console messages"
|
|
168
|
+
onScroll={(e) => { const el = e.currentTarget; pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < 8 }}
|
|
169
|
+
>
|
|
170
|
+
{rows.map((entry) => <ConsoleRow key={entry.id} entry={entry} onSeek={onSeek} />)}
|
|
171
|
+
{!rows.length ? <p data-slot="replay-devtools-empty">{state.console.length ? 'No messages match the filter' : 'Nothing logged yet'}</p> : null}
|
|
172
|
+
{state.counts.laterMessages ? <p data-slot="replay-devtools-later">{state.counts.laterMessages} more later in the recording</p> : null}
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function ConsoleRow({ entry, onSeek }: { entry: ReplayConsoleEntry; onSeek: (ms: number) => void }) {
|
|
179
|
+
const Glyph = entry.level === 'error' || entry.level === 'warn' || entry.level === 'info' ? LEVEL_ICON[entry.level] : null
|
|
180
|
+
const body = (
|
|
181
|
+
<>
|
|
182
|
+
<span data-part="icon" aria-hidden="true">{Glyph ? <Glyph size={12} strokeWidth={2.2} /> : null}</span>
|
|
183
|
+
<span data-part="message">{entry.message || ' '}</span>
|
|
184
|
+
</>
|
|
185
|
+
)
|
|
186
|
+
return (
|
|
187
|
+
<div data-slot="replay-devtools-message" data-level={entry.level}>
|
|
188
|
+
{entry.trace?.length ? (
|
|
189
|
+
<details>
|
|
190
|
+
<summary>{body}</summary>
|
|
191
|
+
<ol data-part="trace">{entry.trace.map((frame, i) => <li key={i}>{frame}</li>)}</ol>
|
|
192
|
+
</details>
|
|
193
|
+
) : <div data-part="line">{body}</div>}
|
|
194
|
+
<button type="button" data-part="time" aria-label={`Go to ${time(entry.time)}`} onClick={() => onSeek(entry.time)}>{time(entry.time)}</button>
|
|
195
|
+
</div>
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/* ── Network ─────────────────────────────────────────────────────────────────────── */
|
|
200
|
+
|
|
201
|
+
type RequestFilter = 'all' | 'fetch' | 'failed'
|
|
202
|
+
type Row = ReplayRequest & { pending: boolean }
|
|
203
|
+
|
|
204
|
+
const statusText = (r: Row) => (r.pending ? '(pending)' : r.status === undefined ? (r.failed ? '(failed)' : '—') : r.status === 0 ? '(failed)' : String(r.status))
|
|
205
|
+
|
|
206
|
+
function NetworkPane({ state, onSeek }: { state: ReplayDevtoolsState; onSeek: (ms: number) => void }) {
|
|
207
|
+
const [query, setQuery] = useState('')
|
|
208
|
+
const [filter, setFilter] = useState<RequestFilter>('all')
|
|
209
|
+
const [selectedId, setSelectedId] = useState<string | null>(null)
|
|
210
|
+
const rows = useMemo(() => {
|
|
211
|
+
const q = query.trim().toLowerCase()
|
|
212
|
+
return state.network.filter((r) =>
|
|
213
|
+
(filter === 'all' || (filter === 'failed' ? r.failed && !r.pending : r.type === 'fetch' || r.type === 'xhr')) &&
|
|
214
|
+
(!q || r.url.toLowerCase().includes(q) || r.method.toLowerCase() === q))
|
|
215
|
+
}, [state.network, query, filter])
|
|
216
|
+
const selected = state.network.find((r) => r.id === selectedId) ?? null
|
|
217
|
+
// The waterfall spans from the first request to the last answer seen by the playhead.
|
|
218
|
+
const span = useMemo(() => {
|
|
219
|
+
const first = state.network[0]?.time ?? 0
|
|
220
|
+
let last = first + 1
|
|
221
|
+
for (const r of state.network) last = Math.max(last, r.time + (r.duration ?? 0))
|
|
222
|
+
return { first, length: last - first }
|
|
223
|
+
}, [state.network])
|
|
224
|
+
|
|
225
|
+
return (
|
|
226
|
+
<div data-slot="replay-devtools-pane" data-pane="network" data-detail={selected ? '' : undefined}>
|
|
227
|
+
<div data-slot="replay-devtools-toolbar">
|
|
228
|
+
<input type="search" aria-label="Filter requests" placeholder="Filter" value={query} onChange={(e) => setQuery(e.currentTarget.value)} />
|
|
229
|
+
<div data-slot="replay-devtools-chips" role="radiogroup" aria-label="Request types">
|
|
230
|
+
{(['all', 'fetch', 'failed'] as const).map((f) => (
|
|
231
|
+
<button key={f} type="button" role="radio" aria-checked={filter === f} onClick={() => setFilter(f)}>
|
|
232
|
+
{f === 'all' ? 'All' : f === 'fetch' ? 'Fetch/XHR' : 'Failed'}
|
|
233
|
+
</button>
|
|
234
|
+
))}
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
<div data-slot="replay-devtools-network">
|
|
238
|
+
<div data-slot="replay-devtools-table-wrap">
|
|
239
|
+
<table data-slot="replay-devtools-table">
|
|
240
|
+
<thead>
|
|
241
|
+
<tr>
|
|
242
|
+
<th scope="col">Name</th>
|
|
243
|
+
<th scope="col">Status</th>
|
|
244
|
+
{selected ? null : <><th scope="col">Method</th><th scope="col">Type</th><th scope="col">Time</th><th scope="col" data-col="waterfall">Waterfall</th></>}
|
|
245
|
+
</tr>
|
|
246
|
+
</thead>
|
|
247
|
+
<tbody>
|
|
248
|
+
{rows.map((r) => (
|
|
249
|
+
<tr
|
|
250
|
+
key={r.id}
|
|
251
|
+
data-failed={(r.failed && !r.pending) || undefined}
|
|
252
|
+
data-pending={r.pending || undefined}
|
|
253
|
+
aria-selected={r.id === selectedId}
|
|
254
|
+
tabIndex={0}
|
|
255
|
+
onClick={() => setSelectedId(r.id === selectedId ? null : r.id)}
|
|
256
|
+
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedId(r.id === selectedId ? null : r.id) } }}
|
|
257
|
+
>
|
|
258
|
+
<td title={r.url}><span data-part="name">{r.name}</span></td>
|
|
259
|
+
<td>{statusText(r)}</td>
|
|
260
|
+
{selected ? null : (
|
|
261
|
+
<>
|
|
262
|
+
<td>{r.method}</td>
|
|
263
|
+
<td>{r.type}</td>
|
|
264
|
+
<td>{r.pending ? 'Pending' : duration(r.duration)}</td>
|
|
265
|
+
<td data-col="waterfall">
|
|
266
|
+
<span
|
|
267
|
+
data-part="bar"
|
|
268
|
+
style={{
|
|
269
|
+
left: `${((r.time - span.first) / span.length) * 100}%`,
|
|
270
|
+
width: `max(2px, ${(((r.duration ?? 0) / span.length) * 100).toFixed(2)}%)`,
|
|
271
|
+
}}
|
|
272
|
+
/>
|
|
273
|
+
</td>
|
|
274
|
+
</>
|
|
275
|
+
)}
|
|
276
|
+
</tr>
|
|
277
|
+
))}
|
|
278
|
+
</tbody>
|
|
279
|
+
</table>
|
|
280
|
+
{!rows.length ? <p data-slot="replay-devtools-empty">{state.network.length ? 'No requests match the filter' : 'No requests yet'}</p> : null}
|
|
281
|
+
</div>
|
|
282
|
+
{selected ? <RequestDetail request={selected} onClose={() => setSelectedId(null)} onSeek={onSeek} /> : null}
|
|
283
|
+
</div>
|
|
284
|
+
<div data-slot="replay-devtools-status">
|
|
285
|
+
<span>{state.counts.requests} {state.counts.requests === 1 ? 'request' : 'requests'}</span>
|
|
286
|
+
{state.counts.failed ? <span data-part="failed">{state.counts.failed} failed</span> : null}
|
|
287
|
+
{state.counts.laterRequests ? <span>{state.counts.laterRequests} more later in the recording</span> : null}
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
type DetailTab = 'headers' | 'payload' | 'response'
|
|
294
|
+
|
|
295
|
+
function RequestDetail({ request, onClose, onSeek }: { request: Row; onClose: () => void; onSeek: (ms: number) => void }) {
|
|
296
|
+
const [tab, setTab] = useState<DetailTab>('headers')
|
|
297
|
+
const pretty = (body?: string) => {
|
|
298
|
+
if (body === undefined || body === '') return undefined
|
|
299
|
+
try { return JSON.stringify(JSON.parse(body), null, 2) } catch { return body }
|
|
300
|
+
}
|
|
301
|
+
const general: [string, string][] = [
|
|
302
|
+
['Request URL', request.url],
|
|
303
|
+
['Request Method', request.method],
|
|
304
|
+
['Status Code', request.pending ? 'Pending' : request.status !== undefined ? `${request.status}${request.statusText ? ` ${request.statusText}` : ''}` : request.failed ? 'Failed' : 'Unknown'],
|
|
305
|
+
...(request.error ? [['Error', request.error] as [string, string]] : []),
|
|
306
|
+
['Sent at', time(request.time)],
|
|
307
|
+
['Duration', duration(request.duration)],
|
|
308
|
+
]
|
|
309
|
+
return (
|
|
310
|
+
<div data-slot="replay-devtools-detail">
|
|
311
|
+
<div data-slot="replay-devtools-detail-tabs">
|
|
312
|
+
<button type="button" aria-label="Close request details" data-slot="replay-devtools-close" onClick={onClose}><X size={13} /></button>
|
|
313
|
+
<div data-slot="replay-devtools-tablist" role="tablist" aria-label={`${request.name} details`}>
|
|
314
|
+
{(['headers', 'payload', 'response'] as const).map((t) => (
|
|
315
|
+
<button key={t} type="button" role="tab" aria-selected={tab === t} onClick={() => setTab(t)}>
|
|
316
|
+
{t[0]!.toUpperCase() + t.slice(1)}
|
|
317
|
+
</button>
|
|
318
|
+
))}
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
<div data-slot="replay-devtools-detail-body">
|
|
322
|
+
{tab === 'headers' ? (
|
|
323
|
+
<>
|
|
324
|
+
<Section title="General" rows={general} />
|
|
325
|
+
{request.responseHeaders ? <Section title="Response Headers" rows={Object.entries(request.responseHeaders)} /> : null}
|
|
326
|
+
{request.requestHeaders ? <Section title="Request Headers" rows={Object.entries(request.requestHeaders)} /> : null}
|
|
327
|
+
<button type="button" data-slot="replay-devtools-jump" onClick={() => onSeek(request.time)}>Go to {time(request.time)} in the recording</button>
|
|
328
|
+
</>
|
|
329
|
+
) : (
|
|
330
|
+
<Body text={pretty(tab === 'payload' ? request.requestBody : request.responseBody)} empty={tab === 'payload' ? 'This request has no recorded payload' : request.pending ? 'Waiting for the response' : 'The recording has no response body for this request'} />
|
|
331
|
+
)}
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function Section({ title, rows }: { title: string; rows: [string, string][] }) {
|
|
338
|
+
return (
|
|
339
|
+
<details open data-slot="replay-devtools-section">
|
|
340
|
+
<summary><ChevronRight size={12} data-part="chevron" />{title}</summary>
|
|
341
|
+
<dl>
|
|
342
|
+
{rows.map(([k, v]) => (
|
|
343
|
+
<div key={k}><dt>{k}</dt><dd>{v}</dd></div>
|
|
344
|
+
))}
|
|
345
|
+
</dl>
|
|
346
|
+
</details>
|
|
347
|
+
)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function Body({ text, empty }: { text?: string; empty: string }) {
|
|
351
|
+
return text !== undefined ? <pre data-slot="replay-devtools-body">{text}</pre> : <p data-slot="replay-devtools-empty">{empty}</p>
|
|
352
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/* The player behind `ReplayPreview`: an rrweb session replay in a browser frame. The headless `ReplayStage`
|
|
2
2
|
plays the recording (rrweb loads lazily, on first render); this adds the chrome around it: a URL bar that
|
|
3
3
|
follows the recording's navigations, play/pause, a scrubber with the recording's moments on a rail above
|
|
4
|
-
it, a speed control, fullscreen,
|
|
4
|
+
it, a speed control, fullscreen, a drawn cursor whose clicks ripple, and DevTools: Console and Network buttons
|
|
5
|
+
in the top bar with what had been logged and requested by the playhead, which open a DevTools panel over the page. Until the player is ready a
|
|
5
6
|
poster (a page-shaped skeleton by default) holds the frame's exact size, so nothing jumps.
|
|
6
7
|
|
|
7
8
|
Plain React + plain CSS (`@layer docstream.replay` in styles.css, `[data-slot]` hooks). Keys (focus inside
|
|
@@ -15,6 +16,8 @@ import {
|
|
|
15
16
|
import { useReducedMotion } from 'framer-motion'
|
|
16
17
|
import { Lock, Maximize, Minimize, Pause, Play, RotateCcw } from 'lucide-react'
|
|
17
18
|
import { ReplayStage } from './ReplayStage'
|
|
19
|
+
import { DevtoolsButtons, DevtoolsPanel, type ReplayDevtoolsTab } from './ReplayDevtools'
|
|
20
|
+
import { getReplayDevtools, replayDevtoolsAt } from './devtools'
|
|
18
21
|
import { getReplayMarkers, getReplayMeta, getReplayPointerTrack, replayPointerAt } from './markers'
|
|
19
22
|
import type { ReplayMarker, ReplayMarkerKind, ReplayPointerTrack } from './markers'
|
|
20
23
|
import type { ReplayStageHandle } from './stage-types'
|
|
@@ -59,6 +62,12 @@ export interface ReplayPlayerOptions {
|
|
|
59
62
|
markers?: boolean | ReplayMarker[]
|
|
60
63
|
/** Which recorded marker kinds to show (default all). */
|
|
61
64
|
markerKinds?: ReplayMarkerKind[]
|
|
65
|
+
/** Console and Network buttons (in the top bar, or beside the controls without the browser chrome) that open a
|
|
66
|
+
* DevTools panel cut at the playhead. `true` (default) shows them when the recording has console messages or
|
|
67
|
+
* requests; `false` hides them. */
|
|
68
|
+
devtools?: boolean
|
|
69
|
+
/** Open the DevTools panel on this tab at mount. */
|
|
70
|
+
defaultDevtoolsTab?: ReplayDevtoolsTab
|
|
62
71
|
/** Draw the recorded pointer and click ripples (default true). */
|
|
63
72
|
cursor?: boolean
|
|
64
73
|
/** `width` (default): the page scales to the frame's width and sets its height. `contain`: fill the frame's
|
|
@@ -89,7 +98,7 @@ const MARKER_NOUN: Record<ReplayMarkerKind, string> = {
|
|
|
89
98
|
export function ReplayPlayer({
|
|
90
99
|
events, url, title = 'Session replay', chrome = 'browser', trafficLights = true, controls = true,
|
|
91
100
|
autoplay = false, loop = false, initialTime = 0, speeds = DEFAULT_PLAYER_SPEEDS, markers = true, markerKinds,
|
|
92
|
-
cursor = true, fit = 'width', poster, lazy = true, playerRef, onTimeUpdate, onPlayingChange, onMarkerPress,
|
|
101
|
+
cursor = true, devtools = true, defaultDevtoolsTab, fit = 'width', poster, lazy = true, playerRef, onTimeUpdate, onPlayingChange, onMarkerPress,
|
|
93
102
|
className, style,
|
|
94
103
|
}: ReplayPlayerOptions & { events: ReplayEvent[] }) {
|
|
95
104
|
const rootRef = useRef<HTMLDivElement>(null)
|
|
@@ -103,6 +112,10 @@ export function ReplayPlayer({
|
|
|
103
112
|
return markerKinds ? list.filter((m) => markerKinds.includes(m.kind)) : list
|
|
104
113
|
}, [markers, recorded, markerKinds])
|
|
105
114
|
|
|
115
|
+
const tools = useMemo(() => getReplayDevtools(events), [events])
|
|
116
|
+
const showTools = devtools && (tools.console.length > 0 || tools.network.length > 0)
|
|
117
|
+
const [panel, setPanel] = useState<ReplayDevtoolsTab | null>(defaultDevtoolsTab ?? null)
|
|
118
|
+
|
|
106
119
|
const [visible, setVisible] = useState(!lazy)
|
|
107
120
|
const [ready, setReady] = useState(false)
|
|
108
121
|
const [posterGone, setPosterGone] = useState(false)
|
|
@@ -195,6 +208,10 @@ export function ReplayPlayer({
|
|
|
195
208
|
return current
|
|
196
209
|
}, [url, meta.href, recorded, time])
|
|
197
210
|
|
|
211
|
+
const toolsState = useMemo(() => replayDevtoolsAt(tools, time), [tools, time])
|
|
212
|
+
const openTools = (tab: ReplayDevtoolsTab) => setPanel((p) => (p === tab ? null : tab))
|
|
213
|
+
const toolButtons = showTools ? <DevtoolsButtons state={toolsState} open={panel} onOpen={openTools} /> : null
|
|
214
|
+
|
|
198
215
|
const w = meta.width || 1280
|
|
199
216
|
const h = meta.height || 800
|
|
200
217
|
const contain = fit === 'contain' || fullscreen
|
|
@@ -221,7 +238,7 @@ export function ReplayPlayer({
|
|
|
221
238
|
}}
|
|
222
239
|
style={style}
|
|
223
240
|
>
|
|
224
|
-
{chrome === 'browser' ? <BrowserBar href={href} trafficLights={trafficLights} loading={!ready && meta.playable} /> : null}
|
|
241
|
+
{chrome === 'browser' ? <BrowserBar href={href} trafficLights={trafficLights} loading={!ready && meta.playable} actions={toolButtons} /> : null}
|
|
225
242
|
|
|
226
243
|
<div data-slot="replay-preview-stage" style={{ '--replay-ar': `${w} / ${h}` } as CSSProperties}>
|
|
227
244
|
{visible && meta.playable ? (
|
|
@@ -249,8 +266,19 @@ export function ReplayPlayer({
|
|
|
249
266
|
</div>
|
|
250
267
|
) : null}
|
|
251
268
|
|
|
269
|
+
{showTools && panel ? (
|
|
270
|
+
<DevtoolsPanel
|
|
271
|
+
state={toolsState}
|
|
272
|
+
at={time}
|
|
273
|
+
tab={panel}
|
|
274
|
+
onTab={setPanel}
|
|
275
|
+
onClose={() => { setPanel(null); rootRef.current?.focus({ preventScroll: true }) }}
|
|
276
|
+
onSeek={(ms) => handle.seek(ms)}
|
|
277
|
+
/>
|
|
278
|
+
) : null}
|
|
279
|
+
|
|
252
280
|
{controls ? (
|
|
253
|
-
<div data-slot="replay-preview-center" data-show={(ready && !playing) || undefined}>
|
|
281
|
+
<div data-slot="replay-preview-center" data-show={(ready && !playing && !(showTools && panel)) || undefined}>
|
|
254
282
|
<button
|
|
255
283
|
type="button"
|
|
256
284
|
tabIndex={-1}
|
|
@@ -280,6 +308,7 @@ export function ReplayPlayer({
|
|
|
280
308
|
onSeek={(ms) => handle.seek(ms)}
|
|
281
309
|
onMarker={jumpTo}
|
|
282
310
|
/>
|
|
311
|
+
{chrome !== 'browser' ? toolButtons : null}
|
|
283
312
|
<button type="button" data-slot="replay-preview-speed" aria-label={`Playback speed ${speed}×`} onClick={() => cycleSpeed(1)}>
|
|
284
313
|
{`${speed}×`}
|
|
285
314
|
</button>
|
|
@@ -301,7 +330,7 @@ export function splitReplayAddress(href: string): { host: string; path: string }
|
|
|
301
330
|
return cut < 0 ? { host: shown, path: '' } : { host: shown.slice(0, cut), path: shown.slice(cut) }
|
|
302
331
|
}
|
|
303
332
|
|
|
304
|
-
export function BrowserBar({ href, trafficLights, loading }: { href: string; trafficLights: boolean; loading: boolean }) {
|
|
333
|
+
export function BrowserBar({ href, trafficLights, loading, actions }: { href: string; trafficLights: boolean; loading: boolean; actions?: ReactNode }) {
|
|
305
334
|
const { host, path } = splitReplayAddress(href)
|
|
306
335
|
// Once the recording is past the site root, where it is (path, query, hash) is what matters:
|
|
307
336
|
// it leads and keeps its room, and the host dims and gives way first.
|
|
@@ -322,7 +351,8 @@ export function BrowserBar({ href, trafficLights, loading }: { href: string; tra
|
|
|
322
351
|
{path ? <span data-part="path">{path}</span> : null}
|
|
323
352
|
</span>
|
|
324
353
|
</div>
|
|
325
|
-
{
|
|
354
|
+
{actions ? <div data-slot="replay-preview-actions">{actions}</div>
|
|
355
|
+
: trafficLights ? <span aria-hidden="true" data-slot="replay-preview-lights-spacer" /> : null}
|
|
326
356
|
</div>
|
|
327
357
|
)
|
|
328
358
|
}
|
package/src/replay/demo/index.ts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import type { ReplayEvent } from '../types';
|
|
2
2
|
|
|
3
|
-
/* A small synthetic rrweb recording (
|
|
3
|
+
/* A small synthetic rrweb recording (67 events, ~9 s, 1280×800) for ReplayPreview's stories, docs and the loop-qa
|
|
4
4
|
block: a shopper on a mock "Northwind" storefront adds two products, opens the cart, types an email and a
|
|
5
5
|
promo code, and "Place order" fails — a custom `network` event (POST /api/checkout 500) and an `error` event
|
|
6
|
-
mark the moment.
|
|
6
|
+
mark the moment. Console messages (rrweb/console@1) and the page's other requests (rrweb/network@1, with
|
|
7
|
+
headers and bodies) were added by hand so the player's DevTools have something to show; none of them fail, so the
|
|
8
|
+
timeline markers are unchanged. Recorded with rrweb 2.0.0-alpha.20 against a local page (no external assets), timestamps
|
|
7
9
|
rebased to 2026-09-24 14:32:08 UTC so every render is identical. */
|
|
8
10
|
export const replayDemoEvents: ReplayEvent[] = [
|
|
9
11
|
{"type":4,"data":{"href":"https://shop.northwind.test/collections/trail","width":1280,"height":800},"timestamp":1790260328000},
|
|
10
12
|
{"type":2,"data":{"node":{"type":0,"childNodes":[{"type":1,"name":"html","publicId":"","systemId":"","id":2},{"type":2,"tagName":"html","attributes":{},"childNodes":[{"type":2,"tagName":"head","attributes":{},"childNodes":[{"type":2,"tagName":"meta","attributes":{"charset":"utf-8"},"childNodes":[],"id":5},{"type":2,"tagName":"title","attributes":{},"childNodes":[{"type":3,"textContent":"Northwind Outfitters","id":7}],"id":6},{"type":3,"textContent":"\n","id":8},{"type":2,"tagName":"style","attributes":{"_cssText":"* { box-sizing: border-box; }body { margin: 0px; font: 14px / 1.45 -apple-system, BlinkMacSystemFont, \"Segoe UI\", Inter, sans-serif; color: rgb(28, 25, 23); background: rgb(250, 250, 249); }header { display: flex; align-items: center; gap: 28px; height: 64px; padding: 0px 40px; background: rgb(255, 255, 255); border-bottom: 1px solid rgb(231, 229, 228); }.logo { font-weight: 700; font-size: 17px; letter-spacing: -0.02em; }.logo b { color: rgb(13, 148, 136); }nav a { color: rgb(87, 83, 78); text-decoration: none; margin-right: 20px; }.sp { flex: 1 1 0%; }.cart { position: relative; border: 1px solid rgb(214, 211, 209); background: rgb(255, 255, 255); border-radius: 999px; padding: 7px 14px; font: inherit; cursor: pointer; }.badge { position: absolute; top: -6px; right: -6px; min-width: 18px; height: 18px; border-radius: 9px; background: rgb(13, 148, 136); color: rgb(255, 255, 255); font-size: 11px; display: grid; place-items: center; padding: 0px 5px; }.hero { padding: 36px 40px 12px; }.hero h1 { margin: 0px; font-size: 30px; letter-spacing: -0.03em; }.hero p { margin: 6px 0px 0px; color: rgb(120, 113, 108); }.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; padding: 20px 40px; }.card { background: rgb(255, 255, 255); border: 1px solid rgb(231, 229, 228); border-radius: 14px; overflow: hidden; transition: box-shadow 0.2s; }.card:hover { box-shadow: rgba(0, 0, 0, 0.08) 0px 8px 24px; }.img { height: 150px; }.i1 { background: linear-gradient(135deg, rgb(153, 246, 228), rgb(13, 148, 136)); }.i2 { background: linear-gradient(135deg, rgb(253, 230, 138), rgb(217, 119, 6)); }.i3 { background: linear-gradient(135deg, rgb(199, 210, 254), rgb(79, 70, 229)); }.i4 { background: linear-gradient(135deg, rgb(254, 205, 211), rgb(225, 29, 72)); }.body { padding: 12px 14px 14px; }.name { font-weight: 600; }.price { color: rgb(120, 113, 108); margin: 2px 0px 10px; }.add { width: 100%; border: 0px; border-radius: 9px; background: rgb(28, 25, 23); color: rgb(255, 255, 255); padding: 9px; font-style: inherit; font-variant: inherit; font-stretch: inherit; font-size: inherit; line-height: inherit; font-family: inherit; font-optical-sizing: inherit; font-size-adjust: inherit; font-kerning: inherit; font-feature-settings: inherit; font-variation-settings: inherit; font-language-override: inherit; font-weight: 600; cursor: pointer; }.drawer { position: fixed; top: 0px; right: 0px; bottom: 0px; width: 380px; background: rgb(255, 255, 255); border-left: 1px solid rgb(231, 229, 228); box-shadow: rgba(0, 0, 0, 0.12) -12px 0px 40px; padding: 24px; transform: translateX(100%); transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1); }.drawer.open { transform: none; }.drawer h2 { margin: 0px 0px 16px; font-size: 18px; }.line { display: flex; justify-content: space-between; padding: 10px 0px; border-bottom: 1px solid rgb(245, 245, 244); }label { display: block; font-size: 12px; color: rgb(120, 113, 108); margin: 14px 0px 4px; }input { width: 100%; border: 1px solid rgb(214, 211, 209); border-radius: 9px; padding: 9px 11px; font: inherit; }.pay { margin-top: 18px; width: 100%; border: 0px; border-radius: 10px; background: rgb(13, 148, 136); color: rgb(255, 255, 255); padding: 11px; font-style: inherit; font-variant: inherit; font-stretch: inherit; font-size: inherit; line-height: inherit; font-family: inherit; font-optical-sizing: inherit; font-size-adjust: inherit; font-kerning: inherit; font-feature-settings: inherit; font-variation-settings: inherit; font-language-override: inherit; font-weight: 600; cursor: pointer; }.err { display: none; margin-top: 14px; border-radius: 10px; background: rgb(254, 242, 242); border: 1px solid rgb(254, 202, 202); color: rgb(185, 28, 28); padding: 10px 12px; font-size: 13px; }.err.show { display: block; }"},"childNodes":[{"type":3,"textContent":"","id":10}],"id":9},{"type":2,"tagName":"script","attributes":{"type":"text/javascript"},"childNodes":[{"type":3,"textContent":"SCRIPT_PLACEHOLDER","id":12}],"id":11}],"id":4},{"type":2,"tagName":"body","attributes":{},"childNodes":[{"type":3,"textContent":"\n","id":14},{"type":2,"tagName":"header","attributes":{},"childNodes":[{"type":2,"tagName":"div","attributes":{"class":"logo"},"childNodes":[{"type":3,"textContent":"north","id":17},{"type":2,"tagName":"b","attributes":{},"childNodes":[{"type":3,"textContent":"wind","id":19}],"id":18}],"id":16},{"type":2,"tagName":"nav","attributes":{},"childNodes":[{"type":2,"tagName":"a","attributes":{"href":"file:///tmp/rr/page.html#"},"childNodes":[{"type":3,"textContent":"New","id":22}],"id":21},{"type":2,"tagName":"a","attributes":{"href":"file:///tmp/rr/page.html#"},"childNodes":[{"type":3,"textContent":"Trail","id":24}],"id":23},{"type":2,"tagName":"a","attributes":{"href":"file:///tmp/rr/page.html#"},"childNodes":[{"type":3,"textContent":"Road","id":26}],"id":25},{"type":2,"tagName":"a","attributes":{"href":"file:///tmp/rr/page.html#"},"childNodes":[{"type":3,"textContent":"Sale","id":28}],"id":27}],"id":20},{"type":2,"tagName":"div","attributes":{"class":"sp"},"childNodes":[],"id":29},{"type":2,"tagName":"button","attributes":{"class":"cart","id":"cart"},"childNodes":[{"type":3,"textContent":"Cart ","id":31},{"type":2,"tagName":"span","attributes":{"class":"badge","id":"count"},"childNodes":[{"type":3,"textContent":"0","id":33}],"id":32}],"id":30}],"id":15},{"type":3,"textContent":"\n","id":34},{"type":2,"tagName":"section","attributes":{"class":"hero"},"childNodes":[{"type":2,"tagName":"h1","attributes":{},"childNodes":[{"type":3,"textContent":"Fall trail collection","id":37}],"id":36},{"type":2,"tagName":"p","attributes":{},"childNodes":[{"type":3,"textContent":"Built for mud, rain and long miles.","id":39}],"id":38}],"id":35},{"type":3,"textContent":"\n","id":40},{"type":2,"tagName":"section","attributes":{"class":"grid"},"childNodes":[{"type":3,"textContent":"\n","id":42},{"type":2,"tagName":"div","attributes":{"class":"card"},"childNodes":[{"type":2,"tagName":"div","attributes":{"class":"img i1"},"childNodes":[],"id":44},{"type":2,"tagName":"div","attributes":{"class":"body"},"childNodes":[{"type":2,"tagName":"div","attributes":{"class":"name"},"childNodes":[{"type":3,"textContent":"Trail Runner 2","id":47}],"id":46},{"type":2,"tagName":"div","attributes":{"class":"price"},"childNodes":[{"type":3,"textContent":"$148","id":49}],"id":48},{"type":2,"tagName":"button","attributes":{"class":"add","data-p":"Trail Runner 2","data-c":"148"},"childNodes":[{"type":3,"textContent":"Add to cart","id":51}],"id":50}],"id":45}],"id":43},{"type":3,"textContent":"\n","id":52},{"type":2,"tagName":"div","attributes":{"class":"card"},"childNodes":[{"type":2,"tagName":"div","attributes":{"class":"img i2"},"childNodes":[],"id":54},{"type":2,"tagName":"div","attributes":{"class":"body"},"childNodes":[{"type":2,"tagName":"div","attributes":{"class":"name"},"childNodes":[{"type":3,"textContent":"Summit Shell","id":57}],"id":56},{"type":2,"tagName":"div","attributes":{"class":"price"},"childNodes":[{"type":3,"textContent":"$229","id":59}],"id":58},{"type":2,"tagName":"button","attributes":{"class":"add","data-p":"Summit Shell","data-c":"229"},"childNodes":[{"type":3,"textContent":"Add to cart","id":61}],"id":60}],"id":55}],"id":53},{"type":3,"textContent":"\n","id":62},{"type":2,"tagName":"div","attributes":{"class":"card"},"childNodes":[{"type":2,"tagName":"div","attributes":{"class":"img i3"},"childNodes":[],"id":64},{"type":2,"tagName":"div","attributes":{"class":"body"},"childNodes":[{"type":2,"tagName":"div","attributes":{"class":"name"},"childNodes":[{"type":3,"textContent":"Ridge Pack 28L","id":67}],"id":66},{"type":2,"tagName":"div","attributes":{"class":"price"},"childNodes":[{"type":3,"textContent":"$119","id":69}],"id":68},{"type":2,"tagName":"button","attributes":{"class":"add","data-p":"Ridge Pack 28L","data-c":"119"},"childNodes":[{"type":3,"textContent":"Add to cart","id":71}],"id":70}],"id":65}],"id":63},{"type":3,"textContent":"\n","id":72},{"type":2,"tagName":"div","attributes":{"class":"card"},"childNodes":[{"type":2,"tagName":"div","attributes":{"class":"img i4"},"childNodes":[],"id":74},{"type":2,"tagName":"div","attributes":{"class":"body"},"childNodes":[{"type":2,"tagName":"div","attributes":{"class":"name"},"childNodes":[{"type":3,"textContent":"Merino Crew","id":77}],"id":76},{"type":2,"tagName":"div","attributes":{"class":"price"},"childNodes":[{"type":3,"textContent":"$68","id":79}],"id":78},{"type":2,"tagName":"button","attributes":{"class":"add","data-p":"Merino Crew","data-c":"68"},"childNodes":[{"type":3,"textContent":"Add to cart","id":81}],"id":80}],"id":75}],"id":73},{"type":3,"textContent":"\n","id":82}],"id":41},{"type":3,"textContent":"\n","id":83},{"type":2,"tagName":"aside","attributes":{"class":"drawer","id":"drawer"},"childNodes":[{"type":2,"tagName":"h2","attributes":{},"childNodes":[{"type":3,"textContent":"Your cart","id":86}],"id":85},{"type":2,"tagName":"div","attributes":{"id":"lines"},"childNodes":[],"id":87},{"type":2,"tagName":"div","attributes":{"class":"line"},"childNodes":[{"type":2,"tagName":"b","attributes":{},"childNodes":[{"type":3,"textContent":"Total","id":90}],"id":89},{"type":2,"tagName":"b","attributes":{"id":"total"},"childNodes":[{"type":3,"textContent":"$0","id":92}],"id":91}],"id":88},{"type":3,"textContent":"\n","id":93},{"type":2,"tagName":"label","attributes":{},"childNodes":[{"type":3,"textContent":"Email","id":95}],"id":94},{"type":2,"tagName":"input","attributes":{"id":"email","placeholder":"you@example.com"},"childNodes":[],"id":96},{"type":2,"tagName":"label","attributes":{},"childNodes":[{"type":3,"textContent":"Promo code","id":98}],"id":97},{"type":2,"tagName":"input","attributes":{"id":"promo","placeholder":"Optional"},"childNodes":[],"id":99},{"type":3,"textContent":"\n","id":100},{"type":2,"tagName":"button","attributes":{"class":"pay","id":"pay"},"childNodes":[{"type":3,"textContent":"Place order","id":102}],"id":101},{"type":2,"tagName":"div","attributes":{"class":"err","id":"err"},"childNodes":[{"type":3,"textContent":"Something went wrong. Please try again.","id":104}],"id":103}],"id":84},{"type":3,"textContent":"\n","id":105},{"type":2,"tagName":"script","attributes":{},"childNodes":[{"type":3,"textContent":"SCRIPT_PLACEHOLDER","id":107}],"id":106},{"type":3,"textContent":"\n","id":108}],"id":13}],"id":3}],"id":1},"initialOffset":{"left":0,"top":0}},"timestamp":1790260328004},
|
|
11
13
|
{"type":3,"data":{"source":1,"positions":[{"x":640,"y":300,"id":41,"timeOffset":0}]},"timestamp":1790260328010},
|
|
14
|
+
{"type":6,"data":{"plugin":"rrweb/network@1","payload":{"requests":[{"url":"https://shop.northwind.test/collections/trail","method":"GET","initiatorType":"navigation","status":200,"startTime":0,"endTime":186,"requestHeaders":{"accept":"*/*"},"responseHeaders":{"content-type":"text/html; charset=utf-8","cache-control":"max-age=31536000"}},{"url":"https://shop.northwind.test/assets/app-4f2a.js","method":"GET","initiatorType":"script","status":200,"startTime":120,"endTime":214,"requestHeaders":{"accept":"*/*"},"responseHeaders":{"content-type":"text/javascript","cache-control":"max-age=31536000"}},{"url":"https://shop.northwind.test/assets/app-4f2a.css","method":"GET","initiatorType":"link","status":200,"startTime":122,"endTime":183,"requestHeaders":{"accept":"*/*"},"responseHeaders":{"content-type":"text/css","cache-control":"max-age=31536000"}}]}},"timestamp":1790260328190},
|
|
15
|
+
{"type":6,"data":{"plugin":"rrweb/console@1","payload":{"level":"log","trace":[],"payload":["\"[northwind] storefront ready\"","\"build 4.12.0\""]}},"timestamp":1790260328240},
|
|
16
|
+
{"type":6,"data":{"plugin":"rrweb/network@1","payload":{"requests":[{"url":"https://shop.northwind.test/api/products?collection=trail","method":"GET","initiatorType":"fetch","status":200,"startTime":300,"endTime":442,"requestHeaders":{"accept":"application/json"},"responseHeaders":{"content-type":"application/json; charset=utf-8","cache-control":"no-store"},"responseBody":"{\"products\": [{\"sku\": \"TR2\", \"name\": \"Trail Runner 2\", \"price\": 148}, {\"sku\": \"SS1\", \"name\": \"Summit Shell\", \"price\": 229}, {\"sku\": \"RP28\", \"name\": \"Ridge Pack 28L\", \"price\": 119}, {\"sku\": \"MC1\", \"name\": \"Merino Crew\", \"price\": 68}]}"}]}},"timestamp":1790260328420},
|
|
12
17
|
{"type":3,"data":{"source":1,"positions":[{"x":608,"y":302,"id":54,"timeOffset":-99},{"x":357,"y":319,"id":54,"timeOffset":-35}]},"timestamp":1790260328511},
|
|
13
18
|
{"type":3,"data":{"source":1,"positions":[{"x":223,"y":330,"id":44,"timeOffset":0}]},"timestamp":1790260329021},
|
|
14
19
|
{"type":3,"data":{"source":1,"positions":[{"x":410,"y":330,"id":54,"timeOffset":-438},{"x":454,"y":341,"id":54,"timeOffset":-109},{"x":247,"y":433,"id":50,"timeOffset":-46}]},"timestamp":1790260329522},
|
|
@@ -17,6 +22,8 @@ export const replayDemoEvents: ReplayEvent[] = [
|
|
|
17
22
|
{"type":3,"data":{"source":2,"type":0,"id":50,"x":182.5,"y":436.5299987792969},"timestamp":1790260329770},
|
|
18
23
|
{"type":3,"data":{"source":2,"type":2,"id":50,"x":182,"y":436,"pointerType":0},"timestamp":1790260329770},
|
|
19
24
|
{"type":3,"data":{"source":0,"texts":[],"attributes":[],"removes":[{"parentId":32,"id":33},{"parentId":91,"id":92}],"adds":[{"parentId":32,"nextId":null,"node":{"type":3,"textContent":"1","id":109}},{"parentId":87,"nextId":null,"node":{"type":2,"tagName":"div","attributes":{"class":"line"},"childNodes":[],"id":110}},{"parentId":110,"nextId":null,"node":{"type":2,"tagName":"span","attributes":{},"childNodes":[],"id":111}},{"parentId":111,"nextId":null,"node":{"type":3,"textContent":"$148","id":112}},{"parentId":91,"nextId":null,"node":{"type":3,"textContent":"$148","id":113}},{"parentId":110,"nextId":111,"node":{"type":2,"tagName":"span","attributes":{},"childNodes":[],"id":114}},{"parentId":114,"nextId":null,"node":{"type":3,"textContent":"Trail Runner 2","id":115}}]},"timestamp":1790260329771},
|
|
25
|
+
{"type":6,"data":{"plugin":"rrweb/network@1","payload":{"requests":[{"url":"https://shop.northwind.test/api/cart","method":"POST","initiatorType":"fetch","status":200,"startTime":2000,"endTime":2168,"requestHeaders":{"accept":"application/json","content-type":"application/json"},"responseHeaders":{"content-type":"application/json; charset=utf-8","cache-control":"no-store"},"requestBody":"{\"sku\": \"TR2\", \"qty\": 1}","responseBody":"{\"items\": 1, \"total\": 148}"}]}},"timestamp":1790260329939},
|
|
26
|
+
{"type":6,"data":{"plugin":"rrweb/console@1","payload":{"level":"info","trace":[],"payload":["\"cart: added\"","\"Trail Runner 2\""]}},"timestamp":1790260329941},
|
|
20
27
|
{"type":3,"data":{"source":1,"positions":[{"x":182,"y":436,"id":50,"timeOffset":-253}]},"timestamp":1790260330023},
|
|
21
28
|
{"type":5,"data":{"tag":"checkpoint","payload":{"label":"Added Trail Runner 2"}},"timestamp":1790260330279},
|
|
22
29
|
{"type":3,"data":{"source":1,"positions":[{"x":223,"y":438,"id":50,"timeOffset":-243},{"x":512,"y":454,"id":60,"timeOffset":-181}]},"timestamp":1790260330524},
|
|
@@ -26,6 +33,8 @@ export const replayDemoEvents: ReplayEvent[] = [
|
|
|
26
33
|
{"type":3,"data":{"source":2,"type":0,"id":70,"x":792.5,"y":436.5299987792969},"timestamp":1790260330663},
|
|
27
34
|
{"type":3,"data":{"source":2,"type":2,"id":70,"x":792,"y":436,"pointerType":0},"timestamp":1790260330663},
|
|
28
35
|
{"type":3,"data":{"source":0,"texts":[],"attributes":[],"removes":[{"parentId":32,"id":109},{"parentId":87,"id":110},{"parentId":91,"id":113}],"adds":[{"parentId":32,"nextId":null,"node":{"type":3,"textContent":"2","id":116}},{"parentId":87,"nextId":null,"node":{"type":2,"tagName":"div","attributes":{"class":"line"},"childNodes":[],"id":117}},{"parentId":117,"nextId":null,"node":{"type":2,"tagName":"span","attributes":{},"childNodes":[],"id":118}},{"parentId":118,"nextId":null,"node":{"type":3,"textContent":"$119","id":119}},{"parentId":91,"nextId":null,"node":{"type":3,"textContent":"$267","id":120}},{"parentId":87,"nextId":117,"node":{"type":2,"tagName":"div","attributes":{"class":"line"},"childNodes":[],"id":121}},{"parentId":121,"nextId":null,"node":{"type":2,"tagName":"span","attributes":{},"childNodes":[],"id":122}},{"parentId":121,"nextId":122,"node":{"type":2,"tagName":"span","attributes":{},"childNodes":[],"id":123}},{"parentId":123,"nextId":null,"node":{"type":3,"textContent":"Trail Runner 2","id":124}},{"parentId":122,"nextId":null,"node":{"type":3,"textContent":"$148","id":125}},{"parentId":117,"nextId":118,"node":{"type":2,"tagName":"span","attributes":{},"childNodes":[],"id":126}},{"parentId":126,"nextId":null,"node":{"type":3,"textContent":"Ridge Pack 28L","id":127}}]},"timestamp":1790260330664},
|
|
36
|
+
{"type":6,"data":{"plugin":"rrweb/network@1","payload":{"requests":[{"url":"https://shop.northwind.test/api/cart","method":"POST","initiatorType":"fetch","status":200,"startTime":2900,"endTime":3052,"requestHeaders":{"accept":"application/json","content-type":"application/json"},"responseHeaders":{"content-type":"application/json; charset=utf-8","cache-control":"no-store"},"requestBody":"{\"sku\": \"RP28\", \"qty\": 1}","responseBody":"{\"items\": 2, \"total\": 267}"}]}},"timestamp":1790260330816},
|
|
37
|
+
{"type":6,"data":{"plugin":"rrweb/console@1","payload":{"level":"info","trace":[],"payload":["\"cart: added\"","\"Ridge Pack 28L\""]}},"timestamp":1790260330818},
|
|
29
38
|
{"type":3,"data":{"source":1,"positions":[{"x":792,"y":436,"id":70,"timeOffset":-363}]},"timestamp":1790260331025},
|
|
30
39
|
{"type":3,"data":{"source":1,"positions":[{"x":815,"y":414,"id":65,"timeOffset":-249},{"x":1003,"y":234,"id":74,"timeOffset":-183},{"x":1191,"y":54,"id":15,"timeOffset":-116}]},"timestamp":1790260331526},
|
|
31
40
|
{"type":3,"data":{"source":2,"type":1,"id":30,"x":1211.1400146484375,"y":31.489999771118164},"timestamp":1790260331688},
|
|
@@ -50,6 +59,8 @@ export const replayDemoEvents: ReplayEvent[] = [
|
|
|
50
59
|
{"type":3,"data":{"source":2,"type":0,"id":99,"x":1090.5,"y":321.2099914550781},"timestamp":1790260333914},
|
|
51
60
|
{"type":3,"data":{"source":2,"type":2,"id":99,"x":1090,"y":321,"pointerType":0},"timestamp":1790260333914},
|
|
52
61
|
{"type":3,"data":{"source":1,"positions":[{"x":1090,"y":321,"id":99,"timeOffset":-441}]},"timestamp":1790260334354},
|
|
62
|
+
{"type":6,"data":{"plugin":"rrweb/console@1","payload":{"level":"warn","trace":["applyPromo (checkout.ts:71)","HTMLInputElement.onchange (checkout.ts:40)"],"payload":["\"[promo] `code` is deprecated; send `promoCode` instead\""]}},"timestamp":1790260334694},
|
|
63
|
+
{"type":6,"data":{"plugin":"rrweb/network@1","payload":{"requests":[{"url":"https://shop.northwind.test/api/promo/validate?code=FALL25","method":"GET","initiatorType":"fetch","status":200,"startTime":6800,"endTime":6918,"requestHeaders":{"accept":"application/json"},"responseHeaders":{"content-type":"application/json; charset=utf-8","cache-control":"no-store"},"responseBody":"{\"valid\": true, \"discount\": 0.15}"}]}},"timestamp":1790260334834},
|
|
53
64
|
{"type":3,"data":{"source":1,"positions":[{"x":1090,"y":330,"id":99,"timeOffset":-193},{"x":1090,"y":401,"id":101,"timeOffset":-128}]},"timestamp":1790260334855},
|
|
54
65
|
{"type":3,"data":{"source":2,"type":1,"id":101,"x":1090.5,"y":380.5},"timestamp":1790260334954},
|
|
55
66
|
{"type":3,"data":{"source":5,"text":"FALL25","isChecked":false,"id":99},"timestamp":1790260334954},
|
|
@@ -59,8 +70,8 @@ export const replayDemoEvents: ReplayEvent[] = [
|
|
|
59
70
|
{"type":3,"data":{"source":2,"type":2,"id":101,"x":1090,"y":380,"pointerType":0},"timestamp":1790260334954},
|
|
60
71
|
{"type":3,"data":{"source":0,"texts":[],"attributes":[],"removes":[{"parentId":101,"id":102}],"adds":[{"parentId":101,"nextId":null,"node":{"type":3,"textContent":"Placing order…","id":128}}]},"timestamp":1790260334954},
|
|
61
72
|
{"type":3,"data":{"source":1,"positions":[{"x":1090,"y":380,"id":101,"timeOffset":-402}]},"timestamp":1790260335356},
|
|
62
|
-
{"type":5,"data":{"tag":"network","payload":{"method":"POST","status":500,"url":"/api/checkout","label":"POST /api/checkout 500"}},"timestamp":1790260335855},
|
|
63
|
-
{"type":5,"data":{"tag":"error","payload":{"label":"TypeError","message":"Cannot read properties of undefined (reading 'total') at applyPromo (checkout.ts:88)"}},"timestamp":1790260335856},
|
|
73
|
+
{"type":5,"data":{"tag":"network","payload":{"method":"POST","status":500,"url":"/api/checkout","label":"POST /api/checkout 500","duration":412,"type":"fetch","requestHeaders":{"content-type":"application/json"},"requestBody":"{\"email\": \"sam@example.com\", \"promoCode\": null, \"code\": \"FALL25\", \"items\": [\"TR2\", \"RP28\"]}","responseHeaders":{"content-type":"application/json; charset=utf-8","x-request-id":"req_7Hq2c9"},"responseBody":"{\"error\": \"internal_error\", \"message\": \"Cannot read properties of undefined (reading 'total')\"}"}},"timestamp":1790260335855},
|
|
74
|
+
{"type":5,"data":{"tag":"error","payload":{"label":"TypeError","message":"Cannot read properties of undefined (reading 'total') at applyPromo (checkout.ts:88)","stack":["at applyPromo (checkout.ts:88:19)","at placeOrder (checkout.ts:132:5)","at HTMLButtonElement.onclick (checkout.ts:44:3)"]}},"timestamp":1790260335856},
|
|
64
75
|
{"type":3,"data":{"source":0,"texts":[],"attributes":[{"id":103,"attributes":{"class":"err show"}}],"removes":[{"parentId":101,"id":128}],"adds":[{"parentId":101,"nextId":null,"node":{"type":3,"textContent":"Place order","id":129}}]},"timestamp":1790260335856},
|
|
65
76
|
{"type":3,"data":{"source":1,"positions":[{"x":1085,"y":388,"id":101,"timeOffset":0}]},"timestamp":1790260336357},
|
|
66
77
|
{"type":3,"data":{"source":1,"positions":[{"x":1050,"y":444,"id":103,"timeOffset":-441}]},"timestamp":1790260336859},
|