@pro-vi/designer 0.3.9 → 0.3.11
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 +1 -1
- package/dist/browser.js +12 -2
- package/dist/cdp-ensure.js +3 -0
- package/dist/cdp-trace.js +509 -0
- package/dist/designer-controller.js +74 -5
- package/dist/mcp-server.js +1 -1
- package/dist/run-state.js +327 -0
- package/dist/scripts/ci-health.js +1 -0
- package/dist/scripts/trace-analyze.js +366 -0
- package/dist/scripts/trace-spike.js +274 -0
- package/dist/setup.js +33 -2
- package/dist/ui-anchors.js +140 -2
- package/package.json +6 -4
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
#!/usr/bin/env -S node --import tsx
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
function asRec(v) {
|
|
5
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
|
6
|
+
}
|
|
7
|
+
function num(v) {
|
|
8
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
9
|
+
}
|
|
10
|
+
function normalizeEndpoint(url) {
|
|
11
|
+
try {
|
|
12
|
+
const u = new URL(url);
|
|
13
|
+
const p = u.pathname.replace(/[0-9a-f]{8}-[0-9a-f-]{27,}/gi, ':id').replace(/\/\d{4,}/g, '/:n');
|
|
14
|
+
return u.origin + p;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return url.slice(0, 120);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function pct(sorted, p) {
|
|
21
|
+
if (sorted.length === 0)
|
|
22
|
+
return 0;
|
|
23
|
+
const idx = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length));
|
|
24
|
+
return sorted[idx] ?? 0;
|
|
25
|
+
}
|
|
26
|
+
function ms(n) {
|
|
27
|
+
return `${Math.round(n)}ms`;
|
|
28
|
+
}
|
|
29
|
+
function main() {
|
|
30
|
+
const argPath = process.argv[2];
|
|
31
|
+
if (!argPath) {
|
|
32
|
+
console.error('Usage: trace-analyze.ts <traceDir | trace.jsonl>');
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
const dir = argPath.endsWith('.jsonl') ? path.dirname(argPath) : argPath;
|
|
36
|
+
const jsonlPath = argPath.endsWith('.jsonl') ? argPath : path.join(dir, 'trace.jsonl');
|
|
37
|
+
const manifestPath = path.join(dir, 'manifest.json');
|
|
38
|
+
let manifest = {};
|
|
39
|
+
if (fs.existsSync(manifestPath)) {
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
42
|
+
manifest = asRec(parsed);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
manifest = {};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const reqs = new Map();
|
|
49
|
+
const sockets = new Map();
|
|
50
|
+
const domSamples = [];
|
|
51
|
+
const markers = [];
|
|
52
|
+
let monoToWallOffset = null;
|
|
53
|
+
let firstTs = Infinity;
|
|
54
|
+
let lastTs = 0;
|
|
55
|
+
let totalLines = 0;
|
|
56
|
+
for (const line of fs.readFileSync(jsonlPath, 'utf8').split('\n')) {
|
|
57
|
+
if (!line.trim())
|
|
58
|
+
continue;
|
|
59
|
+
totalLines++;
|
|
60
|
+
let ev;
|
|
61
|
+
try {
|
|
62
|
+
const parsed = JSON.parse(line);
|
|
63
|
+
ev = asRec(parsed);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const ts = num(ev.ts) ?? 0;
|
|
69
|
+
if (ts) {
|
|
70
|
+
firstTs = Math.min(firstTs, ts);
|
|
71
|
+
lastTs = Math.max(lastTs, ts);
|
|
72
|
+
}
|
|
73
|
+
if (ev.kind === 'dom-sample') {
|
|
74
|
+
domSamples.push({ ts, sample: asRec(ev.sample) });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (ev.kind === 'recorder') {
|
|
78
|
+
const detail = asRec(ev.detail);
|
|
79
|
+
if (ev.event === 'marker')
|
|
80
|
+
markers.push({ ts, name: String(detail.name || ''), detail });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (ev.kind !== 'cdp')
|
|
84
|
+
continue;
|
|
85
|
+
const method = String(ev.method || '');
|
|
86
|
+
const p = asRec(ev.params);
|
|
87
|
+
const requestId = typeof p.requestId === 'string' ? p.requestId : null;
|
|
88
|
+
if (method === 'Network.requestWillBeSent' && requestId) {
|
|
89
|
+
const req = asRec(p.request);
|
|
90
|
+
const mono = num(p.timestamp);
|
|
91
|
+
const wall = num(p.wallTime);
|
|
92
|
+
if (mono !== null && wall !== null && monoToWallOffset === null)
|
|
93
|
+
monoToWallOffset = wall - mono;
|
|
94
|
+
reqs.set(requestId, {
|
|
95
|
+
requestId,
|
|
96
|
+
url: String(req.url || ''),
|
|
97
|
+
method: String(req.method || 'GET'),
|
|
98
|
+
resourceType: typeof p.type === 'string' ? p.type : null,
|
|
99
|
+
mimeType: null,
|
|
100
|
+
status: null,
|
|
101
|
+
hasContentLength: false,
|
|
102
|
+
sentMono: mono,
|
|
103
|
+
sentWall: wall,
|
|
104
|
+
responseMono: null,
|
|
105
|
+
finishedMono: null,
|
|
106
|
+
failed: null,
|
|
107
|
+
fromCache: false,
|
|
108
|
+
chunks: [],
|
|
109
|
+
sseMessages: 0
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
else if (requestId && reqs.has(requestId)) {
|
|
113
|
+
const r = reqs.get(requestId);
|
|
114
|
+
if (method === 'Network.responseReceived') {
|
|
115
|
+
const resp = asRec(p.response);
|
|
116
|
+
r.mimeType = typeof resp.mimeType === 'string' ? resp.mimeType : null;
|
|
117
|
+
r.status = num(resp.status);
|
|
118
|
+
r.responseMono = num(p.timestamp);
|
|
119
|
+
const headers = asRec(resp.headers);
|
|
120
|
+
r.hasContentLength = Object.keys(headers).some((k) => k.toLowerCase() === 'content-length');
|
|
121
|
+
}
|
|
122
|
+
else if (method === 'Network.dataReceived') {
|
|
123
|
+
r.chunks.push({
|
|
124
|
+
mono: num(p.timestamp) ?? 0,
|
|
125
|
+
bytes: num(p.dataLength) ?? 0,
|
|
126
|
+
encodedBytes: num(p.encodedDataLength) ?? 0
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
else if (method === 'Network.loadingFinished') {
|
|
130
|
+
r.finishedMono = num(p.timestamp);
|
|
131
|
+
}
|
|
132
|
+
else if (method === 'Network.loadingFailed') {
|
|
133
|
+
r.failed = String(p.errorText || 'failed') + (p.canceled === true ? ' (canceled)' : '');
|
|
134
|
+
r.finishedMono = num(p.timestamp);
|
|
135
|
+
}
|
|
136
|
+
else if (method === 'Network.requestServedFromCache') {
|
|
137
|
+
r.fromCache = true;
|
|
138
|
+
}
|
|
139
|
+
else if (method === 'Network.eventSourceMessageReceived') {
|
|
140
|
+
r.sseMessages++;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (method === 'Network.webSocketCreated' && requestId) {
|
|
144
|
+
sockets.set(requestId, {
|
|
145
|
+
requestId,
|
|
146
|
+
url: String(p.url || ''),
|
|
147
|
+
framesSent: 0,
|
|
148
|
+
framesReceived: 0,
|
|
149
|
+
bytesReceived: 0,
|
|
150
|
+
closed: false
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
else if (requestId && sockets.has(requestId)) {
|
|
154
|
+
const s = sockets.get(requestId);
|
|
155
|
+
if (method === 'Network.webSocketFrameSent')
|
|
156
|
+
s.framesSent++;
|
|
157
|
+
if (method === 'Network.webSocketFrameReceived') {
|
|
158
|
+
s.framesReceived++;
|
|
159
|
+
const resp = asRec(p.response);
|
|
160
|
+
s.bytesReceived += typeof resp.payloadData === 'string' ? resp.payloadData.length : num(resp.payloadBytes) ?? 0;
|
|
161
|
+
}
|
|
162
|
+
if (method === 'Network.webSocketClosed')
|
|
163
|
+
s.closed = true;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const monoToWall = (mono) => mono !== null && monoToWallOffset !== null ? (mono + monoToWallOffset) * 1000 : null;
|
|
167
|
+
const streamKind = (r) => {
|
|
168
|
+
if (r.sseMessages > 0 || r.mimeType === 'text/event-stream')
|
|
169
|
+
return 'sse';
|
|
170
|
+
const span = r.chunks.length >= 2 ? (r.chunks[r.chunks.length - 1]?.mono ?? 0) - (r.chunks[0]?.mono ?? 0) : 0;
|
|
171
|
+
if (r.chunks.length >= 3 && span > 1 && !r.hasContentLength)
|
|
172
|
+
return 'fetch-chunked';
|
|
173
|
+
return 'plain';
|
|
174
|
+
};
|
|
175
|
+
const endpoints = new Map();
|
|
176
|
+
for (const r of reqs.values()) {
|
|
177
|
+
const key = normalizeEndpoint(r.url);
|
|
178
|
+
const e = endpoints.get(key) || { hits: 0, methods: new Set(), kinds: new Set(), bytes: 0, sentWalls: [] };
|
|
179
|
+
e.hits++;
|
|
180
|
+
e.methods.add(r.method);
|
|
181
|
+
e.kinds.add(streamKind(r));
|
|
182
|
+
e.bytes += r.chunks.reduce((a, c) => a + c.encodedBytes, 0);
|
|
183
|
+
const w = monoToWall(r.sentMono);
|
|
184
|
+
if (w !== null)
|
|
185
|
+
e.sentWalls.push(w);
|
|
186
|
+
endpoints.set(key, e);
|
|
187
|
+
}
|
|
188
|
+
const iterStart = markers.find((m) => m.name === 'iterate-start')?.ts ?? null;
|
|
189
|
+
let main_ = null;
|
|
190
|
+
let mainScore = -1;
|
|
191
|
+
for (const r of reqs.values()) {
|
|
192
|
+
if (!/^https:\/\/claude\.ai\//.test(r.url))
|
|
193
|
+
continue;
|
|
194
|
+
if (r.method !== 'POST')
|
|
195
|
+
continue;
|
|
196
|
+
const span = r.responseMono !== null && r.finishedMono !== null ? r.finishedMono - r.responseMono : 0;
|
|
197
|
+
let score = span * 1000 + r.chunks.length;
|
|
198
|
+
const w = monoToWall(r.sentMono);
|
|
199
|
+
if (iterStart !== null && w !== null && w >= iterStart - 1000 && w <= iterStart + 5000)
|
|
200
|
+
score += 100_000;
|
|
201
|
+
if (score > mainScore) {
|
|
202
|
+
mainScore = score;
|
|
203
|
+
main_ = r;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const lines = [];
|
|
207
|
+
const out = (s = '') => {
|
|
208
|
+
lines.push(s);
|
|
209
|
+
};
|
|
210
|
+
const m = manifest;
|
|
211
|
+
out(`# Trace summary — ${m.scenario ?? path.basename(dir)}`);
|
|
212
|
+
out();
|
|
213
|
+
out(`- file: \`${jsonlPath}\``);
|
|
214
|
+
out(`- span: ${((lastTs - firstTs) / 1000).toFixed(1)}s | lines: ${totalLines} | requests: ${reqs.size} | sockets: ${sockets.size} | dom-samples: ${domSamples.length}`);
|
|
215
|
+
if (m.iterate)
|
|
216
|
+
out(`- iterate: failureMode=${m.iterate.failureMode} elapsed=${Math.round((m.iterate.elapsedMs ?? 0) / 1000)}s`);
|
|
217
|
+
for (const mk of markers)
|
|
218
|
+
out(`- marker \`${mk.name}\` @ +${((mk.ts - firstTs) / 1000).toFixed(1)}s`);
|
|
219
|
+
out();
|
|
220
|
+
out(`## Endpoints`);
|
|
221
|
+
out();
|
|
222
|
+
out(`| endpoint | hits | methods | kind | bytes | periodicity |`);
|
|
223
|
+
out(`|---|---|---|---|---|---|`);
|
|
224
|
+
const sortedEndpoints = [...endpoints.entries()].sort((a, b) => b[1].bytes - a[1].bytes || b[1].hits - a[1].hits);
|
|
225
|
+
for (const [ep, e] of sortedEndpoints) {
|
|
226
|
+
const walls = e.sentWalls.sort((x, y) => x - y);
|
|
227
|
+
const gaps = [];
|
|
228
|
+
for (let i = 1; i < walls.length; i++)
|
|
229
|
+
gaps.push((walls[i] ?? 0) - (walls[i - 1] ?? 0));
|
|
230
|
+
gaps.sort((x, y) => x - y);
|
|
231
|
+
const period = gaps.length >= 2 ? `~${(pct(gaps, 50) / 1000).toFixed(1)}s` : '';
|
|
232
|
+
out(`| ${ep.replace(/^https:\/\//, '')} | ${e.hits} | ${[...e.methods].join(',')} | ${[...e.kinds].join(',')} | ${e.bytes} | ${period} |`);
|
|
233
|
+
}
|
|
234
|
+
out();
|
|
235
|
+
if (main_) {
|
|
236
|
+
const kind = streamKind(main_);
|
|
237
|
+
out(`## Main generation request`);
|
|
238
|
+
out();
|
|
239
|
+
out(`**Verdict: generation streams via \`${kind}\` at \`${main_.method} ${normalizeEndpoint(main_.url)}\`** (status ${main_.status}, mime ${main_.mimeType})`);
|
|
240
|
+
out();
|
|
241
|
+
const t0 = main_.sentMono ?? 0;
|
|
242
|
+
out(`- request sent: t0${iterStart !== null && monoToWall(t0) !== null ? ` (+${((monoToWall(t0) - iterStart) / 1000).toFixed(1)}s after iterate-start)` : ''}`);
|
|
243
|
+
if (main_.responseMono !== null)
|
|
244
|
+
out(`- response headers: t0+${ms((main_.responseMono - t0) * 1000)}`);
|
|
245
|
+
if (main_.finishedMono !== null)
|
|
246
|
+
out(`- loading finished: t0+${((main_.finishedMono - t0)).toFixed(1)}s${main_.failed ? ` (FAILED: ${main_.failed})` : ''}`);
|
|
247
|
+
out(`- chunks: ${main_.chunks.length} | total encoded bytes: ${main_.chunks.reduce((a, c) => a + c.encodedBytes, 0)}`);
|
|
248
|
+
out();
|
|
249
|
+
const gaps = [];
|
|
250
|
+
for (let i = 1; i < main_.chunks.length; i++) {
|
|
251
|
+
gaps.push(((main_.chunks[i]?.mono ?? 0) - (main_.chunks[i - 1]?.mono ?? 0)) * 1000);
|
|
252
|
+
}
|
|
253
|
+
if (gaps.length) {
|
|
254
|
+
const sorted = [...gaps].sort((a, b) => a - b);
|
|
255
|
+
const mean = gaps.reduce((a, b) => a + b, 0) / gaps.length;
|
|
256
|
+
out(`### Inter-chunk gaps (candidate STALLED thresholds)`);
|
|
257
|
+
out();
|
|
258
|
+
out(`max ${ms(sorted[sorted.length - 1] ?? 0)} | p95 ${ms(pct(sorted, 95))} | p50 ${ms(pct(sorted, 50))} | mean ${ms(mean)}`);
|
|
259
|
+
out();
|
|
260
|
+
out(`→ a STALLED detector needs its no-chunk timeout comfortably above max (e.g. ${Math.ceil(((sorted[sorted.length - 1] ?? 0) * 3) / 1000)}s).`);
|
|
261
|
+
out();
|
|
262
|
+
}
|
|
263
|
+
const lastChunkWall = monoToWall(main_.chunks[main_.chunks.length - 1]?.mono ?? null);
|
|
264
|
+
const finishedWall = monoToWall(main_.finishedMono);
|
|
265
|
+
out(`### Network-quiet vs DOM-stable`);
|
|
266
|
+
out();
|
|
267
|
+
if (lastChunkWall !== null)
|
|
268
|
+
out(`- last chunk of main request: +${((lastChunkWall - firstTs) / 1000).toFixed(1)}s`);
|
|
269
|
+
if (finishedWall !== null)
|
|
270
|
+
out(`- main request finished: +${((finishedWall - firstTs) / 1000).toFixed(1)}s`);
|
|
271
|
+
let lastTurnChange = null;
|
|
272
|
+
let lastIframeChange = null;
|
|
273
|
+
let prevTurns = null;
|
|
274
|
+
let prevIframe = null;
|
|
275
|
+
for (const d of domSamples) {
|
|
276
|
+
if (d.sample.chatTurnCount !== prevTurns) {
|
|
277
|
+
if (prevTurns !== null)
|
|
278
|
+
lastTurnChange = d.ts;
|
|
279
|
+
prevTurns = d.sample.chatTurnCount;
|
|
280
|
+
}
|
|
281
|
+
if (d.sample.iframeSrc !== prevIframe) {
|
|
282
|
+
if (prevIframe !== null)
|
|
283
|
+
lastIframeChange = d.ts;
|
|
284
|
+
prevIframe = d.sample.iframeSrc;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (lastTurnChange !== null)
|
|
288
|
+
out(`- last chatTurnCount change (dom): +${((lastTurnChange - firstTs) / 1000).toFixed(1)}s`);
|
|
289
|
+
if (lastIframeChange !== null)
|
|
290
|
+
out(`- last iframeSrc change (dom): +${((lastIframeChange - firstTs) / 1000).toFixed(1)}s`);
|
|
291
|
+
const stopSeen = domSamples.filter((d) => d.sample.stopProbe).length;
|
|
292
|
+
out(`- dom-samples with a visible stop/cancel button: ${stopSeen}/${domSamples.length}`);
|
|
293
|
+
const probes = domSamples.map((d) => d.sample.stopProbe).filter(Boolean);
|
|
294
|
+
if (probes.length)
|
|
295
|
+
out(`- stop-button probe: \`${JSON.stringify(probes[0]).slice(0, 300)}\``);
|
|
296
|
+
out();
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
out(`## Main generation request`);
|
|
300
|
+
out();
|
|
301
|
+
out(`(none identified — expected for idle/quota traces)`);
|
|
302
|
+
out();
|
|
303
|
+
}
|
|
304
|
+
const byRpc = (name) => [...reqs.values()]
|
|
305
|
+
.filter((r) => r.url.includes(`OmeletteService/${name}`))
|
|
306
|
+
.sort((a, b) => (a.sentMono ?? 0) - (b.sentMono ?? 0));
|
|
307
|
+
const renews = byRpc('RenewTurn');
|
|
308
|
+
const releases = byRpc('ReleaseTurn');
|
|
309
|
+
const chats = byRpc('Chat');
|
|
310
|
+
if (renews.length || releases.length || chats.length) {
|
|
311
|
+
out(`## Turn lifecycle (OmeletteService)`);
|
|
312
|
+
out();
|
|
313
|
+
if (chats.length) {
|
|
314
|
+
out(`Chat segments: ${chats.length}`);
|
|
315
|
+
for (const c of chats.slice(0, 40)) {
|
|
316
|
+
const w = monoToWall(c.sentMono);
|
|
317
|
+
const dur = c.responseMono !== null && c.finishedMono !== null ? c.finishedMono - c.responseMono : 0;
|
|
318
|
+
out(`- +${w !== null ? ((w - firstTs) / 1000).toFixed(1) : '?'}s dur=${dur.toFixed(1)}s chunks=${c.chunks.length} bytes=${c.chunks.reduce((a, ch) => a + ch.encodedBytes, 0)}${c.failed ? ` FAILED: ${c.failed}` : ''}`);
|
|
319
|
+
}
|
|
320
|
+
out();
|
|
321
|
+
}
|
|
322
|
+
if (renews.length >= 2) {
|
|
323
|
+
const walls = renews.map((r) => monoToWall(r.sentMono)).filter((w) => w !== null);
|
|
324
|
+
const gaps = [];
|
|
325
|
+
for (let i = 1; i < walls.length; i++)
|
|
326
|
+
gaps.push((walls[i] ?? 0) - (walls[i - 1] ?? 0));
|
|
327
|
+
gaps.sort((a, b) => a - b);
|
|
328
|
+
const firstW = walls[0] ?? 0;
|
|
329
|
+
const lastW = walls[walls.length - 1] ?? 0;
|
|
330
|
+
out(`RenewTurn: ${renews.length} calls, median gap ${(pct(gaps, 50) / 1000).toFixed(1)}s, first +${((firstW - firstTs) / 1000).toFixed(1)}s, last +${((lastW - firstTs) / 1000).toFixed(1)}s`);
|
|
331
|
+
}
|
|
332
|
+
for (const rel of releases) {
|
|
333
|
+
const w = monoToWall(rel.sentMono);
|
|
334
|
+
out(`ReleaseTurn: +${w !== null ? ((w - firstTs) / 1000).toFixed(1) : '?'}s ← discrete FINISHED candidate`);
|
|
335
|
+
}
|
|
336
|
+
const iterDone = markers.find((mk) => mk.name === 'iterate-done')?.ts ?? null;
|
|
337
|
+
if (releases.length && iterDone !== null) {
|
|
338
|
+
const w = monoToWall(releases[releases.length - 1]?.sentMono ?? null);
|
|
339
|
+
if (w !== null)
|
|
340
|
+
out(`→ ReleaseTurn led the controller's HTML-stability verdict (iterate-done) by ${((iterDone - w) / 1000).toFixed(1)}s`);
|
|
341
|
+
}
|
|
342
|
+
out();
|
|
343
|
+
}
|
|
344
|
+
if (sockets.size) {
|
|
345
|
+
out(`## WebSockets`);
|
|
346
|
+
out();
|
|
347
|
+
for (const s of sockets.values()) {
|
|
348
|
+
out(`- ${s.url.slice(0, 100)} — frames sent ${s.framesSent} / recv ${s.framesReceived}, recv bytes ${s.bytesReceived}${s.closed ? ', closed' : ''}`);
|
|
349
|
+
}
|
|
350
|
+
out();
|
|
351
|
+
}
|
|
352
|
+
const failed = [...reqs.values()].filter((r) => r.failed);
|
|
353
|
+
if (failed.length) {
|
|
354
|
+
out(`## Failed requests`);
|
|
355
|
+
out();
|
|
356
|
+
for (const r of failed)
|
|
357
|
+
out(`- ${r.method} ${normalizeEndpoint(r.url)} — ${r.failed}`);
|
|
358
|
+
out();
|
|
359
|
+
}
|
|
360
|
+
const md = lines.join('\n');
|
|
361
|
+
const outPath = path.join(dir, 'summary.md');
|
|
362
|
+
fs.writeFileSync(outPath, md);
|
|
363
|
+
console.log(md);
|
|
364
|
+
console.log(`\n(written to ${outPath})`);
|
|
365
|
+
}
|
|
366
|
+
main();
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
#!/usr/bin/env -S node --import tsx
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { DesignerController } from "../designer-controller.js";
|
|
5
|
+
import { CdpTraceRecorder } from "../cdp-trace.js";
|
|
6
|
+
import { artifactsRoot } from "../artifact-store.js";
|
|
7
|
+
import { getSession } from "../session-store.js";
|
|
8
|
+
const USAGE = `Usage:
|
|
9
|
+
trace-spike.ts quota [--seconds 60] capture quota banner + short idle trace
|
|
10
|
+
trace-spike.ts idle [--minutes 3] baseline noise trace
|
|
11
|
+
trace-spike.ts success "<prompt>" [--key K] [--name N] [--fidelity highfi|wireframe] [--decisive] [--sample-ms 1500]
|
|
12
|
+
trace-spike.ts noop ["<prompt>"] [--key K] chat-only prompt (expects no file change)
|
|
13
|
+
trace-spike.ts watch [--key K] record until Ctrl-C (opportunistic capture)
|
|
14
|
+
|
|
15
|
+
Traces land in artifacts/trace/<scenario>-<ts>/{trace.jsonl,manifest.json}.`;
|
|
16
|
+
function parseArgv(argv) {
|
|
17
|
+
const positional = [];
|
|
18
|
+
const flags = {};
|
|
19
|
+
for (let i = 0; i < argv.length; i++) {
|
|
20
|
+
const a = argv[i];
|
|
21
|
+
if (!a)
|
|
22
|
+
continue;
|
|
23
|
+
if (a.startsWith('--')) {
|
|
24
|
+
const name = a.slice(2);
|
|
25
|
+
const next = argv[i + 1];
|
|
26
|
+
if (next !== undefined && !next.startsWith('--')) {
|
|
27
|
+
flags[name] = next;
|
|
28
|
+
i++;
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
flags[name] = true;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
positional.push(a);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { positional, flags };
|
|
39
|
+
}
|
|
40
|
+
function escapeRegExp(s) {
|
|
41
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
42
|
+
}
|
|
43
|
+
const NOOP_PROMPT = 'Answer in chat only — do not create, modify, or delete any files: briefly describe what the current design does.';
|
|
44
|
+
function buildSampleJs(c) {
|
|
45
|
+
const sel = c.selectors;
|
|
46
|
+
return `(() => {
|
|
47
|
+
const q = (s) => { try { return document.querySelector(s); } catch { return null; } };
|
|
48
|
+
const vis = (el) => { if (!el) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };
|
|
49
|
+
const composer = q(${JSON.stringify(sel.composer.promptTextarea)});
|
|
50
|
+
const send = q(${JSON.stringify(sel.composer.sendButton)});
|
|
51
|
+
const iframe = q(${JSON.stringify(sel.preview.iframeOrContainer)});
|
|
52
|
+
const msgs = q(${JSON.stringify(sel.messages.chatMessagesContainer)});
|
|
53
|
+
let chatTurnCount = 0; let lastTurnRole = null;
|
|
54
|
+
if (msgs) {
|
|
55
|
+
const turns = msgs.querySelectorAll('[data-index]');
|
|
56
|
+
chatTurnCount = turns.length;
|
|
57
|
+
const last = turns[turns.length - 1];
|
|
58
|
+
if (last) {
|
|
59
|
+
const t = (last.innerText || '').trim();
|
|
60
|
+
lastTurnRole = t.startsWith('Claude') ? 'assistant' : t.startsWith('You') ? 'user' : 'unknown';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// selectors.json has composer.stopButton: null — probe generically so the
|
|
64
|
+
// trace doubles as selector discovery for the real stop button.
|
|
65
|
+
let stopProbe = null;
|
|
66
|
+
for (const b of Array.from(document.querySelectorAll('button'))) {
|
|
67
|
+
const label = ((b.getAttribute('aria-label') || '') + ' ' + (b.textContent || '')).trim();
|
|
68
|
+
if (/\\b(stop|cancel)\\b/i.test(label) && vis(b)) {
|
|
69
|
+
stopProbe = {
|
|
70
|
+
label: label.slice(0, 80),
|
|
71
|
+
testid: b.getAttribute('data-testid'),
|
|
72
|
+
outerHTML: b.outerHTML.slice(0, 400)
|
|
73
|
+
};
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
url: location.href,
|
|
79
|
+
iframeSrc: iframe && iframe.src ? iframe.src : null,
|
|
80
|
+
composerVisible: vis(composer),
|
|
81
|
+
sendVisible: vis(send),
|
|
82
|
+
sendDisabled: send ? (send.disabled === true || send.getAttribute('aria-disabled') === 'true') : null,
|
|
83
|
+
chatTurnCount,
|
|
84
|
+
lastTurnRole,
|
|
85
|
+
stopProbe
|
|
86
|
+
};
|
|
87
|
+
})()`;
|
|
88
|
+
}
|
|
89
|
+
const QUOTA_BANNER_JS = `(() => {
|
|
90
|
+
// Find the smallest element whose text mentions a percentage AND
|
|
91
|
+
// weekly-limit language — that's the usage banner.
|
|
92
|
+
const all = Array.from(document.querySelectorAll('div, section, aside'));
|
|
93
|
+
let best = null;
|
|
94
|
+
for (const el of all) {
|
|
95
|
+
const t = (el.innerText || '').trim();
|
|
96
|
+
if (!t || t.length > 600) continue;
|
|
97
|
+
if (!/\\d+\\s*%/.test(t)) continue;
|
|
98
|
+
if (!/week|usage|limit|resets/i.test(t)) continue;
|
|
99
|
+
if (!best || t.length < (best.innerText || '').trim().length) best = el;
|
|
100
|
+
}
|
|
101
|
+
if (!best) return { found: false, text: null, outerHTML: null };
|
|
102
|
+
return { found: true, text: (best.innerText || '').trim(), outerHTML: best.outerHTML.slice(0, 8000) };
|
|
103
|
+
})()`;
|
|
104
|
+
function sleep(ms) {
|
|
105
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
106
|
+
}
|
|
107
|
+
async function main() {
|
|
108
|
+
const { positional, flags } = parseArgv(process.argv.slice(2));
|
|
109
|
+
const scenario = positional[0] || '';
|
|
110
|
+
if (!['quota', 'idle', 'success', 'noop', 'watch'].includes(scenario)) {
|
|
111
|
+
console.log(USAGE);
|
|
112
|
+
process.exit(scenario ? 1 : 0);
|
|
113
|
+
}
|
|
114
|
+
const key = String(flags.key || 'trace-spike');
|
|
115
|
+
const sampleMs = Number(flags['sample-ms'] || (scenario === 'idle' || scenario === 'watch' ? 5000 : 1500));
|
|
116
|
+
const controller = new DesignerController({ key });
|
|
117
|
+
const ready = await controller.ensureReady();
|
|
118
|
+
console.log(`ready: ${ready.url}`);
|
|
119
|
+
let prompt = null;
|
|
120
|
+
if (scenario === 'success' || scenario === 'noop') {
|
|
121
|
+
prompt = positional[1] || (scenario === 'noop' ? NOOP_PROMPT : null);
|
|
122
|
+
if (!prompt) {
|
|
123
|
+
console.error('success requires a prompt argument');
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
const stored = getSession(key);
|
|
127
|
+
if (stored?.designUrl) {
|
|
128
|
+
await controller.resumeSession();
|
|
129
|
+
}
|
|
130
|
+
else if (scenario === 'noop') {
|
|
131
|
+
console.error(`No stored session for key=${key} — run a success scenario first so noop has a design to ask about.`);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
const name = String(flags.name || 'aurora-trace');
|
|
136
|
+
const fidelity = flags.fidelity === 'wireframe' ? 'wireframe' : 'highfi';
|
|
137
|
+
const created = await controller.createSession(name, fidelity);
|
|
138
|
+
console.log(`created session: ${created.url}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
142
|
+
const outDir = path.join(artifactsRoot(), 'trace', `${scenario}-${stamp}`);
|
|
143
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
144
|
+
const targetUrlFlag = typeof flags['target-url'] === 'string' ? String(flags['target-url']) : null;
|
|
145
|
+
const recorder = await CdpTraceRecorder.attach({
|
|
146
|
+
outFile: path.join(outDir, 'trace.jsonl'),
|
|
147
|
+
preferUrlPrefix: getSession(key)?.designUrl?.split('?')[0] || null,
|
|
148
|
+
...(targetUrlFlag ? { urlPattern: new RegExp('^' + escapeRegExp(targetUrlFlag)) } : {})
|
|
149
|
+
});
|
|
150
|
+
await recorder.start();
|
|
151
|
+
console.log(`recording → ${outDir}`);
|
|
152
|
+
const manifest = {
|
|
153
|
+
scenario,
|
|
154
|
+
key,
|
|
155
|
+
prompt,
|
|
156
|
+
startedAt: new Date().toISOString(),
|
|
157
|
+
endedAt: null,
|
|
158
|
+
aborted: false,
|
|
159
|
+
node: process.version,
|
|
160
|
+
cdp: recorder.targetInfo(),
|
|
161
|
+
iterate: null,
|
|
162
|
+
quota: null,
|
|
163
|
+
summary: null
|
|
164
|
+
};
|
|
165
|
+
const sampleJs = buildSampleJs(controller);
|
|
166
|
+
let samplerInFlight = false;
|
|
167
|
+
const sampler = setInterval(() => {
|
|
168
|
+
if (samplerInFlight)
|
|
169
|
+
return;
|
|
170
|
+
samplerInFlight = true;
|
|
171
|
+
controller.browser
|
|
172
|
+
.evalValue(sampleJs)
|
|
173
|
+
.then((sample) => recorder.record({ ts: Date.now(), kind: 'dom-sample', sample }))
|
|
174
|
+
.catch(() => null)
|
|
175
|
+
.finally(() => {
|
|
176
|
+
samplerInFlight = false;
|
|
177
|
+
});
|
|
178
|
+
}, sampleMs);
|
|
179
|
+
let finished = false;
|
|
180
|
+
const finalize = async (aborted) => {
|
|
181
|
+
if (finished)
|
|
182
|
+
return;
|
|
183
|
+
finished = true;
|
|
184
|
+
clearInterval(sampler);
|
|
185
|
+
manifest.aborted = aborted;
|
|
186
|
+
manifest.endedAt = new Date().toISOString();
|
|
187
|
+
manifest.summary = await recorder.stop();
|
|
188
|
+
fs.writeFileSync(path.join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
189
|
+
console.log(`\ntrace: ${path.join(outDir, 'trace.jsonl')}`);
|
|
190
|
+
console.log(`manifest: ${path.join(outDir, 'manifest.json')}`);
|
|
191
|
+
console.log(`events: ${manifest.summary.total} | bodies: ${manifest.summary.bodyCaptures} | reconnects: ${manifest.summary.reconnects}`);
|
|
192
|
+
};
|
|
193
|
+
process.on('SIGINT', () => {
|
|
194
|
+
void finalize(true).then(() => process.exit(130));
|
|
195
|
+
});
|
|
196
|
+
try {
|
|
197
|
+
if (scenario === 'quota') {
|
|
198
|
+
const screenshotPath = path.join(outDir, 'quota-banner.png');
|
|
199
|
+
await controller.browser.screenshot(screenshotPath, { full: true }).catch((e) => {
|
|
200
|
+
console.warn(`screenshot failed: ${e.message}`);
|
|
201
|
+
return '';
|
|
202
|
+
});
|
|
203
|
+
const banner = await controller.browser
|
|
204
|
+
.evalValue(QUOTA_BANNER_JS)
|
|
205
|
+
.catch(() => ({ found: false, text: null, outerHTML: null }));
|
|
206
|
+
let bannerHtmlPath = null;
|
|
207
|
+
if (banner.found && banner.outerHTML) {
|
|
208
|
+
bannerHtmlPath = path.join(outDir, 'quota-banner.html');
|
|
209
|
+
fs.writeFileSync(bannerHtmlPath, banner.outerHTML);
|
|
210
|
+
}
|
|
211
|
+
manifest.quota = {
|
|
212
|
+
bannerText: banner.text,
|
|
213
|
+
bannerHtmlPath,
|
|
214
|
+
screenshotPath: fs.existsSync(screenshotPath) ? screenshotPath : null
|
|
215
|
+
};
|
|
216
|
+
console.log(banner.found ? `banner: ${banner.text}` : 'banner: NOT FOUND (see screenshot)');
|
|
217
|
+
const seconds = Number(flags.seconds || 60);
|
|
218
|
+
recorder.marker('quota-idle-start', { seconds });
|
|
219
|
+
await sleep(seconds * 1000);
|
|
220
|
+
recorder.marker('quota-idle-end');
|
|
221
|
+
}
|
|
222
|
+
else if (scenario === 'idle') {
|
|
223
|
+
const minutes = Number(flags.minutes || 3);
|
|
224
|
+
recorder.marker('idle-start', { minutes });
|
|
225
|
+
await sleep(minutes * 60_000);
|
|
226
|
+
recorder.marker('idle-end');
|
|
227
|
+
}
|
|
228
|
+
else if (scenario === 'success' || scenario === 'noop') {
|
|
229
|
+
recorder.marker('iterate-start', { prompt, decisive: flags.decisive === true });
|
|
230
|
+
const result = await controller.iterate(prompt, { decisive: flags.decisive === true });
|
|
231
|
+
recorder.marker('iterate-done', {
|
|
232
|
+
failureMode: result.done.failureMode,
|
|
233
|
+
elapsedMs: result.done.elapsedMs,
|
|
234
|
+
changed: result.changed,
|
|
235
|
+
newFiles: result.newFiles
|
|
236
|
+
});
|
|
237
|
+
manifest.iterate = {
|
|
238
|
+
failureMode: result.done.failureMode,
|
|
239
|
+
ok: result.done.ok,
|
|
240
|
+
elapsedMs: result.done.elapsedMs,
|
|
241
|
+
changed: result.changed,
|
|
242
|
+
newFiles: result.newFiles,
|
|
243
|
+
removedFiles: result.removedFiles,
|
|
244
|
+
activeFile: result.activeFile,
|
|
245
|
+
htmlBytes: result.htmlBytes,
|
|
246
|
+
chatReplyBytes: result.chatReply ? result.chatReply.length : 0
|
|
247
|
+
};
|
|
248
|
+
console.log(`iterate: ok=${result.done.ok} failureMode=${result.done.failureMode} elapsed=${Math.round(result.done.elapsedMs / 1000)}s newFiles=[${result.newFiles.join(', ')}]`);
|
|
249
|
+
const banner = await controller.browser
|
|
250
|
+
.evalValue(QUOTA_BANNER_JS)
|
|
251
|
+
.catch(() => ({ found: false, text: null, outerHTML: null }));
|
|
252
|
+
if (banner.found && banner.outerHTML) {
|
|
253
|
+
const bannerHtmlPath = path.join(outDir, 'quota-banner.html');
|
|
254
|
+
fs.writeFileSync(bannerHtmlPath, banner.outerHTML);
|
|
255
|
+
manifest.quota = { bannerText: banner.text, bannerHtmlPath, screenshotPath: null };
|
|
256
|
+
console.log(`quota banner captured: ${banner.text}`);
|
|
257
|
+
}
|
|
258
|
+
await sleep(5000);
|
|
259
|
+
}
|
|
260
|
+
else if (scenario === 'watch') {
|
|
261
|
+
recorder.marker('watch-start');
|
|
262
|
+
console.log('watching — Ctrl-C to stop');
|
|
263
|
+
await new Promise(() => {
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
finally {
|
|
268
|
+
await finalize(false);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
main().catch(async (e) => {
|
|
272
|
+
console.error(e.message);
|
|
273
|
+
process.exit(1);
|
|
274
|
+
});
|