aipeek 0.2.2 → 0.2.4
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/dist/{chunk-GIMXNZD5.cjs → chunk-3NVB3GGE.cjs} +3 -2
- package/dist/{chunk-JOY7QP24.js → chunk-72ZKZ42D.js} +3 -2
- package/dist/index.cjs +2 -2
- package/dist/index.js +1 -1
- package/dist/plugin.cjs +2 -2
- package/dist/plugin.js +1 -1
- package/package.json +3 -2
- package/src/client/client-patch.ts +312 -0
- package/src/client/client.ts +689 -0
- package/src/core/action.ts +272 -0
- package/src/core/check.ts +34 -0
- package/src/core/compact.ts +338 -0
- package/src/core/detail.ts +252 -0
- package/src/core/diff.ts +38 -0
- package/src/core/emit.ts +186 -0
- package/src/core/types.ts +75 -0
- package/src/core/util.ts +16 -0
- package/src/index.ts +5 -0
- package/src/server/cli.ts +70 -0
- package/src/server/plugin.ts +536 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
import type { Plugin, ViteDevServer } from 'vite'
|
|
2
|
+
import type { ActionArgs, ActionResult } from '../core/action'
|
|
3
|
+
import type { RawState } from '../core/types'
|
|
4
|
+
import { Buffer } from 'node:buffer'
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { dirname, resolve } from 'node:path'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import { transformSync } from 'esbuild'
|
|
9
|
+
import { resolveAction } from '../core/action'
|
|
10
|
+
import { check } from '../core/check'
|
|
11
|
+
import { compact } from '../core/compact'
|
|
12
|
+
import { detail } from '../core/detail'
|
|
13
|
+
import { diffState } from '../core/diff'
|
|
14
|
+
import { emit, emitCheck, emitDiff, emitSummary } from '../core/emit'
|
|
15
|
+
|
|
16
|
+
function readBody(req: { on: (e: string, cb: (c: unknown) => void) => void }): Promise<string> {
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
let s = ''
|
|
19
|
+
req.on('data', c => s += c)
|
|
20
|
+
req.on('end', () => resolve(s))
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
25
|
+
// client/core sources are read at runtime (client.ts served via Vite transform,
|
|
26
|
+
// client-patch.ts compiled by esbuild). Two layouts: source consumption
|
|
27
|
+
// (__dirname=src/server → ../client) and published package (__dirname=dist → ../src/client).
|
|
28
|
+
const clientDir = existsSync(resolve(__dirname, '../client'))
|
|
29
|
+
? resolve(__dirname, '../client')
|
|
30
|
+
: resolve(__dirname, '../src/client')
|
|
31
|
+
const clientPath = resolve(clientDir, 'client.ts')
|
|
32
|
+
const patchPath = resolve(clientDir, 'client-patch.ts')
|
|
33
|
+
|
|
34
|
+
// Compile patch code to JS at plugin init (synchronous, runs once)
|
|
35
|
+
function compilePatch(): string {
|
|
36
|
+
const source = readFileSync(patchPath, 'utf-8')
|
|
37
|
+
const result = transformSync(source, {
|
|
38
|
+
loader: 'ts',
|
|
39
|
+
target: 'es2020',
|
|
40
|
+
format: 'iife',
|
|
41
|
+
minify: false,
|
|
42
|
+
})
|
|
43
|
+
return result.code
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function aipeekSnippet(port: number) {
|
|
47
|
+
const base = `http://localhost:${port}/__aipeek`
|
|
48
|
+
return `
|
|
49
|
+
# aipeek — Runtime Browser Inspector
|
|
50
|
+
|
|
51
|
+
IMPORTANT: Before debugging any UI issue, visual bug, or runtime error, ALWAYS fetch the live app state first. Do NOT guess — look at the actual browser state.
|
|
52
|
+
|
|
53
|
+
## Read state — cheapest first
|
|
54
|
+
|
|
55
|
+
\`\`\`bash
|
|
56
|
+
curl ${base}/screen # state-machine projection {view, modal, focus, knobs} — START HERE
|
|
57
|
+
curl ${base}/ui # React component tree — deep-dive when /screen isn't enough
|
|
58
|
+
curl '${base}/dom?scope=ChatInput' # semantic DOM scoped to a component — UI as text, src locations
|
|
59
|
+
curl ${base} # high-density summary (ok sections → 1 line, issues → expanded)
|
|
60
|
+
curl ${base}?full # full dump: UI tree + console + network + errors + state
|
|
61
|
+
curl ${base}/check # pass/fail health check — use after code changes
|
|
62
|
+
curl ${base}/console # console logs (errors, warnings, info)
|
|
63
|
+
curl ${base}/network # fetch/XHR requests with status and timing
|
|
64
|
+
curl ${base}/errors # uncaught errors and unhandled rejections
|
|
65
|
+
curl ${base}/state # registered store snapshots
|
|
66
|
+
\`\`\`
|
|
67
|
+
|
|
68
|
+
\`/screen\` projects the whole UI to a few state variables — start there, not \`/ui\`. Append
|
|
69
|
+
\`?full\` for untruncated output. Append \`/{index}\` for a specific item's detail.
|
|
70
|
+
|
|
71
|
+
To inspect or edit a component, work top-down — the full DOM is huge, a scoped view is
|
|
72
|
+
accurate: \`/screen\` or \`/ui\` to find the component, then \`/dom?scope=<Name>\` (matches the
|
|
73
|
+
source path) or \`/dom?sel=<css>\` for just that subtree. Each line carries its source
|
|
74
|
+
location (\`@File.tsx:line\`), so the DOM view tells you exactly where to edit.
|
|
75
|
+
|
|
76
|
+
## Drive the page (acts on the currently-open tab — no separate browser)
|
|
77
|
+
|
|
78
|
+
\`\`\`bash
|
|
79
|
+
curl '${base}/click?text=New' # click by visible text (or sel= for CSS)
|
|
80
|
+
curl '${base}/fill?sel=textarea&value=hi' # set an input/textarea/select value
|
|
81
|
+
curl '${base}/press?key=Enter' # key on focused element (e.g. Control+a)
|
|
82
|
+
curl '${base}/wait?text=Done&timeout=8000' # poll until text/sel appears (add gone=1 to wait until it disappears)
|
|
83
|
+
curl '${base}/screenshot?out=shot.png' # DOM→PNG into .aipeek/ (html-to-image; lossy)
|
|
84
|
+
\`\`\`
|
|
85
|
+
|
|
86
|
+
\`click\`/\`fill\`/\`press\` settle the DOM and append the resulting UI tree (\`--- ui after ---\`)
|
|
87
|
+
to the response — no follow-up read needed. On a miss, the response lists the reachable
|
|
88
|
+
clickable elements so you can re-target. URL-encode any \`sel=\` with non-ASCII or quotes:
|
|
89
|
+
\`curl -G ${base}/click --data-urlencode 'sel=button[title="知识库"]'\`.
|
|
90
|
+
|
|
91
|
+
**Chain — a whole interaction in one round-trip.** POST a JSON array; runs in sequence,
|
|
92
|
+
each step settles before the next, stops on first failure:
|
|
93
|
+
|
|
94
|
+
\`\`\`bash
|
|
95
|
+
curl -X POST ${base}/chain -d '[
|
|
96
|
+
{"type":"click","sel":"button[title=\\"知识库\\"]"},
|
|
97
|
+
{"type":"wait","text":"Done"},
|
|
98
|
+
{"type":"fill","sel":"textarea","value":"hi"},
|
|
99
|
+
{"type":"press","key":"Enter"}
|
|
100
|
+
]'
|
|
101
|
+
\`\`\`
|
|
102
|
+
|
|
103
|
+
**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.
|
|
105
|
+
|
|
106
|
+
aipeek auto-detects errors after HMR and prints them to the terminal — watch for \`[aipeek]\` messages.
|
|
107
|
+
`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// normalize a line so cosmetic diffs (indent, port number) don't break matching
|
|
111
|
+
function norm(line: string): string {
|
|
112
|
+
const t = line.trim()
|
|
113
|
+
const i = t.indexOf('localhost:')
|
|
114
|
+
if (i === -1)
|
|
115
|
+
return t
|
|
116
|
+
let j = i + 'localhost:'.length
|
|
117
|
+
while (j < t.length && t[j] >= '0' && t[j] <= '9') j++
|
|
118
|
+
return `${t.slice(0, i)}localhost:PORT${t.slice(j)}`
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// strip every existing aipeek block by simulating a human reading line-by-line:
|
|
122
|
+
// scan downward; once enough recent lines belong to the snippet we're INSIDE a
|
|
123
|
+
// block; once they stop belonging we're OUTSIDE again. A run whose lines are
|
|
124
|
+
// >50% snippet-content is dropped. Content-fuzzy — survives wording/port edits.
|
|
125
|
+
function stripBlocks(content: string, snippet: string): string {
|
|
126
|
+
const known = new Set(snippet.split('\n').map(norm).filter(l => l.length > 3))
|
|
127
|
+
const lines = content.split('\n')
|
|
128
|
+
const keep: string[] = []
|
|
129
|
+
let inside = false
|
|
130
|
+
let buf: string[] = []
|
|
131
|
+
let hits = 0
|
|
132
|
+
|
|
133
|
+
const flush = () => {
|
|
134
|
+
// settle the buffered run: drop it if it reads as snippet, else keep it
|
|
135
|
+
if (buf.length && hits / buf.length <= 0.5)
|
|
136
|
+
keep.push(...buf)
|
|
137
|
+
buf = []
|
|
138
|
+
hits = 0
|
|
139
|
+
inside = false
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
for (const line of lines) {
|
|
143
|
+
const isKnown = known.has(norm(line))
|
|
144
|
+
if (!inside) {
|
|
145
|
+
if (isKnown) {
|
|
146
|
+
inside = true
|
|
147
|
+
buf = [line]
|
|
148
|
+
hits = 1
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
keep.push(line)
|
|
152
|
+
}
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
// INSIDE: keep accumulating until we drift away from the snippet
|
|
156
|
+
buf.push(line)
|
|
157
|
+
if (isKnown)
|
|
158
|
+
hits++
|
|
159
|
+
// a run of unknown lines means the block ended — settle it
|
|
160
|
+
else if (buf.slice(-3).every(l => !known.has(norm(l))))
|
|
161
|
+
flush()
|
|
162
|
+
}
|
|
163
|
+
flush()
|
|
164
|
+
return keep.join('\n')
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function injectClaudeMd(root: string, port: number) {
|
|
168
|
+
const path = resolve(root, 'CLAUDE.md')
|
|
169
|
+
const snippet = aipeekSnippet(port)
|
|
170
|
+
try {
|
|
171
|
+
if (!existsSync(path)) {
|
|
172
|
+
writeFileSync(path, snippet.trimStart())
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
const stripped = stripBlocks(readFileSync(path, 'utf-8'), snippet).trimEnd()
|
|
176
|
+
writeFileSync(path, `${stripped}\n${snippet}`)
|
|
177
|
+
}
|
|
178
|
+
catch {}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function aipeekPlugin(): Plugin {
|
|
182
|
+
let pendingResolve: ((data: RawState) => void) | null = null
|
|
183
|
+
let server: ViteDevServer
|
|
184
|
+
let lastRaw: RawState | null = null
|
|
185
|
+
let pushTimer: ReturnType<typeof setTimeout> | undefined
|
|
186
|
+
const pendingActions = new Map<number, (r: ActionResult) => void>()
|
|
187
|
+
let actionId = 0
|
|
188
|
+
|
|
189
|
+
let pendingDom: ((dom: string) => void) | null = null
|
|
190
|
+
let pendingScreen: ((screen: string) => void) | null = null
|
|
191
|
+
const pendingEvals = new Map<number, (r: { ok: boolean, value?: string, error?: string }) => void>()
|
|
192
|
+
let evalId = 0
|
|
193
|
+
|
|
194
|
+
// Multi-tab: round one asks only the visible tab (requireVisible). If no tab is
|
|
195
|
+
// visible (user reading the terminal), nobody answers within VISIBLE_MS, so round
|
|
196
|
+
// two drops the guard and any tab replies. `arm` installs the pending slot and
|
|
197
|
+
// returns a clearer; the slot's resolve settles the promise on either round.
|
|
198
|
+
const VISIBLE_MS = 400
|
|
199
|
+
function twoPhase<T>(
|
|
200
|
+
event: string,
|
|
201
|
+
payload: Record<string, unknown>,
|
|
202
|
+
arm: (resolve: (v: T) => void) => () => void,
|
|
203
|
+
fullMs = 3000,
|
|
204
|
+
): Promise<T> {
|
|
205
|
+
return new Promise<T>((resolve, reject) => {
|
|
206
|
+
let settled = false
|
|
207
|
+
const clear = arm((v) => {
|
|
208
|
+
settled = true
|
|
209
|
+
resolve(v)
|
|
210
|
+
})
|
|
211
|
+
server.hot.send(event, { ...payload, requireVisible: true })
|
|
212
|
+
setTimeout(() => {
|
|
213
|
+
if (settled)
|
|
214
|
+
return
|
|
215
|
+
// no visible tab answered — ask everyone
|
|
216
|
+
server.hot.send(event, { ...payload, requireVisible: false })
|
|
217
|
+
setTimeout(() => {
|
|
218
|
+
if (settled)
|
|
219
|
+
return
|
|
220
|
+
clear()
|
|
221
|
+
reject(new Error(`timeout: no client response within ${VISIBLE_MS + fullMs}ms`))
|
|
222
|
+
}, fullMs)
|
|
223
|
+
}, VISIBLE_MS)
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function collectFromClient(): Promise<RawState> {
|
|
228
|
+
return twoPhase<RawState>('aipeek:collect', {}, (resolve) => {
|
|
229
|
+
pendingResolve = resolve
|
|
230
|
+
return () => {
|
|
231
|
+
pendingResolve = null
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function collectDomFromClient(scope?: string, sel?: string): Promise<string> {
|
|
237
|
+
return twoPhase<string>('aipeek:collect-dom', { scope, sel }, (resolve) => {
|
|
238
|
+
pendingDom = resolve
|
|
239
|
+
return () => {
|
|
240
|
+
pendingDom = null
|
|
241
|
+
}
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function collectScreenFromClient(): Promise<string> {
|
|
246
|
+
return twoPhase<string>('aipeek:collect-screen', {}, (resolve) => {
|
|
247
|
+
pendingScreen = resolve
|
|
248
|
+
return () => {
|
|
249
|
+
pendingScreen = null
|
|
250
|
+
}
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function sendAction(type: string, args: ActionArgs): Promise<ActionResult> {
|
|
255
|
+
const id = ++actionId
|
|
256
|
+
// wait actions own their timeout; give the channel that long + slack
|
|
257
|
+
const fullMs = Math.max(args.timeout ?? 0, 3000) + 2000
|
|
258
|
+
return twoPhase<ActionResult>('aipeek:action', { id, type, args }, (resolve) => {
|
|
259
|
+
pendingActions.set(id, resolve)
|
|
260
|
+
return () => {
|
|
261
|
+
pendingActions.delete(id)
|
|
262
|
+
}
|
|
263
|
+
}, fullMs)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function evalInClient(code: string): Promise<{ ok: boolean, value?: string, error?: string }> {
|
|
267
|
+
const id = ++evalId
|
|
268
|
+
return twoPhase('aipeek:eval', { id, code }, (resolve) => {
|
|
269
|
+
pendingEvals.set(id, resolve)
|
|
270
|
+
return () => {
|
|
271
|
+
pendingEvals.delete(id)
|
|
272
|
+
}
|
|
273
|
+
}, 8000)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
name: 'aipeek',
|
|
278
|
+
apply: 'serve',
|
|
279
|
+
|
|
280
|
+
transformIndexHtml() {
|
|
281
|
+
const patchCode = compilePatch()
|
|
282
|
+
return [
|
|
283
|
+
// Synchronous inline script — patches console/fetch/XHR/errors
|
|
284
|
+
// BEFORE any ES modules execute
|
|
285
|
+
{
|
|
286
|
+
tag: 'script',
|
|
287
|
+
children: patchCode,
|
|
288
|
+
injectTo: 'head-prepend',
|
|
289
|
+
},
|
|
290
|
+
// Module script — collectors + HMR channel (can be deferred)
|
|
291
|
+
{
|
|
292
|
+
tag: 'script',
|
|
293
|
+
attrs: { type: 'module' },
|
|
294
|
+
children: `import '/@fs/${clientPath}'`,
|
|
295
|
+
injectTo: 'body',
|
|
296
|
+
},
|
|
297
|
+
]
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
configureServer(_server) {
|
|
301
|
+
server = _server
|
|
302
|
+
injectClaudeMd(server.config.root, server.config.server.port || 5173)
|
|
303
|
+
|
|
304
|
+
server.hot.on('aipeek:state', (data: RawState) => {
|
|
305
|
+
if (pendingResolve) {
|
|
306
|
+
pendingResolve(data)
|
|
307
|
+
pendingResolve = null
|
|
308
|
+
}
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
server.hot.on('aipeek:result', (data: ActionResult & { id: number }) => {
|
|
312
|
+
const resolve = pendingActions.get(data.id)
|
|
313
|
+
if (resolve) {
|
|
314
|
+
pendingActions.delete(data.id)
|
|
315
|
+
resolve(data)
|
|
316
|
+
}
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
server.hot.on('aipeek:eval-result', (data: { id: number, ok: boolean, value?: string, error?: string }) => {
|
|
320
|
+
const resolve = pendingEvals.get(data.id)
|
|
321
|
+
if (resolve) {
|
|
322
|
+
pendingEvals.delete(data.id)
|
|
323
|
+
resolve(data)
|
|
324
|
+
}
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
server.hot.on('aipeek:dom', (data: { dom: string }) => {
|
|
328
|
+
if (pendingDom) {
|
|
329
|
+
pendingDom(data.dom)
|
|
330
|
+
pendingDom = null
|
|
331
|
+
}
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
server.hot.on('aipeek:screen', (data: { screen: string }) => {
|
|
335
|
+
if (pendingScreen) {
|
|
336
|
+
pendingScreen(data.screen)
|
|
337
|
+
pendingScreen = null
|
|
338
|
+
}
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
// push mode: auto-detect errors after HMR
|
|
342
|
+
server.hot.on('vite:afterUpdate', () => {
|
|
343
|
+
clearTimeout(pushTimer)
|
|
344
|
+
pushTimer = setTimeout(async () => {
|
|
345
|
+
try {
|
|
346
|
+
const raw = await collectFromClient()
|
|
347
|
+
const diff = diffState(lastRaw, raw)
|
|
348
|
+
lastRaw = raw
|
|
349
|
+
if (!diff.clean) {
|
|
350
|
+
const msg = emitDiff(diff)
|
|
351
|
+
if (msg)
|
|
352
|
+
server.config.logger.warn(msg)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
catch {}
|
|
356
|
+
}, 500)
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
// /__aipeek — summary
|
|
360
|
+
// /__aipeek/{section}/{index} — detail
|
|
361
|
+
// /__aipeek/check — health check
|
|
362
|
+
server.middlewares.use('/__aipeek', async (req, res) => {
|
|
363
|
+
const url = new URL(req.url || '/', 'http://localhost')
|
|
364
|
+
const parts = url.pathname.split('/').filter(Boolean)
|
|
365
|
+
const full = url.searchParams.has('full')
|
|
366
|
+
|
|
367
|
+
try {
|
|
368
|
+
// /__aipeek/eval — run arbitrary JS in the page. POST body = code,
|
|
369
|
+
// or ?code=. The page evaluates it and returns the result (or thrown
|
|
370
|
+
// error). The escape hatch for anything the typed endpoints can't do:
|
|
371
|
+
// install event listeners, read closures, probe real event flow.
|
|
372
|
+
if (parts[0] === 'eval') {
|
|
373
|
+
let code = url.searchParams.get('code') || ''
|
|
374
|
+
if (!code && req.method === 'POST')
|
|
375
|
+
code = await readBody(req)
|
|
376
|
+
if (!code) {
|
|
377
|
+
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
378
|
+
res.end('eval needs ?code= or a POST body')
|
|
379
|
+
return
|
|
380
|
+
}
|
|
381
|
+
const r = await evalInClient(code)
|
|
382
|
+
res.writeHead(r.ok ? 200 : 422, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
383
|
+
res.end(r.ok ? (r.value ?? 'undefined') : `error: ${r.error}`)
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// /__aipeek/dom[?scope=Component|?sel=CSS] — semantic DOM, scoped, on-demand
|
|
388
|
+
if (parts[0] === 'dom') {
|
|
389
|
+
const dom = await collectDomFromClient(
|
|
390
|
+
url.searchParams.get('scope') || undefined,
|
|
391
|
+
url.searchParams.get('sel') || undefined,
|
|
392
|
+
)
|
|
393
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
394
|
+
res.end(dom || '(empty)')
|
|
395
|
+
return
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// /__aipeek/screen — state-machine projection {view, modal, focus, knobs}
|
|
399
|
+
if (parts[0] === 'screen') {
|
|
400
|
+
const screen = await collectScreenFromClient()
|
|
401
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
402
|
+
res.end(screen || '(empty)')
|
|
403
|
+
return
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// /__aipeek/chain — POST a JSON array of actions, run them in
|
|
407
|
+
// sequence (each settles the DOM before the next), stop on first
|
|
408
|
+
// failure. One round-trip for a whole interaction.
|
|
409
|
+
if (parts[0] === 'chain') {
|
|
410
|
+
const body = await readBody(req)
|
|
411
|
+
let steps: Array<{ type: string } & ActionArgs>
|
|
412
|
+
try {
|
|
413
|
+
steps = JSON.parse(body)
|
|
414
|
+
if (!Array.isArray(steps))
|
|
415
|
+
throw new Error('body must be a JSON array')
|
|
416
|
+
}
|
|
417
|
+
catch (e) {
|
|
418
|
+
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
419
|
+
res.end(`invalid chain body: ${e instanceof Error ? e.message : String(e)}`)
|
|
420
|
+
return
|
|
421
|
+
}
|
|
422
|
+
lastRaw = null
|
|
423
|
+
const lines: string[] = []
|
|
424
|
+
let lastUi = ''
|
|
425
|
+
let allOk = true
|
|
426
|
+
for (let i = 0; i < steps.length; i++) {
|
|
427
|
+
const { type, ...args } = steps[i]
|
|
428
|
+
const check = resolveAction(type, args)
|
|
429
|
+
if (!check.valid) {
|
|
430
|
+
lines.push(`[${i}] ✗ ${type}: ${check.error}`)
|
|
431
|
+
allOk = false
|
|
432
|
+
break
|
|
433
|
+
}
|
|
434
|
+
const r = await sendAction(type, args)
|
|
435
|
+
lines.push(`[${i}] ${r.ok ? '✓' : '✗'} ${type}: ${r.ok ? (r.detail || 'ok') : r.error}`)
|
|
436
|
+
// Per-step screen projection — captures the transition each
|
|
437
|
+
// mutating step caused, so a view change mid-chain is visible
|
|
438
|
+
// at its source step rather than collapsed into the final tree.
|
|
439
|
+
if (r.screen)
|
|
440
|
+
lines.push(r.screen.split('\n').map(l => ` ${l}`).join('\n'))
|
|
441
|
+
if (r.ui)
|
|
442
|
+
lastUi = r.ui
|
|
443
|
+
if (!r.ok) {
|
|
444
|
+
allOk = false
|
|
445
|
+
break
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
res.writeHead(allOk ? 200 : 422, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
449
|
+
res.end(lastUi ? `${lines.join('\n')}\n\n--- ui after ---\n${lastUi}` : lines.join('\n'))
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// action endpoints: /__aipeek/{click|fill|press|wait|screenshot}?...
|
|
454
|
+
if (['click', 'fill', 'press', 'wait', 'screenshot'].includes(parts[0])) {
|
|
455
|
+
const q = url.searchParams
|
|
456
|
+
const args: ActionArgs = {
|
|
457
|
+
sel: q.get('sel') || undefined,
|
|
458
|
+
text: q.get('text') || undefined,
|
|
459
|
+
value: q.has('value') ? q.get('value')! : undefined,
|
|
460
|
+
key: q.get('key') || undefined,
|
|
461
|
+
timeout: q.has('timeout') ? Number(q.get('timeout')) : undefined,
|
|
462
|
+
gone: q.has('gone') ? q.get('gone') !== 'false' : undefined,
|
|
463
|
+
}
|
|
464
|
+
const check = resolveAction(parts[0], args)
|
|
465
|
+
if (!check.valid) {
|
|
466
|
+
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
467
|
+
res.end(check.error)
|
|
468
|
+
return
|
|
469
|
+
}
|
|
470
|
+
const result = await sendAction(parts[0], args)
|
|
471
|
+
lastRaw = null // page mutated; force fresh collect next read
|
|
472
|
+
if (parts[0] === 'screenshot' && result.dataUrl) {
|
|
473
|
+
const dir = resolve(server.config.root, '.aipeek')
|
|
474
|
+
mkdirSync(dir, { recursive: true })
|
|
475
|
+
const name = q.get('out') || `shot-${result.dataUrl.length}.png`
|
|
476
|
+
const file = resolve(dir, name)
|
|
477
|
+
writeFileSync(file, Buffer.from(result.dataUrl.split(',')[1], 'base64'))
|
|
478
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
479
|
+
res.end(`saved: ${file}`)
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
res.writeHead(result.ok ? 200 : 422, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
483
|
+
const head = result.ok ? (result.detail || 'ok') : `${result.error}${result.detail ? `\n\nclickable: ${result.detail}` : ''}`
|
|
484
|
+
res.end(result.ui ? `${head}\n\n--- ui after ---\n${result.ui}` : head)
|
|
485
|
+
return
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// check endpoint
|
|
489
|
+
if (parts[0] === 'check') {
|
|
490
|
+
const raw = await collectFromClient()
|
|
491
|
+
lastRaw = raw
|
|
492
|
+
const result = check(raw)
|
|
493
|
+
const output = emitCheck(result)
|
|
494
|
+
res.writeHead(result.pass ? 200 : 417, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
495
|
+
res.end(output)
|
|
496
|
+
return
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// detail: /__aipeek/{section}[/{index}][?full]
|
|
500
|
+
if (parts.length >= 1) {
|
|
501
|
+
if (!lastRaw)
|
|
502
|
+
lastRaw = await collectFromClient()
|
|
503
|
+
const result = detail(lastRaw, parts[0], parts[1], full)
|
|
504
|
+
if (result !== null) {
|
|
505
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
506
|
+
res.end(result)
|
|
507
|
+
return
|
|
508
|
+
}
|
|
509
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
|
510
|
+
res.end(`not found: ${parts.join('/')}`)
|
|
511
|
+
return
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// summary or full: /__aipeek[?full]
|
|
515
|
+
const raw = await collectFromClient()
|
|
516
|
+
lastRaw = raw
|
|
517
|
+
if (full) {
|
|
518
|
+
const compacted = compact(raw)
|
|
519
|
+
const output = emit(compacted)
|
|
520
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
521
|
+
res.end(output)
|
|
522
|
+
}
|
|
523
|
+
else {
|
|
524
|
+
const output = emitSummary(raw)
|
|
525
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
526
|
+
res.end(output)
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
catch (err) {
|
|
530
|
+
res.writeHead(504, { 'Content-Type': 'text/plain' })
|
|
531
|
+
res.end(err instanceof Error ? err.message : 'unknown error')
|
|
532
|
+
}
|
|
533
|
+
})
|
|
534
|
+
},
|
|
535
|
+
}
|
|
536
|
+
}
|