aipeek 0.2.7 → 0.2.9
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 +92 -18
- package/dist/{chunk-STYCUT23.cjs → chunk-7ALIH3JX.cjs} +622 -46
- package/dist/{chunk-37VLLZIU.js → chunk-7NJSWR7E.js} +621 -45
- package/dist/index.cjs +2 -2
- package/dist/index.d.cts +43 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.js +1 -1
- package/dist/plugin.cjs +2 -2
- package/dist/plugin.js +1 -1
- package/package.json +3 -1
- package/src/babel/line-profiler.ts +190 -0
- package/src/client/client-patch.ts +332 -2
- package/src/client/client.ts +259 -44
- package/src/core/action.ts +199 -22
- 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 +250 -0
- package/src/core/types.ts +73 -0
- package/src/core/util.ts +115 -0
- package/src/server/plugin.ts +463 -52
|
@@ -6,7 +6,7 @@ import { fileURLToPath } from "url";
|
|
|
6
6
|
import { transformSync } from "esbuild";
|
|
7
7
|
|
|
8
8
|
// src/core/action.ts
|
|
9
|
-
var TYPES = ["click", "fill", "press", "wait", "screenshot", "realclick", "query"];
|
|
9
|
+
var TYPES = ["click", "fill", "press", "wait", "screenshot", "realclick", "query", "assert", "drag", "scrollIntoView", "drop", "clipboard"];
|
|
10
10
|
function resolveAction(type, args) {
|
|
11
11
|
if (!TYPES.includes(type))
|
|
12
12
|
return { valid: false, error: `unknown action: ${type}` };
|
|
@@ -30,6 +30,30 @@ function resolveAction(type, args) {
|
|
|
30
30
|
return { valid: true };
|
|
31
31
|
case "query":
|
|
32
32
|
return args.sel ? { valid: true } : { valid: false, error: "query needs sel=" };
|
|
33
|
+
case "assert":
|
|
34
|
+
if (!args.screen && !args.sel)
|
|
35
|
+
return { valid: false, error: "assert needs screen= (a __AIPEEK_SCREEN__ key) or sel=" };
|
|
36
|
+
if (args.equals === void 0)
|
|
37
|
+
return { valid: false, error: "assert needs equals=" };
|
|
38
|
+
return { valid: true };
|
|
39
|
+
case "drag":
|
|
40
|
+
if (!hasTarget)
|
|
41
|
+
return { valid: false, error: "drag needs sel= or text= (the source)" };
|
|
42
|
+
if (!args.to)
|
|
43
|
+
return { valid: false, error: "drag needs to= (destination selector)" };
|
|
44
|
+
return { valid: true };
|
|
45
|
+
case "scrollIntoView":
|
|
46
|
+
return hasTarget ? { valid: true } : { valid: false, error: "scrollIntoView needs sel= or text=" };
|
|
47
|
+
case "drop":
|
|
48
|
+
if (!hasTarget)
|
|
49
|
+
return { valid: false, error: "drop needs sel= or text= (the drop target)" };
|
|
50
|
+
if (!args.files || !args.files.length)
|
|
51
|
+
return { valid: false, error: "drop needs files= (array of file names)" };
|
|
52
|
+
return { valid: true };
|
|
53
|
+
case "clipboard":
|
|
54
|
+
if (args.mode === "write" && args.value === void 0)
|
|
55
|
+
return { valid: false, error: "clipboard write needs value=" };
|
|
56
|
+
return { valid: true };
|
|
33
57
|
default:
|
|
34
58
|
return { valid: false, error: `unknown action: ${type}` };
|
|
35
59
|
}
|
|
@@ -67,6 +91,156 @@ function check(raw) {
|
|
|
67
91
|
};
|
|
68
92
|
}
|
|
69
93
|
|
|
94
|
+
// src/core/perf.ts
|
|
95
|
+
var FRAME_NOISE_PCT = 3;
|
|
96
|
+
var SELF_NOISE_REL = 0.1;
|
|
97
|
+
var SELF_NOISE_MS = 5;
|
|
98
|
+
var avgOf = (samples) => samples.length > 0 ? samples.reduce((s, v) => s + v, 0) / samples.length : 0;
|
|
99
|
+
function summarizeStat(stat) {
|
|
100
|
+
const avg = stat.n > 0 ? stat.total / stat.n : 0;
|
|
101
|
+
return {
|
|
102
|
+
total: stat.total,
|
|
103
|
+
n: stat.n,
|
|
104
|
+
avg,
|
|
105
|
+
max: stat.max
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function compactPerformance(p) {
|
|
109
|
+
const all = p.buckets.find((b) => b.name === "__all__");
|
|
110
|
+
if (!all) return "perf: (no data)";
|
|
111
|
+
const { total, long, samples } = all.frames;
|
|
112
|
+
const fps = total > 0 ? Math.round(1e3 / avgOf(samples) * 10) / 10 : 0;
|
|
113
|
+
const droppedPct = total > 0 ? Math.round(long / total * 100) : 0;
|
|
114
|
+
const parts = [`${fps}fps`, `${droppedPct}% dropped`];
|
|
115
|
+
if (p.longtasks.count > 0) {
|
|
116
|
+
parts.push(`${p.longtasks.count} longtasks(max ${Math.round(p.longtasks.max)}ms)`);
|
|
117
|
+
}
|
|
118
|
+
const topComps = Object.entries(all.components).map(([name, stat]) => ({ name, total: stat.total })).sort((a, b) => b.total - a.total).slice(0, 2);
|
|
119
|
+
if (topComps.length > 0 && p.mobxPatched) {
|
|
120
|
+
const hot = topComps.map((c) => `${c.name} ${Math.round(c.total)}ms`).join(", ");
|
|
121
|
+
parts.push(`hot: ${hot}`);
|
|
122
|
+
}
|
|
123
|
+
return `perf: ${parts.join(" \xB7 ")}`;
|
|
124
|
+
}
|
|
125
|
+
function detailPerformance(p) {
|
|
126
|
+
const lines = [];
|
|
127
|
+
const windowSec = (p.windowMs / 1e3).toFixed(1);
|
|
128
|
+
lines.push(`=== Performance (${windowSec}s window) ===`);
|
|
129
|
+
if (p.hiddenFrames > 0) {
|
|
130
|
+
lines.push(`\u26A0 Tab was backgrounded \u2014 ${p.hiddenFrames} hidden frames skipped (rAF throttling lies about timing)`);
|
|
131
|
+
lines.push("For accurate profiling: keep tab FOREGROUND + run /profile/reset before reproducing");
|
|
132
|
+
}
|
|
133
|
+
lines.push(`MobX attribution: ${p.mobxPatched ? "ON" : "OFF"}`);
|
|
134
|
+
if (!p.mobxPatched) {
|
|
135
|
+
lines.push("(Component attribution disabled \u2014 set window.__AIPEEK_MOBX__={Reaction} in src/lib/mobx.ts to enable)");
|
|
136
|
+
}
|
|
137
|
+
lines.push("");
|
|
138
|
+
if (p.longtasks.count > 0) {
|
|
139
|
+
lines.push(`Longtasks (>50ms): ${p.longtasks.count}, max ${Math.round(p.longtasks.max)}ms`);
|
|
140
|
+
lines.push("");
|
|
141
|
+
}
|
|
142
|
+
const showPerView = p.buckets.length > 1;
|
|
143
|
+
for (const bucket of p.buckets) {
|
|
144
|
+
if (bucket.name !== "__all__" && !showPerView) continue;
|
|
145
|
+
lines.push(`--- ${bucket.name} ---`);
|
|
146
|
+
const { total, long, max, samples } = bucket.frames;
|
|
147
|
+
const droppedPct = total > 0 ? (long / total * 100).toFixed(1) : "0.0";
|
|
148
|
+
const avgFrame = avgOf(samples).toFixed(1);
|
|
149
|
+
lines.push(`Frames: ${total} total, ${long} dropped (${droppedPct}%), max ${Math.round(max)}ms, avg ${avgFrame}ms`);
|
|
150
|
+
const comps = Object.entries(bucket.components).map(([name, stat]) => ({ name, ...summarizeStat(stat) })).sort((a, b) => b.total - a.total).slice(0, 20);
|
|
151
|
+
if (comps.length > 0 && p.mobxPatched) {
|
|
152
|
+
lines.push("Components (by total self-time):");
|
|
153
|
+
for (const c of comps) {
|
|
154
|
+
lines.push(` ${c.name.padEnd(30)} ${Math.round(c.total)}ms / ${c.n}\xD7 = ${c.avg.toFixed(1)}ms avg, max ${Math.round(c.max)}ms`);
|
|
155
|
+
}
|
|
156
|
+
} else if (!p.mobxPatched) {
|
|
157
|
+
lines.push("(no component data \u2014 attribution OFF)");
|
|
158
|
+
}
|
|
159
|
+
const hotLines = Object.entries(bucket.lines ?? {}).map(([label, stat]) => ({ label, ...summarizeStat(stat) })).sort((a, b) => b.total - a.total).slice(0, 15);
|
|
160
|
+
if (hotLines.length > 0) {
|
|
161
|
+
lines.push("Hot lines (by total inclusive time \u2014 contains nested calls):");
|
|
162
|
+
for (const l of hotLines) {
|
|
163
|
+
lines.push(` ${l.label.padEnd(36)} ${Math.round(l.total)}ms / ${l.n}\xD7 = ${l.avg.toFixed(1)}ms avg, max ${Math.round(l.max)}ms`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
lines.push("");
|
|
167
|
+
}
|
|
168
|
+
return lines.join("\n");
|
|
169
|
+
}
|
|
170
|
+
function diffPerformance(before, after) {
|
|
171
|
+
const out = [];
|
|
172
|
+
const beforeAll = before.buckets.find((b) => b.name === "__all__");
|
|
173
|
+
const afterAll = after.buckets.find((b) => b.name === "__all__");
|
|
174
|
+
const droppedPct = (b) => b && b.frames.total > 0 ? b.frames.long / b.frames.total * 100 : 0;
|
|
175
|
+
const beforeDrop = droppedPct(beforeAll);
|
|
176
|
+
const afterDrop = droppedPct(afterAll);
|
|
177
|
+
const dropDelta = afterDrop - beforeDrop;
|
|
178
|
+
const sumTotal = (b) => b ? Object.values(b.components).reduce((s, c) => s + c.total, 0) : 0;
|
|
179
|
+
const beforeSelf = sumTotal(beforeAll);
|
|
180
|
+
const afterSelf = sumTotal(afterAll);
|
|
181
|
+
const selfDelta = afterSelf - beforeSelf;
|
|
182
|
+
const bothSampledFrames = (beforeAll?.frames.total ?? 0) > 0 && (afterAll?.frames.total ?? 0) > 0;
|
|
183
|
+
const sawDrops = (beforeAll?.frames.long ?? 0) > 0 || (afterAll?.frames.long ?? 0) > 0;
|
|
184
|
+
const framesExercised = bothSampledFrames && sawDrops;
|
|
185
|
+
const afterBlind = (afterAll?.frames.total ?? 0) === 0 && afterSelf === 0;
|
|
186
|
+
let verdict;
|
|
187
|
+
if (afterBlind) {
|
|
188
|
+
verdict = "NO DATA";
|
|
189
|
+
} else if (framesExercised) {
|
|
190
|
+
if (dropDelta < -FRAME_NOISE_PCT) verdict = "IMPROVED";
|
|
191
|
+
else if (dropDelta > FRAME_NOISE_PCT) verdict = "REGRESSED";
|
|
192
|
+
else verdict = "UNCHANGED";
|
|
193
|
+
} else {
|
|
194
|
+
const floor = Math.max(SELF_NOISE_MS, beforeSelf * SELF_NOISE_REL);
|
|
195
|
+
if (selfDelta < -floor) verdict = "IMPROVED";
|
|
196
|
+
else if (selfDelta > floor) verdict = "REGRESSED";
|
|
197
|
+
else verdict = "UNCHANGED";
|
|
198
|
+
}
|
|
199
|
+
out.push(`=== Perf diff: ${verdict} ===`);
|
|
200
|
+
if (afterBlind) {
|
|
201
|
+
out.push("after-window recorded no frames and no self-time \u2014 tab backgrounded or idle. Re-run with the tab focused and the interaction reproduced.");
|
|
202
|
+
out.push("");
|
|
203
|
+
return out.join("\n").trimEnd();
|
|
204
|
+
}
|
|
205
|
+
out.push(`Dropped frames: ${beforeDrop.toFixed(1)}% \u2192 ${afterDrop.toFixed(1)}% (${dropDelta >= 0 ? "+" : ""}${dropDelta.toFixed(1)}%)`);
|
|
206
|
+
if (!framesExercised && Math.round(selfDelta) !== 0) {
|
|
207
|
+
out.push(`Total self-time: ${Math.round(beforeSelf)}ms \u2192 ${Math.round(afterSelf)}ms (${selfDelta >= 0 ? "+" : ""}${Math.round(selfDelta)}ms) \u2014 frames flat, verdict by self-time`);
|
|
208
|
+
}
|
|
209
|
+
if (before.longtasks.count !== after.longtasks.count) {
|
|
210
|
+
out.push(`Longtasks: ${before.longtasks.count} \u2192 ${after.longtasks.count}`);
|
|
211
|
+
}
|
|
212
|
+
out.push("");
|
|
213
|
+
const compDelta = keyedTotalDelta(beforeAll?.components ?? {}, afterAll?.components ?? {});
|
|
214
|
+
if (compDelta.length > 0) {
|
|
215
|
+
out.push("Components (\u0394 total self-time):");
|
|
216
|
+
for (const d of compDelta) out.push(" " + formatDelta(d));
|
|
217
|
+
out.push("");
|
|
218
|
+
}
|
|
219
|
+
const lineDelta = keyedTotalDelta(beforeAll?.lines ?? {}, afterAll?.lines ?? {});
|
|
220
|
+
if (lineDelta.length > 0) {
|
|
221
|
+
out.push("Hot lines (\u0394 total inclusive time):");
|
|
222
|
+
for (const d of lineDelta) out.push(" " + formatDelta(d));
|
|
223
|
+
}
|
|
224
|
+
return out.join("\n").trimEnd();
|
|
225
|
+
}
|
|
226
|
+
function keyedTotalDelta(a, b) {
|
|
227
|
+
const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
228
|
+
const rows = [];
|
|
229
|
+
for (const key of keys) {
|
|
230
|
+
const beforeT = a[key]?.total ?? 0;
|
|
231
|
+
const afterT = b[key]?.total ?? 0;
|
|
232
|
+
const delta = afterT - beforeT;
|
|
233
|
+
if (Math.round(delta) === 0) continue;
|
|
234
|
+
rows.push({ key, before: beforeT, after: afterT, delta, isNew: !a[key], gone: !b[key] });
|
|
235
|
+
}
|
|
236
|
+
return rows.sort((x, y) => Math.abs(y.delta) - Math.abs(x.delta)).slice(0, 20);
|
|
237
|
+
}
|
|
238
|
+
function formatDelta(d) {
|
|
239
|
+
const sign = d.delta >= 0 ? "+" : "";
|
|
240
|
+
const tag = d.isNew ? " (new)" : d.gone ? " (gone)" : "";
|
|
241
|
+
return `${d.key.padEnd(36)} ${Math.round(d.before)}ms \u2192 ${Math.round(d.after)}ms (${sign}${Math.round(d.delta)}ms)${tag}`;
|
|
242
|
+
}
|
|
243
|
+
|
|
70
244
|
// src/core/util.ts
|
|
71
245
|
function truncate(s, max) {
|
|
72
246
|
return s.length > max ? `${s.slice(0, max)}\u2026` : s;
|
|
@@ -122,6 +296,78 @@ function compactUrl(url, search) {
|
|
|
122
296
|
return truncate(url, 80);
|
|
123
297
|
}
|
|
124
298
|
}
|
|
299
|
+
var STALE_MS = 3e4;
|
|
300
|
+
var isLive = (t, now) => now - t.lastSeen < STALE_MS;
|
|
301
|
+
function formatTabs(tabs, now) {
|
|
302
|
+
const live = tabs.filter((t) => isLive(t, now));
|
|
303
|
+
if (!live.length)
|
|
304
|
+
return "(no live tabs)";
|
|
305
|
+
return live.sort((a, b) => Number(b.visible) - Number(a.visible) || a.id.localeCompare(b.id)).map((t) => {
|
|
306
|
+
const age = Math.round((now - t.lastSeen) / 1e3);
|
|
307
|
+
const mark = t.visible ? "\u25CF visible" : "\u25CB background";
|
|
308
|
+
return `${t.id} ${mark} ${truncate(t.title || "(untitled)", 40)} ${compactUrl(t.url)} ${age}s ago`;
|
|
309
|
+
}).join("\n");
|
|
310
|
+
}
|
|
311
|
+
function diagnose(tab, tabs, now, socketCount, port) {
|
|
312
|
+
if (tab) {
|
|
313
|
+
const t = tabs.find((x) => x.id === tab);
|
|
314
|
+
return t && isLive(t, now) ? `tab '${tab}' is connected but didn't answer in time \u2014 the handler likely threw or hung (bad selector, infinite loop, or a read that needs the tab foreground). Check /__aipeek/console.` : `tab '${tab}' is not connected \u2014 it closed, or the server restarted and it hasn't re-handshaked yet. Check /__aipeek/tabs for live ids; if the page is mid self-heal, retry in ~2s.`;
|
|
315
|
+
}
|
|
316
|
+
const live = tabs.filter((t) => isLive(t, now));
|
|
317
|
+
if (live.length)
|
|
318
|
+
return `${live.length} tab(s) connected but none answered \u2014 a handler threw, or they just closed. Check /__aipeek/tabs (still live?) and /__aipeek/console (errors?).`;
|
|
319
|
+
return socketCount > 0 ? `no aipeek tab registered, but ${socketCount} page(s) are connected to this dev server via HMR \u2014 the page is open yet the aipeek client didn't load. Is it served by THIS vite server on :${port}? A separate vite config / vite preview / production build won't inject it.` : `no browser tab connected to this dev server on :${port} \u2014 open the app, or check you're hitting the right port.`;
|
|
320
|
+
}
|
|
321
|
+
function formatActions(entries, anchorTs) {
|
|
322
|
+
if (!entries.length)
|
|
323
|
+
return "(no actions)";
|
|
324
|
+
const sorted = [...entries].sort((a, b) => a.ts - b.ts);
|
|
325
|
+
const multiTab = new Set(sorted.map((e) => e.tab).filter(Boolean)).size > 1;
|
|
326
|
+
const lines = [];
|
|
327
|
+
const prevByTab = /* @__PURE__ */ new Map();
|
|
328
|
+
let anchored = false;
|
|
329
|
+
for (const e of sorted) {
|
|
330
|
+
const isAnchor = anchorTs !== void 0 && !anchored && e.ts === anchorTs;
|
|
331
|
+
if (isAnchor) {
|
|
332
|
+
lines.push("\u2014\u2014\u2014\u2014\u2014\u2014 \u4F60\u5F53\u524D\u7684\u884C\u4E3A \u2014\u2014\u2014\u2014\u2014\u2014");
|
|
333
|
+
anchored = true;
|
|
334
|
+
}
|
|
335
|
+
const who = e.trusted ? "T" : "S";
|
|
336
|
+
const tag = multiTab ? `${e.tab ?? "?"} ` : "";
|
|
337
|
+
const val = e.value ? ` value="${e.value}"` : "";
|
|
338
|
+
const change = uiChange(prevByTab.get(e.tab), e);
|
|
339
|
+
lines.push(`${tag}[${who}] ${e.type} ${e.target}${val}${change ? ` \u2192 ${change}` : ""}`);
|
|
340
|
+
if (isAnchor)
|
|
341
|
+
lines.push("\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014");
|
|
342
|
+
prevByTab.set(e.tab, e);
|
|
343
|
+
}
|
|
344
|
+
return lines.join("\n");
|
|
345
|
+
}
|
|
346
|
+
function appendAction(log, tab, entry, max) {
|
|
347
|
+
const last = log[log.length - 1];
|
|
348
|
+
if (entry.type === "input" && last?.type === "input" && last.tab === tab && last.target === entry.target) {
|
|
349
|
+
log[log.length - 1] = { ...entry, tab };
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
log.push({ ...entry, tab });
|
|
353
|
+
if (log.length > max)
|
|
354
|
+
log.shift();
|
|
355
|
+
}
|
|
356
|
+
function uiChange(prev, cur) {
|
|
357
|
+
if (cur.modal === void 0)
|
|
358
|
+
return "";
|
|
359
|
+
const prevModal = prev?.modal ?? "none";
|
|
360
|
+
if (cur.modal !== prevModal) {
|
|
361
|
+
if (cur.modal === "none")
|
|
362
|
+
return "\u5F39\u7A97\u5173\u95ED";
|
|
363
|
+
if (prevModal === "none")
|
|
364
|
+
return `\u5F39\u7A97\u6253\u5F00\u300C${cur.modal}\u300D`;
|
|
365
|
+
return `\u5F39\u7A97\u5207\u6362\u300C${cur.modal}\u300D`;
|
|
366
|
+
}
|
|
367
|
+
if (prev && cur.view !== void 0 && cur.view !== prev.view)
|
|
368
|
+
return `\u89C6\u56FE ${prev.view}\u2192${cur.view}`;
|
|
369
|
+
return "";
|
|
370
|
+
}
|
|
125
371
|
|
|
126
372
|
// src/core/compact.ts
|
|
127
373
|
var SLOW_THRESHOLD = 1e3;
|
|
@@ -358,6 +604,7 @@ function compact(raw) {
|
|
|
358
604
|
network: compactNetwork(raw.network),
|
|
359
605
|
errors: compactErrors(raw.errors),
|
|
360
606
|
state: compactState(raw.state),
|
|
607
|
+
performance: raw.performance ? detailPerformance(raw.performance) : void 0,
|
|
361
608
|
timestamp: raw.timestamp,
|
|
362
609
|
counts: {
|
|
363
610
|
console: raw.console.length,
|
|
@@ -381,6 +628,8 @@ function detail(raw, section, index, full) {
|
|
|
381
628
|
return detailError(raw.errors, index, full);
|
|
382
629
|
case "state":
|
|
383
630
|
return detailState(raw.state, index, full);
|
|
631
|
+
case "profile":
|
|
632
|
+
return raw.performance ? detailPerformance(raw.performance) : "(no perf data \u2014 is the tab foreground?)";
|
|
384
633
|
default:
|
|
385
634
|
return null;
|
|
386
635
|
}
|
|
@@ -578,6 +827,43 @@ function byteSize(s) {
|
|
|
578
827
|
}
|
|
579
828
|
|
|
580
829
|
// src/core/diff.ts
|
|
830
|
+
function diffScreen(before, after, newErrors, newExceptions, newFailedRequests) {
|
|
831
|
+
const lines = [];
|
|
832
|
+
if (after.view !== before.view)
|
|
833
|
+
lines.push(`view: ${before.view} \u2192 ${after.view}`);
|
|
834
|
+
if (after.modal !== before.modal) {
|
|
835
|
+
if (after.modal === "none")
|
|
836
|
+
lines.push(`modal: closed (${before.modal})`);
|
|
837
|
+
else if (before.modal === "none")
|
|
838
|
+
lines.push(`modal: opened ${after.modal}`);
|
|
839
|
+
else
|
|
840
|
+
lines.push(`modal: ${before.modal} \u2192 ${after.modal}`);
|
|
841
|
+
}
|
|
842
|
+
if (after.focus !== before.focus)
|
|
843
|
+
lines.push(`focus: ${after.focus}`);
|
|
844
|
+
const beforeDomain = before.domain ?? {};
|
|
845
|
+
const afterDomain = after.domain ?? {};
|
|
846
|
+
for (const key of /* @__PURE__ */ new Set([...Object.keys(beforeDomain), ...Object.keys(afterDomain)])) {
|
|
847
|
+
const b = stringifyDomain(beforeDomain[key]);
|
|
848
|
+
const a = stringifyDomain(afterDomain[key]);
|
|
849
|
+
if (b !== a)
|
|
850
|
+
lines.push(`${key}: ${b} \u2192 ${a}`);
|
|
851
|
+
}
|
|
852
|
+
for (const e of newErrors)
|
|
853
|
+
lines.push(`+error: ${e.text}`);
|
|
854
|
+
for (const e of newExceptions)
|
|
855
|
+
lines.push(`+exception: ${e.message}`);
|
|
856
|
+
for (const r of newFailedRequests)
|
|
857
|
+
lines.push(`+failed: ${r.method} ${r.url} \u2192 ${r.status || "failed"}`);
|
|
858
|
+
return lines;
|
|
859
|
+
}
|
|
860
|
+
function stringifyDomain(v) {
|
|
861
|
+
if (v === null || v === void 0)
|
|
862
|
+
return String(v);
|
|
863
|
+
if (typeof v === "object")
|
|
864
|
+
return JSON.stringify(v).slice(0, 80);
|
|
865
|
+
return String(v).slice(0, 80);
|
|
866
|
+
}
|
|
581
867
|
function diffState(prev, curr) {
|
|
582
868
|
if (!prev) {
|
|
583
869
|
return {
|
|
@@ -608,7 +894,7 @@ function diffState(prev, curr) {
|
|
|
608
894
|
|
|
609
895
|
// src/core/emit.ts
|
|
610
896
|
import pc from "picocolors";
|
|
611
|
-
var SECTIONS = ["ui", "console", "network", "errors", "state"];
|
|
897
|
+
var SECTIONS = ["ui", "console", "network", "errors", "state", "performance"];
|
|
612
898
|
var COUNTED_SECTIONS = {
|
|
613
899
|
console: "console",
|
|
614
900
|
network: "network",
|
|
@@ -679,6 +965,15 @@ function emitSummary(raw) {
|
|
|
679
965
|
lines.push(`errors (${raw.errors.length}):`);
|
|
680
966
|
for (const e of raw.errors) lines.push(` ${truncate(e.message, 150)}`);
|
|
681
967
|
}
|
|
968
|
+
if (raw.performance) {
|
|
969
|
+
const all = raw.performance.buckets.find((b) => b.name === "__all__");
|
|
970
|
+
if (all) {
|
|
971
|
+
const droppedPct = all.frames.total > 0 ? all.frames.long / all.frames.total * 100 : 0;
|
|
972
|
+
if (droppedPct > 10) {
|
|
973
|
+
lines.push(compactPerformance(raw.performance));
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
682
977
|
const storeNames = Object.keys(raw.state);
|
|
683
978
|
if (storeNames.length) {
|
|
684
979
|
const parts = storeNames.map((n) => {
|
|
@@ -786,12 +1081,24 @@ function aipeekSnippet(port) {
|
|
|
786
1081
|
return `
|
|
787
1082
|
# aipeek \u2014 Runtime Browser Inspector
|
|
788
1083
|
|
|
1084
|
+
IMPORTANT: Before debugging any UI issue, visual bug, or runtime error, ALWAYS \`curl ${base}/screen\` to see the live app first. Do NOT guess, and do NOT ask the user to open or describe the page \u2014 you read it directly, including background tabs.
|
|
1085
|
+
|
|
1086
|
+
- \`curl ${base}/screen\` \u2014 state-machine view of the UI (start here). \`/ui\`, \`/dom?scope=X\` for detail; \`/console\`, \`/network\`, \`/errors\`, \`/check\` for health.
|
|
1087
|
+
- \`curl ${base}/click?text=\u2026\` (also \`/fill\`, \`/press\`, \`/wait\`) \u2014 drive the page. POST \`/chain\` for a whole scripted interaction.
|
|
1088
|
+
- \`curl ${base}/help\` \u2014 full command reference (every endpoint, flags, federation, examples). Read it the first time you reach for a command you don't see above.`;
|
|
1089
|
+
}
|
|
1090
|
+
function helpText(port) {
|
|
1091
|
+
const base = `http://localhost:${port}/__aipeek`;
|
|
1092
|
+
return `
|
|
1093
|
+
# aipeek \u2014 Runtime Browser Inspector
|
|
1094
|
+
|
|
789
1095
|
IMPORTANT: Before debugging any UI issue, visual bug, or runtime error, ALWAYS fetch the live app state first. Do NOT guess \u2014 look at the actual browser state.
|
|
790
1096
|
|
|
791
1097
|
## Read state \u2014 cheapest first
|
|
792
1098
|
|
|
793
1099
|
\`\`\`bash
|
|
794
|
-
curl ${base}/screen # state-machine projection {view, modal, focus, knobs} \u2014 START HERE
|
|
1100
|
+
curl ${base}/screen # state-machine projection {view, modal, focus, knobs} \u2014 START HERE (returns token: tN)
|
|
1101
|
+
curl '${base}/screen?since=tN' # only what moved since that token (view/modal/focus + new errors), not a full snapshot
|
|
795
1102
|
curl ${base}/ui # React component tree \u2014 deep-dive when /screen isn't enough
|
|
796
1103
|
curl '${base}/dom?scope=ChatInput' # semantic DOM scoped to a component \u2014 UI as text, src locations
|
|
797
1104
|
curl ${base} # high-density summary (ok sections \u2192 1 line, issues \u2192 expanded)
|
|
@@ -801,14 +1108,24 @@ curl ${base}/console # console logs (errors, warnings, info)
|
|
|
801
1108
|
curl ${base}/network # fetch/XHR requests with status and timing
|
|
802
1109
|
curl ${base}/errors # uncaught errors and unhandled rejections
|
|
803
1110
|
curl ${base}/state # registered store snapshots
|
|
1111
|
+
curl ${base}/tabs # list live tabs (id, visible/background, title) for ?tab= addressing
|
|
1112
|
+
curl ${base}/timeline # interleaved action stream across all tabs (who clicked what, in order)
|
|
804
1113
|
curl '${base}/query?sel=[role=menuitem]' # this selector right now: count + each element's text/visible/attrs
|
|
1114
|
+
curl ${base}/profile # performance profiler: which component/function is burning frames (+ source lines with AIPEEK_LINES=1)
|
|
1115
|
+
curl ${base}/profile/reset # clear the profiler window, then reproduce the interaction
|
|
1116
|
+
curl ${base}/profile/diff # closed loop: 1st call marks baseline, fix+reproduce, 2nd call \u2192 IMPROVED/REGRESSED verdict
|
|
805
1117
|
\`\`\`
|
|
806
1118
|
|
|
807
1119
|
\`/query\` is the read-side twin of click/fill's \`sel=\` \u2014 assert on a specific element
|
|
808
1120
|
(how many match, its text, \`data-state\`, \`aria-*\`/\`data-*\`, value, disabled) without \`/eval\`.
|
|
1121
|
+
Secret fields (password inputs, API-key/token fields) show \`\u2039redacted N chars\u203A\` instead of
|
|
1122
|
+
their value across \`/dom\`, \`/query\` and \`/screen\` \u2014 length stays visible, the secret doesn't.
|
|
809
1123
|
|
|
810
1124
|
\`/screen\` projects the whole UI to a few state variables \u2014 start there, not \`/ui\`. Append
|
|
811
|
-
\`?full\` for untruncated output.
|
|
1125
|
+
\`?full\` for untruncated output. Each read prints a \`token: tN\` line; pass it back as
|
|
1126
|
+
\`/screen?since=tN\` to get only the transition since that read (view/modal/focus + new
|
|
1127
|
+
errors/failed requests), \`(no state change)\` if nothing moved \u2014 the cheap "what changed
|
|
1128
|
+
after I acted" read, without re-paying for the unchanged 99%.
|
|
812
1129
|
|
|
813
1130
|
To inspect or edit a component, work top-down \u2014 the full DOM is huge, a scoped view is
|
|
814
1131
|
accurate: \`/screen\` or \`/ui\` to find the component, then \`/dom?scope=<Name>\` (matches the
|
|
@@ -825,11 +1142,59 @@ curl '${base}/wait?text=Done&timeout=8000' # poll until text/sel appears (add go
|
|
|
825
1142
|
curl '${base}/screenshot?out=shot.png' # DOM\u2192PNG into .aipeek/ (html-to-image; lossy)
|
|
826
1143
|
\`\`\`
|
|
827
1144
|
|
|
828
|
-
\`click\`/\`fill\`/\`press\` settle the DOM and append
|
|
829
|
-
|
|
830
|
-
|
|
1145
|
+
\`click\`/\`fill\`/\`press\` settle the DOM and append \`--- changed ---\`: only the state-machine
|
|
1146
|
+
transition this action caused (\`view: a \u2192 b\`, \`modal: opened X\`, \`focus: \u2026\`) plus any new
|
|
1147
|
+
errors/failed requests \u2014 not a fresh snapshot. \`(no state change)\` means nothing moved. Read
|
|
1148
|
+
the delta, then drill into /ui or /dom for detail if you need it. On a miss, the response lists
|
|
1149
|
+
the reachable clickable elements so you can re-target. URL-encode any \`sel=\` with non-ASCII or quotes:
|
|
831
1150
|
\`curl -G ${base}/click --data-urlencode 'sel=button[title="\u77E5\u8BC6\u5E93"]'\`.
|
|
832
1151
|
|
|
1152
|
+
Each \`click\`/\`fill\`/\`press\` response also carries a \`--- recent actions ---\` timeline:
|
|
1153
|
+
the semantic page actions (yours and the user's) in order, \`T\`=trusted human / \`S\`=synthetic
|
|
1154
|
+
aipeek, each with its resulting UI change (\`\u2192 \u5F39\u7A97\u6253\u5F00\u300C\u2026\u300D\`/\`\u2192 \u5F39\u7A97\u5173\u95ED\`). Your own action is
|
|
1155
|
+
bracketed by \`\u4F60\u5F53\u524D\u7684\u884C\u4E3A\` dividers. So if the user closed a dialog you just opened, you see
|
|
1156
|
+
their \`T key:Escape \u2192 \u5F39\u7A97\u5173\u95ED\` right after your \`S\` action \u2014 no need to query for it.
|
|
1157
|
+
|
|
1158
|
+
**Beyond click/fill/press** \u2014 four more interactions for what those can't reach:
|
|
1159
|
+
|
|
1160
|
+
\`\`\`bash
|
|
1161
|
+
curl '${base}/scrollIntoView?text=Row 99' # scroll a target into view (off-screen list rows)
|
|
1162
|
+
curl '${base}/drag?sel=.item&to=.slot' # synthetic pointer drag, source \u2192 destination
|
|
1163
|
+
curl '${base}/drop?sel=.dropzone&files=a.png,b.pdf' # fire a file-drop (DataTransfer) on a target
|
|
1164
|
+
curl '${base}/clipboard?mode=write&value=hi' # seed the clipboard (mode=read reports it back)
|
|
1165
|
+
\`\`\`
|
|
1166
|
+
|
|
1167
|
+
\`drag\` fires a real pointer sequence (down \u2192 stepped moves past dnd-kit's activation
|
|
1168
|
+
distance \u2192 up); if a dnd-kit reorder doesn't take, retry the same gesture via \`realclick\`
|
|
1169
|
+
(trusted events). \`drop\` delivers the drop event with the named files (synthetic Files have
|
|
1170
|
+
no byte content \u2014 fine for triggering handlers, not for real uploads). \`clipboard\` needs the
|
|
1171
|
+
tab focused (browser security) and says so plainly when it isn't, rather than hanging.
|
|
1172
|
+
|
|
1173
|
+
A control tagged \`{needs-trusted?}\` in \`/screen\` or \`/dom\` opens a popup (\`aria-haspopup\`)
|
|
1174
|
+
that a synthetic click may not trigger \u2014 reach for \`realclick\` on it from the start instead
|
|
1175
|
+
of discovering it via a dead click. (Right-click-only menus carry no DOM marker, so they
|
|
1176
|
+
still surface only on a miss \u2014 use \`realclick\` with \`button=right\` there.)
|
|
1177
|
+
|
|
1178
|
+
**Multiple tabs.** Every read/drive command takes \`?tab=<id>\` to address one specific tab \u2014
|
|
1179
|
+
including a **background** one (you can drive the Chat tab while the user is looking at a
|
|
1180
|
+
different tab). Run \`${base}/tabs\` to see the live ids. With one tab open, omit \`?tab=\` and
|
|
1181
|
+
it just works. With several tabs open and no \`?tab=\`, the command returns \`409\` + the tab
|
|
1182
|
+
list (rather than randomly hitting one) \u2014 pick an id from it and retry with \`?tab=\`.
|
|
1183
|
+
|
|
1184
|
+
**Multiple servers (federation).** When several dev servers run at once \u2014 a micro-frontend,
|
|
1185
|
+
separate front/back servers, or a teammate's machine \u2014 every command also takes
|
|
1186
|
+
\`?host=<host:port>\` to reach a *sibling* aipeek. The plugin you curl reverse-proxies the
|
|
1187
|
+
request to that peer (server-side, no browser): \`${base}/screen?host=localhost:5174\` reads
|
|
1188
|
+
the app on :5174; combine with \`?tab=\` to point at one tab over there
|
|
1189
|
+
(\`?host=192.168.1.9:5173&tab=t3\`). Omit \`?host=\` and it's the local server as always. There's
|
|
1190
|
+
no registry \u2014 you name the peer, so list its tabs with \`/tabs?host=<host:port>\` first.
|
|
1191
|
+
|
|
1192
|
+
**Cross-tab timeline.** \`${base}/timeline\` interleaves the semantic actions of *every* tab
|
|
1193
|
+
in time order \u2014 each line \`<tab> [T|S] <action> \u2192 <ui change>\` (\`T\`=trusted human,
|
|
1194
|
+
\`S\`=synthetic aipeek). The per-action \`--- recent actions ---\` tail only shows the acting
|
|
1195
|
+
tab; \`/timeline\` is the group view, so an A/B comparison across two tabs (drive A, watch B
|
|
1196
|
+
react) is one read. \`?tab=<id>\` filters to one tab's history.
|
|
1197
|
+
|
|
833
1198
|
**Chain \u2014 a whole interaction in one round-trip.** POST a JSON array; runs in sequence,
|
|
834
1199
|
each step settles before the next, stops on first failure:
|
|
835
1200
|
|
|
@@ -838,10 +1203,18 @@ curl -X POST ${base}/chain -d '[
|
|
|
838
1203
|
{"type":"click","sel":"button[title=\\"\u77E5\u8BC6\u5E93\\"]"},
|
|
839
1204
|
{"type":"wait","text":"Done"},
|
|
840
1205
|
{"type":"fill","sel":"textarea","value":"hi"},
|
|
1206
|
+
{"type":"assert","screen":"\u6D41\u5F0F\u4E2D","equals":"false"},
|
|
841
1207
|
{"type":"press","key":"Enter"}
|
|
842
1208
|
]'
|
|
843
1209
|
\`\`\`
|
|
844
1210
|
|
|
1211
|
+
\`assert\` is the chain's mid-step judge: \`{type,screen,equals}\` checks a domain variable
|
|
1212
|
+
(from the app's \`window.__AIPEEK_SCREEN__\`), or \`{type,sel,equals}\` an element's text. On
|
|
1213
|
+
mismatch the chain stops and reports \`asserted X=="Y", actual "Z"\` \u2014 a test, not a guess.
|
|
1214
|
+
Domain variables also show up in \`/screen\`'s \`domain:\` block and in every \`--- changed ---\`
|
|
1215
|
+
diff (e.g. \`\u6D41\u5F0F\u4E2D: false \u2192 true\`) \u2014 the app's own state machine, which a DOM-only inspector
|
|
1216
|
+
can't see. The app opts in by setting \`window.__AIPEEK_SCREEN__ = () => ({...})\`.
|
|
1217
|
+
|
|
845
1218
|
**Escape hatch.** \`curl '${base}/eval?code=...'\` (or POST the code as the body) runs arbitrary
|
|
846
1219
|
JS in the page and returns the result \u2014 for what the typed endpoints can't do (install listeners,
|
|
847
1220
|
read closures, probe event flow). For count/text/state/attr assertions reach for \`/query\` first.
|
|
@@ -877,9 +1250,35 @@ function aipeekPlugin() {
|
|
|
877
1250
|
let pendingResolve = null;
|
|
878
1251
|
let server;
|
|
879
1252
|
let lastRaw = null;
|
|
1253
|
+
let perfBaseline = null;
|
|
880
1254
|
let pushTimer;
|
|
881
1255
|
const pendingActions = /* @__PURE__ */ new Map();
|
|
882
1256
|
let actionId = 0;
|
|
1257
|
+
const tabs = /* @__PURE__ */ new Map();
|
|
1258
|
+
function seen(data) {
|
|
1259
|
+
if (!data.tab)
|
|
1260
|
+
return;
|
|
1261
|
+
const prev = tabs.get(data.tab);
|
|
1262
|
+
tabs.set(data.tab, {
|
|
1263
|
+
id: data.tab,
|
|
1264
|
+
url: data.url ?? prev?.url ?? "",
|
|
1265
|
+
title: data.title ?? prev?.title ?? "",
|
|
1266
|
+
visible: data.visible ?? prev?.visible ?? false,
|
|
1267
|
+
lastSeen: Date.now()
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
const liveTabs = () => [...tabs.values()].filter((t) => isLive(t, Date.now()));
|
|
1271
|
+
const diagnose2 = (tab) => diagnose(tab, [...tabs.values()], Date.now(), server.ws.clients.size, server.config.server.port || 5173);
|
|
1272
|
+
const actionLog = [];
|
|
1273
|
+
const BOOT_ID = Date.now().toString(36);
|
|
1274
|
+
const mergeActions = (tab, actions) => {
|
|
1275
|
+
if (!tab || !actions?.length)
|
|
1276
|
+
return;
|
|
1277
|
+
for (const entry of actions) {
|
|
1278
|
+
if (!actionLog.some((e) => e.tab === tab && e.ts === entry.ts))
|
|
1279
|
+
appendAction(actionLog, tab, entry, 200);
|
|
1280
|
+
}
|
|
1281
|
+
};
|
|
883
1282
|
const cdpQueue = [];
|
|
884
1283
|
let cdpWaiter = null;
|
|
885
1284
|
const cdpResults = /* @__PURE__ */ new Map();
|
|
@@ -905,14 +1304,53 @@ function aipeekPlugin() {
|
|
|
905
1304
|
let pendingScreen = null;
|
|
906
1305
|
const pendingEvals = /* @__PURE__ */ new Map();
|
|
907
1306
|
let evalId = 0;
|
|
1307
|
+
const screenStash = /* @__PURE__ */ new Map();
|
|
1308
|
+
let screenToken = 0;
|
|
1309
|
+
function stashScreen(r) {
|
|
1310
|
+
const token = `t${++screenToken}`;
|
|
1311
|
+
screenStash.set(token, { snap: r.snap, console: r.console, network: r.network, errors: r.errors });
|
|
1312
|
+
if (screenStash.size > 32)
|
|
1313
|
+
screenStash.delete(screenStash.keys().next().value);
|
|
1314
|
+
return token;
|
|
1315
|
+
}
|
|
908
1316
|
const VISIBLE_MS = 400;
|
|
909
|
-
function twoPhase(event, payload, arm, fullMs = 3e3) {
|
|
1317
|
+
function twoPhase(event, payload, arm, fullMs = 3e3, tab) {
|
|
910
1318
|
return new Promise((resolve2, reject) => {
|
|
911
1319
|
let settled = false;
|
|
912
1320
|
const clear = arm((v) => {
|
|
913
1321
|
settled = true;
|
|
914
1322
|
resolve2(v);
|
|
915
1323
|
});
|
|
1324
|
+
if (tab) {
|
|
1325
|
+
const RETRY_MS = 500;
|
|
1326
|
+
const ABSENT_CEILING_MS = 1e4;
|
|
1327
|
+
const startedAt = Date.now();
|
|
1328
|
+
const deliver = () => server.hot.send(event, { ...payload, tab });
|
|
1329
|
+
deliver();
|
|
1330
|
+
const iv = setInterval(() => {
|
|
1331
|
+
if (settled) {
|
|
1332
|
+
clearInterval(iv);
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
const t = tabs.get(tab);
|
|
1336
|
+
const live = !!t && isLive(t, Date.now());
|
|
1337
|
+
const elapsed = Date.now() - startedAt;
|
|
1338
|
+
if (live) {
|
|
1339
|
+
if (elapsed > fullMs) {
|
|
1340
|
+
clearInterval(iv);
|
|
1341
|
+
clear();
|
|
1342
|
+
reject(new Error(diagnose2(tab)));
|
|
1343
|
+
}
|
|
1344
|
+
} else if (elapsed > ABSENT_CEILING_MS) {
|
|
1345
|
+
clearInterval(iv);
|
|
1346
|
+
clear();
|
|
1347
|
+
reject(new Error(diagnose2(tab)));
|
|
1348
|
+
} else {
|
|
1349
|
+
deliver();
|
|
1350
|
+
}
|
|
1351
|
+
}, RETRY_MS);
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
916
1354
|
server.hot.send(event, { ...payload, requireVisible: true });
|
|
917
1355
|
setTimeout(() => {
|
|
918
1356
|
if (settled)
|
|
@@ -922,36 +1360,36 @@ function aipeekPlugin() {
|
|
|
922
1360
|
if (settled)
|
|
923
1361
|
return;
|
|
924
1362
|
clear();
|
|
925
|
-
reject(new Error(
|
|
1363
|
+
reject(new Error(diagnose2(tab)));
|
|
926
1364
|
}, fullMs);
|
|
927
1365
|
}, VISIBLE_MS);
|
|
928
1366
|
});
|
|
929
1367
|
}
|
|
930
|
-
function collectFromClient() {
|
|
1368
|
+
function collectFromClient(tab) {
|
|
931
1369
|
return twoPhase("aipeek:collect", {}, (resolve2) => {
|
|
932
1370
|
pendingResolve = resolve2;
|
|
933
1371
|
return () => {
|
|
934
1372
|
pendingResolve = null;
|
|
935
1373
|
};
|
|
936
|
-
});
|
|
1374
|
+
}, 3e3, tab);
|
|
937
1375
|
}
|
|
938
|
-
function collectDomFromClient(scope, sel) {
|
|
1376
|
+
function collectDomFromClient(scope, sel, tab) {
|
|
939
1377
|
return twoPhase("aipeek:collect-dom", { scope, sel }, (resolve2) => {
|
|
940
1378
|
pendingDom = resolve2;
|
|
941
1379
|
return () => {
|
|
942
1380
|
pendingDom = null;
|
|
943
1381
|
};
|
|
944
|
-
});
|
|
1382
|
+
}, 3e3, tab);
|
|
945
1383
|
}
|
|
946
|
-
function collectScreenFromClient() {
|
|
1384
|
+
function collectScreenFromClient(tab) {
|
|
947
1385
|
return twoPhase("aipeek:collect-screen", {}, (resolve2) => {
|
|
948
1386
|
pendingScreen = resolve2;
|
|
949
1387
|
return () => {
|
|
950
1388
|
pendingScreen = null;
|
|
951
1389
|
};
|
|
952
|
-
});
|
|
1390
|
+
}, 3e3, tab);
|
|
953
1391
|
}
|
|
954
|
-
function sendAction(type, args) {
|
|
1392
|
+
function sendAction(type, args, tab) {
|
|
955
1393
|
const id = ++actionId;
|
|
956
1394
|
const fullMs = Math.max(args.timeout ?? 0, 3e3) + 2e3;
|
|
957
1395
|
return twoPhase("aipeek:action", { id, type, args }, (resolve2) => {
|
|
@@ -959,28 +1397,28 @@ function aipeekPlugin() {
|
|
|
959
1397
|
return () => {
|
|
960
1398
|
pendingActions.delete(id);
|
|
961
1399
|
};
|
|
962
|
-
}, fullMs);
|
|
1400
|
+
}, fullMs, tab);
|
|
963
1401
|
}
|
|
964
|
-
async function runAction(type, args) {
|
|
965
|
-
const result = await sendAction(type, args);
|
|
1402
|
+
async function runAction(type, args, tab) {
|
|
1403
|
+
const result = await sendAction(type, args, tab);
|
|
966
1404
|
lastRaw = null;
|
|
967
|
-
if (type === "realclick" && result.ok && result.
|
|
1405
|
+
if (type === "realclick" && result.ok && !result.fired) {
|
|
968
1406
|
const cdp = await runCdpClick(result.x, result.y, args.button ?? "left");
|
|
969
1407
|
if (!cdp.ok)
|
|
970
1408
|
return { ok: false, error: `cdp click failed: ${cdp.error ?? "unknown"}` };
|
|
971
1409
|
result.detail = `${result.detail} \u2192 clicked via extension`;
|
|
972
|
-
result.
|
|
1410
|
+
result.screen = (await collectScreenFromClient(tab)).screen;
|
|
973
1411
|
}
|
|
974
1412
|
return result;
|
|
975
1413
|
}
|
|
976
|
-
function evalInClient(code) {
|
|
1414
|
+
function evalInClient(code, tab) {
|
|
977
1415
|
const id = ++evalId;
|
|
978
1416
|
return twoPhase("aipeek:eval", { id, code }, (resolve2) => {
|
|
979
1417
|
pendingEvals.set(id, resolve2);
|
|
980
1418
|
return () => {
|
|
981
1419
|
pendingEvals.delete(id);
|
|
982
1420
|
};
|
|
983
|
-
}, 8e3);
|
|
1421
|
+
}, 8e3, tab);
|
|
984
1422
|
}
|
|
985
1423
|
return {
|
|
986
1424
|
name: "aipeek",
|
|
@@ -1008,12 +1446,16 @@ function aipeekPlugin() {
|
|
|
1008
1446
|
server = _server;
|
|
1009
1447
|
injectClaudeMd(server.config.root, server.config.server.port || 5173);
|
|
1010
1448
|
server.hot.on("aipeek:state", (data) => {
|
|
1449
|
+
seen(data);
|
|
1450
|
+
mergeActions(data.tab, data.actions);
|
|
1011
1451
|
if (pendingResolve) {
|
|
1012
1452
|
pendingResolve(data);
|
|
1013
1453
|
pendingResolve = null;
|
|
1014
1454
|
}
|
|
1015
1455
|
});
|
|
1456
|
+
server.hot.on("aipeek:hello", (data) => seen(data));
|
|
1016
1457
|
server.hot.on("aipeek:result", (data) => {
|
|
1458
|
+
seen(data);
|
|
1017
1459
|
const resolve2 = pendingActions.get(data.id);
|
|
1018
1460
|
if (resolve2) {
|
|
1019
1461
|
pendingActions.delete(data.id);
|
|
@@ -1021,6 +1463,7 @@ function aipeekPlugin() {
|
|
|
1021
1463
|
}
|
|
1022
1464
|
});
|
|
1023
1465
|
server.hot.on("aipeek:eval-result", (data) => {
|
|
1466
|
+
seen(data);
|
|
1024
1467
|
const resolve2 = pendingEvals.get(data.id);
|
|
1025
1468
|
if (resolve2) {
|
|
1026
1469
|
pendingEvals.delete(data.id);
|
|
@@ -1028,14 +1471,16 @@ function aipeekPlugin() {
|
|
|
1028
1471
|
}
|
|
1029
1472
|
});
|
|
1030
1473
|
server.hot.on("aipeek:dom", (data) => {
|
|
1474
|
+
seen(data);
|
|
1031
1475
|
if (pendingDom) {
|
|
1032
1476
|
pendingDom(data.dom);
|
|
1033
1477
|
pendingDom = null;
|
|
1034
1478
|
}
|
|
1035
1479
|
});
|
|
1036
1480
|
server.hot.on("aipeek:screen", (data) => {
|
|
1481
|
+
seen(data);
|
|
1037
1482
|
if (pendingScreen) {
|
|
1038
|
-
pendingScreen(data
|
|
1483
|
+
pendingScreen(data);
|
|
1039
1484
|
pendingScreen = null;
|
|
1040
1485
|
}
|
|
1041
1486
|
});
|
|
@@ -1059,7 +1504,56 @@ function aipeekPlugin() {
|
|
|
1059
1504
|
const url = new URL(req.url || "/", "http://localhost");
|
|
1060
1505
|
const parts = url.pathname.split("/").filter(Boolean);
|
|
1061
1506
|
const full = url.searchParams.has("full");
|
|
1507
|
+
const tab = url.searchParams.get("tab") || void 0;
|
|
1508
|
+
const host = url.searchParams.get("host") || void 0;
|
|
1509
|
+
const selfPort = server.config.server.port || 5173;
|
|
1510
|
+
const selfHosts = /* @__PURE__ */ new Set([`localhost:${selfPort}`, `127.0.0.1:${selfPort}`, `:${selfPort}`, `${selfPort}`]);
|
|
1511
|
+
if (host && !selfHosts.has(host)) {
|
|
1512
|
+
const fwd = new URL(url);
|
|
1513
|
+
fwd.searchParams.delete("host");
|
|
1514
|
+
const target = `http://${host}/__aipeek/${parts.join("/")}${fwd.search}`;
|
|
1515
|
+
try {
|
|
1516
|
+
const body = req.method === "POST" ? await readBody(req) : void 0;
|
|
1517
|
+
const r = await fetch(target, { method: req.method, body });
|
|
1518
|
+
send(res, r.status, await r.text());
|
|
1519
|
+
} catch (e) {
|
|
1520
|
+
const code = e.cause?.code;
|
|
1521
|
+
const why = code === "ECONNREFUSED" ? `nothing is listening on ${host} \u2014 its dev server isn't running (start it), or the port is wrong.` : code === "ENOTFOUND" ? `host '${host}' doesn't resolve \u2014 check the hostname.` : code === "ETIMEDOUT" || code === "UND_ERR_CONNECT_TIMEOUT" ? `connection to ${host} timed out \u2014 it's unreachable (firewall, or wrong host).` : `${e.message} (code ${code ?? "unknown"}).`;
|
|
1522
|
+
send(res, 502, `cannot reach aipeek peer at ${host}: ${why}`);
|
|
1523
|
+
}
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
if (parts[0] === "ping") {
|
|
1527
|
+
send(res, 200, BOOT_ID);
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
if (parts[0] === "help") {
|
|
1531
|
+
send(res, 200, helpText(selfPort).trim());
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
const ambiguous = () => !tab && !["tabs", "timeline", "cdp"].includes(parts[0]) && liveTabs().length > 1;
|
|
1535
|
+
const refuse = () => send(res, 409, `multiple live tabs \u2014 add ?tab=<id>:
|
|
1536
|
+
|
|
1537
|
+
${formatTabs(liveTabs(), Date.now())}`);
|
|
1062
1538
|
try {
|
|
1539
|
+
if (parts[0] === "tabs") {
|
|
1540
|
+
const roster = formatTabs(liveTabs(), Date.now());
|
|
1541
|
+
send(res, 200, roster === "(no live tabs)" ? `(${diagnose2()})` : roster);
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1544
|
+
if (parts[0] === "timeline") {
|
|
1545
|
+
const targets = tab ? [tab] : liveTabs().map((t) => t.id);
|
|
1546
|
+
for (const id of targets)
|
|
1547
|
+
await collectFromClient(id).catch(() => {
|
|
1548
|
+
});
|
|
1549
|
+
const entries = tab ? actionLog.filter((e) => e.tab === tab) : actionLog;
|
|
1550
|
+
send(res, 200, formatActions(entries));
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
if (ambiguous()) {
|
|
1554
|
+
refuse();
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1063
1557
|
if (parts[0] === "eval") {
|
|
1064
1558
|
let code = url.searchParams.get("code") || "";
|
|
1065
1559
|
if (!code && req.method === "POST")
|
|
@@ -1068,21 +1562,40 @@ function aipeekPlugin() {
|
|
|
1068
1562
|
send(res, 400, "eval needs ?code= or a POST body");
|
|
1069
1563
|
return;
|
|
1070
1564
|
}
|
|
1071
|
-
const r = await evalInClient(code);
|
|
1565
|
+
const r = await evalInClient(code, tab);
|
|
1072
1566
|
send(res, r.ok ? 200 : 422, r.ok ? r.value ?? "undefined" : `error: ${r.error}`);
|
|
1073
1567
|
return;
|
|
1074
1568
|
}
|
|
1075
1569
|
if (parts[0] === "dom") {
|
|
1076
1570
|
const dom = await collectDomFromClient(
|
|
1077
1571
|
url.searchParams.get("scope") || void 0,
|
|
1078
|
-
url.searchParams.get("sel") || void 0
|
|
1572
|
+
url.searchParams.get("sel") || void 0,
|
|
1573
|
+
tab
|
|
1079
1574
|
);
|
|
1080
1575
|
send(res, 200, dom || "(empty)");
|
|
1081
1576
|
return;
|
|
1082
1577
|
}
|
|
1083
1578
|
if (parts[0] === "screen") {
|
|
1084
|
-
const
|
|
1085
|
-
|
|
1579
|
+
const reply = await collectScreenFromClient(tab);
|
|
1580
|
+
const since = url.searchParams.get("since");
|
|
1581
|
+
const token = stashScreen(reply);
|
|
1582
|
+
if (since) {
|
|
1583
|
+
const prev = screenStash.get(since);
|
|
1584
|
+
if (!prev) {
|
|
1585
|
+
send(res, 422, `unknown since token "${since}" (expired or never issued) \u2014 read /screen first for a fresh token`);
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
const d = diffState(
|
|
1589
|
+
{ ui: "", console: prev.console, network: prev.network, errors: prev.errors, state: {}, url: "", timestamp: 0 },
|
|
1590
|
+
{ ui: "", console: reply.console, network: reply.network, errors: reply.errors, state: {}, url: "", timestamp: 0 }
|
|
1591
|
+
);
|
|
1592
|
+
const changed = diffScreen(prev.snap, reply.snap, d.newErrors, d.newExceptions, d.newFailedRequests);
|
|
1593
|
+
send(res, 200, `token: ${token}
|
|
1594
|
+
${changed.length ? changed.join("\n") : "(no state change)"}`);
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
send(res, 200, reply.screen ? `token: ${token}
|
|
1598
|
+
${reply.screen}` : "(empty)");
|
|
1086
1599
|
return;
|
|
1087
1600
|
}
|
|
1088
1601
|
if (parts[0] === "cdp" && parts[1] === "poll") {
|
|
@@ -1136,7 +1649,7 @@ function aipeekPlugin() {
|
|
|
1136
1649
|
}
|
|
1137
1650
|
lastRaw = null;
|
|
1138
1651
|
const lines = [];
|
|
1139
|
-
let
|
|
1652
|
+
let lastActions = "";
|
|
1140
1653
|
let allOk = true;
|
|
1141
1654
|
for (let i = 0; i < steps.length; i++) {
|
|
1142
1655
|
const { type, ...args } = steps[i];
|
|
@@ -1146,24 +1659,25 @@ function aipeekPlugin() {
|
|
|
1146
1659
|
allOk = false;
|
|
1147
1660
|
break;
|
|
1148
1661
|
}
|
|
1149
|
-
const r = await runAction(type, args);
|
|
1662
|
+
const r = await runAction(type, args, tab);
|
|
1150
1663
|
lines.push(`[${i}] ${r.ok ? "\u2713" : "\u2717"} ${type}: ${r.ok ? r.detail || "ok" : r.error}`);
|
|
1151
1664
|
if (r.screen)
|
|
1152
1665
|
lines.push(r.screen.split("\n").map((l) => ` ${l}`).join("\n"));
|
|
1153
|
-
if (r.
|
|
1154
|
-
|
|
1666
|
+
if (r.actions)
|
|
1667
|
+
lastActions = r.actions;
|
|
1155
1668
|
if (!r.ok) {
|
|
1156
1669
|
allOk = false;
|
|
1157
1670
|
break;
|
|
1158
1671
|
}
|
|
1159
1672
|
}
|
|
1160
|
-
|
|
1673
|
+
const chainActions = lastActions ? `
|
|
1161
1674
|
|
|
1162
|
-
---
|
|
1163
|
-
${
|
|
1675
|
+
--- recent actions ---
|
|
1676
|
+
${lastActions}` : "";
|
|
1677
|
+
send(res, allOk ? 200 : 422, `${lines.join("\n")}${chainActions}`);
|
|
1164
1678
|
return;
|
|
1165
1679
|
}
|
|
1166
|
-
if (["click", "fill", "press", "wait", "screenshot", "realclick", "query"].includes(parts[0])) {
|
|
1680
|
+
if (["click", "fill", "press", "wait", "screenshot", "realclick", "query", "assert", "drag", "scrollIntoView", "drop", "clipboard"].includes(parts[0])) {
|
|
1167
1681
|
const q = url.searchParams;
|
|
1168
1682
|
const args = {
|
|
1169
1683
|
sel: q.get("sel") || void 0,
|
|
@@ -1174,14 +1688,19 @@ ${lastUi}` : lines.join("\n"));
|
|
|
1174
1688
|
gone: q.has("gone") ? q.get("gone") !== "false" : void 0,
|
|
1175
1689
|
button: q.get("button") === "right" ? "right" : q.get("button") === "left" ? "left" : void 0,
|
|
1176
1690
|
x: q.has("x") ? Number(q.get("x")) : void 0,
|
|
1177
|
-
y: q.has("y") ? Number(q.get("y")) : void 0
|
|
1691
|
+
y: q.has("y") ? Number(q.get("y")) : void 0,
|
|
1692
|
+
screen: q.get("screen") || void 0,
|
|
1693
|
+
equals: q.has("equals") ? q.get("equals") : void 0,
|
|
1694
|
+
to: q.get("to") || void 0,
|
|
1695
|
+
files: q.has("files") ? q.get("files").split(",").map((s) => s.trim()).filter(Boolean) : void 0,
|
|
1696
|
+
mode: q.get("mode") === "write" ? "write" : q.get("mode") === "read" ? "read" : void 0
|
|
1178
1697
|
};
|
|
1179
1698
|
const check2 = resolveAction(parts[0], args);
|
|
1180
1699
|
if (!check2.valid) {
|
|
1181
1700
|
send(res, 400, check2.error ?? "invalid action");
|
|
1182
1701
|
return;
|
|
1183
1702
|
}
|
|
1184
|
-
const result = await runAction(parts[0], args);
|
|
1703
|
+
const result = await runAction(parts[0], args, tab);
|
|
1185
1704
|
if (parts[0] === "screenshot" && result.dataUrl) {
|
|
1186
1705
|
const dir = resolve(server.config.root, ".aipeek");
|
|
1187
1706
|
mkdirSync(dir, { recursive: true });
|
|
@@ -1194,32 +1713,89 @@ ${lastUi}` : lines.join("\n"));
|
|
|
1194
1713
|
const head = result.ok ? result.detail || "ok" : `${result.error}${result.detail ? `
|
|
1195
1714
|
|
|
1196
1715
|
clickable: ${result.detail}` : ""}`;
|
|
1197
|
-
|
|
1716
|
+
const actionsTail = result.actions ? `
|
|
1198
1717
|
|
|
1199
|
-
---
|
|
1200
|
-
${result.
|
|
1718
|
+
--- recent actions ---
|
|
1719
|
+
${result.actions}` : "";
|
|
1720
|
+
const changedTail = result.screen ? `
|
|
1721
|
+
|
|
1722
|
+
--- changed ---
|
|
1723
|
+
${result.screen}` : "";
|
|
1724
|
+
send(res, result.ok ? 200 : 422, `${head}${actionsTail}${changedTail}`);
|
|
1201
1725
|
return;
|
|
1202
1726
|
}
|
|
1203
1727
|
if (parts[0] === "check") {
|
|
1204
|
-
const raw2 = await collectFromClient();
|
|
1728
|
+
const raw2 = await collectFromClient(tab);
|
|
1205
1729
|
lastRaw = raw2;
|
|
1206
1730
|
const result = check(raw2);
|
|
1207
1731
|
const output = emitCheck(result);
|
|
1208
1732
|
send(res, result.pass ? 200 : 417, output);
|
|
1209
1733
|
return;
|
|
1210
1734
|
}
|
|
1735
|
+
if (parts[0] === "profile") {
|
|
1736
|
+
const noPerfMsg = (t) => {
|
|
1737
|
+
const info = t ? tabs.get(t) : liveTabs()[0];
|
|
1738
|
+
return info ? `tab '${info.id}' is connected but BACKGROUNDED \u2014 the browser throttles rAF to ~1fps for hidden tabs, so there are no real frames to profile. You don't need to open anything: the page is already running. Ask the user to click that browser tab to the foreground and keep it there ~2s, then re-run /profile. (Profiling is the only read needing foreground \u2014 /screen and /dom work backgrounded.)` : `(${diagnose2(t)})`;
|
|
1739
|
+
};
|
|
1740
|
+
const resetPerfWindow = () => new Promise((resolve2, reject) => {
|
|
1741
|
+
const timeout = setTimeout(() => reject(new Error("timeout waiting for perf-reset-ack")), 3e3);
|
|
1742
|
+
const handler = (data) => {
|
|
1743
|
+
if (tab && data.tab !== tab) return;
|
|
1744
|
+
clearTimeout(timeout);
|
|
1745
|
+
server.hot.off("aipeek:perf-reset-ack", handler);
|
|
1746
|
+
resolve2();
|
|
1747
|
+
};
|
|
1748
|
+
server.hot.on("aipeek:perf-reset-ack", handler);
|
|
1749
|
+
server.hot.send("aipeek:perf-reset", { tab, requireVisible: false });
|
|
1750
|
+
});
|
|
1751
|
+
if (parts[1] === "reset") {
|
|
1752
|
+
await resetPerfWindow();
|
|
1753
|
+
send(res, 200, "perf window cleared \u2014 reproduce the interaction, then GET /profile");
|
|
1754
|
+
return;
|
|
1755
|
+
}
|
|
1756
|
+
if (parts[1] === "diff") {
|
|
1757
|
+
const raw3 = await collectFromClient(tab);
|
|
1758
|
+
lastRaw = raw3;
|
|
1759
|
+
if (!raw3.performance) {
|
|
1760
|
+
send(res, 200, noPerfMsg(tab));
|
|
1761
|
+
return;
|
|
1762
|
+
}
|
|
1763
|
+
if (!perfBaseline) {
|
|
1764
|
+
perfBaseline = raw3.performance;
|
|
1765
|
+
await resetPerfWindow();
|
|
1766
|
+
send(res, 200, "baseline captured + window cleared \u2014 make your fix, reproduce the interaction, then GET /profile/diff again for the verdict");
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
const report = diffPerformance(perfBaseline, raw3.performance);
|
|
1770
|
+
perfBaseline = null;
|
|
1771
|
+
send(res, 200, report);
|
|
1772
|
+
return;
|
|
1773
|
+
}
|
|
1774
|
+
const raw2 = await collectFromClient(tab);
|
|
1775
|
+
lastRaw = raw2;
|
|
1776
|
+
if (!raw2.performance) {
|
|
1777
|
+
send(res, 200, noPerfMsg(tab));
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1780
|
+
const hiddenNote = raw2.performance.hiddenFrames > 10 ? `
|
|
1781
|
+
|
|
1782
|
+
\u26A0 ${raw2.performance.hiddenFrames} frames skipped while tab was hidden \u2014 bring it to foreground and /profile/reset for accurate data.` : "";
|
|
1783
|
+
send(res, 200, detail(raw2, "profile", void 0, false) + hiddenNote);
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1211
1786
|
if (parts.length >= 1) {
|
|
1212
1787
|
if (!lastRaw)
|
|
1213
|
-
lastRaw = await collectFromClient();
|
|
1788
|
+
lastRaw = await collectFromClient(tab);
|
|
1214
1789
|
const result = detail(lastRaw, parts[0], parts[1], full);
|
|
1215
1790
|
if (result !== null) {
|
|
1216
1791
|
send(res, 200, result);
|
|
1217
1792
|
return;
|
|
1218
1793
|
}
|
|
1219
|
-
|
|
1794
|
+
const SECTIONS2 = ["ui", "console", "network", "errors", "state", "profile"];
|
|
1795
|
+
send(res, 404, SECTIONS2.includes(parts[0]) ? `'${parts[0]}' is empty right now \u2014 nothing captured this window (not an error).` : `unknown section '${parts[0]}'. Valid: ${SECTIONS2.join(", ")}. (Or /screen, /dom, /tabs, /timeline, /check.)`);
|
|
1220
1796
|
return;
|
|
1221
1797
|
}
|
|
1222
|
-
const raw = await collectFromClient();
|
|
1798
|
+
const raw = await collectFromClient(tab);
|
|
1223
1799
|
lastRaw = raw;
|
|
1224
1800
|
if (full) {
|
|
1225
1801
|
const compacted = compact(raw);
|