aipeek 0.2.3 → 0.2.4
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/package.json +2 -4
- package/src/core/check.ts +34 -0
- package/src/core/compact.ts +338 -0
- package/src/core/detail.ts +252 -0
- package/src/core/diff.ts +38 -0
- package/src/core/emit.ts +186 -0
- package/src/core/util.ts +16 -0
- package/src/index.ts +5 -0
- package/src/server/cli.ts +70 -0
- package/src/server/plugin.ts +536 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aipeek",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "Gives AI a peek into your running browser app — UI tree, console, network, errors, state",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -19,9 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
|
21
21
|
"dist",
|
|
22
|
-
"src
|
|
23
|
-
"src/core/action.ts",
|
|
24
|
-
"src/core/types.ts"
|
|
22
|
+
"src"
|
|
25
23
|
],
|
|
26
24
|
"scripts": {
|
|
27
25
|
"build": "tsup",
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { CheckResult, RawState } from './types'
|
|
2
|
+
|
|
3
|
+
export function check(raw: RawState): CheckResult {
|
|
4
|
+
const consoleErrors = raw.console.filter(l => l.level === 'error')
|
|
5
|
+
const failedRequests = raw.network.filter(r => r.status >= 400 || r.failed)
|
|
6
|
+
|
|
7
|
+
const assertions = [
|
|
8
|
+
{
|
|
9
|
+
name: 'no-console-errors',
|
|
10
|
+
pass: consoleErrors.length === 0,
|
|
11
|
+
...consoleErrors.length && { detail: consoleErrors[0].text },
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
name: 'no-uncaught-errors',
|
|
15
|
+
pass: raw.errors.length === 0,
|
|
16
|
+
...raw.errors.length && { detail: raw.errors[0].message },
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: 'no-failed-requests',
|
|
20
|
+
pass: failedRequests.length === 0,
|
|
21
|
+
...failedRequests.length && { detail: `${failedRequests[0].method} ${failedRequests[0].url} ${failedRequests[0].status}` },
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: 'ui-not-empty',
|
|
25
|
+
pass: raw.ui.trim().length > 0,
|
|
26
|
+
...!raw.ui.trim().length && { detail: 'UI tree is empty' },
|
|
27
|
+
},
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
pass: assertions.every(a => a.pass),
|
|
32
|
+
assertions,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import type { CompactState, ErrorEntry, LogEntry, NetworkRequest, RawState } from './types'
|
|
2
|
+
import { compactUrl, truncate } from './util'
|
|
3
|
+
|
|
4
|
+
const SLOW_THRESHOLD = 1000
|
|
5
|
+
|
|
6
|
+
// --- UI (merged component tree + DOM semantics) ---
|
|
7
|
+
|
|
8
|
+
const MAX_UI_DEPTH = 6
|
|
9
|
+
const UI_PRIMITIVES = new Set([
|
|
10
|
+
'Button',
|
|
11
|
+
'Input',
|
|
12
|
+
'Label',
|
|
13
|
+
'Badge',
|
|
14
|
+
'Checkbox',
|
|
15
|
+
'Skeleton',
|
|
16
|
+
'Spinner',
|
|
17
|
+
'Switch',
|
|
18
|
+
'Tabs',
|
|
19
|
+
'Tooltip',
|
|
20
|
+
'Popover',
|
|
21
|
+
'Dialog',
|
|
22
|
+
'Select',
|
|
23
|
+
'Card',
|
|
24
|
+
'Table',
|
|
25
|
+
'Slider',
|
|
26
|
+
'Progress',
|
|
27
|
+
'RadioGroup',
|
|
28
|
+
'HoverCard',
|
|
29
|
+
'DropdownMenu',
|
|
30
|
+
'ContextMenu',
|
|
31
|
+
'Command',
|
|
32
|
+
'Form',
|
|
33
|
+
'Alert',
|
|
34
|
+
'Pagination',
|
|
35
|
+
'Textarea',
|
|
36
|
+
'TooltipProvider',
|
|
37
|
+
'DialogPortal',
|
|
38
|
+
'Router',
|
|
39
|
+
'RenderErrorBoundary',
|
|
40
|
+
'RouterProvider',
|
|
41
|
+
'RouterProvider2',
|
|
42
|
+
'PanelGroup',
|
|
43
|
+
'Panel',
|
|
44
|
+
])
|
|
45
|
+
|
|
46
|
+
// component name = leading run before the first space, '[', or '—' separator
|
|
47
|
+
export function nameOf(line: string): string {
|
|
48
|
+
let end = 0
|
|
49
|
+
while (end < line.length) {
|
|
50
|
+
const c = line[end]
|
|
51
|
+
if (c === ' ' || c === '\t' || c === '[' || c === '—')
|
|
52
|
+
break
|
|
53
|
+
end++
|
|
54
|
+
}
|
|
55
|
+
return line.slice(0, end)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function compactUI(tree: string): string {
|
|
59
|
+
if (!tree)
|
|
60
|
+
return ''
|
|
61
|
+
|
|
62
|
+
const lines = tree.split('\n')
|
|
63
|
+
const result: string[] = []
|
|
64
|
+
const repeatTracker = new Map<string, { count: number, lastIndex: number }>()
|
|
65
|
+
|
|
66
|
+
for (let i = 0; i < lines.length; i++) {
|
|
67
|
+
const line = lines[i]
|
|
68
|
+
const trimmed = line.trimStart()
|
|
69
|
+
if (!trimmed)
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
const indent = line.length - trimmed.length
|
|
73
|
+
const depth = Math.floor(indent / 2)
|
|
74
|
+
if (depth > MAX_UI_DEPTH)
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
const componentName = nameOf(trimmed)
|
|
78
|
+
if (UI_PRIMITIVES.has(componentName))
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
// fold repeated siblings
|
|
82
|
+
const key = `${depth}:${componentName}`
|
|
83
|
+
const tracker = repeatTracker.get(key)
|
|
84
|
+
if (tracker && i - tracker.lastIndex <= 2) {
|
|
85
|
+
tracker.count++
|
|
86
|
+
tracker.lastIndex = i
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// flush previous repeat groups
|
|
91
|
+
for (const [k, t] of repeatTracker) {
|
|
92
|
+
if (t.count > 1) {
|
|
93
|
+
const d = Number.parseInt(k.split(':')[0])
|
|
94
|
+
const name = k.split(':').slice(1).join(':')
|
|
95
|
+
result.push(`${' '.repeat(d)}${name} ×${t.count}`)
|
|
96
|
+
}
|
|
97
|
+
if (t.count > 1 || i - t.lastIndex > 2) {
|
|
98
|
+
repeatTracker.delete(k)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
repeatTracker.set(key, { count: 1, lastIndex: i })
|
|
103
|
+
result.push(line)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// flush remaining
|
|
107
|
+
for (const [k, t] of repeatTracker) {
|
|
108
|
+
if (t.count > 1) {
|
|
109
|
+
const d = Number.parseInt(k.split(':')[0])
|
|
110
|
+
const name = k.split(':').slice(1).join(':')
|
|
111
|
+
result.push(`${' '.repeat(d)}${name} ×${t.count}`)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return result.join('\n')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// --- Console ---
|
|
119
|
+
|
|
120
|
+
// case-insensitive substrings that mark a log line as dev-tooling noise
|
|
121
|
+
const NOISE_SUBSTRINGS = [
|
|
122
|
+
'[hmr]',
|
|
123
|
+
'[vite]',
|
|
124
|
+
'hot module',
|
|
125
|
+
'react-devtools',
|
|
126
|
+
'download the react devtools',
|
|
127
|
+
'warning: react does not recognize',
|
|
128
|
+
'source map',
|
|
129
|
+
'favicon.ico',
|
|
130
|
+
'webpack',
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
export function compactConsole(logs: LogEntry[]): string {
|
|
134
|
+
if (!logs.length)
|
|
135
|
+
return ''
|
|
136
|
+
|
|
137
|
+
// filter noise
|
|
138
|
+
const filtered = logs.filter((l) => {
|
|
139
|
+
const lower = l.text.toLowerCase()
|
|
140
|
+
return !NOISE_SUBSTRINGS.some(s => lower.includes(s))
|
|
141
|
+
})
|
|
142
|
+
if (!filtered.length)
|
|
143
|
+
return ''
|
|
144
|
+
|
|
145
|
+
// dedup consecutive same messages
|
|
146
|
+
const deduped: { entry: LogEntry, count: number }[] = []
|
|
147
|
+
for (const log of filtered) {
|
|
148
|
+
const last = deduped[deduped.length - 1]
|
|
149
|
+
if (last && last.entry.text === log.text && last.entry.level === log.level) {
|
|
150
|
+
last.count++
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
deduped.push({ entry: log, count: 1 })
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// prioritize: errors first, then warns, then recent info/debug
|
|
158
|
+
const errors = deduped.filter(d => d.entry.level === 'error')
|
|
159
|
+
const warns = deduped.filter(d => d.entry.level === 'warn')
|
|
160
|
+
const rest = deduped.filter(d => d.entry.level !== 'error' && d.entry.level !== 'warn')
|
|
161
|
+
|
|
162
|
+
// keep last N info/debug entries
|
|
163
|
+
const recentRest = rest.slice(-10)
|
|
164
|
+
|
|
165
|
+
const lines: string[] = []
|
|
166
|
+
for (const group of [...errors, ...warns, ...recentRest]) {
|
|
167
|
+
const prefix = `[${group.entry.level}]`
|
|
168
|
+
const count = group.count > 1 ? ` ×${group.count}` : ''
|
|
169
|
+
const source = group.entry.source ? ` (${group.entry.source})` : ''
|
|
170
|
+
const text = truncate(group.entry.text, 200)
|
|
171
|
+
lines.push(`${prefix}${count} ${text}${source}`)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return lines.join('\n')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// --- Network ---
|
|
178
|
+
|
|
179
|
+
export function compactNetwork(requests: NetworkRequest[]): string {
|
|
180
|
+
if (!requests.length)
|
|
181
|
+
return ''
|
|
182
|
+
|
|
183
|
+
// only fetch/XHR
|
|
184
|
+
const relevant = requests.filter(r =>
|
|
185
|
+
r.resourceType === 'fetch' || r.resourceType === 'xhr' || r.resourceType === 'websocket'
|
|
186
|
+
|| r.resourceType === 'eventsource' || isApiUrl(r.url),
|
|
187
|
+
)
|
|
188
|
+
if (!relevant.length)
|
|
189
|
+
return ''
|
|
190
|
+
|
|
191
|
+
const lines: string[] = []
|
|
192
|
+
for (const req of relevant) {
|
|
193
|
+
const duration = req.duration > 0 ? ` ${formatDuration(req.duration)}` : ''
|
|
194
|
+
const slow = req.duration >= SLOW_THRESHOLD ? ' [SLOW]' : ''
|
|
195
|
+
const url = compactUrl(req.url, 50)
|
|
196
|
+
const headers = diagnosticHeaders(req)
|
|
197
|
+
|
|
198
|
+
if (req.failed || req.status >= 400) {
|
|
199
|
+
// failed: show more detail
|
|
200
|
+
const body = req.responseBody ? ` "${truncate(req.responseBody, 100)}"` : ''
|
|
201
|
+
const failure = req.failureText ? ` (${req.failureText})` : ''
|
|
202
|
+
lines.push(`${req.method} ${url} ${req.status}${failure}${body}${headers}${duration}${slow}`)
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
lines.push(`${req.method} ${url} ${req.status}${headers}${duration}${slow}`)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return lines.join('\n')
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function isApiUrl(url: string): boolean {
|
|
213
|
+
try {
|
|
214
|
+
const u = new URL(url)
|
|
215
|
+
return u.pathname.startsWith('/api') || u.pathname.includes('/graphql')
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return false
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const DIAGNOSTIC_HEADERS = ['content-type', 'x-error', 'www-authenticate', 'access-control-allow-origin']
|
|
223
|
+
|
|
224
|
+
function diagnosticHeaders(req: NetworkRequest): string {
|
|
225
|
+
const h = req.responseHeaders
|
|
226
|
+
if (!h)
|
|
227
|
+
return ''
|
|
228
|
+
const parts: string[] = []
|
|
229
|
+
for (const key of DIAGNOSTIC_HEADERS) {
|
|
230
|
+
const val = h[key]
|
|
231
|
+
if (!val)
|
|
232
|
+
continue
|
|
233
|
+
// skip common json content-type on success — not diagnostic
|
|
234
|
+
if (key === 'content-type' && req.status < 400 && val.includes('application/json'))
|
|
235
|
+
continue
|
|
236
|
+
parts.push(`${key}: ${truncate(val, 60)}`)
|
|
237
|
+
}
|
|
238
|
+
if (!parts.length)
|
|
239
|
+
return ''
|
|
240
|
+
return ` [${parts.join(', ')}]`
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function formatDuration(ms: number): string {
|
|
244
|
+
return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms`
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// --- Errors ---
|
|
248
|
+
|
|
249
|
+
export function compactErrors(errors: ErrorEntry[]): string {
|
|
250
|
+
if (!errors.length)
|
|
251
|
+
return ''
|
|
252
|
+
|
|
253
|
+
// dedup by message
|
|
254
|
+
const seen = new Map<string, ErrorEntry>()
|
|
255
|
+
for (const err of errors) {
|
|
256
|
+
if (!seen.has(err.message)) {
|
|
257
|
+
seen.set(err.message, err)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const lines: string[] = []
|
|
262
|
+
for (const err of seen.values()) {
|
|
263
|
+
lines.push(err.message)
|
|
264
|
+
if (err.stack) {
|
|
265
|
+
const frames = filterStack(err.stack)
|
|
266
|
+
for (const frame of frames) {
|
|
267
|
+
lines.push(` at ${frame}`)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return lines.join('\n')
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function filterStack(stack: string): string[] {
|
|
276
|
+
return stack
|
|
277
|
+
.split('\n')
|
|
278
|
+
.map(l => l.trim())
|
|
279
|
+
.filter(l => l.startsWith('at '))
|
|
280
|
+
.map(l => l.slice(3))
|
|
281
|
+
.filter(l => !l.includes('node_modules') && !l.includes('<anonymous>'))
|
|
282
|
+
.slice(0, 5)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// --- State ---
|
|
286
|
+
|
|
287
|
+
export function compactState(state: Record<string, unknown>): string {
|
|
288
|
+
if (!state || !Object.keys(state).length)
|
|
289
|
+
return ''
|
|
290
|
+
|
|
291
|
+
const lines: string[] = []
|
|
292
|
+
for (const [name, value] of Object.entries(state)) {
|
|
293
|
+
lines.push(`${name}:`)
|
|
294
|
+
if (typeof value === 'object' && value !== null) {
|
|
295
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
296
|
+
lines.push(` ${k}: ${formatValue(v)}`)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
lines.push(` ${String(value)}`)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return lines.join('\n')
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function formatValue(v: unknown): string {
|
|
307
|
+
if (v === null || v === undefined)
|
|
308
|
+
return String(v)
|
|
309
|
+
if (typeof v === 'string')
|
|
310
|
+
return v
|
|
311
|
+
if (typeof v === 'number' || typeof v === 'boolean')
|
|
312
|
+
return String(v)
|
|
313
|
+
if (typeof v === 'object') {
|
|
314
|
+
const s = JSON.stringify(v)
|
|
315
|
+
return s.length > 120 ? `${s.slice(0, 120)}…` : s
|
|
316
|
+
}
|
|
317
|
+
return String(v)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// --- Main ---
|
|
321
|
+
|
|
322
|
+
export function compact(raw: RawState): CompactState {
|
|
323
|
+
return {
|
|
324
|
+
url: raw.url,
|
|
325
|
+
ui: compactUI(raw.ui),
|
|
326
|
+
console: compactConsole(raw.console),
|
|
327
|
+
network: compactNetwork(raw.network),
|
|
328
|
+
errors: compactErrors(raw.errors),
|
|
329
|
+
state: compactState(raw.state),
|
|
330
|
+
timestamp: raw.timestamp,
|
|
331
|
+
counts: {
|
|
332
|
+
console: raw.console.length,
|
|
333
|
+
network: raw.network.length,
|
|
334
|
+
errors: raw.errors.length,
|
|
335
|
+
state: Object.keys(raw.state).length,
|
|
336
|
+
},
|
|
337
|
+
}
|
|
338
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import type { ErrorEntry, LogEntry, NetworkRequest, RawState } from './types'
|
|
2
|
+
import { compactUI } from './compact'
|
|
3
|
+
import { truncate } from './util'
|
|
4
|
+
|
|
5
|
+
export function detail(raw: RawState, section: string, index: string | undefined, full: boolean): string | null {
|
|
6
|
+
switch (section) {
|
|
7
|
+
case 'ui': return full ? (raw.ui || null) : (compactUI(raw.ui) || null)
|
|
8
|
+
case 'console': return detailConsole(raw.console, index, full)
|
|
9
|
+
case 'network': return detailNetwork(raw.network, index, full)
|
|
10
|
+
case 'errors': return detailError(raw.errors, index, full)
|
|
11
|
+
case 'state': return detailState(raw.state, index, full)
|
|
12
|
+
default: return null
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// --- Console ---
|
|
17
|
+
|
|
18
|
+
function detailConsole(logs: LogEntry[], index: string | undefined, full: boolean): string | null {
|
|
19
|
+
if (index === undefined) {
|
|
20
|
+
if (!logs.length)
|
|
21
|
+
return '(empty)'
|
|
22
|
+
return logs.map((log, i) => `[${i}] [${log.level}] ${truncate(log.text, 120)}`).join('\n')
|
|
23
|
+
}
|
|
24
|
+
const i = Number.parseInt(index)
|
|
25
|
+
if (Number.isNaN(i) || i < 0 || i >= logs.length)
|
|
26
|
+
return null
|
|
27
|
+
const log = logs[i]
|
|
28
|
+
|
|
29
|
+
if (full) {
|
|
30
|
+
const parts = [`[${log.level}] ${log.text}`]
|
|
31
|
+
if (log.timestamp)
|
|
32
|
+
parts.push(`timestamp: ${new Date(log.timestamp).toISOString()}`)
|
|
33
|
+
if (log.source)
|
|
34
|
+
parts.push(`source: ${log.source}`)
|
|
35
|
+
return parts.join('\n')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return `[${log.level}] ${truncate(log.text, 200)}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// --- Network ---
|
|
42
|
+
|
|
43
|
+
function detailNetwork(requests: NetworkRequest[], index: string | undefined, full: boolean): string | null {
|
|
44
|
+
if (index === undefined) {
|
|
45
|
+
if (!requests.length)
|
|
46
|
+
return '(empty)'
|
|
47
|
+
return requests.map((req, i) => `[${i}] ${req.method} ${req.status} ${truncate(req.url, 80)} ${req.duration}ms${req.failed ? ' FAILED' : ''}`).join('\n')
|
|
48
|
+
}
|
|
49
|
+
const i = Number.parseInt(index)
|
|
50
|
+
if (Number.isNaN(i) || i < 0 || i >= requests.length)
|
|
51
|
+
return null
|
|
52
|
+
const req = requests[i]
|
|
53
|
+
|
|
54
|
+
const lines = [
|
|
55
|
+
`${req.method} ${req.url}`,
|
|
56
|
+
`status: ${req.status}`,
|
|
57
|
+
`duration: ${req.duration}ms`,
|
|
58
|
+
`type: ${req.resourceType}`,
|
|
59
|
+
]
|
|
60
|
+
if (req.failed)
|
|
61
|
+
lines.push(`failed: ${req.failureText || 'true'}`)
|
|
62
|
+
|
|
63
|
+
if (full) {
|
|
64
|
+
if (req.requestHeaders && Object.keys(req.requestHeaders).length) {
|
|
65
|
+
lines.push('request-headers:')
|
|
66
|
+
for (const [k, v] of Object.entries(req.requestHeaders)) lines.push(` ${k}: ${v}`)
|
|
67
|
+
}
|
|
68
|
+
if (req.requestBody)
|
|
69
|
+
lines.push(`request-body:\n${req.requestBody}`)
|
|
70
|
+
if (req.responseHeaders && Object.keys(req.responseHeaders).length) {
|
|
71
|
+
lines.push('response-headers:')
|
|
72
|
+
for (const [k, v] of Object.entries(req.responseHeaders)) lines.push(` ${k}: ${v}`)
|
|
73
|
+
}
|
|
74
|
+
if (req.responseBody)
|
|
75
|
+
lines.push(`response-body:\n${req.responseBody}`)
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
if (req.requestBody) {
|
|
79
|
+
lines.push(`request-body: ${byteSize(req.requestBody)}`)
|
|
80
|
+
if (req.requestSample) {
|
|
81
|
+
const schema = jsonSchema(req.requestSample)
|
|
82
|
+
if (schema)
|
|
83
|
+
lines.push(schema)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (req.responseBody) {
|
|
87
|
+
if (req.status >= 400) {
|
|
88
|
+
lines.push(`response-body: ${byteSize(req.responseBody)} "${truncate(req.responseBody, 100)}"`)
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
if (req.responseSample) {
|
|
92
|
+
lines.push(`response-body: ${byteSize(req.responseBody)}`)
|
|
93
|
+
const schema = jsonSchema(req.responseSample)
|
|
94
|
+
if (schema)
|
|
95
|
+
lines.push(schema)
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
lines.push(`response-body: ${byteSize(req.responseBody)} ${truncate(req.responseBody, 100)}`)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return lines.join('\n')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// --- Errors ---
|
|
108
|
+
|
|
109
|
+
function detailError(errors: ErrorEntry[], index: string | undefined, full: boolean): string | null {
|
|
110
|
+
if (index === undefined) {
|
|
111
|
+
if (!errors.length)
|
|
112
|
+
return '(empty)'
|
|
113
|
+
return errors.map((err, i) => `[${i}] ${truncate(err.message, 120)}`).join('\n')
|
|
114
|
+
}
|
|
115
|
+
const i = Number.parseInt(index)
|
|
116
|
+
if (Number.isNaN(i) || i < 0 || i >= errors.length)
|
|
117
|
+
return null
|
|
118
|
+
const err = errors[i]
|
|
119
|
+
|
|
120
|
+
if (full) {
|
|
121
|
+
const lines = [err.message]
|
|
122
|
+
if (err.stack)
|
|
123
|
+
lines.push(err.stack)
|
|
124
|
+
if (err.source)
|
|
125
|
+
lines.push(`source: ${err.source}`)
|
|
126
|
+
if (err.line != null)
|
|
127
|
+
lines.push(`location: ${err.source || ''}:${err.line}:${err.column ?? 0}`)
|
|
128
|
+
return lines.join('\n')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const lines = [err.message]
|
|
132
|
+
if (err.stack) {
|
|
133
|
+
const appFrames = err.stack.split('\n')
|
|
134
|
+
.map(l => l.trim())
|
|
135
|
+
.filter(l => l.startsWith('at ') && !l.includes('node_modules'))
|
|
136
|
+
.slice(0, 3)
|
|
137
|
+
if (appFrames.length)
|
|
138
|
+
lines.push(...appFrames)
|
|
139
|
+
const totalApp = err.stack.split('\n').filter(l => l.trim().startsWith('at ') && !l.includes('node_modules')).length
|
|
140
|
+
if (totalApp > 3)
|
|
141
|
+
lines.push(` ... ${totalApp - 3} more app frames`)
|
|
142
|
+
}
|
|
143
|
+
if (err.line != null)
|
|
144
|
+
lines.push(`location: ${err.source || ''}:${err.line}:${err.column ?? 0}`)
|
|
145
|
+
return lines.join('\n')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// --- State ---
|
|
149
|
+
|
|
150
|
+
function detailState(state: Record<string, unknown>, name: string | undefined, full: boolean): string | null {
|
|
151
|
+
if (!name) {
|
|
152
|
+
const keys = Object.keys(state)
|
|
153
|
+
if (!keys.length)
|
|
154
|
+
return '(empty)'
|
|
155
|
+
return keys.map(k => `${k}: ${formatSummaryValue(state[k])}`).join('\n')
|
|
156
|
+
}
|
|
157
|
+
if (!(name in state))
|
|
158
|
+
return null
|
|
159
|
+
const value = state[name]
|
|
160
|
+
if (full) {
|
|
161
|
+
try {
|
|
162
|
+
return JSON.stringify(value, null, 2)
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return String(value)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (typeof value !== 'object' || value === null)
|
|
169
|
+
return `${name}: ${typeof value}`
|
|
170
|
+
const lines: string[] = []
|
|
171
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
172
|
+
lines.push(`${k}: ${formatSummaryValue(v)}`)
|
|
173
|
+
}
|
|
174
|
+
return lines.join('\n')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isArraySentinel(v: string): boolean {
|
|
178
|
+
if (!v.startsWith('Array(') || !v.endsWith(')'))
|
|
179
|
+
return false
|
|
180
|
+
const digits = v.slice(6, -1)
|
|
181
|
+
return digits.length > 0 && [...digits].every(c => c >= '0' && c <= '9')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function formatSummaryValue(v: unknown): string {
|
|
185
|
+
if (v === null || v === undefined)
|
|
186
|
+
return String(v)
|
|
187
|
+
if (typeof v === 'string') {
|
|
188
|
+
if (isArraySentinel(v)) // "Array(N)" from boundedSnapshot — leave untruncated
|
|
189
|
+
return v
|
|
190
|
+
return v.length > 80 ? `${v.slice(0, 80)}…` : v
|
|
191
|
+
}
|
|
192
|
+
if (typeof v === 'number' || typeof v === 'boolean')
|
|
193
|
+
return String(v)
|
|
194
|
+
if (typeof v === 'object') {
|
|
195
|
+
const s = JSON.stringify(v)
|
|
196
|
+
return s.length > 80 ? `${s.slice(0, 80)}…` : s
|
|
197
|
+
}
|
|
198
|
+
return String(v)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// --- JSON schema fallback ---
|
|
202
|
+
|
|
203
|
+
function jsonSchema(sample: string): string | null {
|
|
204
|
+
try {
|
|
205
|
+
return schemaOf(JSON.parse(sample), 0)
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return null
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function schemaOf(v: unknown, d: number): string {
|
|
213
|
+
if (v === null)
|
|
214
|
+
return 'null'
|
|
215
|
+
if (typeof v === 'string')
|
|
216
|
+
return 'string'
|
|
217
|
+
if (typeof v === 'number')
|
|
218
|
+
return 'number'
|
|
219
|
+
if (typeof v === 'boolean')
|
|
220
|
+
return 'boolean'
|
|
221
|
+
if (Array.isArray(v)) {
|
|
222
|
+
if (!v.length)
|
|
223
|
+
return '[]'
|
|
224
|
+
if (d >= 3)
|
|
225
|
+
return '[…]'
|
|
226
|
+
return `${schemaOf(v[0], d + 1)}[]`
|
|
227
|
+
}
|
|
228
|
+
if (typeof v === 'object') {
|
|
229
|
+
if (d >= 3)
|
|
230
|
+
return '{…}'
|
|
231
|
+
const entries = Object.entries(v as Record<string, unknown>)
|
|
232
|
+
if (!entries.length)
|
|
233
|
+
return '{}'
|
|
234
|
+
const max = d === 0 ? 12 : 6
|
|
235
|
+
const fields = entries.slice(0, max).map(([k, val]) => `${k}: ${schemaOf(val, d + 1)}`)
|
|
236
|
+
if (entries.length > max)
|
|
237
|
+
fields.push(`… ${entries.length - max} more`)
|
|
238
|
+
return `{ ${fields.join(', ')} }`
|
|
239
|
+
}
|
|
240
|
+
return typeof v
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// --- Utils ---
|
|
244
|
+
|
|
245
|
+
function byteSize(s: string): string {
|
|
246
|
+
const bytes = new TextEncoder().encode(s).length
|
|
247
|
+
if (bytes < 1024)
|
|
248
|
+
return `${bytes}B`
|
|
249
|
+
if (bytes < 1024 * 1024)
|
|
250
|
+
return `${(bytes / 1024).toFixed(1)}KB`
|
|
251
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
|
|
252
|
+
}
|
package/src/core/diff.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { DiffResult, RawState } from './types'
|
|
2
|
+
|
|
3
|
+
export function diffState(prev: RawState | null, curr: RawState): DiffResult {
|
|
4
|
+
if (!prev) {
|
|
5
|
+
return {
|
|
6
|
+
newErrors: curr.console.filter(l => l.level === 'error'),
|
|
7
|
+
newExceptions: [...curr.errors],
|
|
8
|
+
newFailedRequests: curr.network.filter(r => r.status >= 400 || r.failed),
|
|
9
|
+
uiGone: false,
|
|
10
|
+
clean: curr.console.filter(l => l.level === 'error').length === 0
|
|
11
|
+
&& curr.errors.length === 0
|
|
12
|
+
&& curr.network.filter(r => r.status >= 400 || r.failed).length === 0,
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const prevErrorTexts = new Set(prev.console.filter(l => l.level === 'error').map(l => l.text))
|
|
17
|
+
const newErrors = curr.console.filter(l => l.level === 'error' && !prevErrorTexts.has(l.text))
|
|
18
|
+
|
|
19
|
+
const prevExceptionMsgs = new Set(prev.errors.map(e => e.message))
|
|
20
|
+
const newExceptions = curr.errors.filter(e => !prevExceptionMsgs.has(e.message))
|
|
21
|
+
|
|
22
|
+
const prevFailedKeys = new Set(
|
|
23
|
+
prev.network.filter(r => r.status >= 400 || r.failed).map(r => `${r.method}|${r.url}|${r.status}`),
|
|
24
|
+
)
|
|
25
|
+
const newFailedRequests = curr.network
|
|
26
|
+
.filter(r => r.status >= 400 || r.failed)
|
|
27
|
+
.filter(r => !prevFailedKeys.has(`${r.method}|${r.url}|${r.status}`))
|
|
28
|
+
|
|
29
|
+
const uiGone = prev.ui.trim().length > 0 && curr.ui.trim().length === 0
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
newErrors,
|
|
33
|
+
newExceptions,
|
|
34
|
+
newFailedRequests,
|
|
35
|
+
uiGone,
|
|
36
|
+
clean: newErrors.length === 0 && newExceptions.length === 0 && newFailedRequests.length === 0 && !uiGone,
|
|
37
|
+
}
|
|
38
|
+
}
|