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,272 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
// aipeek actions — write capability over the existing HMR channel.
|
|
3
|
+
//
|
|
4
|
+
// Two layers, intentionally split:
|
|
5
|
+
// - resolveAction(): pure param validation. Runs plugin-side, unit-tested.
|
|
6
|
+
// - performAction(): real DOM mutation. Runs browser-side (client.ts imports it).
|
|
7
|
+
// References window/document — never imported plugin-side.
|
|
8
|
+
|
|
9
|
+
export type ActionType = 'click' | 'fill' | 'press' | 'wait' | 'screenshot'
|
|
10
|
+
|
|
11
|
+
export interface ActionArgs {
|
|
12
|
+
sel?: string
|
|
13
|
+
text?: string
|
|
14
|
+
value?: string
|
|
15
|
+
key?: string
|
|
16
|
+
timeout?: number
|
|
17
|
+
gone?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ActionResult {
|
|
21
|
+
ok: boolean
|
|
22
|
+
detail?: string
|
|
23
|
+
error?: string
|
|
24
|
+
dataUrl?: string
|
|
25
|
+
ui?: string
|
|
26
|
+
screen?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const TYPES: ActionType[] = ['click', 'fill', 'press', 'wait', 'screenshot']
|
|
30
|
+
|
|
31
|
+
// --- Pure validation (plugin-side) ---
|
|
32
|
+
|
|
33
|
+
export function resolveAction(type: string, args: ActionArgs): { valid: boolean, error?: string } {
|
|
34
|
+
if (!TYPES.includes(type as ActionType))
|
|
35
|
+
return { valid: false, error: `unknown action: ${type}` }
|
|
36
|
+
|
|
37
|
+
const hasTarget = !!(args.sel || args.text)
|
|
38
|
+
switch (type) {
|
|
39
|
+
case 'click':
|
|
40
|
+
return hasTarget ? { valid: true } : { valid: false, error: 'click needs sel= or text=' }
|
|
41
|
+
case 'fill':
|
|
42
|
+
if (!hasTarget)
|
|
43
|
+
return { valid: false, error: 'fill needs sel= or text=' }
|
|
44
|
+
if (args.value === undefined)
|
|
45
|
+
return { valid: false, error: 'fill needs value=' }
|
|
46
|
+
return { valid: true }
|
|
47
|
+
case 'press':
|
|
48
|
+
return args.key ? { valid: true } : { valid: false, error: 'press needs key=' }
|
|
49
|
+
case 'wait':
|
|
50
|
+
return hasTarget ? { valid: true } : { valid: false, error: 'wait needs sel= or text=' }
|
|
51
|
+
case 'screenshot':
|
|
52
|
+
return { valid: true }
|
|
53
|
+
default:
|
|
54
|
+
return { valid: false, error: `unknown action: ${type}` }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// --- Browser-side execution (client.ts only) ---
|
|
59
|
+
|
|
60
|
+
export const INTERACTIVE = 'a, button, input, textarea, select, [role], [onclick], [tabindex], [contenteditable]'
|
|
61
|
+
|
|
62
|
+
function visibleText(el: Element): string {
|
|
63
|
+
const aria = el.getAttribute('aria-label')
|
|
64
|
+
if (aria)
|
|
65
|
+
return aria
|
|
66
|
+
return (el.textContent || '').trim()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isVisible(el: Element): boolean {
|
|
70
|
+
const r = el.getBoundingClientRect()
|
|
71
|
+
return r.width > 0 && r.height > 0
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function findElement(sel?: string, text?: string): Element | null {
|
|
75
|
+
if (sel) {
|
|
76
|
+
try {
|
|
77
|
+
return document.querySelector(sel)
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
throw new Error(`invalid selector: ${sel} — URL-encode it (curl -G --data-urlencode 'sel=...')`)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (text) {
|
|
84
|
+
const lower = text.toLowerCase()
|
|
85
|
+
const candidates = Array.from(document.querySelectorAll(INTERACTIVE))
|
|
86
|
+
return candidates.find(el => isVisible(el) && visibleText(el).toLowerCase().includes(lower)) || null
|
|
87
|
+
}
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function clickableList(): string {
|
|
92
|
+
// Clip to the open modal's subtree when present — the layer beneath is
|
|
93
|
+
// unreachable, so listing it (e.g. 90 chat items) is pure noise.
|
|
94
|
+
const root = document.querySelector('[role="dialog"][data-state="open"]') ?? document
|
|
95
|
+
const els = Array.from(root.querySelectorAll(INTERACTIVE))
|
|
96
|
+
.filter(isVisible)
|
|
97
|
+
.map(el => visibleText(el).slice(0, 40))
|
|
98
|
+
.filter(Boolean)
|
|
99
|
+
return [...new Set(els)].slice(0, 30).join(' | ')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function performAction(type: ActionType, args: ActionArgs): Promise<ActionResult> {
|
|
103
|
+
try {
|
|
104
|
+
switch (type) {
|
|
105
|
+
case 'click': return doClick(args)
|
|
106
|
+
case 'fill': return doFill(args)
|
|
107
|
+
case 'press': return doPress(args)
|
|
108
|
+
case 'wait': return await doWait(args)
|
|
109
|
+
case 'screenshot': return await doScreenshot(args)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Click like a human, not like el.click(). A real click is a *position* the browser
|
|
118
|
+
// hit-tests, then a full event sequence at that point — hover, pointerdown/mousedown,
|
|
119
|
+
// browser-decided focus, pointerup/mouseup, click. Two things matter that el.click()
|
|
120
|
+
// skips entirely:
|
|
121
|
+
// 1. The target is whatever sits at (x,y) per elementFromPoint — so overlays,
|
|
122
|
+
// portals, and pointer-events:none are honored exactly as a mouse would.
|
|
123
|
+
// 2. mousedown is cancelable and moves focus; if a handler preventDefault()s it,
|
|
124
|
+
// focus does NOT move (radix triggers rely on this). We replicate that contract.
|
|
125
|
+
// This is what makes aipeek catch focus/dismiss bugs that synthetic clicks hide.
|
|
126
|
+
const MOUSE_SEQUENCE = ['pointerover', 'pointerenter', 'mouseover', 'pointermove'] as const
|
|
127
|
+
|
|
128
|
+
function focusableAncestor(el: Element | null): HTMLElement | null {
|
|
129
|
+
return el?.closest<HTMLElement>('button, a[href], input, textarea, select, [tabindex], [contenteditable=""], [contenteditable="true"]') ?? null
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function realClickAt(x: number, y: number): Element | null {
|
|
133
|
+
// The element a real mouse would hit at this point — not necessarily the one we
|
|
134
|
+
// searched for (it may be covered, or be a portal target).
|
|
135
|
+
const target = (document.elementFromPoint(x, y) as HTMLElement | null) ?? document.body
|
|
136
|
+
const base = { bubbles: true, cancelable: true, composed: true, clientX: x, clientY: y, view: window, button: 0, detail: 1 }
|
|
137
|
+
const ptr = { ...base, pointerId: 1, pointerType: 'mouse', isPrimary: true, width: 1, height: 1 }
|
|
138
|
+
|
|
139
|
+
for (const type of MOUSE_SEQUENCE)
|
|
140
|
+
target.dispatchEvent(type.startsWith('pointer') ? new PointerEvent(type, ptr) : new MouseEvent(type, base))
|
|
141
|
+
|
|
142
|
+
target.dispatchEvent(new PointerEvent('pointerdown', { ...ptr, buttons: 1 }))
|
|
143
|
+
const mousedownLive = target.dispatchEvent(new MouseEvent('mousedown', { ...base, buttons: 1 }))
|
|
144
|
+
|
|
145
|
+
// Browser focus rule: mousedown moves focus to the nearest focusable ancestor,
|
|
146
|
+
// UNLESS a handler called preventDefault() on mousedown. Match that exactly —
|
|
147
|
+
// this is the contract radix's PopoverTrigger depends on.
|
|
148
|
+
if (mousedownLive) {
|
|
149
|
+
const focusable = focusableAncestor(target)
|
|
150
|
+
if (focusable && document.activeElement !== focusable)
|
|
151
|
+
focusable.focus()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
target.dispatchEvent(new PointerEvent('pointerup', { ...ptr, buttons: 0 }))
|
|
155
|
+
target.dispatchEvent(new MouseEvent('mouseup', { ...base, buttons: 0 }))
|
|
156
|
+
target.dispatchEvent(new MouseEvent('click', base))
|
|
157
|
+
return target
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function realClick(el: HTMLElement): Element | null {
|
|
161
|
+
const r = el.getBoundingClientRect()
|
|
162
|
+
return realClickAt(r.left + r.width / 2, r.top + r.height / 2)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function doClick(args: ActionArgs): ActionResult {
|
|
166
|
+
const el = findElement(args.sel, args.text)
|
|
167
|
+
if (!el)
|
|
168
|
+
return { ok: false, error: `no element for ${args.sel || args.text}`, detail: clickableList() }
|
|
169
|
+
const hit = realClick(el as HTMLElement)
|
|
170
|
+
// Report when the mouse actually landed on something else (overlay/portal) — a
|
|
171
|
+
// common cause of "I clicked X but nothing happened".
|
|
172
|
+
const note = hit && hit !== el && !el.contains(hit) && !hit.contains(el) ? ` (hit <${hit.tagName.toLowerCase()}> on top)` : ''
|
|
173
|
+
return { ok: true, detail: `clicked ${visibleText(el).slice(0, 40) || el.tagName.toLowerCase()}${note}` }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function doFill(args: ActionArgs): ActionResult {
|
|
177
|
+
const el = findElement(args.sel, args.text)
|
|
178
|
+
if (!el)
|
|
179
|
+
return { ok: false, error: `no element for ${args.sel || args.text}`, detail: clickableList() }
|
|
180
|
+
const value = args.value ?? ''
|
|
181
|
+
const html = el as HTMLElement
|
|
182
|
+
html.focus()
|
|
183
|
+
|
|
184
|
+
if (html.isContentEditable) {
|
|
185
|
+
// ProseMirror/tiptap listen on beforeinput — select-all then insertText
|
|
186
|
+
const sel = window.getSelection()
|
|
187
|
+
const range = document.createRange()
|
|
188
|
+
range.selectNodeContents(html)
|
|
189
|
+
sel?.removeAllRanges()
|
|
190
|
+
sel?.addRange(range)
|
|
191
|
+
document.execCommand('insertText', false, value)
|
|
192
|
+
if (html.textContent !== value && !value.includes(html.textContent ?? '\0')) {
|
|
193
|
+
// execCommand may be a no-op under some setups — fall back to direct text + input event
|
|
194
|
+
html.textContent = value
|
|
195
|
+
html.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value }))
|
|
196
|
+
}
|
|
197
|
+
return { ok: true, detail: `filled contenteditable, ${value.length} chars` }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const input = el as HTMLInputElement
|
|
201
|
+
input.value = value
|
|
202
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
203
|
+
input.dispatchEvent(new Event('change', { bubbles: true }))
|
|
204
|
+
return { ok: true, detail: `filled ${value.length} chars` }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function doPress(args: ActionArgs): ActionResult {
|
|
208
|
+
const parts = (args.key ?? '').split('+')
|
|
209
|
+
const key = parts[parts.length - 1]
|
|
210
|
+
const mods = parts.slice(0, -1).map(m => m.toLowerCase())
|
|
211
|
+
const init: KeyboardEventInit = {
|
|
212
|
+
key,
|
|
213
|
+
bubbles: true,
|
|
214
|
+
cancelable: true,
|
|
215
|
+
ctrlKey: mods.includes('control') || mods.includes('ctrl'),
|
|
216
|
+
shiftKey: mods.includes('shift'),
|
|
217
|
+
altKey: mods.includes('alt'),
|
|
218
|
+
metaKey: mods.includes('meta') || mods.includes('cmd'),
|
|
219
|
+
}
|
|
220
|
+
const target = (document.activeElement as HTMLElement) || document.body
|
|
221
|
+
target.dispatchEvent(new KeyboardEvent('keydown', init))
|
|
222
|
+
target.dispatchEvent(new KeyboardEvent('keyup', init))
|
|
223
|
+
return { ok: true, detail: `pressed ${args.key} on ${target.tagName.toLowerCase()}` }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function doWait(args: ActionArgs): Promise<ActionResult> {
|
|
227
|
+
const timeout = args.timeout ?? 5000
|
|
228
|
+
const start = performance.now()
|
|
229
|
+
const verb = args.gone ? 'disappeared' : 'appeared'
|
|
230
|
+
return new Promise((resolve) => {
|
|
231
|
+
const tick = () => {
|
|
232
|
+
const present = !!findElement(args.sel, args.text)
|
|
233
|
+
if (present !== !!args.gone) {
|
|
234
|
+
resolve({ ok: true, detail: `${verb} after ${Math.round(performance.now() - start)}ms` })
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
if (performance.now() - start > timeout) {
|
|
238
|
+
resolve({ ok: false, error: `timeout ${timeout}ms waiting for ${args.sel || args.text} to ${args.gone ? 'disappear' : 'appear'}` })
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
setTimeout(tick, 100)
|
|
242
|
+
}
|
|
243
|
+
tick()
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function doScreenshot(args: ActionArgs): Promise<ActionResult> {
|
|
248
|
+
const target = args.sel ? document.querySelector(args.sel) : document.body
|
|
249
|
+
if (!target)
|
|
250
|
+
return { ok: false, error: `no element for ${args.sel}` }
|
|
251
|
+
const { toPng } = await import('html-to-image')
|
|
252
|
+
// html-to-image re-fetches every <img> to inline it; one cross-origin or
|
|
253
|
+
// rate-limited (429) image rejects the whole render. Drop external/broken
|
|
254
|
+
// images from the capture — a screenshot minus a few avatars beats no screenshot.
|
|
255
|
+
const sameOrigin = (src: string) => {
|
|
256
|
+
try {
|
|
257
|
+
return new URL(src, location.href).origin === location.origin
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return false
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const filter = (n: HTMLElement) => !(n instanceof HTMLImageElement && (!n.complete || n.naturalWidth === 0 || !sameOrigin(n.src)))
|
|
264
|
+
try {
|
|
265
|
+
const dataUrl = await toPng(target as HTMLElement, { filter, imagePlaceholder: 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==' })
|
|
266
|
+
return { ok: true, dataUrl, detail: `${target.tagName.toLowerCase()} captured` }
|
|
267
|
+
}
|
|
268
|
+
catch (e) {
|
|
269
|
+
const msg = e instanceof Event ? `image load failed (${(e.target as HTMLImageElement)?.src?.slice(0, 80) || 'unknown'})` : e instanceof Error ? e.message : String(e)
|
|
270
|
+
return { ok: false, error: `screenshot failed: ${msg}` }
|
|
271
|
+
}
|
|
272
|
+
}
|
|
@@ -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
|
+
}
|