aipeek 0.2.5 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/{chunk-X3HAXWFJ.js → chunk-37VLLZIU.js} +176 -98
- package/dist/{chunk-6EZKMGRD.cjs → chunk-STYCUT23.cjs} +183 -105
- package/dist/index.cjs +2 -2
- package/dist/index.js +1 -1
- package/dist/plugin.cjs +4 -2
- package/dist/plugin.js +5 -3
- package/package.json +2 -1
- package/src/client/client-patch.ts +35 -3
- package/src/client/client.ts +19 -18
- package/src/core/action.ts +136 -4
- package/src/core/compact.ts +4 -30
- package/src/core/detail.ts +10 -26
- package/src/core/util.ts +53 -0
- package/src/server/plugin.ts +146 -56
package/src/server/plugin.ts
CHANGED
|
@@ -21,6 +21,12 @@ function readBody(req: { on: (e: string, cb: (c: unknown) => void) => void }): P
|
|
|
21
21
|
})
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// 所有端点都回文本响应——writeHead(Content-Type) + end 二连写了 15 遍(且有 2 处漏 charset)。收敛成一处。
|
|
25
|
+
function send(res: { writeHead: (s: number, h: Record<string, string>) => void, end: (b: string) => void }, status: number, body: string) {
|
|
26
|
+
res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
27
|
+
res.end(body)
|
|
28
|
+
}
|
|
29
|
+
|
|
24
30
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
25
31
|
// client/core sources are read at runtime (client.ts served via Vite transform,
|
|
26
32
|
// client-patch.ts compiled by esbuild). Two layouts: source consumption
|
|
@@ -63,8 +69,12 @@ curl ${base}/console # console logs (errors, warnings, info)
|
|
|
63
69
|
curl ${base}/network # fetch/XHR requests with status and timing
|
|
64
70
|
curl ${base}/errors # uncaught errors and unhandled rejections
|
|
65
71
|
curl ${base}/state # registered store snapshots
|
|
72
|
+
curl '${base}/query?sel=[role=menuitem]' # this selector right now: count + each element's text/visible/attrs
|
|
66
73
|
\`\`\`
|
|
67
74
|
|
|
75
|
+
\`/query\` is the read-side twin of click/fill's \`sel=\` — assert on a specific element
|
|
76
|
+
(how many match, its text, \`data-state\`, \`aria-*\`/\`data-*\`, value, disabled) without \`/eval\`.
|
|
77
|
+
|
|
68
78
|
\`/screen\` projects the whole UI to a few state variables — start there, not \`/ui\`. Append
|
|
69
79
|
\`?full\` for untruncated output. Append \`/{index}\` for a specific item's detail.
|
|
70
80
|
|
|
@@ -101,7 +111,8 @@ curl -X POST ${base}/chain -d '[
|
|
|
101
111
|
\`\`\`
|
|
102
112
|
|
|
103
113
|
**Escape hatch.** \`curl '${base}/eval?code=...'\` (or POST the code as the body) runs arbitrary
|
|
104
|
-
JS in the page and returns the result — for
|
|
114
|
+
JS in the page and returns the result — for what the typed endpoints can't do (install listeners,
|
|
115
|
+
read closures, probe event flow). For count/text/state/attr assertions reach for \`/query\` first.
|
|
105
116
|
|
|
106
117
|
aipeek auto-detects errors after HMR and prints them to the terminal — watch for \`[aipeek]\` messages.
|
|
107
118
|
`
|
|
@@ -110,27 +121,26 @@ aipeek auto-detects errors after HMR and prints them to the terminal — watch f
|
|
|
110
121
|
export const START_TAG = '<!-- AIPEEK:START -->'
|
|
111
122
|
export const END_TAG = '<!-- AIPEEK:END -->'
|
|
112
123
|
|
|
113
|
-
// Marker-based injection
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
124
|
+
// Marker-based injection 的纯核心:existing(文件现内容,缺文件传 null)+ port → 新内容。
|
|
125
|
+
// 块夹在 START_TAG..END_TAG 间,再注入是确定性 splice(找标记替换中间)而非模糊行匹配。
|
|
126
|
+
// 缺文件 → 仅块;有标记 → 替换其内容;无标记 → 末尾追加新块。fs 读写是 injectClaudeMd 的边界,
|
|
127
|
+
// 这里 0 副作用——四条分支(新建/替换/追加/补换行)全可被快照锁死。
|
|
128
|
+
export function renderClaudeMd(existing: string | null, port: number): string {
|
|
129
|
+
const block = `${START_TAG}\n${aipeekSnippet(port).trim()}\n${END_TAG}\n`
|
|
130
|
+
if (existing === null)
|
|
131
|
+
return block
|
|
132
|
+
const si = existing.indexOf(START_TAG)
|
|
133
|
+
const ei = existing.indexOf(END_TAG)
|
|
134
|
+
if (si !== -1 && ei !== -1)
|
|
135
|
+
return existing.slice(0, si) + block.trimEnd() + existing.slice(ei + END_TAG.length)
|
|
136
|
+
const sep = existing.endsWith('\n') ? '' : '\n'
|
|
137
|
+
return `${existing}${sep}\n${block}`
|
|
138
|
+
}
|
|
139
|
+
|
|
117
140
|
export function injectClaudeMd(root: string, port: number) {
|
|
118
141
|
const path = resolve(root, 'CLAUDE.md')
|
|
119
|
-
const block = `${START_TAG}\n${aipeekSnippet(port).trim()}\n${END_TAG}\n`
|
|
120
142
|
try {
|
|
121
|
-
|
|
122
|
-
writeFileSync(path, block)
|
|
123
|
-
return
|
|
124
|
-
}
|
|
125
|
-
const content = readFileSync(path, 'utf-8')
|
|
126
|
-
const si = content.indexOf(START_TAG)
|
|
127
|
-
const ei = content.indexOf(END_TAG)
|
|
128
|
-
if (si !== -1 && ei !== -1) {
|
|
129
|
-
writeFileSync(path, content.slice(0, si) + block.trimEnd() + content.slice(ei + END_TAG.length))
|
|
130
|
-
return
|
|
131
|
-
}
|
|
132
|
-
const sep = content.endsWith('\n') ? '' : '\n'
|
|
133
|
-
writeFileSync(path, `${content}${sep}\n${block}`)
|
|
143
|
+
writeFileSync(path, renderClaudeMd(existsSync(path) ? readFileSync(path, 'utf-8') : null, port))
|
|
134
144
|
}
|
|
135
145
|
catch {}
|
|
136
146
|
}
|
|
@@ -143,6 +153,38 @@ export function aipeekPlugin(): Plugin {
|
|
|
143
153
|
const pendingActions = new Map<number, (r: ActionResult) => void>()
|
|
144
154
|
let actionId = 0
|
|
145
155
|
|
|
156
|
+
// Chrome real-input channel: synthetic events can't open a Radix ContextMenu, and the
|
|
157
|
+
// in-page script can't reach chrome.debugger. So for a plain browser tab, realclick is a
|
|
158
|
+
// two-step handshake — the page resolves the element to (x,y), then the server enqueues a
|
|
159
|
+
// CDP command here for the extension to execute with trusted input. The extension long-polls
|
|
160
|
+
// /cdp/poll for the next command and POSTs the verdict to /cdp/result. Electron never touches
|
|
161
|
+
// this (it fires sendInputEvent in-process from the page — see client.ts).
|
|
162
|
+
interface CdpCommand { id: number, x: number, y: number, button: 'left' | 'right' }
|
|
163
|
+
const cdpQueue: CdpCommand[] = []
|
|
164
|
+
let cdpWaiter: ((cmd: CdpCommand | null) => void) | null = null
|
|
165
|
+
const cdpResults = new Map<number, (r: { ok: boolean, error?: string }) => void>()
|
|
166
|
+
let cdpId = 0
|
|
167
|
+
|
|
168
|
+
function runCdpClick(x: number, y: number, button: 'left' | 'right'): Promise<{ ok: boolean, error?: string }> {
|
|
169
|
+
const id = ++cdpId
|
|
170
|
+
const cmd: CdpCommand = { id, x, y, button }
|
|
171
|
+
return new Promise((resolve, reject) => {
|
|
172
|
+
cdpResults.set(id, resolve)
|
|
173
|
+
// hand the command to a parked poller, else queue it for the next poll
|
|
174
|
+
if (cdpWaiter) {
|
|
175
|
+
cdpWaiter(cmd)
|
|
176
|
+
cdpWaiter = null
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
cdpQueue.push(cmd)
|
|
180
|
+
}
|
|
181
|
+
setTimeout(() => {
|
|
182
|
+
if (cdpResults.delete(id))
|
|
183
|
+
reject(new Error('cdp timeout: no extension result within 10s (is the aipeek extension loaded and the debugger attached?)'))
|
|
184
|
+
}, 10000)
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
146
188
|
let pendingDom: ((dom: string) => void) | null = null
|
|
147
189
|
let pendingScreen: ((screen: string) => void) | null = null
|
|
148
190
|
const pendingEvals = new Map<number, (r: { ok: boolean, value?: string, error?: string }) => void>()
|
|
@@ -220,6 +262,24 @@ export function aipeekPlugin(): Plugin {
|
|
|
220
262
|
}, fullMs)
|
|
221
263
|
}
|
|
222
264
|
|
|
265
|
+
// sendAction + the Chrome realclick handshake, in one place so the single endpoint and
|
|
266
|
+
// /chain both get it. The page resolves realclick to (x,y): if it set result.ui, Electron
|
|
267
|
+
// already fired the trusted click in-process — done. If ui is undefined (plain Chrome tab),
|
|
268
|
+
// the page couldn't click, so drive the extension's CDP queue with the coords, then collect
|
|
269
|
+
// the settled screen as the ui. A CDP failure comes back as a normal ok:false result.
|
|
270
|
+
async function runAction(type: string, args: ActionArgs): Promise<ActionResult> {
|
|
271
|
+
const result = await sendAction(type, args)
|
|
272
|
+
lastRaw = null // page mutated; force fresh collect next read
|
|
273
|
+
if (type === 'realclick' && result.ok && result.ui === undefined) {
|
|
274
|
+
const cdp = await runCdpClick(result.x!, result.y!, args.button ?? 'left')
|
|
275
|
+
if (!cdp.ok)
|
|
276
|
+
return { ok: false, error: `cdp click failed: ${cdp.error ?? 'unknown'}` }
|
|
277
|
+
result.detail = `${result.detail} → clicked via extension`
|
|
278
|
+
result.ui = await collectScreenFromClient()
|
|
279
|
+
}
|
|
280
|
+
return result
|
|
281
|
+
}
|
|
282
|
+
|
|
223
283
|
function evalInClient(code: string): Promise<{ ok: boolean, value?: string, error?: string }> {
|
|
224
284
|
const id = ++evalId
|
|
225
285
|
return twoPhase('aipeek:eval', { id, code }, (resolve) => {
|
|
@@ -331,13 +391,11 @@ export function aipeekPlugin(): Plugin {
|
|
|
331
391
|
if (!code && req.method === 'POST')
|
|
332
392
|
code = await readBody(req)
|
|
333
393
|
if (!code) {
|
|
334
|
-
res
|
|
335
|
-
res.end('eval needs ?code= or a POST body')
|
|
394
|
+
send(res, 400, 'eval needs ?code= or a POST body')
|
|
336
395
|
return
|
|
337
396
|
}
|
|
338
397
|
const r = await evalInClient(code)
|
|
339
|
-
res
|
|
340
|
-
res.end(r.ok ? (r.value ?? 'undefined') : `error: ${r.error}`)
|
|
398
|
+
send(res, r.ok ? 200 : 422, r.ok ? (r.value ?? 'undefined') : `error: ${r.error}`)
|
|
341
399
|
return
|
|
342
400
|
}
|
|
343
401
|
|
|
@@ -347,16 +405,59 @@ export function aipeekPlugin(): Plugin {
|
|
|
347
405
|
url.searchParams.get('scope') || undefined,
|
|
348
406
|
url.searchParams.get('sel') || undefined,
|
|
349
407
|
)
|
|
350
|
-
res
|
|
351
|
-
res.end(dom || '(empty)')
|
|
408
|
+
send(res, 200, dom || '(empty)')
|
|
352
409
|
return
|
|
353
410
|
}
|
|
354
411
|
|
|
355
412
|
// /__aipeek/screen — state-machine projection {view, modal, focus, knobs}
|
|
356
413
|
if (parts[0] === 'screen') {
|
|
357
414
|
const screen = await collectScreenFromClient()
|
|
358
|
-
res
|
|
359
|
-
|
|
415
|
+
send(res, 200, screen || '(empty)')
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// /__aipeek/cdp/poll — the Chrome extension long-polls here for the next
|
|
420
|
+
// trusted-input command. Returns the command as JSON, or 204 on timeout
|
|
421
|
+
// (the extension simply re-polls). Only one poller is parked at a time.
|
|
422
|
+
if (parts[0] === 'cdp' && parts[1] === 'poll') {
|
|
423
|
+
const queued = cdpQueue.shift()
|
|
424
|
+
if (queued) {
|
|
425
|
+
send(res, 200, JSON.stringify(queued))
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
const cmd = await new Promise<CdpCommand | null>((resolve) => {
|
|
429
|
+
cdpWaiter = resolve
|
|
430
|
+
setTimeout(() => {
|
|
431
|
+
if (cdpWaiter === resolve) {
|
|
432
|
+
cdpWaiter = null
|
|
433
|
+
resolve(null)
|
|
434
|
+
}
|
|
435
|
+
}, 25000)
|
|
436
|
+
})
|
|
437
|
+
if (cmd)
|
|
438
|
+
send(res, 200, JSON.stringify(cmd))
|
|
439
|
+
else
|
|
440
|
+
send(res, 204, '')
|
|
441
|
+
return
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// /__aipeek/cdp/result — POST {id, ok, error?}; resolves the awaiting realclick.
|
|
445
|
+
if (parts[0] === 'cdp' && parts[1] === 'result') {
|
|
446
|
+
const body = await readBody(req)
|
|
447
|
+
let data: { id: number, ok: boolean, error?: string }
|
|
448
|
+
try {
|
|
449
|
+
data = JSON.parse(body)
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
send(res, 400, 'cdp/result needs a JSON body {id, ok, error?}')
|
|
453
|
+
return
|
|
454
|
+
}
|
|
455
|
+
const resolveCdp = cdpResults.get(data.id)
|
|
456
|
+
if (resolveCdp) {
|
|
457
|
+
cdpResults.delete(data.id)
|
|
458
|
+
resolveCdp({ ok: data.ok, error: data.error })
|
|
459
|
+
}
|
|
460
|
+
send(res, 200, 'ok')
|
|
360
461
|
return
|
|
361
462
|
}
|
|
362
463
|
|
|
@@ -372,8 +473,7 @@ export function aipeekPlugin(): Plugin {
|
|
|
372
473
|
throw new Error('body must be a JSON array')
|
|
373
474
|
}
|
|
374
475
|
catch (e) {
|
|
375
|
-
res
|
|
376
|
-
res.end(`invalid chain body: ${e instanceof Error ? e.message : String(e)}`)
|
|
476
|
+
send(res, 400, `invalid chain body: ${e instanceof Error ? e.message : String(e)}`)
|
|
377
477
|
return
|
|
378
478
|
}
|
|
379
479
|
lastRaw = null
|
|
@@ -388,7 +488,7 @@ export function aipeekPlugin(): Plugin {
|
|
|
388
488
|
allOk = false
|
|
389
489
|
break
|
|
390
490
|
}
|
|
391
|
-
const r = await
|
|
491
|
+
const r = await runAction(type, args)
|
|
392
492
|
lines.push(`[${i}] ${r.ok ? '✓' : '✗'} ${type}: ${r.ok ? (r.detail || 'ok') : r.error}`)
|
|
393
493
|
// Per-step screen projection — captures the transition each
|
|
394
494
|
// mutating step caused, so a view change mid-chain is visible
|
|
@@ -402,13 +502,12 @@ export function aipeekPlugin(): Plugin {
|
|
|
402
502
|
break
|
|
403
503
|
}
|
|
404
504
|
}
|
|
405
|
-
res
|
|
406
|
-
res.end(lastUi ? `${lines.join('\n')}\n\n--- ui after ---\n${lastUi}` : lines.join('\n'))
|
|
505
|
+
send(res, allOk ? 200 : 422, lastUi ? `${lines.join('\n')}\n\n--- ui after ---\n${lastUi}` : lines.join('\n'))
|
|
407
506
|
return
|
|
408
507
|
}
|
|
409
508
|
|
|
410
|
-
// action endpoints: /__aipeek/{click|fill|press|wait|screenshot}?...
|
|
411
|
-
if (['click', 'fill', 'press', 'wait', 'screenshot'].includes(parts[0])) {
|
|
509
|
+
// action endpoints: /__aipeek/{click|fill|press|wait|screenshot|realclick}?...
|
|
510
|
+
if (['click', 'fill', 'press', 'wait', 'screenshot', 'realclick', 'query'].includes(parts[0])) {
|
|
412
511
|
const q = url.searchParams
|
|
413
512
|
const args: ActionArgs = {
|
|
414
513
|
sel: q.get('sel') || undefined,
|
|
@@ -417,28 +516,27 @@ export function aipeekPlugin(): Plugin {
|
|
|
417
516
|
key: q.get('key') || undefined,
|
|
418
517
|
timeout: q.has('timeout') ? Number(q.get('timeout')) : undefined,
|
|
419
518
|
gone: q.has('gone') ? q.get('gone') !== 'false' : undefined,
|
|
519
|
+
button: q.get('button') === 'right' ? 'right' : q.get('button') === 'left' ? 'left' : undefined,
|
|
520
|
+
x: q.has('x') ? Number(q.get('x')) : undefined,
|
|
521
|
+
y: q.has('y') ? Number(q.get('y')) : undefined,
|
|
420
522
|
}
|
|
421
523
|
const check = resolveAction(parts[0], args)
|
|
422
524
|
if (!check.valid) {
|
|
423
|
-
res
|
|
424
|
-
res.end(check.error)
|
|
525
|
+
send(res, 400, check.error ?? 'invalid action')
|
|
425
526
|
return
|
|
426
527
|
}
|
|
427
|
-
const result = await
|
|
428
|
-
lastRaw = null // page mutated; force fresh collect next read
|
|
528
|
+
const result = await runAction(parts[0], args)
|
|
429
529
|
if (parts[0] === 'screenshot' && result.dataUrl) {
|
|
430
530
|
const dir = resolve(server.config.root, '.aipeek')
|
|
431
531
|
mkdirSync(dir, { recursive: true })
|
|
432
532
|
const name = q.get('out') || `shot-${result.dataUrl.length}.png`
|
|
433
533
|
const file = resolve(dir, name)
|
|
434
534
|
writeFileSync(file, Buffer.from(result.dataUrl.split(',')[1], 'base64'))
|
|
435
|
-
res
|
|
436
|
-
res.end(`saved: ${file}`)
|
|
535
|
+
send(res, 200, `saved: ${file}`)
|
|
437
536
|
return
|
|
438
537
|
}
|
|
439
|
-
res.writeHead(result.ok ? 200 : 422, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
440
538
|
const head = result.ok ? (result.detail || 'ok') : `${result.error}${result.detail ? `\n\nclickable: ${result.detail}` : ''}`
|
|
441
|
-
res.
|
|
539
|
+
send(res, result.ok ? 200 : 422, result.ui ? `${head}\n\n--- ui after ---\n${result.ui}` : head)
|
|
442
540
|
return
|
|
443
541
|
}
|
|
444
542
|
|
|
@@ -448,8 +546,7 @@ export function aipeekPlugin(): Plugin {
|
|
|
448
546
|
lastRaw = raw
|
|
449
547
|
const result = check(raw)
|
|
450
548
|
const output = emitCheck(result)
|
|
451
|
-
res
|
|
452
|
-
res.end(output)
|
|
549
|
+
send(res, result.pass ? 200 : 417, output)
|
|
453
550
|
return
|
|
454
551
|
}
|
|
455
552
|
|
|
@@ -459,12 +556,10 @@ export function aipeekPlugin(): Plugin {
|
|
|
459
556
|
lastRaw = await collectFromClient()
|
|
460
557
|
const result = detail(lastRaw, parts[0], parts[1], full)
|
|
461
558
|
if (result !== null) {
|
|
462
|
-
res
|
|
463
|
-
res.end(result)
|
|
559
|
+
send(res, 200, result)
|
|
464
560
|
return
|
|
465
561
|
}
|
|
466
|
-
res
|
|
467
|
-
res.end(`not found: ${parts.join('/')}`)
|
|
562
|
+
send(res, 404, `not found: ${parts.join('/')}`)
|
|
468
563
|
return
|
|
469
564
|
}
|
|
470
565
|
|
|
@@ -473,19 +568,14 @@ export function aipeekPlugin(): Plugin {
|
|
|
473
568
|
lastRaw = raw
|
|
474
569
|
if (full) {
|
|
475
570
|
const compacted = compact(raw)
|
|
476
|
-
|
|
477
|
-
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
478
|
-
res.end(output)
|
|
571
|
+
send(res, 200, emit(compacted))
|
|
479
572
|
}
|
|
480
573
|
else {
|
|
481
|
-
|
|
482
|
-
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
483
|
-
res.end(output)
|
|
574
|
+
send(res, 200, emitSummary(raw))
|
|
484
575
|
}
|
|
485
576
|
}
|
|
486
577
|
catch (err) {
|
|
487
|
-
res
|
|
488
|
-
res.end(err instanceof Error ? err.message : 'unknown error')
|
|
578
|
+
send(res, 504, err instanceof Error ? err.message : 'unknown error')
|
|
489
579
|
}
|
|
490
580
|
})
|
|
491
581
|
},
|