aipeek 0.2.6 → 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 +94 -19
- package/dist/{chunk-XA2LT6I4.js → chunk-4BPXH2SW.js} +715 -59
- package/dist/{chunk-5ZZYOETF.cjs → chunk-SDUTK75Y.cjs} +717 -61
- 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 +4 -2
- package/dist/plugin.js +5 -3
- 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 +255 -42
- package/src/core/action.ts +274 -18
- 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 +577 -65
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'
|
|
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
|
|
@@ -15,6 +16,17 @@ export interface ActionArgs {
|
|
|
15
16
|
key?: string
|
|
16
17
|
timeout?: number
|
|
17
18
|
gone?: boolean
|
|
19
|
+
button?: 'left' | 'right'
|
|
20
|
+
x?: number
|
|
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'
|
|
18
30
|
}
|
|
19
31
|
|
|
20
32
|
export interface ActionResult {
|
|
@@ -22,11 +34,16 @@ export interface ActionResult {
|
|
|
22
34
|
detail?: string
|
|
23
35
|
error?: string
|
|
24
36
|
dataUrl?: string
|
|
25
|
-
ui?: string
|
|
26
37
|
screen?: string
|
|
38
|
+
actions?: string
|
|
39
|
+
x?: number
|
|
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
|
|
27
44
|
}
|
|
28
45
|
|
|
29
|
-
const TYPES: ActionType[] = ['click', 'fill', 'press', 'wait', 'screenshot']
|
|
46
|
+
const TYPES: ActionType[] = ['click', 'fill', 'press', 'wait', 'screenshot', 'realclick', 'query', 'assert', 'drag', 'scrollIntoView', 'drop', 'clipboard']
|
|
30
47
|
|
|
31
48
|
// --- Pure validation (plugin-side) ---
|
|
32
49
|
|
|
@@ -38,6 +55,8 @@ export function resolveAction(type: string, args: ActionArgs): { valid: boolean,
|
|
|
38
55
|
switch (type) {
|
|
39
56
|
case 'click':
|
|
40
57
|
return hasTarget ? { valid: true } : { valid: false, error: 'click needs sel= or text=' }
|
|
58
|
+
case 'realclick':
|
|
59
|
+
return hasTarget || (args.x !== undefined && args.y !== undefined) ? { valid: true } : { valid: false, error: 'realclick needs sel=, text=, or x= & y=' }
|
|
41
60
|
case 'fill':
|
|
42
61
|
if (!hasTarget)
|
|
43
62
|
return { valid: false, error: 'fill needs sel= or text=' }
|
|
@@ -50,6 +69,32 @@ export function resolveAction(type: string, args: ActionArgs): { valid: boolean,
|
|
|
50
69
|
return hasTarget ? { valid: true } : { valid: false, error: 'wait needs sel= or text=' }
|
|
51
70
|
case 'screenshot':
|
|
52
71
|
return { valid: true }
|
|
72
|
+
case 'query':
|
|
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 }
|
|
53
98
|
default:
|
|
54
99
|
return { valid: false, error: `unknown action: ${type}` }
|
|
55
100
|
}
|
|
@@ -57,18 +102,35 @@ export function resolveAction(type: string, args: ActionArgs): { valid: boolean,
|
|
|
57
102
|
|
|
58
103
|
// --- Browser-side execution (client.ts only) ---
|
|
59
104
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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))
|
|
67
127
|
}
|
|
68
128
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
72
134
|
}
|
|
73
135
|
|
|
74
136
|
export function findElement(sel?: string, text?: string): Element | null {
|
|
@@ -83,7 +145,10 @@ export function findElement(sel?: string, text?: string): Element | null {
|
|
|
83
145
|
if (text) {
|
|
84
146
|
const lower = text.toLowerCase()
|
|
85
147
|
const candidates = Array.from(document.querySelectorAll(INTERACTIVE))
|
|
86
|
-
|
|
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
|
|
87
152
|
}
|
|
88
153
|
return null
|
|
89
154
|
}
|
|
@@ -93,8 +158,8 @@ function clickableList(): string {
|
|
|
93
158
|
// unreachable, so listing it (e.g. 90 chat items) is pure noise.
|
|
94
159
|
const root = document.querySelector('[role="dialog"][data-state="open"]') ?? document
|
|
95
160
|
const els = Array.from(root.querySelectorAll(INTERACTIVE))
|
|
96
|
-
.filter(
|
|
97
|
-
.map(el =>
|
|
161
|
+
.filter(reachable)
|
|
162
|
+
.map(el => elementLabel(el).slice(0, 40))
|
|
98
163
|
.filter(Boolean)
|
|
99
164
|
return [...new Set(els)].slice(0, 30).join(' | ')
|
|
100
165
|
}
|
|
@@ -103,10 +168,17 @@ export async function performAction(type: ActionType, args: ActionArgs): Promise
|
|
|
103
168
|
try {
|
|
104
169
|
switch (type) {
|
|
105
170
|
case 'click': return doClick(args)
|
|
171
|
+
case 'realclick': return doResolveRealClick(args)
|
|
106
172
|
case 'fill': return doFill(args)
|
|
107
173
|
case 'press': return doPress(args)
|
|
108
174
|
case 'wait': return await doWait(args)
|
|
109
175
|
case 'screenshot': return await doScreenshot(args)
|
|
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)
|
|
110
182
|
}
|
|
111
183
|
}
|
|
112
184
|
catch (e) {
|
|
@@ -223,7 +295,25 @@ function doClick(args: ActionArgs): ActionResult {
|
|
|
223
295
|
// Report when the mouse actually landed on something else (overlay/portal) — a
|
|
224
296
|
// common cause of "I clicked X but nothing happened".
|
|
225
297
|
const note = hit && hit !== el && !el.contains(hit) && !hit.contains(el) ? ` (hit <${hit.tagName.toLowerCase()}> on top)` : ''
|
|
226
|
-
return { ok: true, detail: `clicked ${
|
|
298
|
+
return { ok: true, detail: `clicked ${elementLabel(el).slice(0, 40) || el.tagName.toLowerCase()}${note}` }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// realclick resolves an element to a viewport-center (x,y) but does NOT synthetic-click.
|
|
302
|
+
// The trusted click is fired by whichever channel can produce isTrusted=true input:
|
|
303
|
+
// Electron's webContents.sendInputEvent (via electronAPI.invoke, in client.ts) or a Chrome
|
|
304
|
+
// extension's chrome.debugger (via the server's CDP queue). Synthetic events can't open a
|
|
305
|
+
// Radix ContextMenu — that's the whole reason this path exists. When x= & y= are given
|
|
306
|
+
// directly we pass them through unchanged.
|
|
307
|
+
function doResolveRealClick(args: ActionArgs): ActionResult {
|
|
308
|
+
if (args.x !== undefined && args.y !== undefined)
|
|
309
|
+
return { ok: true, x: args.x, y: args.y, detail: `resolved (${args.x}, ${args.y})` }
|
|
310
|
+
const el = findElement(args.sel, args.text)
|
|
311
|
+
if (!el)
|
|
312
|
+
return { ok: false, error: `no element for ${args.sel || args.text}`, detail: clickableList() }
|
|
313
|
+
const r = el.getBoundingClientRect()
|
|
314
|
+
const x = Math.round(r.left + r.width / 2)
|
|
315
|
+
const y = Math.round(r.top + r.height / 2)
|
|
316
|
+
return { ok: true, x, y, detail: `resolved ${elementLabel(el).slice(0, 40) || el.tagName.toLowerCase()} at (${x}, ${y})` }
|
|
227
317
|
}
|
|
228
318
|
|
|
229
319
|
function doFill(args: ActionArgs): ActionResult {
|
|
@@ -250,8 +340,16 @@ function doFill(args: ActionArgs): ActionResult {
|
|
|
250
340
|
return { ok: true, detail: `filled contenteditable, ${value.length} chars` }
|
|
251
341
|
}
|
|
252
342
|
|
|
343
|
+
// React overrides the value setter on the element *instance* to track changes; a plain
|
|
344
|
+
// `input.value = x` writes through it so React's tracker never sees a diff and onChange
|
|
345
|
+
// never fires (controlled inputs stay empty). Call the *prototype* setter instead — the
|
|
346
|
+
// tracker observes the change and the synthetic onChange fires.
|
|
253
347
|
const input = el as HTMLInputElement
|
|
254
|
-
|
|
348
|
+
const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype
|
|
349
|
+
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set
|
|
350
|
+
if (setter)
|
|
351
|
+
setter.call(input, value)
|
|
352
|
+
else input.value = value
|
|
255
353
|
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
256
354
|
input.dispatchEvent(new Event('change', { bubbles: true }))
|
|
257
355
|
return { ok: true, detail: `filled ${value.length} chars` }
|
|
@@ -323,3 +421,161 @@ async function doScreenshot(args: ActionArgs): Promise<ActionResult> {
|
|
|
323
421
|
return { ok: false, error: `screenshot failed: ${msg}` }
|
|
324
422
|
}
|
|
325
423
|
}
|
|
424
|
+
|
|
425
|
+
// The read-side twin of click/fill's `sel=`: instead of acting on the matched
|
|
426
|
+
// element, report the facts you'd otherwise reach for via /eval — count of
|
|
427
|
+
// matches, and per-element text / visible / assertion-relevant attrs. /wait
|
|
428
|
+
// answers "appears over time"; query answers "what is it now". Attrs are a
|
|
429
|
+
// whitelist (role, data-state, data-*, aria-*, value, disabled, checked, href,
|
|
430
|
+
// title) — a node's full class/style set is noise. Capped at 20 matches.
|
|
431
|
+
const QUERY_ATTRS = ['role', 'data-state', 'value', 'href', 'title']
|
|
432
|
+
const QUERY_PREFIXES = ['data-', 'aria-']
|
|
433
|
+
|
|
434
|
+
function elAttrs(el: Element): Record<string, string> {
|
|
435
|
+
const sensitive = isSensitive(el)
|
|
436
|
+
const out: Record<string, string> = {}
|
|
437
|
+
for (const attr of Array.from(el.attributes)) {
|
|
438
|
+
if (QUERY_ATTRS.includes(attr.name) || QUERY_PREFIXES.some(p => attr.name.startsWith(p)))
|
|
439
|
+
out[attr.name] = attr.name === 'value' && sensitive ? `‹redacted ${attr.value.length} chars›` : attr.value
|
|
440
|
+
}
|
|
441
|
+
const input = el as HTMLInputElement
|
|
442
|
+
if (typeof input.value === 'string' && out.value === undefined && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT'))
|
|
443
|
+
out.value = safeValue(el)
|
|
444
|
+
if (input.disabled)
|
|
445
|
+
out.disabled = 'true'
|
|
446
|
+
if (input.checked)
|
|
447
|
+
out.checked = 'true'
|
|
448
|
+
return out
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function doQuery(args: ActionArgs): ActionResult {
|
|
452
|
+
let els: Element[]
|
|
453
|
+
try {
|
|
454
|
+
els = Array.from(document.querySelectorAll(args.sel!))
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
return { ok: false, error: `invalid selector: ${args.sel} — URL-encode it (curl -G --data-urlencode 'sel=...')` }
|
|
458
|
+
}
|
|
459
|
+
const count = els.length
|
|
460
|
+
const matches = els.slice(0, 20).map(el => ({
|
|
461
|
+
text: elementLabel(el).slice(0, 80),
|
|
462
|
+
visible: isVisible(el),
|
|
463
|
+
attrs: elAttrs(el),
|
|
464
|
+
}))
|
|
465
|
+
const head = count > 20 ? `(showing 20 of ${count})\n` : ''
|
|
466
|
+
return { ok: true, detail: head + JSON.stringify({ count, matches }, null, 2) }
|
|
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) {
|