aipeek 0.2.2 → 0.2.3

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