aipeek 0.2.5 → 0.2.7
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/README.md +2 -1
- package/dist/{chunk-X3HAXWFJ.js → chunk-37VLLZIU.js} +176 -98
- package/dist/{chunk-6EZKMGRD.cjs → chunk-STYCUT23.cjs} +183 -105
- package/dist/index.cjs +2 -2
- package/dist/index.js +1 -1
- package/dist/plugin.cjs +4 -2
- package/dist/plugin.js +5 -3
- package/package.json +2 -1
- package/src/client/client-patch.ts +35 -3
- package/src/client/client.ts +19 -18
- package/src/core/action.ts +136 -4
- package/src/core/compact.ts +4 -30
- package/src/core/detail.ts +10 -26
- package/src/core/util.ts +53 -0
- package/src/server/plugin.ts +146 -56
package/src/client/client.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import type { ActionArgs, ActionType } from '../core/action'
|
|
7
7
|
import type { ErrorEntry, LogEntry, NetworkRequest } from '../core/types'
|
|
8
|
-
import { INTERACTIVE, performAction } from '../core/action'
|
|
8
|
+
import { INTERACTIVE, performAction, runEval, withDialogGuard } from '../core/action'
|
|
9
9
|
|
|
10
10
|
declare global {
|
|
11
11
|
interface Window { __AIPEEK_STORES__?: Record<string, unknown> }
|
|
@@ -643,35 +643,36 @@ if (import.meta.hot) {
|
|
|
643
643
|
import.meta.hot.on('aipeek:action', async (msg: { id: number, type: ActionType, args: ActionArgs, requireVisible?: boolean }) => {
|
|
644
644
|
if (skip(msg))
|
|
645
645
|
return
|
|
646
|
-
|
|
646
|
+
// Guard against native alert/confirm/prompt freezing the probe (see withDialogGuard).
|
|
647
|
+
const { result, dialogs } = await withDialogGuard(() => performAction(msg.type, msg.args))
|
|
648
|
+
if (dialogs.length)
|
|
649
|
+
result.detail = `${result.detail ?? ''} [auto-dismissed ${dialogs.join('; ')}]`.trim()
|
|
650
|
+
// realclick resolved to (x,y) but didn't click — synthetic events can't open a Radix
|
|
651
|
+
// ContextMenu. Fire a trusted click through whatever channel can: in Electron the page
|
|
652
|
+
// can reach the main process via electronAPI.invoke('aipeek:input') → sendInputEvent;
|
|
653
|
+
// in a plain Chrome tab it can't (no chrome.debugger from page JS), so leave ui
|
|
654
|
+
// undefined and let the server drive its extension queue.
|
|
655
|
+
const electronAPI = (window as { electronAPI?: { invoke: (channel: string, ...args: unknown[]) => Promise<unknown> } }).electronAPI
|
|
656
|
+
if (msg.type === 'realclick' && result.ok && electronAPI) {
|
|
657
|
+
await electronAPI.invoke('aipeek:input', { type: 'click', button: msg.args.button ?? 'left', x: result.x, y: result.y })
|
|
658
|
+
result.ui = await waitForStable()
|
|
659
|
+
result.screen = collectScreen()
|
|
660
|
+
}
|
|
647
661
|
// For mutating actions, settle the DOM then ship both the full UI tree and
|
|
648
662
|
// the compact screen projection — the caller skips a round-trip to /ui, and
|
|
649
663
|
// /chain uses the per-step screen so an interaction's every transition shows.
|
|
650
|
-
if (result.ok && (msg.type === 'click' || msg.type === 'fill' || msg.type === 'press')) {
|
|
664
|
+
else if (result.ok && (msg.type === 'click' || msg.type === 'fill' || msg.type === 'press')) {
|
|
651
665
|
result.ui = await waitForStable()
|
|
652
666
|
result.screen = collectScreen()
|
|
653
667
|
}
|
|
654
668
|
import.meta.hot!.send('aipeek:result', { id: msg.id, ...result })
|
|
655
669
|
})
|
|
656
670
|
|
|
657
|
-
// eval: run server-supplied code in the page
|
|
658
|
-
// code can `await` and use `return`; non-string results are JSON-stringified.
|
|
671
|
+
// eval: run server-supplied code in the page with auto-return (see runEval).
|
|
659
672
|
import.meta.hot.on('aipeek:eval', async (msg: { id: number, code: string, requireVisible?: boolean }) => {
|
|
660
673
|
if (skip(msg))
|
|
661
674
|
return
|
|
662
|
-
|
|
663
|
-
let value: string | undefined
|
|
664
|
-
let error: string | undefined
|
|
665
|
-
try {
|
|
666
|
-
// eslint-disable-next-line no-new-func
|
|
667
|
-
const fn = new Function(`return (async () => { ${msg.code} })()`)
|
|
668
|
-
const result = await fn()
|
|
669
|
-
value = typeof result === 'string' ? result : JSON.stringify(result, null, 2)
|
|
670
|
-
}
|
|
671
|
-
catch (e) {
|
|
672
|
-
ok = false
|
|
673
|
-
error = e instanceof Error ? `${e.message}\n${e.stack ?? ''}` : String(e)
|
|
674
|
-
}
|
|
675
|
+
const { ok, value, error } = await runEval(msg.code)
|
|
675
676
|
import.meta.hot!.send('aipeek:eval-result', { id: msg.id, ok, value, error })
|
|
676
677
|
})
|
|
677
678
|
|
package/src/core/action.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// - performAction(): real DOM mutation. Runs browser-side (client.ts imports it).
|
|
7
7
|
// References window/document — never imported plugin-side.
|
|
8
8
|
|
|
9
|
-
export type ActionType = 'click' | 'fill' | 'press' | 'wait' | 'screenshot'
|
|
9
|
+
export type ActionType = 'click' | 'fill' | 'press' | 'wait' | 'screenshot' | 'realclick' | 'query'
|
|
10
10
|
|
|
11
11
|
export interface ActionArgs {
|
|
12
12
|
sel?: string
|
|
@@ -15,6 +15,9 @@ export interface ActionArgs {
|
|
|
15
15
|
key?: string
|
|
16
16
|
timeout?: number
|
|
17
17
|
gone?: boolean
|
|
18
|
+
button?: 'left' | 'right'
|
|
19
|
+
x?: number
|
|
20
|
+
y?: number
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
export interface ActionResult {
|
|
@@ -24,9 +27,11 @@ export interface ActionResult {
|
|
|
24
27
|
dataUrl?: string
|
|
25
28
|
ui?: string
|
|
26
29
|
screen?: string
|
|
30
|
+
x?: number
|
|
31
|
+
y?: number
|
|
27
32
|
}
|
|
28
33
|
|
|
29
|
-
const TYPES: ActionType[] = ['click', 'fill', 'press', 'wait', 'screenshot']
|
|
34
|
+
const TYPES: ActionType[] = ['click', 'fill', 'press', 'wait', 'screenshot', 'realclick', 'query']
|
|
30
35
|
|
|
31
36
|
// --- Pure validation (plugin-side) ---
|
|
32
37
|
|
|
@@ -38,6 +43,8 @@ export function resolveAction(type: string, args: ActionArgs): { valid: boolean,
|
|
|
38
43
|
switch (type) {
|
|
39
44
|
case 'click':
|
|
40
45
|
return hasTarget ? { valid: true } : { valid: false, error: 'click needs sel= or text=' }
|
|
46
|
+
case 'realclick':
|
|
47
|
+
return hasTarget || (args.x !== undefined && args.y !== undefined) ? { valid: true } : { valid: false, error: 'realclick needs sel=, text=, or x= & y=' }
|
|
41
48
|
case 'fill':
|
|
42
49
|
if (!hasTarget)
|
|
43
50
|
return { valid: false, error: 'fill needs sel= or text=' }
|
|
@@ -50,6 +57,8 @@ export function resolveAction(type: string, args: ActionArgs): { valid: boolean,
|
|
|
50
57
|
return hasTarget ? { valid: true } : { valid: false, error: 'wait needs sel= or text=' }
|
|
51
58
|
case 'screenshot':
|
|
52
59
|
return { valid: true }
|
|
60
|
+
case 'query':
|
|
61
|
+
return args.sel ? { valid: true } : { valid: false, error: 'query needs sel=' }
|
|
53
62
|
default:
|
|
54
63
|
return { valid: false, error: `unknown action: ${type}` }
|
|
55
64
|
}
|
|
@@ -57,7 +66,7 @@ export function resolveAction(type: string, args: ActionArgs): { valid: boolean,
|
|
|
57
66
|
|
|
58
67
|
// --- Browser-side execution (client.ts only) ---
|
|
59
68
|
|
|
60
|
-
export const INTERACTIVE = 'a, button, input, textarea, select, [role], [onclick], [tabindex], [contenteditable]'
|
|
69
|
+
export const INTERACTIVE = 'a, button, input, textarea, select, [role], [onclick], [tabindex], [contenteditable], [aria-label]'
|
|
61
70
|
|
|
62
71
|
function visibleText(el: Element): string {
|
|
63
72
|
const aria = el.getAttribute('aria-label')
|
|
@@ -103,10 +112,12 @@ export async function performAction(type: ActionType, args: ActionArgs): Promise
|
|
|
103
112
|
try {
|
|
104
113
|
switch (type) {
|
|
105
114
|
case 'click': return doClick(args)
|
|
115
|
+
case 'realclick': return doResolveRealClick(args)
|
|
106
116
|
case 'fill': return doFill(args)
|
|
107
117
|
case 'press': return doPress(args)
|
|
108
118
|
case 'wait': return await doWait(args)
|
|
109
119
|
case 'screenshot': return await doScreenshot(args)
|
|
120
|
+
case 'query': return doQuery(args)
|
|
110
121
|
}
|
|
111
122
|
}
|
|
112
123
|
catch (e) {
|
|
@@ -114,6 +125,59 @@ export async function performAction(type: ActionType, args: ActionArgs): Promise
|
|
|
114
125
|
}
|
|
115
126
|
}
|
|
116
127
|
|
|
128
|
+
export interface EvalResult { ok: boolean, value?: string, error?: string }
|
|
129
|
+
|
|
130
|
+
// Run server-supplied JS in the page with auto-return (Chrome-console / Node-REPL
|
|
131
|
+
// ergonomics): try the whole code as a single expression first so `qsa(...).length`
|
|
132
|
+
// or `1+1` yield a value without an explicit `return`. If that fails to *compile*
|
|
133
|
+
// (multi-statement, contains `return`, etc.) fall back to a plain statement block.
|
|
134
|
+
// A compile error only swaps the wrapper; a runtime throw surfaces as the error.
|
|
135
|
+
// undefined results are dropped (no value); objects are JSON-stringified.
|
|
136
|
+
export async function runEval(code: string): Promise<EvalResult> {
|
|
137
|
+
try {
|
|
138
|
+
let fn: () => Promise<unknown>
|
|
139
|
+
try {
|
|
140
|
+
// eslint-disable-next-line no-new-func
|
|
141
|
+
fn = new Function(`return (async () => (${code}))()`) as () => Promise<unknown>
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// eslint-disable-next-line no-new-func
|
|
145
|
+
fn = new Function(`return (async () => { ${code} })()`) as () => Promise<unknown>
|
|
146
|
+
}
|
|
147
|
+
const result = await fn()
|
|
148
|
+
const value = result === undefined ? undefined : typeof result === 'string' ? result : JSON.stringify(result, null, 2)
|
|
149
|
+
return { ok: true, value }
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
return { ok: false, error: e instanceof Error ? `${e.message}\n${e.stack ?? ''}` : String(e) }
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Native alert/confirm/prompt are *synchronous* and freeze the whole JS thread until
|
|
157
|
+
// a human dismisses them — which deadlocks the probe (it runs on that same thread, so
|
|
158
|
+
// the HMR channel can never answer and every curl times out). A click that hits a
|
|
159
|
+
// `copy-to-clipboard` fallback or a `confirm("delete?")` would hang aipeek forever.
|
|
160
|
+
// So we stub them for the duration of `fn`: auto-answer (confirm→true, prompt→default,
|
|
161
|
+
// alert→noop) and return what was suppressed so the caller can report it. Always
|
|
162
|
+
// restored in finally — the page's own dialogs work again after the action settles.
|
|
163
|
+
export async function withDialogGuard<T>(fn: () => Promise<T>): Promise<{ result: T, dialogs: string[] }> {
|
|
164
|
+
const realAlert = window.alert
|
|
165
|
+
const realConfirm = window.confirm
|
|
166
|
+
const realPrompt = window.prompt
|
|
167
|
+
const dialogs: string[] = []
|
|
168
|
+
window.alert = (m?: unknown) => { dialogs.push(`alert: ${String(m ?? '')}`.slice(0, 80)) }
|
|
169
|
+
window.confirm = (m?: unknown) => { dialogs.push(`confirm→true: ${String(m ?? '')}`.slice(0, 80)); return true }
|
|
170
|
+
window.prompt = (m?: unknown, d?: string) => { dialogs.push(`prompt→default: ${String(m ?? '')}`.slice(0, 80)); return d ?? '' }
|
|
171
|
+
try {
|
|
172
|
+
return { result: await fn(), dialogs }
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
window.alert = realAlert
|
|
176
|
+
window.confirm = realConfirm
|
|
177
|
+
window.prompt = realPrompt
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
117
181
|
// Click like a human, not like el.click(). A real click is a *position* the browser
|
|
118
182
|
// hit-tests, then a full event sequence at that point — hover, pointerdown/mousedown,
|
|
119
183
|
// browser-decided focus, pointerup/mouseup, click. Two things matter that el.click()
|
|
@@ -173,6 +237,24 @@ function doClick(args: ActionArgs): ActionResult {
|
|
|
173
237
|
return { ok: true, detail: `clicked ${visibleText(el).slice(0, 40) || el.tagName.toLowerCase()}${note}` }
|
|
174
238
|
}
|
|
175
239
|
|
|
240
|
+
// realclick resolves an element to a viewport-center (x,y) but does NOT synthetic-click.
|
|
241
|
+
// The trusted click is fired by whichever channel can produce isTrusted=true input:
|
|
242
|
+
// Electron's webContents.sendInputEvent (via electronAPI.invoke, in client.ts) or a Chrome
|
|
243
|
+
// extension's chrome.debugger (via the server's CDP queue). Synthetic events can't open a
|
|
244
|
+
// Radix ContextMenu — that's the whole reason this path exists. When x= & y= are given
|
|
245
|
+
// directly we pass them through unchanged.
|
|
246
|
+
function doResolveRealClick(args: ActionArgs): ActionResult {
|
|
247
|
+
if (args.x !== undefined && args.y !== undefined)
|
|
248
|
+
return { ok: true, x: args.x, y: args.y, detail: `resolved (${args.x}, ${args.y})` }
|
|
249
|
+
const el = findElement(args.sel, args.text)
|
|
250
|
+
if (!el)
|
|
251
|
+
return { ok: false, error: `no element for ${args.sel || args.text}`, detail: clickableList() }
|
|
252
|
+
const r = el.getBoundingClientRect()
|
|
253
|
+
const x = Math.round(r.left + r.width / 2)
|
|
254
|
+
const y = Math.round(r.top + r.height / 2)
|
|
255
|
+
return { ok: true, x, y, detail: `resolved ${visibleText(el).slice(0, 40) || el.tagName.toLowerCase()} at (${x}, ${y})` }
|
|
256
|
+
}
|
|
257
|
+
|
|
176
258
|
function doFill(args: ActionArgs): ActionResult {
|
|
177
259
|
const el = findElement(args.sel, args.text)
|
|
178
260
|
if (!el)
|
|
@@ -197,8 +279,15 @@ function doFill(args: ActionArgs): ActionResult {
|
|
|
197
279
|
return { ok: true, detail: `filled contenteditable, ${value.length} chars` }
|
|
198
280
|
}
|
|
199
281
|
|
|
282
|
+
// React overrides the value setter on the element *instance* to track changes; a plain
|
|
283
|
+
// `input.value = x` writes through it so React's tracker never sees a diff and onChange
|
|
284
|
+
// never fires (controlled inputs stay empty). Call the *prototype* setter instead — the
|
|
285
|
+
// tracker observes the change and the synthetic onChange fires.
|
|
200
286
|
const input = el as HTMLInputElement
|
|
201
|
-
|
|
287
|
+
const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype
|
|
288
|
+
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set
|
|
289
|
+
if (setter) setter.call(input, value)
|
|
290
|
+
else input.value = value
|
|
202
291
|
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
203
292
|
input.dispatchEvent(new Event('change', { bubbles: true }))
|
|
204
293
|
return { ok: true, detail: `filled ${value.length} chars` }
|
|
@@ -270,3 +359,46 @@ async function doScreenshot(args: ActionArgs): Promise<ActionResult> {
|
|
|
270
359
|
return { ok: false, error: `screenshot failed: ${msg}` }
|
|
271
360
|
}
|
|
272
361
|
}
|
|
362
|
+
|
|
363
|
+
// The read-side twin of click/fill's `sel=`: instead of acting on the matched
|
|
364
|
+
// element, report the facts you'd otherwise reach for via /eval — count of
|
|
365
|
+
// matches, and per-element text / visible / assertion-relevant attrs. /wait
|
|
366
|
+
// answers "appears over time"; query answers "what is it now". Attrs are a
|
|
367
|
+
// whitelist (role, data-state, data-*, aria-*, value, disabled, checked, href,
|
|
368
|
+
// title) — a node's full class/style set is noise. Capped at 20 matches.
|
|
369
|
+
const QUERY_ATTRS = ['role', 'data-state', 'value', 'href', 'title']
|
|
370
|
+
const QUERY_PREFIXES = ['data-', 'aria-']
|
|
371
|
+
|
|
372
|
+
function elAttrs(el: Element): Record<string, string> {
|
|
373
|
+
const out: Record<string, string> = {}
|
|
374
|
+
for (const attr of Array.from(el.attributes)) {
|
|
375
|
+
if (QUERY_ATTRS.includes(attr.name) || QUERY_PREFIXES.some(p => attr.name.startsWith(p)))
|
|
376
|
+
out[attr.name] = attr.value
|
|
377
|
+
}
|
|
378
|
+
const input = el as HTMLInputElement
|
|
379
|
+
if (typeof input.value === 'string' && out.value === undefined && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT'))
|
|
380
|
+
out.value = input.value
|
|
381
|
+
if (input.disabled)
|
|
382
|
+
out.disabled = 'true'
|
|
383
|
+
if (input.checked)
|
|
384
|
+
out.checked = 'true'
|
|
385
|
+
return out
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function doQuery(args: ActionArgs): ActionResult {
|
|
389
|
+
let els: Element[]
|
|
390
|
+
try {
|
|
391
|
+
els = Array.from(document.querySelectorAll(args.sel!))
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
return { ok: false, error: `invalid selector: ${args.sel} — URL-encode it (curl -G --data-urlencode 'sel=...')` }
|
|
395
|
+
}
|
|
396
|
+
const count = els.length
|
|
397
|
+
const matches = els.slice(0, 20).map(el => ({
|
|
398
|
+
text: visibleText(el).slice(0, 80),
|
|
399
|
+
visible: isVisible(el),
|
|
400
|
+
attrs: elAttrs(el),
|
|
401
|
+
}))
|
|
402
|
+
const head = count > 20 ? `(showing 20 of ${count})\n` : ''
|
|
403
|
+
return { ok: true, detail: head + JSON.stringify({ count, matches }, null, 2) }
|
|
404
|
+
}
|
package/src/core/compact.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CompactState, ErrorEntry, LogEntry, NetworkRequest, RawState } from './types'
|
|
2
|
-
import { compactUrl, truncate } from './util'
|
|
2
|
+
import { appStackFrames, compactUrl, formatValue, truncate } from './util'
|
|
3
3
|
|
|
4
4
|
const SLOW_THRESHOLD = 1000
|
|
5
5
|
|
|
@@ -262,26 +262,14 @@ export function compactErrors(errors: ErrorEntry[]): string {
|
|
|
262
262
|
for (const err of seen.values()) {
|
|
263
263
|
lines.push(err.message)
|
|
264
264
|
if (err.stack) {
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
lines.push(` at ${frame}`)
|
|
268
|
-
}
|
|
265
|
+
for (const frame of appStackFrames(err.stack, 5))
|
|
266
|
+
lines.push(` ${frame}`)
|
|
269
267
|
}
|
|
270
268
|
}
|
|
271
269
|
|
|
272
270
|
return lines.join('\n')
|
|
273
271
|
}
|
|
274
272
|
|
|
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
273
|
// --- State ---
|
|
286
274
|
|
|
287
275
|
export function compactState(state: Record<string, unknown>): string {
|
|
@@ -293,7 +281,7 @@ export function compactState(state: Record<string, unknown>): string {
|
|
|
293
281
|
lines.push(`${name}:`)
|
|
294
282
|
if (typeof value === 'object' && value !== null) {
|
|
295
283
|
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
296
|
-
lines.push(` ${k}: ${formatValue(v)}`)
|
|
284
|
+
lines.push(` ${k}: ${truncate(formatValue(v), 120)}`)
|
|
297
285
|
}
|
|
298
286
|
}
|
|
299
287
|
else {
|
|
@@ -303,20 +291,6 @@ export function compactState(state: Record<string, unknown>): string {
|
|
|
303
291
|
return lines.join('\n')
|
|
304
292
|
}
|
|
305
293
|
|
|
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
294
|
// --- Main ---
|
|
321
295
|
|
|
322
296
|
export function compact(raw: RawState): CompactState {
|
package/src/core/detail.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ErrorEntry, LogEntry, NetworkRequest, RawState } from './types'
|
|
2
2
|
import { compactUI } from './compact'
|
|
3
|
-
import { truncate } from './util'
|
|
3
|
+
import { appStackFrames, formatValue, truncate } from './util'
|
|
4
4
|
|
|
5
5
|
export function detail(raw: RawState, section: string, index: string | undefined, full: boolean): string | null {
|
|
6
6
|
switch (section) {
|
|
@@ -130,15 +130,10 @@ function detailError(errors: ErrorEntry[], index: string | undefined, full: bool
|
|
|
130
130
|
|
|
131
131
|
const lines = [err.message]
|
|
132
132
|
if (err.stack) {
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
.
|
|
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`)
|
|
133
|
+
const all = appStackFrames(err.stack, Infinity)
|
|
134
|
+
lines.push(...all.slice(0, 3))
|
|
135
|
+
if (all.length > 3)
|
|
136
|
+
lines.push(` ... ${all.length - 3} more app frames`)
|
|
142
137
|
}
|
|
143
138
|
if (err.line != null)
|
|
144
139
|
lines.push(`location: ${err.source || ''}:${err.line}:${err.column ?? 0}`)
|
|
@@ -159,10 +154,10 @@ function detailState(state: Record<string, unknown>, name: string | undefined, f
|
|
|
159
154
|
const value = state[name]
|
|
160
155
|
if (full) {
|
|
161
156
|
try {
|
|
162
|
-
return JSON.stringify(value, null, 2)
|
|
157
|
+
return JSON.stringify(value, null, 2) ?? formatValue(value)
|
|
163
158
|
}
|
|
164
159
|
catch {
|
|
165
|
-
return
|
|
160
|
+
return formatValue(value) // 循环引用 / Error / Map → 不再 {}
|
|
166
161
|
}
|
|
167
162
|
}
|
|
168
163
|
if (typeof value !== 'object' || value === null)
|
|
@@ -182,20 +177,9 @@ function isArraySentinel(v: string): boolean {
|
|
|
182
177
|
}
|
|
183
178
|
|
|
184
179
|
function formatSummaryValue(v: unknown): string {
|
|
185
|
-
if (v ===
|
|
186
|
-
return
|
|
187
|
-
|
|
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)
|
|
180
|
+
if (typeof v === 'string' && isArraySentinel(v)) // "Array(N)" from boundedSnapshot — leave untruncated
|
|
181
|
+
return v
|
|
182
|
+
return truncate(formatValue(v), 80)
|
|
199
183
|
}
|
|
200
184
|
|
|
201
185
|
// --- JSON schema fallback ---
|
package/src/core/util.ts
CHANGED
|
@@ -2,6 +2,59 @@ export function truncate(s: string, max: number): string {
|
|
|
2
2
|
return s.length > max ? `${s.slice(0, max)}…` : s
|
|
3
3
|
}
|
|
4
4
|
|
|
5
|
+
// unknown → 人类可读字符串。穷举 JS 类型,让 JSON.stringify 的「漏网类型 → {}」失败模式不可能出现。
|
|
6
|
+
// 返回未截断字符串——截断由调用方按各自 max 处理。
|
|
7
|
+
export function formatValue(v: unknown, seen: Set<object> = new Set()): string {
|
|
8
|
+
if (v === null || v === undefined)
|
|
9
|
+
return String(v)
|
|
10
|
+
const t = typeof v
|
|
11
|
+
if (t === 'string')
|
|
12
|
+
return v as string
|
|
13
|
+
if (t === 'number' || t === 'boolean' || t === 'bigint')
|
|
14
|
+
return String(v)
|
|
15
|
+
if (t === 'symbol')
|
|
16
|
+
return (v as symbol).toString()
|
|
17
|
+
if (t === 'function')
|
|
18
|
+
return `[Function: ${(v as { name?: string }).name || 'anonymous'}]`
|
|
19
|
+
// 此后 v 是 object
|
|
20
|
+
const obj = v as object
|
|
21
|
+
if (seen.has(obj))
|
|
22
|
+
return '[Circular]'
|
|
23
|
+
if (v instanceof Error)
|
|
24
|
+
return v.stack || `${v.name}: ${v.message}`
|
|
25
|
+
seen.add(obj)
|
|
26
|
+
if (v instanceof Map) {
|
|
27
|
+
const items = [...v.entries()].slice(0, 15).map(([k, val]) => `${formatValue(k, seen)} => ${formatValue(val, seen)}`)
|
|
28
|
+
return `Map(${v.size}) {${items.join(', ')}${v.size > 15 ? ', …' : ''}}`
|
|
29
|
+
}
|
|
30
|
+
if (v instanceof Set) {
|
|
31
|
+
const items = [...v.values()].slice(0, 15).map(val => formatValue(val, seen))
|
|
32
|
+
return `Set(${v.size}) {${items.join(', ')}${v.size > 15 ? ', …' : ''}}`
|
|
33
|
+
}
|
|
34
|
+
if (Array.isArray(v)) {
|
|
35
|
+
const items = v.slice(0, 30).map(val => formatValue(val, seen))
|
|
36
|
+
return `[${items.join(', ')}${v.length > 30 ? ', …' : ''}]`
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
return JSON.stringify(v)
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// 循环引用 / getter 抛错 → 手动遍历可枚举键,循环处标 [Circular]
|
|
43
|
+
const entries = Object.entries(v as Record<string, unknown>).slice(0, 15)
|
|
44
|
+
const parts = entries.map(([k, val]) => `${k}: ${formatValue(val, seen)}`)
|
|
45
|
+
return `{${parts.join(', ')}}`
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// stack → 应用栈帧(去 node_modules / <anonymous>),保留 `at ` 前缀。max 与溢出提示由调用方决定。
|
|
50
|
+
export function appStackFrames(stack: string, max: number): string[] {
|
|
51
|
+
return stack
|
|
52
|
+
.split('\n')
|
|
53
|
+
.map(l => l.trim())
|
|
54
|
+
.filter(l => l.startsWith('at ') && !l.includes('node_modules') && !l.includes('<anonymous>'))
|
|
55
|
+
.slice(0, max)
|
|
56
|
+
}
|
|
57
|
+
|
|
5
58
|
// pathname only; pass `search` to append a truncated query string
|
|
6
59
|
export function compactUrl(url: string, search?: number): string {
|
|
7
60
|
try {
|