aipeek 0.2.2 → 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/dist/{chunk-GIMXNZD5.cjs → chunk-3NVB3GGE.cjs} +3 -2
- package/dist/{chunk-JOY7QP24.js → chunk-72ZKZ42D.js} +3 -2
- package/dist/index.cjs +2 -2
- package/dist/index.js +1 -1
- package/dist/plugin.cjs +2 -2
- package/dist/plugin.js +1 -1
- package/package.json +3 -2
- package/src/client/client-patch.ts +312 -0
- package/src/client/client.ts +689 -0
- package/src/core/action.ts +272 -0
- 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/types.ts +75 -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
|
@@ -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
|
+
}
|
package/src/core/emit.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { CheckResult, CompactState, DiffResult, RawState } from './types'
|
|
2
|
+
import pc from 'picocolors'
|
|
3
|
+
import { nameOf } from './compact'
|
|
4
|
+
import { compactUrl, truncate } from './util'
|
|
5
|
+
|
|
6
|
+
// --- Full emit (for ?full) ---
|
|
7
|
+
|
|
8
|
+
const SECTIONS = ['ui', 'console', 'network', 'errors', 'state'] as const
|
|
9
|
+
const COUNTED_SECTIONS: Record<string, keyof CompactState['counts']> = {
|
|
10
|
+
console: 'console',
|
|
11
|
+
network: 'network',
|
|
12
|
+
errors: 'errors',
|
|
13
|
+
state: 'state',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function emit(state: CompactState): string {
|
|
17
|
+
const sections: string[] = []
|
|
18
|
+
|
|
19
|
+
for (const key of SECTIONS) {
|
|
20
|
+
if (state[key]) {
|
|
21
|
+
const countKey = COUNTED_SECTIONS[key]
|
|
22
|
+
const count = countKey ? state.counts?.[countKey] ?? 0 : 0
|
|
23
|
+
const attr = count ? ` count="${count}"` : ''
|
|
24
|
+
sections.push(`<${key}${attr}>\n${state[key]}\n</${key}>`)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!sections.length) {
|
|
29
|
+
sections.push('<empty/>')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return `<aipeek url="${state.url}">\n\n${sections.join('\n\n')}\n\n</aipeek>\n\ndetail: GET /__aipeek/{section}/{index}?full`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// --- Summary emit (default, high-density) ---
|
|
36
|
+
|
|
37
|
+
export function emitSummary(raw: RawState): string {
|
|
38
|
+
const consoleErrors = raw.console.filter(l => l.level === 'error')
|
|
39
|
+
const consoleWarns = raw.console.filter(l => l.level === 'warn')
|
|
40
|
+
const failedReqs = raw.network.filter(r => r.status >= 400 || r.failed)
|
|
41
|
+
const hasIssues = consoleErrors.length > 0 || raw.errors.length > 0 || failedReqs.length > 0
|
|
42
|
+
|
|
43
|
+
const lines: string[] = []
|
|
44
|
+
|
|
45
|
+
// ui — always 1-line summary
|
|
46
|
+
if (raw.ui.trim()) {
|
|
47
|
+
lines.push(`ui: ${summarizeUI(raw.ui)}`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// console — 1-line if clean, expand errors/warns if dirty
|
|
51
|
+
if (raw.console.length) {
|
|
52
|
+
if (consoleErrors.length || consoleWarns.length) {
|
|
53
|
+
const parts: string[] = []
|
|
54
|
+
for (const e of consoleErrors) parts.push(` [error] ${truncate(e.text, 150)}`)
|
|
55
|
+
for (const w of consoleWarns) parts.push(` [warn] ${truncate(w.text, 150)}`)
|
|
56
|
+
const rest = raw.console.length - consoleErrors.length - consoleWarns.length
|
|
57
|
+
if (rest > 0)
|
|
58
|
+
parts.push(` … ${rest} more`)
|
|
59
|
+
lines.push(`console (${raw.console.length}):`)
|
|
60
|
+
lines.push(...parts)
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
lines.push(`console: ${raw.console.length} logs`)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// network — 1-line if clean, expand failures if dirty
|
|
68
|
+
if (raw.network.length) {
|
|
69
|
+
if (failedReqs.length) {
|
|
70
|
+
lines.push(`network (${raw.network.length}):`)
|
|
71
|
+
for (const r of failedReqs) {
|
|
72
|
+
const body = r.responseBody ? ` "${truncate(r.responseBody, 80)}"` : ''
|
|
73
|
+
lines.push(` ${r.method} ${compactUrl(r.url)} ${r.status}${body}`)
|
|
74
|
+
}
|
|
75
|
+
const ok = raw.network.length - failedReqs.length
|
|
76
|
+
if (ok > 0)
|
|
77
|
+
lines.push(` … ${ok} ok`)
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
lines.push(`network: ${raw.network.length} ok`)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// errors — always expand (these are uncaught exceptions)
|
|
85
|
+
if (raw.errors.length) {
|
|
86
|
+
lines.push(`errors (${raw.errors.length}):`)
|
|
87
|
+
for (const e of raw.errors) lines.push(` ${truncate(e.message, 150)}`)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// state — 1-line: store names + key counts
|
|
91
|
+
const storeNames = Object.keys(raw.state)
|
|
92
|
+
if (storeNames.length) {
|
|
93
|
+
const parts = storeNames.map((n) => {
|
|
94
|
+
const v = raw.state[n]
|
|
95
|
+
const keys = typeof v === 'object' && v !== null ? Object.keys(v).length : 0
|
|
96
|
+
return keys > 0 ? `${n}(${keys})` : n
|
|
97
|
+
})
|
|
98
|
+
lines.push(`state: ${parts.join(', ')}`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!lines.length)
|
|
102
|
+
return '<aipeek>empty</aipeek>'
|
|
103
|
+
|
|
104
|
+
const status = hasIssues
|
|
105
|
+
? `${consoleErrors.length + raw.errors.length + failedReqs.length} issues`
|
|
106
|
+
: 'ok'
|
|
107
|
+
|
|
108
|
+
return `<aipeek url="${raw.url}" status="${status}">\n${lines.join('\n')}\n</aipeek>${hasIssues ? '\n\ndetail: GET /__aipeek/{section}/{index}' : ''}`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function summarizeUI(tree: string): string {
|
|
112
|
+
// extract top-level component names (depth 0-1)
|
|
113
|
+
const components: string[] = []
|
|
114
|
+
const counts = new Map<string, number>()
|
|
115
|
+
|
|
116
|
+
for (const line of tree.split('\n')) {
|
|
117
|
+
const trimmed = line.trimStart()
|
|
118
|
+
if (!trimmed)
|
|
119
|
+
continue
|
|
120
|
+
const indent = line.length - trimmed.length
|
|
121
|
+
if (indent > 2)
|
|
122
|
+
continue // only depth 0-1
|
|
123
|
+
|
|
124
|
+
const name = nameOf(trimmed)
|
|
125
|
+
if (!name)
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
// extract state markers
|
|
129
|
+
const focused = trimmed.includes('[focused]')
|
|
130
|
+
const generating = trimmed.includes('[generating]')
|
|
131
|
+
const loading = trimmed.includes('[loading]')
|
|
132
|
+
|
|
133
|
+
const existing = counts.get(name) || 0
|
|
134
|
+
if (existing > 0) {
|
|
135
|
+
counts.set(name, existing + 1)
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
counts.set(name, 1)
|
|
139
|
+
|
|
140
|
+
let label = name
|
|
141
|
+
if (focused)
|
|
142
|
+
label += '[focused]'
|
|
143
|
+
if (generating)
|
|
144
|
+
label += '[generating]'
|
|
145
|
+
if (loading)
|
|
146
|
+
label += '[loading]'
|
|
147
|
+
components.push(label)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// apply counts
|
|
151
|
+
const result = components.map((c) => {
|
|
152
|
+
const name = c.split('[')[0]
|
|
153
|
+
const count = counts.get(name) || 1
|
|
154
|
+
return count > 1 ? `${c}(×${count})` : c
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
return result.join(', ') || 'empty'
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// --- Check emit ---
|
|
161
|
+
|
|
162
|
+
export function emitCheck(result: CheckResult): string {
|
|
163
|
+
const lines = result.assertions.map(a =>
|
|
164
|
+
a.pass
|
|
165
|
+
? `✓ ${a.name}`
|
|
166
|
+
: `✗ ${a.name}${a.detail ? `: ${a.detail}` : ''}`,
|
|
167
|
+
)
|
|
168
|
+
return `<aipeek-check pass="${result.pass}">\n${lines.join('\n')}\n</aipeek-check>`
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// --- Diff emit (terminal, colored) ---
|
|
172
|
+
|
|
173
|
+
export function emitDiff(diff: DiffResult): string {
|
|
174
|
+
const issues: string[] = []
|
|
175
|
+
for (const e of diff.newErrors) issues.push(pc.red(` [error] ${e.text}`))
|
|
176
|
+
for (const e of diff.newExceptions) issues.push(pc.red(` [exception] ${e.message}`))
|
|
177
|
+
for (const r of diff.newFailedRequests) issues.push(pc.yellow(` [network] ${r.method} ${r.url} ${r.status}`))
|
|
178
|
+
if (diff.uiGone)
|
|
179
|
+
issues.push(pc.magenta(' [ui] component tree disappeared'))
|
|
180
|
+
|
|
181
|
+
if (!issues.length)
|
|
182
|
+
return ''
|
|
183
|
+
|
|
184
|
+
const count = issues.length
|
|
185
|
+
return `${pc.bold('[aipeek]')} ${pc.red(`✗ ${count} issue${count > 1 ? 's' : ''} after HMR`)}\n${issues.join('\n')}`
|
|
186
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export interface LogEntry {
|
|
2
|
+
level: 'error' | 'warn' | 'info' | 'debug' | 'log'
|
|
3
|
+
text: string
|
|
4
|
+
timestamp?: number
|
|
5
|
+
source?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface NetworkRequest {
|
|
9
|
+
method: string
|
|
10
|
+
url: string
|
|
11
|
+
status: number
|
|
12
|
+
duration: number
|
|
13
|
+
resourceType: string
|
|
14
|
+
requestHeaders?: Record<string, string>
|
|
15
|
+
responseHeaders?: Record<string, string>
|
|
16
|
+
requestBody?: string
|
|
17
|
+
requestSample?: string
|
|
18
|
+
responseBody?: string
|
|
19
|
+
responseSample?: string
|
|
20
|
+
failed?: boolean
|
|
21
|
+
failureText?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ErrorEntry {
|
|
25
|
+
message: string
|
|
26
|
+
stack?: string
|
|
27
|
+
source?: string
|
|
28
|
+
line?: number
|
|
29
|
+
column?: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RawState {
|
|
33
|
+
url: string
|
|
34
|
+
ui: string
|
|
35
|
+
console: LogEntry[]
|
|
36
|
+
network: NetworkRequest[]
|
|
37
|
+
errors: ErrorEntry[]
|
|
38
|
+
state: Record<string, unknown>
|
|
39
|
+
timestamp: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CompactState {
|
|
43
|
+
url: string
|
|
44
|
+
ui: string
|
|
45
|
+
console: string
|
|
46
|
+
network: string
|
|
47
|
+
errors: string
|
|
48
|
+
state: string
|
|
49
|
+
timestamp: number
|
|
50
|
+
counts: {
|
|
51
|
+
console: number
|
|
52
|
+
network: number
|
|
53
|
+
errors: number
|
|
54
|
+
state: number
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface Assertion {
|
|
59
|
+
name: string
|
|
60
|
+
pass: boolean
|
|
61
|
+
detail?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface CheckResult {
|
|
65
|
+
pass: boolean
|
|
66
|
+
assertions: Assertion[]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface DiffResult {
|
|
70
|
+
newErrors: LogEntry[]
|
|
71
|
+
newExceptions: ErrorEntry[]
|
|
72
|
+
newFailedRequests: NetworkRequest[]
|
|
73
|
+
uiGone: boolean
|
|
74
|
+
clean: boolean
|
|
75
|
+
}
|
package/src/core/util.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function truncate(s: string, max: number): string {
|
|
2
|
+
return s.length > max ? `${s.slice(0, max)}…` : s
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
// pathname only; pass `search` to append a truncated query string
|
|
6
|
+
export function compactUrl(url: string, search?: number): string {
|
|
7
|
+
try {
|
|
8
|
+
const u = new URL(url)
|
|
9
|
+
if (search && u.search)
|
|
10
|
+
return `${u.pathname}?${truncate(u.search.slice(1), search)}`
|
|
11
|
+
return u.pathname
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return truncate(url, 80)
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { check } from './core/check'
|
|
2
|
+
export { diffState } from './core/diff'
|
|
3
|
+
export { emitCheck, emitDiff, emitSummary } from './core/emit'
|
|
4
|
+
export type { CheckResult, CompactState, DiffResult, RawState } from './core/types'
|
|
5
|
+
export { aipeekPlugin } from './server/plugin'
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/* eslint-disable no-console -- this is a CLI; stdout is its output channel */
|
|
2
|
+
import process from 'node:process'
|
|
3
|
+
import pc from 'picocolors'
|
|
4
|
+
|
|
5
|
+
const args = process.argv.slice(2)
|
|
6
|
+
const flags = Object.fromEntries(
|
|
7
|
+
args.filter(a => a.startsWith('--')).map((a) => {
|
|
8
|
+
const [k, v] = a.slice(2).split('=')
|
|
9
|
+
return [k, v ?? 'true']
|
|
10
|
+
}),
|
|
11
|
+
)
|
|
12
|
+
const positional = args.filter(a => !a.startsWith('--'))
|
|
13
|
+
|
|
14
|
+
async function main() {
|
|
15
|
+
if (flags.help) {
|
|
16
|
+
console.log(`
|
|
17
|
+
${pc.bold('aipeek')} — runtime snapshot from Vite dev server
|
|
18
|
+
|
|
19
|
+
${pc.dim('Usage:')}
|
|
20
|
+
aipeek Full summary
|
|
21
|
+
aipeek check Health check (pass/fail)
|
|
22
|
+
aipeek console Console logs
|
|
23
|
+
aipeek network/0 Network request detail
|
|
24
|
+
aipeek errors/1?full Error detail (untruncated)
|
|
25
|
+
|
|
26
|
+
${pc.dim('Options:')}
|
|
27
|
+
--port=<port> Dev server port (default: 5173)
|
|
28
|
+
--full Untruncated output
|
|
29
|
+
--help Show this help
|
|
30
|
+
|
|
31
|
+
${pc.dim('Setup:')}
|
|
32
|
+
Add aipeekPlugin() to your vite.config.ts plugins array.
|
|
33
|
+
`)
|
|
34
|
+
process.exit(0)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const port = flags.port || '5173'
|
|
38
|
+
const sub = positional[0] || ''
|
|
39
|
+
const full = flags.full ? '?full' : ''
|
|
40
|
+
const path = sub ? `/${sub}` : ''
|
|
41
|
+
const endpoint = `http://localhost:${port}/__aipeek${path}${full}`
|
|
42
|
+
|
|
43
|
+
const resp = await fetch(endpoint)
|
|
44
|
+
if (!resp.ok && resp.status !== 417) {
|
|
45
|
+
const text = await resp.text()
|
|
46
|
+
console.error(pc.red(`Error ${resp.status}: ${text}`))
|
|
47
|
+
process.exit(1)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const text = await resp.text()
|
|
51
|
+
// color check results — assertions are one per line: "✓ name" / "✗ name: detail"
|
|
52
|
+
if (sub === 'check') {
|
|
53
|
+
const colored = text.split('\n').map((line) => {
|
|
54
|
+
if (line.startsWith('✓'))
|
|
55
|
+
return pc.green(line)
|
|
56
|
+
if (line.startsWith('✗'))
|
|
57
|
+
return pc.red(line)
|
|
58
|
+
return line
|
|
59
|
+
}).join('\n')
|
|
60
|
+
console.log(colored)
|
|
61
|
+
process.exit(resp.status === 417 ? 1 : 0)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
console.log(text)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
main().catch((err) => {
|
|
68
|
+
console.error(pc.red(`Error: ${err.message}`))
|
|
69
|
+
process.exit(1)
|
|
70
|
+
})
|