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