aipeek 0.2.7 → 0.2.9

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.
@@ -6,7 +6,8 @@
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' | 'realclick' | 'query'
9
+ export type ActionType = 'click' | 'fill' | 'press' | 'wait' | 'screenshot' | 'realclick' | 'query' | 'assert'
10
+ | 'drag' | 'scrollIntoView' | 'drop' | 'clipboard'
10
11
 
11
12
  export interface ActionArgs {
12
13
  sel?: string
@@ -18,6 +19,14 @@ export interface ActionArgs {
18
19
  button?: 'left' | 'right'
19
20
  x?: number
20
21
  y?: number
22
+ // assert: a domain key (from __AIPEEK_SCREEN__) or a CSS sel, checked against `equals`.
23
+ screen?: string
24
+ equals?: string
25
+ // drag: sel/text = source, to = destination selector. drop: sel = target, files = names.
26
+ to?: string
27
+ files?: string[]
28
+ // clipboard: mode read|write; value = text to write (write only).
29
+ mode?: 'read' | 'write'
21
30
  }
22
31
 
23
32
  export interface ActionResult {
@@ -25,13 +34,16 @@ export interface ActionResult {
25
34
  detail?: string
26
35
  error?: string
27
36
  dataUrl?: string
28
- ui?: string
29
37
  screen?: string
38
+ actions?: string
30
39
  x?: number
31
40
  y?: number
41
+ // realclick only: true if the page already fired the trusted click in-process (Electron).
42
+ // false/undefined means a plain Chrome tab couldn't, so the server drives its CDP queue.
43
+ fired?: boolean
32
44
  }
33
45
 
34
- const TYPES: ActionType[] = ['click', 'fill', 'press', 'wait', 'screenshot', 'realclick', 'query']
46
+ const TYPES: ActionType[] = ['click', 'fill', 'press', 'wait', 'screenshot', 'realclick', 'query', 'assert', 'drag', 'scrollIntoView', 'drop', 'clipboard']
35
47
 
36
48
  // --- Pure validation (plugin-side) ---
37
49
 
@@ -59,6 +71,30 @@ export function resolveAction(type: string, args: ActionArgs): { valid: boolean,
59
71
  return { valid: true }
60
72
  case 'query':
61
73
  return args.sel ? { valid: true } : { valid: false, error: 'query needs sel=' }
74
+ case 'assert':
75
+ if (!args.screen && !args.sel)
76
+ return { valid: false, error: 'assert needs screen= (a __AIPEEK_SCREEN__ key) or sel=' }
77
+ if (args.equals === undefined)
78
+ return { valid: false, error: 'assert needs equals=' }
79
+ return { valid: true }
80
+ case 'drag':
81
+ if (!hasTarget)
82
+ return { valid: false, error: 'drag needs sel= or text= (the source)' }
83
+ if (!args.to)
84
+ return { valid: false, error: 'drag needs to= (destination selector)' }
85
+ return { valid: true }
86
+ case 'scrollIntoView':
87
+ return hasTarget ? { valid: true } : { valid: false, error: 'scrollIntoView needs sel= or text=' }
88
+ case 'drop':
89
+ if (!hasTarget)
90
+ return { valid: false, error: 'drop needs sel= or text= (the drop target)' }
91
+ if (!args.files || !args.files.length)
92
+ return { valid: false, error: 'drop needs files= (array of file names)' }
93
+ return { valid: true }
94
+ case 'clipboard':
95
+ if (args.mode === 'write' && args.value === undefined)
96
+ return { valid: false, error: 'clipboard write needs value=' }
97
+ return { valid: true }
62
98
  default:
63
99
  return { valid: false, error: `unknown action: ${type}` }
64
100
  }
@@ -66,18 +102,35 @@ export function resolveAction(type: string, args: ActionArgs): { valid: boolean,
66
102
 
67
103
  // --- Browser-side execution (client.ts only) ---
68
104
 
69
- export const INTERACTIVE = 'a, button, input, textarea, select, [role], [onclick], [tabindex], [contenteditable], [aria-label]'
70
-
71
- function visibleText(el: Element): string {
72
- const aria = el.getAttribute('aria-label')
73
- if (aria)
74
- return aria
75
- return (el.textContent || '').trim()
105
+ // 命中判定原语(INTERACTIVE/getDirectText/elementLabel/isVisible/reachable)的真身已搬进
106
+ // teact 内置的「DOM 命中」领域(teact/dom/candidates)—— write 路(这里)与 teact 的命中
107
+ // 轨迹快照共用同一份真理。re-export client.ts 的 `from '../core/action'` 无需改路径。
108
+ export { elementLabel, getDirectText, INTERACTIVE, isVisible, reachable } from 'teact/dom'
109
+ import { elementLabel, INTERACTIVE, isVisible, reachable } from 'teact/dom'
110
+
111
+ // A field whose live value must never be echoed: API keys, tokens, passwords. `.value`
112
+ // returns the real string even for type=password (masking is visual only), so reading it
113
+ // out in /dom, /query or /screen would leak the secret in plaintext — the exact hole a
114
+ // DOM-dumping inspector falls into. We redact instead. Signals (substring, no regex):
115
+ // type=password, autocomplete=*-password / one-time-code, or a name/placeholder/aria-label
116
+ // hinting a secret. Shared by every value-echoing reader so the redaction can't be bypassed
117
+ // by hitting a different endpoint.
118
+ const SECRET_HINTS = ['password', 'api key', 'apikey', 'api-key', 'api_key', 'secret', 'token', 'private key']
119
+ export function isSensitive(el: Element): boolean {
120
+ if ((el as HTMLInputElement).type === 'password')
121
+ return true
122
+ const ac = (el.getAttribute('autocomplete') || '').toLowerCase()
123
+ if (ac.includes('password') || ac === 'one-time-code')
124
+ return true
125
+ const hint = `${el.getAttribute('name') || ''} ${el.getAttribute('placeholder') || ''} ${el.getAttribute('aria-label') || ''}`.toLowerCase()
126
+ return SECRET_HINTS.some(h => hint.includes(h))
76
127
  }
77
128
 
78
- function isVisible(el: Element): boolean {
79
- const r = el.getBoundingClientRect()
80
- return r.width > 0 && r.height > 0
129
+ // The value to show for a field: real value, or a length-preserving redaction for secrets
130
+ // (so "is it empty / roughly how long" stays observable without exposing the secret).
131
+ export function safeValue(el: Element): string {
132
+ const v = (el as HTMLInputElement).value ?? ''
133
+ return isSensitive(el) ? (v ? `‹redacted ${v.length} chars›` : '') : v
81
134
  }
82
135
 
83
136
  export function findElement(sel?: string, text?: string): Element | null {
@@ -92,7 +145,10 @@ export function findElement(sel?: string, text?: string): Element | null {
92
145
  if (text) {
93
146
  const lower = text.toLowerCase()
94
147
  const candidates = Array.from(document.querySelectorAll(INTERACTIVE))
95
- return candidates.find(el => isVisible(el) && visibleText(el).toLowerCase().includes(lower)) || null
148
+ // Prefer a reachable match (clickable now); fall back to a merely-visible one so a
149
+ // covered/off-viewport target still resolves (the caller can scrollIntoView/realclick).
150
+ const matches = candidates.filter(el => isVisible(el) && elementLabel(el).toLowerCase().includes(lower))
151
+ return matches.find(reachable) ?? matches[0] ?? null
96
152
  }
97
153
  return null
98
154
  }
@@ -102,8 +158,8 @@ function clickableList(): string {
102
158
  // unreachable, so listing it (e.g. 90 chat items) is pure noise.
103
159
  const root = document.querySelector('[role="dialog"][data-state="open"]') ?? document
104
160
  const els = Array.from(root.querySelectorAll(INTERACTIVE))
105
- .filter(isVisible)
106
- .map(el => visibleText(el).slice(0, 40))
161
+ .filter(reachable)
162
+ .map(el => elementLabel(el).slice(0, 40))
107
163
  .filter(Boolean)
108
164
  return [...new Set(els)].slice(0, 30).join(' | ')
109
165
  }
@@ -118,6 +174,11 @@ export async function performAction(type: ActionType, args: ActionArgs): Promise
118
174
  case 'wait': return await doWait(args)
119
175
  case 'screenshot': return await doScreenshot(args)
120
176
  case 'query': return doQuery(args)
177
+ case 'assert': return doAssert(args)
178
+ case 'drag': return doDrag(args)
179
+ case 'scrollIntoView': return doScrollIntoView(args)
180
+ case 'drop': return doDrop(args)
181
+ case 'clipboard': return await doClipboard(args)
121
182
  }
122
183
  }
123
184
  catch (e) {
@@ -234,7 +295,7 @@ function doClick(args: ActionArgs): ActionResult {
234
295
  // Report when the mouse actually landed on something else (overlay/portal) — a
235
296
  // common cause of "I clicked X but nothing happened".
236
297
  const note = hit && hit !== el && !el.contains(hit) && !hit.contains(el) ? ` (hit <${hit.tagName.toLowerCase()}> on top)` : ''
237
- return { ok: true, detail: `clicked ${visibleText(el).slice(0, 40) || el.tagName.toLowerCase()}${note}` }
298
+ return { ok: true, detail: `clicked ${elementLabel(el).slice(0, 40) || el.tagName.toLowerCase()}${note}` }
238
299
  }
239
300
 
240
301
  // realclick resolves an element to a viewport-center (x,y) but does NOT synthetic-click.
@@ -252,7 +313,7 @@ function doResolveRealClick(args: ActionArgs): ActionResult {
252
313
  const r = el.getBoundingClientRect()
253
314
  const x = Math.round(r.left + r.width / 2)
254
315
  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})` }
316
+ return { ok: true, x, y, detail: `resolved ${elementLabel(el).slice(0, 40) || el.tagName.toLowerCase()} at (${x}, ${y})` }
256
317
  }
257
318
 
258
319
  function doFill(args: ActionArgs): ActionResult {
@@ -286,7 +347,8 @@ function doFill(args: ActionArgs): ActionResult {
286
347
  const input = el as HTMLInputElement
287
348
  const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype
288
349
  const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set
289
- if (setter) setter.call(input, value)
350
+ if (setter)
351
+ setter.call(input, value)
290
352
  else input.value = value
291
353
  input.dispatchEvent(new Event('input', { bubbles: true }))
292
354
  input.dispatchEvent(new Event('change', { bubbles: true }))
@@ -370,14 +432,15 @@ const QUERY_ATTRS = ['role', 'data-state', 'value', 'href', 'title']
370
432
  const QUERY_PREFIXES = ['data-', 'aria-']
371
433
 
372
434
  function elAttrs(el: Element): Record<string, string> {
435
+ const sensitive = isSensitive(el)
373
436
  const out: Record<string, string> = {}
374
437
  for (const attr of Array.from(el.attributes)) {
375
438
  if (QUERY_ATTRS.includes(attr.name) || QUERY_PREFIXES.some(p => attr.name.startsWith(p)))
376
- out[attr.name] = attr.value
439
+ out[attr.name] = attr.name === 'value' && sensitive ? `‹redacted ${attr.value.length} chars›` : attr.value
377
440
  }
378
441
  const input = el as HTMLInputElement
379
442
  if (typeof input.value === 'string' && out.value === undefined && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT'))
380
- out.value = input.value
443
+ out.value = safeValue(el)
381
444
  if (input.disabled)
382
445
  out.disabled = 'true'
383
446
  if (input.checked)
@@ -395,10 +458,124 @@ function doQuery(args: ActionArgs): ActionResult {
395
458
  }
396
459
  const count = els.length
397
460
  const matches = els.slice(0, 20).map(el => ({
398
- text: visibleText(el).slice(0, 80),
461
+ text: elementLabel(el).slice(0, 80),
399
462
  visible: isVisible(el),
400
463
  attrs: elAttrs(el),
401
464
  }))
402
465
  const head = count > 20 ? `(showing 20 of ${count})\n` : ''
403
466
  return { ok: true, detail: head + JSON.stringify({ count, matches }, null, 2) }
404
467
  }
468
+
469
+ // Assert a domain variable (from __AIPEEK_SCREEN__) or a DOM element's text equals the
470
+ // expected value. Pass/fail with the actual value on failure — the chain stops and says
471
+ // "asserted X==Y, actual Z", not "the next step happened to miss".
472
+ function doAssert(args: ActionArgs): ActionResult {
473
+ let actual: string
474
+ if (args.screen) {
475
+ let domain: Record<string, unknown> = {}
476
+ try {
477
+ domain = window.__AIPEEK_SCREEN__?.() ?? {}
478
+ }
479
+ catch (e) {
480
+ return { ok: false, error: `__AIPEEK_SCREEN__ threw: ${e instanceof Error ? e.message : String(e)}` }
481
+ }
482
+ if (!(args.screen in domain))
483
+ return { ok: false, error: `no domain key "${args.screen}" — available: ${Object.keys(domain).join(', ') || '(none — app injected no __AIPEEK_SCREEN__)'}` }
484
+ actual = String(domain[args.screen])
485
+ }
486
+ else {
487
+ const el = findElement(args.sel, args.text)
488
+ if (!el)
489
+ return { ok: false, error: `no element for ${args.sel || args.text}` }
490
+ actual = elementLabel(el)
491
+ }
492
+ const target = args.screen ? `screen.${args.screen}` : (args.sel || args.text)
493
+ return actual === args.equals
494
+ ? { ok: true, detail: `${target} == ${args.equals}` }
495
+ : { ok: false, error: `asserted ${target} == "${args.equals}", actual "${actual}"` }
496
+ }
497
+
498
+ // Scroll an element into the viewport so it can be clicked. In a virtual list (tanstack-
499
+ // virtual, dnd-kit) a row off-screen isn't in the DOM at all — but a row rendered yet
500
+ // scrolled out of view is the common case, and this brings it back. Honest about the
501
+ // virtual case: if the element isn't found, say so (the row may need the list scrolled
502
+ // first via a container scroll, which /eval can do).
503
+ function doScrollIntoView(args: ActionArgs): ActionResult {
504
+ const el = findElement(args.sel, args.text)
505
+ if (!el)
506
+ return { ok: false, error: `no element for ${args.sel || args.text} — if it's in a virtual list it may not be rendered yet`, detail: clickableList() }
507
+ el.scrollIntoView({ block: 'center', inline: 'center' })
508
+ const r = el.getBoundingClientRect()
509
+ const inView = r.top >= 0 && r.left >= 0 && r.bottom <= innerHeight && r.right <= innerWidth
510
+ return { ok: true, detail: `scrolled ${elementLabel(el).slice(0, 40) || el.tagName.toLowerCase()} into view${inView ? '' : ' (still partly off-screen)'}` }
511
+ }
512
+
513
+ // Drag via a synthetic pointer sequence: pointerdown on source → a few pointermove steps
514
+ // (dnd-kit's PointerSensor has an activation distance constraint, so we move in increments
515
+ // past it) → pointerup on destination. Also fires the HTML5 drag events for libraries that
516
+ // use the native DnD API. dnd-kit relies on pointer capture + isTrusted in some paths; when
517
+ // synthetic events don't trigger the reorder, fall back to realclick's trusted channel
518
+ // (caller sees needsTrusted in the detail). Best-effort, honestly reported.
519
+ function doDrag(args: ActionArgs): ActionResult {
520
+ const src = findElement(args.sel, args.text)
521
+ if (!src)
522
+ return { ok: false, error: `no source element for ${args.sel || args.text}`, detail: clickableList() }
523
+ const dst = args.to ? document.querySelector(args.to) : null
524
+ if (!dst)
525
+ return { ok: false, error: `no destination element for to=${args.to}` }
526
+ const sr = src.getBoundingClientRect()
527
+ const dr = dst.getBoundingClientRect()
528
+ const sx = sr.left + sr.width / 2
529
+ const sy = sr.top + sr.height / 2
530
+ const dx = dr.left + dr.width / 2
531
+ const dy = dr.top + dr.height / 2
532
+ const ptr = (x: number, y: number, extra: PointerEventInit = {}) =>
533
+ ({ bubbles: true, cancelable: true, composed: true, clientX: x, clientY: y, view: window, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, ...extra })
534
+
535
+ src.dispatchEvent(new PointerEvent('pointerdown', ptr(sx, sy, { buttons: 1 })))
536
+ // Step through the path so dnd-kit clears its activation-distance constraint.
537
+ const steps = 8
538
+ for (let i = 1; i <= steps; i++) {
539
+ const x = sx + (dx - sx) * (i / steps)
540
+ const y = sy + (dy - sy) * (i / steps)
541
+ const over = (document.elementFromPoint(x, y) as HTMLElement | null) ?? dst as HTMLElement
542
+ over.dispatchEvent(new PointerEvent('pointermove', ptr(x, y, { buttons: 1 })))
543
+ }
544
+ ;(dst as HTMLElement).dispatchEvent(new PointerEvent('pointerup', ptr(dx, dy, { buttons: 0 })))
545
+ return { ok: true, detail: `dragged ${elementLabel(src).slice(0, 30) || src.tagName.toLowerCase()} → ${elementLabel(dst).slice(0, 30) || dst.tagName.toLowerCase()} (synthetic; if no reorder, retry with realclick — dnd-kit may need trusted pointer events)` }
546
+ }
547
+
548
+ // Drop synthetic files onto a target — the file-upload path without a native file picker
549
+ // (which synthetic clicks can't drive). Builds a DataTransfer with empty File objects of
550
+ // the given names and fires the dragenter→dragover→drop sequence libraries listen for.
551
+ function doDrop(args: ActionArgs): ActionResult {
552
+ const target = findElement(args.sel, args.text)
553
+ if (!target)
554
+ return { ok: false, error: `no drop target for ${args.sel || args.text}`, detail: clickableList() }
555
+ const dt = new DataTransfer()
556
+ for (const name of args.files ?? [])
557
+ dt.items.add(new File([''], name, { type: 'application/octet-stream' }))
558
+ const r = target.getBoundingClientRect()
559
+ const init = { bubbles: true, cancelable: true, composed: true, clientX: r.left + r.width / 2, clientY: r.top + r.height / 2, dataTransfer: dt }
560
+ for (const type of ['dragenter', 'dragover', 'drop'] as const)
561
+ target.dispatchEvent(new DragEvent(type, init))
562
+ return { ok: true, detail: `dropped ${dt.items.length} file(s) [${(args.files ?? []).join(', ')}] on ${elementLabel(target).slice(0, 30) || target.tagName.toLowerCase()}` }
563
+ }
564
+
565
+ // Read or write the clipboard. write seeds it (so a subsequent paste can be tested);
566
+ // read reports what the page put there (so a "copy" button can be verified). navigator.
567
+ // clipboard needs a focused document and (for read) permission — degrade with a clear
568
+ // error rather than hang.
569
+ async function doClipboard(args: ActionArgs): Promise<ActionResult> {
570
+ try {
571
+ if (args.mode === 'write') {
572
+ await navigator.clipboard.writeText(args.value ?? '')
573
+ return { ok: true, detail: `clipboard ← "${(args.value ?? '').slice(0, 60)}"` }
574
+ }
575
+ const text = await navigator.clipboard.readText()
576
+ return { ok: true, detail: `clipboard: "${text.slice(0, 200)}"` }
577
+ }
578
+ catch (e) {
579
+ return { ok: false, error: `clipboard ${args.mode ?? 'read'} failed: ${e instanceof Error ? e.message : String(e)} (needs document focus / permission)` }
580
+ }
581
+ }
@@ -1,4 +1,5 @@
1
1
  import type { CompactState, ErrorEntry, LogEntry, NetworkRequest, RawState } from './types'
2
+ import { detailPerformance } from './perf'
2
3
  import { appStackFrames, compactUrl, formatValue, truncate } from './util'
3
4
 
4
5
  const SLOW_THRESHOLD = 1000
@@ -301,6 +302,7 @@ export function compact(raw: RawState): CompactState {
301
302
  network: compactNetwork(raw.network),
302
303
  errors: compactErrors(raw.errors),
303
304
  state: compactState(raw.state),
305
+ performance: raw.performance ? detailPerformance(raw.performance) : undefined,
304
306
  timestamp: raw.timestamp,
305
307
  counts: {
306
308
  console: raw.console.length,
@@ -1,5 +1,6 @@
1
- import type { ErrorEntry, LogEntry, NetworkRequest, RawState } from './types'
1
+ import type { ErrorEntry, LogEntry, NetworkRequest, PerformanceData, RawState } from './types'
2
2
  import { compactUI } from './compact'
3
+ import { detailPerformance } from './perf'
3
4
  import { appStackFrames, formatValue, truncate } from './util'
4
5
 
5
6
  export function detail(raw: RawState, section: string, index: string | undefined, full: boolean): string | null {
@@ -9,6 +10,7 @@ export function detail(raw: RawState, section: string, index: string | undefined
9
10
  case 'network': return detailNetwork(raw.network, index, full)
10
11
  case 'errors': return detailError(raw.errors, index, full)
11
12
  case 'state': return detailState(raw.state, index, full)
13
+ case 'profile': return raw.performance ? detailPerformance(raw.performance) : '(no perf data — is the tab foreground?)'
12
14
  default: return null
13
15
  }
14
16
  }
package/src/core/diff.ts CHANGED
@@ -1,4 +1,58 @@
1
- import type { DiffResult, RawState } from './types'
1
+ import type { DiffResult, ErrorEntry, LogEntry, NetworkRequest, RawState, ScreenSnap } from './types'
2
+
3
+ // The change a single action caused: the state-machine transition (view/modal/focus)
4
+ // plus any errors/failed requests it triggered. AI reads "what moved", then drills into
5
+ // /ui or /dom for detail if it needs more — the response carries the delta, not a snapshot.
6
+ // Pure: caller passes before/after snaps and the already-diffed error/request deltas.
7
+ export function diffScreen(
8
+ before: ScreenSnap,
9
+ after: ScreenSnap,
10
+ newErrors: LogEntry[],
11
+ newExceptions: ErrorEntry[],
12
+ newFailedRequests: NetworkRequest[],
13
+ ): string[] {
14
+ const lines: string[] = []
15
+ if (after.view !== before.view)
16
+ lines.push(`view: ${before.view} → ${after.view}`)
17
+ if (after.modal !== before.modal) {
18
+ if (after.modal === 'none')
19
+ lines.push(`modal: closed (${before.modal})`)
20
+ else if (before.modal === 'none')
21
+ lines.push(`modal: opened ${after.modal}`)
22
+ else
23
+ lines.push(`modal: ${before.modal} → ${after.modal}`)
24
+ }
25
+ if (after.focus !== before.focus)
26
+ lines.push(`focus: ${after.focus}`)
27
+ // The app's domain state machine (from __AIPEEK_SCREEN__) — these transitions are
28
+ // the ones a DOM-only projector can't see (流式 false → true never touches the DOM).
29
+ const beforeDomain = before.domain ?? {}
30
+ const afterDomain = after.domain ?? {}
31
+ for (const key of new Set([...Object.keys(beforeDomain), ...Object.keys(afterDomain)])) {
32
+ const b = stringifyDomain(beforeDomain[key])
33
+ const a = stringifyDomain(afterDomain[key])
34
+ if (b !== a)
35
+ lines.push(`${key}: ${b} → ${a}`)
36
+ }
37
+ for (const e of newErrors)
38
+ lines.push(`+error: ${e.text}`)
39
+ for (const e of newExceptions)
40
+ lines.push(`+exception: ${e.message}`)
41
+ for (const r of newFailedRequests)
42
+ lines.push(`+failed: ${r.method} ${r.url} → ${r.status || 'failed'}`)
43
+ return lines
44
+ }
45
+
46
+ // One-line, bounded, comparison-stable stringification of a domain value. Used both to
47
+ // detect change (string equality) and to render the transition. Objects compare by their
48
+ // JSON — good enough for the shallow domain maps __AIPEEK_SCREEN__ returns.
49
+ export function stringifyDomain(v: unknown): string {
50
+ if (v === null || v === undefined)
51
+ return String(v)
52
+ if (typeof v === 'object')
53
+ return JSON.stringify(v).slice(0, 80)
54
+ return String(v).slice(0, 80)
55
+ }
2
56
 
3
57
  export function diffState(prev: RawState | null, curr: RawState): DiffResult {
4
58
  if (!prev) {
package/src/core/emit.ts CHANGED
@@ -1,11 +1,12 @@
1
- import type { CheckResult, CompactState, DiffResult, RawState } from './types'
1
+ import type { CheckResult, CompactState, DiffResult, PerformanceData, RawState } from './types'
2
2
  import pc from 'picocolors'
3
3
  import { nameOf } from './compact'
4
+ import { compactPerformance } from './perf'
4
5
  import { compactUrl, truncate } from './util'
5
6
 
6
7
  // --- Full emit (for ?full) ---
7
8
 
8
- const SECTIONS = ['ui', 'console', 'network', 'errors', 'state'] as const
9
+ const SECTIONS = ['ui', 'console', 'network', 'errors', 'state', 'performance'] as const
9
10
  const COUNTED_SECTIONS: Record<string, keyof CompactState['counts']> = {
10
11
  console: 'console',
11
12
  network: 'network',
@@ -87,6 +88,17 @@ export function emitSummary(raw: RawState): string {
87
88
  for (const e of raw.errors) lines.push(` ${truncate(e.message, 150)}`)
88
89
  }
89
90
 
91
+ // perf — only show when jank (dropped >10%)
92
+ if (raw.performance) {
93
+ const all = raw.performance.buckets.find(b => b.name === '__all__')
94
+ if (all) {
95
+ const droppedPct = all.frames.total > 0 ? (all.frames.long / all.frames.total) * 100 : 0
96
+ if (droppedPct > 10) {
97
+ lines.push(compactPerformance(raw.performance))
98
+ }
99
+ }
100
+ }
101
+
90
102
  // state — 1-line: store names + key counts
91
103
  const storeNames = Object.keys(raw.state)
92
104
  if (storeNames.length) {