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.
@@ -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 anything the typed endpoints can't do.
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 the block lives between START_TAG and END_TAG, so
114
- // re-injection is a deterministic splice (find markers, replace between) rather
115
- // than fuzzy line matching. New file write markers + snippet. Existing markers
116
- // replace their contents. No markers yet → append a fresh marked block.
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
- if (!existsSync(path)) {
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.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
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.writeHead(r.ok ? 200 : 422, { 'Content-Type': 'text/plain; charset=utf-8' })
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.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
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.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
359
- res.end(screen || '(empty)')
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.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
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 sendAction(type, args)
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.writeHead(allOk ? 200 : 422, { 'Content-Type': 'text/plain; charset=utf-8' })
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.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
424
- res.end(check.error)
525
+ send(res, 400, check.error ?? 'invalid action')
425
526
  return
426
527
  }
427
- const result = await sendAction(parts[0], args)
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.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
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.end(result.ui ? `${head}\n\n--- ui after ---\n${result.ui}` : head)
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.writeHead(result.pass ? 200 : 417, { 'Content-Type': 'text/plain; charset=utf-8' })
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.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
463
- res.end(result)
559
+ send(res, 200, result)
464
560
  return
465
561
  }
466
- res.writeHead(404, { 'Content-Type': 'text/plain' })
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
- const output = emit(compacted)
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
- const output = emitSummary(raw)
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.writeHead(504, { 'Content-Type': 'text/plain' })
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
  },