aipeek 0.2.7 → 0.2.8
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 +92 -18
- package/dist/{chunk-37VLLZIU.js → chunk-4BPXH2SW.js} +620 -45
- package/dist/{chunk-STYCUT23.cjs → chunk-SDUTK75Y.cjs} +621 -46
- package/dist/index.cjs +2 -2
- package/dist/index.d.cts +44 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.js +1 -1
- package/dist/plugin.cjs +2 -2
- package/dist/plugin.js +1 -1
- package/package.json +3 -1
- package/src/babel/line-profiler.ts +190 -0
- package/src/client/client-patch.ts +326 -2
- package/src/client/client.ts +246 -44
- package/src/core/action.ts +199 -22
- package/src/core/compact.ts +2 -0
- package/src/core/detail.ts +3 -1
- package/src/core/diff.ts +55 -1
- package/src/core/emit.ts +14 -2
- package/src/core/perf.ts +239 -0
- package/src/core/types.ts +73 -0
- package/src/core/util.ts +115 -0
- package/src/server/plugin.ts +463 -52
package/src/core/action.ts
CHANGED
|
@@ -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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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(
|
|
106
|
-
.map(el =>
|
|
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 ${
|
|
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 ${
|
|
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)
|
|
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 =
|
|
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:
|
|
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
|
+
}
|
package/src/core/compact.ts
CHANGED
|
@@ -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,
|
package/src/core/detail.ts
CHANGED
|
@@ -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) {
|